Compare commits
52
Commits
v1.3.0
...
3735c5bfa7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3735c5bfa7 | ||
|
|
f1fda9bdee | ||
|
|
b2f63601c0 | ||
|
|
35e80c6d1c | ||
|
|
4a2b1f3795 | ||
|
|
14c47aa2a0 | ||
|
|
b5c52004c8 | ||
|
|
9f3f4b6f62 | ||
|
|
0e35735a2a | ||
|
|
20c7620393 | ||
|
|
173d82a238 | ||
|
|
914eaed71c | ||
|
|
289d308b57 | ||
|
|
8b9f636cce | ||
|
|
9c8a87e90d | ||
|
|
82f60ed428 | ||
|
|
569bf733e9 | ||
|
|
cb11ffdd2d | ||
|
|
a33a3a71e4 | ||
|
|
63751a79ad | ||
|
|
86b94a9e64 | ||
|
|
77fe78d874 | ||
|
|
5d4bb53b8a | ||
|
|
99069ba5fe | ||
|
|
37bd657299 | ||
|
|
f48daa71c8 | ||
|
|
8bc0749b42 | ||
|
|
1c67dd20d7 | ||
|
|
1100580c2c | ||
|
|
31adc34a19 | ||
|
|
b4ca795003 | ||
|
|
8556221b08 | ||
|
|
dadd9ec164 | ||
|
|
c171047adf | ||
|
|
afbe9db409 | ||
|
|
49794b4973 | ||
|
|
1f62653118 | ||
|
|
8ae09f238b | ||
|
|
644fdefa66 | ||
|
|
14cf212a60 | ||
|
|
db9a6f1875 | ||
|
|
ce8525bee8 | ||
|
|
01b9e9f1d0 | ||
|
|
33af5408fd | ||
|
|
67d99dd6c0 | ||
|
|
7fc262f9c3 | ||
|
|
27cd6b3703 | ||
|
|
ffce798754 | ||
|
|
acdb929a99 | ||
|
|
95d69a5512 | ||
|
|
3a0007118c | ||
|
|
aa194be09a |
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: make-widget
|
||||
description: Scaffold a new widget type for the espresso_frame server (the ~13-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").
|
||||
---
|
||||
|
||||
Adding a widget type is a very consistent, repeated pattern in this
|
||||
codebase (`photos`/`calendar`/`whiteboard`/`tasks`/`static`) -- see
|
||||
`docs/widgets.md` for the system's actual data model/rendering/dialog
|
||||
architecture (read that first if you haven't). This skill is the
|
||||
checklist of every file that pattern touches, so nothing gets silently
|
||||
dropped (the static-image widget shipped without a `docs/widgets.md`
|
||||
update; this skill exists so that doesn't keep happening).
|
||||
|
||||
**For a complete worked example touching every item below**, `git show
|
||||
35e80c6 --stat` (the static-image widget's commit) in this repo.
|
||||
|
||||
All paths below are relative to `server/`.
|
||||
|
||||
## Before writing any code: shape decisions
|
||||
|
||||
Answer these first -- they determine which existing widget type is the
|
||||
closest template to copy from:
|
||||
|
||||
- **Live upstream to poll, or self-contained/user-authored?** Calendar/
|
||||
whiteboard/tasks fetch from somewhere external on a throttle
|
||||
(`checked_at` + `get_or_refresh_*` in `routers/common.py`). Photos'
|
||||
queue and the static image widget don't -- their content is set once
|
||||
via the dialog (an upload, a pick) and just sits there until changed.
|
||||
A text widget is almost certainly this second shape.
|
||||
- **Single source, or multi-source merge?** Calendar/tasks merge
|
||||
several *people's* data (`FrameCalendar`/`FrameTaskList`, owner-adds/
|
||||
anyone-mutes). Only reach for that shape if the new type genuinely
|
||||
needs to combine several linked users' own data -- most new widget
|
||||
types are single-owner/single-config and don't need it.
|
||||
- **Any button actions**, or is `ACTIONS = {}` correct (nothing to
|
||||
advance/back/force)? Tasks and static image are both `{}`.
|
||||
- **Minimum sane grid footprint** -- how small can this widget be
|
||||
before its content is illegible/pointless?
|
||||
|
||||
Pick your template accordingly:
|
||||
|
||||
| New widget shape | Copy from |
|
||||
|---|---|
|
||||
| Self-contained, user-authored/uploaded, no fetch, no actions | `app/widgets/static_image.py` |
|
||||
| Single external source, throttled fetch, one "check_now" action | `app/widgets/whiteboard.py` |
|
||||
| Multi-source merge, owner-adds/anyone-mutes permissions | `app/widgets/tasks.py` (simpler) or `calendar.py` (also has size-tier rendering) |
|
||||
| Stateful queue/rotation with advance/back | `app/widgets/photos.py` |
|
||||
|
||||
## The checklist
|
||||
|
||||
1. **`app/models.py`** -- new `<Type>WidgetConfig` table, `widget_id`
|
||||
`Mapped[int]` primary key `ForeignKey("widgets.id", ondelete="CASCADE")`,
|
||||
plus whatever fields the type needs. Add it to the `WIDGET_CONFIG_MODELS`
|
||||
dict at the bottom of the file.
|
||||
2. **`app/grid.py`** -- add an entry to `MIN_FOOTPRINT`.
|
||||
3. **`app/widgets/<type>.py`** -- new module exposing:
|
||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image`
|
||||
-- RGB, exactly `target_w x target_h`, **never raises** for a
|
||||
foreseeable failure (missing config, fetch error) -- fall back to
|
||||
`._shared.placeholder_image(target_w, target_h, [lines])` instead.
|
||||
- `ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]`
|
||||
- `ACTION_LABELS: dict[str, str]`
|
||||
4. **`app/widgets/__init__.py`** -- import the new module, add it to
|
||||
`WIDGET_TYPES`.
|
||||
5. **`app/migration.py`** -- new `_migration_N`. A brand-new table with
|
||||
no legacy data to carry forward is just
|
||||
`Base.metadata.create_all(bind=conn)` (see `_migration_20`) -- it
|
||||
only creates the one new table, existing ones are untouched. Register
|
||||
`(N, _migration_N)` as the new last entry in `MIGRATIONS`.
|
||||
6. **`app/routers/api_widgets.py`**:
|
||||
- Add any new `Form(...)` fields to `api_widget_config_save`'s
|
||||
signature, and a new `elif widget.widget_type == "<type>":` branch
|
||||
inside its body. Reuse an existing field name (e.g. `display_mode`)
|
||||
where the semantics genuinely match -- fields are namespaced by
|
||||
which widget type actually reads them, not by name collision, so
|
||||
this is safe (see the comment above `display_mode` in that
|
||||
function).
|
||||
- Add type-specific endpoints as needed (upload/source-select/etc.).
|
||||
Use `require_widget_control` for widget-wide settings a dialog Save
|
||||
button changes; use `require_widget_view` (not control) for the
|
||||
owner-adds/anyone-mutes multi-source pattern, matching
|
||||
`api_widget_calendar_select`/`api_widget_task_list_select`.
|
||||
- Add a `GET .../preview/<type>` endpoint mirroring the others --
|
||||
`render_preview_png` (the full palette/dither pipeline) for
|
||||
image-like content, or a dedicated `render_<type>_preview_png` in a
|
||||
rendering module for text/graphics content (see
|
||||
`calendar_render.render_tasks_preview_png`).
|
||||
7. **`app/routers/frame_pages.py`** -- import the new config model, add
|
||||
an `if widget.widget_type == "<type>":` branch in `widget_dialog()`
|
||||
returning `templates.TemplateResponse("_widget_dialog_<type>.html", {...})`.
|
||||
8. **`app/templates/_widget_dialog_<type>.html`** -- the dialog
|
||||
fragment: settings card(s) + `<img class="preview-img"
|
||||
id="<type>-preview">` + a refresh button, using the existing
|
||||
`.card`/`.card-title`/`.sub`/`.checkbox-row` classes from
|
||||
`theme.css` rather than inventing new ones.
|
||||
9. **`app/static/widget_dialog_<type>.js`** -- an `init<Type>Dialog()`/
|
||||
`close<Type>Dialog()` pair (not a page-load script -- see any
|
||||
existing `widget_dialog_*.js`'s header comment for the contract).
|
||||
`window.fetch` already CSRF-injects (see `common.js`), so POSTs don't
|
||||
need a manual header. **Never build user-supplied text into the DOM
|
||||
via `innerHTML` string interpolation** -- use `textContent`/
|
||||
`createElement` (a filename, a task summary, anything another linked
|
||||
user's account could have set is a stored-XSS vector otherwise).
|
||||
10. **`app/templates/frame_layout.html`** -- add
|
||||
`<script src="/static/widget_dialog_<type>.js"></script>` next to
|
||||
the other widget dialog scripts.
|
||||
11. **`app/static/frame_layout.js`** -- add the type to both
|
||||
`DIALOG_INIT` and `DIALOG_CLOSE`.
|
||||
12. **`app/static/common.js`** -- add a `WIDGET_LABELS` entry (the
|
||||
human label shown in the add-widget button, the widget box, and the
|
||||
button-assignment picker in `frame_config.js`).
|
||||
13. **`docs/widgets.md`** -- update every place that enumerates widget
|
||||
types: the intro sentence, the `widget_type` column-value list, the
|
||||
`MIN_FOOTPRINT` prose line, the `app/widgets/` module list. This is
|
||||
the project's own "start here" doc per `CLAUDE.md` -- don't ship a
|
||||
widget without it staying accurate.
|
||||
|
||||
## Tests (`server/tests/`)
|
||||
|
||||
- `test_widgets_<type>.py` -- unit-level `render()` tests, no HTTP:
|
||||
correct size/mode with no config, with config, at
|
||||
`grid.MIN_FOOTPRINT`'s smallest box, `ACTIONS == {}` if passive.
|
||||
Mirror `test_widgets_static.py` (self-contained) or
|
||||
`test_widgets_tasks.py` (fetch-backed, monkeypatches the fetch call).
|
||||
- `test_widget_config_and_queue_endpoints.py` -- add a
|
||||
`_add_<type>_widget` helper plus an HTTP-level
|
||||
`test_config_save_updates_a_<type>_widget` test, and tests for any new
|
||||
endpoints (upload/select/preview: 400 before configured, 200 after,
|
||||
400 for the wrong widget type via `_require_widget_type`).
|
||||
- `test_migrations.py` -- add the new table to
|
||||
`test_expected_columns_exist_on_current_schema`'s spot-check
|
||||
(`inspector.get_table_names()` or `inspector.get_columns(...)`).
|
||||
- Owner-adds/anyone-mutes multi-source table? Add cases to
|
||||
`test_permission_boundaries.py` following its existing
|
||||
calendar-select/task-list-select pattern (owner can add, non-owner
|
||||
can mute but not add, 404 for an unrelated widget id, 400 for the
|
||||
wrong widget type).
|
||||
- Any pure-logic helper module (decoding, parsing -- like
|
||||
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
|
||||
DB, just the function.
|
||||
|
||||
Run the full suite before calling it done:
|
||||
|
||||
```bash
|
||||
cd server && .venv/bin/pytest -q
|
||||
```
|
||||
|
||||
Comfortably under 30s for the whole suite (~200+ tests) -- there's no
|
||||
reason to skip this or run a subset.
|
||||
|
||||
## Browser verification (required, not optional)
|
||||
|
||||
Per `CLAUDE.md`, reading the JS is not enough -- this project has
|
||||
shipped UI bugs (mobile viewport CSS collapse, a dialog's status message
|
||||
landing behind its own backdrop, a JSON/form body mismatch) that only
|
||||
showed up live. Use the `run-server` skill:
|
||||
|
||||
- Clear existing widgets and add one of the new type
|
||||
(`POST /api/frames/1/widgets`), resize it (`PATCH`), open its dialog
|
||||
(`click .widget-box-settings`), exercise its actual settings/upload
|
||||
flow through the real UI controls (not just a raw `fetch` in `eval` --
|
||||
that only proves the endpoint works, not that the button is wired to
|
||||
it), and check `console-errors` for anything beyond the expected
|
||||
favicon 404.
|
||||
- Check the full composited panel preview
|
||||
(`#frame-preview-thumb` on `/frames/{id}/config`) actually shows the
|
||||
new widget's content -- not just its own dialog's `preview/<type>`
|
||||
image, which only proves the render function works in isolation.
|
||||
- Screenshot **both** desktop (`viewport 1280 900`) and mobile
|
||||
(the driver's default) widths -- the layout genuinely forks at the
|
||||
860px breakpoint in `theme.css`.
|
||||
|
||||
## Commit
|
||||
|
||||
One commit for the whole widget (models + migration + render + router +
|
||||
UI + tests + docs) -- this project's convention is one feature per
|
||||
commit, not split by layer. No `Co-Authored-By: Claude` trailer (see
|
||||
root `CLAUDE.md`).
|
||||
@@ -0,0 +1,2 @@
|
||||
# Generated by setup.sh -- bakes in this host's /tmp paths, not portable.
|
||||
env.sh
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
name: run-server
|
||||
description: Build, run, and drive the espresso_frame FastAPI server (server/) -- start it against a scratch DB, browser-test its UI, run its pytest suite. Use when asked to start the server, take a screenshot of a frame page, click through the web UI, or verify a server/UI change actually works.
|
||||
---
|
||||
|
||||
The espresso_frame server is a FastAPI app (`app.main:app`) with a
|
||||
server-rendered Jinja UI. For agent/automated use it's driven by a
|
||||
Playwright REPL at `.claude/skills/run-server/driver.py`, run under
|
||||
tmux -- `chromium-cli` isn't available in this container, so this
|
||||
driver replaces it (same command vocabulary: nav/wait-for/click/fill/
|
||||
screenshot/eval/console-errors).
|
||||
|
||||
This container ships with **no Python, Node, Docker, or browser, and
|
||||
no sudo**. `setup.sh` bootstraps everything non-root; it's the bulk of
|
||||
what makes this skill non-obvious. Run it once per fresh container.
|
||||
|
||||
Commands below (`setup.sh`, `start-server.sh`, `stop-server.sh`,
|
||||
`env.sh`, `driver.py`) are invoked from the **repo root** via their
|
||||
`.claude/skills/run-server/` path -- the scripts `cd` into `server/`
|
||||
themselves. Anything under `.venv/` (the venv itself, `pytest`,
|
||||
`uvicorn`) lives inside `server/`, so those commands need `server/`
|
||||
prefixed or `cd server` first.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
None to install manually -- `setup.sh` does it all without root, using
|
||||
only `curl`/`apt-get download`/`dpkg-deb -x` (never `apt-get install`,
|
||||
which needs root). It downloads ~450MB total (Python, Chromium, tmux,
|
||||
shared libs) on first run.
|
||||
|
||||
```bash
|
||||
bash .claude/skills/run-server/setup.sh
|
||||
```
|
||||
|
||||
Re-running is safe and fast -- every step checks whether it already
|
||||
happened (venv exists? chromium downloaded? libs extracted? tmux
|
||||
present?) before doing any work.
|
||||
|
||||
This creates:
|
||||
- `.venv/` -- Python 3.12 + `requirements.txt` + `playwright`, via `uv`
|
||||
(a static Rust binary that fetches its own Python -- no compiler
|
||||
needed, install via `curl -LsSf https://astral.sh/uv/install.sh | sh`)
|
||||
- `~/.cache/ms-playwright/` -- Chromium (full `chrome` + headless-shell)
|
||||
- `/tmp/run-server-chromium-deps/` -- Chromium's + tmux's shared libs
|
||||
and fonts, extracted (not installed) from `.deb` files
|
||||
- `.claude/skills/run-server/env.sh` -- the `PATH`/`LD_LIBRARY_PATH`/
|
||||
`FONTCONFIG_PATH`/`RUN_SERVER_CHROME_BIN` exports the driver needs
|
||||
(gitignored -- host-specific `/tmp` paths, regenerated by setup.sh)
|
||||
|
||||
## Run (agent path)
|
||||
|
||||
**1. Start the server** against a scratch DB (never the real
|
||||
deployment's data -- see `CLAUDE.md`):
|
||||
|
||||
```bash
|
||||
bash .claude/skills/run-server/start-server.sh
|
||||
# -> server PID <pid> up on http://127.0.0.1:8420 (log: /tmp/run-server-scratch/server.log)
|
||||
```
|
||||
|
||||
Optional args: `start-server.sh [scratch-dir] [port]` (defaults
|
||||
`/tmp/run-server-scratch` / `8420`).
|
||||
|
||||
**2. Drive it**, wrapped in tmux so you can send one command at a time
|
||||
and read the response before sending the next. `tmux` itself was
|
||||
extracted the same non-root way as Chromium's libs (see Prerequisites)
|
||||
and needs `LD_LIBRARY_PATH` set in *this* shell too, not just inside
|
||||
the pane -- source `env.sh` before the first `tmux` call:
|
||||
|
||||
```bash
|
||||
source .claude/skills/run-server/env.sh
|
||||
tmux new-session -d -s runserver -x 200 -y 50
|
||||
tmux send-keys -t runserver \
|
||||
'source .claude/skills/run-server/env.sh && server/.venv/bin/python .claude/skills/run-server/driver.py' Enter
|
||||
timeout 20 bash -c 'until tmux capture-pane -t runserver -p | grep -q "driver>"; do sleep 0.3; done'
|
||||
|
||||
# Every fresh scratch DB starts with no users -- bootstrap-admin
|
||||
# completes first-run /setup and logs in (see Commands table):
|
||||
tmux send-keys -t runserver 'bootstrap-admin' Enter
|
||||
timeout 15 bash -c 'until tmux capture-pane -t runserver -p | grep -q "bootstrapped admin"; do sleep 0.3; done'
|
||||
|
||||
tmux send-keys -t runserver 'nav /frames/1/config' Enter
|
||||
tmux send-keys -t runserver 'wait-for #frame-preview-thumb' Enter
|
||||
tmux send-keys -t runserver 'screenshot before' Enter
|
||||
tmux capture-pane -t runserver -p
|
||||
```
|
||||
|
||||
Poll for a specific marker between `send-keys` and `capture-pane`
|
||||
(`driver>`, `bootstrapped admin`, `screenshot:`, ...) rather than a
|
||||
fixed `sleep` -- it's faster and fails loudly instead of capturing a
|
||||
half-rendered screen. Give each poll its own `timeout` (~15-20s); don't
|
||||
chain many polling loops inside one shell invocation -- see Gotchas.
|
||||
|
||||
Screenshots land in `/tmp/run-server-shots/` (override:
|
||||
`SCREENSHOT_DIR`). **Actually look at them** -- a blank or error-page
|
||||
screenshot is a failure to launch, not success.
|
||||
|
||||
**Test every UI change at both a desktop and a mobile viewport.** The
|
||||
driver defaults to a desktop size (1280x900); switch with `viewport`.
|
||||
The app's mobile breakpoint is 860px (`theme.css`) -- below that the
|
||||
sidebar goes off-canvas behind a hamburger (`.mobile-bar`). A page that
|
||||
looks right at 1280px can still overflow, overlap the mobile bar, or
|
||||
mis-center a `<dialog>` at phone widths -- screenshot both:
|
||||
|
||||
```bash
|
||||
tmux send-keys -t runserver 'viewport 1280 900' Enter # desktop (also the default)
|
||||
tmux send-keys -t runserver 'screenshot desktop-x' Enter
|
||||
tmux send-keys -t runserver 'viewport 390 844' Enter # iPhone-ish mobile width
|
||||
tmux send-keys -t runserver 'screenshot mobile-x' Enter
|
||||
```
|
||||
|
||||
### Driver commands
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `nav <path-or-url>` | navigate (relative paths resolve against `http://127.0.0.1:8420`, override with `RUN_SERVER_BASE_URL`) |
|
||||
| `wait-for <selector>` | wait up to 10s for a selector (plain CSS -- see Gotchas for attribute selectors) |
|
||||
| `click <selector>` | click an element |
|
||||
| `fill <selector> <value>` | fill an input |
|
||||
| `press <key>` | keyboard press (e.g. `Enter`) |
|
||||
| `screenshot [name]` | → `/tmp/run-server-shots/<name>.png` |
|
||||
| `eval <js>` | evaluate JS in the page, prints JSON |
|
||||
| `console-errors` | prints all captured console/page errors as a JSON array |
|
||||
| `viewport [w] [h]` | resize the viewport, default `390 844` -- use `1280 900` for desktop (see Gotchas re: real touch input) |
|
||||
| `is-open <dialog-selector>` | prints `true`/`false` for a `<dialog>` element's `.open` -- use this instead of `wait-for sel[open]` |
|
||||
| `bootstrap-admin [user] [pass]` | completes first-run `/setup` (defaults `admin`/`testpassword123`); links frame #1 and logs in |
|
||||
| `quit` | closes the browser, exits the driver |
|
||||
|
||||
**3. Stop the server** when done:
|
||||
|
||||
```bash
|
||||
bash .claude/skills/run-server/stop-server.sh
|
||||
tmux kill-session -t runserver
|
||||
```
|
||||
|
||||
## Run (human path)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
DATABASE_URL="sqlite:////tmp/dev.db" CONFIG_PATH="/tmp/dev-config.json" \
|
||||
.venv/bin/uvicorn app.main:app --reload --port 8420
|
||||
```
|
||||
|
||||
Open `http://localhost:8420` in a real browser. `start.sh` (the Docker
|
||||
entrypoint) is not this -- it also launches the Node whiteboard
|
||||
render-service sidecar, which needs Node (not installed here) and
|
||||
isn't needed for most UI testing.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd server && .venv/bin/pytest
|
||||
```
|
||||
|
||||
Uses its own tempfile SQLite per run (`tests/conftest.py`) -- no setup
|
||||
needed beyond the venv.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`viewport` only resizes the window -- it does not emulate touch
|
||||
input.** `click` still dispatches a mouse click, not a tap; there's
|
||||
no touch-delay, no `:hover`-stickiness-after-tap, no `hasTouch`
|
||||
context. It catches real bugs (layout overflow, off-canvas sidebar,
|
||||
a `<dialog>` mis-centering at phone widths) but won't catch anything
|
||||
that's specifically a touch-vs-mouse event difference. Good enough
|
||||
for CSS/layout verification; not a substitute for testing on an
|
||||
actual phone if a change touches touch-specific interaction.
|
||||
|
||||
- **`chrome-headless-shell` (Playwright's default headless target)
|
||||
crashes on basic calls in this container**, e.g. `page.set_content()`
|
||||
returns `TargetClosedError`, even after every `ldd`-reported missing
|
||||
library is resolved. The full `chrome` binary (`chromium-*/chrome-linux64/chrome`)
|
||||
+ `--no-sandbox` is stable; `driver.py` and `setup.sh` both use it,
|
||||
not the headless-shell default.
|
||||
|
||||
- **Missing fonts silently break `fill()`, not just rendering.** Before
|
||||
`fontconfig`/`libfontconfig1` were extracted and `fonts.conf` pointed
|
||||
at the extracted font dir, `page.fill()` ran with no error but left
|
||||
inputs empty (`input_value()` returned `""`), and all text rendered
|
||||
invisible in screenshots. It looks like a scripting bug, not a
|
||||
missing-lib problem -- if `fill` silently no-ops, suspect fonts
|
||||
before suspecting the selector or a race.
|
||||
|
||||
- **No `apt-get install` / `playwright install-deps` (no root) and no
|
||||
`apt-get update` into the real `/var/lib/apt/lists` (root-owned).**
|
||||
Worked around by redirecting apt's state dirs to a scratch,
|
||||
user-writable path (`-o Dir::State::Lists=... -o Dir::Cache=...`),
|
||||
which makes plain `update` and `install --download-only --print-uris`
|
||||
work as a non-root user; then `dpkg-deb -x <deb> <root>` (extract,
|
||||
not install) needs no root either. `setup.sh` does this for
|
||||
Chromium's deps *and* for `tmux` itself, which also isn't
|
||||
preinstalled.
|
||||
|
||||
- **`wait-for` with an attribute selector like `#frame-preview-dialog[open]`
|
||||
is unreliable through `tmux send-keys`** -- shell/tmux escaping of
|
||||
`[`/`]` easily mangles it (seen: a real 10s Playwright timeout from a
|
||||
garbled selector, not a fast failure). Use the app-specific `is-open
|
||||
<selector>` command instead of `wait-for sel[open]` to check a
|
||||
`<dialog>`'s open state.
|
||||
|
||||
- **Don't chain many `tmux send-keys` + polling-`timeout` loops inside
|
||||
one shell invocation.** Each poll can legitimately take up to its own
|
||||
timeout (e.g. 15s) if a selector is wrong; five or six chained in one
|
||||
command can add up past this tool's own command timeout even though
|
||||
each individual step is fine. Send one or two commands per shell
|
||||
call and check the pane before continuing.
|
||||
|
||||
- **A crashed `chrome-headless-shell` process can leave a large core
|
||||
dump file** (`server/core`, ~170MB, from the crash described above)
|
||||
if core dumps are enabled. It's not part of the app -- delete it, and
|
||||
use full `chrome` (as `driver.py` does) to avoid triggering it again.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`error while loading shared libraries: libglib-2.0.so.0` (or similar)
|
||||
when launching Chromium directly**: `LD_LIBRARY_PATH` isn't set --
|
||||
source `.claude/skills/run-server/env.sh` first, or run through
|
||||
`driver.py`, which reads `RUN_SERVER_CHROME_BIN` from it.
|
||||
- **`fc-list` prints nothing after extracting fonts**: `fonts.conf`'s
|
||||
`<dir>` entries still point at the real (unpopulated) `/usr/share/fonts`.
|
||||
`setup.sh` patches this with `sed`; if you extracted packages by hand,
|
||||
do the same.
|
||||
- **`E: Could not open lock file ... Permission denied` from `apt-get`**:
|
||||
you're missing the `-o Dir::State::Lists=... -o Dir::Cache=...`
|
||||
overrides -- plain `apt-get update`/`install` always needs root here.
|
||||
- **`tmux: command not found`**: not preinstalled and no sudo; run
|
||||
`setup.sh`, which fetches it the same non-root way as Chromium's libs.
|
||||
- **`tmux: error while loading shared libraries: libutempter.so.0`**:
|
||||
you sourced `env.sh` inside the driver's tmux pane but not in the
|
||||
shell that *invokes* `tmux` itself -- `tmux` was extracted from the
|
||||
same non-root `.deb` set as Chromium and needs `LD_LIBRARY_PATH` too.
|
||||
`source .claude/skills/run-server/env.sh` before the first `tmux`
|
||||
command, not just inside `send-keys`.
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""REPL driver for the espresso_frame server's web UI.
|
||||
|
||||
Playwright-based since chromium-cli isn't available in this container.
|
||||
Reads one command per line from stdin, prints a result line -- built
|
||||
for tmux send-keys/capture-pane use by an agent. Vocabulary mirrors
|
||||
chromium-cli where it overlaps (nav/wait-for/click/fill/screenshot/
|
||||
eval/console-errors).
|
||||
|
||||
Requires setup.sh to have run first (Python venv + Playwright Chromium
|
||||
+ the non-root shared-lib/font extraction). Run via:
|
||||
|
||||
.claude/skills/run-server/env.sh sourced, then
|
||||
server/.venv/bin/python .claude/skills/run-server/driver.py
|
||||
|
||||
See SKILL.md for the full agent-path invocation (tmux wrapping etc).
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
SHOT_DIR = os.environ.get("SCREENSHOT_DIR", "/tmp/run-server-shots")
|
||||
os.makedirs(SHOT_DIR, exist_ok=True)
|
||||
BASE = os.environ.get("RUN_SERVER_BASE_URL", "http://127.0.0.1:8420")
|
||||
|
||||
|
||||
def find_chrome() -> str:
|
||||
override = os.environ.get("RUN_SERVER_CHROME_BIN")
|
||||
if override and os.path.exists(override):
|
||||
return override
|
||||
matches = glob.glob(os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome"))
|
||||
if not matches:
|
||||
sys.exit("chrome binary not found -- run setup.sh first")
|
||||
return matches[0]
|
||||
|
||||
|
||||
pw = sync_playwright().start()
|
||||
# The FULL `chrome` binary, not chrome-headless-shell (Playwright's
|
||||
# default headless target): chrome-headless-shell crashed on basic
|
||||
# calls like set_content() in this container even once every
|
||||
# ldd-reported missing lib was resolved. Full chrome + --no-sandbox is
|
||||
# stable here.
|
||||
browser = pw.chromium.launch(executable_path=find_chrome(), args=["--no-sandbox"])
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||
console_errors: list[str] = []
|
||||
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
|
||||
# Playwright auto-DISMISSES native confirm()/alert() dialogs by default
|
||||
# (returns false) -- several destructive actions in this app (remove
|
||||
# widget, clear all widgets) gate on `confirm()`, so without this a
|
||||
# `click` on one of those buttons would silently no-op. Auto-accept
|
||||
# instead, since a driver testing a "yes, do the destructive thing"
|
||||
# flow needs the confirm to actually go through.
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
|
||||
|
||||
def cmd_nav(arg):
|
||||
url = arg if arg.startswith("http") else BASE + arg
|
||||
page.goto(url)
|
||||
print("nav ->", page.url)
|
||||
|
||||
|
||||
def cmd_wait_for(arg):
|
||||
page.wait_for_selector(arg, timeout=10000)
|
||||
print("found:", arg)
|
||||
|
||||
|
||||
def cmd_click(arg):
|
||||
page.click(arg)
|
||||
print("clicked:", arg)
|
||||
|
||||
|
||||
def cmd_fill(arg):
|
||||
sel, _, value = arg.partition(" ")
|
||||
page.fill(sel, value)
|
||||
print("filled:", sel, "=", value)
|
||||
|
||||
|
||||
def cmd_press(arg):
|
||||
page.keyboard.press(arg)
|
||||
print("pressed:", arg)
|
||||
|
||||
|
||||
def cmd_screenshot(arg):
|
||||
name = arg or f"ss-{len(os.listdir(SHOT_DIR))}"
|
||||
path = os.path.join(SHOT_DIR, name + ".png")
|
||||
page.screenshot(path=path)
|
||||
print("screenshot:", path)
|
||||
|
||||
|
||||
def cmd_eval(arg):
|
||||
try:
|
||||
print(json.dumps(page.evaluate(arg)))
|
||||
except Exception as e:
|
||||
print("ERROR:", e)
|
||||
|
||||
|
||||
def cmd_console_errors(_arg):
|
||||
print(json.dumps(console_errors))
|
||||
|
||||
|
||||
def cmd_viewport(arg):
|
||||
"""Resize the viewport. No args -> 390x844 (iPhone-ish mobile
|
||||
width); the page itself starts at 1280x900 (desktop) on launch, so
|
||||
`viewport 1280 900` gets back to that. The app's mobile breakpoint
|
||||
is 860px (see theme.css) -- anything under that exercises the
|
||||
off-canvas sidebar/mobile-bar layout."""
|
||||
parts = arg.split()
|
||||
width = int(parts[0]) if len(parts) > 0 else 390
|
||||
height = int(parts[1]) if len(parts) > 1 else 844
|
||||
page.set_viewport_size({"width": width, "height": height})
|
||||
print("viewport:", width, "x", height)
|
||||
|
||||
|
||||
def cmd_is_open(arg):
|
||||
"""App-specific: print whether a <dialog> element is open (true/false)."""
|
||||
print(json.dumps(page.eval_on_selector(arg, "el => el.open")))
|
||||
|
||||
|
||||
def cmd_bootstrap_admin(arg):
|
||||
"""App-specific: complete first-run /setup (username/password args,
|
||||
default admin/testpassword123). Every scratch DB starts with no
|
||||
users, and /setup is the only way in -- it also auto-links the
|
||||
pre-existing frame #1 (created by migrations) to the new admin, so
|
||||
/frames/1/... is reachable right after this."""
|
||||
parts = arg.split()
|
||||
username = parts[0] if len(parts) > 0 else "admin"
|
||||
password = parts[1] if len(parts) > 1 else "testpassword123"
|
||||
page.goto(BASE + "/setup")
|
||||
page.fill("input[name=username]", username)
|
||||
page.fill("input[name=password]", password)
|
||||
page.click("button[type=submit]")
|
||||
page.wait_for_load_state("networkidle")
|
||||
print("bootstrapped admin, now at:", page.url)
|
||||
|
||||
|
||||
def cmd_quit(_arg):
|
||||
browser.close()
|
||||
pw.stop()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
COMMANDS = {
|
||||
"nav": cmd_nav,
|
||||
"wait-for": cmd_wait_for,
|
||||
"click": cmd_click,
|
||||
"fill": cmd_fill,
|
||||
"press": cmd_press,
|
||||
"screenshot": cmd_screenshot,
|
||||
"eval": cmd_eval,
|
||||
"console-errors": cmd_console_errors,
|
||||
"viewport": cmd_viewport,
|
||||
"is-open": cmd_is_open,
|
||||
"bootstrap-admin": cmd_bootstrap_admin,
|
||||
"quit": cmd_quit,
|
||||
}
|
||||
|
||||
print("run-server driver -- commands:", ", ".join(COMMANDS), flush=True)
|
||||
print("driver>", end=" ", flush=True)
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
print("driver>", end=" ", flush=True)
|
||||
continue
|
||||
cmd, _, rest = line.partition(" ")
|
||||
fn = COMMANDS.get(cmd)
|
||||
if fn is None:
|
||||
print("unknown command:", cmd, "-- try one of:", ", ".join(COMMANDS))
|
||||
else:
|
||||
try:
|
||||
fn(rest)
|
||||
except Exception as e:
|
||||
print("ERROR:", e)
|
||||
print("driver>", end=" ", flush=True)
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time (idempotent) environment bootstrap for running the
|
||||
# espresso_frame FastAPI server and browser-driving its UI, in a
|
||||
# container that ships with NO Python/Node/Docker/browser and NO sudo.
|
||||
# Re-run any time; every step checks whether it already happened.
|
||||
set -euo pipefail
|
||||
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
|
||||
|
||||
UV_BIN="$HOME/.local/bin/uv"
|
||||
DEPS_ROOT="/tmp/run-server-chromium-deps"
|
||||
APT_WORK="/tmp/apt-work-run-server"
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SKILL_DIR/env.sh"
|
||||
|
||||
# 1. uv: a static Rust binary that can fetch its own Python build with
|
||||
# no C compiler needed (this container has none).
|
||||
if [ ! -x "$UV_BIN" ]; then
|
||||
echo "installing uv..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
fi
|
||||
|
||||
# 2. Python 3.12 + venv + server deps
|
||||
if [ ! -x .venv/bin/uvicorn ]; then
|
||||
echo "creating venv + installing server deps..."
|
||||
"$UV_BIN" python install 3.12
|
||||
"$UV_BIN" venv --python 3.12 .venv
|
||||
"$UV_BIN" pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# 3. Playwright (Python) + its Chromium download (~280MB: full chrome +
|
||||
# chrome-headless-shell + ffmpeg)
|
||||
if ! .venv/bin/python -c "import playwright" 2>/dev/null; then
|
||||
echo "installing playwright..."
|
||||
"$UV_BIN" pip install playwright
|
||||
fi
|
||||
if ! ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome >/dev/null 2>&1; then
|
||||
echo "downloading chromium..."
|
||||
.venv/bin/playwright install chromium
|
||||
fi
|
||||
|
||||
# 4. Chromium's shared libs + fonts. `playwright install-deps` and
|
||||
# `apt-get install` both need root; neither is available. Instead:
|
||||
# download the .deb files directly (apt-get download works read-only
|
||||
# without root once given a user-writable state dir) and extract
|
||||
# (not install) them with dpkg-deb -x, which needs no root either.
|
||||
if [ ! -f "$DEPS_ROOT/usr/lib/x86_64-linux-gnu/libglib-2.0.so.0" ]; then
|
||||
echo "fetching chromium's shared libs + fonts (non-root)..."
|
||||
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
|
||||
|
||||
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
|
||||
-o Dir::Etc::SourceParts=/dev/null update
|
||||
|
||||
PKGS="libglib2.0-0t64 libnspr4 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64
|
||||
libdbus-1-3 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1
|
||||
libxkbcommon0 libasound2t64 libatspi2.0-0t64 libcups2t64 libcairo2
|
||||
libpango-1.0-0 libpangocairo-1.0-0 libx11-6 libxcb1 libxext6
|
||||
fonts-liberation fontconfig libfontconfig1"
|
||||
# ^ first row: what chrome-headless-shell's ldd reported missing.
|
||||
# second row: what the FULL chrome binary additionally needed (we use
|
||||
# full chrome, not headless-shell -- see Gotchas in SKILL.md).
|
||||
|
||||
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
|
||||
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
|
||||
--print-uris $PKGS | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/urls.txt"
|
||||
|
||||
(cd "$APT_WORK/debs" && xargs -n1 -P8 curl -sS -O --max-time 30) < "$APT_WORK/urls.txt"
|
||||
for f in "$APT_WORK"/debs/*.deb; do dpkg-deb -x "$f" "$DEPS_ROOT"; done
|
||||
|
||||
# fonts.conf as shipped points at the real /usr/share/fonts, which is
|
||||
# root-owned and has nothing extracted into it. Point it at our
|
||||
# extracted copy instead, and give it a writable cache dir.
|
||||
mkdir -p /tmp/run-server-fontcache
|
||||
sed -i "s#<dir>/usr/share/fonts</dir>#<dir>$DEPS_ROOT/usr/share/fonts</dir>#" \
|
||||
"$DEPS_ROOT/etc/fonts/fonts.conf"
|
||||
sed -i "s#<cachedir>.*</cachedir>#<cachedir>/tmp/run-server-fontcache</cachedir>#" \
|
||||
"$DEPS_ROOT/etc/fonts/fonts.conf"
|
||||
|
||||
PATH="$DEPS_ROOT/usr/bin:$PATH" \
|
||||
LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu" \
|
||||
FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts" \
|
||||
"$DEPS_ROOT/usr/bin/fc-cache" -f
|
||||
fi
|
||||
|
||||
# 5. tmux -- also missing, also no apt/sudo. Same non-root download +
|
||||
# dpkg-deb -x trick, into the same extracted root (so its `usr/bin` is
|
||||
# already on PATH via env.sh).
|
||||
if [ ! -f "$DEPS_ROOT/usr/bin/tmux" ]; then
|
||||
echo "fetching tmux (non-root)..."
|
||||
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
|
||||
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
|
||||
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
|
||||
--print-uris tmux | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/tmux_urls.txt"
|
||||
(cd "$APT_WORK/debs" && xargs -n1 -P3 curl -sS -O --max-time 30) < "$APT_WORK/tmux_urls.txt"
|
||||
for f in $(sed -E 's#.*/##' "$APT_WORK/tmux_urls.txt"); do dpkg-deb -x "$APT_WORK/debs/$f" "$DEPS_ROOT"; done
|
||||
fi
|
||||
|
||||
CHROME_BIN="$(ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | head -1)"
|
||||
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Generated by setup.sh. Source this before running driver.py (it
|
||||
# needs LD_LIBRARY_PATH/FONTCONFIG_PATH set before the Chromium
|
||||
# subprocess launches -- driver.py does not source it for you).
|
||||
# start-server.sh does NOT need this file -- uvicorn has no such deps.
|
||||
export PATH="\$HOME/.local/bin:$DEPS_ROOT/usr/bin:\$PATH"
|
||||
export LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu"
|
||||
export FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts"
|
||||
export RUN_SERVER_CHROME_BIN="$CHROME_BIN"
|
||||
EOF
|
||||
|
||||
echo "setup complete -> $ENV_FILE"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Background-launch the server against a scratch DB/config -- never the
|
||||
# real deployment's data (see CLAUDE.md). Waits for readiness, prints
|
||||
# the PID and log path. Usage: ./start-server.sh [scratch-dir] [port]
|
||||
set -euo pipefail
|
||||
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
|
||||
|
||||
SCRATCH="${1:-/tmp/run-server-scratch}"
|
||||
PORT="${2:-8420}"
|
||||
mkdir -p "$SCRATCH"
|
||||
|
||||
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
|
||||
CONFIG_PATH="$SCRATCH/config.json" \
|
||||
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
|
||||
> "$SCRATCH/server.log" 2>&1 &
|
||||
PID=$!
|
||||
echo "$PID" > "$SCRATCH/server.pid"
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if ! curl -sf -o /dev/null "http://127.0.0.1:$PORT/"; then
|
||||
echo "server did not become ready -- check $SCRATCH/server.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "server PID $PID up on http://127.0.0.1:$PORT (log: $SCRATCH/server.log, db: $SCRATCH/test.db)"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: ./stop-server.sh [scratch-dir]
|
||||
SCRATCH="${1:-/tmp/run-server-scratch}"
|
||||
if [ -f "$SCRATCH/server.pid" ]; then
|
||||
kill "$(cat "$SCRATCH/server.pid")" 2>/dev/null || true
|
||||
rm -f "$SCRATCH/server.pid"
|
||||
echo "stopped"
|
||||
else
|
||||
echo "no $SCRATCH/server.pid -- nothing to stop"
|
||||
fi
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Firmware build check
|
||||
|
||||
# Fires on every push touching firmware source, unlike
|
||||
# firmware-release-build.yml (which only builds+publishes when
|
||||
# firmware/version.txt itself is bumped -- the "cut a release" signal).
|
||||
# This just verifies both board variants still compile; nothing else in
|
||||
# CI catches a firmware/** push that breaks the build until someone
|
||||
# happens to bump the version next.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "firmware/**"
|
||||
- ".gitea/workflows/firmware-build-check.yml"
|
||||
|
||||
jobs:
|
||||
build-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Same docker create/cp/start pattern as firmware-release-build.yml
|
||||
# (see that file's own comment for why -- the runner's job
|
||||
# workspace lives in a named Docker volume, not a real host path,
|
||||
# so a nested `docker run -v "$PWD:..."` bind-mounts nothing
|
||||
# useful). No release/artifact step here -- this only needs to
|
||||
# prove `idf.py build` still succeeds for each board.
|
||||
- name: Build (devkit -- ESP32-C6-DevKitC-1)
|
||||
run: |
|
||||
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
|
||||
git config --global --add safe.directory /workspace &&
|
||||
. "$IDF_PATH/export.sh" &&
|
||||
./build_for_board.sh devkit set-target esp32c6 &&
|
||||
./build_for_board.sh devkit build
|
||||
')
|
||||
docker cp "$PWD/." "$cid:/workspace"
|
||||
docker start -a "$cid"
|
||||
docker rm "$cid"
|
||||
|
||||
- name: Build (xiao -- Seeed XIAO ESP32-C6)
|
||||
run: |
|
||||
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
|
||||
git config --global --add safe.directory /workspace &&
|
||||
. "$IDF_PATH/export.sh" &&
|
||||
./build_for_board.sh xiao set-target esp32c6 &&
|
||||
./build_for_board.sh xiao build
|
||||
')
|
||||
docker cp "$PWD/." "$cid:/workspace"
|
||||
docker start -a "$cid"
|
||||
docker rm "$cid"
|
||||
@@ -8,7 +8,27 @@ on:
|
||||
- ".gitea/workflows/server-docker-build.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: server
|
||||
run: pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
working-directory: server
|
||||
run: pytest
|
||||
|
||||
build-and-push:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -32,3 +52,21 @@ jobs:
|
||||
tags: |
|
||||
git.thumeit.com/tfaour/espresso-frame-server:latest
|
||||
git.thumeit.com/tfaour/espresso-frame-server:${{ gitea.sha }}
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy over SSH
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '22' }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
|
||||
espressoframe_deployer@"$DEPLOY_HOST" \
|
||||
'cd ~/espresso-frame && docker compose pull && docker compose up -d'
|
||||
|
||||
+12
-1
@@ -17,6 +17,13 @@ server/**/__pycache__/
|
||||
server/.venv/
|
||||
server/*.egg-info/
|
||||
server/data/
|
||||
server/.pytest_cache/
|
||||
# render-service/ (whiteboard mode's Node sidecar) -- installed fresh
|
||||
# inside the Docker image, never committed. No package-lock.json exists
|
||||
# yet either (no Node/npm available in this project's dev environment to
|
||||
# generate one -- see render-service/README.md); if one's added later, do
|
||||
# NOT ignore it, lockfiles belong in git.
|
||||
server/render-service/node_modules/
|
||||
# Real deploy config, copied from docker-compose.yml.example -- holds the
|
||||
# Immich API key, must never be committed.
|
||||
server/docker-compose.yml
|
||||
@@ -27,4 +34,8 @@ server/docker-compose.yml
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
.claude/
|
||||
.claude/*
|
||||
# ...except Claude Code skills (e.g. agent-run instructions for this
|
||||
# app) -- those are project tooling worth sharing, not personal/local
|
||||
# state like settings.local.json or worktrees/.
|
||||
!.claude/skills/
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# espresso_frame
|
||||
|
||||
A DIY e-ink photo frame: an ESP32-C6 (`firmware/`, ESP-IDF) driving a
|
||||
Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a
|
||||
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
|
||||
image processing (crop/dither/quantize/pack), and serves a placeable
|
||||
photos/calendar/whiteboard widget system to the device.
|
||||
|
||||
Start here, don't re-derive from scratch:
|
||||
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
|
||||
server talk (sequence diagram, boot flow).
|
||||
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
|
||||
(data model, grid placement, compositor, button-action dispatch). Notes
|
||||
a known gap at the bottom (legacy `Frame` columns not yet dropped).
|
||||
- [`docs/hardware.md`](docs/hardware.md) -- wiring.
|
||||
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
|
||||
-- per-component setup, config, and a lot of accumulated gotchas
|
||||
(Immich API shape, TLS trust-anchor details, button GPIO wakeup
|
||||
quirks, etc.) -- check these before assuming something is a new bug.
|
||||
|
||||
## Conventions specific to this repo
|
||||
|
||||
- **No `Co-Authored-By: Claude` trailers in commits.** Attribution lives
|
||||
in the root [`README.md`](README.md) instead (see its last line) --
|
||||
the maintainer's explicit preference, not the default.
|
||||
- **Copyleft dependencies need an explicit flag, not a silent decision.**
|
||||
Before adding anything LGPL/GPL/AGPL (or unclear), verify the actual
|
||||
license via `pip show`/package metadata -- including transitive deps,
|
||||
not just the top-level package -- and present the finding and tradeoff
|
||||
in plain text rather than picking an approach unilaterally (hand-rolling
|
||||
an alternative, swapping packages, silently accepting it). This project
|
||||
has knowingly accepted AGPL-3.0-or-later exposure once already
|
||||
(`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate,
|
||||
explicit call -- not a precedent for skipping the check next time.
|
||||
- **Scope new auth/access-control broadly, not just to the literal
|
||||
endpoint named.** When a request changes the trust model (e.g. adding
|
||||
public-internet exposure), apply the new gate to every endpoint serving
|
||||
real data or performing a real action, and call out anything you're
|
||||
tempted to exclude and why. This repo shipped a token gate once that
|
||||
covered `/api/*` but left `/frame/image` -- the actual photo bytes --
|
||||
open; caught immediately in production.
|
||||
|
||||
## Working in this repo
|
||||
|
||||
- **Server tests**: `cd server && pytest` (SQLite, fixtures wipe/reseed
|
||||
between tests -- see `tests/conftest.py`). Migration changes need a
|
||||
matching test in `tests/test_migrations.py`; anything touching
|
||||
`require_frame_view`/`require_frame_control` boundaries needs a
|
||||
same-shape permission test (see `tests/test_permission_boundaries.py`
|
||||
and `tests/test_button_actions.py` for the pattern: owner, linked user,
|
||||
unrelated user, logged out).
|
||||
- **UI changes**: verify in a real browser (Playwright), not just by
|
||||
reading the JS -- this project has hit multiple bugs that only showed up
|
||||
live (mobile viewport CSS collapse, a dialog's status message landing
|
||||
behind its own backdrop, a JSON/form-urlencoded body mismatch). Spin up
|
||||
`uvicorn app.main:app` against a scratch `DATABASE_URL`/`CONFIG_PATH`
|
||||
sqlite file, don't touch the real deployment's data. `.claude/skills/run-server/`
|
||||
(`/run-server`) has a driver for exactly this.
|
||||
- **New/changed UI must work at both desktop and mobile widths --
|
||||
screenshot both, don't assume one implies the other.** The layout
|
||||
genuinely forks at the 860px breakpoint (`theme.css`): the sidebar
|
||||
goes off-canvas behind a hamburger below it. A dialog, header
|
||||
control, or new widget that looks right at a wide viewport can
|
||||
overflow, overlap the mobile bar, or mis-center at phone widths.
|
||||
`run-server`'s driver has a `viewport` command for exactly this
|
||||
(defaults to a phone size; switch to `1280 900` for desktop).
|
||||
- **Deploy**: Gitea Actions at `git.thumeit.com/tfaour/espresso_frame`
|
||||
(`.gitea/workflows/server-docker-build.yml`: `test` -> `build-and-push`
|
||||
-> `deploy` on any push to `main` touching `server/**`; `deploy` SSHes
|
||||
into the host as `espressoframe_deployer` and runs `docker compose pull
|
||||
&& docker compose up -d`). A separate workflow
|
||||
(`firmware-release-build.yml`) builds+publishes firmware binaries as
|
||||
Gitea release assets when `firmware/version.txt` changes. Poll CI status
|
||||
with `curl https://git.thumeit.com/api/v1/repos/tfaour/espresso_frame/actions/tasks`
|
||||
rather than asking the user to check.
|
||||
- **Device-facing paths are frozen.** `/frame/image`, `/frame/advance`,
|
||||
`/frame/back`, `/frame/config`, `/frame/battery`, `/frame/firmware` and
|
||||
their exact JSON key names (`refresh_interval_s`, `firmware_version`,
|
||||
etc.) are baked into deployed firmware -- never rename or restructure
|
||||
these without a firmware-side migration story to match.
|
||||
@@ -24,9 +24,10 @@ time in deep sleep.
|
||||
- ESP32-C6 dev board (8MB flash)
|
||||
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI
|
||||
|
||||
See [`docs/hardware.md`](docs/hardware.md) for wiring and
|
||||
See [`docs/hardware.md`](docs/hardware.md) for wiring,
|
||||
[`docs/architecture.md`](docs/architecture.md) for how the two halves talk
|
||||
to each other.
|
||||
to each other, and [`docs/widgets.md`](docs/widgets.md) for the server's
|
||||
placeable photos/calendar/whiteboard widget system.
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
+16
-5
@@ -22,17 +22,17 @@ sequenceDiagram
|
||||
Frame->>Frame: Connect to home WiFi
|
||||
alt next-photo button pressed
|
||||
Frame->>Server: POST /frame/advance
|
||||
Server->>Server: Force-advance to next queued photo, reset interval clock
|
||||
Server->>Server: Run every action assigned to NEXT, in order<br/>(may span several widgets -- see docs/widgets.md)
|
||||
else back-photo button pressed
|
||||
Frame->>Server: POST /frame/back
|
||||
Server->>Server: Return to previously-current photo (bounded history),<br/>reset interval clock
|
||||
Server->>Server: Run every action assigned to BACK, in order
|
||||
else normal wake
|
||||
Frame->>Server: GET /frame/image
|
||||
Server->>Server: Advance only if refresh_interval_s has elapsed<br/>since the current photo was set -- otherwise a no-op
|
||||
Server->>Server: Render every widget on the panel into its own region<br/>(each independently idempotent -- a photo widget only<br/>actually advances once its own refresh_interval_s has elapsed)
|
||||
end
|
||||
Server->>Immich: List album assets / download preview / faces
|
||||
Server->>Immich: List album assets / download preview / faces<br/>(once per photo widget on the panel)
|
||||
Immich-->>Server: JPEG + face bounding boxes
|
||||
Server->>Server: Crop (face-aware) + quantize (dither) + pack 4bpp
|
||||
Server->>Server: Composite every widget's region onto one canvas,<br/>then enhance/overlay/quantize (dither)/pack 4bpp once
|
||||
Server-->>Frame: 192,000 raw bytes, streamed
|
||||
Frame->>Frame: Write to panel SPI buffer, compute CRC32
|
||||
alt CRC unchanged since last physical refresh
|
||||
@@ -45,6 +45,17 @@ sequenceDiagram
|
||||
Frame->>Frame: Deep sleep (server-configured interval, or a short<br/>retry interval on any failure)
|
||||
```
|
||||
|
||||
The device-facing endpoints above (`/frame/image`, `/frame/advance`,
|
||||
`/frame/back`, `/frame/config`) are frozen -- baked into deployed firmware
|
||||
-- and unchanged by any of this. What *does* change server-side: a frame's
|
||||
panel isn't a single fixed "mode" anymore, it holds an arbitrary
|
||||
arrangement of independently placed/sized widgets (photos/calendar/
|
||||
whiteboard, including several of the same type), each rendered into its
|
||||
own region and composited together, with NEXT/BACK each mapped to their
|
||||
own ordered list of per-widget actions rather than one fixed meaning. See
|
||||
[`docs/widgets.md`](widgets.md) for the widget system's data model,
|
||||
placement grid, and button-action dispatch.
|
||||
|
||||
## Firmware boot flow
|
||||
|
||||
1. **No stored config** (first boot, or NVS erased): bring up the display,
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# Widget system
|
||||
|
||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like
|
||||
arranging icons on an Android home screen. A frame can hold several widgets of the
|
||||
same type (e.g. two photo widgets pointed at different Immich albums side
|
||||
by side).
|
||||
This replaced an earlier design where `Frame.mode` picked exactly one
|
||||
full-panel renderer; that column (and the other now-dead per-mode `Frame`
|
||||
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
|
||||
is still physically present but unused, pending a final cleanup migration
|
||||
(see "Known gaps" below).
|
||||
|
||||
The device-facing contract is unchanged by any of this: `GET /frame/image`,
|
||||
`POST /frame/advance`, `POST /frame/back` are the same frozen paths
|
||||
firmware has always called (see `docs/architecture.md`) -- what changed is
|
||||
entirely server-side, in how those endpoints decide what to render and what
|
||||
a button press does.
|
||||
|
||||
## Data model
|
||||
|
||||
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
||||
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h`
|
||||
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||
already checked) -- that's what keeps compositing simple: no z-order,
|
||||
no blending, just N independent regions pasted onto one shared canvas.
|
||||
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||
`StaticWidgetConfig`, `TextWidgetConfig`, each keyed by `widget_id` with
|
||||
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
||||
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
||||
text (paragraphs of styled runs), never raw HTML -- see
|
||||
`server/app/text_content.py`'s module docstring for why that parse
|
||||
step is the widget's actual stored-XSS sanitization boundary.
|
||||
`PhotoWidgetConfig`
|
||||
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
||||
advance/back/queue logic ports across widget instances unchanged.
|
||||
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
||||
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
|
||||
into its own widget type (migration 17) so a task list can be placed
|
||||
and sized independent of any calendar's view/footprint, then (migration
|
||||
18) given the same multi-source shape a calendar widget already has.
|
||||
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
|
||||
`frame_id`) since a frame can now have more than one independent
|
||||
calendar/tasks widget, each with its own included set. Identical
|
||||
shape and permission model (owner-added, anyone-linked-can-mute, see
|
||||
"Per-widget config UI" below) -- `FrameTaskList` just has no `"ics"`
|
||||
calendar_key variant, since a plain ICS subscription has no VTODO
|
||||
(task) collection.
|
||||
- `FrameButtonAction` (`id`, `frame_id`, `button` [`"next"`|`"back"`],
|
||||
`widget_id`, `action`, `sort_order`) -- see "Button actions" below.
|
||||
|
||||
## Placement: a grid, not freeform pixels
|
||||
|
||||
`app/grid.py` is pure grid math, no I/O. The grid is `GRID_LONG=8` x
|
||||
`GRID_SHORT=5` cells, defined relative to the panel's long/short axis
|
||||
(not "landscape" specifically) so it stays valid across
|
||||
`image_pipeline.logical_render_size(orientation)`'s genuine width/height
|
||||
swap for portrait -- landscape orientations are 8 cols x 5 rows, portrait
|
||||
are 5 cols x 8 rows, same cell size either way. **Changing a frame's
|
||||
orientation invalidates its existing layout** (an 8x5 arrangement isn't
|
||||
valid on a 5x8 grid) -- the server resets to one full-panel widget on an
|
||||
orientation change rather than trying to remap coordinates.
|
||||
|
||||
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1.
|
||||
Enforced both client-side
|
||||
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
||||
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||
alone.
|
||||
|
||||
## Rendering: one shared compositor
|
||||
|
||||
`app/widgets/` is the render/action registry -- one module per
|
||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
||||
`static_image.py`, `text.py`), each exposing:
|
||||
|
||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
|
||||
an unquantized RGB image exactly `target_w x target_h`, the widget's
|
||||
content composed into its own region. Never raises for a foreseeable
|
||||
failure (an Immich hiccup, an unconfigured widget) -- falls back to a
|
||||
small placeholder within its own region instead, so one widget having a
|
||||
bad moment doesn't blank the whole panel.
|
||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
||||
for whiteboard). Empty for tasks, static image, and text -- nothing to
|
||||
advance/back/force for a passive checklist, a fixed uploaded image, or
|
||||
a fixed block of authored text.
|
||||
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
||||
assignment UI.
|
||||
|
||||
`routers/device.py`'s `_render_widgets` loads every `Widget` row for the
|
||||
frame, maps each one's grid rect to pixels (`grid.cell_to_pixels`), calls
|
||||
its module's `render()`, and hands the whole list of `(rect, image)`
|
||||
regions to `image_pipeline.render_panel` -- which pastes every region onto
|
||||
one shared canvas, then runs enhance/manage-overlay/quantize/dither/pack
|
||||
**once** over the composited result. Quantizing the whole canvas together
|
||||
(not each region separately before pasting) is what keeps the 6-color
|
||||
e-ink dithering pattern consistent across a widget boundary instead of a
|
||||
visible seam at the edge.
|
||||
|
||||
Calendar widgets pick from discrete size tiers (`calendar_render.py`'s
|
||||
`_SIZE_TIERS`) for font size/margins/row heights based on their actual
|
||||
grid footprint, rather than continuously scaling constants tuned for a
|
||||
full ~800x480 canvas -- falls back to agenda view if a widget is too small
|
||||
for month view to stay legible.
|
||||
|
||||
## Button actions
|
||||
|
||||
Each physical button (NEXT/BACK) maps to an **ordered list** of
|
||||
`(widget, action)` bindings, not a fixed meaning -- e.g. NEXT can be
|
||||
"photo widget A: advance" *and* "calendar widget B: advance" together, or
|
||||
even a mismatched combination on purpose. On a press,
|
||||
`routers/device.py`'s `_run_button_actions` runs every assigned action for
|
||||
that button in order (each in its own `widget_locked` span -- never nested,
|
||||
since the underlying per-frame lock isn't reentrant), catching and
|
||||
logging any single action's failure without blocking the rest, then
|
||||
re-renders and returns the whole composed panel once at the end regardless
|
||||
of which actions succeeded.
|
||||
|
||||
The web UI for this is the "Button assignments" card on a frame's
|
||||
Configuration tab (`static/frame_config.js`, `GET`/`PUT
|
||||
/api/frames/{id}/buttons`) -- add/remove/reorder, autosaved. Two widgets of
|
||||
the same type would otherwise both just say "Photos" in the assignment
|
||||
dropdowns; the UI disambiguates using each widget's grid position (e.g.
|
||||
"Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them
|
||||
apart by eye on the Layout canvas.
|
||||
|
||||
A newly-created widget (including the one auto-migrated from a frame's old
|
||||
`mode` on upgrade) gets a sensible default binding reproducing its old
|
||||
button behavior -- see `migration.py`'s `_default_button_actions`.
|
||||
|
||||
## Per-widget config UI
|
||||
|
||||
Each widget has a gear-icon button on the Layout canvas that opens a
|
||||
`<dialog>` with that widget's own settings (album, calendar/task-list
|
||||
inclusion, whiteboard source, etc.) -- not a per-frame tab, since a
|
||||
frame can now have several widgets of the same type with independent
|
||||
settings. The
|
||||
dialog HTML is injected server-rendered (`routers/frame_pages.py`'s
|
||||
`widget_dialog`, dispatching on `widget.widget_type`); its JS is a
|
||||
top-level, always-loaded file (`static/widget_dialog_*.js`) exposing
|
||||
`init<Type>Dialog()`/`close<Type>Dialog()`, since dynamically-injected
|
||||
HTML can't carry executable `<script>` tags. While a dialog is open,
|
||||
`window.FRAME_API` is repointed at that widget's own API base
|
||||
(`/api/frames/{id}/widgets/{widget_id}`) and restored on close;
|
||||
`window.FRAME_BASE_API` stays pointed at the frame-level base throughout
|
||||
for the always-present header/status-bar JS.
|
||||
|
||||
## Known gaps (Phase 6, not yet done)
|
||||
|
||||
The original 8-phase rollout plan's last phase is still open:
|
||||
|
||||
- Legacy per-mode `Frame` columns (`mode`, `album_id`,
|
||||
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, etc.) are
|
||||
still physically present in the schema but no longer read or written
|
||||
anywhere -- they need a dedicated final migration to drop them. Left in
|
||||
place deliberately through the widget-system rollout (a much larger
|
||||
blast radius cutover than this project's usual same-migration-drop
|
||||
convention) but there's no reason to keep carrying them now that every
|
||||
phase has shipped.
|
||||
- `server/README.md` still describes photos/calendar/whiteboard as
|
||||
per-frame "modes" in several places rather than widgets -- needs a pass
|
||||
once the column drop above is safely deployed.
|
||||
- Whiteboard rendering is tagged **(alpha)** in the UI -- not fully
|
||||
reliable yet, treat it as experimental if extending it.
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
data/
|
||||
render-service/node_modules/
|
||||
.git/
|
||||
+50
-6
@@ -2,18 +2,62 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# tzdata: python:3.12-slim doesn't include it by default, so the zoneinfo
|
||||
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
|
||||
# would have no named zones to resolve without this -- ZoneInfo() would
|
||||
# raise for anything other than "UTC".
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
||||
# tzdata/fonts in their own layer, kept separate from the much larger
|
||||
# Node.js/npm layers below -- see those layers' own comments for why
|
||||
# they're split up the way they are. tzdata: python:3.12-slim doesn't
|
||||
# include it by default, so the zoneinfo database backing the web UI's
|
||||
# "Timezone" setting (used by "Quiet hours") would have no named zones
|
||||
# to resolve without this -- ZoneInfo() would raise for anything other
|
||||
# than "UTC". fontconfig/fonts-dejavu-core: whiteboard mode's
|
||||
# render-service/ (own README there) needs something to render
|
||||
# whiteboard text with.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata fontconfig fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js: whiteboard frame mode's render-service/ runs as a second
|
||||
# process in this same container rather than a separate compose service
|
||||
# -- it's a lightweight, stateless, localhost-only sidecar with nothing
|
||||
# worth independently scaling or restarting. NodeSource's setup script is
|
||||
# used instead of Debian bookworm's own apt Node package, which is both
|
||||
# older than jsdom's minimum (20.19+) and inconsistently available.
|
||||
# curl/gnupg are only needed to add and fetch NodeSource's repo -- purged
|
||||
# again in this same RUN (not a later one; Docker layers are immutable,
|
||||
# so removing them in a *different* instruction wouldn't shrink this
|
||||
# one's actual pushed size) so their bytes don't end up in the image at
|
||||
# all, only nodejs's.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates gnupg \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& apt-get purge -y --auto-remove curl gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# render-service/'s dependencies installed as several separate layers
|
||||
# rather than one `npm install` covering all of them -- a from-scratch
|
||||
# push of this image once hit Cloudflare's payload-size limit on a
|
||||
# single blob/layer upload (the registry sits behind it), and splitting
|
||||
# a big layer into several smaller ones is the direct fix for exactly
|
||||
# that failure mode, independent of anything about the registry itself.
|
||||
# --no-save: package.json already fully declares these (with the exact
|
||||
# same version pins used here) as the single source of truth for what
|
||||
# this service depends on -- these calls are just about *when* each one
|
||||
# gets installed for layer-size reasons, not re-deciding what's needed.
|
||||
COPY render-service/package.json ./render-service/package.json
|
||||
WORKDIR /app/render-service
|
||||
RUN npm install --omit=dev --no-save express@^5.2.1 && npm cache clean --force
|
||||
RUN npm install --omit=dev --no-save jsdom@^29.1.1 && npm cache clean --force
|
||||
RUN npm install --omit=dev --no-save @excalidraw/[email protected] && npm cache clean --force
|
||||
RUN npm install --omit=dev --no-save @resvg/[email protected] && npm cache clean --force
|
||||
WORKDIR /app
|
||||
|
||||
COPY render-service/server.js ./render-service/server.js
|
||||
COPY app ./app
|
||||
COPY start.sh .
|
||||
RUN chmod +x start.sh
|
||||
|
||||
EXPOSE 8420
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
|
||||
CMD ["./start.sh"]
|
||||
|
||||
@@ -213,6 +213,57 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
never vendored or modified, so this project's own code stays under its
|
||||
own license; LGPL's copyleft terms apply to that library itself, not
|
||||
to code that merely links against it dynamically.
|
||||
- CalDAV account support (`app/caldav_client.py`, alongside the plain ICS
|
||||
subscription) wraps the `caldav` PyPI package. `caldav` itself is
|
||||
dual-licensed GPL-3.0-or-later/Apache-2.0, but it hard-depends on
|
||||
`icalendar-searcher`, which is **AGPL-3.0-or-later** -- the strongest
|
||||
copyleft in this project's dependency tree, and the one whose
|
||||
network-use clause is written specifically for server applications
|
||||
like this one (not just "don't vendor/modify it," which was enough
|
||||
reasoning for the LGPL dependency above). Taking this on was an
|
||||
explicit, informed call by the project owner, not a default -- anyone
|
||||
redistributing this project (vs. just self-hosting it) should
|
||||
re-evaluate that tradeoff for their own situation before doing so.
|
||||
- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`)
|
||||
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
|
||||
file -- which turns out to be Excalidraw scene JSON (elements/appState/
|
||||
files), not an image -- and renders it via `render-service/`, a small
|
||||
Node.js sidecar using Excalidraw's own real export code
|
||||
(`@excalidraw/utils`'s `exportToSvg`) plus `@resvg/resvg-js` (a native
|
||||
Rust SVG rasterizer, no headless browser) to turn that into a PNG. That
|
||||
sidecar runs as a **second process inside this same container**
|
||||
(`Dockerfile` installs Node, `start.sh` launches it in the background
|
||||
before `exec`-ing uvicorn), reachable only at `127.0.0.1:3001` from the
|
||||
Python process -- not a second docker-compose service, since it's
|
||||
lightweight, stateless, and has nothing worth independently scaling or
|
||||
restarting. License check (after getting burned once already in this
|
||||
same file, on the CalDAV dependency below, into checking transitive
|
||||
deps and not just top-level ones): Excalidraw, `@excalidraw/utils`,
|
||||
every one of its own runtime dependencies, `@resvg/resvg-js`
|
||||
(MPL-2.0 -- weak/file-level copyleft, doesn't extend to code that just
|
||||
calls into it), `jsdom`, and `express` are all MIT/Apache-2.0/Zlib/
|
||||
MPL-2.0 -- no repeat of the AGPL surprise. **Not runtime-tested against
|
||||
a real `npm install`/`docker build`** -- this project's dev environment
|
||||
has no Node.js/npm, only network access to the npm registry API (used
|
||||
to verify the above and pick real, current dependency versions). See
|
||||
`render-service/README.md` for exactly what is and isn't verified.
|
||||
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
|
||||
(used for every other bit of text this project renders) has no glyphs
|
||||
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
|
||||
skipping the codepoint. `app/calendar_render.py` draws emoji runs with
|
||||
a vendored font instead (Google's Noto Emoji, OFL-1.1 -- license text
|
||||
at `app/fonts/OFL.txt`), the one deliberate exception to this project's
|
||||
usual "no new font/icon assets" default elsewhere in calendar_render.py
|
||||
-- there's no way to hand-draw arbitrary emoji with primitives the way
|
||||
the weather icons are. Full color (`app/fonts/NotoColorEmoji.ttf`,
|
||||
embedded CBDT bitmap glyphs) is tried first and confirmed to hold up
|
||||
fine through the panel's own Floyd-Steinberg dithering; a deployment
|
||||
whose Pillow/FreeType wasn't built with embedded color bitmap support
|
||||
falls back to a monochrome outline font (`app/fonts/NotoEmoji.ttf`)
|
||||
instead of crashing or rendering nothing. Color glyphs are only stored
|
||||
at one embedded bitmap size (109px), so they're rasterized once at
|
||||
that size and scaled down to the target row height rather than drawn
|
||||
directly like normal vector text.
|
||||
- The 6-color palette RGB values in `app/image_pipeline.py`
|
||||
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
||||
(Waveshare doesn't publish exact color primaries for this panel).
|
||||
@@ -252,3 +303,19 @@ CONFIG_PATH=./data/config.json uvicorn app.main:app --reload --host 0.0.0.0 --po
|
||||
`--host 0.0.0.0` matters here: without it, uvicorn defaults to
|
||||
`127.0.0.1` (localhost-only), which the ESP32 can't reach over the LAN.
|
||||
The Docker image already binds `0.0.0.0` by default.
|
||||
|
||||
## Running tests
|
||||
|
||||
```
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
Runs against a fresh temp SQLite database (`tests/conftest.py` sets
|
||||
`DATABASE_URL` before anything imports `app.db`), with every table wiped
|
||||
and reseeded (frame #1 + server settings, same as a real fresh install)
|
||||
between tests -- no Docker, Node, or a real Immich/CalDAV/WebDAV server
|
||||
needed; a few tests spin up small local HTTP servers as fixtures to
|
||||
stand in for those. Also runs as its own job in
|
||||
`.gitea/workflows/server-docker-build.yml`, gating the image build/push
|
||||
-- a failing test suite blocks the push, not just decorates it.
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""CalDAV account support: discovering which calendars an account exposes,
|
||||
and fetching one calendar's events or tasks -- the second way (alongside
|
||||
calendar_feed.py's single-file ICS subscription) a user can link a
|
||||
calendar for calendar frame mode (Nextcloud, Fastmail, iCloud, Radicale,
|
||||
Baikal, ...). Task lists (VTODO collections) are CalDAV-only -- a plain
|
||||
ICS subscription doesn't meaningfully have one -- see fetch_tasks.
|
||||
|
||||
Thin wrapper around the `caldav` PyPI package (RFC 4791 client). NOTE ON
|
||||
LICENSING: `caldav` itself is dual-licensed GPL-3.0-or-later / Apache-2.0,
|
||||
but it hard-depends on `icalendar-searcher`, which is AGPL-3.0-or-later --
|
||||
the strongest copyleft license in this project's dependency tree, and the
|
||||
one whose network-use clause is specifically written for server
|
||||
applications like this one. This was an explicit, informed call by the
|
||||
project owner to accept that exposure rather than hand-roll a CalDAV
|
||||
client -- see the server README's Notes section. Anyone redistributing
|
||||
this project (as opposed to just self-hosting it) should reread that
|
||||
tradeoff for their own situation.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py. Event parsing/expansion reuses
|
||||
icalendar + recurring_ical_events directly (rather than trusting each
|
||||
CalDAV server's own possibly-inconsistent RRULE expansion) so a CalDAV
|
||||
calendar and an ICS subscription behave identically once fetched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
import caldav
|
||||
import icalendar
|
||||
import recurring_ical_events
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HTTP_TIMEOUT_S = 15
|
||||
|
||||
|
||||
class CalDavError(Exception):
|
||||
"""Discovery or fetch failed -- network, auth, or an unexpected
|
||||
server response. Raised loudly; callers (Settings' discover
|
||||
endpoint, calendar_feed.merge_events) decide what to do. Wraps
|
||||
whatever the caldav package/its transport raised, since that
|
||||
exception hierarchy isn't something call sites should need to know
|
||||
about directly."""
|
||||
|
||||
|
||||
def discover_calendars(base_url: str, username: str, password: str) -> list[dict]:
|
||||
"""[{"href": absolute_calendar_url, "display_name": str}, ...] for
|
||||
every calendar in this account. base_url is the server's CalDAV
|
||||
entry point (e.g. "https://cloud.example.com/remote.php/dav/" for
|
||||
Nextcloud) -- the caller supplies it directly, same idiom as the
|
||||
plain ICS subscription URL."""
|
||||
try:
|
||||
client = caldav.DAVClient(url=base_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
|
||||
calendars = client.principal().calendars()
|
||||
except Exception as e:
|
||||
raise CalDavError(str(e)) from e
|
||||
|
||||
result = []
|
||||
for cal in calendars:
|
||||
try:
|
||||
display_name = cal.get_display_name() or cal.name
|
||||
except Exception:
|
||||
display_name = None
|
||||
result.append({"href": str(cal.url), "display_name": display_name or str(cal.url)})
|
||||
return result
|
||||
|
||||
|
||||
def fetch_calendar_events(calendar_url: str, username: str, password: str,
|
||||
window_start: date, window_end: date) -> list[dict]:
|
||||
"""One CalDAV calendar's events in [window_start, window_end] -- same
|
||||
event dict shape as calendar_feed.fetch_source_events (no
|
||||
"owner_display_name"; the caller adds that).
|
||||
|
||||
Deliberately does NOT use the calendar-query REPORT's server-side
|
||||
time-range filter (caldav.Calendar.date_search) -- RFC 4791 leaves
|
||||
that corner case underspecified and real servers disagree on it
|
||||
(the caldav package's own docs warn "servers often behave
|
||||
differently when presented with a search request"; confirmed here
|
||||
too, once against a real server, as a calendar whose events just
|
||||
silently never came back despite discovery/auth both working
|
||||
fine). Instead this fetches every event in the calendar unfiltered
|
||||
(get_events() is a plain "list VEVENTs" REPORT with no time-range
|
||||
element -- the much more universally-supported case) and does 100%
|
||||
of the date-window filtering/recurrence-expansion client-side via
|
||||
icalendar + recurring_ical_events, exactly like calendar_feed.py
|
||||
already does for plain ICS feeds. Heavier per-fetch (the whole
|
||||
calendar, not just the window) but far more reliable."""
|
||||
try:
|
||||
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
|
||||
calendar = caldav.Calendar(client=client, url=calendar_url)
|
||||
objects = calendar.get_events()
|
||||
except Exception as e:
|
||||
raise CalDavError(str(e)) from e
|
||||
|
||||
events: list[dict] = []
|
||||
for obj in objects:
|
||||
try:
|
||||
ical = icalendar.Calendar.from_ical(obj.data)
|
||||
occurrences = recurring_ical_events.of(ical).between(window_start, window_end)
|
||||
except Exception as e: # one malformed resource shouldn't blank the whole calendar
|
||||
logger.warning("Could not parse a CalDAV event from %s: %s", calendar_url, e)
|
||||
continue
|
||||
for occ in occurrences:
|
||||
dtstart = occ.get("DTSTART")
|
||||
dtend = occ.get("DTEND")
|
||||
if dtstart is None:
|
||||
continue
|
||||
start_dt = dtstart.dt
|
||||
end_dt = dtend.dt if dtend is not None else start_dt
|
||||
all_day = not isinstance(start_dt, datetime)
|
||||
events.append({
|
||||
"summary": str(occ.get("SUMMARY") or "(untitled)"),
|
||||
"start": start_dt.isoformat(),
|
||||
"end": end_dt.isoformat(),
|
||||
"all_day": all_day,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def fetch_tasks(calendar_url: str, username: str, password: str,
|
||||
completed_since: datetime | None = None) -> list[dict]:
|
||||
"""Outstanding VTODOs from one CalDAV task list, plus -- when
|
||||
completed_since is given -- ones completed at or after that cutoff
|
||||
(see routers/common.py's get_or_refresh_tasks_for_widget, which
|
||||
passes "now - 24h" when TaskWidgetConfig.show_completed is on;
|
||||
None, the default, means completed tasks are dropped entirely, the
|
||||
original behavior). {"summary", "due" (ISO date/datetime string or
|
||||
None), "completed_at" (ISO datetime string, or None for an
|
||||
outstanding task)}, ... . Outstanding tasks sort first (by due date,
|
||||
no-due-date last), any included completed ones after (most recently
|
||||
completed first).
|
||||
|
||||
Fetches every task including completed ones and filters/sorts
|
||||
client-side rather than trusting get_todos()'s own
|
||||
include_completed/sort_keys server-side filtering, same reasoning as
|
||||
fetch_calendar_events not trusting the time-range REPORT filter --
|
||||
a simpler filter than a time range, but not worth re-litigating
|
||||
which server-side filters are reliable one at a time."""
|
||||
try:
|
||||
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
|
||||
calendar = caldav.Calendar(client=client, url=calendar_url)
|
||||
objects = calendar.get_todos(include_completed=True)
|
||||
except Exception as e:
|
||||
raise CalDavError(str(e)) from e
|
||||
|
||||
outstanding: list[dict] = []
|
||||
completed: list[dict] = []
|
||||
for obj in objects:
|
||||
try:
|
||||
ical = icalendar.Calendar.from_ical(obj.data)
|
||||
except Exception as e: # one malformed resource shouldn't blank the whole list
|
||||
logger.warning("Could not parse a CalDAV task from %s: %s", calendar_url, e)
|
||||
continue
|
||||
for component in ical.walk("VTODO"):
|
||||
status = str(component.get("STATUS") or "NEEDS-ACTION").upper()
|
||||
summary = str(component.get("SUMMARY") or "(untitled)")
|
||||
if status == "COMPLETED":
|
||||
completed_prop = component.get("COMPLETED")
|
||||
completed_dt = completed_prop.dt if completed_prop is not None else None
|
||||
if completed_since is None or completed_dt is None or completed_dt < completed_since:
|
||||
continue
|
||||
completed.append({"summary": summary, "due": None, "completed_at": completed_dt.isoformat()})
|
||||
else:
|
||||
due = component.get("DUE")
|
||||
outstanding.append({
|
||||
"summary": summary,
|
||||
"due": due.dt.isoformat() if due is not None else None,
|
||||
"completed_at": None,
|
||||
})
|
||||
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
|
||||
completed.sort(key=lambda t: t["completed_at"], reverse=True)
|
||||
return outstanding + completed
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskSource:
|
||||
"""One task list to merge in -- CalDAV only, no ICS variant (a plain
|
||||
ICS subscription has no VTODO collection to speak of).
|
||||
owner_display_name tags every task pulled from this source so a
|
||||
merged checklist can show whose task is whose; color_index (2-5,
|
||||
into image_pipeline.DEFAULT_PALETTE_RGB) is this list's manually
|
||||
pinned color, or None for calendar_render.py's auto-cycle-by-owner-
|
||||
name fallback -- see models.FrameTaskList."""
|
||||
|
||||
owner_display_name: str
|
||||
url: str
|
||||
username: str
|
||||
password: str
|
||||
color_index: int | None = None
|
||||
|
||||
|
||||
def merge_tasks(sources: list[TaskSource], completed_since: datetime | None = None) -> tuple[list[dict], str]:
|
||||
"""Fetches each source independently -- one broken list never blanks
|
||||
another's tasks. Returns (merged_tasks, fetch_summary); fetch_summary
|
||||
is "" when every source succeeded, else "N of M task lists
|
||||
unavailable" (same no-naming-names posture as calendar_feed.
|
||||
merge_events). No cross-list duplicate collapsing (unlike
|
||||
merge_events) -- a task synced to two lists at once is rare enough,
|
||||
and lower-stakes than a duplicated calendar event, not to be worth
|
||||
the same de-dup machinery."""
|
||||
merged: list[dict] = []
|
||||
failures = 0
|
||||
for source in sources:
|
||||
try:
|
||||
tasks = fetch_tasks(source.url, source.username, source.password, completed_since=completed_since)
|
||||
except CalDavError:
|
||||
failures += 1
|
||||
continue
|
||||
for task in tasks:
|
||||
merged.append({
|
||||
**task,
|
||||
"owner_display_name": source.owner_display_name,
|
||||
"color_index": source.color_index,
|
||||
})
|
||||
|
||||
outstanding = [t for t in merged if t["completed_at"] is None]
|
||||
completed = [t for t in merged if t["completed_at"] is not None]
|
||||
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
|
||||
completed.sort(key=lambda t: t["completed_at"], reverse=True)
|
||||
summary = f"{failures} of {len(sources)} task lists unavailable" if failures else ""
|
||||
return outstanding + completed, summary
|
||||
+63
-18
@@ -1,10 +1,11 @@
|
||||
"""Fetch, parse, and merge per-user ICS calendar feeds for calendar frame
|
||||
mode (see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
||||
"""Fetch, parse, and merge per-user calendar feeds -- ICS subscriptions
|
||||
and (via caldav_client.py) CalDAV collections -- for calendar frame mode
|
||||
(see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
|
||||
get_or_refresh_calendar_events) supply plain (owner_display_name, url)
|
||||
pairs, not ORM objects, so this module stays testable against fixture .ics
|
||||
text with no database or app involved.
|
||||
get_or_refresh_calendar_events) supply plain CalendarSource values, not
|
||||
ORM objects, so this module stays testable against fixture .ics text with
|
||||
no database or app involved.
|
||||
|
||||
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
|
||||
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
|
||||
@@ -16,12 +17,15 @@ code under LGPL terms).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
import httpx
|
||||
import icalendar
|
||||
import recurring_ical_events
|
||||
|
||||
from . import caldav_client
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
|
||||
|
||||
@@ -83,27 +87,68 @@ def fetch_source_events(url: str, window_start: date, window_end: date) -> list[
|
||||
return events
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalendarSource:
|
||||
"""One calendar to merge in: either a plain ICS subscription (kind
|
||||
"ics", url is the feed itself) or one CalDAV collection (kind
|
||||
"caldav", url is the calendar's own URL, username/password its
|
||||
account credentials) -- see caldav_client.py. owner_display_name
|
||||
tags every event pulled from this source so a merged agenda can show
|
||||
whose event is whose. color_index (2-5, into
|
||||
image_pipeline.DEFAULT_PALETTE_RGB) is this calendar's manually
|
||||
pinned color, or None to fall back on calendar_render.py's old
|
||||
auto-cycle-by-owner-name behavior -- see models.FrameCalendar."""
|
||||
|
||||
owner_display_name: str
|
||||
kind: str
|
||||
url: str
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
color_index: int | None = None
|
||||
|
||||
|
||||
def merge_events(
|
||||
sources: list[tuple[str, str]], window_start: date, window_end: date
|
||||
sources: list[CalendarSource], window_start: date, window_end: date
|
||||
) -> tuple[list[dict], str]:
|
||||
"""sources: [(owner_display_name, ics_url), ...]. Fetches each
|
||||
independently -- one broken feed never blanks another's events.
|
||||
Returns (merged_time_sorted_events, fetch_summary); fetch_summary is
|
||||
"" when every source succeeded, else "N of M calendars unavailable"
|
||||
(never *which* source -- naming whose feed is down to everyone who
|
||||
looks at a shared household display is a bigger overshare than the
|
||||
outage itself)."""
|
||||
"""Fetches each source independently -- one broken feed never blanks
|
||||
another's events. Returns (merged_time_sorted_events, fetch_summary);
|
||||
fetch_summary is "" when every source succeeded, else "N of M
|
||||
calendars unavailable" (never *which* source -- naming whose feed is
|
||||
down to everyone who looks at a shared household display is a bigger
|
||||
overshare than the outage itself).
|
||||
|
||||
Events sharing the exact same (summary, start, end, all_day) across
|
||||
different calendars -- e.g. a shared family event synced onto more
|
||||
than one person's calendar -- collapse into one entry rather than
|
||||
showing as duplicate rows. Every merged event carries a "sources"
|
||||
list ([{"owner_display_name", "color_index"}, ...], length 1 for an
|
||||
ordinary non-duplicated event) that calendar_render.py draws a
|
||||
color indicator per entry of, so a collapsed event still visibly
|
||||
shows every calendar it came from."""
|
||||
merged: list[dict] = []
|
||||
by_key: dict[tuple, dict] = {}
|
||||
failures = 0
|
||||
for owner_display_name, url in sources:
|
||||
for source in sources:
|
||||
try:
|
||||
events = fetch_source_events(url, window_start, window_end)
|
||||
except CalendarFetchError:
|
||||
if source.kind == "caldav":
|
||||
events = caldav_client.fetch_calendar_events(
|
||||
source.url, source.username, source.password, window_start, window_end
|
||||
)
|
||||
else:
|
||||
events = fetch_source_events(source.url, window_start, window_end)
|
||||
except (CalendarFetchError, caldav_client.CalDavError):
|
||||
failures += 1
|
||||
continue
|
||||
for event in events:
|
||||
event["owner_display_name"] = owner_display_name
|
||||
merged.append(event)
|
||||
source_entry = {"owner_display_name": source.owner_display_name, "color_index": source.color_index}
|
||||
key = (event["summary"], event["start"], event["end"], event["all_day"])
|
||||
existing = by_key.get(key)
|
||||
if existing is None:
|
||||
event["sources"] = [source_entry]
|
||||
by_key[key] = event
|
||||
merged.append(event)
|
||||
else:
|
||||
existing["sources"].append(source_entry)
|
||||
|
||||
merged.sort(key=lambda e: e["start"])
|
||||
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
|
||||
|
||||
+756
-110
File diff suppressed because it is too large
Load Diff
+31
-1
@@ -16,7 +16,7 @@ from typing import Iterator
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from .models import Frame
|
||||
from .models import WIDGET_CONFIG_MODELS, Frame, Widget
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
|
||||
|
||||
@@ -86,3 +86,33 @@ def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
|
||||
db.refresh(frame)
|
||||
yield frame
|
||||
db.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
|
||||
"""Same lock/refresh/commit dance as frame_locked, additionally
|
||||
resolving and refreshing the widget's own per-type config row
|
||||
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/
|
||||
TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately
|
||||
still locks at *frame* granularity -- the exact same per-frame
|
||||
threading.Lock frame_locked
|
||||
uses, not a separate per-widget lock -- simplest, avoids a new class
|
||||
of multi-lock deadlock bugs, and this project's actual concurrency
|
||||
needs are tiny (a handful of users per household frame).
|
||||
|
||||
threading.Lock is not reentrant: a caller executing several widget
|
||||
actions in one pass (e.g. a button press assigned multiple
|
||||
(widget, action) pairs, see routers/device.py) MUST call this once
|
||||
per action, sequentially, never nested inside an outer
|
||||
frame_locked/widget_locked span for the same frame -- nesting would
|
||||
deadlock instantly, not just misbehave."""
|
||||
with frame_locked(db, frame_id) as frame:
|
||||
widget = db.get(Widget, widget_id)
|
||||
if widget is None or widget.frame_id != frame_id:
|
||||
raise LookupError(f"Widget {widget_id} does not belong to frame {frame_id}")
|
||||
config_model = WIDGET_CONFIG_MODELS[widget.widget_type]
|
||||
config = db.get(config_model, widget_id)
|
||||
if config is None:
|
||||
raise LookupError(f"Widget {widget_id} has no {widget.widget_type} config row")
|
||||
db.refresh(config)
|
||||
yield frame, widget, config
|
||||
|
||||
@@ -25,7 +25,7 @@ MAX_LABELED_FACES = 6
|
||||
|
||||
|
||||
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
|
||||
orientation: str = "landscape") -> list[dict]:
|
||||
orientation: str = "landscape", region: tuple[int, int, int, int] | None = None) -> list[dict]:
|
||||
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
|
||||
logical (pre-rotation) frame space at each named face's bottom-center
|
||||
point -- manage_overlay.compose() draws these directly onto the
|
||||
@@ -38,6 +38,15 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
|
||||
match the settings that were active then -- otherwise the placement
|
||||
computed here won't match what's actually on screen.
|
||||
|
||||
`region` is (x0, y0, w, h): where in the logical canvas the photo
|
||||
actually landed, if not the whole thing -- e.g. a photo widget placed
|
||||
in one corner of the panel rather than full-screen (see
|
||||
routers/common.py's build_manage_content, which passes each photo
|
||||
widget's own placement rect) -- without this a label would be placed
|
||||
as if the photo filled the entire canvas, landing well off where the
|
||||
widget actually is. None (the default) means the photo fills the
|
||||
whole logical canvas.
|
||||
|
||||
The placement math matches render_frame()'s own composition step
|
||||
exactly (see image_pipeline._placement_transform, shared so the two
|
||||
can't drift apart).
|
||||
@@ -46,11 +55,16 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
|
||||
if not named:
|
||||
return []
|
||||
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
if region is None:
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h
|
||||
else:
|
||||
region_x0, region_y0, target_w, target_h = region
|
||||
|
||||
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
|
||||
|
||||
scale_x, scale_y, offset_x, offset_y = _placement_transform(
|
||||
fitted.width, fitted.height, logical_w, logical_h, display_mode, faces
|
||||
fitted.width, fitted.height, target_w, target_h, display_mode, faces
|
||||
)
|
||||
|
||||
labels = []
|
||||
@@ -65,12 +79,16 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
|
||||
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
|
||||
bottom_y = face["boundingBoxY2"] * img_scale_y
|
||||
|
||||
frame_x = center_x * scale_x + offset_x
|
||||
frame_y = bottom_y * scale_y + offset_y
|
||||
# Relative to the region's own origin first (matches
|
||||
# _placement_transform's target_w/target_h space), then shifted
|
||||
# into full-canvas coordinates.
|
||||
region_x = center_x * scale_x + offset_x
|
||||
region_y = bottom_y * scale_y + offset_y
|
||||
|
||||
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
|
||||
continue # this face got cropped out of the final frame entirely
|
||||
if not (0 <= region_x <= target_w and 0 <= region_y <= target_h):
|
||||
continue # this face got cropped out of the region entirely
|
||||
|
||||
labels.append({"name": face["person"]["name"], "x": int(frame_x), "y": int(frame_y)})
|
||||
labels.append({"name": face["person"]["name"],
|
||||
"x": int(region_x + region_x0), "y": int(region_y + region_y0)})
|
||||
|
||||
return labels
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2013 Google LLC
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Snap-to-grid placement math for widgets (see models.Widget) -- pure,
|
||||
no I/O, no ORM.
|
||||
|
||||
The grid is defined relative to the panel's long/short axis, not
|
||||
landscape/portrait specifically, so it stays valid across
|
||||
image_pipeline.logical_render_size(orientation)'s genuine width/height
|
||||
swap for portrait (not just a rotation applied at the very end) --
|
||||
landscape orientations are GRID_LONG columns x GRID_SHORT rows, portrait
|
||||
orientations are GRID_SHORT columns x GRID_LONG rows, same cell size
|
||||
either way. Changing a frame's orientation therefore invalidates any
|
||||
existing widget layout (an 8x5 arrangement isn't valid on a 5x8 grid) --
|
||||
callers are expected to reset to one full-panel widget on an orientation
|
||||
change, not try to remap coordinates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
GRID_LONG = 8
|
||||
GRID_SHORT = 5
|
||||
|
||||
# Per-widget-type minimum grid footprint (cols, rows) -- enforced both in
|
||||
# the placement UI and server-side (routers/api_widgets.py). A calendar
|
||||
# widget crammed into 1x1 would be illegible regardless of size-tier
|
||||
# scaling (see calendar_render.py); whiteboard needs enough room to be
|
||||
# worth looking at; photos can go as small as a single cell; tasks needs
|
||||
# enough width for a due-date prefix plus a couple words of summary
|
||||
# without truncating on every row.
|
||||
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||
"photos": (1, 1),
|
||||
"calendar": (3, 2),
|
||||
"whiteboard": (2, 2),
|
||||
"tasks": (2, 2),
|
||||
"static": (1, 1),
|
||||
"text": (2, 1),
|
||||
}
|
||||
|
||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
||||
|
||||
|
||||
def grid_dims(orientation: str) -> tuple[int, int]:
|
||||
"""(cols, rows) for this orientation."""
|
||||
if orientation in ("portrait", "portrait_flipped"):
|
||||
return GRID_SHORT, GRID_LONG
|
||||
return GRID_LONG, GRID_SHORT
|
||||
|
||||
|
||||
def full_panel_rect(orientation: str) -> Rect:
|
||||
"""The single full-panel widget rect for this orientation -- what a
|
||||
frame gets reset to whenever its layout can't carry over (initial
|
||||
migration backfill, an orientation change)."""
|
||||
cols, rows = grid_dims(orientation)
|
||||
return (0, 0, cols, rows)
|
||||
|
||||
|
||||
def in_bounds(orientation: str, rect: Rect) -> bool:
|
||||
cols, rows = grid_dims(orientation)
|
||||
x, y, w, h = rect
|
||||
return x >= 0 and y >= 0 and w > 0 and h > 0 and x + w <= cols and y + h <= rows
|
||||
|
||||
|
||||
def meets_minimum(widget_type: str, rect: Rect) -> bool:
|
||||
min_w, min_h = MIN_FOOTPRINT.get(widget_type, (1, 1))
|
||||
_, _, w, h = rect
|
||||
return w >= min_w and h >= min_h
|
||||
|
||||
|
||||
def overlaps(a: Rect, b: Rect) -> bool:
|
||||
ax, ay, aw, ah = a
|
||||
bx, by, bw, bh = b
|
||||
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
|
||||
|
||||
|
||||
def find_open_rect(orientation: str, existing: list[Rect], w: int, h: int) -> Rect | None:
|
||||
"""First w x h rect that's in-bounds and doesn't overlap any of
|
||||
`existing`, scanning row-major (top-left first) -- used when creating
|
||||
a widget without an explicit placement (see routers/api_widgets.py),
|
||||
so adding one from a type picker doesn't require the caller to find
|
||||
empty space itself first. None if no such rect fits anywhere."""
|
||||
cols, rows = grid_dims(orientation)
|
||||
for y in range(rows - h + 1):
|
||||
for x in range(cols - w + 1):
|
||||
candidate = (x, y, w, h)
|
||||
if not any(overlaps(candidate, other) for other in existing):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def cell_to_pixels(orientation: str, panel_w: int, panel_h: int, rect: Rect) -> tuple[int, int, int, int]:
|
||||
"""Grid rect -> pixel rect in logical (pre-rotation) canvas space --
|
||||
against image_pipeline.logical_render_size(orientation)'s own
|
||||
(panel_w, panel_h), the same space every renderer already composes
|
||||
in before the final orientation transpose."""
|
||||
cols, rows = grid_dims(orientation)
|
||||
cell_w = panel_w / cols
|
||||
cell_h = panel_h / rows
|
||||
x, y, w, h = rect
|
||||
px, py = round(x * cell_w), round(y * cell_h)
|
||||
# Snap the far edge to the next cell boundary rather than compounding
|
||||
# per-cell rounding error across w/h -- keeps adjacent widgets'
|
||||
# shared edge pixel-exact instead of leaving a stray gap/overlap.
|
||||
px2, py2 = round((x + w) * cell_w), round((y + h) * cell_h)
|
||||
return (px, py, px2 - px, py2 - py)
|
||||
@@ -4,11 +4,34 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageOps
|
||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
||||
|
||||
EPD_WIDTH = 800
|
||||
EPD_HEIGHT = 480
|
||||
|
||||
# PIL's TrueType rendering antialiases by default (graduated gray edge
|
||||
# pixels). Those survive straight into _quantize's Floyd-Steinberg
|
||||
# dithering, which -- confirmed visually -- turns them into scattered
|
||||
# colored speckles along every glyph edge once forced onto the panel's 6
|
||||
# colors, since a mid-gray input has no close palette match and the
|
||||
# diffused error bounces between whichever colors are nearest. Drawing
|
||||
# through a thresholded bilevel mask instead keeps every edge pure
|
||||
# black/white, which _quantize then reproduces exactly (both are already
|
||||
# palette colors, nothing to dither). Shared by every module that draws
|
||||
# text before quantization (this file's render_placeholder,
|
||||
# calendar_render.py, manage_overlay.py).
|
||||
_TEXT_MASK_THRESHOLD = 110
|
||||
|
||||
|
||||
def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.ImageFont,
|
||||
fill: tuple[int, int, int] = (0, 0, 0)) -> None:
|
||||
bbox = font.getbbox(text)
|
||||
w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
|
||||
mask = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), text, fill=255, font=font)
|
||||
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
|
||||
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
|
||||
|
||||
# How each orientation maps the logically-composed image onto the native
|
||||
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
||||
# crop ratio matches how the frame actually hangs) and rotate into native
|
||||
@@ -197,6 +220,13 @@ DISPLAY_MODE_LABELS = {
|
||||
DEFAULT_DISPLAY_MODE = "crop_faces"
|
||||
LETTERBOX_BG = (255, 255, 255)
|
||||
|
||||
# Static-image widget only offers a subset of DISPLAY_MODES -- no face
|
||||
# detection for an uploaded image, so "crop_faces" (which silently falls
|
||||
# back to crop_fill anyway, see compose_into) would just be a confusing
|
||||
# duplicate entry in that dialog's dropdown.
|
||||
STATIC_DISPLAY_MODES = ["crop_fill", "stretch_fill", "letterbox"]
|
||||
DEFAULT_STATIC_DISPLAY_MODE = "crop_fill"
|
||||
|
||||
|
||||
def _placement_transform(
|
||||
img_width: int, img_height: int, target_w: int, target_h: int,
|
||||
@@ -358,6 +388,56 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
|
||||
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
|
||||
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
|
||||
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False) -> bytes:
|
||||
"""The widget system's compositor -- generalizes render_frame's tail
|
||||
(paste, enhance once, overlay once, quantize once, pack once) from
|
||||
"compose one photo" to "paste N already-rendered regions, then run
|
||||
the same single shared pipeline over the result." Not a
|
||||
restructuring: the calendar mode's old photo-inlay feature already
|
||||
pasted a second, independently-composed image onto the canvas before
|
||||
`_enhance`/`_quantize` ran exactly once over the whole thing -- this
|
||||
just generalizes that from a fixed 1-2 region split to an arbitrary
|
||||
list.
|
||||
|
||||
Each region is (rect, image): rect is (x, y, w, h) in *logical*
|
||||
(pre-rotation) canvas space -- the same space logical_render_size(
|
||||
orientation) describes, and what app/grid.py's cell_to_pixels()
|
||||
produces -- and image is an already-composed RGB image exactly w x h
|
||||
in size (e.g. from compose_into() for a photo/whiteboard widget, or
|
||||
calendar_render's own builder for a calendar widget). Regions are
|
||||
expected not to overlap (see models.Widget's docstring on why) --
|
||||
this function doesn't enforce that itself, callers/the placement API
|
||||
do, since by the time rendering happens it's too late to do anything
|
||||
but paste in whatever order they're given (later entries would just
|
||||
paint over earlier ones).
|
||||
|
||||
Quantizing/dithering the *whole* composited canvas once, rather than
|
||||
each region separately before pasting, is what keeps a 6-color
|
||||
e-ink panel's dithering pattern consistent across a widget boundary
|
||||
instead of showing a visible seam where two independently-dithered
|
||||
regions meet.
|
||||
|
||||
as_png=True returns a normal browser-viewable PNG in logical (upright)
|
||||
orientation instead of packed native-panel bytes, same convention as
|
||||
render_preview_png -- used for the web UI's live "how it's displaying"
|
||||
thumbnail."""
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
||||
for (x, y, w, h), region_img in regions:
|
||||
canvas.paste(region_img.convert("RGB"), (x, y))
|
||||
|
||||
fitted = _enhance(canvas, color_boost, contrast_boost)
|
||||
fitted = _apply_manage_overlay(fitted, manage)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
if as_png:
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
|
||||
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
||||
@@ -378,7 +458,7 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||
|
||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||
manage: dict | None = None) -> bytes:
|
||||
manage: dict | None = None, as_png: bool = False) -> bytes:
|
||||
"""A readable full-panel message (plus an optional QR code) in the
|
||||
same packed format as render_frame -- what /frame/image serves for a
|
||||
frame that isn't claimed or configured yet, so a fresh device shows
|
||||
@@ -387,14 +467,14 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
`manage`, same as render_frame's -- lets the manage button still work
|
||||
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
||||
yet."""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
margin = 24
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw = ImageDraw.Draw(img) # measurement only (textbbox/textlength) -- painting goes through draw_text
|
||||
|
||||
title_font = ImageFont.load_default(size=34)
|
||||
body_font = ImageFont.load_default(size=24)
|
||||
max_text_w = logical_w - margin * 2
|
||||
|
||||
qr_img = None
|
||||
if qr_url:
|
||||
@@ -409,19 +489,39 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
scale = max(1, target // raw.width)
|
||||
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
||||
|
||||
# Word-wrap each input line to the panel's actual width (portrait is
|
||||
# much narrower than landscape -- a line written assuming ~800px
|
||||
# would otherwise run straight off the edge) before laying anything
|
||||
# out, so wrapped sub-lines count toward the vertical centering below.
|
||||
def wrap(text: str, font) -> list[str]:
|
||||
words = text.split()
|
||||
if not words:
|
||||
return [text]
|
||||
out, current = [], words[0]
|
||||
for word in words[1:]:
|
||||
candidate = f"{current} {word}"
|
||||
if draw.textlength(candidate, font=font) <= max_text_w:
|
||||
current = candidate
|
||||
else:
|
||||
out.append(current)
|
||||
current = word
|
||||
out.append(current)
|
||||
return out
|
||||
|
||||
# Vertical layout: text block, then QR under it, centered as a group.
|
||||
line_heights = []
|
||||
for i, line in enumerate(lines):
|
||||
font = title_font if i == 0 else body_font
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
|
||||
for sub_line in wrap(line, font):
|
||||
bbox = draw.textbbox((0, 0), sub_line, font=font)
|
||||
line_heights.append((sub_line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
|
||||
gap = 14
|
||||
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
|
||||
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
|
||||
y = max(20, (logical_h - total_h) // 2)
|
||||
|
||||
for line, font, w, h in line_heights:
|
||||
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font)
|
||||
draw_text(img, ((logical_w - w) // 2, y), line, font)
|
||||
y += h + gap
|
||||
|
||||
if qr_img:
|
||||
@@ -429,4 +529,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
if as_png:
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Decodes an arbitrary uploaded file (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF/...)
|
||||
into a plain RGB PIL image, for the static-image widget (see routers/
|
||||
api_widgets.py's api_widget_static_upload, app/widgets/static_image.py).
|
||||
The result is stored (as PNG bytes) rather than the original upload, so
|
||||
render() never needs to re-run PDF/GIF decoding on every panel refresh --
|
||||
this module only runs once, at upload time.
|
||||
|
||||
PDF decoding uses pypdfium2 (Google's PDFium bindings -- BSD-3-Clause/
|
||||
Apache-2.0, no copyleft exposure) rather than a GPL/AGPL alternative
|
||||
like PyMuPDF, per CLAUDE.md's copyleft-dependency convention (a check
|
||||
that only applies to copyleft/unclear licenses -- this one's plainly
|
||||
permissive, so no explicit flag was needed here)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pypdfium2 as pdfium
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # generous for a single image/PDF page; stops an accidental huge upload
|
||||
# ~144 DPI off a PDF's 72-DPI native unit -- comfortably above the panel's
|
||||
# own 800x480, without ballooning render time/memory on a poster-sized page.
|
||||
PDF_RENDER_SCALE = 2.0
|
||||
|
||||
|
||||
def decode_upload(data: bytes) -> Image.Image:
|
||||
"""Raises HTTPException(400) for anything that isn't a recognizable
|
||||
image or PDF. Detects PDF by magic bytes, not the client-supplied
|
||||
filename/content-type (neither is trustworthy). A PDF renders only
|
||||
its first page -- there's no "which page" concept for a single-image
|
||||
widget."""
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(400, f"File is too large (max {MAX_UPLOAD_BYTES // (1024 * 1024)}MB)")
|
||||
if data.startswith(b"%PDF-"):
|
||||
return _decode_pdf(data)
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.load()
|
||||
except UnidentifiedImageError:
|
||||
raise HTTPException(400, "Not a recognizable image or PDF file") from None
|
||||
return img.convert("RGB")
|
||||
|
||||
|
||||
def _decode_pdf(data: bytes) -> Image.Image:
|
||||
try:
|
||||
pdf = pdfium.PdfDocument(data)
|
||||
if len(pdf) == 0:
|
||||
raise HTTPException(400, "PDF has no pages")
|
||||
bitmap = pdf[0].render(scale=PDF_RENDER_SCALE)
|
||||
except pdfium.PdfiumError as e:
|
||||
raise HTTPException(400, f"Could not read PDF: {e}") from e
|
||||
return bitmap.to_pil().convert("RGB")
|
||||
+4
-2
@@ -4,7 +4,8 @@ for the panel, and serves ESP32 frames ready-to-display images.
|
||||
This module is assembly only -- routes live in app/routers/:
|
||||
device.py the firmware-facing /frame/* protocol (paths frozen)
|
||||
api_frames.py the web UI's JSON API, /api/frames/{id}/...
|
||||
frame_pages.py the per-frame Photos/Configuration/Stats pages
|
||||
api_widgets.py widget CRUD + grid placement, /api/frames/{id}/widgets
|
||||
frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages
|
||||
pages.py setup/login/claim/settings/admin
|
||||
manage.py the limited manage-QR surface (/m/, /api/m/)
|
||||
Storage is SQLite via models.py/db.py; migration.py imports a
|
||||
@@ -30,7 +31,7 @@ from .auth import (
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .models import Frame
|
||||
from .routers import api_frames, device, frame_pages, manage, pages
|
||||
from .routers import api_frames, api_widgets, device, frame_pages, manage, pages
|
||||
from .routers.common import shell_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,6 +46,7 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.include_router(device.router)
|
||||
app.include_router(api_frames.router)
|
||||
app.include_router(api_widgets.router)
|
||||
app.include_router(frame_pages.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(manage.router)
|
||||
|
||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text
|
||||
|
||||
PADDING = 16
|
||||
QR_TEXT_GAP = 8
|
||||
LINE_GAP = 4
|
||||
@@ -62,13 +64,13 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image
|
||||
return w, h
|
||||
|
||||
|
||||
def _draw_centered_lines(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
|
||||
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
|
||||
center_x: int, top: int) -> None:
|
||||
y = top
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
w = bbox[2] - bbox[0]
|
||||
draw.text((center_x - w // 2, y), line, fill=(0, 0, 0), font=font)
|
||||
draw_text(img, (center_x - w // 2, y), line, font)
|
||||
y += (bbox[3] - bbox[1]) + LINE_GAP
|
||||
|
||||
|
||||
@@ -92,7 +94,7 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
|
||||
center_x = x0 + w // 2
|
||||
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
||||
if caption:
|
||||
_draw_centered_lines(draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
||||
_draw_centered_lines(img, draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
||||
return x0, y0, w, h
|
||||
|
||||
|
||||
@@ -106,7 +108,7 @@ def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str]
|
||||
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
||||
|
||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
||||
_draw_centered_lines(draw, lines, font, x0 + w // 2, y0 + PADDING)
|
||||
_draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
|
||||
|
||||
|
||||
def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]:
|
||||
@@ -121,12 +123,33 @@ def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner:
|
||||
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
|
||||
|
||||
|
||||
# DEFAULT_PALETTE_RGB order is [BLACK, WHITE, YELLOW, RED, BLUE, GREEN]
|
||||
# (see image_pipeline.PANEL_CODES) -- picked by level so the fill itself
|
||||
# carries the "how worried should I be" signal, not just the number next
|
||||
# to it. Thresholds match the low-battery-alert spirit elsewhere in this
|
||||
# project (not tied to a frame's own configured alert threshold, since
|
||||
# this glyph has to make sense with no configuration at all).
|
||||
_BATTERY_LOW = DEFAULT_PALETTE_RGB[3] # red
|
||||
_BATTERY_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
|
||||
_BATTERY_HIGH = DEFAULT_PALETTE_RGB[5] # green
|
||||
|
||||
|
||||
def _battery_fill_color(percent: int) -> tuple[int, int, int]:
|
||||
if percent <= 15:
|
||||
return _BATTERY_LOW
|
||||
if percent <= 40:
|
||||
return _BATTERY_MEDIUM
|
||||
return _BATTERY_HIGH
|
||||
|
||||
|
||||
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
|
||||
anchor_w: int, anchor_h: int) -> None:
|
||||
"""Battery glyph + "NN%" text, right-aligned under the given anchor
|
||||
box (the manage QR box) -- a sensible default position, not a
|
||||
constraint anything else has to route around; move this call site's
|
||||
arguments to place it anywhere else instead."""
|
||||
"""Battery glyph (now actually filled to `percent`, not just a static
|
||||
outline -- easy now that this renders server-side instead of being a
|
||||
fixed bitmap firmware drew) + "NN%" text, right-aligned under the
|
||||
given anchor box (the manage QR box) -- a sensible default position,
|
||||
not a constraint anything else has to route around; move this call
|
||||
site's arguments to place it anywhere else instead."""
|
||||
font = _font(BODY_FONT_SIZE)
|
||||
text = f"{percent}%"
|
||||
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
|
||||
@@ -143,13 +166,18 @@ def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anc
|
||||
|
||||
icon_x = x0 + PADDING
|
||||
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
|
||||
inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE
|
||||
inner_x1, inner_y1 = icon_x + BATTERY_ICON_W - BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_H - BATTERY_ICON_STROKE
|
||||
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (percent / 100))
|
||||
if fill_x1 > inner_x0:
|
||||
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_battery_fill_color(percent))
|
||||
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
|
||||
width=BATTERY_ICON_STROKE)
|
||||
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
|
||||
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
|
||||
fill=(0, 0, 0))
|
||||
draw.text((icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
||||
text, fill=(0, 0, 0), font=font)
|
||||
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
||||
text, font)
|
||||
|
||||
|
||||
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
|
||||
@@ -174,7 +202,7 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
|
||||
y0 = max(0, min(y0, img_h - h))
|
||||
|
||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
||||
draw.text((x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, fill=(0, 0, 0), font=font)
|
||||
draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
|
||||
|
||||
|
||||
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
|
||||
|
||||
+681
-3
@@ -15,11 +15,23 @@ import secrets
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import inspect, select, text
|
||||
|
||||
from . import config
|
||||
from . import config, grid
|
||||
from .db import SessionLocal, engine
|
||||
from .models import Base, BatteryLog, Frame, ServerSettings
|
||||
from .models import (
|
||||
Base,
|
||||
BatteryLog,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
ServerSettings,
|
||||
TaskWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -100,6 +112,413 @@ def _migration_7(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
|
||||
|
||||
|
||||
def _migration_8(conn) -> None:
|
||||
"""Configurable week-start day for calendar mode's week/month views
|
||||
(0=Monday..6=Sunday, matching Python's date.weekday()/calendar.Calendar
|
||||
convention exactly -- no translation needed at render time). Default 0
|
||||
(Monday) matches calendar_render.py's previous hardcoded behavior, so
|
||||
this is a no-op for every existing frame until changed."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start INTEGER NOT NULL DEFAULT 0"))
|
||||
|
||||
|
||||
|
||||
def _migration_9(conn) -> None:
|
||||
"""CalDAV support alongside the plain ICS subscription (see
|
||||
caldav_client.py), and the frame_calendars table that replaces
|
||||
user_frames.calendar_included now that one account (CalDAV) can
|
||||
expose more than one calendar -- see models.py's FrameCalendar.
|
||||
Existing single-calendar opt-ins are carried forward as "ics" rows
|
||||
before the old column is dropped, so nobody's frame goes silently
|
||||
calendar-less after this migration."""
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_url TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_username TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_password TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_calendars TEXT"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_checked_at REAL NOT NULL DEFAULT 0.0"))
|
||||
|
||||
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)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
|
||||
"SELECT uf.frame_id, uf.user_id, 'ics', 'My calendar', 1 "
|
||||
"FROM user_frames uf JOIN users u ON u.id = uf.user_id "
|
||||
"WHERE uf.calendar_included = 1 AND u.calendar_ics_url != ''"
|
||||
))
|
||||
conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included"))
|
||||
|
||||
|
||||
def _migration_10(conn) -> None:
|
||||
"""Optional weather strip for calendar mode (agenda/today & tomorrow/
|
||||
week views -- never month, see calendar_render.py's _BUILDERS).
|
||||
Multiple cities per frame (calendar_weather_cities), each geocoded
|
||||
once via weather.py's Open-Meteo lookup (no API key) and their daily
|
||||
forecasts refreshed on their own throttle, same shape idiom as
|
||||
calendar_checked_at/calendar_cached_events. Off by default -- no
|
||||
existing frame's render changes until its Calendar tab turns it on."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_enabled INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cities TEXT"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cached TEXT"))
|
||||
|
||||
|
||||
def _migration_11(conn) -> None:
|
||||
"""Manual per-calendar color choice (frame_calendars.color_index,
|
||||
2-5 into image_pipeline.DEFAULT_PALETTE_RGB -- Yellow/Red/Blue/
|
||||
Green). calendar_render.py's event color bar/dot used to auto-cycle
|
||||
through those same four colors in whatever order calendars happened
|
||||
to appear; this lets a household pin a specific one instead so it
|
||||
stays stable and recognizable. NULL (the default) keeps the old
|
||||
auto-cycle behavior -- no existing frame's render changes until
|
||||
someone actually picks a color."""
|
||||
conn.execute(text("ALTER TABLE frame_calendars ADD COLUMN color_index INTEGER"))
|
||||
|
||||
|
||||
def _migration_12(conn) -> None:
|
||||
"""Week view flexibility: a configurable day count (2-10, default 7
|
||||
-- the original fixed behavior) and a horizontal/vertical layout
|
||||
choice, plus an optional CalDAV task list that takes the space of
|
||||
one day slot when enabled (see calendar_render.py's _build_week/
|
||||
_draw_tasks). Every new column has a behavior-preserving default --
|
||||
no existing frame's render changes until its Calendar tab touches
|
||||
one of these."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_days INTEGER NOT NULL DEFAULT 7"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_calendar_key TEXT"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_cached TEXT"))
|
||||
|
||||
|
||||
def _migration_13(conn) -> None:
|
||||
"""A day-count-relative start offset for the week view
|
||||
(calendar_week_start_offset), used instead of calendar_week_start's
|
||||
fixed-weekday anchor once the view isn't a literal 7-day week --
|
||||
"start on the most recent Monday" stops meaning much for e.g. a
|
||||
5-day view. Default 0 (starts today) is a behavior-preserving no-op
|
||||
until someone changes the day count away from 7."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0"))
|
||||
|
||||
|
||||
def _migration_14(conn) -> None:
|
||||
"""Whiteboard frame mode: generic WebDAV credentials per user
|
||||
(webdav_username/password, plus webdav_reuse_caldav_creds as a
|
||||
convenience when it's the same Nextcloud account as an already-
|
||||
configured CalDAV one -- see models.py's User docstring), and the
|
||||
frame-level whiteboard source (whiteboard_user_id/url) + rendered-
|
||||
PNG cache (see webdav_client.py, whiteboard.py,
|
||||
routers/device.py's RENDERERS["whiteboard"]). Every new column has a
|
||||
behavior-preserving default -- no existing frame's render changes
|
||||
until its mode is actually switched to "whiteboard"."""
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_username TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_password TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_reuse_caldav_creds INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_url TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_checked_at REAL NOT NULL DEFAULT 0.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB"))
|
||||
|
||||
|
||||
def _migration_15(conn) -> None:
|
||||
"""Optional starting folder for the whiteboard file-picker (see
|
||||
models.py's User.webdav_base_url docstring) -- purely a browsing
|
||||
convenience, never used for actual fetch/render."""
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_base_url TEXT NOT NULL DEFAULT ''"))
|
||||
|
||||
|
||||
def _migration_16(conn) -> None:
|
||||
"""Widget system: a frame can now hold N independently placed/sized
|
||||
widgets (photos/calendar/whiteboard) instead of exactly one mode-wide
|
||||
renderer -- see models.py's Widget/PhotoWidgetConfig/
|
||||
CalendarWidgetConfig/WhiteboardWidgetConfig/FrameButtonAction,
|
||||
app/grid.py, app/widgets/.
|
||||
|
||||
This migration only creates the new (empty) tables -- it does NOT
|
||||
backfill a widget per existing frame here. That backfill (reading
|
||||
each frame's current mode/settings to build a widget that reproduces
|
||||
its exact current display, including the calendar_photo_inlay ->
|
||||
two-widgets special case) is real per-mode branching logic that's
|
||||
much less error-prone written as typed ORM object construction than
|
||||
as hand-written column-by-column SQL -- see _ensure_widgets_backfilled,
|
||||
called unconditionally at the end of run_migrations() for both this
|
||||
upgrade path AND the from-scratch _ensure_frame_one() path, so both
|
||||
produce the same default-widget invariant from one place rather than
|
||||
two separately-maintained ones. Every existing frame is briefly
|
||||
widget-less between this migration and that call within the same
|
||||
startup, not across restarts -- nothing reads these tables yet at
|
||||
that point regardless."""
|
||||
conn.execute(text(
|
||||
"CREATE TABLE widgets ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
||||
"widget_type TEXT NOT NULL, "
|
||||
"x INTEGER NOT NULL, "
|
||||
"y INTEGER NOT NULL, "
|
||||
"w INTEGER NOT NULL, "
|
||||
"h INTEGER NOT NULL, "
|
||||
"sort_order INTEGER NOT NULL DEFAULT 0, "
|
||||
"created_at REAL NOT NULL DEFAULT 0.0)"
|
||||
))
|
||||
conn.execute(text("CREATE INDEX ix_widgets_frame ON widgets (frame_id)"))
|
||||
|
||||
conn.execute(text(
|
||||
"CREATE TABLE photo_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"album_id TEXT NOT NULL DEFAULT '', "
|
||||
"photo_order TEXT NOT NULL DEFAULT 'sequential', "
|
||||
"display_mode TEXT NOT NULL DEFAULT 'crop_faces', "
|
||||
"queue_target_len INTEGER NOT NULL DEFAULT 20, "
|
||||
"current_asset_id TEXT NOT NULL DEFAULT '', "
|
||||
"current_asset_set_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"queue TEXT NOT NULL DEFAULT '[]', "
|
||||
"queue_cursor INTEGER NOT NULL DEFAULT 0, "
|
||||
"history TEXT NOT NULL DEFAULT '[]', "
|
||||
"excluded_asset_ids TEXT NOT NULL DEFAULT '[]')"
|
||||
))
|
||||
|
||||
conn.execute(text(
|
||||
"CREATE TABLE calendar_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"view TEXT NOT NULL DEFAULT 'agenda', "
|
||||
"week_start INTEGER NOT NULL DEFAULT 0, "
|
||||
"browse_offset INTEGER NOT NULL DEFAULT 0, "
|
||||
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"cached_events TEXT, "
|
||||
"fetch_summary TEXT NOT NULL DEFAULT '', "
|
||||
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
|
||||
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
||||
"weather_cities TEXT, "
|
||||
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"weather_cached TEXT, "
|
||||
"week_days INTEGER NOT NULL DEFAULT 7, "
|
||||
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
|
||||
"week_start_offset INTEGER NOT NULL DEFAULT 0, "
|
||||
"tasks_enabled INTEGER NOT NULL DEFAULT 0, "
|
||||
"tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||
"tasks_calendar_key TEXT, "
|
||||
"tasks_checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"tasks_cached TEXT)"
|
||||
))
|
||||
|
||||
conn.execute(text(
|
||||
"CREATE TABLE whiteboard_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||
"url TEXT NOT NULL DEFAULT '', "
|
||||
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"cached_image BLOB)"
|
||||
))
|
||||
|
||||
conn.execute(text(
|
||||
"CREATE TABLE frame_button_actions ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
||||
"button TEXT NOT NULL, "
|
||||
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"action TEXT NOT NULL, "
|
||||
"sort_order INTEGER NOT NULL DEFAULT 0, "
|
||||
"created_at REAL NOT NULL DEFAULT 0.0)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE INDEX ix_frame_button_actions_frame_button ON frame_button_actions (frame_id, button, sort_order)"
|
||||
))
|
||||
|
||||
|
||||
def _migration_17(conn) -> None:
|
||||
"""Splits the calendar widget's old week-view-only task list out into
|
||||
its own standalone widget type (see models.TaskWidgetConfig,
|
||||
app/widgets/tasks.py) -- a task list is no longer tied to a
|
||||
calendar's view or footprint, and can be placed/sized on its own.
|
||||
|
||||
Every calendar_widget_configs row that still has a task source
|
||||
configured gets a new sibling `tasks` widget carrying that source
|
||||
over, auto-placed in whatever open grid space is left on its frame
|
||||
(same find_open_rect logic a manual "add widget" uses; if truly none
|
||||
is left, the source is dropped and logged -- rare enough, and with
|
||||
no interactive way to ask during a boot-time migration, that this is
|
||||
an acceptable edge case). calendar_widget_configs then drops its now
|
||||
-dead tasks_* columns -- this project's usual same-migration-drop
|
||||
convention (see docs/widgets.md's Known Gaps for the one deliberate,
|
||||
much-larger-blast-radius exception)."""
|
||||
conn.execute(text(
|
||||
"CREATE TABLE task_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||
"calendar_key TEXT, "
|
||||
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"cached TEXT)"
|
||||
))
|
||||
|
||||
rows = conn.execute(text(
|
||||
"SELECT cwc.widget_id, w.frame_id, f.orientation, "
|
||||
"cwc.tasks_user_id, cwc.tasks_calendar_key, cwc.tasks_checked_at, cwc.tasks_cached "
|
||||
"FROM calendar_widget_configs cwc "
|
||||
"JOIN widgets w ON w.id = cwc.widget_id "
|
||||
"JOIN frames f ON f.id = w.frame_id "
|
||||
"WHERE cwc.tasks_calendar_key IS NOT NULL"
|
||||
)).mappings().all()
|
||||
|
||||
skipped = 0
|
||||
now = time.time()
|
||||
for row in rows:
|
||||
existing = conn.execute(text(
|
||||
"SELECT x, y, w, h FROM widgets WHERE frame_id = :frame_id"
|
||||
), {"frame_id": row["frame_id"]}).all()
|
||||
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
||||
rect = grid.find_open_rect(row["orientation"], [tuple(r) for r in existing], min_w, min_h)
|
||||
if rect is None:
|
||||
skipped += 1
|
||||
continue
|
||||
x, y, w, h = rect
|
||||
max_sort = conn.execute(text(
|
||||
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
|
||||
), {"frame_id": row["frame_id"]}).scalar()
|
||||
result = conn.execute(text(
|
||||
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) "
|
||||
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)"
|
||||
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
|
||||
"sort_order": max_sort + 1, "created_at": now})
|
||||
new_widget_id = result.lastrowid
|
||||
conn.execute(text(
|
||||
"INSERT INTO task_widget_configs (widget_id, user_id, calendar_key, checked_at, cached) "
|
||||
"VALUES (:widget_id, :user_id, :calendar_key, :checked_at, :cached)"
|
||||
), {"widget_id": new_widget_id, "user_id": row["tasks_user_id"],
|
||||
"calendar_key": row["tasks_calendar_key"], "checked_at": row["tasks_checked_at"],
|
||||
"cached": row["tasks_cached"]})
|
||||
|
||||
if skipped:
|
||||
logger.warning(
|
||||
"%d calendar widget(s) had a task list configured but no open grid space for a "
|
||||
"standalone tasks widget -- their task source was dropped", skipped
|
||||
)
|
||||
|
||||
# Rebuild calendar_widget_configs without the now-dead tasks_*
|
||||
# columns -- SQLite can't drop tasks_user_id directly (it's part of
|
||||
# an FK constraint), same situation frame_calendars hit in
|
||||
# _migration_9, same rebuild-create-copy-drop-rename fix.
|
||||
conn.execute(text(
|
||||
"CREATE TABLE calendar_widget_configs_new ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"view TEXT NOT NULL DEFAULT 'agenda', "
|
||||
"week_start INTEGER NOT NULL DEFAULT 0, "
|
||||
"browse_offset INTEGER NOT NULL DEFAULT 0, "
|
||||
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"cached_events TEXT, "
|
||||
"fetch_summary TEXT NOT NULL DEFAULT '', "
|
||||
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
|
||||
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
||||
"weather_cities TEXT, "
|
||||
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"weather_cached TEXT, "
|
||||
"week_days INTEGER NOT NULL DEFAULT 7, "
|
||||
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
|
||||
"week_start_offset INTEGER NOT NULL DEFAULT 0)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"INSERT INTO calendar_widget_configs_new "
|
||||
"(widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
|
||||
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
|
||||
"week_days, week_layout, week_start_offset) "
|
||||
"SELECT widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
|
||||
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
|
||||
"week_days, week_layout, week_start_offset "
|
||||
"FROM calendar_widget_configs"
|
||||
))
|
||||
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
||||
conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs"))
|
||||
|
||||
|
||||
def _migration_18(conn) -> None:
|
||||
"""A tasks widget can now merge more than one person's CalDAV task
|
||||
list, checkbox-included with an optional pinned color each -- same
|
||||
multi-source shape calendar widgets already have (models.
|
||||
FrameCalendar), rather than the single user_id/calendar_key pair
|
||||
migration 17 gave TaskWidgetConfig when tasks first became their own
|
||||
widget type. Also adds show_completed (see caldav_client.
|
||||
fetch_tasks' completed_since -- off by default, so this migration
|
||||
changes no widget's on-panel appearance by itself).
|
||||
|
||||
Each task_widget_configs row's existing single source, if any,
|
||||
carries forward as that widget's first frame_task_lists row
|
||||
(included) before the now-dead user_id/calendar_key columns are
|
||||
dropped -- same "carry forward the old single opt-in as a row before
|
||||
dropping the column" shape _migration_9 used for frame_calendars."""
|
||||
conn.execute(text(
|
||||
"CREATE TABLE frame_task_lists ("
|
||||
"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(
|
||||
"CREATE UNIQUE INDEX ix_frame_task_lists_unique ON frame_task_lists (widget_id, user_id, calendar_key)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
|
||||
"SELECT widget_id, user_id, calendar_key, 1 FROM task_widget_configs "
|
||||
"WHERE calendar_key IS NOT NULL AND user_id IS NOT NULL"
|
||||
))
|
||||
|
||||
conn.execute(text(
|
||||
"CREATE TABLE task_widget_configs_new ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"cached TEXT, "
|
||||
"show_completed INTEGER NOT NULL DEFAULT 0)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"INSERT INTO task_widget_configs_new (widget_id, checked_at, cached) "
|
||||
"SELECT widget_id, checked_at, cached FROM task_widget_configs"
|
||||
))
|
||||
conn.execute(text("DROP TABLE task_widget_configs"))
|
||||
conn.execute(text("ALTER TABLE task_widget_configs_new RENAME TO task_widget_configs"))
|
||||
|
||||
|
||||
def _migration_19(conn) -> None:
|
||||
"""Optional custom on-panel name for a tasks widget (see
|
||||
calendar_render._draw_tasks), replacing the default "Tasks" header
|
||||
-- the only widget type with its own on-panel title at all, since
|
||||
it's the only one where "which list is this" isn't already obvious
|
||||
from its content. "" (the default) keeps the old hardcoded text, so
|
||||
this changes no existing widget's appearance by itself. Plain
|
||||
column add, no FK/index involved -- no rebuild-table dance needed
|
||||
(unlike task_widget_configs' two previous migrations)."""
|
||||
conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN name TEXT NOT NULL DEFAULT ''"))
|
||||
|
||||
|
||||
def _migration_20(conn) -> None:
|
||||
"""New widget type: a static image widget shows whatever single
|
||||
image (or a PDF's first page) the user last uploaded (see
|
||||
app/image_upload.py, routers/api_widgets.py's api_widget_static_
|
||||
upload) -- no live upstream to poll, unlike every other widget type.
|
||||
Brand new table with no existing data to carry forward, so this is
|
||||
just create_all's usual "creates the one new table; existing ones
|
||||
untouched" shape (see _migration_2)."""
|
||||
Base.metadata.create_all(bind=conn)
|
||||
|
||||
|
||||
def _migration_21(conn) -> None:
|
||||
"""New widget type: a text widget shows user-authored rich text (see
|
||||
app/text_content.py, app/widgets/text.py, models.TextWidgetConfig)
|
||||
-- another no-live-upstream type like migration 20's static image.
|
||||
Same brand-new-table create_all shape."""
|
||||
Base.metadata.create_all(bind=conn)
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -108,6 +527,20 @@ MIGRATIONS = [
|
||||
(5, _migration_5),
|
||||
(6, _migration_6),
|
||||
(7, _migration_7),
|
||||
(8, _migration_8),
|
||||
(9, _migration_9),
|
||||
(10, _migration_10),
|
||||
(11, _migration_11),
|
||||
(12, _migration_12),
|
||||
(13, _migration_13),
|
||||
(14, _migration_14),
|
||||
(15, _migration_15),
|
||||
(16, _migration_16),
|
||||
(17, _migration_17),
|
||||
(18, _migration_18),
|
||||
(19, _migration_19),
|
||||
(20, _migration_20),
|
||||
(21, _migration_21),
|
||||
]
|
||||
|
||||
|
||||
@@ -135,6 +568,8 @@ def run_migrations() -> None:
|
||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||
_ensure_frame_one()
|
||||
_ensure_server_settings()
|
||||
_ensure_widgets_backfilled()
|
||||
_ensure_frame_calendars_rekeyed()
|
||||
|
||||
|
||||
def new_device_token() -> str:
|
||||
@@ -239,3 +674,246 @@ def _ensure_server_settings() -> None:
|
||||
if db.get(ServerSettings, 1) is None:
|
||||
db.add(ServerSettings(id=1))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig:
|
||||
return PhotoWidgetConfig(
|
||||
widget_id=widget_id,
|
||||
album_id=frame.album_id,
|
||||
order=frame.order,
|
||||
display_mode=frame.display_mode,
|
||||
queue_target_len=frame.queue_target_len,
|
||||
current_asset_id=frame.current_asset_id,
|
||||
current_asset_set_at=frame.current_asset_set_at,
|
||||
queue=list(frame.queue),
|
||||
queue_cursor=frame.queue_cursor,
|
||||
history=list(frame.history),
|
||||
excluded_asset_ids=list(frame.excluded_asset_ids),
|
||||
)
|
||||
|
||||
|
||||
def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig:
|
||||
return CalendarWidgetConfig(
|
||||
widget_id=widget_id,
|
||||
view=frame.calendar_view,
|
||||
week_start=frame.calendar_week_start,
|
||||
browse_offset=frame.calendar_browse_offset,
|
||||
checked_at=frame.calendar_checked_at,
|
||||
cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None,
|
||||
fetch_summary=frame.calendar_fetch_summary,
|
||||
weather_enabled=frame.calendar_weather_enabled,
|
||||
weather_units=frame.calendar_weather_units,
|
||||
weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None,
|
||||
weather_checked_at=frame.calendar_weather_checked_at,
|
||||
weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None,
|
||||
week_days=frame.calendar_week_days,
|
||||
week_layout=frame.calendar_week_layout,
|
||||
week_start_offset=frame.calendar_week_start_offset,
|
||||
# tasks_* deliberately not carried over -- see
|
||||
# _task_config_and_list_from_frame, a sibling standalone widget
|
||||
# now, not part of this config.
|
||||
)
|
||||
|
||||
|
||||
def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]:
|
||||
"""Only ever called for a frame whose legacy calendar_tasks_* columns
|
||||
(see Frame's own docstring on those -- a dead pre-widget-system
|
||||
field set, same status as calendar_photo_inlay below) still carry a
|
||||
configured source -- i.e. a database jumping straight from before
|
||||
the widget system existed to after tasks became their own
|
||||
multi-list widget type in a single upgrade, skipping both
|
||||
intermediate periods where it would have lived on
|
||||
CalendarWidgetConfig (_migration_17's extraction) and then a
|
||||
single-source TaskWidgetConfig (_migration_18's extraction) instead.
|
||||
Reproduces the same shape those two migrations arrive at directly:
|
||||
a bare cache-state config plus one included FrameTaskList row."""
|
||||
cfg = TaskWidgetConfig(
|
||||
widget_id=widget_id,
|
||||
checked_at=frame.calendar_tasks_checked_at,
|
||||
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
|
||||
)
|
||||
task_list = FrameTaskList(
|
||||
widget_id=widget_id,
|
||||
user_id=frame.calendar_tasks_user_id,
|
||||
calendar_key=frame.calendar_tasks_calendar_key,
|
||||
included=True,
|
||||
)
|
||||
return cfg, task_list
|
||||
|
||||
|
||||
def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
|
||||
return WhiteboardWidgetConfig(
|
||||
widget_id=widget_id,
|
||||
user_id=frame.whiteboard_user_id,
|
||||
url=frame.whiteboard_url,
|
||||
checked_at=frame.whiteboard_checked_at,
|
||||
cached_image=frame.whiteboard_cached_image,
|
||||
)
|
||||
|
||||
|
||||
def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
|
||||
"""NEXT/BACK -> whatever this widget's own advance/back concept is
|
||||
(see app/widgets/ for the actual action registry, built in a later
|
||||
phase) -- reproduces each mode's exact old button behavior for the
|
||||
one auto-migrated widget, so upgrading changes nothing about what the
|
||||
physical buttons do until someone deliberately reassigns them."""
|
||||
if widget_type == "whiteboard":
|
||||
# No real "next"/"back" concept for a static board -- both
|
||||
# buttons already meant "check now" before this migration (see
|
||||
# the old _advance_whiteboard_mode/_back_whiteboard_mode).
|
||||
return [
|
||||
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
|
||||
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
|
||||
]
|
||||
return [
|
||||
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
|
||||
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
|
||||
]
|
||||
|
||||
|
||||
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
|
||||
"""Only relevant for a database jumping straight from before the
|
||||
widget system existed to after tasks became their own widget type
|
||||
in one upgrade (see _task_config_and_list_from_frame) --
|
||||
frame.calendar_tasks_* is the dead legacy field set otherwise.
|
||||
Requires both calendar_key and user_id (FrameTaskList.user_id is
|
||||
NOT NULL) -- same guard _migration_18's own SQL extraction uses.
|
||||
Auto-placed in whatever open space is left after the widget(s) above
|
||||
it in _backfill_frame_widgets claimed theirs, same find_open_rect
|
||||
logic a manual "add widget" uses; silently dropped (logged) if none
|
||||
fits, same as this migration having nowhere else to put it either."""
|
||||
if not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
|
||||
return
|
||||
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
||||
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
|
||||
if rect is None:
|
||||
logger.warning(
|
||||
"Frame %d had a legacy task list configured but no open grid space for a "
|
||||
"standalone tasks widget during backfill -- its task source was dropped", frame.id
|
||||
)
|
||||
return
|
||||
x, y, w, h = rect
|
||||
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
|
||||
sort_order=next_sort_order, created_at=time.time())
|
||||
db.add(task_widget)
|
||||
db.flush()
|
||||
cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id)
|
||||
db.add(cfg)
|
||||
db.add(task_list)
|
||||
|
||||
|
||||
def _backfill_frame_widgets(db, frame: Frame) -> None:
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
|
||||
|
||||
if mode == "calendar" and frame.calendar_photo_inlay:
|
||||
# Reproduces the old fixed 50/50 inlay split as two independent
|
||||
# widgets instead of silently dropping half of what the frame was
|
||||
# showing -- see models.py's CalendarWidgetConfig docstring on why
|
||||
# "photo inlay" isn't a widget-system concept anymore otherwise.
|
||||
half = cols // 2
|
||||
cal_widget = Widget(frame_id=frame.id, widget_type="calendar",
|
||||
x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time())
|
||||
photo_widget = Widget(frame_id=frame.id, widget_type="photos",
|
||||
x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time())
|
||||
db.add_all([cal_widget, photo_widget])
|
||||
db.flush() # assign ids before the FK'd config rows reference them
|
||||
db.add(_calendar_config_from_frame(frame, cal_widget.id))
|
||||
db.add(_photo_config_from_frame(frame, photo_widget.id))
|
||||
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
|
||||
_maybe_add_legacy_tasks_widget(
|
||||
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
|
||||
)
|
||||
return
|
||||
|
||||
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
|
||||
sort_order=0, created_at=time.time())
|
||||
db.add(widget)
|
||||
db.flush()
|
||||
if mode == "photos":
|
||||
db.add(_photo_config_from_frame(frame, widget.id))
|
||||
elif mode == "calendar":
|
||||
db.add(_calendar_config_from_frame(frame, widget.id))
|
||||
elif mode == "whiteboard":
|
||||
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
||||
db.add_all(_default_button_actions(frame.id, widget.id, mode))
|
||||
if mode == "calendar":
|
||||
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
|
||||
|
||||
|
||||
def _ensure_widgets_backfilled() -> None:
|
||||
"""Every frame needs at least one Widget once the widget system is
|
||||
live -- runs unconditionally after every startup (both a from-scratch
|
||||
_ensure_frame_one() install and an existing-install upgrade past
|
||||
_migration_16 land here) and is a no-op for any frame that already
|
||||
has one. Builds a widget that reproduces the frame's current mode/
|
||||
settings/state exactly, so upgrading never changes what a frame
|
||||
displays or what its physical buttons do on its own."""
|
||||
with SessionLocal() as db:
|
||||
for frame in db.scalars(select(Frame)).all():
|
||||
has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first()
|
||||
if has_widget is not 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)"
|
||||
))
|
||||
|
||||
+420
-16
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, LargeBinary, String
|
||||
from sqlalchemy.ext.mutable import MutableList
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
@@ -48,12 +48,45 @@ class User(Base):
|
||||
# email -- see routers/device.py's frame_battery) go here; blank = no
|
||||
# email configured, both features silently no-op for this user.
|
||||
email: Mapped[str] = mapped_column(String, default="")
|
||||
# Personal iCal/CalDAV .ics subscription URL (no OAuth) for calendar
|
||||
# frame mode -- see calendar_feed.py. Setting this alone shows up
|
||||
# nowhere: a linked frame only pulls this user's events in once
|
||||
# they've also opted in on that frame's own Configuration -> Calendar
|
||||
# card (UserFrame.calendar_included below).
|
||||
# Personal ICS subscription URL (no OAuth) for calendar frame mode --
|
||||
# see calendar_feed.py. Setting this alone shows up nowhere: a linked
|
||||
# frame only pulls this user's events in once they've also added it
|
||||
# on that frame's own Calendar tab (FrameCalendar below).
|
||||
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
|
||||
# A CalDAV account (Nextcloud, Fastmail, iCloud, ...) alongside the
|
||||
# plain ICS subscription above -- see caldav_client.py. calendar_url
|
||||
# is the server's CalDAV entry point the user pasted in, not any one
|
||||
# calendar's own URL; the individual calendars it exposes are
|
||||
# discovered and cached below.
|
||||
calendar_caldav_url: Mapped[str] = mapped_column(String, default="")
|
||||
calendar_caldav_username: Mapped[str] = mapped_column(String, default="")
|
||||
calendar_caldav_password: Mapped[str] = mapped_column(String, default="")
|
||||
# [{"href", "display_name"}, ...] from the last successful
|
||||
# caldav_client.discover_calendars() call, refreshed by Settings'
|
||||
# "Discover calendars" button -- NULL until discovery has ever
|
||||
# succeeded. This is what a frame's Calendar tab offers the user to
|
||||
# add, without hitting the CalDAV server on every page load.
|
||||
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# WebDAV credentials for whiteboard frame mode (see webdav_client.py,
|
||||
# whiteboard.py) -- generic WebDAV, not Nextcloud-specific, but
|
||||
# webdav_reuse_caldav_creds is a convenience for the common case
|
||||
# where it IS the same Nextcloud account as calendar_caldav_*: skip
|
||||
# re-entering the same username/password, since Nextcloud's CalDAV
|
||||
# and general-file-WebDAV both sit under the one account. Doesn't
|
||||
# try to be clever and derive the reuse automatically -- an explicit
|
||||
# opt-in, same as everywhere else in this project defaults features
|
||||
# off rather than silently inferring them.
|
||||
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 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="")
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
@@ -88,8 +121,9 @@ class Frame(Base):
|
||||
# the migrated legacy frame until its device first reports an id.
|
||||
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
||||
name: Mapped[str] = mapped_column(String, default="")
|
||||
# Renderer dispatch seam for future calendar/canva modes -- only
|
||||
# "photos" is registered today (see routers/device.py RENDERERS).
|
||||
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
|
||||
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
|
||||
# and routers/common.py FRAME_MODES).
|
||||
mode: Mapped[str] = mapped_column(String, default="photos")
|
||||
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
||||
owner_user_id: Mapped[int | None] = mapped_column(
|
||||
@@ -148,6 +182,9 @@ class Frame(Base):
|
||||
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
||||
# routers/device.py's RENDERERS["calendar"]) --
|
||||
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
|
||||
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
|
||||
# which day week/month views start their grid on.
|
||||
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# Agenda view only; reuses this frame's existing photos-mode album/
|
||||
# queue, not a separate photo setup.
|
||||
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -170,6 +207,77 @@ class Frame(Base):
|
||||
# outage to everyone who looks at it.
|
||||
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
# Optional weather strip, agenda/today & tomorrow/week views only --
|
||||
# never month, there's no room (see calendar_render.py's _BUILDERS).
|
||||
# Off by default.
|
||||
calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius"
|
||||
# [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
|
||||
# weather.geocode_city() when added from the Calendar tab.
|
||||
calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
# Throttled per-city forecast cache (see routers/common.py's
|
||||
# get_or_refresh_weather) -- same shape idiom as
|
||||
# calendar_checked_at/calendar_cached_events above.
|
||||
# [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
|
||||
calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
|
||||
# Week view: how many days to show (2-10, default 7 -- the original
|
||||
# fixed behavior) and whether they're laid out as side-by-side
|
||||
# columns or stacked bands (see calendar_render.py's _build_week).
|
||||
calendar_week_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||
calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical"
|
||||
# Only used when calendar_week_days != 7 -- calendar_week_start's
|
||||
# fixed-weekday anchor ("start on the most recent Monday") stops
|
||||
# making sense once the view isn't a literal calendar week, so a
|
||||
# non-7-day view instead starts this many days from today (0 =
|
||||
# starts today, negative = starts in the past, positive = starts in
|
||||
# the future). Ignored (calendar_week_start governs instead) at the
|
||||
# default 7 days, so this has no effect until someone actually
|
||||
# changes the day count.
|
||||
calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Optional task list, week view only -- takes the space of one day
|
||||
# slot rather than adding an extra one (see calendar_render.py's
|
||||
# _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.
|
||||
# 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 []).
|
||||
calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
calendar_tasks_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
|
||||
# already filtered to outstanding (not-completed) tasks and sorted
|
||||
# by due date -- see caldav_client.fetch_tasks.
|
||||
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
|
||||
# -- whiteboard mode (see webdav_client.py, whiteboard.py,
|
||||
# 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, 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
|
||||
)
|
||||
# The specific .whiteboard file's WebDAV URL -- pasted directly, same
|
||||
# idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
|
||||
# account-has-several-calendars case, a WebDAV account doesn't need
|
||||
# a picker step here since the user already knows which one file).
|
||||
whiteboard_url: Mapped[str] = mapped_column(String, default="")
|
||||
whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
|
||||
# BLOB rather than the JSON columns the rest of this cache-pattern
|
||||
# family uses, since this is binary image data, not JSON-shaped.
|
||||
whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
# -- state --
|
||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
@@ -231,14 +339,310 @@ class UserFrame(Base):
|
||||
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
# Explicit per-(user,frame) opt-in for calendar frame mode -- being
|
||||
# linked to a frame does NOT by itself contribute this user's
|
||||
# calendar to it (deliberate choice, not an oversight: each person's
|
||||
# calendar is their own data to share or not, not something a
|
||||
# frame's controller decides on their behalf). Meaningless if the
|
||||
# user has no calendar_ics_url set. See routers/api_frames.py's
|
||||
# api_calendar_included.
|
||||
calendar_included: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class FrameCalendar(Base):
|
||||
"""One calendar included on one frame -- calendar_key is "ics" (the
|
||||
owner's single calendar_ics_url) or "caldav:<href>" (one of the
|
||||
owner's CalDAV collections; href matches an entry in
|
||||
User.calendar_caldav_calendars). Replaces the old single
|
||||
UserFrame.calendar_included boolean now that a CalDAV account can
|
||||
expose more than one calendar.
|
||||
|
||||
A row only ever gets created by its own owner (adding a calendar to
|
||||
a frame is each person's own data-sharing choice, not something a
|
||||
frame's controller decides on their behalf) -- but once it exists,
|
||||
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_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
|
||||
_ensure_frame_calendars_rekeyed, which re-keyed this table)."""
|
||||
|
||||
__tablename__ = "frame_calendars"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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
|
||||
# if the owner's CalDAV account later stops offering this calendar.
|
||||
calendar_label: Mapped[str] = mapped_column(String, default="")
|
||||
included: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
# Index into image_pipeline.DEFAULT_PALETTE_RGB/PALETTE_LABELS (2-5:
|
||||
# Yellow/Red/Blue/Green -- 0/1 are reserved, already the page's
|
||||
# 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_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", "widget_id", "user_id", "calendar_key", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class FrameTaskList(Base):
|
||||
"""One CalDAV task list included on one tasks widget -- calendar_key
|
||||
is "caldav:<href>" (an entry in User.calendar_caldav_calendars; no
|
||||
"ics" variant, unlike FrameCalendar -- a plain ICS subscription has
|
||||
no VTODO collection). Same owner-controls-their-own-data shape as
|
||||
FrameCalendar in every other respect: a row is only ever created by
|
||||
its own owner, but any user linked to the frame may flip included
|
||||
back to False, and only the owner may flip it back to True or set
|
||||
color_index. See routers/api_widgets.py's api_widget_task_list_select/
|
||||
api_widget_task_list_color."""
|
||||
|
||||
__tablename__ = "frame_task_lists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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)
|
||||
calendar_label: Mapped[str] = mapped_column(String, default="")
|
||||
included: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_frame_task_lists_unique", "widget_id", "user_id", "calendar_key", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class Widget(Base):
|
||||
"""One placed/sized content item on a frame's panel -- the unit the
|
||||
widget system replaces the old single Frame.mode with (see
|
||||
app/grid.py for the grid this x/y/w/h is measured in, and app/widgets/
|
||||
for the widget_type -> render/action dispatch registry). Widgets never
|
||||
overlap (enforced server-side in routers/api_widgets.py), which is
|
||||
what keeps compositing simple: no z-order, no blending, just N
|
||||
independent regions pasted onto one shared canvas before a single
|
||||
shared dither/quantize pass (see image_pipeline.render_panel).
|
||||
|
||||
widget_type selects which of the three per-type extension tables below
|
||||
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig) holds
|
||||
this widget's actual settings/state -- a 1:1 relational split rather
|
||||
than one wide table with every type's columns, matching how
|
||||
FrameCalendar/BatteryLog are already their own tables in this
|
||||
codebase rather than crammed onto Frame."""
|
||||
|
||||
__tablename__ = "widgets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks"
|
||||
x: Mapped[int] = mapped_column(Integer)
|
||||
y: Mapped[int] = mapped_column(Integer)
|
||||
w: Mapped[int] = mapped_column(Integer)
|
||||
h: Mapped[int] = mapped_column(Integer)
|
||||
# Display/tie-break ordering only (e.g. listing widgets in a UI) --
|
||||
# NOT a z-order, since widgets never overlap. Named sort_order, not
|
||||
# order, to sidestep the SQL-keyword dance Frame.order needed
|
||||
# (mapped to a differently-named column) -- nothing outside this
|
||||
# table needs to match a specific attribute name here.
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
|
||||
|
||||
|
||||
class PhotoWidgetConfig(Base):
|
||||
"""One photo widget's settings + queue state. Attribute names match
|
||||
Frame's old photo-queue columns exactly (down to `order`'s same
|
||||
photo_order column-name dodge) -- app/photo_queue.py's 5 functions
|
||||
are duck-typed against these exact names (never isinstance-checked
|
||||
against Frame), so they port unchanged onto this table."""
|
||||
|
||||
__tablename__ = "photo_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
album_id: Mapped[str] = mapped_column(String, default="")
|
||||
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
|
||||
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
|
||||
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
|
||||
|
||||
class CalendarWidgetConfig(Base):
|
||||
"""One calendar widget's settings + cached-fetch state -- the same
|
||||
fields that used to live as calendar_* columns directly on Frame,
|
||||
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) and minus
|
||||
tasks_* (also dropped: split out into its own standalone widget
|
||||
type, see TaskWidgetConfig, so a task list isn't tied to a
|
||||
calendar's week view/footprint anymore). "Included 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"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
view: Mapped[str] = mapped_column(String, default="agenda")
|
||||
week_start: Mapped[int] = mapped_column(Integer, default=0)
|
||||
browse_offset: Mapped[int] = mapped_column(Integer, default=0)
|
||||
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
fetch_summary: Mapped[str] = mapped_column(String, default="")
|
||||
weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
weather_units: Mapped[str] = mapped_column(String, default="fahrenheit")
|
||||
weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
week_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
|
||||
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class TaskWidgetConfig(Base):
|
||||
"""One tasks widget's settings + cached-fetch state -- split out of
|
||||
CalendarWidgetConfig (which used to carry these as tasks_* columns,
|
||||
a week-view-only task list bolted onto a calendar widget) so a task
|
||||
list can be placed and sized on its own, independent of any
|
||||
calendar's view/footprint. No separate "enabled" flag -- unlike the
|
||||
old bolted-on version, the widget's mere presence on the grid is the
|
||||
on/off switch, same as every other widget type.
|
||||
|
||||
Which task lists feed this widget lives in FrameTaskList, not here
|
||||
-- a widget can merge more than one person's list, mirroring
|
||||
CalendarWidgetConfig/FrameCalendar exactly (this used to be a single
|
||||
user_id/calendar_key pair here, one list only; migration 18 carried
|
||||
each widget's existing single source forward as its first
|
||||
FrameTaskList row when splitting this out)."""
|
||||
|
||||
__tablename__ = "task_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
# Shown on-panel in place of the default "Tasks" header (see
|
||||
# calendar_render._draw_tasks) -- "" keeps the default. The only
|
||||
# widget type with its own on-panel title at all, since it's the
|
||||
# only one where "which list is this" isn't already obvious from
|
||||
# its content the way a calendar/photo/whiteboard's is.
|
||||
name: Mapped[str] = mapped_column(String, default="")
|
||||
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# [{"summary", "due", "completed_at" (ISO date/datetime strings or
|
||||
# None), "owner_display_name", "color_index"}, ...] -- the merged
|
||||
# multi-list result, same general shape as CalendarWidgetConfig.
|
||||
# cached_events. See caldav_client.merge_tasks.
|
||||
cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
# Also include tasks completed in the last 24h (drawn checked-box +
|
||||
# muted, see calendar_render._draw_tasks) rather than just
|
||||
# outstanding ones -- off by default, same "opt into more" posture
|
||||
# as calendar_weather_enabled.
|
||||
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class WhiteboardWidgetConfig(Base):
|
||||
"""One whiteboard widget's source + rendered-PNG cache -- the same
|
||||
fields that used to live as whiteboard_* columns directly on Frame."""
|
||||
|
||||
__tablename__ = "whiteboard_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
url: Mapped[str] = mapped_column(String, default="")
|
||||
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
|
||||
class TextWidgetConfig(Base):
|
||||
"""One text widget's authored content + display settings -- another
|
||||
no-live-upstream type like StaticWidgetConfig, just parsed rich text
|
||||
instead of an uploaded image. content is never raw HTML: the
|
||||
dialog's contenteditable innerHTML is parsed server-side (see
|
||||
app/text_content.py, the sanitization boundary) into this plain
|
||||
run structure at save time, so render() (app/widgets/text.py) never
|
||||
re-parses/sanitizes HTML on every panel refresh, and the dialog never
|
||||
re-injects stored HTML via innerHTML when reopened.
|
||||
|
||||
[[{"text","bold","italic","underline","color","bg"}, ...], ...] --
|
||||
outer list is paragraphs (line breaks), inner list is styled runs
|
||||
within that paragraph. color/bg are "#rrggbb" or null (falls back to
|
||||
black text / no highlight). NULL (not just []) means never
|
||||
configured, matching StaticWidgetConfig.image's None-vs-empty
|
||||
convention for "not configured yet"."""
|
||||
|
||||
__tablename__ = "text_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
content: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
# Base point size for the whole block -- render() shrinks this down
|
||||
# (never up) to fit the widget's actual box; per-run font size isn't
|
||||
# supported, only the bold/italic/underline/color/bg style flags are
|
||||
# per-run (see app/text_content.py) -- keeps the wrap/shrink-to-fit
|
||||
# layout in app/widgets/text.py to one size per render pass.
|
||||
font_size: Mapped[int] = mapped_column(Integer, default=28)
|
||||
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
|
||||
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
|
||||
|
||||
|
||||
class StaticWidgetConfig(Base):
|
||||
"""One static-image widget's uploaded content + display settings --
|
||||
unlike every other widget type, this one has no live upstream to
|
||||
poll (Immich/CalDAV/WebDAV): the "source" is whatever the user last
|
||||
uploaded (see routers/api_widgets.py's api_widget_static_upload,
|
||||
app/image_upload.py), decoded once at upload time into plain RGB PNG
|
||||
bytes so app/widgets/static_image.py's render() never re-runs
|
||||
PDF/GIF decoding on every panel refresh."""
|
||||
|
||||
__tablename__ = "static_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
original_filename: Mapped[str] = mapped_column(String, default="")
|
||||
uploaded_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# Same DISPLAY_MODES vocabulary as PhotoWidgetConfig.display_mode,
|
||||
# minus crop_faces -- no face detection for an uploaded image (see
|
||||
# image_pipeline.STATIC_DISPLAY_MODES).
|
||||
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
|
||||
|
||||
|
||||
# widget_type -> its per-type extension table, keyed by widget_id. Used
|
||||
# by db.widget_locked() to resolve the right config row without importing
|
||||
# app/widgets/'s heavier render/action registry just for this lookup.
|
||||
WIDGET_CONFIG_MODELS: dict[str, type] = {
|
||||
"photos": PhotoWidgetConfig,
|
||||
"calendar": CalendarWidgetConfig,
|
||||
"whiteboard": WhiteboardWidgetConfig,
|
||||
"tasks": TaskWidgetConfig,
|
||||
"static": StaticWidgetConfig,
|
||||
"text": TextWidgetConfig,
|
||||
}
|
||||
|
||||
|
||||
class FrameButtonAction(Base):
|
||||
"""One (widget, action) binding for one of a frame's two physical
|
||||
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
|
||||
"advance"}. A button can have several of these (sort_order gives
|
||||
execution order); on a press, every row for that (frame, button) runs
|
||||
-- see routers/device.py's frame_advance/frame_back. Deliberately
|
||||
unconstrained about which widget/action pairs with which button (the
|
||||
user's own idea for resolving "what does NEXT even mean with several
|
||||
widgets on screen": let them assign literally anything to either
|
||||
button, including mismatched combinations, rather than the server
|
||||
guessing a sensible default)."""
|
||||
|
||||
__tablename__ = "frame_button_actions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||
button: Mapped[str] = mapped_column(String) # "next" | "back"
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
|
||||
action: Mapped[str] = mapped_column(String) # e.g. "advance", "back", "check_now" -- see app/widgets/
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
|
||||
)
|
||||
|
||||
|
||||
class PendingClaim(Base):
|
||||
|
||||
+27
-13
@@ -85,11 +85,21 @@ def _top_up(cfg: Frame, assets: list[dict]) -> None:
|
||||
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
|
||||
|
||||
|
||||
def advance_forced(cfg: Frame, assets: list[dict]) -> None:
|
||||
def advance_forced(cfg: Frame, assets: list[dict], frame: Frame) -> None:
|
||||
"""Unconditionally moves to the next photo, ignoring elapsed time, and
|
||||
resets the interval clock from now. Used by the explicit next-photo
|
||||
action (POST /frame/advance) and by get_current() once the refresh
|
||||
interval has elapsed -- always mutates cfg."""
|
||||
interval has elapsed -- always mutates cfg.
|
||||
|
||||
`frame` is a separate reference to the owning Frame, for fields that
|
||||
stay frame-level rather than moving onto a photo widget's own config
|
||||
(currently just stats_photos_displayed) -- once a photo widget's
|
||||
queue state lives on its own PhotoWidgetConfig row rather than
|
||||
directly on Frame (see models.py), `cfg` and `frame` stop being the
|
||||
same object; every existing caller today still passes the same Frame
|
||||
for both, which is also why this stays a required (not optional)
|
||||
param -- no implicit "guess which Frame owns this" fallback to get
|
||||
wrong later."""
|
||||
if cfg.current_asset_id:
|
||||
# Recorded regardless of *why* this advance happened (a manual
|
||||
# next-press or the timer just elapsing) -- back should be able
|
||||
@@ -105,7 +115,7 @@ def advance_forced(cfg: Frame, assets: list[dict]) -> None:
|
||||
# only asset is already current) -- keep showing what we have.
|
||||
cfg.current_asset_id = assets[0]["id"]
|
||||
cfg.current_asset_set_at = time.time()
|
||||
cfg.stats_photos_displayed += 1
|
||||
frame.stats_photos_displayed += 1
|
||||
# Refill back up to queue_target_len now that current_asset_id has
|
||||
# changed -- otherwise the queue is left one short until the *next*
|
||||
# advance, since the pop above consumes one of the items _top_up just
|
||||
@@ -113,7 +123,7 @@ def advance_forced(cfg: Frame, assets: list[dict]) -> None:
|
||||
_top_up(cfg, assets)
|
||||
|
||||
|
||||
def back_forced(cfg: Frame, assets: list[dict]) -> bool:
|
||||
def back_forced(cfg: Frame, assets: list[dict], frame: Frame) -> bool:
|
||||
"""Unconditionally moves to the previously-current photo, the mirror
|
||||
image of advance_forced() -- pops the most recent entry off history,
|
||||
pushes the photo it's replacing onto the front of queue (so pressing
|
||||
@@ -122,7 +132,7 @@ def back_forced(cfg: Frame, assets: list[dict]) -> bool:
|
||||
since). Returns whether it actually moved -- False (history empty or
|
||||
entirely stale) is a no-op, callers should still just display
|
||||
whatever's current rather than treating it as an error. Used by the
|
||||
back-photo button (POST /frame/back)."""
|
||||
back-photo button (POST /frame/back). See advance_forced() on `frame`."""
|
||||
valid_ids = {a["id"] for a in assets}
|
||||
while cfg.history:
|
||||
previous_id = cfg.history.pop()
|
||||
@@ -132,12 +142,12 @@ def back_forced(cfg: Frame, assets: list[dict]) -> bool:
|
||||
cfg.queue.insert(0, cfg.current_asset_id)
|
||||
cfg.current_asset_id = previous_id
|
||||
cfg.current_asset_set_at = time.time()
|
||||
cfg.stats_photos_displayed += 1
|
||||
frame.stats_photos_displayed += 1
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
|
||||
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str, frame: Frame) -> bool:
|
||||
"""Permanently excludes asset_id from this frame's rotation (see the
|
||||
module docstring) -- doesn't touch Immich, just this frame's own
|
||||
selection. Scrubs it out of queue and history too, so it can't
|
||||
@@ -146,10 +156,10 @@ def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
|
||||
*not* through advance_forced(), since that would record the removed
|
||||
photo in history, and going back to a photo you just explicitly
|
||||
removed doesn't make sense. Returns whether the current photo
|
||||
changed as a result."""
|
||||
changed as a result. See advance_forced() on `frame`."""
|
||||
if asset_id not in cfg.excluded_asset_ids:
|
||||
cfg.excluded_asset_ids.append(asset_id)
|
||||
cfg.stats_photos_removed += 1
|
||||
frame.stats_photos_removed += 1
|
||||
cfg.queue = [a for a in cfg.queue if a != asset_id]
|
||||
cfg.history = [a for a in cfg.history if a != asset_id]
|
||||
|
||||
@@ -167,7 +177,7 @@ def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
|
||||
remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
|
||||
cfg.current_asset_id = remaining[0] if remaining else ""
|
||||
cfg.current_asset_set_at = time.time()
|
||||
cfg.stats_photos_displayed += 1
|
||||
frame.stats_photos_displayed += 1
|
||||
_top_up(cfg, assets)
|
||||
return True
|
||||
|
||||
@@ -180,7 +190,7 @@ def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
|
||||
_top_up(cfg, assets)
|
||||
|
||||
|
||||
def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) -> bool:
|
||||
def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bool = False) -> bool:
|
||||
"""Time-based, idempotent path used by GET /frame/image. Advances only
|
||||
if the current photo is unset/invalid or refresh_interval_s has
|
||||
elapsed since it was set. Returns whether it changed anything, so the
|
||||
@@ -190,6 +200,10 @@ def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) ->
|
||||
ahead, while a wake that lands after the interval has elapsed still
|
||||
advances exactly once, even after a long time offline.
|
||||
|
||||
refresh_interval_s is read off `frame`, not `cfg` -- it's a device
|
||||
wake-cadence setting shared by the whole panel, not something that
|
||||
becomes per-widget (see advance_forced() on the cfg/frame split).
|
||||
|
||||
in_quiet_hours suppresses *only* the elapsed-time trigger -- an
|
||||
unset/invalid current photo still gets picked regardless, since
|
||||
showing nothing is worse than showing something even at 3am. This
|
||||
@@ -200,9 +214,9 @@ def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) ->
|
||||
window (see main.py's _effective_refresh_interval_s)."""
|
||||
valid_ids = {a["id"] for a in assets}
|
||||
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
|
||||
time_elapsed = (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s
|
||||
time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
|
||||
stale = needs_pick or (time_elapsed and not in_quiet_hours)
|
||||
if not stale:
|
||||
return False
|
||||
advance_forced(cfg, assets)
|
||||
advance_forced(cfg, assets, frame)
|
||||
return True
|
||||
|
||||
@@ -114,6 +114,18 @@ def in_quiet_hours(cfg) -> bool:
|
||||
return in_quiet
|
||||
|
||||
|
||||
def quiet_span_s(cfg) -> int:
|
||||
"""Seconds per day quiet hours keeps the device asleep -- 0 when
|
||||
disabled. Used by common.py's battery-remaining estimate to turn a
|
||||
per-wake battery cost into a wall-clock duration: quiet hours cuts
|
||||
how many wakes happen per day without changing what any one wake
|
||||
costs, so it belongs in the wakes-per-day math, not the per-wake
|
||||
rate itself."""
|
||||
if not cfg.quiet_hours_enabled:
|
||||
return 0
|
||||
return _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end)
|
||||
|
||||
|
||||
def max_expected_gap_s(cfg) -> int:
|
||||
"""Longest gap between wakes the device might legitimately have --
|
||||
normally just refresh_interval_s, but quiet hours can make the real
|
||||
|
||||
+187
-317
@@ -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
|
||||
@@ -22,34 +23,18 @@ 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 import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours
|
||||
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
|
||||
from ..image_pipeline import (
|
||||
DEFAULT_DISPLAY_MODE,
|
||||
DISPLAY_MODES,
|
||||
PALETTE_LABELS,
|
||||
hex_to_rgb,
|
||||
render_preview_png,
|
||||
)
|
||||
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame, UserFrame
|
||||
from .common import (
|
||||
FRAME_MODES,
|
||||
OVERDUE_FACTOR,
|
||||
battery_estimate_s,
|
||||
calendar_sources_for_frame,
|
||||
fetch_source_and_faces,
|
||||
get_or_refresh_calendar_events,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
require_configured,
|
||||
valid_http_url,
|
||||
)
|
||||
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
||||
from .device import render_frame_preview_png
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,12 +42,36 @@ 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")
|
||||
|
||||
|
||||
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
|
||||
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
|
||||
cols x rows (see grid.grid_dims) -- landscape and portrait use a
|
||||
transposed grid (8x5 vs 5x8), so an existing placement is often
|
||||
literally out of bounds on the new grid, not just visually wrong.
|
||||
There's no sensible coordinate remap between two differently-shaped
|
||||
grids, so instead: keep whichever widget was first by placement
|
||||
order, resized to fill the new full panel, and delete the rest --
|
||||
cascading to their own config rows and any FrameButtonAction
|
||||
bindings via ondelete="CASCADE" (see models.py). The frontend is
|
||||
expected to confirm this with the user before submitting an
|
||||
orientation change (see frame_config.js) -- this always executes
|
||||
unconditionally once called, same posture as every other
|
||||
confirm-on-the-client / act-unconditionally-on-the-server action in
|
||||
this codebase."""
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
if not widgets:
|
||||
return
|
||||
keep, *rest = widgets
|
||||
for widget in rest:
|
||||
db.delete(widget)
|
||||
keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation)
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/albums")
|
||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||
url, key = immich_creds(frame)
|
||||
@@ -78,11 +87,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),
|
||||
@@ -96,37 +101,38 @@ def api_config_save(
|
||||
color_boost: float | None = Form(None),
|
||||
contrast_boost: float | None = Form(None),
|
||||
dither_strength: float | None = Form(None),
|
||||
mode: str | None = Form(None),
|
||||
calendar_view: str | None = Form(None),
|
||||
calendar_photo_inlay: bool | None = Form(None),
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""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.
|
||||
|
||||
An actual orientation *change* resets the frame's widget layout (see
|
||||
_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."""
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
if name is not None:
|
||||
cfg.name = name.strip()[:64] or cfg.name
|
||||
if album_id is not None and album_id != cfg.album_id:
|
||||
# A newly selected album starts clean -- the old current photo
|
||||
# and queue don't mean anything in the new album's context.
|
||||
cfg.current_asset_id = ""
|
||||
cfg.current_asset_set_at = 0.0
|
||||
cfg.queue = []
|
||||
cfg.queue_cursor = 0
|
||||
cfg.history = []
|
||||
cfg.excluded_asset_ids = []
|
||||
cfg.album_id = album_id
|
||||
if order is not None:
|
||||
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
||||
if refresh_interval_s is not None:
|
||||
cfg.refresh_interval_s = max(
|
||||
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
||||
)
|
||||
if display_mode is not None:
|
||||
cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
|
||||
if queue_target_len is not None:
|
||||
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
||||
if orientation is not None:
|
||||
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
||||
new_orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
||||
if new_orientation != cfg.orientation:
|
||||
_reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation)
|
||||
cfg.orientation = new_orientation
|
||||
if quiet_hours_enabled is not None:
|
||||
cfg.quiet_hours_enabled = quiet_hours_enabled
|
||||
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
|
||||
@@ -162,19 +168,8 @@ def api_config_save(
|
||||
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||
if dither_strength is not None:
|
||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
||||
if mode is not None:
|
||||
cfg.mode = mode if mode in FRAME_MODES else "photos"
|
||||
if calendar_view is not None:
|
||||
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||
if new_view != cfg.calendar_view:
|
||||
# A stale offset means something different in a different
|
||||
# view's units (days vs. weeks vs. months) -- same
|
||||
# reasoning as album_id's reset above.
|
||||
cfg.calendar_browse_offset = 0
|
||||
cfg.calendar_view = new_view
|
||||
if calendar_photo_inlay is not None:
|
||||
cfg.calendar_photo_inlay = calendar_photo_inlay
|
||||
cfg.stats_config_saves += 1
|
||||
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@@ -207,65 +202,144 @@ 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)
|
||||
require_configured(frame)
|
||||
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
snapshot = {
|
||||
"current_asset_id": cfg.current_asset_id,
|
||||
"queue": list(cfg.queue),
|
||||
"last_seen": cfg.last_seen,
|
||||
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
|
||||
"firmware_version": cfg.device_firmware_version,
|
||||
"firmware_available": cfg.firmware_available_version,
|
||||
"battery_percent": cfg.battery_percent,
|
||||
"battery_as_of": cfg.battery_as_of,
|
||||
"battery_estimate_s": battery_estimate_s(cfg),
|
||||
"controller_id": cfg.controlled_by_user_id,
|
||||
"controller": (
|
||||
(cfg.controlled_by.display_name or cfg.controlled_by.username)
|
||||
if cfg.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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview")
|
||||
def api_frame_preview(
|
||||
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
||||
):
|
||||
"""A small PNG of exactly what the frame is currently displaying --
|
||||
the same widget compositor /frame/image uses (see routers/device.py's
|
||||
render_frame_preview_png), just handed back upright and unpacked for
|
||||
the dashboard header's live thumbnail instead of the device's packed
|
||||
native format. Not cached: cheap enough for an on-demand header image,
|
||||
and each widget's own render is already idempotent between a device's
|
||||
real wakes (see photo_queue.get_current, calendar widget's browse
|
||||
reset), so an extra read here doesn't skip or duplicate anything."""
|
||||
png = render_frame_preview_png(db, frame, request)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
BUTTONS = ("next", "back")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/buttons")
|
||||
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""Everything the button-assignment UI needs in one call: every
|
||||
widget on the frame with the actions its type supports (see
|
||||
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
|
||||
ordered list of (widget, action) bindings.
|
||||
|
||||
Includes each widget's placement (x/y/w/h) and the frame's grid
|
||||
dimensions -- two widgets of the same type otherwise look identical
|
||||
in the assignment UI's dropdowns (both just say "Photos"); the
|
||||
client derives a position label ("top-left" etc.) from this to tell
|
||||
them apart, the same way you'd tell them apart by eye on the Layout
|
||||
canvas."""
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
widget_options = [
|
||||
{
|
||||
"id": w.id,
|
||||
"widget_type": w.widget_type,
|
||||
"x": w.x, "y": w.y, "w": w.w, "h": w.h,
|
||||
"actions": [
|
||||
{"action": action, "label": label}
|
||||
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
|
||||
],
|
||||
}
|
||||
for w in widgets
|
||||
]
|
||||
result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}}
|
||||
for button in BUTTONS:
|
||||
rows = db.scalars(
|
||||
select(FrameButtonAction)
|
||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
|
||||
.order_by(FrameButtonAction.sort_order)
|
||||
).all()
|
||||
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
|
||||
return result
|
||||
|
||||
|
||||
class ButtonActionItem(BaseModel):
|
||||
widget_id: int
|
||||
action: str
|
||||
|
||||
|
||||
class ButtonActionsRequest(BaseModel):
|
||||
actions: list[ButtonActionItem]
|
||||
|
||||
|
||||
@router.put("/api/frames/{frame_id}/buttons/{button}")
|
||||
def api_buttons_save(
|
||||
button: str, body: ButtonActionsRequest,
|
||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||
):
|
||||
"""Replaces the whole ordered action list for one button in a single
|
||||
call -- simpler and more atomic than separate add/remove/reorder
|
||||
endpoints for what's normally a list of one to a handful of entries,
|
||||
and the UI always has the full list in hand anyway (see
|
||||
static/frame_config.js)."""
|
||||
if button not in BUTTONS:
|
||||
raise HTTPException(404, "No such button")
|
||||
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
|
||||
for item in body.actions:
|
||||
widget = widgets_by_id.get(item.widget_id)
|
||||
if widget is None:
|
||||
raise HTTPException(400, f"No such widget: {item.widget_id}")
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
if module is None or item.action not in module.ACTIONS:
|
||||
raise HTTPException(
|
||||
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
|
||||
)
|
||||
with frame_locked(db, frame.id):
|
||||
db.execute(
|
||||
delete(FrameButtonAction).where(
|
||||
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
|
||||
)
|
||||
)
|
||||
for i, item in enumerate(body.actions):
|
||||
db.add(FrameButtonAction(
|
||||
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
|
||||
sort_order=i, created_at=time.time(),
|
||||
))
|
||||
db.commit()
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/battery-log")
|
||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
@@ -276,210 +350,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."""
|
||||
with frame_locked(db, frame.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."""
|
||||
with frame_locked(db, frame.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()."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
|
||||
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
||||
"""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."""
|
||||
require_configured(frame)
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.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) -> str:
|
||||
"""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."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
asset_id = cfg.current_asset_id
|
||||
if not asset_id:
|
||||
raise HTTPException(404, "No current photo")
|
||||
return asset_id
|
||||
|
||||
|
||||
@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."""
|
||||
asset_id = _current_asset_id(frame, db)
|
||||
client = immich_client_for(frame)
|
||||
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
||||
png = render_preview_png(
|
||||
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
display_mode=frame.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 CalendarIncludedRequest(BaseModel):
|
||||
included: bool
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/calendar-included")
|
||||
def api_calendar_included(
|
||||
body: CalendarIncludedRequest,
|
||||
request: Request,
|
||||
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""A user's own opt-in into this frame's merged calendar (see
|
||||
UserFrame.calendar_included). Deliberately not require_frame_control:
|
||||
this is the toggling user's own data-sharing preference about their
|
||||
own calendar, not a frame setting its controller manages on someone
|
||||
else's behalf -- there's no target user_id in the request body by
|
||||
design, it always toggles the calling session's own row."""
|
||||
user = require_user_api(request, db)
|
||||
row = db.get(UserFrame, (user.id, frame.id))
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not linked to this frame")
|
||||
row.calendar_included = body.included
|
||||
# Force this frame's merged cache to pick up the change promptly
|
||||
# rather than waiting out the throttle.
|
||||
frame.calendar_checked_at = 0.0
|
||||
db.commit()
|
||||
return {"status": "saved", "included": row.calendar_included}
|
||||
|
||||
|
||||
def _calendar_photo_inlay(frame: Frame, db: Session):
|
||||
"""The agenda view's optional photo-inlay source image, or None if
|
||||
inlay is off, not agenda view, or the frame's photos-mode album isn't
|
||||
configured. Shared shape between the live render (routers/device.py's
|
||||
_render_calendar_mode) and this preview endpoint; small enough that
|
||||
duplicating rather than factoring out is fine, since the two call
|
||||
sites differ slightly in error handling."""
|
||||
if not (frame.calendar_view == "agenda" and frame.calendar_photo_inlay):
|
||||
return None
|
||||
url, key = immich_creds(frame)
|
||||
if not (url and key and frame.album_id):
|
||||
return None
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
if not asset_id:
|
||||
return None
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
return Image.open(io.BytesIO(client.download_asset_preview(asset_id)))
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
@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."""
|
||||
if not calendar_sources_for_frame(db, frame):
|
||||
raise HTTPException(400, "No calendars included on this frame yet")
|
||||
events, summary = get_or_refresh_calendar_events(db, frame)
|
||||
photo_inlay = _calendar_photo_inlay(frame, db)
|
||||
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||
png = calendar_render.render_calendar_preview_png(
|
||||
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/firmware")
|
||||
def api_firmware_upload(
|
||||
|
||||
@@ -0,0 +1,997 @@
|
||||
"""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
|
||||
inclusion/color, 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
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
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 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, widget_locked
|
||||
from ..image_pipeline import (
|
||||
DEFAULT_DISPLAY_MODE,
|
||||
DEFAULT_STATIC_DISPLAY_MODE,
|
||||
DISPLAY_MODES,
|
||||
hex_to_rgb,
|
||||
STATIC_DISPLAY_MODES,
|
||||
render_preview_png,
|
||||
)
|
||||
from ..image_upload import decode_upload
|
||||
from ..models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
WIDGET_CONFIG_MODELS,
|
||||
Widget,
|
||||
)
|
||||
from ..text_content import has_text, parse_rich_text
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from ..widgets import text as text_widget
|
||||
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,
|
||||
task_sources_for_widget,
|
||||
valid_http_url,
|
||||
webdav_creds_for,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MIN_QUEUE_TARGET_LEN = 5
|
||||
MAX_QUEUE_TARGET_LEN = 5000
|
||||
MIN_TEXT_FONT_SIZE = 10
|
||||
MAX_TEXT_FONT_SIZE = 96
|
||||
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
|
||||
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
|
||||
|
||||
|
||||
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)
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
return {
|
||||
"orientation": frame.orientation,
|
||||
"grid": {"cols": cols, "rows": rows},
|
||||
"widget_types": list(WIDGET_TYPES.keys()),
|
||||
"min_footprint": grid.MIN_FOOTPRINT,
|
||||
"widgets": [_widget_dict(w) for w in widgets],
|
||||
"control": {
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _other_rects(db: Session, frame_id: int, exclude_widget_id: int | None) -> list[grid.Rect]:
|
||||
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame_id)).all()
|
||||
return [(w.x, w.y, w.w, w.h) for w in widgets if w.id != exclude_widget_id]
|
||||
|
||||
|
||||
def _validate_placement(db: Session, frame: Frame, widget_type: str, rect: grid.Rect,
|
||||
exclude_widget_id: int | None = None) -> None:
|
||||
if not grid.in_bounds(frame.orientation, rect):
|
||||
raise HTTPException(400, "Placement is out of bounds for this frame's grid")
|
||||
if not grid.meets_minimum(widget_type, rect):
|
||||
min_w, min_h = grid.MIN_FOOTPRINT.get(widget_type, (1, 1))
|
||||
raise HTTPException(400, f"A {widget_type} widget needs at least {min_w}x{min_h} grid cells")
|
||||
for other_rect in _other_rects(db, frame.id, exclude_widget_id):
|
||||
if grid.overlaps(rect, other_rect):
|
||||
raise HTTPException(400, "Overlaps another widget")
|
||||
|
||||
|
||||
class WidgetCreateRequest(BaseModel):
|
||||
widget_type: str
|
||||
x: int | None = None
|
||||
y: int | None = None
|
||||
w: int | None = None
|
||||
h: int | None = None
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets")
|
||||
def api_widget_create(
|
||||
body: WidgetCreateRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
||||
):
|
||||
if body.widget_type not in WIDGET_TYPES:
|
||||
raise HTTPException(400, f"Unknown widget type: {body.widget_type}")
|
||||
min_w, min_h = grid.MIN_FOOTPRINT.get(body.widget_type, (1, 1))
|
||||
w, h = body.w or min_w, body.h or min_h
|
||||
|
||||
if body.x is None or body.y is None:
|
||||
rect = grid.find_open_rect(frame.orientation, _other_rects(db, frame.id, None), w, h)
|
||||
if rect is None:
|
||||
raise HTTPException(400, "No open space left for a widget this size")
|
||||
else:
|
||||
rect = (body.x, body.y, w, h)
|
||||
_validate_placement(db, frame, body.widget_type, rect)
|
||||
|
||||
with frame_locked(db, frame.id):
|
||||
max_sort = db.scalar(select(func.max(Widget.sort_order)).where(Widget.frame_id == frame.id))
|
||||
x, y, w, h = rect
|
||||
widget = Widget(frame_id=frame.id, widget_type=body.widget_type, x=x, y=y, w=w, h=h,
|
||||
sort_order=(max_sort or 0) + 1, created_at=time.time())
|
||||
db.add(widget)
|
||||
db.flush()
|
||||
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
|
||||
db.commit()
|
||||
return _widget_dict(widget)
|
||||
|
||||
|
||||
class WidgetPlacementRequest(BaseModel):
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
|
||||
|
||||
@router.patch("/api/frames/{frame_id}/widgets/{widget_id}")
|
||||
def api_widget_move(
|
||||
widget_id: int, body: WidgetPlacementRequest,
|
||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||
):
|
||||
widget = db.get(Widget, widget_id)
|
||||
if widget is None or widget.frame_id != frame.id:
|
||||
raise HTTPException(404, "No such widget")
|
||||
rect = (body.x, body.y, body.w, body.h)
|
||||
_validate_placement(db, frame, widget.widget_type, rect, exclude_widget_id=widget.id)
|
||||
with frame_locked(db, frame.id):
|
||||
widget.x, widget.y, widget.w, widget.h = body.x, body.y, body.w, body.h
|
||||
db.commit()
|
||||
return _widget_dict(widget)
|
||||
|
||||
|
||||
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
|
||||
def api_widget_delete(
|
||||
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Cascades to the widget's own config row and any FrameButtonAction
|
||||
bindings that pointed at it (both ondelete="CASCADE" FKs, see
|
||||
models.py) -- nothing left pointing at a widget id that no longer
|
||||
exists."""
|
||||
widget = db.get(Widget, widget_id)
|
||||
if widget is None or widget.frame_id != frame.id:
|
||||
raise HTTPException(404, "No such widget")
|
||||
with frame_locked(db, frame.id):
|
||||
db.delete(widget)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.delete("/api/frames/{frame_id}/widgets")
|
||||
def api_widgets_clear(frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)):
|
||||
"""The Layout tab's "Clear all" button -- same per-widget cascade as
|
||||
api_widget_delete, just every widget on this frame in one locked
|
||||
transaction instead of one request per widget."""
|
||||
with frame_locked(db, frame.id):
|
||||
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
|
||||
for widget in widgets:
|
||||
db.delete(widget)
|
||||
db.commit()
|
||||
return {"status": "cleared", "count": len(widgets)}
|
||||
|
||||
|
||||
# --- 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 (display_mode is also reused by the static branch below --
|
||||
# each widget's own dialog only ever posts its own fields, so the two
|
||||
# Form(None) uses of the same name never collide)
|
||||
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),
|
||||
# tasks
|
||||
tasks_name: str | None = Form(None),
|
||||
tasks_show_completed: bool | None = Form(None),
|
||||
# text
|
||||
text_html: str | None = Form(None),
|
||||
text_font_size: int | None = Form(None),
|
||||
text_align: str | None = Form(None),
|
||||
text_background_color: str | 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
|
||||
elif widget.widget_type == "tasks":
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
|
||||
if tasks_name is not None:
|
||||
# Truncated, not rejected -- MAX_TASKS_NAME_LEN is a
|
||||
# sane on-panel-header length, not a validation rule the
|
||||
# user needs an error for.
|
||||
tcfg.name = tasks_name.strip()[:MAX_TASKS_NAME_LEN]
|
||||
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
|
||||
tcfg.show_completed = tasks_show_completed
|
||||
tcfg.checked_at = 0.0 # pick up the change promptly
|
||||
elif widget.widget_type == "static":
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
||||
if display_mode is not None:
|
||||
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
|
||||
elif widget.widget_type == "text":
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
|
||||
if text_html is not None:
|
||||
# The one save path that touches app/text_content.py --
|
||||
# see its module docstring for why parsing (not storing
|
||||
# raw HTML) is the actual sanitization boundary here.
|
||||
xcfg.content = parse_rich_text(text_html)
|
||||
if text_font_size is not None:
|
||||
xcfg.font_size = max(MIN_TEXT_FONT_SIZE, min(MAX_TEXT_FONT_SIZE, text_font_size))
|
||||
if text_align is not None:
|
||||
xcfg.align = text_align if text_align in ("left", "center", "right") else "left"
|
||||
if text_background_color is not None:
|
||||
xcfg.background_color = (
|
||||
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
||||
)
|
||||
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/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:<href>", 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
|
||||
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,
|
||||
week_start_offset=ccfg.week_start_offset,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- Tasks: inclusion/color/preview ---------------------------------------
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/tasks")
|
||||
def api_widget_preview_tasks(
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
||||
):
|
||||
"""The same cached merged task list a live device render would use,
|
||||
same "reflects what's currently saved" convention as the other
|
||||
preview endpoints."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "tasks")
|
||||
if not task_sources_for_widget(db, widget):
|
||||
raise HTTPException(400, "No task lists included on this widget yet")
|
||||
tcfg = db.get(TaskWidgetConfig, widget.id)
|
||||
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, title=tcfg.name or "Tasks")
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
class TaskListSelectRequest(BaseModel):
|
||||
user_id: int
|
||||
calendar_key: str
|
||||
calendar_label: str = ""
|
||||
included: bool
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-select")
|
||||
def api_widget_task_list_select(
|
||||
body: TaskListSelectRequest, request: Request,
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Include/exclude one CalDAV task list (calendar_key "caldav:<href>",
|
||||
see FrameTaskList) on this tasks widget -- same one-sided permission
|
||||
split as api_widget_calendar_select: turning a list ON requires being
|
||||
its owner (nobody can add someone else's task list 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 list they'd rather not
|
||||
see there even if they don't own it. Deliberately not
|
||||
require_widget_control for the same reason."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "tasks")
|
||||
user = require_user_api(request, db)
|
||||
if body.included and body.user_id != user.id:
|
||||
raise HTTPException(403, "Only a task list's owner can add it to a frame")
|
||||
row = db.execute(
|
||||
select(FrameTaskList).where(
|
||||
FrameTaskList.widget_id == widget.id,
|
||||
FrameTaskList.user_id == body.user_id,
|
||||
FrameTaskList.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 = FrameTaskList(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(TaskWidgetConfig, widget.id).checked_at = 0.0
|
||||
db.commit()
|
||||
return {"status": "saved", "included": row.included}
|
||||
|
||||
|
||||
class TaskListColorRequest(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}/task-list-color")
|
||||
def api_widget_task_list_color(
|
||||
body: TaskListColorRequest, 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 task
|
||||
lists (models.FrameTaskList.color_index) -- always owner-only, same
|
||||
as api_widget_calendar_color. None clears the pin, reverting
|
||||
calendar_render.py to its auto-cycle-by-owner-name behavior for this
|
||||
list."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "tasks")
|
||||
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(FrameTaskList).where(
|
||||
FrameTaskList.widget_id == widget.id,
|
||||
FrameTaskList.user_id == user.id,
|
||||
FrameTaskList.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(TaskWidgetConfig, widget.id).checked_at = 0.0
|
||||
db.commit()
|
||||
return {"status": "saved", "color_index": row.color_index}
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
# --- Static image: upload/preview -----------------------------------------
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
|
||||
async def api_widget_static_upload(
|
||||
file: UploadFile = File(...),
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Decodes an uploaded PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF (see
|
||||
app/image_upload.py) into plain RGB PNG bytes and stores it as this
|
||||
widget's whole content -- a widget-wide setting like a photos
|
||||
widget's album, hence require_widget_control (the frame's "take
|
||||
control" gate) rather than the calendar/tasks owner-adds/anyone-mutes
|
||||
split, since there's only ever one image and no per-person data."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "static")
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "No file uploaded")
|
||||
image = decode_upload(data)
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
||||
scfg.image = buf.getvalue()
|
||||
scfg.original_filename = (file.filename or "")[:255]
|
||||
scfg.uploaded_at = time.time()
|
||||
return {"status": "saved", "filename": scfg.original_filename}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/static")
|
||||
def api_widget_preview_static(
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
||||
):
|
||||
"""The uploaded image run through this frame's actual saved
|
||||
rendering pipeline (display mode, palette, color/contrast/dithering)
|
||||
-- "how it will look on the frame", same convention as the photos/
|
||||
whiteboard preview endpoints."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "static")
|
||||
scfg = db.get(StaticWidgetConfig, widget.id)
|
||||
if not scfg.image:
|
||||
raise HTTPException(400, "No image uploaded to this widget yet")
|
||||
source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
|
||||
png = render_preview_png(
|
||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
display_mode=scfg.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")
|
||||
|
||||
|
||||
# --- Text: preview ----------------------------------------------------------
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/text")
|
||||
def api_widget_preview_text(
|
||||
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
||||
):
|
||||
"""The saved rich text run through the same word-wrap/shrink-to-fit
|
||||
layout and quantize pass a live device render would use -- same
|
||||
"reflects what's currently saved" convention as the other preview
|
||||
endpoints."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "text")
|
||||
xcfg = db.get(TextWidgetConfig, widget.id)
|
||||
if not has_text(xcfg.content):
|
||||
raise HTTPException(400, "No text authored on this widget yet")
|
||||
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- 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_task_list_select: 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")
|
||||
+439
-104
@@ -5,8 +5,9 @@ from __future__ import annotations
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
@@ -15,15 +16,27 @@ from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import calendar_feed, quiet_hours
|
||||
from ..db import frame_locked
|
||||
from ..image_pipeline import render_frame
|
||||
from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
|
||||
from ..db import widget_locked
|
||||
from ..image_pipeline import logical_render_size
|
||||
from ..immich_client import ImmichClient
|
||||
from ..models import Frame, User, UserFrame
|
||||
from ..models import (
|
||||
BatteryLog,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
WhiteboardWidgetConfig,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRAME_MODES = ("photos", "calendar")
|
||||
FRAME_MODES = ("photos", "calendar", "whiteboard")
|
||||
|
||||
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
||||
@@ -38,8 +51,13 @@ RECHARGE_JUMP_PCT = 5 # a report this much above the recent baseline = battery
|
||||
# still needs to clear all of them, while a single stray low one doesn't
|
||||
# get to set the bar.
|
||||
RECHARGE_LOOKBACK = 3
|
||||
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
|
||||
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
|
||||
BATTERY_ESTIMATE_SAMPLE_COUNT = 100 # most recent battery_log rows considered
|
||||
MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average
|
||||
# Modified z-score cutoff (Iglewicz & Hoaglin's standard figure) for
|
||||
# _reject_outlier_drops -- see that function's docstring for why a
|
||||
# single noisy reading needs rejecting at the per-wake-drop level, not
|
||||
# just at the recharge-detection level.
|
||||
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
|
||||
|
||||
# "Overdue" threshold multiplier: the device should check in roughly every
|
||||
# refresh_interval_s; give it half again as long before flagging it.
|
||||
@@ -67,17 +85,9 @@ def immich_client_for(frame: Frame) -> ImmichClient:
|
||||
return ImmichClient(url, key)
|
||||
|
||||
|
||||
def require_configured(frame: Frame) -> None:
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
if not frame.album_id:
|
||||
raise HTTPException(400, "No album configured yet")
|
||||
|
||||
|
||||
def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
|
||||
def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
|
||||
try:
|
||||
assets = client.list_album_assets(frame.album_id)
|
||||
assets = client.list_album_assets(album_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
||||
if not assets:
|
||||
@@ -85,18 +95,22 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
|
||||
return assets
|
||||
|
||||
|
||||
def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]:
|
||||
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."""
|
||||
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:
|
||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||
|
||||
faces = None
|
||||
if frame.display_mode == "crop_faces":
|
||||
if display_mode == "crop_faces":
|
||||
try:
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
@@ -107,30 +121,125 @@ def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) ->
|
||||
return Image.open(io.BytesIO(jpeg_bytes)), faces
|
||||
|
||||
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
|
||||
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
||||
return render_frame(source, faces=faces, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength, manage=manage)
|
||||
def _avg_wake_interval_s(frame: Frame) -> float:
|
||||
"""Average wall-clock seconds between wakes: refresh_interval_s
|
||||
scaled up for however much of each day quiet hours removes from the
|
||||
wake schedule entirely -- fewer wakes/day, not a cheaper wake. This
|
||||
is what lets battery_estimate_s convert a per-wake drop rate into a
|
||||
remaining-time estimate that reacts to both settings immediately,
|
||||
rather than only after enough new history accumulates under them."""
|
||||
active_day_s = max(1, 86400 - quiet_hours.quiet_span_s(frame))
|
||||
interval_s = max(1, frame.refresh_interval_s)
|
||||
wakes_per_day = max(1, active_day_s // interval_s)
|
||||
return 86400 / wakes_per_day
|
||||
|
||||
|
||||
def battery_estimate_s(frame: Frame) -> int | None:
|
||||
"""Linear remaining-time estimate from the current discharge cycle's
|
||||
observed rate, or None when there's not enough signal to be honest
|
||||
about (too little time observed, or too little drop -- a flat line
|
||||
extrapolates to garbage)."""
|
||||
hist = frame.battery_history
|
||||
if len(hist) < 2:
|
||||
def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, float]]:
|
||||
"""Drops (weight, drop_pct) pairs whose drop is a wild outlier
|
||||
relative to the rest of the recent steps. A single noisy ADC/
|
||||
regulator glitch (see firmware/main/battery.c) corrupts one of the
|
||||
two steps around it, whichever way it reads: a glitch that dips low
|
||||
then recovers makes the step INTO it a spurious huge drop (the step
|
||||
back out is an increase, already excluded above as a "recharge");
|
||||
one that spikes high then settles makes the step OUT OF it the
|
||||
spurious one instead (the step into it is the excluded "recharge").
|
||||
Either way, one bad reading survives the recharge filter looking
|
||||
like an ordinary, legitimately huge drop and swings the whole
|
||||
remaining-time estimate on its own.
|
||||
|
||||
Uses a MAD-based modified z-score (robust to a small number of
|
||||
extreme values in a way a plain mean/stdev z-score isn't -- a single
|
||||
huge outlier inflates the stdev itself, which just hides the outlier
|
||||
from a stdev-based test) rather than a fixed percent-point cutoff, so
|
||||
it adapts to how noisy a given frame's own sensor actually is
|
||||
instead of guessing one global threshold for every install."""
|
||||
drops = [drop for _, drop in steps]
|
||||
median = statistics.median(drops)
|
||||
abs_devs = [abs(d - median) for d in drops]
|
||||
mad = statistics.median(abs_devs)
|
||||
if mad == 0:
|
||||
# The standard median-based MAD degenerates to exactly 0 as soon
|
||||
# as more than half the steps share the median exactly -- and
|
||||
# real battery data is small integer percents, so "most wakes
|
||||
# cost exactly 1%" ties are the norm, not an edge case. That's
|
||||
# precisely the shape a single spliced-in glitch among a steady
|
||||
# discharge rate has (18 steps at "1", one at "26"), so treating
|
||||
# MAD==0 as "no spread, nothing to reject" would let exactly the
|
||||
# outlier this function exists for sail straight through. Fall
|
||||
# back to mean absolute deviation instead, which only reaches 0
|
||||
# when every single step is identical.
|
||||
mad = statistics.mean(abs_devs)
|
||||
if mad == 0:
|
||||
return steps # every step really is identical -- nothing to reject
|
||||
kept = [
|
||||
(weight, drop) for weight, drop in steps
|
||||
if abs(0.6745 * (drop - median) / mad) <= OUTLIER_MODIFIED_Z_THRESHOLD
|
||||
]
|
||||
return kept or steps # never filter down to nothing
|
||||
|
||||
|
||||
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
||||
"""Remaining-time estimate from a recency-weighted average of the
|
||||
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
|
||||
rows of the permanent battery_log table -- not just the current
|
||||
discharge cycle's battery_history, which resets to empty on every
|
||||
recharge and so often doesn't hold enough signal on its own even
|
||||
though the frame has plenty of history overall.
|
||||
|
||||
Consecutive reports are assumed to be consecutive wakes (firmware
|
||||
reports battery on every wake while on battery), so each step's
|
||||
(prev_percent - next_percent) is that wake's cost. A step where
|
||||
percent went *up* is a recharge, not negative drain, and is skipped
|
||||
entirely rather than folded in as a weird outlier; a flat step
|
||||
(0% change) still counts as a real, cheap wake -- excluding those
|
||||
would systematically overstate the per-wake cost by only counting
|
||||
the wakes that happened to tick the percentage down. The remaining
|
||||
steps then get one more pass, _reject_outlier_drops, to catch the
|
||||
single-noisy-reading case that "percent went up" alone can't (see
|
||||
that function's docstring). Steps are weighted linearly by recency
|
||||
(step i of n gets weight i, 1-indexed) so a recent change in usage
|
||||
pattern shows up quickly instead of being washed out by a long flat
|
||||
history.
|
||||
|
||||
The resulting %/wake rate is then converted to wall-clock time using
|
||||
the frame's *current* refresh_interval_s and quiet-hours settings
|
||||
(see _avg_wake_interval_s), not whatever cadence produced the
|
||||
historical data -- so halving refresh_interval_s roughly halves the
|
||||
estimate immediately (not exactly halves: quiet hours removes a
|
||||
fixed wake-free window from every day regardless of interval, which
|
||||
is the "other things going on" that keeps the scaling sublinear)."""
|
||||
if frame.battery_percent < 0:
|
||||
return None
|
||||
first_ts, first_pct = hist[0]
|
||||
last_ts, last_pct = hist[-1]
|
||||
span = last_ts - first_ts
|
||||
drop = first_pct - last_pct
|
||||
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
|
||||
rows = db.execute(
|
||||
select(BatteryLog.percent)
|
||||
.where(BatteryLog.frame_id == frame.id)
|
||||
.order_by(BatteryLog.ts.desc())
|
||||
.limit(BATTERY_ESTIMATE_SAMPLE_COUNT)
|
||||
).scalars().all()
|
||||
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
|
||||
return None
|
||||
rate = drop / span # percent per second
|
||||
return int(last_pct / rate)
|
||||
percents = list(reversed(rows)) # chronological order
|
||||
|
||||
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
|
||||
for i in range(1, len(percents)):
|
||||
prev_pct, next_pct = percents[i - 1], percents[i]
|
||||
if next_pct > prev_pct:
|
||||
continue # recharge (or a swap) -- not a discharge sample
|
||||
steps.append((i, prev_pct - next_pct)) # later steps (larger i) weigh more
|
||||
|
||||
if len(steps) < MIN_ESTIMATE_SAMPLES:
|
||||
return None
|
||||
steps = _reject_outlier_drops(steps)
|
||||
|
||||
weight_total = sum(weight for weight, _ in steps)
|
||||
if weight_total <= 0:
|
||||
return None
|
||||
avg_drop_per_wake = sum(weight * drop for weight, drop in steps) / weight_total
|
||||
if avg_drop_per_wake <= 0:
|
||||
return None # flat -- no honest rate to extrapolate
|
||||
|
||||
remaining_wakes = frame.battery_percent / avg_drop_per_wake
|
||||
return int(remaining_wakes * _avg_wake_interval_s(frame))
|
||||
|
||||
|
||||
def shell_context(request, db: Session, user, active_frame: Frame | None = None,
|
||||
@@ -241,16 +350,63 @@ def _format_taken_at(exif: dict) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _manage_content_asset_id(frame: Frame) -> str | None:
|
||||
"""Whether frame.current_asset_id refers to a photo actually visible
|
||||
right now, for whichever mode is active -- always true in photos
|
||||
mode; only true in calendar mode when the agenda view's photo inlay
|
||||
is on (otherwise current_asset_id could be stale, left over from
|
||||
whenever photos mode last ran, and showing its location/date/share
|
||||
info on a manage overlay over a view with no visible photo at all
|
||||
would be actively misleading, not just unhelpful)."""
|
||||
relevant = frame.mode != "calendar" or (frame.calendar_view == "agenda" and frame.calendar_photo_inlay)
|
||||
return frame.current_asset_id if relevant and frame.current_asset_id else None
|
||||
def widget_of_type(db: Session, frame: Frame, widget_type: str) -> Widget | None:
|
||||
"""The frame's first widget of this type, by placement order. Until
|
||||
the placement UI (a later phase) ships, every frame has at most one
|
||||
widget per type -- the auto-migrated default -- so callers needing
|
||||
"the photo widget" / "the calendar widget" / "the whiteboard widget"
|
||||
for what's still effectively a single-widget-per-type frame use this
|
||||
rather than querying Widget directly. None if the frame has no widget
|
||||
of this type."""
|
||||
return db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == widget_type)
|
||||
.order_by(Widget.sort_order)
|
||||
).first()
|
||||
|
||||
|
||||
def photo_widget_config_or_404(db: Session, frame: Frame) -> tuple[Widget, PhotoWidgetConfig]:
|
||||
"""The frame's photo widget + its config, or a 400 if Immich creds or
|
||||
an album aren't set up yet. Immich creds are frame/owner-level, but
|
||||
album_id lives on PhotoWidgetConfig. Shared by api_frames.py and
|
||||
manage.py, whose photo-related endpoints both need exactly this."""
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
widget = widget_of_type(db, frame, "photos")
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id) if widget else None
|
||||
if widget is None or not cfg.album_id:
|
||||
raise HTTPException(400, "No album configured yet")
|
||||
return widget, cfg
|
||||
|
||||
|
||||
def photo_widgets_for_frame(db: Session, frame: Frame) -> list[Widget]:
|
||||
return db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
|
||||
.order_by(Widget.sort_order)
|
||||
).all()
|
||||
|
||||
|
||||
def _primary_photo_widget(db: Session, frame: Frame, photo_widgets: list[Widget]) -> Widget | None:
|
||||
"""The one photo widget the manage overlay's location/date/share-link
|
||||
boxes show info for -- unlike face labels (which generalize to every
|
||||
photo widget on screen, see build_manage_content), there's only one
|
||||
of each of these fixed panel corners to go around, so with more than
|
||||
one photo widget some single one has to be picked. Resolution rule:
|
||||
whichever photo widget the NEXT button's first assigned action
|
||||
targets, falling back to the first photo widget by placement order
|
||||
if none is button-assigned."""
|
||||
if not photo_widgets:
|
||||
return None
|
||||
next_actions = db.scalars(
|
||||
select(FrameButtonAction)
|
||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == "next")
|
||||
.order_by(FrameButtonAction.sort_order)
|
||||
).all()
|
||||
photo_widget_ids = {w.id for w in photo_widgets}
|
||||
for action in next_actions:
|
||||
if action.widget_id in photo_widget_ids:
|
||||
return next(w for w in photo_widgets if w.id == action.widget_id)
|
||||
return photo_widgets[0]
|
||||
|
||||
|
||||
def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||
@@ -259,82 +415,261 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||
/frame/face-labels, both removed -- see the module docstring in
|
||||
manage_overlay.py) are now just internal calls made here, once,
|
||||
server-side, since compositing itself also moved server-side.
|
||||
management_url and battery_percent always apply; location/date/
|
||||
share-URL/face-labels only when there's a real current photo (see
|
||||
_manage_content_asset_id) -- absent otherwise, which
|
||||
manage_overlay.compose() already treats as "skip that region",
|
||||
exactly the graceful-degradation behavior the old firmware-fetched
|
||||
version had."""
|
||||
management_url and battery_percent always apply. location/date/
|
||||
share-URL come from one "primary" photo widget (see
|
||||
_primary_photo_widget -- there's only one of each of those fixed
|
||||
panel corners, so with more than one photo widget on screen some
|
||||
single one has to be picked); face labels generalize more simply,
|
||||
since manage_overlay.compose() already takes a flat list and draws
|
||||
each one independently -- every photo widget's own named faces get
|
||||
concatenated in, each positioned within that widget's own region
|
||||
(see face_labels.compute_face_labels' region param) rather than as
|
||||
if a photo filled the whole panel."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
content: dict = {
|
||||
"management_url": f"{base}/m/{frame.manage_token}",
|
||||
"battery_percent": frame.battery_percent,
|
||||
}
|
||||
|
||||
asset_id = _manage_content_asset_id(frame)
|
||||
if not asset_id:
|
||||
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||
if not photo_widgets:
|
||||
return content
|
||||
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
asset = client.get_asset(asset_id)
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch manage-overlay photo info for asset %s: %s", asset_id, e)
|
||||
return content
|
||||
|
||||
exif = asset.get("exifInfo") or {}
|
||||
content["location_lines"] = _format_location(exif)
|
||||
content["taken_at"] = _format_taken_at(exif)
|
||||
content["share_url"] = f"{base}/frame/share/{asset_id}"
|
||||
|
||||
if any((face.get("person") or {}).get("name") for face in faces):
|
||||
primary = _primary_photo_widget(db, frame, photo_widgets)
|
||||
primary_cfg = db.get(PhotoWidgetConfig, primary.id) if primary else None
|
||||
if primary_cfg and primary_cfg.current_asset_id:
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(asset_id)
|
||||
from ..face_labels import compute_face_labels
|
||||
|
||||
content["face_labels"] = compute_face_labels(preview_bytes, faces, frame.display_mode, frame.orientation)
|
||||
asset = client.get_asset(primary_cfg.current_asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
||||
logger.warning(
|
||||
"Could not fetch manage-overlay photo info for asset %s: %s", primary_cfg.current_asset_id, e
|
||||
)
|
||||
else:
|
||||
exif = asset.get("exifInfo") or {}
|
||||
content["location_lines"] = _format_location(exif)
|
||||
content["taken_at"] = _format_taken_at(exif)
|
||||
content["share_url"] = f"{base}/frame/share/{primary_cfg.current_asset_id}"
|
||||
|
||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||
face_labels: list[dict] = []
|
||||
for widget in photo_widgets:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.current_asset_id:
|
||||
continue
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(cfg.current_asset_id)
|
||||
faces = client.get_asset_faces(cfg.current_asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch manage-overlay face info for asset %s: %s", cfg.current_asset_id, e)
|
||||
continue
|
||||
if not any((face.get("person") or {}).get("name") for face in faces):
|
||||
continue # no Immich-identified person on this widget's current photo -- nothing to label
|
||||
|
||||
from ..face_labels import compute_face_labels
|
||||
|
||||
region = grid.cell_to_pixels(frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h))
|
||||
face_labels.extend(compute_face_labels(preview_bytes, faces, cfg.display_mode, frame.orientation,
|
||||
region=region))
|
||||
|
||||
if face_labels:
|
||||
content["face_labels"] = face_labels
|
||||
return content
|
||||
|
||||
|
||||
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[tuple[str, str]]:
|
||||
"""Every user linked to this frame with BOTH a calendar URL set AND
|
||||
explicit per-frame opt-in (UserFrame.calendar_included) -- the exact
|
||||
set calendar_feed.merge_events needs. [(display_name-or-username,
|
||||
ics_url), ...]."""
|
||||
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:<href>" resolves against the href itself, authenticated with
|
||||
the owner's CalDAV account credentials (see caldav_client.py)."""
|
||||
rows = db.execute(
|
||||
select(User)
|
||||
.join(UserFrame, UserFrame.user_id == User.id)
|
||||
.where(UserFrame.frame_id == frame.id, UserFrame.calendar_included == True, # noqa: E712
|
||||
User.calendar_ics_url != "")
|
||||
).scalars().all()
|
||||
return [(u.display_name or u.username, u.calendar_ics_url) for u in rows]
|
||||
select(FrameCalendar, User)
|
||||
.join(User, User.id == FrameCalendar.user_id)
|
||||
.where(FrameCalendar.widget_id == widget.id, FrameCalendar.included == True) # noqa: E712
|
||||
).all()
|
||||
sources = []
|
||||
for fc, u in rows:
|
||||
name = u.display_name or u.username
|
||||
if fc.calendar_key == "ics":
|
||||
if u.calendar_ics_url:
|
||||
sources.append(calendar_feed.CalendarSource(
|
||||
name, "ics", u.calendar_ics_url, color_index=fc.color_index
|
||||
))
|
||||
elif fc.calendar_key.startswith("caldav:") and u.calendar_caldav_username:
|
||||
href = fc.calendar_key[len("caldav:"):]
|
||||
sources.append(calendar_feed.CalendarSource(
|
||||
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password,
|
||||
color_index=fc.color_index,
|
||||
))
|
||||
return sources
|
||||
|
||||
|
||||
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
|
||||
def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
|
||||
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
|
||||
-- same shape as the Gitea release-check throttle in api_frames.py's
|
||||
api_firmware_check. One shared cache for the 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."""
|
||||
api_firmware_check -- reading/writing CalendarWidgetConfig (see
|
||||
app/widgets/calendar.py, which this backs). One shared cache for the
|
||||
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."""
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
now = time.time()
|
||||
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
return frame.calendar_cached_events, frame.calendar_fetch_summary
|
||||
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,
|
||||
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
|
||||
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
|
||||
)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_cached_events = events
|
||||
locked.calendar_fetch_summary = summary
|
||||
locked.calendar_checked_at = now
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
locked_cfg.cached_events = events
|
||||
locked_cfg.fetch_summary = summary
|
||||
locked_cfg.checked_at = now
|
||||
return events, summary
|
||||
|
||||
|
||||
def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
|
||||
"""Throttled per-city forecast cache (weather.CHECK_INTERVAL_S, much
|
||||
longer than calendar_feed's -- weather doesn't need to be that
|
||||
fresh), reading/writing CalendarWidgetConfig (see
|
||||
app/widgets/calendar.py). [] if weather's off or no cities are
|
||||
configured. A city whose refetch fails keeps its last-known days
|
||||
rather than going blank for one bad cycle -- calendar_render.py
|
||||
would otherwise show a real city as having no forecast at all just
|
||||
because one refresh hit a network hiccup."""
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
if not cfg.weather_enabled or not cfg.weather_cities:
|
||||
return []
|
||||
now = time.time()
|
||||
if cfg.weather_cached is not None and now - cfg.weather_checked_at < weather.CHECK_INTERVAL_S:
|
||||
return cfg.weather_cached
|
||||
|
||||
previous_days = {c["label"]: c.get("days", {}) for c in (cfg.weather_cached or [])}
|
||||
result = []
|
||||
for city in cfg.weather_cities:
|
||||
try:
|
||||
days = weather.fetch_daily_forecast(city["latitude"], city["longitude"], cfg.weather_units)
|
||||
except weather.WeatherFetchError as e:
|
||||
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
|
||||
days = previous_days.get(city["label"], {})
|
||||
result.append({"label": city["label"], "days": days})
|
||||
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
locked_cfg.weather_cached = result
|
||||
locked_cfg.weather_checked_at = now
|
||||
return result
|
||||
|
||||
|
||||
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
|
||||
|
||||
|
||||
def task_sources_for_widget(db: Session, widget: Widget) -> list[caldav_client.TaskSource]:
|
||||
"""Every task list included on this tasks widget (FrameTaskList.
|
||||
included) -- the exact set caldav_client.merge_tasks needs. CalDAV
|
||||
only (calendar_key is always "caldav:<href>" -- no "ics" variant, a
|
||||
plain ICS subscription has no VTODO collection), resolved against
|
||||
the owning user's CalDAV account credentials."""
|
||||
rows = db.execute(
|
||||
select(FrameTaskList, User)
|
||||
.join(User, User.id == FrameTaskList.user_id)
|
||||
.where(FrameTaskList.widget_id == widget.id, FrameTaskList.included == True) # noqa: E712
|
||||
).all()
|
||||
sources = []
|
||||
for ftl, u in rows:
|
||||
if not ftl.calendar_key.startswith("caldav:") or not u.calendar_caldav_username:
|
||||
continue
|
||||
href = ftl.calendar_key[len("caldav:"):]
|
||||
sources.append(caldav_client.TaskSource(
|
||||
u.display_name or u.username, href, u.calendar_caldav_username, u.calendar_caldav_password,
|
||||
color_index=ftl.color_index,
|
||||
))
|
||||
return sources
|
||||
|
||||
|
||||
def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
|
||||
"""Throttled multi-list merge-fetch cache (calendar_feed.
|
||||
CHECK_INTERVAL_S, same cadence as event merging), reading/writing
|
||||
TaskWidgetConfig (see app/widgets/tasks.py). [] if no list is
|
||||
included yet. Same posture as get_or_refresh_calendar_events_for_
|
||||
widget (which this otherwise mirrors closely), not weather's own
|
||||
per-city stale-cache fallback: a broken list just contributes
|
||||
nothing to this cycle's merge (logged in fetch_summary) rather than
|
||||
silently keeping its last-known tasks around."""
|
||||
cfg = db.get(TaskWidgetConfig, widget.id)
|
||||
now = time.time()
|
||||
if cfg.cached is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
return cfg.cached
|
||||
|
||||
sources = task_sources_for_widget(db, widget)
|
||||
if not sources:
|
||||
return []
|
||||
completed_since = datetime.now(timezone.utc) - timedelta(hours=TASKS_COMPLETED_WINDOW_HOURS) \
|
||||
if cfg.show_completed else None
|
||||
tasks, summary = caldav_client.merge_tasks(sources, completed_since=completed_since)
|
||||
if summary:
|
||||
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, summary)
|
||||
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
locked_cfg.cached = tasks
|
||||
locked_cfg.checked_at = now
|
||||
return tasks
|
||||
|
||||
|
||||
def webdav_creds_for(user: User) -> tuple[str, str] | None:
|
||||
"""(username, password) for `user`'s WebDAV access -- their own
|
||||
dedicated webdav_username/password, or (if they opted in)
|
||||
calendar_caldav_username/password reused from their CalDAV account
|
||||
(see models.py's User docstring on webdav_reuse_caldav_creds). None
|
||||
if neither is actually set up."""
|
||||
if user.webdav_reuse_caldav_creds:
|
||||
if user.calendar_caldav_username:
|
||||
return user.calendar_caldav_username, user.calendar_caldav_password
|
||||
return None
|
||||
if user.webdav_username:
|
||||
return user.webdav_username, user.webdav_password
|
||||
return None
|
||||
|
||||
|
||||
def get_or_refresh_whiteboard_for_widget(
|
||||
db: Session, frame: Frame, widget: Widget, force: bool = False
|
||||
) -> bytes | None:
|
||||
"""Throttled render cache (calendar_feed.CHECK_INTERVAL_S), reading/
|
||||
writing WhiteboardWidgetConfig (see app/widgets/whiteboard.py) --
|
||||
None if no whiteboard source is configured, credentials are missing
|
||||
(e.g. the owning user unlinked their WebDAV/CalDAV account), or the
|
||||
most recent fetch/render failed and nothing was ever cached yet. A
|
||||
failure after a previous success keeps showing the last good render
|
||||
rather than going blank for one bad refresh cycle, same reasoning as
|
||||
get_or_refresh_weather_for_widget/get_or_refresh_tasks_for_widget.
|
||||
force=True (the web UI's "Refresh now" button) skips the throttle
|
||||
entirely -- unlike a device's normal wake, a person clicking a
|
||||
button means do it right now, not eventually once the cache goes
|
||||
stale."""
|
||||
cfg = db.get(WhiteboardWidgetConfig, widget.id)
|
||||
if not cfg.url or not cfg.user_id:
|
||||
return None
|
||||
now = time.time()
|
||||
if not force and cfg.cached_image is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
return cfg.cached_image
|
||||
|
||||
user = db.get(User, cfg.user_id)
|
||||
creds = webdav_creds_for(user) if user else None
|
||||
if creds is None:
|
||||
return cfg.cached_image
|
||||
|
||||
try:
|
||||
png = whiteboard.fetch_and_render(cfg.url, creds[0], creds[1])
|
||||
except whiteboard.WhiteboardRenderError as e:
|
||||
logger.warning("Could not refresh whiteboard for widget %d: %s", widget.id, e)
|
||||
return cfg.cached_image
|
||||
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
locked_cfg.cached_image = png
|
||||
locked_cfg.checked_at = now
|
||||
return png
|
||||
|
||||
+144
-154
@@ -22,24 +22,22 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import calendar_render, mail, photo_queue, quiet_hours
|
||||
from .. import grid, mail, quiet_hours
|
||||
from ..auth import get_server_settings, require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..firmware import firmware_path
|
||||
from ..image_pipeline import render_placeholder
|
||||
from ..models import BatteryLog, Frame
|
||||
from ..image_pipeline import logical_render_size, render_panel, render_placeholder
|
||||
from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from .common import (
|
||||
BATTERY_HISTORY_MAX,
|
||||
BATTERY_LOG_MAX,
|
||||
RECHARGE_JUMP_PCT,
|
||||
RECHARGE_LOOKBACK,
|
||||
build_manage_content,
|
||||
get_or_refresh_calendar_events,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
render_asset,
|
||||
require_configured,
|
||||
photo_widgets_for_frame,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,12 +45,13 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
|
||||
"""What an unclaimed or not-yet-configured frame displays instead of a
|
||||
photo -- instructions with a QR, rendered at 200 so the device treats
|
||||
it as a perfectly normal image and never error-loops. The URLs are
|
||||
built from the request's own base URL: whatever address the device
|
||||
reached us at is by definition an address that works on this
|
||||
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
|
||||
as_png: bool = False) -> bytes:
|
||||
"""What an unclaimed or widget-less frame displays instead of real
|
||||
content -- instructions with a QR, rendered at 200 so the device
|
||||
treats it as a perfectly normal image and never error-loops. The
|
||||
URLs are built from the request's own base URL: whatever address the
|
||||
device reached us at is by definition an address that works on this
|
||||
network."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
if frame.owner_user_id is None and frame.device_id:
|
||||
@@ -63,6 +62,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
as_png=as_png,
|
||||
)
|
||||
if frame.owner_user_id is None:
|
||||
return render_placeholder(
|
||||
@@ -70,137 +70,115 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
as_png=as_png,
|
||||
)
|
||||
return render_placeholder(
|
||||
["Almost there!", "Pick an album for this frame:", base],
|
||||
["Almost there!", "Add a widget for this frame at", base],
|
||||
qr_url=base,
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
as_png=as_png,
|
||||
)
|
||||
|
||||
|
||||
def _frame_configured(frame: Frame) -> bool:
|
||||
url, key = immich_creds(frame)
|
||||
return bool(url and key and frame.album_id)
|
||||
|
||||
|
||||
# --- photos mode ---
|
||||
|
||||
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||
is_normal_wake: bool) -> bytes:
|
||||
if not _frame_configured(frame):
|
||||
return _setup_placeholder(frame, request, manage=manage)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id, manage=manage)
|
||||
|
||||
|
||||
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.advance_forced(locked, assets)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id, manage=manage)
|
||||
|
||||
|
||||
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.back_forced(locked, assets)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id, manage=manage)
|
||||
|
||||
|
||||
# --- calendar mode ---
|
||||
|
||||
def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||
is_normal_wake: bool) -> bytes:
|
||||
from .common import calendar_sources_for_frame
|
||||
|
||||
if not calendar_sources_for_frame(db, frame):
|
||||
return render_placeholder(
|
||||
["This frame's calendar isn't set up yet",
|
||||
"Add a calendar in Settings, then include it on",
|
||||
"this frame's Configuration -> Calendar card."],
|
||||
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
|
||||
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
||||
as_png: bool = False) -> bytes:
|
||||
"""The widget-system compositor: renders every widget on this frame
|
||||
into its own region (see app/grid.py for grid-cell -> pixel math) and
|
||||
hands the results to image_pipeline.render_panel for the single
|
||||
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
|
||||
per-mode RENDERERS dict -- a frame can now show several widgets at
|
||||
once instead of exactly one mode owning the whole panel."""
|
||||
all_widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||
regions = []
|
||||
for widget in all_widgets:
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
if module is None:
|
||||
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
||||
px, py, pw, ph = grid.cell_to_pixels(
|
||||
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
|
||||
)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
if is_normal_wake and locked.calendar_browse_offset != 0:
|
||||
locked.calendar_browse_offset = 0
|
||||
browse_offset = locked.calendar_browse_offset
|
||||
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||
inlay_wanted = locked.calendar_photo_inlay and view == "agenda"
|
||||
|
||||
events, summary = get_or_refresh_calendar_events(db, frame)
|
||||
|
||||
photo_inlay = None
|
||||
if inlay_wanted and _frame_configured(frame):
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
if asset_id:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
photo_inlay = Image.open(io.BytesIO(jpeg_bytes))
|
||||
except HTTPException:
|
||||
pass # inlay is nice-to-have -- an Immich hiccup shouldn't blank the whole agenda
|
||||
|
||||
return calendar_render.render_calendar(
|
||||
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
|
||||
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage,
|
||||
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
||||
regions.append(((px, py, pw, ph), img))
|
||||
return render_panel(
|
||||
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
|
||||
)
|
||||
|
||||
|
||||
def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
"""NEXT in calendar mode: moves the displayed period forward one step
|
||||
(day for agenda, week for week view, month for month view) from
|
||||
wherever it currently is -- not from "today" -- so repeated presses
|
||||
walk further forward. See Frame.calendar_browse_offset."""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_browse_offset += 1
|
||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
||||
is_normal_wake: bool, as_png: bool = False) -> bytes:
|
||||
"""The top-level "what does this frame show right now" entry point.
|
||||
An unclaimed frame or one with no widgets yet gets the setup
|
||||
placeholder (needs `request` for its QR URLs -- only available on the
|
||||
normal-wake path where a real request is on hand, never on an
|
||||
advance/back button press); otherwise every widget on it gets
|
||||
composited via _render_widgets. Individual widgets that are
|
||||
themselves unconfigured show their own small placeholder within
|
||||
their own region (see app/widgets/*.py) rather than blanking the
|
||||
whole panel -- a partially-set-up multi-widget frame still shows
|
||||
whatever IS configured."""
|
||||
has_widgets = frame.owner_user_id is not None and (
|
||||
db.scalars(select(Widget.id).where(Widget.frame_id == frame.id).limit(1)).first() is not None
|
||||
)
|
||||
if not has_widgets:
|
||||
if request is None:
|
||||
return render_placeholder(
|
||||
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
manage=manage, as_png=as_png,
|
||||
)
|
||||
return _setup_placeholder(frame, request, manage=manage, as_png=as_png)
|
||||
|
||||
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png)
|
||||
|
||||
|
||||
def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_browse_offset -= 1
|
||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
|
||||
"""The web UI's live "how it's displaying" thumbnail (see
|
||||
routers/api_frames.py's /preview endpoint) -- same compositor
|
||||
/frame/image uses, just handed back as a small upright PNG instead of
|
||||
packed native-panel bytes. Exported from here (rather than
|
||||
duplicated) since this module already owns the full widget-
|
||||
compositing pipeline; nothing about the /frame/* paths themselves
|
||||
changes."""
|
||||
return _render_frame_content(db, frame, request, manage=None, is_normal_wake=True, as_png=True)
|
||||
|
||||
|
||||
RENDERERS = {
|
||||
"photos": _render_photos_mode,
|
||||
"calendar": _render_calendar_mode,
|
||||
}
|
||||
ADVANCE_RENDERERS = {
|
||||
"photos": _advance_photos_mode,
|
||||
"calendar": _advance_calendar_mode,
|
||||
}
|
||||
BACK_RENDERERS = {
|
||||
"photos": _back_photos_mode,
|
||||
"calendar": _back_calendar_mode,
|
||||
}
|
||||
def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
|
||||
"""Executes every (widget, action) binding assigned to this physical
|
||||
button, in order -- see models.FrameButtonAction and the button-
|
||||
assignment UI (a later phase). Each action runs to completion (its
|
||||
own widget_locked span) before the next one starts -- never nested,
|
||||
since db.widget_locked's underlying lock isn't reentrant (see its own
|
||||
docstring) -- a button assigned several actions would deadlock
|
||||
instantly if this looped any other way. One action failing
|
||||
unexpectedly doesn't block the others, or the eventual re-render,
|
||||
from happening -- the user pressed a physical button and expects
|
||||
*something* to happen even if one of several assigned widgets is
|
||||
having a bad moment."""
|
||||
actions = db.scalars(
|
||||
select(FrameButtonAction)
|
||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
|
||||
.order_by(FrameButtonAction.sort_order)
|
||||
).all()
|
||||
for action_row in actions:
|
||||
widget = db.get(Widget, action_row.widget_id)
|
||||
if widget is None:
|
||||
continue
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
action_fn = module.ACTIONS.get(action_row.action) if module else None
|
||||
if action_fn is None:
|
||||
continue
|
||||
try:
|
||||
action_fn(db, frame, widget)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Button action %r failed for widget %d (frame %d)", action_row.action, widget.id, frame.id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/frame/config")
|
||||
@@ -246,42 +224,47 @@ def _manage_flag(request: Request) -> bool:
|
||||
def frame_image(
|
||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Returns the frame's current image. For photos mode: idempotent --
|
||||
only actually advances to the next photo once refresh_interval_s has
|
||||
elapsed since the current one was set (see app/photo_queue.py) --
|
||||
safe to call as often as the device wants, including after an
|
||||
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
||||
unconfigured frame gets a rendered instruction placeholder (200, not
|
||||
an error) so a fresh device never error-loops.
|
||||
"""Returns the frame's current image -- every widget on the frame
|
||||
composited into one panel (see _render_widgets). Each widget's own
|
||||
render is idempotent in whatever way makes sense for its type (e.g.
|
||||
a photo widget only actually advances once its own refresh interval
|
||||
has elapsed, see app/photo_queue.py) -- safe to call as often as the
|
||||
device wants, including after an unplanned reboot, without skipping
|
||||
ahead. An unclaimed frame or one with no widgets yet gets a rendered
|
||||
instruction placeholder (200, not an error) so a fresh device never
|
||||
error-loops.
|
||||
|
||||
?manage=1 (the manage button) composites the manage overlay onto
|
||||
whatever this would have returned anyway -- see build_manage_content.
|
||||
For calendar mode, this is also the "normal wake" that resets
|
||||
calendar_browse_offset back to 0 (see _render_calendar_mode)."""
|
||||
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
|
||||
This is also the "normal wake" that resets any calendar widget's
|
||||
browse position back to today (see app/widgets/calendar.py)."""
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = renderer(db, frame, request, manage, True)
|
||||
content = _render_frame_content(db, frame, request, manage, is_normal_wake=True)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/advance")
|
||||
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Forces an immediate move forward -- the next photo in photos mode,
|
||||
or the next day/week/month in calendar mode -- ignoring
|
||||
refresh_interval_s. Used by the device's next-photo button."""
|
||||
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
|
||||
"""Forces an immediate move forward on whatever widget(s) the NEXT
|
||||
button is assigned to (see models.FrameButtonAction) -- e.g. the next
|
||||
photo for a photo widget, or the next day/week/month for a calendar
|
||||
widget -- then re-renders and returns the whole panel. Used by the
|
||||
device's next-photo button."""
|
||||
_run_button_actions(db, frame, "next")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/back")
|
||||
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""The mirror of /frame/advance -- back a photo in photos mode, back
|
||||
a period in calendar mode. A no-op (still 200, unchanged) if there's
|
||||
nothing to go back to. Used by the device's back-photo button."""
|
||||
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
|
||||
"""The mirror of /frame/advance, for whatever widget(s) the BACK
|
||||
button is assigned to. A no-op (still 200, unchanged) for any widget
|
||||
with nothing to go back to. Used by the device's back-photo button."""
|
||||
_run_button_actions(db, frame, "back")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
class BatteryReport(BaseModel):
|
||||
@@ -376,18 +359,25 @@ def frame_firmware(frame: Frame = Depends(require_device)):
|
||||
|
||||
|
||||
@router.get("/frame/share/{asset_id}")
|
||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Creates a 30-minute public Immich share link for asset_id and
|
||||
redirects to it -- what the manage overlay's bottom-left QR code
|
||||
points to. The link is created lazily, when this actually gets hit
|
||||
(i.e. when someone scans it), not when the manage button was
|
||||
pressed, so the 30-minute window starts when it's actually used.
|
||||
Also scoped to the photo currently showing or queued on THIS frame --
|
||||
not any arbitrary Immich asset id -- as a second layer even a leaked
|
||||
token wouldn't bypass."""
|
||||
require_configured(frame)
|
||||
Also scoped to the photo currently showing or queued on one of THIS
|
||||
frame's own photo widgets -- not any arbitrary Immich asset id -- as
|
||||
a second layer even a leaked token wouldn't bypass."""
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||
showing_or_queued = any(
|
||||
asset_id == cfg.current_asset_id or asset_id in cfg.queue
|
||||
for cfg in (db.get(PhotoWidgetConfig, w.id) for w in photo_widgets)
|
||||
)
|
||||
if not showing_or_queued:
|
||||
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
|
||||
|
||||
client = immich_client_for(frame)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and
|
||||
Stats tabs, all inside the sidebar app shell. Data loading happens
|
||||
client-side against /api/frames/{id}/... (routers/api_frames.py); these
|
||||
routes just authorize and render the scaffold."""
|
||||
"""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
|
||||
|
||||
@@ -18,11 +24,25 @@ from ..image_pipeline import (
|
||||
DEFAULT_PALETTE_RGB,
|
||||
DISPLAY_MODE_LABELS,
|
||||
PALETTE_LABELS,
|
||||
STATIC_DISPLAY_MODES,
|
||||
palette_to_hex,
|
||||
)
|
||||
from ..models import Frame, User, UserFrame
|
||||
from ..models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameCalendar,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
User,
|
||||
UserFrame,
|
||||
WhiteboardWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
from ..quiet_hours import ALL_TIMEZONES
|
||||
from .common import shell_context
|
||||
from .common import shell_context, widget_of_type
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
@@ -41,43 +61,206 @@ def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
|
||||
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
|
||||
|
||||
|
||||
def _calendar_users_for_frame(db: Session, frame_id: int) -> list[dict]:
|
||||
"""Every user linked to this frame, their calendar opt-in state, and
|
||||
whether they even have a calendar URL set -- what the Configuration
|
||||
tab's "Included calendars" list needs. Whether a given row is *this*
|
||||
viewer's own (and therefore editable) is decided in the template,
|
||||
using the `user` shell_context already provides."""
|
||||
rows = db.execute(
|
||||
select(User, UserFrame.calendar_included)
|
||||
.join(UserFrame, UserFrame.user_id == User.id)
|
||||
.where(UserFrame.frame_id == frame_id)
|
||||
.order_by(User.username)
|
||||
).all()
|
||||
return [
|
||||
{"user_id": u.id, "display_name": u.display_name or u.username,
|
||||
"has_url": bool(u.calendar_ics_url), "included": included}
|
||||
for u, included in rows
|
||||
]
|
||||
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,
|
||||
display_mode_labels=DISPLAY_MODE_LABELS,
|
||||
calendar_views=CALENDAR_VIEW_LABELS,
|
||||
calendar_users=_calendar_users_for_frame(db, frame_id),
|
||||
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")
|
||||
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 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."""
|
||||
calendars = []
|
||||
if user.calendar_ics_url:
|
||||
calendars.append({"key": "ics", "label": "My calendar (ICS)"})
|
||||
for c in (user.calendar_caldav_calendars or []):
|
||||
calendars.append({"key": f"caldav:{c['href']}", "label": c.get("display_name") or "Calendar"})
|
||||
return calendars
|
||||
|
||||
|
||||
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_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.widget_id == widget_id)).scalars().all():
|
||||
included_by_user.setdefault(fc.user_id, []).append(fc)
|
||||
|
||||
result = []
|
||||
for u in users:
|
||||
is_self = u.id == viewer_id
|
||||
if is_self:
|
||||
own_rows = {fc.calendar_key: fc for fc in included_by_user.get(u.id, [])}
|
||||
calendars = [
|
||||
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
|
||||
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
|
||||
for c in _user_available_calendars(u)
|
||||
]
|
||||
else:
|
||||
calendars = [
|
||||
{"key": fc.calendar_key, "label": fc.calendar_label, "included": True}
|
||||
for fc in included_by_user.get(u.id, []) if fc.included
|
||||
]
|
||||
result.append({
|
||||
"user_id": u.id, "display_name": u.display_name or u.username,
|
||||
"is_self": is_self, "calendars": calendars,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _task_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
|
||||
"""Per-linked-user task-list list for the tasks dialog's "Included
|
||||
task lists" section -- same shape as _calendar_users_for_widget,
|
||||
restricted to CalDAV calendars only (no "ics" option: a plain ICS
|
||||
subscription has no VTODO collection to speak of, see
|
||||
caldav_client.fetch_tasks)."""
|
||||
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[FrameTaskList]] = {}
|
||||
for ftl in db.execute(select(FrameTaskList).where(FrameTaskList.widget_id == widget_id)).scalars().all():
|
||||
included_by_user.setdefault(ftl.user_id, []).append(ftl)
|
||||
|
||||
result = []
|
||||
for u in users:
|
||||
is_self = u.id == viewer_id
|
||||
available = [c for c in _user_available_calendars(u) if c["key"].startswith("caldav:")]
|
||||
if is_self:
|
||||
own_rows = {ftl.calendar_key: ftl for ftl in included_by_user.get(u.id, [])}
|
||||
task_lists = [
|
||||
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
|
||||
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
|
||||
for c in available
|
||||
]
|
||||
else:
|
||||
task_lists = [
|
||||
{"key": ftl.calendar_key, "label": ftl.calendar_label, "included": True}
|
||||
for ftl in included_by_user.get(u.id, []) if ftl.included
|
||||
]
|
||||
result.append({
|
||||
"user_id": u.id, "display_name": u.display_name or u.username,
|
||||
"is_self": is_self, "task_lists": task_lists,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
|
||||
"""Whose account this widget currently fetches with, for showing
|
||||
"using <name>'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:
|
||||
return None
|
||||
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url}
|
||||
|
||||
|
||||
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 <dialog> 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)
|
||||
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)
|
||||
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,
|
||||
})
|
||||
|
||||
if widget.widget_type == "tasks":
|
||||
task_cfg = db.get(TaskWidgetConfig, widget.id)
|
||||
return templates.TemplateResponse("_widget_dialog_tasks.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
|
||||
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
|
||||
"task_color_labels": PALETTE_LABELS,
|
||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
||||
"palette_to_hex": palette_to_hex,
|
||||
})
|
||||
|
||||
if widget.widget_type == "static":
|
||||
static_cfg = db.get(StaticWidgetConfig, widget.id)
|
||||
return templates.TemplateResponse("_widget_dialog_static.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
|
||||
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
|
||||
})
|
||||
|
||||
if widget.widget_type == "text":
|
||||
text_cfg = db.get(TextWidgetConfig, widget.id)
|
||||
return templates.TemplateResponse("_widget_dialog_text.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
|
||||
})
|
||||
|
||||
if widget.widget_type == "whiteboard":
|
||||
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
|
||||
viewer_has_webdav_creds = bool(
|
||||
user.webdav_username or (user.webdav_reuse_caldav_creds and user.calendar_caldav_username)
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|
||||
|
||||
@@ -18,9 +18,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import photo_queue, quiet_hours
|
||||
from ..db import frame_locked, get_db
|
||||
from ..db import get_db, widget_locked
|
||||
from ..models import Frame
|
||||
from .common import immich_client_for, list_assets, require_configured
|
||||
from .common import immich_client_for, list_assets, photo_widget_config_or_404
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,15 +46,16 @@ def manage_page(manage_token: str, request: Request, db: Session = Depends(get_d
|
||||
|
||||
@router.get("/api/m/{manage_token}/queue")
|
||||
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
require_configured(frame)
|
||||
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
assets = list_assets(client, pcfg.album_id)
|
||||
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
current = cfg.current_asset_id
|
||||
queue = list(cfg.queue)
|
||||
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)
|
||||
current = locked_pcfg.current_asset_id
|
||||
queue = list(locked_pcfg.queue)
|
||||
|
||||
def entry(asset_id: str) -> dict:
|
||||
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
|
||||
@@ -76,7 +77,8 @@ def manage_promote(
|
||||
frame: Frame = Depends(require_manage),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_widget, _ = photo_widget_config_or_404(db, 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] + [a for a in cfg.queue if a != body.asset_id]
|
||||
@@ -87,29 +89,30 @@ def manage_promote(
|
||||
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
"""Advances the server-side current photo; the panel itself updates
|
||||
on the device's next wake (or its next-photo button)."""
|
||||
require_configured(frame)
|
||||
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.advance_forced(cfg, assets)
|
||||
assets = list_assets(client, pcfg.album_id)
|
||||
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
||||
photo_queue.advance_forced(locked_pcfg, assets, locked_frame)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.post("/api/m/{manage_token}/back")
|
||||
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
require_configured(frame)
|
||||
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.back_forced(cfg, assets)
|
||||
assets = list_assets(client, pcfg.album_id)
|
||||
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
||||
photo_queue.back_forced(locked_pcfg, assets, locked_frame)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
|
||||
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
|
||||
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
"""Thumbnails scoped to what this frame is actually showing/queuing --
|
||||
the manage token must not become a general Immich proxy."""
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
_, 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:
|
||||
|
||||
@@ -18,7 +18,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import mail
|
||||
from .. import caldav_client, mail
|
||||
from ..auth import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_LIFETIME_S,
|
||||
@@ -30,6 +30,7 @@ from ..auth import (
|
||||
destroy_session,
|
||||
get_server_settings,
|
||||
hash_password,
|
||||
require_user_api,
|
||||
users_exist,
|
||||
verify_password,
|
||||
)
|
||||
@@ -309,6 +310,23 @@ def _render_claim(request: Request, db: Session, device_id: str, error: str | No
|
||||
frame.owner_user_id == user.id or db.get(UserFrame, (user.id, frame.id)) is not None
|
||||
):
|
||||
status, pending_yours = "claimed_yours", False
|
||||
if frame.device_token_ack:
|
||||
# The device's captive portal redirects here on EVERY
|
||||
# (re)provisioning cycle (see wifi_provisioning.c) -- if the
|
||||
# physical frame was reset/reprovisioned, it no longer has
|
||||
# the access token this frame row already acknowledged, and
|
||||
# auth.require_device permanently locks out an id-only
|
||||
# request once device_token_ack is set (device_id alone,
|
||||
# unlike the token, isn't secret -- it's shown on the
|
||||
# frame's own screen/QR). Reopening that handshake window
|
||||
# here is what "give it a minute to connect" below actually
|
||||
# depends on: it's safe because landing on this branch
|
||||
# already requires knowing the device_id (physical/local
|
||||
# access to the frame) AND being logged in as an owner/
|
||||
# linked user of it.
|
||||
frame.device_token_ack = False
|
||||
db.commit()
|
||||
logger.info("Frame #%d's device token handshake reopened (re-provisioned)", frame.id)
|
||||
else:
|
||||
status, pending_yours = "claimed", False
|
||||
return templates.TemplateResponse(
|
||||
@@ -424,6 +442,13 @@ def settings_submit(
|
||||
immich_url: str = Form(""),
|
||||
immich_api_key: str = Form(""),
|
||||
calendar_ics_url: str = Form(""),
|
||||
calendar_caldav_url: str = Form(""),
|
||||
calendar_caldav_username: str = Form(""),
|
||||
calendar_caldav_password: str = Form(""),
|
||||
webdav_username: str = Form(""),
|
||||
webdav_password: str = Form(""),
|
||||
webdav_reuse_caldav_creds: bool = Form(False),
|
||||
webdav_base_url: str = Form(""),
|
||||
current_password: str = Form(""),
|
||||
new_password: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -452,6 +477,36 @@ def settings_submit(
|
||||
else:
|
||||
user.calendar_ics_url = stripped_ics
|
||||
|
||||
stripped_caldav_url = calendar_caldav_url.strip()
|
||||
if stripped_caldav_url and not valid_http_url(stripped_caldav_url):
|
||||
error = "CalDAV URL must be a plain http:// or https:// URL."
|
||||
else:
|
||||
if stripped_caldav_url != user.calendar_caldav_url:
|
||||
# Server (and likely account) changed -- last discovery no
|
||||
# longer describes what's actually there.
|
||||
user.calendar_caldav_calendars = None
|
||||
user.calendar_caldav_checked_at = 0.0
|
||||
user.calendar_caldav_url = stripped_caldav_url
|
||||
user.calendar_caldav_username = calendar_caldav_username.strip()
|
||||
# Blank password field = keep the existing one, same idiom as the
|
||||
# Immich API key -- a secret that round-trips through HTML is a
|
||||
# secret in every browser's autofill store.
|
||||
if calendar_caldav_password.strip():
|
||||
user.calendar_caldav_password = calendar_caldav_password.strip()
|
||||
|
||||
user.webdav_reuse_caldav_creds = webdav_reuse_caldav_creds
|
||||
user.webdav_username = webdav_username.strip()
|
||||
if webdav_password.strip():
|
||||
user.webdav_password = webdav_password.strip()
|
||||
|
||||
# Not a secret -- round-trips visibly, so blank is an explicit clear,
|
||||
# same convention as calendar_ics_url above.
|
||||
stripped_webdav_base = webdav_base_url.strip()
|
||||
if stripped_webdav_base and not valid_http_url(stripped_webdav_base):
|
||||
error = "WebDAV browse root must be a plain http:// or https:// URL."
|
||||
else:
|
||||
user.webdav_base_url = stripped_webdav_base
|
||||
|
||||
if new_password:
|
||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||
error = "Current password is wrong -- password not changed."
|
||||
@@ -466,6 +521,28 @@ def settings_submit(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/settings/caldav-discover")
|
||||
def api_caldav_discover(request: Request, db: Session = Depends(get_db)):
|
||||
"""Lists the calendars in the CalDAV account already saved on this
|
||||
user's Settings (not whatever's currently typed in the form but not
|
||||
yet saved -- same idiom as /api/frames/{id}/albums using the frame's
|
||||
already-saved Immich creds). Caches the result on the user row so
|
||||
every frame's Calendar tab can offer it without a live round-trip."""
|
||||
user = require_user_api(request, db)
|
||||
if not user.calendar_caldav_url or not user.calendar_caldav_username:
|
||||
raise HTTPException(400, "Save a CalDAV URL and username first")
|
||||
try:
|
||||
calendars = caldav_client.discover_calendars(
|
||||
user.calendar_caldav_url, user.calendar_caldav_username, user.calendar_caldav_password
|
||||
)
|
||||
except caldav_client.CalDavError as e:
|
||||
raise HTTPException(502, f"Could not discover calendars: {e}") from e
|
||||
user.calendar_caldav_calendars = calendars
|
||||
user.calendar_caldav_checked_at = time.time()
|
||||
db.commit()
|
||||
return calendars
|
||||
|
||||
|
||||
def _require_admin_page(request: Request, db: Session) -> User:
|
||||
user = current_user(request, db)
|
||||
if user is None or not user.is_admin:
|
||||
|
||||
@@ -58,8 +58,21 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// Shared display names for widget_type, everywhere one shows up in the
|
||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||
const WIDGET_LABELS = {
|
||||
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
||||
static: 'Static image', text: 'Text',
|
||||
};
|
||||
|
||||
function showStatus(ok, message) {
|
||||
var el = document.getElementById('result');
|
||||
// While a <dialog> is open, its own .dialog-result container gets the
|
||||
// message instead of the page-level #result -- otherwise it lands
|
||||
// behind the dialog's backdrop, invisible until the dialog closes
|
||||
// (e.g. the widget config dialogs on the Layout tab, see
|
||||
// frame_layout.js). Falls back to #result for everything else.
|
||||
var openDialog = document.querySelector('dialog[open]');
|
||||
var el = (openDialog && openDialog.querySelector('.dialog-result')) || document.getElementById('result');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="status ' + (ok ? 'ok' : 'err') + '"></div>';
|
||||
el.firstChild.textContent = message;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,9 +30,9 @@ function renderDeviceStatusBar(device) {
|
||||
if (device.battery) {
|
||||
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
||||
// Shown as soon as there's any battery reading at all, even before
|
||||
// battery_estimate_s can compute a rate (needs 2h+ span and a 2%+
|
||||
// drop within the current discharge cycle -- see common.py) -- so
|
||||
// it's clear the number is coming, not that the feature is broken.
|
||||
// battery_estimate_s has enough discharge samples in battery_log to
|
||||
// average (see common.py) -- so it's clear the number is coming, not
|
||||
// that the feature is broken.
|
||||
const hasEstimate = device.battery_estimate_s !== null && device.battery_estimate_s !== undefined;
|
||||
rows.push([
|
||||
'Est. battery life left',
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// Configuration tab: frame settings + firmware card + take control.
|
||||
// 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.
|
||||
@@ -6,12 +11,8 @@
|
||||
async function saveConfig() {
|
||||
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
||||
const body = new URLSearchParams({
|
||||
mode: document.getElementById('frame_mode').value,
|
||||
name: document.getElementById('frame_name').value || '',
|
||||
order: document.getElementById('order').value,
|
||||
orientation: document.getElementById('orientation').value,
|
||||
refresh_interval_s: String(minutes * 60),
|
||||
display_mode: document.getElementById('display_mode').value,
|
||||
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
||||
@@ -27,79 +28,37 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Orientation swaps the widget grid's long/short axis (see
|
||||
// grid.grid_dims), so an existing widget layout is usually left with
|
||||
// out-of-bounds coordinates on the new grid -- the server resets it to
|
||||
// one full-panel widget when this actually changes (see
|
||||
// api_frames.py's api_config_save). Warn before that happens rather
|
||||
// than silently losing whatever layout was on the Layout tab.
|
||||
let lastSavedOrientation = document.getElementById('orientation').value;
|
||||
|
||||
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const newOrientation = document.getElementById('orientation').value;
|
||||
if (newOrientation !== lastSavedOrientation) {
|
||||
const proceed = confirm(
|
||||
"Changing orientation resets this frame's widget layout to a single " +
|
||||
'full-panel widget -- any other widgets placed on the Layout tab will ' +
|
||||
'be removed. Continue?'
|
||||
);
|
||||
if (!proceed) {
|
||||
document.getElementById('orientation').value = lastSavedOrientation;
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await saveConfig();
|
||||
lastSavedOrientation = newOrientation;
|
||||
showStatus(true, 'Saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Calendar card: mode/view toggling, its own save, self opt-in, preview ----
|
||||
|
||||
const calendarCard = document.getElementById('calendar-card');
|
||||
if (calendarCard) {
|
||||
document.getElementById('frame_mode').addEventListener('change', () => {
|
||||
calendarCard.style.display = document.getElementById('frame_mode').value === 'calendar' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
const inlayRow = document.getElementById('calendar-inlay-row');
|
||||
const inlayHint = document.getElementById('calendar-inlay-hint');
|
||||
document.getElementById('calendar_view').addEventListener('change', () => {
|
||||
const isAgenda = document.getElementById('calendar_view').value === 'agenda';
|
||||
inlayRow.style.display = isAgenda ? 'flex' : 'none';
|
||||
inlayHint.style.display = isAgenda ? 'block' : 'none';
|
||||
});
|
||||
|
||||
document.getElementById('calendar-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
calendar_view: document.getElementById('calendar_view').value,
|
||||
calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked),
|
||||
});
|
||||
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 person's own opt-in -- auto-saves on toggle, not batched into
|
||||
// the form above, since it's the toggling user's own preference (see
|
||||
// api_frames.py's /calendar-included), not a frame-wide setting.
|
||||
document.querySelectorAll('.calendar-self-toggle').forEach((el) => {
|
||||
el.addEventListener('change', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/calendar-included`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ included: el.checked }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, el.checked ? 'Your calendar is included on this frame.' : 'Your calendar removed from this frame.');
|
||||
} catch (e) {
|
||||
el.checked = !el.checked;
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
@@ -114,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) {
|
||||
@@ -232,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 ----
|
||||
@@ -395,3 +362,204 @@ loadFirmwareCheck();
|
||||
// The server throttles actual Gitea API calls itself, so this poll is
|
||||
// cheap either way.
|
||||
setInterval(loadFirmwareCheck, 60000);
|
||||
|
||||
// --- Button assignments -----------------------------------------------
|
||||
// {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}],
|
||||
// grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's
|
||||
// api_buttons_get. Each button's list is edited client-side (add/
|
||||
// remove/reorder) then PUT as a whole -- simpler than separate reorder/
|
||||
// add/remove endpoints for what's normally a handful of entries, and
|
||||
// this file already has the full list in hand after any edit.
|
||||
let buttonsData = null;
|
||||
let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames
|
||||
const BUTTONS = ['next', 'back'];
|
||||
|
||||
// "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the
|
||||
// frame's grid dims -- the same rough position you'd read off the
|
||||
// Layout canvas by eye, used to tell apart two widgets of the same type
|
||||
// that would otherwise both just say "Photos".
|
||||
function widgetPositionLabel(w, grid) {
|
||||
const cx = w.x + w.w / 2;
|
||||
const cy = w.y + w.h / 2;
|
||||
const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : '');
|
||||
const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : '');
|
||||
if (!horiz && !vert) return 'center';
|
||||
if (!vert) return horiz;
|
||||
if (!horiz) return vert;
|
||||
return `${vert}-${horiz}`;
|
||||
}
|
||||
|
||||
// A single widget of a given type keeps the plain type name ("Photos")
|
||||
// -- the common case, no need to clutter it. Only widgets sharing a
|
||||
// type with another widget on the same frame get a number + position
|
||||
// suffix, numbered in reading order (top-to-bottom, left-to-right).
|
||||
function buildWidgetNames(widgets, grid) {
|
||||
const byType = {};
|
||||
widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); });
|
||||
const names = {};
|
||||
Object.values(byType).forEach((group) => {
|
||||
if (group.length === 1) {
|
||||
names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type;
|
||||
return;
|
||||
}
|
||||
const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x));
|
||||
ordered.forEach((w, i) => {
|
||||
const base = WIDGET_LABELS[w.widget_type] || w.widget_type;
|
||||
names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`;
|
||||
});
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
function widgetActionLabel(widgetId, action) {
|
||||
const w = buttonsData.widgets.find((w) => w.id === widgetId);
|
||||
if (!w) return `(deleted widget): ${action}`;
|
||||
const found = w.actions.find((a) => a.action === action);
|
||||
const actionLabel = found ? found.label : action;
|
||||
return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
|
||||
}
|
||||
|
||||
function renderButtonList(button) {
|
||||
const list = document.getElementById(`button-actions-${button}`);
|
||||
const rows = buttonsData[button];
|
||||
list.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<li class="sub">Nothing assigned -- this button won’t do anything.</li>';
|
||||
return;
|
||||
}
|
||||
rows.forEach((row, i) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'button-action-row';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = widgetActionLabel(row.widget_id, row.action);
|
||||
|
||||
const controls = document.createElement('span');
|
||||
controls.className = 'button-action-controls';
|
||||
|
||||
const up = document.createElement('button');
|
||||
up.type = 'button';
|
||||
up.className = 'icon-btn';
|
||||
up.textContent = '↑';
|
||||
up.title = 'Move up';
|
||||
up.disabled = i === 0;
|
||||
up.addEventListener('click', () => moveButtonAction(button, i, -1));
|
||||
|
||||
const down = document.createElement('button');
|
||||
down.type = 'button';
|
||||
down.className = 'icon-btn';
|
||||
down.textContent = '↓';
|
||||
down.title = 'Move down';
|
||||
down.disabled = i === rows.length - 1;
|
||||
down.addEventListener('click', () => moveButtonAction(button, i, 1));
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'icon-btn';
|
||||
remove.textContent = '×';
|
||||
remove.title = 'Remove';
|
||||
remove.addEventListener('click', () => removeButtonAction(button, i));
|
||||
|
||||
controls.appendChild(up);
|
||||
controls.appendChild(down);
|
||||
controls.appendChild(remove);
|
||||
li.appendChild(span);
|
||||
li.appendChild(controls);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function populateActionSelect(button) {
|
||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
||||
actionSel.innerHTML = '';
|
||||
const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value);
|
||||
if (!w) return;
|
||||
w.actions.forEach((a) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.action;
|
||||
opt.textContent = a.label;
|
||||
actionSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function populateWidgetSelect(button) {
|
||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||||
widgetSel.innerHTML = '';
|
||||
buttonsData.widgets.forEach((w) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.id;
|
||||
opt.textContent = widgetNames[w.id] || WIDGET_LABELS[w.widget_type] || w.widget_type;
|
||||
widgetSel.appendChild(opt);
|
||||
});
|
||||
populateActionSelect(button);
|
||||
}
|
||||
|
||||
async function saveButtonActions(button) {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Button assignments saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
await loadButtons(); // resync with server truth rather than leave a stale edit on screen
|
||||
}
|
||||
}
|
||||
|
||||
function moveButtonAction(button, index, delta) {
|
||||
const rows = buttonsData[button];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= rows.length) return;
|
||||
[rows[index], rows[target]] = [rows[target], rows[index]];
|
||||
renderButtonList(button);
|
||||
saveButtonActions(button);
|
||||
}
|
||||
|
||||
function removeButtonAction(button, index) {
|
||||
buttonsData[button].splice(index, 1);
|
||||
renderButtonList(button);
|
||||
saveButtonActions(button);
|
||||
}
|
||||
|
||||
function addButtonAction(button) {
|
||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
||||
if (!widgetSel.value || !actionSel.value) return;
|
||||
buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value });
|
||||
renderButtonList(button);
|
||||
saveButtonActions(button);
|
||||
}
|
||||
|
||||
async function loadButtons() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons`);
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
buttonsData = await resp.json();
|
||||
widgetNames = buildWidgetNames(buttonsData.widgets, buttonsData.grid);
|
||||
document.getElementById('button-assign-groups').style.display =
|
||||
buttonsData.widgets.length ? '' : 'none';
|
||||
document.getElementById('button-assign-empty-hint').style.display =
|
||||
buttonsData.widgets.length ? 'none' : '';
|
||||
BUTTONS.forEach((button) => {
|
||||
renderButtonList(button);
|
||||
populateWidgetSelect(button);
|
||||
});
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
BUTTONS.forEach((button) => {
|
||||
document.getElementById(`button-add-widget-${button}`)
|
||||
.addEventListener('change', () => populateActionSelect(button));
|
||||
document.getElementById(`button-add-${button}`)
|
||||
.addEventListener('click', () => addButtonAction(button));
|
||||
});
|
||||
|
||||
loadButtons();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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 () {
|
||||
var view = document.getElementById('frame-name-view');
|
||||
var editRow = document.getElementById('frame-name-edit-row');
|
||||
var pencil = document.getElementById('frame-name-pencil');
|
||||
var input = document.getElementById('frame-name-input');
|
||||
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_BASE_API) return;
|
||||
|
||||
function openEdit() {
|
||||
input.value = textEl.textContent.trim();
|
||||
view.style.display = 'none';
|
||||
editRow.style.display = 'inline-flex';
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
function closeEdit() {
|
||||
editRow.style.display = 'none';
|
||||
view.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
pencil.addEventListener('click', openEdit);
|
||||
cancelBtn.addEventListener('click', closeEdit);
|
||||
|
||||
async function save() {
|
||||
var name = input.value.trim();
|
||||
if (!name || name === textEl.textContent.trim()) {
|
||||
closeEdit();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ name }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
textEl.textContent = name;
|
||||
closeEdit();
|
||||
showStatus(true, 'Renamed.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
saveBtn.addEventListener('click', save);
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') save();
|
||||
if (e.key === 'Escape') closeEdit();
|
||||
});
|
||||
})();
|
||||
|
||||
// Live "how it's displaying" thumbnail. A real composite render (same
|
||||
// pipeline /frame/image uses), not a cached snapshot, so it's on a slow
|
||||
// poll rather than something tighter like the 10s device-status poll --
|
||||
// no need to hit Immich/calendar/whiteboard sources that often just for
|
||||
// a header thumbnail. Click enlarges it in a dialog (which also fetches
|
||||
// a fresh render); clicking the enlarged image refreshes it again.
|
||||
(function () {
|
||||
var thumb = document.getElementById('frame-preview-thumb');
|
||||
var dialog = document.getElementById('frame-preview-dialog');
|
||||
var bigImg = document.getElementById('frame-preview-dialog-img');
|
||||
var closeBtn = document.getElementById('frame-preview-dialog-close');
|
||||
if (!thumb || !window.FRAME_BASE_API) return;
|
||||
|
||||
function previewUrl() {
|
||||
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
|
||||
}
|
||||
function refreshThumb() {
|
||||
thumb.src = previewUrl();
|
||||
}
|
||||
// Opening the dialog (or clicking the big image inside it) fetches a
|
||||
// fresh render and keeps the header thumb in sync, so this single path
|
||||
// covers both "enlarge" and the old click-to-refresh behavior.
|
||||
function refreshBig() {
|
||||
var url = previewUrl();
|
||||
bigImg.src = url;
|
||||
thumb.src = url;
|
||||
}
|
||||
|
||||
thumb.addEventListener('click', function () {
|
||||
if (!dialog) { refreshThumb(); return; }
|
||||
refreshBig();
|
||||
dialog.showModal();
|
||||
});
|
||||
refreshThumb();
|
||||
setInterval(refreshThumb, 60000);
|
||||
|
||||
if (dialog && bigImg && closeBtn) {
|
||||
bigImg.addEventListener('click', refreshBig);
|
||||
closeBtn.addEventListener('click', function () { dialog.close(); });
|
||||
// Same backdrop-click-to-close trick as #widget-dialog: a click that
|
||||
// lands on the dialog element itself (not its content box) means the
|
||||
// backdrop was hit.
|
||||
dialog.addEventListener('click', function (e) {
|
||||
if (e.target !== dialog) return;
|
||||
var rect = dialog.getBoundingClientRect();
|
||||
var inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
if (!inside) dialog.close();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,346 @@
|
||||
// Layout tab: drag/resize placement canvas for arranging widgets on the
|
||||
// panel, like placing widgets on an Android home screen. Pointer events
|
||||
// (not native HTML5 drag-and-drop, which has known touch
|
||||
// inconsistencies) drive move/resize; every mutation is re-validated
|
||||
// server-side (see routers/api_widgets.py) regardless of what this file
|
||||
// already checked, so after any move/resize/add/remove this just
|
||||
// reloads the canvas from the server's actual state rather than trusting
|
||||
// an optimistic update -- simplest way to guarantee the canvas never
|
||||
// drifts from what a rejected request left in place.
|
||||
//
|
||||
// Widget/canvas geometry is computed and applied in *pixels* from JS,
|
||||
// not CSS percentages/aspect-ratio -- aspect-ratio isn't supported on
|
||||
// every mobile browser this app gets viewed from, and a percentage
|
||||
// height on the widget boxes silently collapses to 0 against an
|
||||
// indeterminate-height ancestor on those browsers (the canvas would
|
||||
// render with no visible size at all, which is exactly what happened
|
||||
// before this was pixel-based).
|
||||
|
||||
let gridState = null; // last-loaded GET .../widgets response
|
||||
|
||||
// WIDGET_LABELS comes from common.js (shared with frame_config.js's
|
||||
// button-assignment UI).
|
||||
|
||||
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.');
|
||||
loadWidgets();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||
|
||||
// Cached each time the canvas is (re)laid out (see layoutCanvas) so drag
|
||||
// math doesn't re-measure the DOM on every pointermove.
|
||||
let canvasMetrics = { width: 0, height: 0, cellW: 0, cellH: 0 };
|
||||
|
||||
function layoutCanvas() {
|
||||
if (!gridState) return;
|
||||
const wrap = document.getElementById('widget-canvas-wrap');
|
||||
const canvas = document.getElementById('widget-canvas');
|
||||
const cols = gridState.grid.cols, rows = gridState.grid.rows;
|
||||
|
||||
// wrap's own width comes from ordinary CSS (100% of the card, capped
|
||||
// at max-width) -- only its height is JS-driven, from that measured
|
||||
// width, to keep the grid's aspect ratio without relying on the CSS
|
||||
// aspect-ratio property.
|
||||
const width = wrap.getBoundingClientRect().width;
|
||||
const height = width * (rows / cols);
|
||||
wrap.style.height = height + 'px';
|
||||
canvas.style.width = width + 'px';
|
||||
canvas.style.height = height + 'px';
|
||||
|
||||
const cellW = width / cols, cellH = height / rows;
|
||||
canvasMetrics = { width, height, cellW, cellH };
|
||||
wrap.style.backgroundImage =
|
||||
`linear-gradient(to right, var(--border) 1px, transparent 1px),` +
|
||||
`linear-gradient(to bottom, var(--border) 1px, transparent 1px)`;
|
||||
wrap.style.backgroundSize = `${cellW}px ${cellH}px`;
|
||||
|
||||
for (const box of canvas.children) {
|
||||
positionBox(box, box._rect);
|
||||
}
|
||||
}
|
||||
|
||||
function positionBox(box, rect) {
|
||||
box._rect = rect;
|
||||
const { cellW, cellH } = canvasMetrics;
|
||||
box.style.left = (rect.x * cellW) + 'px';
|
||||
box.style.top = (rect.y * cellH) + 'px';
|
||||
box.style.width = (rect.w * cellW) + 'px';
|
||||
box.style.height = (rect.h * cellH) + 'px';
|
||||
}
|
||||
|
||||
function startDrag(e, widget, box, isResize) {
|
||||
e.preventDefault();
|
||||
box.setPointerCapture(e.pointerId);
|
||||
const { cellW, cellH } = canvasMetrics;
|
||||
const startX = e.clientX, startY = e.clientY;
|
||||
const orig = { x: widget.x, y: widget.y, w: widget.w, h: widget.h };
|
||||
const cols = gridState.grid.cols, rows = gridState.grid.rows;
|
||||
const minFootprint = gridState.min_footprint[widget.widget_type] || [1, 1];
|
||||
let pending = null;
|
||||
|
||||
box.classList.add('dragging');
|
||||
|
||||
function onMove(ev) {
|
||||
const dxCells = Math.round((ev.clientX - startX) / cellW);
|
||||
const dyCells = Math.round((ev.clientY - startY) / cellH);
|
||||
const next = { ...orig };
|
||||
if (isResize) {
|
||||
next.w = Math.max(minFootprint[0], Math.min(cols - orig.x, orig.w + dxCells));
|
||||
next.h = Math.max(minFootprint[1], Math.min(rows - orig.y, orig.h + dyCells));
|
||||
} else {
|
||||
next.x = Math.max(0, Math.min(cols - orig.w, orig.x + dxCells));
|
||||
next.y = Math.max(0, Math.min(rows - orig.h, orig.y + dyCells));
|
||||
}
|
||||
pending = next;
|
||||
positionBox(box, next);
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
box.removeEventListener('pointermove', onMove);
|
||||
box.removeEventListener('pointerup', onUp);
|
||||
box.classList.remove('dragging');
|
||||
if (pending && (pending.x !== orig.x || pending.y !== orig.y || pending.w !== orig.w || pending.h !== orig.h)) {
|
||||
moveWidget(widget.id, pending);
|
||||
}
|
||||
}
|
||||
|
||||
box.addEventListener('pointermove', onMove);
|
||||
box.addEventListener('pointerup', onUp);
|
||||
}
|
||||
|
||||
async function moveWidget(id, rect) {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rect),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
// Reload either way: reverts the box to its real position if the
|
||||
// move was rejected (e.g. it would've overlapped another widget),
|
||||
// confirms it otherwise. Simpler and more robust than trying to
|
||||
// separately handle "revert on failure" vs. "confirm on success".
|
||||
loadWidgets();
|
||||
}
|
||||
}
|
||||
|
||||
async function removeWidget(id) {
|
||||
if (!confirm('Remove this widget? Its own settings will be lost.')) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Removed.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
loadWidgets();
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllWidgets() {
|
||||
const count = gridState ? gridState.widgets.length : 0;
|
||||
if (!count) return;
|
||||
const noun = count === 1 ? 'widget' : 'widgets';
|
||||
if (!confirm(`Remove all ${count} ${noun} from this frame and start over? Their settings will be lost.`)) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/widgets`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Cleared.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
loadWidgets();
|
||||
}
|
||||
}
|
||||
document.getElementById('clear-all-widgets').addEventListener('click', clearAllWidgets);
|
||||
|
||||
async function addWidget(widgetType) {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/widgets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ widget_type: widgetType }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, `${WIDGET_LABELS[widgetType] || widgetType} widget added.`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
loadWidgets();
|
||||
}
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
const canvas = document.getElementById('widget-canvas');
|
||||
canvas.innerHTML = '';
|
||||
document.getElementById('widget-canvas-empty-hint').style.display = gridState.widgets.length ? 'none' : '';
|
||||
document.getElementById('clear-all-widgets').disabled = !gridState.widgets.length;
|
||||
|
||||
for (const widget of gridState.widgets) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'widget-box';
|
||||
box.dataset.widgetType = widget.widget_type;
|
||||
box._rect = widget;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'widget-box-label';
|
||||
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';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.title = 'Remove this widget';
|
||||
removeBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
removeBtn.addEventListener('click', (e) => { e.stopPropagation(); removeWidget(widget.id); });
|
||||
box.appendChild(removeBtn);
|
||||
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'widget-box-resize-handle';
|
||||
handle.addEventListener('pointerdown', (e) => { e.stopPropagation(); startDrag(e, widget, box, true); });
|
||||
box.appendChild(handle);
|
||||
|
||||
box.addEventListener('pointerdown', (e) => startDrag(e, widget, box, false));
|
||||
|
||||
canvas.appendChild(box);
|
||||
}
|
||||
layoutCanvas();
|
||||
}
|
||||
|
||||
function renderAddButtons() {
|
||||
const container = document.getElementById('add-widget-buttons');
|
||||
container.innerHTML = '';
|
||||
for (const type of gridState.widget_types) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'secondary';
|
||||
btn.textContent = `+ ${WIDGET_LABELS[type] || type}`;
|
||||
btn.addEventListener('click', () => addWidget(type));
|
||||
container.appendChild(btn);
|
||||
}
|
||||
const hint = document.getElementById('add-widget-hint');
|
||||
hint.textContent = 'A new widget is placed in the first open space that fits it -- drag it afterward to reposition.';
|
||||
}
|
||||
|
||||
async function loadWidgets() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/widgets`);
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
gridState = await resp.json();
|
||||
renderCanvas();
|
||||
renderAddButtons();
|
||||
renderControlBanner(gridState.control);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
let resizeTimer = null;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
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 <dialog>, 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,tasks}.js each define an
|
||||
// init<Type>Dialog()/close<Type>Dialog() 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,
|
||||
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
|
||||
};
|
||||
const DIALOG_CLOSE = {
|
||||
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||||
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
|
||||
};
|
||||
|
||||
let openDialogWidgetType = null;
|
||||
|
||||
async function openWidgetDialog(widget) {
|
||||
const dialogEl = document.getElementById('widget-dialog');
|
||||
const bodyEl = document.getElementById('widget-dialog-body');
|
||||
bodyEl.innerHTML = '<p class="sub">Loading...</p>';
|
||||
dialogEl.querySelector('.dialog-result').innerHTML = ''; // clear any message left over from a previous dialog
|
||||
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 = `<p class="sub">Could not load: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('widget-dialog-close').addEventListener('click', () => {
|
||||
document.getElementById('widget-dialog').close();
|
||||
});
|
||||
|
||||
// Native <dialog> 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();
|
||||
@@ -1,126 +0,0 @@
|
||||
// Photos tab: now-displaying, album picker, 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 =
|
||||
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
||||
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 = '<p class="sub">Nothing displayed yet.</p>';
|
||||
}
|
||||
renderControlBanner(data.control);
|
||||
renderUpcoming(data.upcoming);
|
||||
} catch (e) {
|
||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function savePhotoSettings() {
|
||||
const body = new URLSearchParams({
|
||||
album_id: document.getElementById('album_id').value || '',
|
||||
queue_target_len: document.getElementById('queue_target_len').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);
|
||||
@@ -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/<id>' set by the page, and a
|
||||
// loadQueue() global (frame_photos.js) to refetch authoritative state.
|
||||
// Expects window.FRAME_API = '/api/frames/<id>/widgets/<widget_id>' (set
|
||||
// by frame_layout.js when the photos dialog opens), and a loadQueue()
|
||||
// global (widget_dialog_photos.js) to refetch authoritative state.
|
||||
|
||||
let upcomingItems = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Settings page: "Discover calendars" against the CalDAV account already
|
||||
// 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
|
||||
// routers/common.py's webdav_creds_for), no reason to leave them visibly
|
||||
// editable and implying they still do something.
|
||||
const reuseCaldavCreds = document.getElementById('webdav_reuse_caldav_creds');
|
||||
if (reuseCaldavCreds) {
|
||||
const updateWebdavFieldVisibility = () => {
|
||||
document.getElementById('webdav-creds-fields').style.display = reuseCaldavCreds.checked ? 'none' : '';
|
||||
};
|
||||
reuseCaldavCreds.addEventListener('change', updateWebdavFieldVisibility);
|
||||
updateWebdavFieldVisibility();
|
||||
}
|
||||
|
||||
const discoverBtn = document.getElementById('caldav-discover');
|
||||
if (discoverBtn) {
|
||||
discoverBtn.addEventListener('click', async () => {
|
||||
const list = document.getElementById('caldav-calendar-list');
|
||||
discoverBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/settings/caldav-discover', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
const calendars = await resp.json();
|
||||
list.innerHTML = '';
|
||||
if (calendars.length === 0) {
|
||||
list.innerHTML = '<li>No calendars found in this account.</li>';
|
||||
} else {
|
||||
for (const c of calendars) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = c.display_name;
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
showStatus(true, `Found ${calendars.length} calendar${calendars.length === 1 ? '' : 's'}.`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
discoverBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
+271
-1
@@ -167,6 +167,8 @@ h2.card-title, summary.card-title {
|
||||
summary.card-title { cursor: pointer; margin-bottom: 0; }
|
||||
details.card[open] summary.card-title { margin-bottom: 14px; }
|
||||
details.card .sub { margin-top: 8px; }
|
||||
.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
|
||||
.card-title-row .card-title { margin-bottom: 0; }
|
||||
|
||||
.palette-table-wrap { overflow-x: auto; margin-top: 14px; }
|
||||
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
@@ -203,6 +205,12 @@ details.card .sub { margin-top: 8px; }
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input[type="color"] {
|
||||
width: 44px;
|
||||
height: 34px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
@@ -257,10 +265,89 @@ input:focus, select:focus {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
|
||||
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.button-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
|
||||
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.button-action-add select { width: auto; margin-top: 0; }
|
||||
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
|
||||
.richtext-toolbar { display: flex; align-items: center; gap: 4px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.richtext-btn {
|
||||
width: auto;
|
||||
min-width: 32px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.richtext-btn:hover { background: var(--surface); }
|
||||
.richtext-toolbar-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 4px; }
|
||||
.richtext-color-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.richtext-color-label input[type="color"] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.richtext-editor {
|
||||
margin-top: 8px;
|
||||
min-height: 90px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.richtext-editor:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||
.calendar-user-list > li { margin-top: 14px; }
|
||||
.calendar-user-list > li:first-child { margin-top: 0; }
|
||||
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.calendar-row { flex-wrap: wrap; }
|
||||
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
|
||||
.color-swatch {
|
||||
width: 20px; height: 20px; padding: 0; margin: 0;
|
||||
border: 2px solid var(--border); border-radius: 5px;
|
||||
box-shadow: none; cursor: pointer;
|
||||
}
|
||||
.color-swatch.selected { border-color: var(--text); box-shadow: 0 0 0 1.5px var(--text); }
|
||||
.color-swatch-auto {
|
||||
width: auto; height: 20px; padding: 0 6px; font-size: 10px; font-weight: 600;
|
||||
color: var(--text-muted); background: var(--surface-alt);
|
||||
}
|
||||
.color-swatch-auto.selected { color: var(--text); }
|
||||
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 10px 16px;
|
||||
@@ -276,6 +363,7 @@ button {
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button:disabled { opacity: 0.5; cursor: default; pointer-events: none; }
|
||||
button.btn-inline {
|
||||
margin-top: 0;
|
||||
padding: 3px 10px;
|
||||
@@ -304,6 +392,111 @@ button.secondary:hover { background: var(--surface-alt); }
|
||||
}
|
||||
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
|
||||
|
||||
#widget-canvas-wrap {
|
||||
/* Height is set in px by frame_layout.js (measured wrap width * rows/cols)
|
||||
-- not CSS aspect-ratio, which isn't supported on every mobile browser
|
||||
this app gets viewed from, and percentage heights on the widget boxes
|
||||
below would silently collapse to 0 against an indeterminate-height
|
||||
ancestor if it weren't. Box positions/sizes are likewise set in px by
|
||||
JS, not CSS percentages, for the same cross-browser reason. */
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background-color: var(--surface-alt);
|
||||
background-repeat: repeat;
|
||||
}
|
||||
#widget-canvas { position: relative; width: 100%; height: 100%; }
|
||||
.widget-box {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid var(--accent);
|
||||
background: var(--surface-alt); /* fallback for browsers without color-mix() support */
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.widget-box.dragging { cursor: grabbing; box-shadow: var(--shadow-hover); z-index: 2; }
|
||||
.widget-box-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
pointer-events: none;
|
||||
}
|
||||
.widget-box-remove, .widget-box-settings {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--overlay);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.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;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
border-right: 3px solid var(--accent);
|
||||
border-bottom: 3px solid var(--accent);
|
||||
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-body scrolls on its own (min-height: 0 is what lets a
|
||||
flex child actually shrink/scroll instead of forcing the dialog
|
||||
past max-height) so .dialog-result stays pinned as a visible footer
|
||||
regardless of scroll position -- otherwise a save message can land
|
||||
off-screen below a long form with no visible feedback at all. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#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 { overflow-y: auto; min-height: 0; }
|
||||
#widget-dialog-body .card { box-shadow: none; }
|
||||
#widget-dialog-body .card:first-child { margin-top: 0; }
|
||||
.dialog-result { flex: none; }
|
||||
.dialog-result:not(:empty) { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
|
||||
code {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
@@ -373,7 +566,12 @@ code {
|
||||
}
|
||||
.main-col, .side-col { display: flex; flex-direction: column; }
|
||||
@media (max-width: 860px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
/* minmax(0, 1fr), not bare 1fr -- bare 1fr is minmax(auto, 1fr), whose
|
||||
"auto" minimum lets the track (and everything in it) grow to fit its
|
||||
widest descendant's min-content size instead of actually shrinking
|
||||
to the viewport, causing page-wide horizontal overflow/scroll on
|
||||
narrow screens. Same fix the desktop rule above already applies. */
|
||||
.layout { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* App shell: left sidebar (frame list + account nav) + main content. */
|
||||
@@ -468,6 +666,78 @@ code {
|
||||
}
|
||||
.tabs a:hover { color: var(--text); }
|
||||
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
.tabs a.tab-disabled { opacity: 0.45; }
|
||||
.tabs a.tab-disabled:hover { opacity: 0.7; }
|
||||
|
||||
.frame-name-view { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.frame-name-pencil {
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
opacity: 0.55;
|
||||
color: var(--text);
|
||||
transition: opacity .12s ease, background-color .12s ease;
|
||||
}
|
||||
.frame-name-pencil:hover { opacity: 1; background: var(--surface-alt); border-radius: 6px; }
|
||||
.frame-name-edit { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.frame-name-edit input {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
padding: 5px 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.frame-name-edit button { margin-top: 0; }
|
||||
|
||||
.frame-preview-thumb {
|
||||
height: 44px;
|
||||
width: auto;
|
||||
max-width: 130px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
margin-left: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-alt);
|
||||
cursor: pointer;
|
||||
transition: opacity .12s ease;
|
||||
}
|
||||
.frame-preview-thumb:hover { opacity: 0.8; }
|
||||
|
||||
.frame-preview-dialog {
|
||||
position: fixed;
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-hover);
|
||||
line-height: 0; /* avoid a baseline gap under the image */
|
||||
}
|
||||
.frame-preview-dialog::backdrop { background: var(--overlay); }
|
||||
.frame-preview-dialog img {
|
||||
display: block;
|
||||
max-width: min(90vw, 900px);
|
||||
max-height: min(85vh, 900px);
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#frame-preview-dialog-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 18px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.control-banner {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Calendar widget dialog: view/week-start settings, per-user opt-in,
|
||||
// weather, and the rendered preview. Not a page-load script --
|
||||
// frame_layout.js fetches this widget's dialog HTML fragment, injects
|
||||
// it into the shared <dialog>, 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 = '<li class="sub" id="weather-city-empty">No cities added yet.</li>';
|
||||
}
|
||||
showStatus(true, `${label} removed.`);
|
||||
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('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
|
||||
loadCalendarPreview();
|
||||
}
|
||||
|
||||
function closeCalendarDialog() {
|
||||
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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 <dialog>, 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 =
|
||||
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
||||
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 = '<p class="sub">Nothing displayed yet.</p>';
|
||||
}
|
||||
renderUpcoming(data.upcoming);
|
||||
} catch (e) {
|
||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// /albums is frame-level (routers/api_frames.py) -- it lists the
|
||||
// frame owner's whole Immich library, not something scoped to
|
||||
// this one photo widget -- so it uses window.FRAME_BASE_API (the
|
||||
// stable frame-level base), not window.FRAME_API (repointed to
|
||||
// this widget's own API base while the dialog is open).
|
||||
const resp = await fetch(`${window.FRAME_BASE_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;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Static image widget dialog: upload, display-mode setting, and the
|
||||
// rendered preview. Not a page-load script -- frame_layout.js fetches
|
||||
// this widget's dialog HTML fragment, injects it into the shared
|
||||
// <dialog>, points window.FRAME_API at this specific widget
|
||||
// (/api/frames/{id}/widgets/{widget_id}), then calls initStaticDialog().
|
||||
|
||||
function loadStaticPreview() {
|
||||
document.getElementById('static-preview').src = `${window.FRAME_API}/preview/static?_=${Date.now()}`;
|
||||
}
|
||||
|
||||
function initStaticDialog() {
|
||||
document.getElementById('static-upload').addEventListener('click', async () => {
|
||||
const input = document.getElementById('static-file');
|
||||
if (!input.files.length) {
|
||||
showStatus(false, 'Pick a file first.');
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('file', input.files[0]);
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/static-upload`, { method: 'POST', body: form });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
const result = await resp.json();
|
||||
const currentFileEl = document.getElementById('static-current-file');
|
||||
currentFileEl.textContent = 'Currently showing: ';
|
||||
const nameEl = document.createElement('strong');
|
||||
nameEl.textContent = result.filename;
|
||||
currentFileEl.appendChild(nameEl);
|
||||
input.value = '';
|
||||
showStatus(true, 'Uploaded.');
|
||||
loadStaticPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('static-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
display_mode: document.getElementById('display_mode').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.');
|
||||
loadStaticPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
|
||||
loadStaticPreview();
|
||||
}
|
||||
|
||||
function closeStaticDialog() {
|
||||
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Tasks widget dialog: per-user included-task-list checkboxes + color
|
||||
// pins (same shape as the calendar widget's "Included calendars"), the
|
||||
// name/recently-completed settings form, and the rendered preview. Not
|
||||
// a page-load script -- frame_layout.js fetches this widget's dialog
|
||||
// HTML fragment, injects it into the shared <dialog>, points
|
||||
// window.FRAME_API at this specific widget
|
||||
// (/api/frames/{id}/widgets/{widget_id}), then calls initTasksDialog().
|
||||
|
||||
function loadTasksPreview() {
|
||||
document.getElementById('tasks-preview').src = `${window.FRAME_API}/preview/tasks?_=${Date.now()}`;
|
||||
}
|
||||
|
||||
function initTasksDialog() {
|
||||
// Each task list's own include/mute toggle -- auto-saves on change,
|
||||
// not batched into the form below, since it's a data-sharing choice
|
||||
// (see api_widget_task_list_select), not a widget-wide setting. Works
|
||||
// the same element for your own lists (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('.task-list-toggle').forEach((el) => {
|
||||
el.addEventListener('change', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/task-list-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 ? 'Task list included on this widget.' : 'Task list removed from this widget.');
|
||||
loadTasksPreview();
|
||||
} catch (e) {
|
||||
el.checked = !el.checked;
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Per-task-list color pin -- owner-only (the server enforces it;
|
||||
// these buttons only ever render for the viewer's own lists anyway).
|
||||
document.querySelectorAll('.task-list-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}/task-list-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.');
|
||||
loadTasksPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('tasks-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
tasks_name: document.getElementById('tasks_name').value,
|
||||
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
|
||||
});
|
||||
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.');
|
||||
loadTasksPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
||||
loadTasksPreview();
|
||||
}
|
||||
|
||||
function closeTasksDialog() {
|
||||
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Text widget dialog: a small rich-text editor (bold/italic/underline,
|
||||
// text/highlight color), font size/alignment/background settings, and
|
||||
// the rendered preview. Not a page-load script -- frame_layout.js
|
||||
// fetches this widget's dialog HTML fragment, injects it into the
|
||||
// shared <dialog>, points window.FRAME_API at this specific widget
|
||||
// (/api/frames/{id}/widgets/{widget_id}), then calls initTextDialog().
|
||||
//
|
||||
// The editor is never seeded via innerHTML string interpolation --
|
||||
// #text-editor's data-content attribute (server-rendered from
|
||||
// app/text_content.py's already-sanitized run structure, not raw HTML)
|
||||
// is JSON-parsed and rebuilt with createElement/textContent below. The
|
||||
// same <div><span style="..."> shape this produces is exactly what
|
||||
// app/text_content.py's parser expects back on save, so round-tripping
|
||||
// (load -> edit -> save -> reload) is stable.
|
||||
|
||||
function loadTextPreview() {
|
||||
document.getElementById('text-preview').src = `${window.FRAME_API}/preview/text?_=${Date.now()}`;
|
||||
}
|
||||
|
||||
function buildTextEditorContent(editor, paragraphs) {
|
||||
editor.textContent = '';
|
||||
if (!paragraphs || !paragraphs.length) return;
|
||||
paragraphs.forEach((para) => {
|
||||
const div = document.createElement('div');
|
||||
if (!para.length) {
|
||||
div.appendChild(document.createElement('br'));
|
||||
} else {
|
||||
para.forEach((run) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = run.text;
|
||||
if (run.bold) span.style.fontWeight = 'bold';
|
||||
if (run.italic) span.style.fontStyle = 'italic';
|
||||
if (run.underline) span.style.textDecoration = 'underline';
|
||||
if (run.color) span.style.color = run.color;
|
||||
if (run.bg) span.style.backgroundColor = run.bg;
|
||||
div.appendChild(span);
|
||||
});
|
||||
}
|
||||
editor.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
let _textSavedRange = null;
|
||||
let _textSelectionHandler = null;
|
||||
|
||||
function initTextDialog() {
|
||||
const editor = document.getElementById('text-editor');
|
||||
let initialContent = null;
|
||||
try {
|
||||
initialContent = JSON.parse(editor.dataset.content || 'null');
|
||||
} catch (e) { /* leave empty */ }
|
||||
buildTextEditorContent(editor, initialContent);
|
||||
|
||||
// Native <input type="color"> steals focus (and with it, the
|
||||
// editor's text selection) the moment it's interacted with -- track
|
||||
// the most recent in-editor selection continuously so a color pick
|
||||
// can be reapplied to the text the user actually had selected,
|
||||
// instead of applying to nothing.
|
||||
_textSelectionHandler = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
|
||||
_textSavedRange = sel.getRangeAt(0).cloneRange();
|
||||
}
|
||||
};
|
||||
document.addEventListener('selectionchange', _textSelectionHandler);
|
||||
|
||||
function restoreSelection() {
|
||||
if (!_textSavedRange) return;
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(_textSavedRange);
|
||||
}
|
||||
|
||||
['text-bold', 'text-italic', 'text-underline'].forEach((id) => {
|
||||
const btn = document.getElementById(id);
|
||||
// preventDefault on mousedown keeps focus (and the selection) in
|
||||
// the editor, so the click's execCommand has something to act on.
|
||||
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
btn.addEventListener('click', () => {
|
||||
editor.focus();
|
||||
document.execCommand(btn.dataset.cmd, false, null);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('text-color').addEventListener('input', (e) => {
|
||||
editor.focus();
|
||||
restoreSelection();
|
||||
document.execCommand('foreColor', false, e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('text-highlight').addEventListener('input', (e) => {
|
||||
editor.focus();
|
||||
restoreSelection();
|
||||
document.execCommand('hiliteColor', false, e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('text-highlight-clear').addEventListener('mousedown', (e) => e.preventDefault());
|
||||
document.getElementById('text-highlight-clear').addEventListener('click', () => {
|
||||
editor.focus();
|
||||
restoreSelection();
|
||||
document.execCommand('hiliteColor', false, 'transparent');
|
||||
});
|
||||
|
||||
document.getElementById('text_font_size').addEventListener('input', (e) => {
|
||||
document.getElementById('text_font_size_value').textContent = `${e.target.value}px`;
|
||||
});
|
||||
|
||||
document.getElementById('text-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
text_html: editor.innerHTML,
|
||||
text_font_size: document.getElementById('text_font_size').value,
|
||||
text_align: document.getElementById('text_align').value,
|
||||
text_background_color: document.getElementById('text_background_color').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.');
|
||||
loadTextPreview();
|
||||
} catch (e2) {
|
||||
showStatus(false, e2.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
|
||||
loadTextPreview();
|
||||
}
|
||||
|
||||
function closeTextDialog() {
|
||||
if (_textSelectionHandler) {
|
||||
document.removeEventListener('selectionchange', _textSelectionHandler);
|
||||
_textSelectionHandler = null;
|
||||
}
|
||||
_textSavedRange = null;
|
||||
}
|
||||
@@ -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 <dialog>,
|
||||
// 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 = '<li class="sub">Loading...</li>';
|
||||
try {
|
||||
const qs = url ? `?url=${encodeURIComponent(url)}` : '';
|
||||
const resp = await fetch(`${window.FRAME_API}/whiteboard-browse${qs}`);
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
const data = await resp.json();
|
||||
browseCurrent.textContent = data.current_url;
|
||||
browseUp.disabled = !data.parent_url;
|
||||
browseUp.onclick = data.parent_url ? () => browseTo(data.parent_url) : null;
|
||||
browseList.innerHTML = '';
|
||||
if (data.entries.length === 0) {
|
||||
browseList.innerHTML = '<li class="sub">(empty folder)</li>';
|
||||
}
|
||||
for (const entry of data.entries) {
|
||||
const li = document.createElement('li');
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn-inline secondary';
|
||||
btn.style.margin = '2px 0';
|
||||
btn.textContent = (entry.is_dir ? '📁 ' : '📄 ') + entry.name;
|
||||
if (entry.is_dir) {
|
||||
btn.addEventListener('click', () => browseTo(entry.url));
|
||||
} else {
|
||||
btn.addEventListener('click', () => {
|
||||
urlInput.value = entry.url;
|
||||
browsePanel.style.display = 'none';
|
||||
});
|
||||
}
|
||||
li.appendChild(btn);
|
||||
browseList.appendChild(li);
|
||||
}
|
||||
} catch (e) {
|
||||
browseList.innerHTML = '';
|
||||
browseError.textContent = e.message;
|
||||
browseError.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
browseToggle.addEventListener('click', () => {
|
||||
opened = !opened;
|
||||
browsePanel.style.display = opened ? 'block' : 'none';
|
||||
if (opened && !browseCurrent.textContent) {
|
||||
browseTo(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeWhiteboardDialog() {
|
||||
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<span class="frame-name-view" id="frame-name-view">
|
||||
<span id="frame-name-text">{{ frame.name or ("Frame " ~ frame.id) }}</span>
|
||||
<button type="button" class="frame-name-pencil" id="frame-name-pencil" title="Rename frame" aria-label="Rename frame">✎</button>
|
||||
</span>
|
||||
<span class="frame-name-edit" id="frame-name-edit-row" style="display: none;">
|
||||
<input type="text" id="frame-name-input" maxlength="64" value="{{ frame.name }}">
|
||||
<button type="button" class="btn-inline" id="frame-name-save">Save</button>
|
||||
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
|
||||
</span>
|
||||
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame is displaying" title="What the frame is currently displaying -- click to enlarge">
|
||||
|
||||
<dialog id="frame-preview-dialog" class="frame-preview-dialog">
|
||||
<button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
||||
<img id="frame-preview-dialog-img" alt="Live preview of what the frame is displaying" title="Click to refresh">
|
||||
</dialog>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<nav class="tabs">
|
||||
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
|
||||
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'layout' %}active{% endif %}">Layout</a>
|
||||
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
|
||||
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<h2 class="dialog-title">Calendar widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Calendar</h2>
|
||||
<form id="calendar-config-form">
|
||||
<label>View
|
||||
<select id="calendar_view">
|
||||
{% for value, label in calendar_views.items() %}
|
||||
<option value="{{ value }}" {% if calendar_cfg.view == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div id="calendar-week-start-row">
|
||||
<label>Week starts on
|
||||
<select id="calendar_week_start">
|
||||
{% for value, label in week_start_labels.items() %}
|
||||
<option value="{{ value }}" {% if calendar_cfg.week_start == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">Only affects the Week and Month views, and (for Week) only at 7 days.</p>
|
||||
</div>
|
||||
<div id="calendar-week-days-row">
|
||||
<label>Days to show (Week view)
|
||||
<input type="number" id="calendar_week_days" min="2" max="10" value="{{ calendar_cfg.week_days }}">
|
||||
</label>
|
||||
</div>
|
||||
<div id="calendar-week-layout-row">
|
||||
<label>Week view layout
|
||||
<select id="calendar_week_layout">
|
||||
<option value="horizontal" {% if calendar_cfg.week_layout == "horizontal" %}selected{% endif %}>Days side by side</option>
|
||||
<option value="vertical" {% if calendar_cfg.week_layout == "vertical" %}selected{% endif %}>Days stacked</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="calendar-week-offset-row">
|
||||
<label>Week view starts (days from today)
|
||||
<input type="number" id="calendar_week_start_offset" min="-30" max="30" value="{{ calendar_cfg.week_start_offset }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
|
||||
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
|
||||
</div>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
|
||||
<p class="sub">Each linked person adds their own calendars (ICS
|
||||
subscription or CalDAV, set up in <a href="/settings">Settings</a>)
|
||||
-- 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.</p>
|
||||
<ul class="calendar-user-list">
|
||||
{% for u in calendar_users %}
|
||||
<li>
|
||||
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
|
||||
{% 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 %}
|
||||
<div class="checkbox-row calendar-row" style="margin-top: 6px;">
|
||||
<input type="checkbox" class="calendar-toggle"
|
||||
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
|
||||
{% if c.included %}checked{% endif %}>
|
||||
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
|
||||
{% if u.is_self %}
|
||||
<span class="calendar-color-picker" data-key="{{ c.key }}">
|
||||
{% for idx in range(2, 6) %}
|
||||
<button type="button" class="color-swatch {% if c.color_index == idx %}selected{% endif %}"
|
||||
data-index="{{ idx }}" title="{{ calendar_color_labels[idx] }}"
|
||||
style="background-color: {{ current_hex[idx] }};"></button>
|
||||
{% endfor %}
|
||||
<button type="button" class="color-swatch color-swatch-auto {% if c.color_index is none %}selected{% endif %}"
|
||||
data-index="" title="Auto (assigned automatically)">Auto</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif u.is_self %}
|
||||
<p class="sub" style="margin-top: 6px;">No calendars set up yet -- add an ICS link or CalDAV account in <a href="/settings">Settings</a>.</p>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 6px;">No calendars included.</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if calendar_cfg.fetch_summary %}
|
||||
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ calendar_cfg.fetch_summary }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Weather</h2>
|
||||
<p class="sub">Shown above the event list on Agenda, Agenda (today
|
||||
& tomorrow), and Week views -- there's no room for it on Month.</p>
|
||||
<form id="weather-config-form">
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="weather_enabled" {% if calendar_cfg.weather_enabled %}checked{% endif %}>
|
||||
<label for="weather_enabled">Show weather</label>
|
||||
</div>
|
||||
<label>Units
|
||||
<select id="weather_units">
|
||||
<option value="fahrenheit" {% if calendar_cfg.weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
|
||||
<option value="celsius" {% if calendar_cfg.weather_units == "celsius" %}selected{% endif %}>Celsius</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<h3 style="margin-top: 20px; font-size: 14px;">Cities</h3>
|
||||
<p class="sub">Every city shows on every day -- add more than one if
|
||||
people split their time between places.</p>
|
||||
<ul class="calendar-user-list" id="weather-city-list">
|
||||
{% for c in calendar_cfg.weather_cities or [] %}
|
||||
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
|
||||
<span>{{ c.label }}</span>
|
||||
<button type="button" class="btn-inline secondary weather-city-remove" data-label="{{ c.label }}">Remove</button>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="sub" id="weather-city-empty">No cities added yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="checkbox-row" style="margin-top: 10px;">
|
||||
<input type="text" id="weather-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
|
||||
<button type="button" class="btn-inline" id="weather-city-add">Add</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="calendar-preview" alt="Calendar preview">
|
||||
<button type="button" class="secondary" id="calendar-preview-refresh">Refresh preview</button>
|
||||
</section>
|
||||
@@ -0,0 +1,55 @@
|
||||
<h2 class="dialog-title">Photos widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Album</h2>
|
||||
<form id="photos-form">
|
||||
<label>Album
|
||||
<select id="album_id">
|
||||
{% if photo_cfg and photo_cfg.album_id %}<option value="{{ photo_cfg.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Upcoming photos to show
|
||||
<select id="queue_target_len">
|
||||
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
||||
<option value="{{ n }}" {% if photo_cfg and photo_cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Order
|
||||
<select id="order">
|
||||
<option value="sequential" {% if not photo_cfg or photo_cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||||
<option value="shuffle" {% if photo_cfg and photo_cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Display mode
|
||||
<select id="display_mode">
|
||||
{% for mode, label in display_mode_labels.items() %}
|
||||
<option value="{{ mode }}" {% if photo_cfg and photo_cfg.display_mode == mode %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">How a photo's aspect ratio
|
||||
is reconciled with the panel's: <strong>Crop to fill</strong>
|
||||
trims the excess; <strong>Crop to faces</strong> does the same
|
||||
but shifts the crop to keep people on screen; <strong>Stretch to
|
||||
fill</strong> fills the panel exactly without cropping (photos
|
||||
not matching the panel's aspect ratio look stretched);
|
||||
<strong>Shrink to fit</strong> shows the whole photo, letterboxed
|
||||
if needed.</p>
|
||||
<button type="button" class="secondary" id="load-albums">Load Albums</button>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Upcoming</h2>
|
||||
<p class="sub">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.</p>
|
||||
<div id="upcoming-grid" class="photo-grid"></div>
|
||||
</section>
|
||||
@@ -0,0 +1,44 @@
|
||||
<h2 class="dialog-title">Static image widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Image</h2>
|
||||
<p class="sub">Upload a PNG, JPEG, GIF, BMP, WEBP, TIFF, or PDF (first
|
||||
page only) -- it's decoded once on upload and shown as-is until you
|
||||
upload something else.</p>
|
||||
<p class="sub" id="static-current-file">
|
||||
{% if static_cfg and static_cfg.original_filename %}
|
||||
Currently showing: <strong>{{ static_cfg.original_filename }}</strong>
|
||||
{% else %}
|
||||
No image uploaded yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
<input type="file" id="static-file" accept="image/*,application/pdf">
|
||||
<button type="button" class="secondary" id="static-upload" style="margin-top: 8px;">Upload</button>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Settings</h2>
|
||||
<form id="static-config-form">
|
||||
<label>Display mode
|
||||
<select id="display_mode">
|
||||
{% for mode, label in display_mode_labels.items() %}
|
||||
<option value="{{ mode }}" {% if static_cfg and static_cfg.display_mode == mode %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">How the image's aspect ratio
|
||||
is reconciled with this widget's box: <strong>Crop to fill</strong>
|
||||
trims the excess; <strong>Stretch to fill</strong> fills the box
|
||||
exactly without cropping (an image with a different aspect ratio
|
||||
looks stretched); <strong>Shrink to fit</strong> shows the whole
|
||||
image, letterboxed if needed.</p>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="static-preview" alt="Static image preview">
|
||||
<button type="button" class="secondary" id="static-preview-refresh">Refresh now</button>
|
||||
</section>
|
||||
@@ -0,0 +1,67 @@
|
||||
<h2 class="dialog-title">Tasks widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Included task lists</h2>
|
||||
<p class="sub">Each linked person adds their own CalDAV task lists (set
|
||||
up in <a href="/settings">Settings</a> -- a plain ICS subscription
|
||||
has no task list) -- being linked here doesn't include anything
|
||||
automatically. Anyone linked to this frame can mute a list they'd
|
||||
rather not see here, even one they don't own; only its owner can add
|
||||
it back.</p>
|
||||
<ul class="calendar-user-list">
|
||||
{% for u in task_users %}
|
||||
<li>
|
||||
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
|
||||
{% if u.task_lists %}
|
||||
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
|
||||
{% set current_hex = palette_to_hex(current_palette) %}
|
||||
{% for c in u.task_lists %}
|
||||
<div class="checkbox-row calendar-row" style="margin-top: 6px;">
|
||||
<input type="checkbox" class="task-list-toggle"
|
||||
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
|
||||
{% if c.included %}checked{% endif %}>
|
||||
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
|
||||
{% if u.is_self %}
|
||||
<span class="task-list-color-picker" data-key="{{ c.key }}">
|
||||
{% for idx in range(2, 6) %}
|
||||
<button type="button" class="color-swatch {% if c.color_index == idx %}selected{% endif %}"
|
||||
data-index="{{ idx }}" title="{{ task_color_labels[idx] }}"
|
||||
style="background-color: {{ current_hex[idx] }};"></button>
|
||||
{% endfor %}
|
||||
<button type="button" class="color-swatch color-swatch-auto {% if c.color_index is none %}selected{% endif %}"
|
||||
data-index="" title="Auto (assigned automatically)">Auto</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif u.is_self %}
|
||||
<p class="sub" style="margin-top: 6px;">No CalDAV task lists set up yet -- add a CalDAV account in <a href="/settings">Settings</a>.</p>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 6px;">No task lists included.</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Settings</h2>
|
||||
<form id="tasks-config-form">
|
||||
<label>Name
|
||||
<input type="text" id="tasks_name" maxlength="40" placeholder="Tasks" value="{{ task_cfg.name }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">Shown at the top of this widget on the panel instead of "Tasks" -- e.g. "Chores" or "Mom's list".</p>
|
||||
<div class="checkbox-row" style="margin-top: 10px;">
|
||||
<input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}>
|
||||
<label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label>
|
||||
</div>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="tasks-preview" alt="Tasks preview">
|
||||
<button type="button" class="secondary" id="tasks-preview-refresh">Refresh now</button>
|
||||
</section>
|
||||
@@ -0,0 +1,48 @@
|
||||
<h2 class="dialog-title">Text widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Text</h2>
|
||||
<p class="sub">A short block of styled text -- select some and use the
|
||||
toolbar, or just type. Saved as plain text plus style flags, not raw
|
||||
HTML.</p>
|
||||
<div class="richtext-toolbar" id="text-toolbar">
|
||||
<button type="button" class="richtext-btn" id="text-bold" title="Bold" data-cmd="bold"><b>B</b></button>
|
||||
<button type="button" class="richtext-btn" id="text-italic" title="Italic" data-cmd="italic"><i>I</i></button>
|
||||
<button type="button" class="richtext-btn" id="text-underline" title="Underline" data-cmd="underline"><u>U</u></button>
|
||||
<span class="richtext-toolbar-sep"></span>
|
||||
<label class="richtext-color-label" title="Text color">A<input type="color" id="text-color" value="#000000"></label>
|
||||
<label class="richtext-color-label" title="Highlight color">▣<input type="color" id="text-highlight" value="#ffff00"></label>
|
||||
<button type="button" class="richtext-btn" id="text-highlight-clear" title="Remove highlight">×</button>
|
||||
</div>
|
||||
<div class="richtext-editor" id="text-editor" contenteditable="true"
|
||||
data-content='{{ (text_cfg.content if text_cfg else none) | tojson }}'></div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Settings</h2>
|
||||
<form id="text-config-form">
|
||||
<label>Font size
|
||||
<input type="range" id="text_font_size" min="10" max="96" step="2"
|
||||
value="{{ text_cfg.font_size if text_cfg else 28 }}">
|
||||
<span class="slider-value" id="text_font_size_value">{{ text_cfg.font_size if text_cfg else 28 }}px</span>
|
||||
</label>
|
||||
<label>Alignment
|
||||
<select id="text_align">
|
||||
{% for value, label in [("left", "Left"), ("center", "Center"), ("right", "Right")] %}
|
||||
<option value="{{ value }}" {% if text_cfg and text_cfg.align == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Background color
|
||||
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="text-preview" alt="Text widget preview">
|
||||
<button type="button" class="secondary" id="text-preview-refresh">Refresh now</button>
|
||||
</section>
|
||||
@@ -0,0 +1,58 @@
|
||||
<h2 class="dialog-title">Whiteboard widget (alpha)</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Whiteboard source</h2>
|
||||
<p class="sub">Renders a Nextcloud Whiteboard (or any Excalidraw
|
||||
scene) fetched over WebDAV -- credentials set up in
|
||||
<a href="/settings">Settings</a>.</p>
|
||||
|
||||
<div id="whiteboard-current-source">
|
||||
{% if whiteboard_source %}
|
||||
<p class="sub" style="margin-top: 10px;">
|
||||
Currently showing <strong>{{ whiteboard_source.url }}</strong>
|
||||
using <strong>{{ whiteboard_source.display_name }}</strong>'s
|
||||
WebDAV account.
|
||||
<button type="button" class="btn-inline secondary" id="whiteboard-source-clear">Clear</button>
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 10px;">No whiteboard configured yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if viewer_has_webdav_creds %}
|
||||
<form id="whiteboard-source-form" style="margin-top: 16px;">
|
||||
<label><span id="whiteboard-source-form-label">{{ "Change to one of your own files" if whiteboard_source else "Use one of your own files" }}</span>
|
||||
<input type="text" id="whiteboard-url-input"
|
||||
placeholder="https://cloud.example.com/remote.php/dav/files/you/Boards/family.whiteboard"
|
||||
value="{{ whiteboard_source.url if whiteboard_source and whiteboard_source.user_id == user.id else '' }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">The direct WebDAV URL
|
||||
to the specific file -- in Nextcloud's Files app, this is
|
||||
the file's path under
|
||||
<code>remote.php/dav/files/<your-username>/</code>.
|
||||
<button type="button" class="btn-inline secondary" id="whiteboard-browse-toggle">Browse...</button></p>
|
||||
|
||||
<div id="whiteboard-browser" style="display: none; margin-top: 8px; border: 1px solid var(--border-color, #ccc); border-radius: 6px; padding: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
|
||||
<button type="button" class="btn-inline secondary" id="whiteboard-browse-up" disabled>Up</button>
|
||||
<span class="sub" id="whiteboard-browse-current" style="word-break: break-all;"></span>
|
||||
</div>
|
||||
<ul id="whiteboard-browse-list" style="list-style: none; margin: 0; padding: 0; max-height: 260px; overflow-y: auto;"></ul>
|
||||
<p class="sub" id="whiteboard-browse-error" style="display: none; color: var(--error-color, #c00);"></p>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 10px;">Set up WebDAV credentials
|
||||
in <a href="/settings">Settings</a> first to point this widget at
|
||||
one of your own files.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="whiteboard-preview" alt="Whiteboard preview">
|
||||
<button type="button" class="secondary" id="whiteboard-preview-refresh">Refresh now</button>
|
||||
</section>
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% 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 %}
|
||||
@@ -17,21 +17,6 @@
|
||||
<section class="card">
|
||||
<h2 class="card-title">Display settings</h2>
|
||||
<form id="config-form">
|
||||
<label>Frame mode
|
||||
<select id="frame_mode">
|
||||
<option value="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
|
||||
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Frame name
|
||||
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
|
||||
</label>
|
||||
<label>Order
|
||||
<select id="order">
|
||||
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||||
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Orientation
|
||||
<select id="orientation">
|
||||
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
|
||||
@@ -44,21 +29,6 @@
|
||||
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
||||
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
|
||||
</label>
|
||||
<label>Display mode
|
||||
<select id="display_mode">
|
||||
{% for mode, label in display_mode_labels.items() %}
|
||||
<option value="{{ mode }}" {% if frame.display_mode == mode %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">How a photo's aspect ratio
|
||||
is reconciled with the panel's: <strong>Crop to fill</strong>
|
||||
trims the excess; <strong>Crop to faces</strong> does the same
|
||||
but shifts the crop to keep people on screen; <strong>Stretch to
|
||||
fill</strong> fills the panel exactly without cropping (photos
|
||||
not matching the panel's aspect ratio look stretched);
|
||||
<strong>Shrink to fit</strong> shows the whole photo, letterboxed
|
||||
if needed.</p>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
|
||||
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
||||
@@ -84,57 +54,37 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" id="calendar-card" style="{% if frame.mode != 'calendar' %}display: none;{% endif %}">
|
||||
<h2 class="card-title">Calendar</h2>
|
||||
<form id="calendar-config-form">
|
||||
<label>View
|
||||
<select id="calendar_view">
|
||||
{% for value, label in calendar_views.items() %}
|
||||
<option value="{{ value }}" {% if frame.calendar_view == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="checkbox-row" id="calendar-inlay-row" style="{% if frame.calendar_view != 'agenda' %}display: none;{% endif %}">
|
||||
<input type="checkbox" id="calendar_photo_inlay" {% if frame.calendar_photo_inlay %}checked{% endif %}>
|
||||
<label for="calendar_photo_inlay">Show a photo alongside today's agenda</label>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Button assignments</h2>
|
||||
<p class="sub">What the frame's physical NEXT and BACK buttons do --
|
||||
assign one or more widget actions to each, in the order they
|
||||
should run. A button with several actions runs all of them, in
|
||||
order, then the panel redraws once.</p>
|
||||
<p class="sub" id="button-assign-empty-hint" style="display: none;">
|
||||
Add a widget on the Layout tab first -- there's nothing to assign
|
||||
a button to yet.</p>
|
||||
|
||||
<div id="button-assign-groups">
|
||||
<div class="button-assign-group">
|
||||
<h3 class="button-assign-label">NEXT button</h3>
|
||||
<ul class="button-action-list" id="button-actions-next"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-next"></select>
|
||||
<select id="button-add-action-next"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="sub" id="calendar-inlay-hint" style="margin-top: 4px; {% if frame.calendar_view != 'agenda' %}display: none;{% endif %}">
|
||||
Uses the same album configured on the Photos tab -- nothing extra to set up.</p>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
|
||||
<p class="sub">Each linked person decides whether their own calendar
|
||||
contributes to this frame -- being linked here doesn't include it
|
||||
automatically.</p>
|
||||
<ul class="calendar-user-list">
|
||||
{% for u in calendar_users %}
|
||||
<li>
|
||||
{% if u.user_id == user.id %}
|
||||
{% if u.has_url %}
|
||||
<label class="checkbox-row" style="margin-top: 6px;">
|
||||
<input type="checkbox" class="calendar-self-toggle" {% if u.included %}checked{% endif %}>
|
||||
{{ u.display_name }} (you)
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 6px;">{{ u.display_name }} (you) -- no calendar set, add one in <a href="/settings">Settings</a>.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 6px;">{{ u.display_name }}:
|
||||
{% if not u.has_url %}no calendar set{% elif u.included %}included{% else %}not included{% endif %}</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if frame.calendar_fetch_summary %}
|
||||
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ frame.calendar_fetch_summary }}</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="card-title" style="margin-top: 20px;">Preview</h2>
|
||||
<p class="sub">How this frame's calendar currently renders.</p>
|
||||
<img class="preview-img" id="calendar-preview" alt="Calendar preview">
|
||||
<button type="button" class="secondary" id="calendar-preview-refresh">Refresh preview</button>
|
||||
<div class="button-assign-group" style="margin-top: 20px;">
|
||||
<h3 class="button-assign-label">BACK button</h3>
|
||||
<ul class="button-action-list" id="button-actions-back"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-back"></select>
|
||||
<select id="button-add-action-back"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -241,19 +191,25 @@
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">The current photo, and exactly how it renders on the
|
||||
panel with this frame's saved settings above.</p>
|
||||
<div class="preview-compare">
|
||||
<div class="preview-pane">
|
||||
<p class="sub">Now displaying</p>
|
||||
<img class="preview-img" id="preview-original" alt="Original photo">
|
||||
{% if photo_widget_id %}
|
||||
<p class="sub">The current photo (from this frame's photo widget), and
|
||||
exactly how it renders on the panel with this frame's saved settings
|
||||
above.</p>
|
||||
<div class="preview-compare">
|
||||
<div class="preview-pane">
|
||||
<p class="sub">Now displaying</p>
|
||||
<img class="preview-img" id="preview-original" alt="Original photo">
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<p class="sub">How it will look on the frame</p>
|
||||
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<p class="sub">How it will look on the frame</p>
|
||||
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
|
||||
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
|
||||
{% else %}
|
||||
<p class="sub">This frame doesn't have a photo widget to preview
|
||||
against yet -- add one from the Layout tab.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,9 +219,11 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
|
||||
</script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_header.js"></script>
|
||||
<script src="/static/frame_config.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Layout{% 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 %}
|
||||
<div id="control-banner" class="control-banner" style="display: none;">
|
||||
<span id="control-holder"></span>
|
||||
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<div class="card-title-row">
|
||||
<h2 class="card-title">Widgets</h2>
|
||||
<button type="button" id="clear-all-widgets" class="secondary btn-inline">Clear all</button>
|
||||
</div>
|
||||
<p class="sub">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. Click a widget's gear icon for its own
|
||||
settings (which album, which calendars, etc.).</p>
|
||||
<div id="widget-canvas-wrap">
|
||||
<div id="widget-canvas"></div>
|
||||
</div>
|
||||
<p class="sub" id="widget-canvas-empty-hint" style="display: none; margin-top: 10px;">
|
||||
Nothing placed yet -- add a widget below.</p>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Add a widget</h2>
|
||||
<div id="add-widget-buttons" class="checkbox-row" style="gap: 10px; flex-wrap: wrap;"></div>
|
||||
<p class="sub" id="add-widget-hint" style="margin-top: 8px;"></p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="widget-dialog">
|
||||
<button type="button" id="widget-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
||||
<div id="widget-dialog-body"><p class="sub">Loading...</p></div>
|
||||
<div class="dialog-result"></div>
|
||||
</dialog>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||
window.FRAME_ID = {{ frame.id | tojson }};
|
||||
</script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_header.js"></script>
|
||||
<script src="/static/widget_dialog_photos.js"></script>
|
||||
<script src="/static/queue.js"></script>
|
||||
<script src="/static/widget_dialog_calendar.js"></script>
|
||||
<script src="/static/widget_dialog_whiteboard.js"></script>
|
||||
<script src="/static/widget_dialog_tasks.js"></script>
|
||||
<script src="/static/widget_dialog_static.js"></script>
|
||||
<script src="/static/widget_dialog_text.js"></script>
|
||||
<script src="/static/frame_layout.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,62 +0,0 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
|
||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="control-banner" class="control-banner" style="display: none;">
|
||||
<span id="control-holder"></span>
|
||||
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Album</h2>
|
||||
<form id="photos-form">
|
||||
<label>Album
|
||||
<select id="album_id">
|
||||
{% if frame.album_id %}<option value="{{ frame.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Upcoming photos to show
|
||||
<select id="queue_target_len">
|
||||
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
||||
<option value="{{ n }}" {% if frame.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="secondary" id="load-albums">Load Albums</button>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Upcoming</h2>
|
||||
<p class="sub">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.</p>
|
||||
<div id="upcoming-grid" class="photo-grid"></div>
|
||||
</section>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/queue.js"></script>
|
||||
<script src="/static/frame_photos.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
|
||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% 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 %}
|
||||
@@ -21,8 +21,9 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script>window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/battery_chart.js"></script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_header.js"></script>
|
||||
<script src="/static/frame_stats.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -33,17 +33,78 @@
|
||||
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Calendar</h2>
|
||||
<label>Calendar URL (iCal/CalDAV .ics feed)
|
||||
<label>Calendar URL (iCal .ics feed)
|
||||
<input type="text" name="calendar_ics_url" placeholder="https://calendar.example.com/you.ics"
|
||||
value="{{ user.calendar_ics_url }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Your personal calendar
|
||||
subscription link (no login needed -- e.g. Google Calendar's
|
||||
Settings → "Secret address in iCal format", or Apple/Outlook/
|
||||
Nextcloud's equivalent). Setting it here doesn't show it anywhere
|
||||
by itself -- include it on any frame you're linked to from that
|
||||
frame's Configuration → Calendar card, so a frame only shows
|
||||
calendars people have actually chosen to share with it.</p>
|
||||
<p class="sub" style="margin-top: 8px;">A single subscription link
|
||||
(no login needed) -- e.g. Google Calendar's Settings →
|
||||
"Secret address in iCal format", or Apple/Outlook's equivalent.</p>
|
||||
|
||||
<label style="margin-top: 20px;">CalDAV server URL
|
||||
<input type="text" name="calendar_caldav_url" placeholder="https://cloud.example.com/remote.php/dav/"
|
||||
value="{{ user.calendar_caldav_url }}">
|
||||
</label>
|
||||
<label>CalDAV username
|
||||
<input type="text" name="calendar_caldav_username" autocomplete="off"
|
||||
value="{{ user.calendar_caldav_username }}">
|
||||
</label>
|
||||
<label>CalDAV password
|
||||
<input type="password" name="calendar_caldav_password" autocomplete="off"
|
||||
placeholder="{% if user.calendar_caldav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">An account (Nextcloud,
|
||||
Fastmail, iCloud, ...) that can expose more than one calendar --
|
||||
e.g. Nextcloud's is usually
|
||||
<code>https://your-server/remote.php/dav/</code>. Save this
|
||||
section first, then use "Discover calendars" below to list what's
|
||||
in the account.</p>
|
||||
<button type="button" class="secondary" id="caldav-discover" style="margin-top: 12px;">Discover calendars</button>
|
||||
<ul class="sub" id="caldav-calendar-list" style="margin-top: 8px; padding-left: 18px;">
|
||||
{% if user.calendar_caldav_calendars %}
|
||||
{% for c in user.calendar_caldav_calendars %}<li>{{ c.display_name }}</li>{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<p class="sub" style="margin-top: 8px;">Neither of these shows up
|
||||
anywhere by itself -- add individual calendars to any frame
|
||||
you're linked to from that frame's Calendar tab, so a frame only
|
||||
shows calendars people have actually chosen to share with it.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Whiteboard (WebDAV) (alpha)</h2>
|
||||
<p class="sub">Credentials for whiteboard frame mode -- fetching a
|
||||
specific file (e.g. a Nextcloud Whiteboard board) over WebDAV.
|
||||
Any WebDAV server works, not just Nextcloud.</p>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="webdav_reuse_caldav_creds" name="webdav_reuse_caldav_creds"
|
||||
value="true" {% if user.webdav_reuse_caldav_creds %}checked{% endif %}>
|
||||
<label for="webdav_reuse_caldav_creds" style="margin: 0; font-weight: normal;">
|
||||
Reuse my CalDAV username/password above (only works if it's the
|
||||
same account -- e.g. Nextcloud's CalDAV and its regular file
|
||||
storage share one login)</label>
|
||||
</div>
|
||||
<div id="webdav-creds-fields">
|
||||
<label>WebDAV username
|
||||
<input type="text" id="webdav_username" name="webdav_username" autocomplete="off"
|
||||
value="{{ user.webdav_username }}">
|
||||
</label>
|
||||
<label>WebDAV password
|
||||
<input type="password" id="webdav_password" name="webdav_password" autocomplete="off"
|
||||
placeholder="{% if user.webdav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}">
|
||||
</label>
|
||||
</div>
|
||||
<label>WebDAV browse root (optional)
|
||||
<input type="text" id="webdav_base_url" name="webdav_base_url" autocomplete="off"
|
||||
placeholder="https://cloud.example.com/remote.php/dav/files/you/"
|
||||
value="{{ user.webdav_base_url }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">A starting folder for the
|
||||
file picker on a frame's Whiteboard tab, so you can browse to a
|
||||
file instead of typing its exact URL. Not required -- you can
|
||||
still paste a URL directly without setting this.</p>
|
||||
<p class="sub" style="margin-top: 8px;">Doesn't show up anywhere by
|
||||
itself -- point a specific frame's Whiteboard tab at a file URL
|
||||
using this account.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
||||
<label>Current password
|
||||
@@ -58,4 +119,10 @@
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/settings.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Parses a contenteditable div's serialized innerHTML (widget_dialog_
|
||||
text.js's POSTed content_html) into a plain, storage-safe run structure
|
||||
-- list of paragraphs, each a list of {"text", "bold", "italic",
|
||||
"underline", "color", "bg"} runs -- for the text widget (see
|
||||
models.TextWidgetConfig, app/widgets/text.py).
|
||||
|
||||
This is the sanitization boundary the checklist's stored-XSS note
|
||||
(CLAUDE.md, another linked user could have set this) is about: raw HTML
|
||||
never round-trips back into any browser DOM. Only text content and a
|
||||
small fixed set of style flags survive parsing; every tag, attribute,
|
||||
and CSS property not explicitly recognized below is simply discarded --
|
||||
there's no allowlist-of-tags-to-keep-as-HTML step where something could
|
||||
slip through unescaped, because nothing is ever re-emitted as HTML at
|
||||
all. The dialog reconstructs its editor from this same run structure via
|
||||
safe DOM calls (createElement/textContent), never innerHTML."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
# Generous ceilings, not exact UX limits -- just stop a direct API call
|
||||
# (bypassing the dialog's own textarea-ish size) from storing something
|
||||
# pathologically large. MAX_INPUT_CHARS bounds parse work; MAX_TOTAL_CHARS
|
||||
# bounds what's actually kept (a widget's on-panel region is a few
|
||||
# hundred pixels -- there is no legible use for more than a few thousand
|
||||
# characters of body text there).
|
||||
MAX_INPUT_CHARS = 200_000
|
||||
MAX_TOTAL_CHARS = 4_000
|
||||
|
||||
_BASE_STYLE = {"bold": False, "italic": False, "underline": False, "color": None, "bg": None}
|
||||
_BLOCK_TAGS = {"div", "p", "li"}
|
||||
_VOID_TAGS = {"br"}
|
||||
|
||||
_HEX6 = re.compile(r"^#([0-9a-fA-F]{6})$")
|
||||
_HEX3 = re.compile(r"^#([0-9a-fA-F]{3})$")
|
||||
_RGB = re.compile(r"^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$")
|
||||
_STYLE_PROP = re.compile(r"([a-zA-Z-]+)\s*:\s*([^;]+)")
|
||||
_BOLD_WEIGHTS = {"bold", "bolder", "600", "700", "800", "900"}
|
||||
|
||||
|
||||
def _normalize_color(value: str) -> str | None:
|
||||
""""#1a2b3c" / "#abc" / "rgb(26, 43, 60)" -> "#1a2b3c". Anything else
|
||||
(a CSS named color, "transparent", garbage) -> None, i.e. dropped --
|
||||
this is the one place an arbitrary style-attribute string could try
|
||||
to smuggle something through, so it's a strict allowlist match, not
|
||||
a best-effort parse."""
|
||||
value = value.strip()
|
||||
m = _HEX6.match(value)
|
||||
if m:
|
||||
return "#" + m.group(1).lower()
|
||||
m = _HEX3.match(value)
|
||||
if m:
|
||||
return "#" + "".join(c * 2 for c in m.group(1)).lower()
|
||||
m = _RGB.match(value)
|
||||
if m:
|
||||
r, g, b = (max(0, min(255, int(x))) for x in m.groups())
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
return None
|
||||
|
||||
|
||||
class _RichTextParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.paragraphs: list[list[dict]] = [[]]
|
||||
self._style_stack: list[dict] = [_BASE_STYLE]
|
||||
self._at_line_start = True
|
||||
|
||||
def _break(self, tag: str) -> None:
|
||||
# Coalesces contenteditable's per-line block wrapping (Chrome
|
||||
# wraps every line in its own <div> even without a deliberate
|
||||
# blank line) down to one paragraph break per actual line gap,
|
||||
# while still letting an explicit <br> when already at a fresh
|
||||
# line start (Chrome's "<div><br></div>" idiom for a blank line,
|
||||
# or a genuine double Shift+Enter) add a real blank paragraph.
|
||||
if not self._at_line_start:
|
||||
self.paragraphs.append([])
|
||||
self._at_line_start = True
|
||||
elif tag == "br":
|
||||
self.paragraphs.append([])
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag in _BLOCK_TAGS or tag in _VOID_TAGS:
|
||||
self._break(tag)
|
||||
if tag in _VOID_TAGS:
|
||||
return
|
||||
style = dict(self._style_stack[-1])
|
||||
attrs_dict = {k: v for k, v in attrs if v is not None}
|
||||
if tag in ("b", "strong"):
|
||||
style["bold"] = True
|
||||
elif tag in ("i", "em"):
|
||||
style["italic"] = True
|
||||
elif tag == "u":
|
||||
style["underline"] = True
|
||||
elif tag == "font":
|
||||
color = _normalize_color(attrs_dict.get("color", ""))
|
||||
if color:
|
||||
style["color"] = color
|
||||
elif tag == "span":
|
||||
for prop, val in _STYLE_PROP.findall(attrs_dict.get("style", "")):
|
||||
prop = prop.strip().lower()
|
||||
val = val.strip()
|
||||
if prop == "color":
|
||||
color = _normalize_color(val)
|
||||
if color:
|
||||
style["color"] = color
|
||||
elif prop == "background-color":
|
||||
color = _normalize_color(val)
|
||||
if color:
|
||||
style["bg"] = color
|
||||
elif prop == "font-weight" and val.lower() in _BOLD_WEIGHTS:
|
||||
style["bold"] = True
|
||||
elif prop == "font-style" and val.lower() == "italic":
|
||||
style["italic"] = True
|
||||
elif prop == "text-decoration" and "underline" in val.lower():
|
||||
style["underline"] = True
|
||||
# Pushed for every non-void tag, including ones with no
|
||||
# recognized style effect (script/a/img/...) -- keeps push/pop
|
||||
# balanced against handle_endtag regardless of tag, without
|
||||
# needing to track which tags actually pushed something.
|
||||
self._style_stack.append(style)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in _VOID_TAGS:
|
||||
return
|
||||
if len(self._style_stack) > 1:
|
||||
self._style_stack.pop()
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not data:
|
||||
return
|
||||
style = self._style_stack[-1]
|
||||
self.paragraphs[-1].append({"text": data, **style})
|
||||
if data.strip():
|
||||
self._at_line_start = False
|
||||
|
||||
|
||||
def parse_rich_text(html: str) -> list[list[dict]]:
|
||||
"""The sanitization entry point -- see module docstring. Always
|
||||
returns a valid (possibly all-empty) paragraphs structure, never
|
||||
raises for malformed markup (html.parser tolerates unclosed/
|
||||
mismatched tags; handle_endtag's length guard tolerates an
|
||||
over-popped stack)."""
|
||||
parser = _RichTextParser()
|
||||
parser.feed(html[:MAX_INPUT_CHARS])
|
||||
parser.close()
|
||||
paragraphs = parser.paragraphs
|
||||
|
||||
total = 0
|
||||
truncated: list[list[dict]] = []
|
||||
for para in paragraphs:
|
||||
new_para: list[dict] = []
|
||||
for run in para:
|
||||
remaining = MAX_TOTAL_CHARS - total
|
||||
if remaining <= 0:
|
||||
break
|
||||
text = run["text"][:remaining]
|
||||
total += len(text)
|
||||
new_para.append({**run, "text": text})
|
||||
truncated.append(new_para)
|
||||
if total >= MAX_TOTAL_CHARS:
|
||||
break
|
||||
return truncated
|
||||
|
||||
|
||||
def has_text(paragraphs: list[list[dict]] | None) -> bool:
|
||||
if not paragraphs:
|
||||
return False
|
||||
return any(run["text"].strip() for para in paragraphs for run in para)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Weather strip for calendar frame mode (agenda/today & tomorrow/week
|
||||
views only -- never month, there's no room, see calendar_render.py's
|
||||
_BUILDERS). A frame can list multiple cities; each is geocoded once via
|
||||
Open-Meteo's free geocoding API (no API key, no signup, no per-request
|
||||
quota to manage) when added from the Calendar tab, then its daily
|
||||
forecast is refreshed on its own throttle -- same shape idiom as
|
||||
calendar_feed.py's merge-fetch cache.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/caldav_client.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
|
||||
CHECK_INTERVAL_S = 3 * 60 * 60 # weather doesn't need calendar_feed's 20-minute cadence
|
||||
FORECAST_DAYS = 14 # comfortably covers the week view's furthest browse-forward
|
||||
|
||||
# Open-Meteo's WMO weather codes (https://open-meteo.com/en/docs), grouped
|
||||
# into the handful of icon categories calendar_render.py actually draws.
|
||||
_CODE_CATEGORIES = {
|
||||
0: "clear",
|
||||
1: "partly_cloudy", 2: "partly_cloudy",
|
||||
3: "cloudy",
|
||||
45: "fog", 48: "fog",
|
||||
51: "rain", 53: "rain", 55: "rain", 56: "rain", 57: "rain",
|
||||
61: "rain", 63: "rain", 65: "rain", 66: "rain", 67: "rain",
|
||||
80: "rain", 81: "rain", 82: "rain",
|
||||
71: "snow", 73: "snow", 75: "snow", 77: "snow", 85: "snow", 86: "snow",
|
||||
95: "thunderstorm", 96: "thunderstorm", 99: "thunderstorm",
|
||||
}
|
||||
|
||||
|
||||
class WeatherFetchError(Exception):
|
||||
"""Geocoding or forecast fetch failed -- network, no match, or an
|
||||
unexpected response shape. Raised loudly; callers (the Calendar
|
||||
tab's add-city endpoint, get_or_refresh_weather) decide what to do."""
|
||||
|
||||
|
||||
def weather_category(code: int) -> str:
|
||||
"""Falls back to "cloudy" for any WMO code Open-Meteo might add later
|
||||
that isn't in the table above -- an unrecognized code shouldn't drop
|
||||
a day's weather entirely, just render with a generic icon."""
|
||||
return _CODE_CATEGORIES.get(code, "cloudy")
|
||||
|
||||
|
||||
# Open-Meteo's geocoder matches on the bare place name only -- "Portland,
|
||||
# OR" returns zero results even though "Portland" alone returns three
|
||||
# (OR/ME/IN, disambiguated by population-ranked order). So a ", <state>"
|
||||
# or ", <country>" qualifier is split off client-side and used to filter
|
||||
# among the candidates instead of being sent as part of the search term.
|
||||
_US_STATE_ABBREVIATIONS = {
|
||||
"al": "alabama", "ak": "alaska", "az": "arizona", "ar": "arkansas", "ca": "california",
|
||||
"co": "colorado", "ct": "connecticut", "de": "delaware", "fl": "florida", "ga": "georgia",
|
||||
"hi": "hawaii", "id": "idaho", "il": "illinois", "in": "indiana", "ia": "iowa",
|
||||
"ks": "kansas", "ky": "kentucky", "la": "louisiana", "me": "maine", "md": "maryland",
|
||||
"ma": "massachusetts", "mi": "michigan", "mn": "minnesota", "ms": "mississippi", "mo": "missouri",
|
||||
"mt": "montana", "ne": "nebraska", "nv": "nevada", "nh": "new hampshire", "nj": "new jersey",
|
||||
"nm": "new mexico", "ny": "new york", "nc": "north carolina", "nd": "north dakota", "oh": "ohio",
|
||||
"ok": "oklahoma", "or": "oregon", "pa": "pennsylvania", "ri": "rhode island", "sc": "south carolina",
|
||||
"sd": "south dakota", "tn": "tennessee", "tx": "texas", "ut": "utah", "vt": "vermont",
|
||||
"va": "virginia", "wa": "washington", "wv": "west virginia", "wi": "wisconsin", "wy": "wyoming",
|
||||
"dc": "district of columbia",
|
||||
}
|
||||
|
||||
|
||||
def _matches_qualifier(result: dict, qualifier: str) -> bool:
|
||||
q = qualifier.strip().lower()
|
||||
expanded = _US_STATE_ABBREVIATIONS.get(q, q)
|
||||
admin1 = (result.get("admin1") or "").lower()
|
||||
country = (result.get("country") or "").lower()
|
||||
country_code = (result.get("country_code") or "").lower()
|
||||
return expanded in admin1 or expanded in country or q == country_code
|
||||
|
||||
|
||||
def geocode_city(name: str) -> dict:
|
||||
"""Best-match {"label", "latitude", "longitude"} for a free-text city
|
||||
name, optionally qualified with a state/country (e.g. "Portland, OR")
|
||||
via Open-Meteo's geocoder. label is the resolved place name (city +
|
||||
admin1/country when available), not necessarily what the user typed
|
||||
-- shown back so they can confirm it found the right place before
|
||||
it's saved."""
|
||||
query, _, qualifier = name.partition(",")
|
||||
query, qualifier = query.strip(), qualifier.strip()
|
||||
try:
|
||||
resp = httpx.get(GEOCODE_URL, params={"name": query, "count": 10}, timeout=HTTP_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
results = data.get("results") or []
|
||||
if not results:
|
||||
raise WeatherFetchError(f"No location found matching {name!r}")
|
||||
if qualifier:
|
||||
qualified = [r for r in results if _matches_qualifier(r, qualifier)]
|
||||
if not qualified:
|
||||
raise WeatherFetchError(f"No location found matching {name!r}")
|
||||
results = qualified
|
||||
r = results[0]
|
||||
parts = [r["name"]]
|
||||
if r.get("admin1"):
|
||||
parts.append(r["admin1"])
|
||||
if r.get("country"):
|
||||
parts.append(r["country"])
|
||||
return {"label": ", ".join(parts), "latitude": r["latitude"], "longitude": r["longitude"]}
|
||||
|
||||
|
||||
def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[str, dict]:
|
||||
"""{"YYYY-MM-DD": {"code": int, "high": float, "low": float}, ...}
|
||||
for the next FORECAST_DAYS days, already in `units`
|
||||
("fahrenheit"/"celsius") -- Open-Meteo converts server-side, so
|
||||
there's no client-side unit math to get wrong."""
|
||||
try:
|
||||
resp = httpx.get(FORECAST_URL, params={
|
||||
"latitude": latitude, "longitude": longitude,
|
||||
"daily": "weathercode,temperature_2m_max,temperature_2m_min",
|
||||
"temperature_unit": units,
|
||||
"timezone": "auto",
|
||||
"forecast_days": FORECAST_DAYS,
|
||||
}, timeout=HTTP_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
daily = resp.json()["daily"]
|
||||
return {
|
||||
day: {"code": code, "high": high, "low": low}
|
||||
for day, code, high, low in zip(
|
||||
daily["time"], daily["weathercode"], daily["temperature_2m_max"], daily["temperature_2m_min"]
|
||||
)
|
||||
}
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Plain authenticated WebDAV file fetch -- whiteboard frame mode's way
|
||||
of pulling one specific file (a Nextcloud Whiteboard .whiteboard, or any
|
||||
other WebDAV server's file, this isn't Nextcloud-specific) out of a
|
||||
user's account. Deliberately just "GET this URL with Basic auth", the
|
||||
same shape as calendar_feed.py's plain ICS fetch -- no discovery, no
|
||||
account-wide browsing, since the caller already has (or pastes) the
|
||||
exact file URL, unlike caldav_client.py's calendar-account discovery
|
||||
flow which exists because a CalDAV account can hold several calendars
|
||||
worth picking between.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/caldav_client.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import unquote, urljoin, urlsplit, urlunsplit
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import httpx
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
FETCH_MAX_BYTES = 10 * 1024 * 1024 # a whiteboard scene is KB, not MB -- sanity cap, not an expected size
|
||||
|
||||
_DAV_NS = "DAV:"
|
||||
_PROPFIND_BODY = (
|
||||
'<?xml version="1.0" encoding="utf-8" ?>'
|
||||
f'<d:propfind xmlns:d="{_DAV_NS}"><d:prop>'
|
||||
"<d:displayname/><d:resourcetype/></d:prop></d:propfind>"
|
||||
)
|
||||
|
||||
|
||||
class WebDavError(Exception):
|
||||
"""Fetch failed -- network, auth, a missing file, or an oversized
|
||||
response. Raised loudly; callers decide what to do."""
|
||||
|
||||
|
||||
def fetch_file(url: str, username: str, password: str) -> bytes:
|
||||
"""The raw bytes of one WebDAV file, HTTP Basic auth. That's the
|
||||
whole protocol surface whiteboard mode needs -- Basic auth over
|
||||
plain HTTP GET is what WebDAV file access boils down to once you
|
||||
already have the exact URL, no PROPFIND/discovery involved."""
|
||||
try:
|
||||
with httpx.stream("GET", url, auth=(username, password), timeout=HTTP_TIMEOUT_S,
|
||||
follow_redirects=True) as resp:
|
||||
resp.raise_for_status()
|
||||
chunks = []
|
||||
total = 0
|
||||
for chunk in resp.iter_bytes():
|
||||
total += len(chunk)
|
||||
if total > FETCH_MAX_BYTES:
|
||||
raise WebDavError(f"File exceeds {FETCH_MAX_BYTES} bytes")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
except httpx.HTTPError as e:
|
||||
raise WebDavError(str(e)) from e
|
||||
|
||||
|
||||
def list_directory(url: str, username: str, password: str) -> list[dict]:
|
||||
"""One level of a WebDAV directory listing -- name/url/is_dir for
|
||||
each entry, folders first then alphabetical. Powers the whiteboard
|
||||
file-picker (browse instead of paste the exact file URL); a plain
|
||||
Depth-1 PROPFIND for displayname + resourcetype is all that needs,
|
||||
same "just enough protocol, not a full WebDAV client" scope as
|
||||
fetch_file above."""
|
||||
try:
|
||||
resp = httpx.request(
|
||||
"PROPFIND", url, auth=(username, password), timeout=HTTP_TIMEOUT_S,
|
||||
follow_redirects=True, headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=_PROPFIND_BODY,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
raise WebDavError(str(e)) from e
|
||||
|
||||
try:
|
||||
root = ElementTree.fromstring(resp.content)
|
||||
except ElementTree.ParseError as e:
|
||||
raise WebDavError(f"Server returned an unparseable directory listing: {e}") from e
|
||||
|
||||
self_path = urlsplit(url).path.rstrip("/")
|
||||
entries = []
|
||||
for response_el in root.findall(f"{{{_DAV_NS}}}response"):
|
||||
href_el = response_el.find(f"{{{_DAV_NS}}}href")
|
||||
if href_el is None or not href_el.text:
|
||||
continue
|
||||
href = href_el.text
|
||||
if urlsplit(href).path.rstrip("/") == self_path:
|
||||
continue # the listed directory's own entry, not a child
|
||||
|
||||
propstat = response_el.find(f"{{{_DAV_NS}}}propstat")
|
||||
prop = propstat.find(f"{{{_DAV_NS}}}prop") if propstat is not None else None
|
||||
resourcetype = prop.find(f"{{{_DAV_NS}}}resourcetype") if prop is not None else None
|
||||
is_dir = resourcetype is not None and resourcetype.find(f"{{{_DAV_NS}}}collection") is not None
|
||||
|
||||
displayname_el = prop.find(f"{{{_DAV_NS}}}displayname") if prop is not None else None
|
||||
name = (displayname_el.text or "").strip() if displayname_el is not None else ""
|
||||
if not name:
|
||||
name = unquote(href.rstrip("/").rsplit("/", 1)[-1])
|
||||
if not name:
|
||||
continue
|
||||
|
||||
entries.append({"name": name, "url": urljoin(url, href), "is_dir": is_dir})
|
||||
|
||||
entries.sort(key=lambda e: (not e["is_dir"], e["name"].casefold()))
|
||||
return entries
|
||||
|
||||
|
||||
def parent_directory_url(base_url: str, current_url: str) -> str | None:
|
||||
"""One level up from `current_url`, or None if `current_url` is
|
||||
already at (or above) `base_url` -- the file-picker's "Up" button
|
||||
doesn't wander outside the folder the user configured as their
|
||||
browse root in Settings."""
|
||||
base_path = urlsplit(base_url).path.rstrip("/")
|
||||
current = urlsplit(current_url)
|
||||
current_path = current.path.rstrip("/")
|
||||
if len(current_path) <= len(base_path):
|
||||
return None
|
||||
parent_path = current_path.rsplit("/", 1)[0] or "/"
|
||||
if len(parent_path) < len(base_path):
|
||||
parent_path = base_path
|
||||
return urlunsplit((current.scheme, current.netloc, parent_path + "/", "", ""))
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Whiteboard frame mode: fetches a Nextcloud Whiteboard (or any other
|
||||
WebDAV server's) .whiteboard file and renders it via the local
|
||||
render-service sidecar (server/render-service/, own README there) --
|
||||
Excalidraw's real export code, not a hand-rolled reimplementation of its
|
||||
element types/styling/fonts.
|
||||
|
||||
Deliberately renders at a fixed generous width, not the panel's exact
|
||||
target size -- the resulting PNG then runs through
|
||||
image_pipeline.compose_into exactly like a photo would (crop/letterbox
|
||||
per the frame's own display_mode setting), so this module doesn't need
|
||||
to know anything about panel dimensions/orientation, and whiteboard mode
|
||||
reuses the same fit logic photos mode already has instead of a second
|
||||
parallel implementation of it.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/weather.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from . import webdav_client
|
||||
|
||||
RENDER_SERVICE_URL = "http://127.0.0.1:3001/render"
|
||||
# Rendering (not just fetching) can take a moment for a busy board --
|
||||
# more generous than a typical fetch timeout.
|
||||
HTTP_TIMEOUT_S = 30.0
|
||||
# Fixed render width regardless of the target frame's orientation/size --
|
||||
# see module docstring. Comfortably above this panel's 800px long edge
|
||||
# so downstream cropping isn't working from an upscaled source.
|
||||
RENDER_WIDTH = 1600
|
||||
|
||||
CHECK_INTERVAL_S = 20 * 60 # same cadence as calendar_feed's merge-fetch throttle
|
||||
|
||||
|
||||
class WhiteboardRenderError(Exception):
|
||||
"""Fetch or render failed -- network, auth, an invalid/non-JSON
|
||||
file, or the render sidecar itself erroring. Raised loudly; callers
|
||||
decide what to do."""
|
||||
|
||||
|
||||
def fetch_and_render(url: str, username: str, password: str) -> bytes:
|
||||
"""Fetches the .whiteboard file at `url` and renders it to a PNG via
|
||||
the local render-service sidecar. Returns raw PNG bytes at
|
||||
RENDER_WIDTH wide, natural aspect ratio."""
|
||||
try:
|
||||
raw = webdav_client.fetch_file(url, username, password)
|
||||
except webdav_client.WebDavError as e:
|
||||
raise WhiteboardRenderError(f"Could not fetch whiteboard file: {e}") from e
|
||||
|
||||
try:
|
||||
scene = json.loads(raw)
|
||||
except ValueError as e:
|
||||
raise WhiteboardRenderError(f"Not a valid whiteboard file (not JSON): {e}") from e
|
||||
if not isinstance(scene.get("elements"), list):
|
||||
raise WhiteboardRenderError('Not a valid whiteboard file (no "elements" array)')
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
RENDER_SERVICE_URL,
|
||||
json={
|
||||
"elements": scene.get("elements", []),
|
||||
"appState": scene.get("appState", {}),
|
||||
"files": scene.get("files", {}),
|
||||
"width": RENDER_WIDTH,
|
||||
},
|
||||
timeout=HTTP_TIMEOUT_S,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except httpx.HTTPError as e:
|
||||
raise WhiteboardRenderError(f"Render sidecar failed: {e}") from e
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Registry mapping a Widget's widget_type to its render/action module --
|
||||
the widget-system's analogue of routers/device.py's old RENDERERS/
|
||||
ADVANCE_RENDERERS/BACK_RENDERERS dicts, generalized from "one mode owns
|
||||
the whole panel" to "each widget renders into its own region and
|
||||
optionally responds to named button actions."
|
||||
|
||||
Each module in this package exposes:
|
||||
|
||||
render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image
|
||||
An RGB image exactly target_w x target_h, unquantized -- the
|
||||
widget's content composed into its own region. Never returns
|
||||
packed panel bytes or raises for a foreseeable failure (a
|
||||
widget's own fetch hiccup shows a small placeholder instead) --
|
||||
image_pipeline.render_panel composites every widget's own
|
||||
render() result onto one shared canvas and quantizes/packs the
|
||||
whole thing once (see its own docstring). is_normal_wake
|
||||
distinguishes an ordinary /frame/image GET from a button-
|
||||
triggered re-render -- only app/widgets/calendar.py's render()
|
||||
actually uses it (resetting browse_offset back to "today" on a
|
||||
normal wake), but every module accepts it for one uniform call
|
||||
signature regardless.
|
||||
|
||||
ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]
|
||||
Named button actions this widget type supports (e.g. "advance",
|
||||
"back", "check_now") -- see models.FrameButtonAction. Each
|
||||
function mutates the widget's own state via db.widget_locked
|
||||
internally; none return a value or re-render themselves --
|
||||
whatever dispatches a button press (routers/device.py, once the
|
||||
widget-system cutover lands) re-renders the whole panel once
|
||||
after running every assigned action.
|
||||
|
||||
ACTION_LABELS: dict[str, str]
|
||||
Human-readable labels for ACTIONS' keys, for the button-
|
||||
assignment UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import calendar, photos, static_image, tasks, text, whiteboard
|
||||
|
||||
WIDGET_TYPES = {
|
||||
"photos": photos,
|
||||
"calendar": calendar,
|
||||
"whiteboard": whiteboard,
|
||||
"tasks": tasks,
|
||||
"static": static_image,
|
||||
"text": text,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Tiny widget-region placeholder image, shared by every widget-type
|
||||
module for the "not configured yet" / "temporarily unavailable" case --
|
||||
deliberately much simpler than image_pipeline.render_placeholder (no QR
|
||||
code, no full-panel-scale fonts): a widget's own region can be a small
|
||||
fraction of the panel, so its placeholder needs to scale down with it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
_BG = (245, 245, 245)
|
||||
_FG = (90, 90, 90)
|
||||
|
||||
|
||||
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
|
||||
img = Image.new("RGB", (target_w, target_h), _BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font_size = max(10, min(20, target_h // 8))
|
||||
font = ImageFont.load_default(size=font_size)
|
||||
line_h = font_size + 4
|
||||
total_h = line_h * len(lines)
|
||||
y = max(4, (target_h - total_h) // 2)
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_w = bbox[2] - bbox[0]
|
||||
x = max(4, (target_w - line_w) // 2)
|
||||
draw.text((x, y), line, fill=_FG, font=font)
|
||||
y += line_h
|
||||
return img
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Calendar widget: merged-event agenda/week/month view into the
|
||||
widget's own region -- the widget-system analogue of routers/device.py's
|
||||
old _render_calendar_mode/_advance_calendar_mode/_back_calendar_mode.
|
||||
|
||||
render() builds directly at whatever target box it's asked for --
|
||||
calendar_render.py's layout math picks from discrete size tiers (see
|
||||
its own _size_tier) rather than always laying out at full panel size and
|
||||
resizing after the fact, so a small placed calendar widget gets an
|
||||
actually-legible small-size layout instead of a shrunk-down full-size
|
||||
one.
|
||||
|
||||
"Photo inlay" (calendar mode's old half-and-half photo split) has no
|
||||
widget-system equivalent -- place an independent photo widget alongside
|
||||
instead; arbitrary placement is strictly more flexible than one fixed
|
||||
split ever was."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..calendar_render import _build
|
||||
from ..db import widget_locked
|
||||
from ..models import CalendarWidgetConfig, Frame, Widget
|
||||
from ..routers.common import (
|
||||
get_or_refresh_calendar_events_for_widget,
|
||||
get_or_refresh_weather_for_widget,
|
||||
)
|
||||
|
||||
ACTION_LABELS = {"advance": "Next period", "back": "Previous period"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake=True (an ordinary /frame/image GET, not a button
|
||||
press) resets browse_offset back to "today" if it had drifted --
|
||||
mirrors the old _render_calendar_mode's identical behavior. A button
|
||||
press explicitly moved the browse position on purpose, so it passes
|
||||
is_normal_wake=False to render its own already-updated offset instead
|
||||
of immediately snapping back to 0."""
|
||||
if is_normal_wake:
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
if locked_cfg.browse_offset != 0:
|
||||
locked_cfg.browse_offset = 0
|
||||
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
||||
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
|
||||
|
||||
return _build(
|
||||
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
|
||||
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
|
||||
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
|
||||
week_days=cfg.week_days, week_layout=cfg.week_layout,
|
||||
week_start_offset=cfg.week_start_offset,
|
||||
)
|
||||
|
||||
|
||||
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.browse_offset += 1
|
||||
|
||||
|
||||
def _back(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.browse_offset -= 1
|
||||
|
||||
|
||||
ACTIONS = {"advance": _advance, "back": _back}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Photos widget: composes one photo from Immich into the widget's own
|
||||
region -- the widget-system analogue of routers/device.py's old
|
||||
_render_photos_mode/_advance_photos_mode/_back_photos_mode, and
|
||||
app/photo_queue.py's real client.
|
||||
|
||||
render() never raises -- an Immich hiccup for this one widget shouldn't
|
||||
take down the whole panel's render just because one region out of
|
||||
several couldn't be composed this cycle; it falls back to a small
|
||||
placeholder instead, the same resilience calendar mode's old photo-inlay
|
||||
already had (see routers/device.py's `except HTTPException: pass` around
|
||||
its own inlay fetch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import photo_queue, quiet_hours
|
||||
from ..db import widget_locked
|
||||
from ..image_pipeline import compose_into
|
||||
from ..models import Frame, PhotoWidgetConfig, Widget
|
||||
from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets
|
||||
from ._shared import placeholder_image
|
||||
|
||||
ACTION_LABELS = {"advance": "Next photo", "back": "Previous photo"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake is unused here -- photos mode's advance timing is
|
||||
already fully idempotent via get_current()'s own elapsed-time check,
|
||||
unlike calendar mode's browse_offset (see app/widgets/calendar.py's
|
||||
render()). Accepted anyway so every widget type's render() shares one
|
||||
call signature regardless of which ones actually care."""
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
return placeholder_image(target_w, target_h, ["Photos widget", "not configured yet"])
|
||||
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, cfg.album_id)
|
||||
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
|
||||
photo_queue.get_current(locked_cfg, assets, locked_frame,
|
||||
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
|
||||
asset_id = locked_cfg.current_asset_id
|
||||
if not asset_id:
|
||||
return placeholder_image(target_w, target_h, ["No photos available"])
|
||||
source, faces = fetch_source_and_faces(client, cfg.display_mode, asset_id)
|
||||
except HTTPException as e:
|
||||
return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]])
|
||||
|
||||
return compose_into(source, faces, target_w, target_h, cfg.display_mode)
|
||||
|
||||
|
||||
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
return
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, cfg.album_id)
|
||||
except HTTPException:
|
||||
return # nothing to advance to this cycle -- next button press tries again
|
||||
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
|
||||
photo_queue.advance_forced(locked_cfg, assets, locked_frame)
|
||||
|
||||
|
||||
def _back(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
return
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, cfg.album_id)
|
||||
except HTTPException:
|
||||
return
|
||||
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
|
||||
photo_queue.back_forced(locked_cfg, assets, locked_frame)
|
||||
|
||||
|
||||
ACTIONS = {"advance": _advance, "back": _back}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Static image widget: shows whatever image the user last uploaded
|
||||
(routers/api_widgets.py's api_widget_static_upload already decoded
|
||||
PNG/JPEG/GIF/PDF/etc. into plain RGB PNG bytes at upload time, see
|
||||
app/image_upload.py and models.StaticWidgetConfig) -- no live upstream
|
||||
to fetch, so render() is just a decode + compose_into, the simplest of
|
||||
every widget type's render().
|
||||
|
||||
No button actions -- there's nothing to advance/back/check for a fixed
|
||||
uploaded image."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..image_pipeline import compose_into
|
||||
from ..models import Frame, StaticWidgetConfig, Widget
|
||||
from ._shared import placeholder_image
|
||||
|
||||
ACTIONS: dict = {}
|
||||
ACTION_LABELS: dict[str, str] = {}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
|
||||
identical note; every widget type's render() shares one call
|
||||
signature regardless of which ones actually care."""
|
||||
cfg = db.get(StaticWidgetConfig, widget.id)
|
||||
if not cfg.image:
|
||||
return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"])
|
||||
source = Image.open(io.BytesIO(cfg.image)).convert("RGB")
|
||||
return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode=cfg.display_mode)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tasks widget: a simple outstanding-task checklist merged from one or
|
||||
more of its linked users' CalDAV task lists, in its own region -- split
|
||||
out of the calendar widget's old week-view-only, single-list task list
|
||||
(see models.TaskWidgetConfig) so a task list can be placed and sized on
|
||||
its own, independent of any calendar's view/footprint, and can merge
|
||||
more than one person's list the same way a calendar widget merges more
|
||||
than one person's calendar (see models.FrameTaskList).
|
||||
|
||||
No "enabled" concept and no button actions: the widget's mere presence
|
||||
on the grid is the on/off switch (same as every other widget type), and
|
||||
its cache refreshes on the same throttled schedule as weather -- nothing
|
||||
here to advance/back/force. No placeholder for "nothing included yet"
|
||||
either -- same posture as app/widgets/calendar.py, which this otherwise
|
||||
mirrors closely: render() always draws through get_or_refresh_tasks_
|
||||
for_widget's result even when it's [], showing "Nothing outstanding"
|
||||
rather than a distinct not-configured state (the dialog's preview
|
||||
endpoint is what actually 400s for that case, same asymmetry calendar
|
||||
already has)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..calendar_render import _build_tasks
|
||||
from ..models import Frame, TaskWidgetConfig, Widget
|
||||
from ..routers.common import get_or_refresh_tasks_for_widget
|
||||
|
||||
ACTION_LABELS: dict[str, str] = {}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake is unused here -- see app/widgets/photos.py's
|
||||
identical note; every widget type's render() shares one call
|
||||
signature regardless of which ones actually care."""
|
||||
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||
cfg = db.get(TaskWidgetConfig, widget.id)
|
||||
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, cfg.name or "Tasks")
|
||||
|
||||
|
||||
ACTIONS: dict = {}
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Text widget: user-authored rich text (bold/italic/underline, per-run
|
||||
text/highlight color), composed once in the dialog and rendered on
|
||||
every panel refresh from the parsed run structure -- no live upstream to
|
||||
fetch, same self-contained shape as static_image.py, just word-wrapped
|
||||
text instead of an uploaded image. See app/text_content.py for how the
|
||||
dialog's contenteditable HTML becomes models.TextWidgetConfig.content
|
||||
(the sanitization boundary; this module never sees raw HTML).
|
||||
|
||||
Bold/italic use real vendored font weights (app/fonts/NotoSans-*.ttf,
|
||||
OFL-licensed like the emoji fonts already there) rather than every other
|
||||
widget's single ImageFont.load_default() -- the one widget type where
|
||||
that distinction is the whole point.
|
||||
|
||||
No button actions -- there's nothing to advance/back/check for a fixed
|
||||
block of authored text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
|
||||
from ..models import Frame, TextWidgetConfig, Widget
|
||||
from ..text_content import has_text
|
||||
from ._shared import placeholder_image
|
||||
|
||||
ACTIONS: dict = {}
|
||||
ACTION_LABELS: dict[str, str] = {}
|
||||
|
||||
MARGIN = 14
|
||||
MIN_FONT_SIZE = 10
|
||||
LINE_HEIGHT_FACTOR = 1.35
|
||||
DEFAULT_FG = (0, 0, 0)
|
||||
DEFAULT_BG = (255, 255, 255)
|
||||
|
||||
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"
|
||||
_FONT_FILES = {
|
||||
(False, False): "NotoSans-Regular.ttf",
|
||||
(True, False): "NotoSans-Bold.ttf",
|
||||
(False, True): "NotoSans-Italic.ttf",
|
||||
(True, True): "NotoSans-BoldItalic.ttf",
|
||||
}
|
||||
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _font(bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
|
||||
return ImageFont.truetype(str(_FONT_DIR / _FONT_FILES[(bold, italic)]), size)
|
||||
|
||||
|
||||
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
|
||||
"""One paragraph's styled runs -> word groups: each group is a list
|
||||
of same-word sub-tokens that must stay glued together on one line
|
||||
(no whitespace between them in the source) -- otherwise bolding part
|
||||
of a word (e.g. "wor**ld**") would introduce a spurious space at the
|
||||
style boundary once wrapped. Whitespace runs become the implicit gap
|
||||
between groups (collapsed to a single space, however many source
|
||||
characters it was)."""
|
||||
groups: list[list[dict]] = []
|
||||
current: list[dict] = []
|
||||
for run in paragraph:
|
||||
for piece in _WORD_OR_SPACE.findall(run["text"]):
|
||||
if piece.isspace():
|
||||
if current:
|
||||
groups.append(current)
|
||||
current = []
|
||||
else:
|
||||
current.append({**run, "text": piece})
|
||||
if current:
|
||||
groups.append(current)
|
||||
return groups
|
||||
|
||||
|
||||
def _group_width(draw: ImageDraw.ImageDraw, group: list[dict], size: int) -> float:
|
||||
return sum(draw.textlength(tok["text"], font=_font(tok["bold"], tok["italic"], size)) for tok in group)
|
||||
|
||||
|
||||
def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], size: int,
|
||||
max_width: int, space_width: float) -> list[list[list[dict]]]:
|
||||
"""Greedy word wrap -> list of lines, each a list of word groups.
|
||||
An empty `groups` (a blank authored line) still produces one empty
|
||||
line, to preserve the blank line's vertical space."""
|
||||
lines: list[list[list[dict]]] = []
|
||||
current: list[list[dict]] = []
|
||||
current_w = 0.0
|
||||
for group in groups:
|
||||
gw = _group_width(draw, group, size)
|
||||
add_w = gw + (space_width if current else 0)
|
||||
if current and current_w + add_w > max_width:
|
||||
lines.append(current)
|
||||
current = [group]
|
||||
current_w = gw
|
||||
else:
|
||||
current.append(group)
|
||||
current_w += add_w
|
||||
if current or not groups:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
def _fit(draw: ImageDraw.ImageDraw, paragraphs: list[list[dict]], start_size: int,
|
||||
max_width: int, max_height: int) -> tuple[int, list[list[list[dict]]]]:
|
||||
"""Shrinks font size (down to MIN_FONT_SIZE) until the wrapped
|
||||
content's total height fits max_height, or gives up at the floor --
|
||||
a too-small widget box just clips rather than raising. Returns the
|
||||
chosen size and the flat list of lines (each a list of word groups)
|
||||
across every paragraph, in order."""
|
||||
size = max(MIN_FONT_SIZE, start_size)
|
||||
lines: list[list[list[dict]]] = []
|
||||
while True:
|
||||
space_width = draw.textlength(" ", font=_font(False, False, size))
|
||||
lines = []
|
||||
for paragraph in paragraphs:
|
||||
lines.extend(_wrap_paragraph(draw, _paragraph_word_groups(paragraph), size, max_width, space_width))
|
||||
line_h = round(size * LINE_HEIGHT_FACTOR)
|
||||
total_h = len(lines) * line_h
|
||||
if total_h <= max_height or size <= MIN_FONT_SIZE:
|
||||
return size, lines
|
||||
size = max(MIN_FONT_SIZE, size - 2)
|
||||
|
||||
|
||||
def _draw_line(img: Image.Image, draw: ImageDraw.ImageDraw, line: list[list[dict]], y: int,
|
||||
size: int, line_h: int, max_width: int, align: str, space_width: float) -> None:
|
||||
line_width = sum(_group_width(draw, g, size) for g in line) + space_width * max(0, len(line) - 1)
|
||||
if align == "center":
|
||||
x = MARGIN + max(0, (max_width - line_width) / 2)
|
||||
elif align == "right":
|
||||
x = MARGIN + max(0, max_width - line_width)
|
||||
else:
|
||||
x = MARGIN
|
||||
underline_h = max(1, size // 16)
|
||||
for gi, group in enumerate(line):
|
||||
for tok in group:
|
||||
font = _font(tok["bold"], tok["italic"], size)
|
||||
w = draw.textlength(tok["text"], font=font)
|
||||
if tok["bg"]:
|
||||
bg_rgb = hex_to_rgb(tok["bg"])
|
||||
if bg_rgb:
|
||||
draw.rectangle([x, y, x + w, y + line_h], fill=bg_rgb)
|
||||
fill = hex_to_rgb(tok["color"]) if tok["color"] else None
|
||||
draw_text(img, (round(x), y), tok["text"], font, fill or DEFAULT_FG)
|
||||
if tok["underline"]:
|
||||
underline_y = y + font.size + 1
|
||||
draw.rectangle([x, underline_y, x + w, underline_y + underline_h], fill=fill or DEFAULT_FG)
|
||||
x += w
|
||||
if gi < len(line) - 1:
|
||||
x += space_width
|
||||
|
||||
|
||||
def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.Image:
|
||||
bg = hex_to_rgb(cfg.background_color) or DEFAULT_BG
|
||||
img = Image.new("RGB", (target_w, target_h), bg)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
max_width = max(10, target_w - 2 * MARGIN)
|
||||
max_height = max(10, target_h - 2 * MARGIN)
|
||||
size, lines = _fit(draw, cfg.content or [], cfg.font_size, max_width, max_height)
|
||||
line_h = round(size * LINE_HEIGHT_FACTOR)
|
||||
space_width = draw.textlength(" ", font=_font(False, False, size))
|
||||
|
||||
total_h = len(lines) * line_h
|
||||
y = MARGIN + max(0, (max_height - total_h) // 2)
|
||||
align = cfg.align if cfg.align in ("left", "center", "right") else "left"
|
||||
for line in lines:
|
||||
if y + line_h > target_h:
|
||||
break # ran out of room even at the smallest size -- clip remaining lines rather than overflow
|
||||
_draw_line(img, draw, line, y, size, line_h, max_width, align, space_width)
|
||||
y += line_h
|
||||
return img
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
|
||||
identical note; every widget type's render() shares one call
|
||||
signature regardless of which ones actually care."""
|
||||
cfg = db.get(TextWidgetConfig, widget.id)
|
||||
if cfg is None or not has_text(cfg.content):
|
||||
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
|
||||
return _render_text(cfg, target_w, target_h)
|
||||
|
||||
|
||||
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes:
|
||||
"""A normal browser-viewable PNG at full logical panel size --
|
||||
mirrors calendar_render.render_tasks_preview_png's relationship to
|
||||
render_tasks (the dialog's own preview endpoint always renders at
|
||||
the frame's full size, not the widget's actual grid box, same
|
||||
convention every other widget type's preview endpoint follows)."""
|
||||
import io
|
||||
|
||||
target_w, target_h = logical_render_size(orientation)
|
||||
img = _render_text(cfg, target_w, target_h)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Whiteboard widget: fetches/renders a Nextcloud Whiteboard (or any
|
||||
WebDAV .whiteboard file) into the widget's own region -- the
|
||||
widget-system analogue of routers/device.py's old
|
||||
_render_whiteboard_mode/_advance_whiteboard_mode/_back_whiteboard_mode.
|
||||
|
||||
No real "next"/"back" concept for a static board (same as before the
|
||||
widget system) -- both buttons map to the same "check now" action, a
|
||||
forced re-fetch/re-render bypassing the normal throttle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..image_pipeline import compose_into
|
||||
from ..models import Frame, Widget
|
||||
from ..routers.common import get_or_refresh_whiteboard_for_widget
|
||||
from ._shared import placeholder_image
|
||||
|
||||
ACTION_LABELS = {"check_now": "Check for updates"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake is unused here -- see app/widgets/photos.py's
|
||||
identical note; every widget type's render() shares one call
|
||||
signature regardless of which ones actually care."""
|
||||
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget)
|
||||
if png_bytes is None:
|
||||
return placeholder_image(target_w, target_h, ["Whiteboard widget", "not configured yet"])
|
||||
|
||||
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
# letterbox, never cropped: unlike a photo, losing part of a
|
||||
# whiteboard to a crop loses actual content, not just some background
|
||||
# (see the old _render_whiteboard_mode's identical reasoning).
|
||||
return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox")
|
||||
|
||||
|
||||
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
get_or_refresh_whiteboard_for_widget(db, frame, widget, force=True)
|
||||
|
||||
|
||||
ACTIONS = {"check_now": _check_now}
|
||||
@@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
@@ -0,0 +1,44 @@
|
||||
# whiteboard-render
|
||||
|
||||
Local sidecar for whiteboard frame mode -- see `server.js`'s own header
|
||||
comment for the full "why Node, why not a headless browser" reasoning.
|
||||
Not an independently deployed service: it runs as a second process
|
||||
inside the main server's container (`../Dockerfile` installs Node,
|
||||
`../start.sh` launches this in the background before `exec`-ing
|
||||
uvicorn), reachable only at `127.0.0.1:3001` from the Python process in
|
||||
that same container.
|
||||
|
||||
**Not runtime-tested against a real `npm install` during development**
|
||||
-- this environment had no Node.js/npm available, only the npm registry
|
||||
API (used to verify the dependency versions/licenses in `package.json`
|
||||
actually exist and resolve). The code is written carefully against each
|
||||
library's documented API (`@excalidraw/utils`'s `exportToSvg`,
|
||||
`@resvg/resvg-js`'s `Resvg` class), but `docker compose build`+actually
|
||||
running it is the first time this has executed end to end -- and the
|
||||
first real run did in fact crash: `@excalidraw/utils`'s bundle touches
|
||||
bare browser globals (`devicePixelRatio`, `location`, `matchMedia`, ...)
|
||||
the same way inline `<script>` code in a real page would, not as
|
||||
`window.foo` -- jsdom only puts those *on* `dom.window`, so only
|
||||
copying `window`/`document`/`navigator` onto Node's `global` (the
|
||||
original version of this file) left everything else undefined. Fixed
|
||||
by copying jsdom's entire `window` onto `global` plus a `matchMedia`
|
||||
stub (jsdom doesn't implement it at all). If another crash like this
|
||||
shows up, it's almost certainly the same shape -- another bare global
|
||||
the bundle expects that this file hasn't stubbed yet. One other gap
|
||||
worth knowing about going in: jsdom's `<canvas>` has no real 2D
|
||||
rendering context (no `node-canvas` installed), so if Excalidraw's text
|
||||
measurement path depends on `canvas.getContext('2d').measureText(...)`
|
||||
rather than pure SVG/font-metrics math, that could be a next thing to
|
||||
watch for -- not something confirmed broken, just not yet exercised.
|
||||
|
||||
## Local development (if you have Node 20.19+/22.13+ installed)
|
||||
|
||||
```sh
|
||||
cd render-service
|
||||
npm install
|
||||
npm start # listens on 127.0.0.1:3001
|
||||
curl -X POST http://127.0.0.1:3001/render \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"elements": [], "width": 800}' \
|
||||
-o /tmp/test.png # an empty scene -- just checks the service comes up and returns a valid PNG
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "espresso-frame-whiteboard-render",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Local sidecar: renders Excalidraw scene JSON (the format Nextcloud Whiteboard's .whiteboard files use) to PNG for whiteboard frame mode. Runs as a second process inside the main server's container (see ../Dockerfile and ../start.sh), talked to over 127.0.0.1 only -- not an independent deployment, not reachable from outside the container.",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@excalidraw/utils": "0.1.3-test32",
|
||||
"@resvg/resvg-js": "2.6.2",
|
||||
"express": "^5.2.1",
|
||||
"jsdom": "^29.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Local render sidecar for whiteboard frame mode: turns Excalidraw scene
|
||||
// JSON (the format Nextcloud Whiteboard's .whiteboard files use --
|
||||
// {"elements", "appState", "files"}, see app/webdav_client.py) into a
|
||||
// PNG, using the real Excalidraw export code rather than a hand-rolled
|
||||
// reimplementation of its element types/styling/fonts. That's the whole
|
||||
// reason this exists as Node rather than more Python: @excalidraw/utils
|
||||
// IS the renderer real whiteboards are drawn with, so this reproduces
|
||||
// whatever a user actually sees in their whiteboard exactly, and never
|
||||
// drifts out of sync with new element types as Excalidraw adds them.
|
||||
//
|
||||
// Runs as a second process inside the main Python server's own
|
||||
// container (see ../Dockerfile installing Node, and ../start.sh
|
||||
// launching this in the background before exec'ing uvicorn) -- not a
|
||||
// separate deployment, no independent scaling/restart needs, so one
|
||||
// container is simpler than a second docker-compose service. Bound to
|
||||
// 127.0.0.1 only: reachable from the Python process in the same
|
||||
// container, never from outside it, so there's no auth on top of that
|
||||
// -- the network boundary IS the access control here.
|
||||
//
|
||||
// No headless browser (Puppeteer/Playwright) -- jsdom provides just
|
||||
// enough of a browser-like global environment for @excalidraw/utils'
|
||||
// internal DOM calls (e.g. text measurement) to work, and
|
||||
// @resvg/resvg-js (a native Rust SVG rasterizer, no browser process)
|
||||
// turns the resulting SVG into the actual PNG.
|
||||
|
||||
const { JSDOM } = require('jsdom');
|
||||
|
||||
// @excalidraw/utils is a browser bundle: it references things like
|
||||
// `devicePixelRatio` and `location` as bare identifiers, the same way
|
||||
// inline <script> code in a real page would resolve them off the global
|
||||
// scope -- not as `window.devicePixelRatio`. Copying jsdom's entire
|
||||
// `window` onto Node's `global` (not just window/document/navigator) is
|
||||
// what makes those bare references resolve at all; without it, the first
|
||||
// one touched throws a ReferenceError. `pretendToBeVisual` is what makes
|
||||
// jsdom actually populate devicePixelRatio/requestAnimationFrame in the
|
||||
// first place -- both are left undefined otherwise.
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { pretendToBeVisual: true });
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (key in global) continue;
|
||||
try {
|
||||
global[key] = dom.window[key];
|
||||
} catch {
|
||||
// a handful of window properties throw on read outside a real
|
||||
// browser (e.g. some storage/permissions getters) -- skip those
|
||||
// rather than let one bad property crash startup entirely
|
||||
}
|
||||
}
|
||||
global.window = dom.window;
|
||||
global.document = dom.window.document;
|
||||
global.navigator = dom.window.navigator;
|
||||
|
||||
// jsdom doesn't implement matchMedia -- Excalidraw's bundle calls it
|
||||
// unconditionally (theme/print-media detection), so without a stub this
|
||||
// is the next ReferenceError-shaped crash after the one above.
|
||||
if (typeof global.window.matchMedia !== 'function') {
|
||||
const stubMatchMedia = () => ({
|
||||
matches: false,
|
||||
media: '',
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
global.window.matchMedia = stubMatchMedia;
|
||||
global.matchMedia = stubMatchMedia;
|
||||
}
|
||||
|
||||
const express = require('express');
|
||||
const { exportToSvg } = require('@excalidraw/utils');
|
||||
const { Resvg } = require('@resvg/resvg-js');
|
||||
|
||||
const PORT = process.env.RENDER_SERVICE_PORT || 3001;
|
||||
const HOST = '127.0.0.1';
|
||||
// A whiteboard scene is normally tiny (KB, not MB) -- this is a sanity
|
||||
// cap against something going wrong upstream, not a real expected size.
|
||||
const MAX_BODY_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: MAX_BODY_BYTES }));
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.post('/render', async (req, res) => {
|
||||
const { elements, appState, files, width, height } = req.body || {};
|
||||
if (!Array.isArray(elements)) {
|
||||
res.status(400).json({ error: 'elements must be an array (a parsed .whiteboard/Excalidraw scene)' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const svg = await exportToSvg({
|
||||
elements,
|
||||
appState: appState || {},
|
||||
files: files || {},
|
||||
exportPadding: 20,
|
||||
// Font embedding (base64 @font-face rules in the SVG's <defs>) goes
|
||||
// through the browser's FontFace API, which jsdom doesn't implement
|
||||
// and can't be meaningfully polyfilled here -- and we don't need
|
||||
// it anyway: resvg (below) already falls back to whatever fonts
|
||||
// fontconfig finds (see ../Dockerfile's fonts-dejavu-core), so
|
||||
// embedded fonts were never going to make it into the final PNG.
|
||||
skipInliningFonts: true,
|
||||
});
|
||||
// Depending on the installed version, exportToSvg resolves to either
|
||||
// an SVGSVGElement (needs serializing) or already a string -- handle
|
||||
// both rather than assume, since this isn't runtime-tested against a
|
||||
// live install in this environment (no Node available to verify
|
||||
// during development, see the server README's whiteboard mode notes).
|
||||
const svgString = typeof svg === 'string' ? svg : svg.outerHTML;
|
||||
|
||||
const targetWidth = Number(width) || undefined;
|
||||
const resvg = new Resvg(svgString, {
|
||||
fitTo: targetWidth ? { mode: 'width', value: targetWidth } : { mode: 'original' },
|
||||
background: 'rgba(255, 255, 255, 1)',
|
||||
font: {
|
||||
// No bundled Excalidraw font assets (Virgil/Cascadia) in v1 --
|
||||
// text renders in whatever fonts fontconfig finds in the image
|
||||
// (see ../Dockerfile's fonts-dejavu-core), not pixel-identical
|
||||
// to the browser editor's handwriting-style font. Good enough
|
||||
// for "what does the board say", not a design-fidelity tool.
|
||||
loadSystemFonts: true,
|
||||
},
|
||||
});
|
||||
const pngBuffer = resvg.render().asPng();
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.send(pngBuffer);
|
||||
} catch (err) {
|
||||
console.error('Whiteboard render failed:', err);
|
||||
res.status(500).json({ error: String((err && err.message) || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`whiteboard-render listening on ${HOST}:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==9.1.1
|
||||
@@ -9,3 +9,5 @@ sqlalchemy==2.0.51
|
||||
qrcode==8.2
|
||||
icalendar==7.2.2
|
||||
recurring-ical-events==3.8.2
|
||||
caldav==3.2.1
|
||||
pypdfium2==5.12.1
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Starts render-service/ (whiteboard frame mode's Excalidraw-to-PNG
|
||||
# sidecar, see its own README) in the background, bound to 127.0.0.1 --
|
||||
# reachable from this container's Python process, never from outside it.
|
||||
# Then execs uvicorn as the foreground/PID 1 process so it receives
|
||||
# Docker's stop signal directly.
|
||||
#
|
||||
# Wrapped in a restart loop, not a bare `node ... &`: a bare background
|
||||
# process that crashes stays dead for good, with nothing to bring it
|
||||
# back -- turning any single render crash (a not-yet-found jsdom/
|
||||
# Excalidraw edge case, say) into a silent, permanent whiteboard outage
|
||||
# that looks exactly like "stuck showing stale content forever" from the
|
||||
# outside, since routers/common.py's get_or_refresh_whiteboard falls
|
||||
# back to the last good cached image on every failed refresh rather than
|
||||
# going blank. The 2s sleep just avoids a hot-crash-loop pegging a core
|
||||
# if something's wrong at every single startup.
|
||||
(
|
||||
while true; do
|
||||
node ./render-service/server.js
|
||||
echo "whiteboard render sidecar exited (code $?) -- restarting in 2s" >&2
|
||||
sleep 2
|
||||
done
|
||||
) &
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8420
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Shared pytest fixtures for the server test suite.
|
||||
|
||||
DATABASE_URL must be set before app.db (and anything importing it,
|
||||
transitively including app.main) is first imported -- app.db builds its
|
||||
engine/SessionLocal at module import time, not lazily -- so this file
|
||||
sets it as the very first thing it does, ahead of any `from app...`
|
||||
import below. Importing app.main also runs migration.run_migrations()
|
||||
as a side effect of that import (see main.py), which is what actually
|
||||
creates the schema in the fresh temp database this points at.
|
||||
|
||||
Each test shares one migrated schema (re-migrating per test would be
|
||||
needless I/O), but gets a clean slate of *data*: every table is wiped
|
||||
after each test rather than relying on SQLAlchemy's transaction-rollback
|
||||
test-isolation pattern (Session bound to a connection-level transaction
|
||||
via join_transaction_mode="create_savepoint") -- that pattern needs the
|
||||
"pysqlite serializable" event-listener workaround (see SQLAlchemy's own
|
||||
docs on pysqlite's implicit-transaction quirks) that app/db.py's engine
|
||||
doesn't set up, and this suite has no reason to add production-affecting
|
||||
engine config just to make tests work. Deleting tables in reverse
|
||||
dependency order (children before parents) satisfies foreign keys
|
||||
without needing that workaround at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_")
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{Path(_tmp_dir) / 'test.db'}"
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import db as db_module
|
||||
from app import migration
|
||||
from app.auth import hash_password
|
||||
from app.main import app
|
||||
from app.models import Base, Frame, User, UserFrame
|
||||
|
||||
|
||||
def _reset_db() -> None:
|
||||
"""Wipes every ORM-mapped table, then reseeds the same baseline a
|
||||
real fresh install gets (frame #1 + the server-settings singleton --
|
||||
see migration.py's _ensure_frame_one/_ensure_server_settings, both
|
||||
idempotent and both already called by run_migrations). The raw
|
||||
schema_version table isn't part of Base.metadata (see migration.py),
|
||||
so it survives the wipe untouched and run_migrations() skips
|
||||
straight to that reseed step instead of re-running every ALTER."""
|
||||
with db_module.engine.begin() as conn:
|
||||
for table in reversed(Base.metadata.sorted_tables):
|
||||
conn.execute(table.delete())
|
||||
migration.run_migrations()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
session = db_module.SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
_reset_db()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db_session):
|
||||
"""A TestClient whose every request shares this test's own db_session
|
||||
-- so anything the test asserts against db_session sees exactly what
|
||||
the app just did. Table data from this test is wiped once db_session
|
||||
tears down (see _wipe_all_tables above)."""
|
||||
|
||||
def _override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[db_module.get_db] = _override_get_db
|
||||
# follow_redirects=False: nearly every POST route in this app answers
|
||||
# success with a 303 (POST/redirect/GET) -- tests assert against that
|
||||
# 303 directly, matching what a real browser's network tab would show
|
||||
# before it follows the redirect itself.
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def make_user(db: Session, username: str, password: str = "testpass123", **kwargs) -> User:
|
||||
"""A user row directly via the ORM -- bypasses the HTTP signup/claim
|
||||
flow for tests that only care about what happens once a user already
|
||||
exists (permission boundaries, source ownership, etc.)."""
|
||||
import time as _time
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=kwargs.pop("display_name", username.capitalize()),
|
||||
password_hash=hash_password(password),
|
||||
created_at=_time.time(),
|
||||
**kwargs,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
return user
|
||||
|
||||
|
||||
def link_user(db: Session, user: User, frame: Frame) -> None:
|
||||
db.add(UserFrame(user_id=user.id, frame_id=frame.id))
|
||||
db.flush()
|
||||
|
||||
|
||||
def login(client: TestClient, username: str, password: str = "testpass123") -> None:
|
||||
resp = client.post("/login", data={"username": username, "password": password})
|
||||
assert resp.status_code == 303, resp.text
|
||||
|
||||
|
||||
def get_csrf_token(client: TestClient, page_url: str) -> str:
|
||||
"""Scrapes the csrf_token hidden field out of a rendered page -- the
|
||||
same value a real browser's form submit would carry, see auth.py's
|
||||
_csrf_ok."""
|
||||
import re
|
||||
|
||||
resp = client.get(page_url)
|
||||
assert resp.status_code == 200, resp.text
|
||||
m = re.search(r'name="csrf_token" value="([^"]+)"', resp.text)
|
||||
assert m, f"no csrf_token found on {page_url}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def csrf_headers(client: TestClient, page_url: str = "/settings") -> dict:
|
||||
"""X-CSRF-Token header for JSON API POSTs (require_user_api's
|
||||
_csrf_ok checks this header, not a form field -- see common.js'
|
||||
fetchJson, which reads the same <meta name="csrf-token"> tag this
|
||||
scrapes)."""
|
||||
import re
|
||||
|
||||
resp = client.get(page_url)
|
||||
assert resp.status_code == 200, resp.text
|
||||
m = re.search(r'name="csrf-token" content="([^"]+)"', resp.text)
|
||||
assert m, f"no csrf-token meta tag found on {page_url}"
|
||||
return {"X-CSRF-Token": m.group(1)}
|
||||
@@ -0,0 +1,106 @@
|
||||
"""First-run setup, login, and the basic frame-visibility permission
|
||||
gate (require_frame_view/can_view_frame) -- the things every other
|
||||
endpoint's own permission test implicitly depends on already working."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models import Frame
|
||||
|
||||
from .conftest import get_csrf_token, link_user, login, make_user
|
||||
|
||||
|
||||
def test_setup_creates_admin_and_claims_migrated_frame(client, db_session):
|
||||
resp = client.post("/setup", data={
|
||||
"username": "alice", "password": "hunter22", "display_name": "Alice",
|
||||
})
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/"
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
assert frame is not None
|
||||
assert frame.owner_user_id is not None
|
||||
assert frame.controlled_by_user_id is not None
|
||||
|
||||
|
||||
def test_setup_only_works_once(client):
|
||||
resp = client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
assert resp.status_code == 303
|
||||
|
||||
resp = client.post("/setup", data={"username": "mallory", "password": "hunter22"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_login_requires_correct_password(client):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
client.cookies.clear()
|
||||
|
||||
resp = client.post("/login", data={"username": "alice", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.post("/login", data={"username": "alice", "password": "hunter22"})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def test_root_redirects_to_setup_before_any_user_exists(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/setup"
|
||||
|
||||
|
||||
def test_unauthenticated_request_redirects_to_login_once_a_user_exists(client):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
client.cookies.clear()
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_user_not_linked_to_a_frame_cannot_view_it(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
bob = make_user(db_session, "bob")
|
||||
db_session.flush()
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
|
||||
resp = client.get("/frames/1")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_linked_user_can_view_but_not_configure_by_default(client, db_session):
|
||||
"""Being linked grants view access; whether they can also *control*
|
||||
(change settings/take the wheel) is a separate, narrower gate --
|
||||
require_frame_control, exercised via the permission-boundary tests
|
||||
for individual features rather than here."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
bob = make_user(db_session, "bob")
|
||||
frame = db_session.get(Frame, 1)
|
||||
link_user(db_session, bob, frame)
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
|
||||
resp = client.get("/frames/1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_csrf_token_required_for_settings_save(client):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.post("/settings", data={
|
||||
"display_name": "Alice", "email": "[email protected]", "csrf_token": "bogus",
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_settings_save_round_trips_with_real_csrf_token(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
csrf = get_csrf_token(client, "/settings")
|
||||
resp = client.post("/settings", data={
|
||||
"display_name": "Alice Smith", "email": "[email protected]", "csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code in (200, 303), resp.text
|
||||
|
||||
from app.models import User
|
||||
user = db_session.query(User).filter_by(username="alice").one()
|
||||
assert user.display_name == "Alice Smith"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""_reject_outlier_drops -- the outlier-rejection pass in the battery
|
||||
remaining-time estimate (see routers/common.py's battery_estimate_s).
|
||||
Pure function, no DB/HTTP -- (recency_weight, drop_pct) pairs in,
|
||||
filtered pairs out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.routers.common import _reject_outlier_drops
|
||||
|
||||
|
||||
def _steps(drops: list[float]) -> list[tuple[int, float]]:
|
||||
return [(i + 1, d) for i, d in enumerate(drops)]
|
||||
|
||||
|
||||
def _avg(steps: list[tuple[int, float]]) -> float:
|
||||
total_weight = sum(w for w, _ in steps)
|
||||
return sum(w * d for w, d in steps) / total_weight
|
||||
|
||||
|
||||
def test_no_outlier_keeps_every_step():
|
||||
steps = _steps([1, 1, 2, 1, 1, 2, 1])
|
||||
assert _reject_outlier_drops(steps) == steps
|
||||
|
||||
|
||||
def test_single_glitch_dip_is_rejected():
|
||||
"""The exact shape reported in production: 18 ordinary 1%-per-wake
|
||||
steps and one spliced-in 26% glitch -- the naive median-based MAD
|
||||
degenerates to 0 here (more than half the steps tie at the median),
|
||||
which used to let the glitch sail straight through untouched."""
|
||||
normal = [1] * 18
|
||||
glitchy = normal[:9] + [26] + normal[9:]
|
||||
|
||||
kept = _reject_outlier_drops(_steps(glitchy))
|
||||
kept_drops = [d for _, d in kept]
|
||||
assert 26 not in kept_drops
|
||||
assert len(kept) == 18
|
||||
|
||||
# the whole point: the estimate should come out the same as if the
|
||||
# glitch had never been recorded at all
|
||||
baseline_avg = _avg(_steps(normal))
|
||||
filtered_avg = _avg(kept)
|
||||
assert abs(filtered_avg - baseline_avg) < 1e-9
|
||||
|
||||
|
||||
def test_single_glitch_spike_is_rejected():
|
||||
normal = [2] * 18
|
||||
glitchy = normal[:5] + [40] + normal[5:]
|
||||
|
||||
kept = _reject_outlier_drops(_steps(glitchy))
|
||||
kept_drops = [d for _, d in kept]
|
||||
assert 40 not in kept_drops
|
||||
assert len(kept) == 18
|
||||
|
||||
|
||||
def test_identical_steps_reject_nothing():
|
||||
"""Every step tied at the exact same value -- both the median MAD
|
||||
and the mean-absolute-deviation fallback are 0 here, which is the
|
||||
one case _reject_outlier_drops explicitly bails out of rather than
|
||||
filtering down to nothing."""
|
||||
steps = _steps([1] * 10)
|
||||
assert _reject_outlier_drops(steps) == steps
|
||||
|
||||
|
||||
def test_never_filters_down_to_nothing():
|
||||
"""Even a genuinely bimodal series (half the wakes cheap, half
|
||||
expensive -- not a single-glitch shape at all) shouldn't empty the
|
||||
list; a battery_estimate_s caller treats an empty result as
|
||||
"insufficient data," which a merely-noisy history isn't."""
|
||||
steps = _steps([1, 1, 1, 1, 10, 10, 10, 10])
|
||||
kept = _reject_outlier_drops(steps)
|
||||
assert len(kept) > 0
|
||||
@@ -0,0 +1,178 @@
|
||||
"""GET/PUT /api/frames/{id}/buttons -- the button-assignment UI's API
|
||||
(see routers/api_frames.py's api_buttons_get/api_buttons_save and
|
||||
static/frame_config.js). Covers the CRUD/validation layer; multi-action
|
||||
execution order and partial-failure-continues on an actual button press
|
||||
are already exercised end-to-end in test_device_widget_dispatch.py and
|
||||
routers/device.py's _run_button_actions -- this file doesn't re-test
|
||||
device.py's dispatch, just that the assignment API stores/serves/
|
||||
validates what the UI edits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.models import Frame, FrameButtonAction, Widget, WhiteboardWidgetConfig
|
||||
|
||||
from .conftest import csrf_headers, link_user, login, make_user
|
||||
|
||||
|
||||
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
|
||||
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_get_buttons_reflects_the_default_migration_mapping(client, db_session):
|
||||
"""Frame #1's auto-migrated photos widget should already have NEXT ->
|
||||
advance, BACK -> back from _default_button_actions (see
|
||||
migration.py) -- the UI just needs to be able to see that default."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert {w["id"]: w["widget_type"] for w in data["widgets"]} == {photo_widget.id: "photos"}
|
||||
photo_actions = {a["action"] for w in data["widgets"] for a in w["actions"]}
|
||||
assert photo_actions == {"advance", "back"}
|
||||
|
||||
assert data["next"] == [{"id": data["next"][0]["id"], "widget_id": photo_widget.id, "action": "advance"}]
|
||||
assert data["back"] == [{"id": data["back"][0]["id"], "widget_id": photo_widget.id, "action": "back"}]
|
||||
|
||||
|
||||
def test_get_buttons_includes_placement_and_grid_dims(client, db_session):
|
||||
"""Two widgets of the same type otherwise look identical in the
|
||||
assignment UI ("Photos" / "Photos") -- the client tells them apart
|
||||
using x/y/w/h against the frame's grid dims (see
|
||||
static/frame_config.js's buildWidgetNames), so the API needs to
|
||||
actually hand those over."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h = 0, 0, 4, 5
|
||||
db_session.commit()
|
||||
second = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(second)
|
||||
db_session.flush()
|
||||
from app.models import PhotoWidgetConfig
|
||||
db_session.add(PhotoWidgetConfig(widget_id=second.id))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["grid"] == {"cols": 8, "rows": 5}
|
||||
by_id = {w["id"]: w for w in data["widgets"]}
|
||||
assert by_id[photo_widget.id]["x"] == 0 and by_id[photo_widget.id]["w"] == 4
|
||||
assert by_id[second.id]["x"] == 4 and by_id[second.id]["w"] == 4
|
||||
|
||||
|
||||
def test_put_replaces_the_whole_list_in_order(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
board_widget = _add_whiteboard_widget(db_session, frame)
|
||||
|
||||
resp = client.put("/api/frames/1/buttons/next", json={
|
||||
"actions": [
|
||||
{"widget_id": board_widget.id, "action": "check_now"},
|
||||
{"widget_id": photo_widget.id, "action": "advance"},
|
||||
],
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
rows = db_session.query(FrameButtonAction).filter_by(
|
||||
frame_id=frame.id, button="next"
|
||||
).order_by(FrameButtonAction.sort_order).all()
|
||||
assert [(r.widget_id, r.action) for r in rows] == [
|
||||
(board_widget.id, "check_now"), (photo_widget.id, "advance"),
|
||||
]
|
||||
|
||||
# BACK's own default mapping (photos -> back) is untouched by a PUT to next.
|
||||
back_rows = db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="back").all()
|
||||
assert len(back_rows) == 1
|
||||
assert back_rows[0].action == "back"
|
||||
|
||||
|
||||
def test_put_empty_list_clears_the_button(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
|
||||
resp = client.put("/api/frames/1/buttons/next", json={"actions": []}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="next").count() == 0
|
||||
|
||||
|
||||
def test_put_rejects_unknown_button_name(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.put("/api/frames/1/buttons/sideways", json={"actions": []}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_put_rejects_widget_from_another_frame(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
other = Frame(name="Other", device_token="tok-other", manage_token="mtok-other", created_at=time.time())
|
||||
db_session.add(other)
|
||||
db_session.flush()
|
||||
other_widget = Widget(frame_id=other.id, widget_type="photos", x=0, y=0, w=8, h=5,
|
||||
sort_order=0, created_at=time.time())
|
||||
db_session.add(other_widget)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.put("/api/frames/1/buttons/next", json={
|
||||
"actions": [{"widget_id": other_widget.id, "action": "advance"}],
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
# Nothing partially applied -- the whole request is validated before any write.
|
||||
assert db_session.query(FrameButtonAction).filter_by(frame_id=1, button="next").count() == 1
|
||||
|
||||
|
||||
def test_put_rejects_action_the_widget_type_does_not_support(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
|
||||
resp = client.put("/api/frames/1/buttons/next", json={
|
||||
"actions": [{"widget_id": photo_widget.id, "action": "check_now"}],
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_deleting_a_widget_cascades_its_button_bindings(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
|
||||
resp = client.delete(f"/api/frames/1/widgets/{photo_widget.id}", headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id).count() == 0
|
||||
|
||||
|
||||
def test_linked_user_can_view_and_control_can_save(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
bob = make_user(db_session, "bob")
|
||||
frame = db_session.get(Frame, 1)
|
||||
link_user(db_session, bob, frame)
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_unrelated_user_cannot_view(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
make_user(db_session, "mallory")
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "mallory")
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,95 @@
|
||||
"""caldav_client.merge_tasks -- pure-function coverage (sort, per-source
|
||||
color/owner tagging, partial-failure handling), monkeypatching
|
||||
fetch_tasks itself rather than the caldav package's DAVClient/Calendar
|
||||
-- this project has no CalDAV test server fixture (fetch_calendar_events'
|
||||
own caldav branch is likewise only exercised this way, never against a
|
||||
real server -- see test_calendar_feed.py, which only covers the ICS
|
||||
path). fetch_tasks' own VTODO-parsing internals are trusted to the
|
||||
icalendar library, same posture calendar_feed.py already takes for
|
||||
VEVENT parsing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.caldav_client import CalDavError, TaskSource, merge_tasks
|
||||
|
||||
|
||||
def test_single_source_tasks_pass_through_tagged(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.caldav_client.fetch_tasks",
|
||||
lambda url, username, password, completed_since=None: [
|
||||
{"summary": "Buy milk", "due": "2026-08-01", "completed_at": None},
|
||||
],
|
||||
)
|
||||
sources = [TaskSource("Alice", "https://example.com/tasks", "alice", "pw", color_index=3)]
|
||||
tasks, summary = merge_tasks(sources)
|
||||
assert summary == ""
|
||||
assert tasks == [
|
||||
{"summary": "Buy milk", "due": "2026-08-01", "completed_at": None,
|
||||
"owner_display_name": "Alice", "color_index": 3},
|
||||
]
|
||||
|
||||
|
||||
def test_outstanding_tasks_sort_by_due_date_none_last(monkeypatch):
|
||||
def fake_fetch(url, username, password, completed_since=None):
|
||||
by_url = {
|
||||
"a": [{"summary": "No due date", "due": None, "completed_at": None}],
|
||||
"b": [{"summary": "Due soonest", "due": "2026-08-01", "completed_at": None}],
|
||||
}
|
||||
return by_url[url]
|
||||
|
||||
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
|
||||
sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")]
|
||||
tasks, _ = merge_tasks(sources)
|
||||
assert [t["summary"] for t in tasks] == ["Due soonest", "No due date"]
|
||||
|
||||
|
||||
def test_completed_tasks_sort_after_outstanding_most_recent_first(monkeypatch):
|
||||
def fake_fetch(url, username, password, completed_since=None):
|
||||
return [
|
||||
{"summary": "Outstanding", "due": "2026-08-05", "completed_at": None},
|
||||
{"summary": "Done yesterday", "due": None, "completed_at": "2026-08-01T09:00:00+00:00"},
|
||||
{"summary": "Done today", "due": None, "completed_at": "2026-08-02T09:00:00+00:00"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
|
||||
tasks, _ = merge_tasks([TaskSource("Alice", "a", "alice", "pw")])
|
||||
assert [t["summary"] for t in tasks] == ["Outstanding", "Done today", "Done yesterday"]
|
||||
|
||||
|
||||
def test_one_broken_source_does_not_blank_others(monkeypatch):
|
||||
def fake_fetch(url, username, password, completed_since=None):
|
||||
if url == "broken":
|
||||
raise CalDavError("nope")
|
||||
return [{"summary": "Buy milk", "due": None, "completed_at": None}]
|
||||
|
||||
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
|
||||
sources = [TaskSource("Alice", "ok", "alice", "pw"), TaskSource("Bob", "broken", "bob", "pw")]
|
||||
tasks, summary = merge_tasks(sources)
|
||||
assert len(tasks) == 1
|
||||
assert summary == "1 of 2 task lists unavailable"
|
||||
|
||||
|
||||
def test_all_sources_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.caldav_client.fetch_tasks",
|
||||
lambda url, username, password, completed_since=None: (_ for _ in ()).throw(CalDavError("nope")),
|
||||
)
|
||||
sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")]
|
||||
tasks, summary = merge_tasks(sources)
|
||||
assert tasks == []
|
||||
assert summary == "2 of 2 task lists unavailable"
|
||||
|
||||
|
||||
def test_completed_since_is_forwarded_to_fetch_tasks(monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_fetch(url, username, password, completed_since=None):
|
||||
seen.append(completed_since)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
|
||||
cutoff = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
merge_tasks([TaskSource("Alice", "a", "alice", "pw")], completed_since=cutoff)
|
||||
assert seen == [cutoff]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""calendar_feed.py's fetch/parse/merge against a local HTTP server
|
||||
serving fixture .ics text -- pure functions, no ORM/FastAPI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import date
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calendar_feed import CalendarSource, merge_events
|
||||
|
||||
_PLAIN_ICS = b"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:plain-1@example.com
|
||||
SUMMARY:Dentist
|
||||
DTSTART:20260801T140000Z
|
||||
DTEND:20260801T150000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
_RECURRING_ICS = b"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:weekly-1@example.com
|
||||
SUMMARY:Standup
|
||||
DTSTART:20260803T090000Z
|
||||
DTEND:20260803T091500Z
|
||||
RRULE:FREQ=WEEKLY;COUNT=4
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
_SHARED_EVENT_ICS_A = b"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:shared-a@example.com
|
||||
SUMMARY:Family Dinner
|
||||
DTSTART:20260805T230000Z
|
||||
DTEND:20260806T010000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
_SHARED_EVENT_ICS_B = b"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:shared-b@example.com
|
||||
SUMMARY:Family Dinner
|
||||
DTSTART:20260805T230000Z
|
||||
DTEND:20260806T010000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = self.server.feeds.get(self.path) # type: ignore[attr-defined]
|
||||
if body is None:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/calendar")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ics_server():
|
||||
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
server.feeds = {
|
||||
"/plain.ics": _PLAIN_ICS,
|
||||
"/recurring.ics": _RECURRING_ICS,
|
||||
"/shared-a.ics": _SHARED_EVENT_ICS_A,
|
||||
"/shared-b.ics": _SHARED_EVENT_ICS_B,
|
||||
}
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
port = server.server_address[1]
|
||||
try:
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
_WINDOW_START = date(2026, 7, 1)
|
||||
_WINDOW_END = date(2026, 9, 1)
|
||||
|
||||
|
||||
def test_single_source_fetches_its_event(ics_server):
|
||||
sources = [CalendarSource("Alice", "ics", f"{ics_server}/plain.ics")]
|
||||
events, summary = merge_events(sources, _WINDOW_START, _WINDOW_END)
|
||||
assert summary == ""
|
||||
assert len(events) == 1
|
||||
assert events[0]["summary"] == "Dentist"
|
||||
assert events[0]["sources"] == [{"owner_display_name": "Alice", "color_index": None}]
|
||||
|
||||
|
||||
def test_recurring_event_expands_within_window(ics_server):
|
||||
sources = [CalendarSource("Bob", "ics", f"{ics_server}/recurring.ics")]
|
||||
events, summary = merge_events(sources, _WINDOW_START, _WINDOW_END)
|
||||
assert summary == ""
|
||||
assert len(events) == 4 # COUNT=4
|
||||
assert all(e["summary"] == "Standup" for e in events)
|
||||
# distinct occurrences, not the same one repeated
|
||||
assert len({e["start"] for e in events}) == 4
|
||||
|
||||
|
||||
def test_unreachable_source_does_not_blank_others(ics_server):
|
||||
sources = [
|
||||
CalendarSource("Alice", "ics", f"{ics_server}/plain.ics"),
|
||||
CalendarSource("Bob", "ics", f"{ics_server}/does-not-exist.ics"),
|
||||
]
|
||||
events, summary = merge_events(sources, _WINDOW_START, _WINDOW_END)
|
||||
assert len(events) == 1
|
||||
assert events[0]["summary"] == "Dentist"
|
||||
assert summary == "1 of 2 calendars unavailable"
|
||||
|
||||
|
||||
def test_all_sources_unreachable(ics_server):
|
||||
sources = [
|
||||
CalendarSource("Alice", "ics", f"{ics_server}/nope1.ics"),
|
||||
CalendarSource("Bob", "ics", f"{ics_server}/nope2.ics"),
|
||||
]
|
||||
events, summary = merge_events(sources, _WINDOW_START, _WINDOW_END)
|
||||
assert events == []
|
||||
assert summary == "2 of 2 calendars unavailable"
|
||||
|
||||
|
||||
def test_duplicate_event_across_calendars_collapses_with_both_sources(ics_server):
|
||||
"""A shared event synced onto two people's calendars (same summary/
|
||||
start/end/all_day) should show up once, but carry both owners in
|
||||
its `sources` list -- see merge_events' own docstring."""
|
||||
sources = [
|
||||
CalendarSource("Alice", "ics", f"{ics_server}/shared-a.ics"),
|
||||
CalendarSource("Bob", "ics", f"{ics_server}/shared-b.ics"),
|
||||
]
|
||||
events, summary = merge_events(sources, _WINDOW_START, _WINDOW_END)
|
||||
assert summary == ""
|
||||
assert len(events) == 1
|
||||
owners = {s["owner_display_name"] for s in events[0]["sources"]}
|
||||
assert owners == {"Alice", "Bob"}
|
||||
|
||||
|
||||
def test_events_outside_window_are_excluded(ics_server):
|
||||
sources = [CalendarSource("Alice", "ics", f"{ics_server}/plain.ics")]
|
||||
events, _ = merge_events(sources, date(2020, 1, 1), date(2020, 2, 1))
|
||||
assert events == []
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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
|
||||
|
||||
import time
|
||||
|
||||
from app.models import CalendarWidgetConfig, Frame, FrameCalendar, Widget
|
||||
|
||||
from .conftest import csrf_headers
|
||||
|
||||
|
||||
def _configure_calendar_widget(db_session) -> Widget:
|
||||
client_frame = db_session.get(Frame, 1)
|
||||
widget = Widget(frame_id=client_frame.id, widget_type="calendar", 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(CalendarWidgetConfig(widget_id=widget.id, view="agenda"))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
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/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
|
||||
|
||||
|
||||
def test_preview_calendar_requires_an_included_calendar(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
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"})
|
||||
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(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_widgets.get_or_refresh_calendar_events_for_widget",
|
||||
lambda db, frame, widget: ([], ""),
|
||||
)
|
||||
|
||||
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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user