Compare commits
62
Commits
v1.3.0
...
5247f5e512
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5247f5e512 | ||
|
|
8bcc574f99 | ||
|
|
c323402895 | ||
|
|
6fa3e2c2d2 | ||
|
|
af513c1b5a | ||
|
|
c7b164e6cd | ||
|
|
5dfa6c8197 | ||
|
|
e362519261 | ||
|
|
c1c657c0a9 | ||
|
|
edbd90745b | ||
|
|
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"
|
- ".gitea/workflows/server-docker-build.yml"
|
||||||
|
|
||||||
jobs:
|
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:
|
build-and-push:
|
||||||
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -32,3 +52,21 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
git.thumeit.com/tfaour/espresso-frame-server:latest
|
git.thumeit.com/tfaour/espresso-frame-server:latest
|
||||||
git.thumeit.com/tfaour/espresso-frame-server:${{ gitea.sha }}
|
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/.venv/
|
||||||
server/*.egg-info/
|
server/*.egg-info/
|
||||||
server/data/
|
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
|
# Real deploy config, copied from docker-compose.yml.example -- holds the
|
||||||
# Immich API key, must never be committed.
|
# Immich API key, must never be committed.
|
||||||
server/docker-compose.yml
|
server/docker-compose.yml
|
||||||
@@ -27,4 +34,8 @@ server/docker-compose.yml
|
|||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.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,103 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
CURRENT TODO
|
||||||
|
-add more actions for buttons (i.e. change widget/layout)
|
||||||
|
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
|
||||||
|
-make a weather widget
|
||||||
|
-widget border option
|
||||||
|
-battery life widget
|
||||||
|
-sharing layouts with linked users
|
||||||
|
-a "coming up this week" widget
|
||||||
|
-scan to download for non-immich photos too?
|
||||||
|
-switch button reset action? and on reset dismiss the menu.
|
||||||
|
|
||||||
|
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
|
||||||
|
a "coming up this week" widget). 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.
|
||||||
|
- **Commit and push once a task is verified working, without waiting to
|
||||||
|
be asked separately.** Once tests pass (and, for UI changes, the
|
||||||
|
browser check has been done), stage the relevant files, write a normal
|
||||||
|
commit message, and push to the current branch -- the maintainer's
|
||||||
|
standing authorization for the commit/push step itself. This doesn't
|
||||||
|
relax anything else: still run `git status`/review the diff before
|
||||||
|
staging, still never force-push/amend a pushed commit/skip hooks, and
|
||||||
|
still surface anything that looks like it needs a real decision (e.g.
|
||||||
|
a change that would trigger `main`'s deploy workflow, see below)
|
||||||
|
instead of pushing through it silently.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
a "coming up this week" widget. 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)
|
- 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
|
- [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
|
[`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
|
## Getting started
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -22,17 +22,17 @@ sequenceDiagram
|
|||||||
Frame->>Frame: Connect to home WiFi
|
Frame->>Frame: Connect to home WiFi
|
||||||
alt next-photo button pressed
|
alt next-photo button pressed
|
||||||
Frame->>Server: POST /frame/advance
|
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
|
else back-photo button pressed
|
||||||
Frame->>Server: POST /frame/back
|
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
|
else normal wake
|
||||||
Frame->>Server: GET /frame/image
|
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
|
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
|
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
|
Server-->>Frame: 192,000 raw bytes, streamed
|
||||||
Frame->>Frame: Write to panel SPI buffer, compute CRC32
|
Frame->>Frame: Write to panel SPI buffer, compute CRC32
|
||||||
alt CRC unchanged since last physical refresh
|
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)
|
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
|
## Firmware boot flow
|
||||||
|
|
||||||
1. **No stored config** (first boot, or NVS erased): bring up the display,
|
1. **No stored config** (first boot, or NVS erased): bring up the display,
|
||||||
|
|||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Saved layouts
|
||||||
|
|
||||||
|
A user can snapshot a frame's whole widget arrangement -- every widget's
|
||||||
|
type/placement/settings, calendar/task sources, and button-action
|
||||||
|
bindings -- under a name (`SavedLayout` + `SavedLayoutWidget` +
|
||||||
|
`SavedLayoutSource` + `SavedLayoutButtonAction`, `server/app/models.py`),
|
||||||
|
then switch back to it later, or apply it to a *different* frame. Saved
|
||||||
|
layouts are owned by the **user**, not any one frame -- the same set
|
||||||
|
shows up (with a per-frame `compatible` flag) on every frame that user
|
||||||
|
controls whose grid matches (`grid.grid_dims(orientation)`'s cols/rows,
|
||||||
|
landscape-class 8x5 vs. portrait-class 5x8), not just the frame it was
|
||||||
|
captured from.
|
||||||
|
|
||||||
|
Saving only captures an authored *setting*, never runtime/cache state --
|
||||||
|
a photo widget's current queue position, a calendar's fetch cache, a
|
||||||
|
whiteboard's rendered-image cache, etc. are deliberately left out (see
|
||||||
|
`routers/api_layouts.py`'s `LAYOUT_CONFIG_FIELDS` allowlist per
|
||||||
|
`widget_type`), so applying a layout feels like a fresh widget of that
|
||||||
|
type with its settings pre-filled, not a resurrection of stale state
|
||||||
|
from whenever it was saved. A static-image widget's uploaded bytes are
|
||||||
|
the one exception carried through verbatim (`SavedLayoutWidget.image`).
|
||||||
|
Saving again under a name the user already has overwrites that layout's
|
||||||
|
snapshot in place rather than erroring or creating a duplicate --
|
||||||
|
`SavedLayout`'s own docstring.
|
||||||
|
|
||||||
|
Applying a layout to a frame (`api_layout_apply`, `require_frame_control`)
|
||||||
|
deletes every widget currently on that frame and recreates the saved
|
||||||
|
arrangement from scratch, remapping calendar/task sources and button
|
||||||
|
bindings onto the newly-created widget ids -- same "act unconditionally
|
||||||
|
on the server, confirm on the client" posture as the Layout tab's own
|
||||||
|
"Clear all". A source whose owning user account no longer exists is
|
||||||
|
silently dropped rather than left dangling (config is JSON, not
|
||||||
|
FK-checked, so nothing else would catch that).
|
||||||
|
|
||||||
|
The web UI lives in the Layout tab's "Saved layouts" card
|
||||||
|
(`static/saved_layouts.js`, `GET`/`POST /api/frames/{id}/layouts`,
|
||||||
|
`PATCH`/`DELETE /api/layouts/{id}`, `POST
|
||||||
|
/api/frames/{id}/layouts/{id}/apply`) -- name + Save, then a list of
|
||||||
|
saved layouts each with Apply/rename/delete, incompatible ones shown
|
||||||
|
greyed-out with a "different orientation" badge rather than hidden.
|
||||||
|
|
||||||
|
## 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
|
WORKDIR /app
|
||||||
|
|
||||||
# tzdata: python:3.12-slim doesn't include it by default, so the zoneinfo
|
# tzdata/fonts in their own layer, kept separate from the much larger
|
||||||
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
|
# Node.js/npm layers below -- see those layers' own comments for why
|
||||||
# would have no named zones to resolve without this -- ZoneInfo() would
|
# they're split up the way they are. tzdata: python:3.12-slim doesn't
|
||||||
# raise for anything other than "UTC".
|
# include it by default, so the zoneinfo database backing the web UI's
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
# "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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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 app ./app
|
||||||
|
COPY start.sh .
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
|
||||||
EXPOSE 8420
|
EXPOSE 8420
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
|
CMD ["./start.sh"]
|
||||||
|
|||||||
+78
-9
@@ -121,9 +121,11 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
this response may grow.
|
this response may grow.
|
||||||
- `GET /frame/photo-info` -- location/date overlay text for the manage
|
- `GET /frame/photo-info` -- location/date overlay text for the manage
|
||||||
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
|
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
|
||||||
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich
|
- `GET /frame/share/{manage_token}` -- creates a 30-minute public Immich
|
||||||
share link and 302s to it; scoped to the photo currently showing or
|
share link covering every photo widget's currently-showing photo on
|
||||||
queued on *this* frame only.
|
*this* frame and 302s to it. Authenticated by the frame's own
|
||||||
|
`manage_token` (see the manage QR below), not device credentials -- a
|
||||||
|
phone scanning the QR has no way to supply `?id=`/`?token=`.
|
||||||
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
|
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
|
||||||
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
|
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
|
||||||
flat-scalar parser.
|
flat-scalar parser.
|
||||||
@@ -200,12 +202,12 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
in the web UI (or using "Show next") only rearranges what's already in
|
in the web UI (or using "Show next") only rearranges what's already in
|
||||||
that lookahead; it doesn't add or remove photos from the album.
|
that lookahead; it doesn't add or remove photos from the album.
|
||||||
- Auth in one breath: browsers use sessions (+CSRF), devices use
|
- Auth in one breath: browsers use sessions (+CSRF), devices use
|
||||||
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own
|
per-frame tokens (`?id=` + `?token=`), the manage QR and the
|
||||||
limited token, and `MANAGEMENT_TOKEN` survives only as the migration
|
scan-to-download QR both use the frame's own `manage_token` (device
|
||||||
credential for pre-multi-frame firmware. `/frame/share` stays scoped
|
tokens don't work for either -- neither is ever called by firmware,
|
||||||
to photos this frame is actually showing or has queued, not any
|
both are opened by a phone that has no way to supply `?id=`/`?token=`),
|
||||||
Immich asset ID someone might guess -- a second layer a leaked device
|
and `MANAGEMENT_TOKEN` survives only as the migration credential for
|
||||||
token alone wouldn't bypass.
|
pre-multi-frame firmware.
|
||||||
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events
|
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events
|
||||||
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
|
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
|
||||||
which is LGPL-3.0-or-later -- the only non-permissively-licensed
|
which is LGPL-3.0-or-later -- the only non-permissively-licensed
|
||||||
@@ -213,6 +215,57 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
never vendored or modified, so this project's own code stays under its
|
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
|
own license; LGPL's copyleft terms apply to that library itself, not
|
||||||
to code that merely links against it dynamically.
|
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`
|
- The 6-color palette RGB values in `app/image_pipeline.py`
|
||||||
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
||||||
(Waveshare doesn't publish exact color primaries for this panel).
|
(Waveshare doesn't publish exact color primaries for this panel).
|
||||||
@@ -252,3 +305,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
|
`--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.
|
`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.
|
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
|
"""Fetch, parse, and merge per-user calendar feeds -- ICS subscriptions
|
||||||
mode (see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
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
|
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
|
||||||
get_or_refresh_calendar_events) supply plain (owner_display_name, url)
|
get_or_refresh_calendar_events) supply plain CalendarSource values, not
|
||||||
pairs, not ORM objects, so this module stays testable against fixture .ics
|
ORM objects, so this module stays testable against fixture .ics text with
|
||||||
text with no database or app involved.
|
no database or app involved.
|
||||||
|
|
||||||
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
|
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
|
||||||
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
|
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
|
||||||
@@ -16,12 +17,15 @@ code under LGPL terms).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import icalendar
|
import icalendar
|
||||||
import recurring_ical_events
|
import recurring_ical_events
|
||||||
|
|
||||||
|
from . import caldav_client
|
||||||
|
|
||||||
HTTP_TIMEOUT_S = 15.0
|
HTTP_TIMEOUT_S = 15.0
|
||||||
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
|
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
|
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(
|
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]:
|
) -> tuple[list[dict], str]:
|
||||||
"""sources: [(owner_display_name, ics_url), ...]. Fetches each
|
"""Fetches each source independently -- one broken feed never blanks
|
||||||
independently -- one broken feed never blanks another's events.
|
another's events. Returns (merged_time_sorted_events, fetch_summary);
|
||||||
Returns (merged_time_sorted_events, fetch_summary); fetch_summary is
|
fetch_summary is "" when every source succeeded, else "N of M
|
||||||
"" when every source succeeded, else "N of M calendars unavailable"
|
calendars unavailable" (never *which* source -- naming whose feed is
|
||||||
(never *which* source -- naming whose feed is down to everyone who
|
down to everyone who looks at a shared household display is a bigger
|
||||||
looks at a shared household display is a bigger overshare than the
|
overshare than the outage itself).
|
||||||
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] = []
|
merged: list[dict] = []
|
||||||
|
by_key: dict[tuple, dict] = {}
|
||||||
failures = 0
|
failures = 0
|
||||||
for owner_display_name, url in sources:
|
for source in sources:
|
||||||
try:
|
try:
|
||||||
events = fetch_source_events(url, window_start, window_end)
|
if source.kind == "caldav":
|
||||||
except CalendarFetchError:
|
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
|
failures += 1
|
||||||
continue
|
continue
|
||||||
for event in events:
|
for event in events:
|
||||||
event["owner_display_name"] = owner_display_name
|
source_entry = {"owner_display_name": source.owner_display_name, "color_index": source.color_index}
|
||||||
merged.append(event)
|
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"])
|
merged.sort(key=lambda e: e["start"])
|
||||||
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
|
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 import create_engine, event
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
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")
|
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)
|
db.refresh(frame)
|
||||||
yield frame
|
yield frame
|
||||||
db.commit()
|
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,
|
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
|
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
|
||||||
logical (pre-rotation) frame space at each named face's bottom-center
|
logical (pre-rotation) frame space at each named face's bottom-center
|
||||||
point -- manage_overlay.compose() draws these directly onto the
|
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
|
match the settings that were active then -- otherwise the placement
|
||||||
computed here won't match what's actually on screen.
|
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
|
The placement math matches render_frame()'s own composition step
|
||||||
exactly (see image_pipeline._placement_transform, shared so the two
|
exactly (see image_pipeline._placement_transform, shared so the two
|
||||||
can't drift apart).
|
can't drift apart).
|
||||||
@@ -46,11 +55,16 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
|
|||||||
if not named:
|
if not named:
|
||||||
return []
|
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"))
|
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
|
||||||
|
|
||||||
scale_x, scale_y, offset_x, offset_y = _placement_transform(
|
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 = []
|
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
|
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
|
||||||
bottom_y = face["boundingBoxY2"] * img_scale_y
|
bottom_y = face["boundingBoxY2"] * img_scale_y
|
||||||
|
|
||||||
frame_x = center_x * scale_x + offset_x
|
# Relative to the region's own origin first (matches
|
||||||
frame_y = bottom_y * scale_y + offset_y
|
# _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):
|
if not (0 <= region_x <= target_w and 0 <= region_y <= target_h):
|
||||||
continue # this face got cropped out of the final frame entirely
|
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
|
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 (c) 2010-2013, Anton Koovit ([email protected]), with Reserved Font Name 'Arvo'
|
||||||
|
|
||||||
|
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:
|
||||||
|
http://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,93 @@
|
|||||||
|
Copyright 2010 The Crimson Text Project Authors (https://github.com/googlefonts/Crimson)
|
||||||
|
|
||||||
|
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,93 @@
|
|||||||
|
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||||
|
|
||||||
|
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: http://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,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
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:
|
||||||
|
http://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 AND 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,93 @@
|
|||||||
|
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||||
|
|
||||||
|
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: http://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,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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
import io
|
||||||
|
|
||||||
from PIL import Image, ImageEnhance, ImageOps
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
||||||
|
|
||||||
EPD_WIDTH = 800
|
EPD_WIDTH = 800
|
||||||
EPD_HEIGHT = 480
|
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
|
# How each orientation maps the logically-composed image onto the native
|
||||||
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
||||||
# crop ratio matches how the frame actually hangs) and rotate into native
|
# crop ratio matches how the frame actually hangs) and rotate into native
|
||||||
@@ -197,6 +220,13 @@ DISPLAY_MODE_LABELS = {
|
|||||||
DEFAULT_DISPLAY_MODE = "crop_faces"
|
DEFAULT_DISPLAY_MODE = "crop_faces"
|
||||||
LETTERBOX_BG = (255, 255, 255)
|
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(
|
def _placement_transform(
|
||||||
img_width: int, img_height: int, target_w: int, target_h: int,
|
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)
|
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,
|
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||||
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
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,
|
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||||
orientation: str = "landscape", palette_rgb: list | 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
|
"""A readable full-panel message (plus an optional QR code) in the
|
||||||
same packed format as render_frame -- what /frame/image serves for a
|
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
|
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
|
`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
|
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
||||||
yet."""
|
yet."""
|
||||||
from PIL import ImageDraw, ImageFont
|
margin = 24
|
||||||
|
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
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)
|
title_font = ImageFont.load_default(size=34)
|
||||||
body_font = ImageFont.load_default(size=24)
|
body_font = ImageFont.load_default(size=24)
|
||||||
|
max_text_w = logical_w - margin * 2
|
||||||
|
|
||||||
qr_img = None
|
qr_img = None
|
||||||
if qr_url:
|
if qr_url:
|
||||||
@@ -409,19 +489,39 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
scale = max(1, target // raw.width)
|
scale = max(1, target // raw.width)
|
||||||
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
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.
|
# Vertical layout: text block, then QR under it, centered as a group.
|
||||||
line_heights = []
|
line_heights = []
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
font = title_font if i == 0 else body_font
|
font = title_font if i == 0 else body_font
|
||||||
bbox = draw.textbbox((0, 0), line, font=font)
|
for sub_line in wrap(line, font):
|
||||||
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
|
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
|
gap = 14
|
||||||
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
|
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)
|
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
|
||||||
y = max(20, (logical_h - total_h) // 2)
|
y = max(20, (logical_h - total_h) // 2)
|
||||||
|
|
||||||
for line, font, w, h in line_heights:
|
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
|
y += h + gap
|
||||||
|
|
||||||
if qr_img:
|
if qr_img:
|
||||||
@@ -429,4 +529,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
|
|
||||||
img = _apply_manage_overlay(img, manage)
|
img = _apply_manage_overlay(img, manage)
|
||||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
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)
|
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")
|
||||||
@@ -80,10 +80,10 @@ class ImmichClient:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
def create_share_link(self, asset_id: str, expires_in_s: int) -> str:
|
def create_share_link(self, asset_ids: list[str], expires_in_s: int) -> str:
|
||||||
"""Creates a public, view-only Immich share link for a single
|
"""Creates a public, view-only Immich share link covering one or
|
||||||
asset, expiring expires_in_s seconds from now, and returns its
|
more assets, expiring expires_in_s seconds from now, and returns
|
||||||
public URL. Used by the manage-button overlay's share QR --
|
its public URL. Used by the manage-button overlay's share QR --
|
||||||
created lazily (only when someone actually scans it), not when
|
created lazily (only when someone actually scans it), not when
|
||||||
the button's pressed, so the expiry clock starts when it's
|
the button's pressed, so the expiry clock starts when it's
|
||||||
actually used."""
|
actually used."""
|
||||||
@@ -93,7 +93,7 @@ class ImmichClient:
|
|||||||
headers=self._headers,
|
headers=self._headers,
|
||||||
json={
|
json={
|
||||||
"type": "INDIVIDUAL",
|
"type": "INDIVIDUAL",
|
||||||
"assetIds": [asset_id],
|
"assetIds": list(asset_ids),
|
||||||
"expiresAt": expires_at,
|
"expiresAt": expires_at,
|
||||||
"allowUpload": False,
|
"allowUpload": False,
|
||||||
"allowDownload": True,
|
"allowDownload": True,
|
||||||
|
|||||||
+7
-2
@@ -4,7 +4,10 @@ for the panel, and serves ESP32 frames ready-to-display images.
|
|||||||
This module is assembly only -- routes live in app/routers/:
|
This module is assembly only -- routes live in app/routers/:
|
||||||
device.py the firmware-facing /frame/* protocol (paths frozen)
|
device.py the firmware-facing /frame/* protocol (paths frozen)
|
||||||
api_frames.py the web UI's JSON API, /api/frames/{id}/...
|
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
|
||||||
|
api_layouts.py named, user-owned saved layouts, /api/layouts,
|
||||||
|
/api/frames/{id}/layouts
|
||||||
|
frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages
|
||||||
pages.py setup/login/claim/settings/admin
|
pages.py setup/login/claim/settings/admin
|
||||||
manage.py the limited manage-QR surface (/m/, /api/m/)
|
manage.py the limited manage-QR surface (/m/, /api/m/)
|
||||||
Storage is SQLite via models.py/db.py; migration.py imports a
|
Storage is SQLite via models.py/db.py; migration.py imports a
|
||||||
@@ -30,7 +33,7 @@ from .auth import (
|
|||||||
)
|
)
|
||||||
from .db import SessionLocal
|
from .db import SessionLocal
|
||||||
from .models import Frame
|
from .models import Frame
|
||||||
from .routers import api_frames, device, frame_pages, manage, pages
|
from .routers import api_frames, api_layouts, api_widgets, device, frame_pages, manage, pages
|
||||||
from .routers.common import shell_context
|
from .routers.common import shell_context
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -45,6 +48,8 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|||||||
|
|
||||||
app.include_router(device.router)
|
app.include_router(device.router)
|
||||||
app.include_router(api_frames.router)
|
app.include_router(api_frames.router)
|
||||||
|
app.include_router(api_widgets.router)
|
||||||
|
app.include_router(api_layouts.router)
|
||||||
app.include_router(frame_pages.router)
|
app.include_router(frame_pages.router)
|
||||||
app.include_router(pages.router)
|
app.include_router(pages.router)
|
||||||
app.include_router(manage.router)
|
app.include_router(manage.router)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text
|
||||||
|
|
||||||
PADDING = 16
|
PADDING = 16
|
||||||
QR_TEXT_GAP = 8
|
QR_TEXT_GAP = 8
|
||||||
LINE_GAP = 4
|
LINE_GAP = 4
|
||||||
@@ -62,13 +64,13 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image
|
|||||||
return w, h
|
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:
|
center_x: int, top: int) -> None:
|
||||||
y = top
|
y = top
|
||||||
for line in lines:
|
for line in lines:
|
||||||
bbox = draw.textbbox((0, 0), line, font=font)
|
bbox = draw.textbbox((0, 0), line, font=font)
|
||||||
w = bbox[2] - bbox[0]
|
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
|
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
|
center_x = x0 + w // 2
|
||||||
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
||||||
if caption:
|
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
|
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)
|
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.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]:
|
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
|
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,
|
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
|
||||||
anchor_w: int, anchor_h: int) -> None:
|
anchor_w: int, anchor_h: int) -> None:
|
||||||
"""Battery glyph + "NN%" text, right-aligned under the given anchor
|
"""Battery glyph (now actually filled to `percent`, not just a static
|
||||||
box (the manage QR box) -- a sensible default position, not a
|
outline -- easy now that this renders server-side instead of being a
|
||||||
constraint anything else has to route around; move this call site's
|
fixed bitmap firmware drew) + "NN%" text, right-aligned under the
|
||||||
arguments to place it anywhere else instead."""
|
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)
|
font = _font(BODY_FONT_SIZE)
|
||||||
text = f"{percent}%"
|
text = f"{percent}%"
|
||||||
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
|
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_x = x0 + PADDING
|
||||||
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
|
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),
|
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
|
||||||
width=BATTERY_ICON_STROKE)
|
width=BATTERY_ICON_STROKE)
|
||||||
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
|
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],
|
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))
|
fill=(0, 0, 0))
|
||||||
draw.text((icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
||||||
text, fill=(0, 0, 0), font=font)
|
text, font)
|
||||||
|
|
||||||
|
|
||||||
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
|
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))
|
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.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,
|
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
|
||||||
|
|||||||
+788
-3
@@ -15,11 +15,23 @@ import secrets
|
|||||||
import shutil
|
import shutil
|
||||||
import time
|
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 .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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -100,6 +112,518 @@ def _migration_7(conn) -> None:
|
|||||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
|
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.
|
||||||
|
|
||||||
|
Raw CREATE TABLE, not Base.metadata.create_all (this migration
|
||||||
|
originally used create_all -- switched retroactively once it turned
|
||||||
|
out to matter): create_all creates every table declared in Base.
|
||||||
|
metadata that's missing, not just this migration's own new one, so
|
||||||
|
it would just as happily create text_widget_configs (a LATER
|
||||||
|
migration's model, once TextWidgetConfig existed in models.py) years
|
||||||
|
before migration 21 gets a turn -- and then migration 21's own
|
||||||
|
CREATE TABLE collides with the one create_all already snuck in. Same
|
||||||
|
fix, same reasoning as migration 21's own comment about migration
|
||||||
|
22's ALTER TABLE -- see that one for the fuller explanation."""
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE static_widget_configs ("
|
||||||
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||||
|
"image BLOB, "
|
||||||
|
"original_filename TEXT NOT NULL DEFAULT '', "
|
||||||
|
"uploaded_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"display_mode TEXT NOT NULL DEFAULT 'crop_fill')"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Raw CREATE TABLE (not Base.metadata.create_all, unlike migration 20's
|
||||||
|
static_widget_configs) because migration 22 adds a column to this
|
||||||
|
same table right after -- create_all always reflects models.py's
|
||||||
|
CURRENT shape, so replaying the full migration chain on an old
|
||||||
|
database would have it already include that later column and
|
||||||
|
collide with migration 22's ALTER TABLE. Same reason migration 17's
|
||||||
|
task_widget_configs CREATE TABLE is raw SQL rather than create_all,
|
||||||
|
ahead of migration 19's ALTER TABLE ADD COLUMN name."""
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE text_widget_configs ("
|
||||||
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||||
|
"content TEXT, "
|
||||||
|
"font_size INTEGER NOT NULL DEFAULT 28, "
|
||||||
|
"align TEXT NOT NULL DEFAULT 'left', "
|
||||||
|
"background_color TEXT NOT NULL DEFAULT '#ffffff')"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_22(conn) -> None:
|
||||||
|
"""Adds a font family choice to the text widget (app/widgets/text.py's
|
||||||
|
FONT_FAMILIES) alongside its existing font_size -- both whole-widget
|
||||||
|
settings, not per-run. "sans" (Noto Sans) matches the column default
|
||||||
|
so existing text widgets keep rendering in the same font they always
|
||||||
|
have."""
|
||||||
|
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN font_family TEXT NOT NULL DEFAULT 'sans'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_23(conn) -> None:
|
||||||
|
"""New feature: named, user-owned saved layouts (see models.
|
||||||
|
SavedLayout/SavedLayoutWidget/SavedLayoutSource/
|
||||||
|
SavedLayoutButtonAction, routers/api_layouts.py) -- a snapshot of a
|
||||||
|
frame's widget arrangement a user can capture and later apply to any
|
||||||
|
frame they control whose grid matches, instead of manually rebuilding
|
||||||
|
it widget by widget.
|
||||||
|
|
||||||
|
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
||||||
|
migration 20/21's own comments: create_all always reflects models.py's
|
||||||
|
CURRENT shape, so replaying the full chain on an old database could
|
||||||
|
collide with a later migration's ALTER TABLE on one of these same
|
||||||
|
tables."""
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE saved_layouts ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
||||||
|
"name TEXT NOT NULL, "
|
||||||
|
"cols INTEGER NOT NULL, "
|
||||||
|
"rows INTEGER NOT NULL, "
|
||||||
|
"created_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"updated_at REAL NOT NULL DEFAULT 0.0)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE UNIQUE INDEX ix_saved_layouts_user_name ON saved_layouts (user_id, name)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE saved_layout_widgets ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"saved_layout_id INTEGER NOT NULL REFERENCES saved_layouts(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, "
|
||||||
|
"config TEXT NOT NULL DEFAULT '{}', "
|
||||||
|
"image BLOB)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE INDEX ix_saved_layout_widgets_layout ON saved_layout_widgets (saved_layout_id)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE saved_layout_sources ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
|
||||||
|
"kind TEXT NOT NULL, "
|
||||||
|
"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 INDEX ix_saved_layout_sources_widget ON saved_layout_sources (saved_layout_widget_id)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE saved_layout_button_actions ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
|
||||||
|
"button TEXT NOT NULL, "
|
||||||
|
"action TEXT NOT NULL, "
|
||||||
|
"sort_order INTEGER NOT NULL DEFAULT 0)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE INDEX ix_saved_layout_button_actions_widget ON saved_layout_button_actions (saved_layout_widget_id)"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -108,6 +632,22 @@ MIGRATIONS = [
|
|||||||
(5, _migration_5),
|
(5, _migration_5),
|
||||||
(6, _migration_6),
|
(6, _migration_6),
|
||||||
(7, _migration_7),
|
(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),
|
||||||
|
(22, _migration_22),
|
||||||
|
(23, _migration_23),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -135,6 +675,8 @@ def run_migrations() -> None:
|
|||||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||||
_ensure_frame_one()
|
_ensure_frame_one()
|
||||||
_ensure_server_settings()
|
_ensure_server_settings()
|
||||||
|
_ensure_widgets_backfilled()
|
||||||
|
_ensure_frame_calendars_rekeyed()
|
||||||
|
|
||||||
|
|
||||||
def new_device_token() -> str:
|
def new_device_token() -> str:
|
||||||
@@ -239,3 +781,246 @@ def _ensure_server_settings() -> None:
|
|||||||
if db.get(ServerSettings, 1) is None:
|
if db.get(ServerSettings, 1) is None:
|
||||||
db.add(ServerSettings(id=1))
|
db.add(ServerSettings(id=1))
|
||||||
db.commit()
|
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)"
|
||||||
|
))
|
||||||
|
|||||||
+526
-16
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
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.ext.mutable import MutableList
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
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 -- see routers/device.py's frame_battery) go here; blank = no
|
||||||
# email configured, both features silently no-op for this user.
|
# email configured, both features silently no-op for this user.
|
||||||
email: Mapped[str] = mapped_column(String, default="")
|
email: Mapped[str] = mapped_column(String, default="")
|
||||||
# Personal iCal/CalDAV .ics subscription URL (no OAuth) for calendar
|
# Personal ICS subscription URL (no OAuth) for calendar frame mode --
|
||||||
# frame mode -- see calendar_feed.py. Setting this alone shows up
|
# see calendar_feed.py. Setting this alone shows up nowhere: a linked
|
||||||
# nowhere: a linked frame only pulls this user's events in once
|
# frame only pulls this user's events in once they've also added it
|
||||||
# they've also opted in on that frame's own Configuration -> Calendar
|
# on that frame's own Calendar tab (FrameCalendar below).
|
||||||
# card (UserFrame.calendar_included below).
|
|
||||||
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
|
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)
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -88,8 +121,9 @@ class Frame(Base):
|
|||||||
# the migrated legacy frame until its device first reports an id.
|
# the migrated legacy frame until its device first reports an id.
|
||||||
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
||||||
name: Mapped[str] = mapped_column(String, default="")
|
name: Mapped[str] = mapped_column(String, default="")
|
||||||
# Renderer dispatch seam for future calendar/canva modes -- only
|
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
|
||||||
# "photos" is registered today (see routers/device.py RENDERERS).
|
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
|
||||||
|
# and routers/common.py FRAME_MODES).
|
||||||
mode: Mapped[str] = mapped_column(String, default="photos")
|
mode: Mapped[str] = mapped_column(String, default="photos")
|
||||||
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
||||||
owner_user_id: Mapped[int | None] = mapped_column(
|
owner_user_id: Mapped[int | None] = mapped_column(
|
||||||
@@ -148,6 +182,9 @@ class Frame(Base):
|
|||||||
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
||||||
# routers/device.py's RENDERERS["calendar"]) --
|
# routers/device.py's RENDERERS["calendar"]) --
|
||||||
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
|
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/
|
# Agenda view only; reuses this frame's existing photos-mode album/
|
||||||
# queue, not a separate photo setup.
|
# queue, not a separate photo setup.
|
||||||
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
|
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
@@ -170,6 +207,77 @@ class Frame(Base):
|
|||||||
# outage to everyone who looks at it.
|
# outage to everyone who looks at it.
|
||||||
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
|
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 --
|
# -- state --
|
||||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||||
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
@@ -231,14 +339,416 @@ class UserFrame(Base):
|
|||||||
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
|
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
|
||||||
)
|
)
|
||||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
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
|
class FrameCalendar(Base):
|
||||||
# calendar is their own data to share or not, not something a
|
"""One calendar included on one frame -- calendar_key is "ics" (the
|
||||||
# frame's controller decides on their behalf). Meaningless if the
|
owner's single calendar_ics_url) or "caldav:<href>" (one of the
|
||||||
# user has no calendar_ics_url set. See routers/api_frames.py's
|
owner's CalDAV collections; href matches an entry in
|
||||||
# api_calendar_included.
|
User.calendar_caldav_calendars). Replaces the old single
|
||||||
calendar_included: Mapped[bool] = mapped_column(Boolean, default=False)
|
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)
|
||||||
|
# A key into app/widgets/text.py's FONT_FAMILIES, also whole-widget
|
||||||
|
# not per-run (see font_size above for why).
|
||||||
|
font_family: Mapped[str] = mapped_column(String, default="sans")
|
||||||
|
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 SavedLayout(Base):
|
||||||
|
"""A named snapshot of one frame's widget arrangement (types,
|
||||||
|
placement, per-widget settings, button assignments) -- owned by a
|
||||||
|
*user*, not a frame, so it can be applied to any frame that user
|
||||||
|
controls whose grid matches (see docs/widgets.md's "Saved layouts").
|
||||||
|
cols/rows is the grid.grid_dims(orientation) the snapshot was taken
|
||||||
|
at -- an 8x5 (landscape-class) layout isn't meaningful on a 5x8
|
||||||
|
(portrait-class) frame, same reasoning as grid.py's own orientation-
|
||||||
|
change note.
|
||||||
|
|
||||||
|
Saving again with a name that already exists for this user
|
||||||
|
overwrites that layout's snapshot in place (see routers/
|
||||||
|
api_layouts.py's api_layout_save) rather than erroring or quietly
|
||||||
|
creating a second layout with the same name -- the "named save slot"
|
||||||
|
behavior people expect."""
|
||||||
|
|
||||||
|
__tablename__ = "saved_layouts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
name: Mapped[str] = mapped_column(String)
|
||||||
|
cols: Mapped[int] = mapped_column(Integer)
|
||||||
|
rows: Mapped[int] = mapped_column(Integer)
|
||||||
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
updated_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_saved_layouts_user_name", "user_id", "name", unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SavedLayoutWidget(Base):
|
||||||
|
"""One captured widget's type/placement/settings within a
|
||||||
|
SavedLayout -- the snapshot analogue of Widget plus its per-type
|
||||||
|
config row, minus anything that's runtime/cache state rather than an
|
||||||
|
authored setting (a photo widget's current queue position, a
|
||||||
|
calendar's fetch cache, a whiteboard's rendered-image cache, etc.)
|
||||||
|
-- see api_layouts.LAYOUT_CONFIG_FIELDS for the exact per-type field
|
||||||
|
allowlist. `config` holds every JSON-safe captured setting; `image`
|
||||||
|
is only ever populated for a static-image widget's uploaded bytes
|
||||||
|
(its own BLOB column rather than folding base64 into the JSON, same
|
||||||
|
reasoning as StaticWidgetConfig.image itself)."""
|
||||||
|
|
||||||
|
__tablename__ = "saved_layout_widgets"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
saved_layout_id: Mapped[int] = mapped_column(ForeignKey("saved_layouts.id", ondelete="CASCADE"))
|
||||||
|
widget_type: Mapped[str] = mapped_column(String)
|
||||||
|
x: Mapped[int] = mapped_column(Integer)
|
||||||
|
y: Mapped[int] = mapped_column(Integer)
|
||||||
|
w: Mapped[int] = mapped_column(Integer)
|
||||||
|
h: Mapped[int] = mapped_column(Integer)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
config: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_saved_layout_widgets_layout", "saved_layout_id"),)
|
||||||
|
|
||||||
|
|
||||||
|
class SavedLayoutSource(Base):
|
||||||
|
"""One included calendar/task-list source captured on a calendar or
|
||||||
|
tasks SavedLayoutWidget -- the snapshot analogue of FrameCalendar/
|
||||||
|
FrameTaskList. `kind` ("calendar" | "task") distinguishes which,
|
||||||
|
since both shapes are otherwise identical and sharing one table
|
||||||
|
avoids a near-duplicate SavedLayoutTaskSource table."""
|
||||||
|
|
||||||
|
__tablename__ = "saved_layout_sources"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
saved_layout_widget_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
kind: Mapped[str] = mapped_column(String)
|
||||||
|
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_saved_layout_sources_widget", "saved_layout_widget_id"),)
|
||||||
|
|
||||||
|
|
||||||
|
class SavedLayoutButtonAction(Base):
|
||||||
|
"""One (button, action) binding captured for one SavedLayoutWidget --
|
||||||
|
the snapshot analogue of FrameButtonAction. References the captured
|
||||||
|
widget directly rather than a frame_id/widget_id pair (neither
|
||||||
|
exists until the layout is applied) so applying can remap it onto
|
||||||
|
whichever new Widget row that captured widget becomes -- see
|
||||||
|
routers/api_layouts.py's api_layout_apply."""
|
||||||
|
|
||||||
|
__tablename__ = "saved_layout_button_actions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
saved_layout_widget_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
button: Mapped[str] = mapped_column(String)
|
||||||
|
action: Mapped[str] = mapped_column(String)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_saved_layout_button_actions_widget", "saved_layout_widget_id"),)
|
||||||
|
|
||||||
|
|
||||||
class PendingClaim(Base):
|
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
|
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
|
"""Unconditionally moves to the next photo, ignoring elapsed time, and
|
||||||
resets the interval clock from now. Used by the explicit next-photo
|
resets the interval clock from now. Used by the explicit next-photo
|
||||||
action (POST /frame/advance) and by get_current() once the refresh
|
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:
|
if cfg.current_asset_id:
|
||||||
# Recorded regardless of *why* this advance happened (a manual
|
# Recorded regardless of *why* this advance happened (a manual
|
||||||
# next-press or the timer just elapsing) -- back should be able
|
# 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.
|
# only asset is already current) -- keep showing what we have.
|
||||||
cfg.current_asset_id = assets[0]["id"]
|
cfg.current_asset_id = assets[0]["id"]
|
||||||
cfg.current_asset_set_at = time.time()
|
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
|
# Refill back up to queue_target_len now that current_asset_id has
|
||||||
# changed -- otherwise the queue is left one short until the *next*
|
# changed -- otherwise the queue is left one short until the *next*
|
||||||
# advance, since the pop above consumes one of the items _top_up just
|
# 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)
|
_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
|
"""Unconditionally moves to the previously-current photo, the mirror
|
||||||
image of advance_forced() -- pops the most recent entry off history,
|
image of advance_forced() -- pops the most recent entry off history,
|
||||||
pushes the photo it's replacing onto the front of queue (so pressing
|
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
|
since). Returns whether it actually moved -- False (history empty or
|
||||||
entirely stale) is a no-op, callers should still just display
|
entirely stale) is a no-op, callers should still just display
|
||||||
whatever's current rather than treating it as an error. Used by the
|
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}
|
valid_ids = {a["id"] for a in assets}
|
||||||
while cfg.history:
|
while cfg.history:
|
||||||
previous_id = cfg.history.pop()
|
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.queue.insert(0, cfg.current_asset_id)
|
||||||
cfg.current_asset_id = previous_id
|
cfg.current_asset_id = previous_id
|
||||||
cfg.current_asset_set_at = time.time()
|
cfg.current_asset_set_at = time.time()
|
||||||
cfg.stats_photos_displayed += 1
|
frame.stats_photos_displayed += 1
|
||||||
return True
|
return True
|
||||||
return False
|
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
|
"""Permanently excludes asset_id from this frame's rotation (see the
|
||||||
module docstring) -- doesn't touch Immich, just this frame's own
|
module docstring) -- doesn't touch Immich, just this frame's own
|
||||||
selection. Scrubs it out of queue and history too, so it can't
|
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
|
*not* through advance_forced(), since that would record the removed
|
||||||
photo in history, and going back to a photo you just explicitly
|
photo in history, and going back to a photo you just explicitly
|
||||||
removed doesn't make sense. Returns whether the current photo
|
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:
|
if asset_id not in cfg.excluded_asset_ids:
|
||||||
cfg.excluded_asset_ids.append(asset_id)
|
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.queue = [a for a in cfg.queue if a != asset_id]
|
||||||
cfg.history = [a for a in cfg.history 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]
|
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_id = remaining[0] if remaining else ""
|
||||||
cfg.current_asset_set_at = time.time()
|
cfg.current_asset_set_at = time.time()
|
||||||
cfg.stats_photos_displayed += 1
|
frame.stats_photos_displayed += 1
|
||||||
_top_up(cfg, assets)
|
_top_up(cfg, assets)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -180,7 +190,7 @@ def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
|
|||||||
_top_up(cfg, assets)
|
_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
|
"""Time-based, idempotent path used by GET /frame/image. Advances only
|
||||||
if the current photo is unset/invalid or refresh_interval_s has
|
if the current photo is unset/invalid or refresh_interval_s has
|
||||||
elapsed since it was set. Returns whether it changed anything, so the
|
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
|
ahead, while a wake that lands after the interval has elapsed still
|
||||||
advances exactly once, even after a long time offline.
|
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
|
in_quiet_hours suppresses *only* the elapsed-time trigger -- an
|
||||||
unset/invalid current photo still gets picked regardless, since
|
unset/invalid current photo still gets picked regardless, since
|
||||||
showing nothing is worse than showing something even at 3am. This
|
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)."""
|
window (see main.py's _effective_refresh_interval_s)."""
|
||||||
valid_ids = {a["id"] for a in assets}
|
valid_ids = {a["id"] for a in assets}
|
||||||
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
|
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)
|
stale = needs_pick or (time_elapsed and not in_quiet_hours)
|
||||||
if not stale:
|
if not stale:
|
||||||
return False
|
return False
|
||||||
advance_forced(cfg, assets)
|
advance_forced(cfg, assets, frame)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -114,6 +114,18 @@ def in_quiet_hours(cfg) -> bool:
|
|||||||
return in_quiet
|
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:
|
def max_expected_gap_s(cfg) -> int:
|
||||||
"""Longest gap between wakes the device might legitimately have --
|
"""Longest gap between wakes the device might legitimately have --
|
||||||
normally just refresh_interval_s, but quiet hours can make the real
|
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
|
Auth: session-only (require_frame_view for reads, require_frame_control
|
||||||
for mutations -- the "take control" soft lock). The limited manage-QR
|
for mutations -- the "take control" soft lock). The limited manage-QR
|
||||||
surface lives separately under /api/m/ (routers/manage.py), and device
|
surface lives separately under /api/m/ (routers/manage.py), and device
|
||||||
traffic under /frame/* (routers/device.py).
|
traffic under /frame/* (routers/device.py).
|
||||||
|
|
||||||
Config saves are PARTIAL updates: each page's form posts only its own
|
Config saves are PARTIAL updates: only provided fields are touched.
|
||||||
fields (the old single Settings form split across the Photos and
|
Checkboxes are sent explicitly as "true"/"false" strings by the page JS
|
||||||
Configuration tabs), so every field is optional and only provided ones
|
-- an absent field means "not this form's field", never "unchecked".
|
||||||
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
|
from __future__ import annotations
|
||||||
@@ -22,34 +23,18 @@ import httpx
|
|||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
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 ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
from ..image_pipeline import (
|
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
||||||
DEFAULT_DISPLAY_MODE,
|
|
||||||
DISPLAY_MODES,
|
|
||||||
PALETTE_LABELS,
|
|
||||||
hex_to_rgb,
|
|
||||||
render_preview_png,
|
|
||||||
)
|
|
||||||
from ..firmware import firmware_path, parse_app_version
|
from ..firmware import firmware_path, parse_app_version
|
||||||
from ..models import BatteryLog, Frame, UserFrame
|
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
||||||
from .common import (
|
from ..widgets import WIDGET_TYPES
|
||||||
FRAME_MODES,
|
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
||||||
OVERDUE_FACTOR,
|
from .device import render_frame_preview_png
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,12 +42,36 @@ router = APIRouter()
|
|||||||
|
|
||||||
MIN_REFRESH_INTERVAL_S = 60
|
MIN_REFRESH_INTERVAL_S = 60
|
||||||
MAX_REFRESH_INTERVAL_S = 86400
|
MAX_REFRESH_INTERVAL_S = 86400
|
||||||
MIN_QUEUE_TARGET_LEN = 5
|
|
||||||
MAX_QUEUE_TARGET_LEN = 5000
|
|
||||||
|
|
||||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
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")
|
@router.get("/api/frames/{frame_id}/albums")
|
||||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||||
url, key = immich_creds(frame)
|
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")
|
@router.post("/api/frames/{frame_id}/config")
|
||||||
def api_config_save(
|
def api_config_save(
|
||||||
name: str | None = Form(None),
|
name: str | None = Form(None),
|
||||||
album_id: str | None = Form(None),
|
|
||||||
order: str | None = Form(None),
|
|
||||||
refresh_interval_s: int | 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),
|
orientation: str | None = Form(None),
|
||||||
quiet_hours_enabled: bool | None = Form(None),
|
quiet_hours_enabled: bool | None = Form(None),
|
||||||
quiet_hours_start: str | None = Form(None),
|
quiet_hours_start: str | None = Form(None),
|
||||||
@@ -96,37 +101,38 @@ def api_config_save(
|
|||||||
color_boost: float | None = Form(None),
|
color_boost: float | None = Form(None),
|
||||||
contrast_boost: float | None = Form(None),
|
contrast_boost: float | None = Form(None),
|
||||||
dither_strength: 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),
|
frame: Frame = Depends(require_frame_control),
|
||||||
db: Session = Depends(get_db),
|
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:
|
with frame_locked(db, frame.id) as cfg:
|
||||||
if name is not None:
|
if name is not None:
|
||||||
cfg.name = name.strip()[:64] or cfg.name
|
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:
|
if refresh_interval_s is not None:
|
||||||
cfg.refresh_interval_s = max(
|
cfg.refresh_interval_s = max(
|
||||||
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
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:
|
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:
|
if quiet_hours_enabled is not None:
|
||||||
cfg.quiet_hours_enabled = quiet_hours_enabled
|
cfg.quiet_hours_enabled = quiet_hours_enabled
|
||||||
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
|
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))
|
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||||
if dither_strength is not None:
|
if dither_strength is not None:
|
||||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
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
|
cfg.stats_config_saves += 1
|
||||||
|
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
@@ -207,65 +202,144 @@ def api_stats(frame: Frame = Depends(require_frame_view)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/frames/{frame_id}/queue")
|
@router.get("/api/frames/{frame_id}/status")
|
||||||
def api_queue(
|
def api_status(
|
||||||
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
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)
|
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()
|
now = time.time()
|
||||||
|
overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR
|
||||||
return {
|
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": {
|
"control": {
|
||||||
"controller": snapshot["controller"],
|
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
||||||
"you": snapshot["controller_id"] == user.id,
|
"you": frame.controlled_by_user_id == user.id,
|
||||||
},
|
},
|
||||||
"device": {
|
"device": {
|
||||||
"last_seen": snapshot["last_seen"] or None,
|
"last_seen": frame.last_seen or None,
|
||||||
"overdue": bool(
|
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
|
||||||
snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
|
"firmware_version": frame.device_firmware_version or None,
|
||||||
),
|
"firmware_available": frame.firmware_available_version or None,
|
||||||
"firmware_version": snapshot["firmware_version"] or None,
|
|
||||||
"firmware_available": snapshot["firmware_available"] or None,
|
|
||||||
"battery": (
|
"battery": (
|
||||||
{"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
|
{"percent": frame.battery_percent, "as_of": frame.battery_as_of}
|
||||||
if snapshot["battery_percent"] >= 0
|
if frame.battery_percent >= 0 else None
|
||||||
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")
|
@router.get("/api/frames/{frame_id}/battery-log")
|
||||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||||
rows = db.execute(
|
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]}
|
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")
|
@router.post("/api/frames/{frame_id}/firmware")
|
||||||
def api_firmware_upload(
|
def api_firmware_upload(
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""Saved layouts: a named snapshot of one frame's widget arrangement
|
||||||
|
(types, placement, per-widget settings, button assignments) that a user
|
||||||
|
can capture and later apply to any frame they control whose grid matches
|
||||||
|
-- see models.SavedLayout and docs/widgets.md's "Saved layouts" section.
|
||||||
|
|
||||||
|
Layouts are owned by a *user*, not a frame (SavedLayout.user_id), so
|
||||||
|
every endpoint here except save/apply/list (which need a frame_id to act
|
||||||
|
on) is frame-agnostic -- /api/layouts/{id} rather than /api/frames/{fid}/
|
||||||
|
layouts/{id}, gated purely on "is this your own saved layout" rather than
|
||||||
|
frame view/control.
|
||||||
|
|
||||||
|
Save/apply DO live under /api/frames/{frame_id}/... (require_frame_
|
||||||
|
control) since they read/replace one specific frame's actual widgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import grid
|
||||||
|
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
|
from ..db import frame_locked, get_db
|
||||||
|
from ..models import (
|
||||||
|
Frame,
|
||||||
|
FrameButtonAction,
|
||||||
|
FrameCalendar,
|
||||||
|
FrameTaskList,
|
||||||
|
SavedLayout,
|
||||||
|
SavedLayoutButtonAction,
|
||||||
|
SavedLayoutSource,
|
||||||
|
SavedLayoutWidget,
|
||||||
|
User,
|
||||||
|
WIDGET_CONFIG_MODELS,
|
||||||
|
Widget,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_LAYOUT_NAME_LEN = 60
|
||||||
|
|
||||||
|
# Per-widget-type allowlist of config columns that are an actual authored
|
||||||
|
# *setting* (captured/restored by a saved layout) as opposed to runtime/
|
||||||
|
# cache state (a photo widget's current queue position, a calendar's
|
||||||
|
# fetch cache, a whiteboard's rendered-image cache, ...) which a layout
|
||||||
|
# deliberately leaves out -- applying a layout should feel like a fresh
|
||||||
|
# widget of that type with these settings pre-filled, not a resurrection
|
||||||
|
# of stale queue/cache state from whenever it was saved. "static"
|
||||||
|
# excludes `image` on purpose -- that BLOB lives on SavedLayoutWidget.image
|
||||||
|
# instead (see its own model docstring).
|
||||||
|
LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
|
||||||
|
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
|
||||||
|
"calendar": (
|
||||||
|
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
|
||||||
|
"week_days", "week_layout", "week_start_offset",
|
||||||
|
),
|
||||||
|
"tasks": ("name", "show_completed"),
|
||||||
|
"static": ("display_mode", "original_filename"),
|
||||||
|
"text": ("content", "font_size", "font_family", "align", "background_color"),
|
||||||
|
"whiteboard": ("user_id", "url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
|
||||||
|
SOURCE_MODELS: dict[str, tuple[type, str]] = {
|
||||||
|
"calendar": (FrameCalendar, "calendar"),
|
||||||
|
"tasks": (FrameTaskList, "task"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _layout_summary(db: Session, layout: SavedLayout, frame: Frame | None = None) -> dict:
|
||||||
|
widget_count = db.scalar(
|
||||||
|
select(func.count()).select_from(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id)
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"id": layout.id,
|
||||||
|
"name": layout.name,
|
||||||
|
"cols": layout.cols,
|
||||||
|
"rows": layout.rows,
|
||||||
|
"widget_count": widget_count or 0,
|
||||||
|
"created_at": layout.created_at,
|
||||||
|
"updated_at": layout.updated_at,
|
||||||
|
}
|
||||||
|
if frame is not None:
|
||||||
|
cols, rows = grid.grid_dims(frame.orientation)
|
||||||
|
result["compatible"] = (layout.cols, layout.rows) == (cols, rows)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _user_owned_layout(db: Session, layout_id: int, user: User) -> SavedLayout:
|
||||||
|
layout = db.get(SavedLayout, layout_id)
|
||||||
|
if layout is None or layout.user_id != user.id:
|
||||||
|
raise HTTPException(404, "No such saved layout")
|
||||||
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/frames/{frame_id}/layouts")
|
||||||
|
def api_layouts_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||||
|
"""Every saved layout owned by the calling user (saved layouts are
|
||||||
|
global to a user, not scoped to this or any other frame) -- includes
|
||||||
|
a `compatible` flag per layout for whether it can actually be applied
|
||||||
|
to *this* frame's current grid, so the UI can offer incompatible ones
|
||||||
|
greyed-out with a reason rather than hiding them outright."""
|
||||||
|
user = require_user_api(request, db)
|
||||||
|
layouts = db.scalars(
|
||||||
|
select(SavedLayout).where(SavedLayout.user_id == user.id).order_by(SavedLayout.name)
|
||||||
|
).all()
|
||||||
|
return {"layouts": [_layout_summary(db, layout, frame) for layout in layouts]}
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutSaveRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_widget_config(widget: Widget, config) -> dict:
|
||||||
|
fields = LAYOUT_CONFIG_FIELDS.get(widget.widget_type, ())
|
||||||
|
return {field: getattr(config, field) for field in fields}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_sources(db: Session, widget_id: int, model: type) -> list[dict]:
|
||||||
|
rows = db.scalars(select(model).where(model.widget_id == widget_id)).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": row.user_id, "calendar_key": row.calendar_key, "calendar_label": row.calendar_label,
|
||||||
|
"included": row.included, "color_index": row.color_index,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/layouts")
|
||||||
|
def api_layout_save(
|
||||||
|
body: LayoutSaveRequest, request: Request,
|
||||||
|
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Snapshots this frame's current widgets (placement + settings,
|
||||||
|
see LAYOUT_CONFIG_FIELDS), their calendar/task sources, and their
|
||||||
|
button-action bindings into a named layout owned by the calling
|
||||||
|
user. Saving again with a name this user already has overwrites that
|
||||||
|
layout's snapshot in place (see models.SavedLayout's docstring)
|
||||||
|
rather than erroring or creating a duplicate."""
|
||||||
|
user = require_user_api(request, db)
|
||||||
|
name = body.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "Name is required")
|
||||||
|
if len(name) > MAX_LAYOUT_NAME_LEN:
|
||||||
|
raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer")
|
||||||
|
|
||||||
|
cols, rows = grid.grid_dims(frame.orientation)
|
||||||
|
widgets = db.scalars(
|
||||||
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
existing = db.execute(
|
||||||
|
select(SavedLayout).where(SavedLayout.user_id == user.id, SavedLayout.name == name)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
layout = existing
|
||||||
|
layout.cols, layout.rows, layout.updated_at = cols, rows, now
|
||||||
|
db.execute(delete(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id))
|
||||||
|
db.flush()
|
||||||
|
else:
|
||||||
|
layout = SavedLayout(user_id=user.id, name=name, cols=cols, rows=rows, created_at=now, updated_at=now)
|
||||||
|
db.add(layout)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
snapshot_id_by_widget_id: dict[int, int] = {}
|
||||||
|
for widget in widgets:
|
||||||
|
config = db.get(WIDGET_CONFIG_MODELS[widget.widget_type], widget.id)
|
||||||
|
snapshot = SavedLayoutWidget(
|
||||||
|
saved_layout_id=layout.id, widget_type=widget.widget_type,
|
||||||
|
x=widget.x, y=widget.y, w=widget.w, h=widget.h, sort_order=widget.sort_order,
|
||||||
|
config=_snapshot_widget_config(widget, config),
|
||||||
|
image=config.image if widget.widget_type == "static" else None,
|
||||||
|
)
|
||||||
|
db.add(snapshot)
|
||||||
|
db.flush()
|
||||||
|
snapshot_id_by_widget_id[widget.id] = snapshot.id
|
||||||
|
|
||||||
|
source_model = SOURCE_MODELS.get(widget.widget_type)
|
||||||
|
if source_model is not None:
|
||||||
|
model, kind = source_model
|
||||||
|
for source in _snapshot_sources(db, widget.id, model):
|
||||||
|
db.add(SavedLayoutSource(saved_layout_widget_id=snapshot.id, kind=kind, **source))
|
||||||
|
|
||||||
|
actions = db.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
|
||||||
|
for action in actions:
|
||||||
|
snapshot_id = snapshot_id_by_widget_id.get(action.widget_id)
|
||||||
|
if snapshot_id is None:
|
||||||
|
continue
|
||||||
|
db.add(SavedLayoutButtonAction(
|
||||||
|
saved_layout_widget_id=snapshot_id, button=action.button, action=action.action,
|
||||||
|
sort_order=action.sort_order,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return _layout_summary(db, layout, frame)
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutRenameRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/layouts/{layout_id}")
|
||||||
|
def api_layout_rename(layout_id: int, body: LayoutRenameRequest, request: Request, db: Session = Depends(get_db)):
|
||||||
|
user = require_user_api(request, db)
|
||||||
|
layout = _user_owned_layout(db, layout_id, user)
|
||||||
|
name = body.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "Name is required")
|
||||||
|
if len(name) > MAX_LAYOUT_NAME_LEN:
|
||||||
|
raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer")
|
||||||
|
conflict = db.execute(
|
||||||
|
select(SavedLayout).where(
|
||||||
|
SavedLayout.user_id == user.id, SavedLayout.name == name, SavedLayout.id != layout.id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if conflict is not None:
|
||||||
|
raise HTTPException(400, "You already have a saved layout with that name")
|
||||||
|
layout.name = name
|
||||||
|
layout.updated_at = time.time()
|
||||||
|
db.commit()
|
||||||
|
return _layout_summary(db, layout)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/layouts/{layout_id}")
|
||||||
|
def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
|
user = require_user_api(request, db)
|
||||||
|
layout = _user_owned_layout(db, layout_id, user)
|
||||||
|
db.delete(layout)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply")
|
||||||
|
def api_layout_apply(
|
||||||
|
layout_id: int, request: Request,
|
||||||
|
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Replaces this frame's entire widget arrangement with a saved
|
||||||
|
layout's -- every current widget (and its own config/sources/button
|
||||||
|
actions, all ondelete="CASCADE") is deleted first, same "act
|
||||||
|
unconditionally on the server, confirm on the client" posture as
|
||||||
|
api_widgets.api_widgets_clear. A source whose owning user account
|
||||||
|
(or a whiteboard's user_id) no longer exists is silently dropped
|
||||||
|
rather than left dangling -- config is JSON, not FK-checked, so
|
||||||
|
nothing enforces that at the storage layer."""
|
||||||
|
user = require_user_api(request, db)
|
||||||
|
layout = _user_owned_layout(db, layout_id, user)
|
||||||
|
cols, rows = grid.grid_dims(frame.orientation)
|
||||||
|
if (layout.cols, layout.rows) != (cols, rows):
|
||||||
|
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
|
||||||
|
|
||||||
|
snapshots = db.scalars(
|
||||||
|
select(SavedLayoutWidget)
|
||||||
|
.where(SavedLayoutWidget.saved_layout_id == layout.id)
|
||||||
|
.order_by(SavedLayoutWidget.sort_order)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
with frame_locked(db, frame.id):
|
||||||
|
for widget in db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all():
|
||||||
|
db.delete(widget)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
new_widget_id_by_snapshot_id: dict[int, int] = {}
|
||||||
|
for snapshot in snapshots:
|
||||||
|
widget = Widget(
|
||||||
|
frame_id=frame.id, widget_type=snapshot.widget_type,
|
||||||
|
x=snapshot.x, y=snapshot.y, w=snapshot.w, h=snapshot.h,
|
||||||
|
sort_order=snapshot.sort_order, created_at=time.time(),
|
||||||
|
)
|
||||||
|
db.add(widget)
|
||||||
|
db.flush()
|
||||||
|
new_widget_id_by_snapshot_id[snapshot.id] = widget.id
|
||||||
|
|
||||||
|
config = WIDGET_CONFIG_MODELS[snapshot.widget_type](widget_id=widget.id)
|
||||||
|
for field, value in snapshot.config.items():
|
||||||
|
if field == "user_id" and value is not None and db.get(User, value) is None:
|
||||||
|
value = None # that WebDAV account no longer exists
|
||||||
|
setattr(config, field, value)
|
||||||
|
if snapshot.widget_type == "static" and snapshot.image is not None:
|
||||||
|
config.image = snapshot.image
|
||||||
|
config.uploaded_at = time.time()
|
||||||
|
db.add(config)
|
||||||
|
|
||||||
|
source_model = SOURCE_MODELS.get(snapshot.widget_type)
|
||||||
|
if source_model is not None:
|
||||||
|
model, kind = source_model
|
||||||
|
sources = db.scalars(
|
||||||
|
select(SavedLayoutSource).where(
|
||||||
|
SavedLayoutSource.saved_layout_widget_id == snapshot.id, SavedLayoutSource.kind == kind
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for source in sources:
|
||||||
|
if db.get(User, source.user_id) is None:
|
||||||
|
continue # that account no longer exists
|
||||||
|
db.add(model(
|
||||||
|
widget_id=widget.id, user_id=source.user_id, calendar_key=source.calendar_key,
|
||||||
|
calendar_label=source.calendar_label, included=source.included,
|
||||||
|
color_index=source.color_index,
|
||||||
|
))
|
||||||
|
|
||||||
|
for snapshot in snapshots:
|
||||||
|
actions = db.scalars(
|
||||||
|
select(SavedLayoutButtonAction)
|
||||||
|
.where(SavedLayoutButtonAction.saved_layout_widget_id == snapshot.id)
|
||||||
|
.order_by(SavedLayoutButtonAction.sort_order)
|
||||||
|
).all()
|
||||||
|
for action in actions:
|
||||||
|
db.add(FrameButtonAction(
|
||||||
|
frame_id=frame.id, button=action.button,
|
||||||
|
widget_id=new_widget_id_by_snapshot_id[snapshot.id], action=action.action,
|
||||||
|
sort_order=action.sort_order, created_at=time.time(),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
return {"status": "applied", "widget_count": len(snapshots)}
|
||||||
File diff suppressed because it is too large
Load Diff
+447
-104
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import statistics
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -15,15 +16,27 @@ from PIL import Image
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import calendar_feed, quiet_hours
|
from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
|
||||||
from ..db import frame_locked
|
from ..db import widget_locked
|
||||||
from ..image_pipeline import render_frame
|
from ..image_pipeline import logical_render_size
|
||||||
from ..immich_client import ImmichClient
|
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__)
|
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 / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
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
|
# still needs to clear all of them, while a single stray low one doesn't
|
||||||
# get to set the bar.
|
# get to set the bar.
|
||||||
RECHARGE_LOOKBACK = 3
|
RECHARGE_LOOKBACK = 3
|
||||||
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
|
BATTERY_ESTIMATE_SAMPLE_COUNT = 100 # most recent battery_log rows considered
|
||||||
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
|
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
|
# "Overdue" threshold multiplier: the device should check in roughly every
|
||||||
# refresh_interval_s; give it half again as long before flagging it.
|
# 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)
|
return ImmichClient(url, key)
|
||||||
|
|
||||||
|
|
||||||
def require_configured(frame: Frame) -> None:
|
def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
|
||||||
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]:
|
|
||||||
try:
|
try:
|
||||||
assets = client.list_album_assets(frame.album_id)
|
assets = client.list_album_assets(album_id)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
||||||
if not assets:
|
if not assets:
|
||||||
@@ -85,18 +95,22 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
|
|||||||
return assets
|
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
|
"""The shared first half of rendering: download the Immich preview
|
||||||
and (only if display_mode needs it) its detected faces. Used by both
|
and (only if display_mode needs it) its detected faces. Used by the
|
||||||
render_asset (device-facing) and the web UI's rendered-preview
|
web UI's rendered-preview endpoint (routers/api_widgets.py's
|
||||||
endpoint (routers/api_frames.py) so they can't drift apart."""
|
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:
|
try:
|
||||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||||
|
|
||||||
faces = None
|
faces = None
|
||||||
if frame.display_mode == "crop_faces":
|
if display_mode == "crop_faces":
|
||||||
try:
|
try:
|
||||||
faces = client.get_asset_faces(asset_id)
|
faces = client.get_asset_faces(asset_id)
|
||||||
except httpx.HTTPError as e:
|
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
|
return Image.open(io.BytesIO(jpeg_bytes)), faces
|
||||||
|
|
||||||
|
|
||||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
|
def _avg_wake_interval_s(frame: Frame) -> float:
|
||||||
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
"""Average wall-clock seconds between wakes: refresh_interval_s
|
||||||
return render_frame(source, faces=faces, orientation=frame.orientation,
|
scaled up for however much of each day quiet hours removes from the
|
||||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
wake schedule entirely -- fewer wakes/day, not a cheaper wake. This
|
||||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
is what lets battery_estimate_s convert a per-wake drop rate into a
|
||||||
dither_strength=frame.dither_strength, manage=manage)
|
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:
|
def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, float]]:
|
||||||
"""Linear remaining-time estimate from the current discharge cycle's
|
"""Drops (weight, drop_pct) pairs whose drop is a wild outlier
|
||||||
observed rate, or None when there's not enough signal to be honest
|
relative to the rest of the recent steps. A single noisy ADC/
|
||||||
about (too little time observed, or too little drop -- a flat line
|
regulator glitch (see firmware/main/battery.c) corrupts one of the
|
||||||
extrapolates to garbage)."""
|
two steps around it, whichever way it reads: a glitch that dips low
|
||||||
hist = frame.battery_history
|
then recovers makes the step INTO it a spurious huge drop (the step
|
||||||
if len(hist) < 2:
|
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
|
return None
|
||||||
first_ts, first_pct = hist[0]
|
rows = db.execute(
|
||||||
last_ts, last_pct = hist[-1]
|
select(BatteryLog.percent)
|
||||||
span = last_ts - first_ts
|
.where(BatteryLog.frame_id == frame.id)
|
||||||
drop = first_pct - last_pct
|
.order_by(BatteryLog.ts.desc())
|
||||||
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
|
.limit(BATTERY_ESTIMATE_SAMPLE_COUNT)
|
||||||
|
).scalars().all()
|
||||||
|
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
|
||||||
return None
|
return None
|
||||||
rate = drop / span # percent per second
|
percents = list(reversed(rows)) # chronological order
|
||||||
return int(last_pct / rate)
|
|
||||||
|
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,
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _manage_content_asset_id(frame: Frame) -> str | None:
|
def widget_of_type(db: Session, frame: Frame, widget_type: str) -> Widget | None:
|
||||||
"""Whether frame.current_asset_id refers to a photo actually visible
|
"""The frame's first widget of this type, by placement order. Until
|
||||||
right now, for whichever mode is active -- always true in photos
|
the placement UI (a later phase) ships, every frame has at most one
|
||||||
mode; only true in calendar mode when the agenda view's photo inlay
|
widget per type -- the auto-migrated default -- so callers needing
|
||||||
is on (otherwise current_asset_id could be stale, left over from
|
"the photo widget" / "the calendar widget" / "the whiteboard widget"
|
||||||
whenever photos mode last ran, and showing its location/date/share
|
for what's still effectively a single-widget-per-type frame use this
|
||||||
info on a manage overlay over a view with no visible photo at all
|
rather than querying Widget directly. None if the frame has no widget
|
||||||
would be actively misleading, not just unhelpful)."""
|
of this type."""
|
||||||
relevant = frame.mode != "calendar" or (frame.calendar_view == "agenda" and frame.calendar_photo_inlay)
|
return db.scalars(
|
||||||
return frame.current_asset_id if relevant and frame.current_asset_id else None
|
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:
|
def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||||
@@ -259,82 +415,269 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
|||||||
/frame/face-labels, both removed -- see the module docstring in
|
/frame/face-labels, both removed -- see the module docstring in
|
||||||
manage_overlay.py) are now just internal calls made here, once,
|
manage_overlay.py) are now just internal calls made here, once,
|
||||||
server-side, since compositing itself also moved server-side.
|
server-side, since compositing itself also moved server-side.
|
||||||
management_url and battery_percent always apply; location/date/
|
management_url and battery_percent always apply. location/date/
|
||||||
share-URL/face-labels only when there's a real current photo (see
|
share-URL come from one "primary" photo widget (see
|
||||||
_manage_content_asset_id) -- absent otherwise, which
|
_primary_photo_widget -- there's only one of each of those fixed
|
||||||
manage_overlay.compose() already treats as "skip that region",
|
panel corners, so with more than one photo widget on screen some
|
||||||
exactly the graceful-degradation behavior the old firmware-fetched
|
single one has to be picked); face labels generalize more simply,
|
||||||
version had."""
|
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("/")
|
base = str(request.base_url).rstrip("/")
|
||||||
content: dict = {
|
content: dict = {
|
||||||
"management_url": f"{base}/m/{frame.manage_token}",
|
"management_url": f"{base}/m/{frame.manage_token}",
|
||||||
"battery_percent": frame.battery_percent,
|
"battery_percent": frame.battery_percent,
|
||||||
}
|
}
|
||||||
|
|
||||||
asset_id = _manage_content_asset_id(frame)
|
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||||
if not asset_id:
|
if not photo_widgets:
|
||||||
return content
|
return content
|
||||||
|
|
||||||
client = immich_client_for(frame)
|
primary = _primary_photo_widget(db, frame, photo_widgets)
|
||||||
try:
|
primary_cfg = db.get(PhotoWidgetConfig, primary.id) if primary else None
|
||||||
asset = client.get_asset(asset_id)
|
if primary_cfg and primary_cfg.current_asset_id:
|
||||||
faces = client.get_asset_faces(asset_id)
|
client = immich_client_for(frame)
|
||||||
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):
|
|
||||||
try:
|
try:
|
||||||
preview_bytes = client.download_asset_preview(asset_id)
|
asset = client.get_asset(primary_cfg.current_asset_id)
|
||||||
from ..face_labels import compute_face_labels
|
|
||||||
|
|
||||||
content["face_labels"] = compute_face_labels(preview_bytes, faces, frame.display_mode, frame.orientation)
|
|
||||||
except httpx.HTTPError as e:
|
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)
|
||||||
|
|
||||||
|
# Unlike location/date-taken (a single fixed corner, so tied to the
|
||||||
|
# one "primary" widget above), the share link covers every photo
|
||||||
|
# widget's current photo (see manage.manage_share) -- so it only
|
||||||
|
# needs *some* photo widget to have a current photo, not specifically
|
||||||
|
# the primary one, and doesn't depend on the EXIF fetch above
|
||||||
|
# succeeding.
|
||||||
|
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets):
|
||||||
|
content["share_url"] = f"{base}/frame/share/{frame.manage_token}"
|
||||||
|
|
||||||
|
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
|
return content
|
||||||
|
|
||||||
|
|
||||||
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[tuple[str, str]]:
|
def calendar_sources_for_widget(db: Session, widget: Widget) -> list[calendar_feed.CalendarSource]:
|
||||||
"""Every user linked to this frame with BOTH a calendar URL set AND
|
"""Every calendar included on this calendar widget (FrameCalendar.
|
||||||
explicit per-frame opt-in (UserFrame.calendar_included) -- the exact
|
included) -- the exact set calendar_feed.merge_events needs. A
|
||||||
set calendar_feed.merge_events needs. [(display_name-or-username,
|
calendar_key of "ics" resolves against its owner's calendar_ics_url;
|
||||||
ics_url), ...]."""
|
"caldav:<href>" resolves against the href itself, authenticated with
|
||||||
|
the owner's CalDAV account credentials (see caldav_client.py)."""
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(User)
|
select(FrameCalendar, User)
|
||||||
.join(UserFrame, UserFrame.user_id == User.id)
|
.join(User, User.id == FrameCalendar.user_id)
|
||||||
.where(UserFrame.frame_id == frame.id, UserFrame.calendar_included == True, # noqa: E712
|
.where(FrameCalendar.widget_id == widget.id, FrameCalendar.included == True) # noqa: E712
|
||||||
User.calendar_ics_url != "")
|
).all()
|
||||||
).scalars().all()
|
sources = []
|
||||||
return [(u.display_name or u.username, u.calendar_ics_url) for u in rows]
|
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)
|
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
|
||||||
-- same shape as the Gitea release-check throttle in api_frames.py'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
|
api_firmware_check -- reading/writing CalendarWidgetConfig (see
|
||||||
(every included user's events together), not per-user -- ICS feeds
|
app/widgets/calendar.py, which this backs). One shared cache for the
|
||||||
are small and this refetches at most every ~20 minutes regardless of
|
whole merged result (every included user's events together), not
|
||||||
how many are included, so per-user cache columns would add
|
per-user -- ICS feeds are small and this refetches at most every ~20
|
||||||
bookkeeping for a marginal benefit."""
|
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()
|
now = time.time()
|
||||||
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S:
|
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||||
return frame.calendar_cached_events, frame.calendar_fetch_summary
|
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)
|
today = quiet_hours.local_date(frame)
|
||||||
events, summary = calendar_feed.merge_events(
|
events, summary = calendar_feed.merge_events(
|
||||||
sources,
|
sources,
|
||||||
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
|
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
|
||||||
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
|
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
|
||||||
)
|
)
|
||||||
with frame_locked(db, frame.id) as locked:
|
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||||
locked.calendar_cached_events = events
|
locked_cfg.cached_events = events
|
||||||
locked.calendar_fetch_summary = summary
|
locked_cfg.fetch_summary = summary
|
||||||
locked.calendar_checked_at = now
|
locked_cfg.checked_at = now
|
||||||
return events, summary
|
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
|
||||||
|
|||||||
+131
-176
@@ -15,31 +15,25 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
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 ..auth import get_server_settings, require_device
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
from ..firmware import firmware_path
|
from ..firmware import firmware_path
|
||||||
from ..image_pipeline import render_placeholder
|
from ..image_pipeline import logical_render_size, render_panel, render_placeholder
|
||||||
from ..models import BatteryLog, Frame
|
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
||||||
|
from ..widgets import WIDGET_TYPES
|
||||||
from .common import (
|
from .common import (
|
||||||
BATTERY_HISTORY_MAX,
|
BATTERY_HISTORY_MAX,
|
||||||
BATTERY_LOG_MAX,
|
BATTERY_LOG_MAX,
|
||||||
RECHARGE_JUMP_PCT,
|
RECHARGE_JUMP_PCT,
|
||||||
RECHARGE_LOOKBACK,
|
RECHARGE_LOOKBACK,
|
||||||
build_manage_content,
|
build_manage_content,
|
||||||
get_or_refresh_calendar_events,
|
|
||||||
immich_client_for,
|
|
||||||
immich_creds,
|
|
||||||
list_assets,
|
|
||||||
render_asset,
|
|
||||||
require_configured,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -47,12 +41,13 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
|
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
|
||||||
"""What an unclaimed or not-yet-configured frame displays instead of a
|
as_png: bool = False) -> bytes:
|
||||||
photo -- instructions with a QR, rendered at 200 so the device treats
|
"""What an unclaimed or widget-less frame displays instead of real
|
||||||
it as a perfectly normal image and never error-loops. The URLs are
|
content -- instructions with a QR, rendered at 200 so the device
|
||||||
built from the request's own base URL: whatever address the device
|
treats it as a perfectly normal image and never error-loops. The
|
||||||
reached us at is by definition an address that works on this
|
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."""
|
network."""
|
||||||
base = str(request.base_url).rstrip("/")
|
base = str(request.base_url).rstrip("/")
|
||||||
if frame.owner_user_id is None and frame.device_id:
|
if frame.owner_user_id is None and frame.device_id:
|
||||||
@@ -63,6 +58,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
|||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
manage=manage,
|
manage=manage,
|
||||||
|
as_png=as_png,
|
||||||
)
|
)
|
||||||
if frame.owner_user_id is None:
|
if frame.owner_user_id is None:
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
@@ -70,137 +66,115 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
|||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
manage=manage,
|
manage=manage,
|
||||||
|
as_png=as_png,
|
||||||
)
|
)
|
||||||
return render_placeholder(
|
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,
|
qr_url=base,
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
manage=manage,
|
manage=manage,
|
||||||
|
as_png=as_png,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _frame_configured(frame: Frame) -> bool:
|
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
||||||
url, key = immich_creds(frame)
|
as_png: bool = False) -> bytes:
|
||||||
return bool(url and key and frame.album_id)
|
"""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
|
||||||
# --- photos mode ---
|
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
|
||||||
|
per-mode RENDERERS dict -- a frame can now show several widgets at
|
||||||
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
once instead of exactly one mode owning the whole panel."""
|
||||||
is_normal_wake: bool) -> bytes:
|
all_widgets = db.scalars(
|
||||||
if not _frame_configured(frame):
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||||
return _setup_placeholder(frame, request, manage=manage)
|
).all()
|
||||||
client = immich_client_for(frame)
|
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||||
assets = list_assets(client, frame)
|
regions = []
|
||||||
|
for widget in all_widgets:
|
||||||
with frame_locked(db, frame.id) as locked:
|
module = WIDGET_TYPES.get(widget.widget_type)
|
||||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
if module is None:
|
||||||
asset_id = locked.current_asset_id
|
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
||||||
|
px, py, pw, ph = grid.cell_to_pixels(
|
||||||
return render_asset(client, frame, asset_id, manage=manage)
|
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
||||||
with frame_locked(db, frame.id) as locked:
|
regions.append(((px, py, pw, ph), img))
|
||||||
if is_normal_wake and locked.calendar_browse_offset != 0:
|
return render_panel(
|
||||||
locked.calendar_browse_offset = 0
|
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
browse_offset = locked.calendar_browse_offset
|
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||||
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
||||||
"""NEXT in calendar mode: moves the displayed period forward one step
|
is_normal_wake: bool, as_png: bool = False) -> bytes:
|
||||||
(day for agenda, week for week view, month for month view) from
|
"""The top-level "what does this frame show right now" entry point.
|
||||||
wherever it currently is -- not from "today" -- so repeated presses
|
An unclaimed frame or one with no widgets yet gets the setup
|
||||||
walk further forward. See Frame.calendar_browse_offset."""
|
placeholder (needs `request` for its QR URLs -- only available on the
|
||||||
with frame_locked(db, frame.id) as locked:
|
normal-wake path where a real request is on hand, never on an
|
||||||
locked.calendar_browse_offset += 1
|
advance/back button press); otherwise every widget on it gets
|
||||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
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:
|
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
|
||||||
with frame_locked(db, frame.id) as locked:
|
"""The web UI's live "how it's displaying" thumbnail (see
|
||||||
locked.calendar_browse_offset -= 1
|
routers/api_frames.py's /preview endpoint) -- same compositor
|
||||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
/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 = {
|
def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
|
||||||
"photos": _render_photos_mode,
|
"""Executes every (widget, action) binding assigned to this physical
|
||||||
"calendar": _render_calendar_mode,
|
button, in order -- see models.FrameButtonAction and the button-
|
||||||
}
|
assignment UI (a later phase). Each action runs to completion (its
|
||||||
ADVANCE_RENDERERS = {
|
own widget_locked span) before the next one starts -- never nested,
|
||||||
"photos": _advance_photos_mode,
|
since db.widget_locked's underlying lock isn't reentrant (see its own
|
||||||
"calendar": _advance_calendar_mode,
|
docstring) -- a button assigned several actions would deadlock
|
||||||
}
|
instantly if this looped any other way. One action failing
|
||||||
BACK_RENDERERS = {
|
unexpectedly doesn't block the others, or the eventual re-render,
|
||||||
"photos": _back_photos_mode,
|
from happening -- the user pressed a physical button and expects
|
||||||
"calendar": _back_calendar_mode,
|
*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")
|
@router.get("/frame/config")
|
||||||
@@ -246,42 +220,47 @@ def _manage_flag(request: Request) -> bool:
|
|||||||
def frame_image(
|
def frame_image(
|
||||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Returns the frame's current image. For photos mode: idempotent --
|
"""Returns the frame's current image -- every widget on the frame
|
||||||
only actually advances to the next photo once refresh_interval_s has
|
composited into one panel (see _render_widgets). Each widget's own
|
||||||
elapsed since the current one was set (see app/photo_queue.py) --
|
render is idempotent in whatever way makes sense for its type (e.g.
|
||||||
safe to call as often as the device wants, including after an
|
a photo widget only actually advances once its own refresh interval
|
||||||
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
has elapsed, see app/photo_queue.py) -- safe to call as often as the
|
||||||
unconfigured frame gets a rendered instruction placeholder (200, not
|
device wants, including after an unplanned reboot, without skipping
|
||||||
an error) so a fresh device never error-loops.
|
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
|
?manage=1 (the manage button) composites the manage overlay onto
|
||||||
whatever this would have returned anyway -- see build_manage_content.
|
whatever this would have returned anyway -- see build_manage_content.
|
||||||
For calendar mode, this is also the "normal wake" that resets
|
This is also the "normal wake" that resets any calendar widget's
|
||||||
calendar_browse_offset back to 0 (see _render_calendar_mode)."""
|
browse position back to today (see app/widgets/calendar.py)."""
|
||||||
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
|
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
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")
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/frame/advance")
|
@router.post("/frame/advance")
|
||||||
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
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,
|
"""Forces an immediate move forward on whatever widget(s) the NEXT
|
||||||
or the next day/week/month in calendar mode -- ignoring
|
button is assigned to (see models.FrameButtonAction) -- e.g. the next
|
||||||
refresh_interval_s. Used by the device's next-photo button."""
|
photo for a photo widget, or the next day/week/month for a calendar
|
||||||
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
|
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
|
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")
|
@router.post("/frame/back")
|
||||||
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
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
|
"""The mirror of /frame/advance, for whatever widget(s) the BACK
|
||||||
a period in calendar mode. A no-op (still 200, unchanged) if there's
|
button is assigned to. A no-op (still 200, unchanged) for any widget
|
||||||
nothing to go back to. Used by the device's back-photo button."""
|
with nothing to go back to. Used by the device's back-photo button."""
|
||||||
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
|
_run_button_actions(db, frame, "back")
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
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):
|
class BatteryReport(BaseModel):
|
||||||
@@ -373,27 +352,3 @@ def frame_firmware(frame: Frame = Depends(require_device)):
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "No firmware uploaded")
|
raise HTTPException(404, "No firmware uploaded")
|
||||||
return FileResponse(path, media_type="application/octet-stream")
|
return FileResponse(path, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frame/share/{asset_id}")
|
|
||||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
|
||||||
"""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)
|
|
||||||
|
|
||||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
|
||||||
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
|
|
||||||
|
|
||||||
client = immich_client_for(frame)
|
|
||||||
try:
|
|
||||||
share_url = client.create_share_link(asset_id, expires_in_s=1800)
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
raise HTTPException(502, f"Could not create share link: {e}") from e
|
|
||||||
|
|
||||||
return RedirectResponse(share_url)
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and
|
"""The per-frame HTML pages: Layout (/frames/{id}, the widget placement
|
||||||
Stats tabs, all inside the sidebar app shell. Data loading happens
|
canvas), Configuration, and Stats, all inside the sidebar app shell.
|
||||||
client-side against /api/frames/{id}/... (routers/api_frames.py); these
|
Each widget's own settings (album, calendar view/inclusion, whiteboard
|
||||||
routes just authorize and render the scaffold."""
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,11 +24,26 @@ from ..image_pipeline import (
|
|||||||
DEFAULT_PALETTE_RGB,
|
DEFAULT_PALETTE_RGB,
|
||||||
DISPLAY_MODE_LABELS,
|
DISPLAY_MODE_LABELS,
|
||||||
PALETTE_LABELS,
|
PALETTE_LABELS,
|
||||||
|
STATIC_DISPLAY_MODES,
|
||||||
palette_to_hex,
|
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 ..quiet_hours import ALL_TIMEZONES
|
||||||
from .common import shell_context
|
from ..widgets import text as text_widget
|
||||||
|
from .common import shell_context, widget_of_type
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
@@ -41,43 +62,207 @@ def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
|
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
|
||||||
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
def frame_layout_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
|
return _frame_page(request, db, frame_id, "frame_layout.html", "layout")
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
||||||
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
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(
|
return _frame_page(
|
||||||
request, db, frame_id, "frame_config.html", "config",
|
request, db, frame_id, "frame_config.html", "config",
|
||||||
timezones=ALL_TIMEZONES,
|
timezones=ALL_TIMEZONES,
|
||||||
palette_labels=PALETTE_LABELS,
|
palette_labels=PALETTE_LABELS,
|
||||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||||
palette_to_hex=palette_to_hex,
|
palette_to_hex=palette_to_hex,
|
||||||
display_mode_labels=DISPLAY_MODE_LABELS,
|
photo_widget_id=photo_widget_id,
|
||||||
calendar_views=CALENDAR_VIEW_LABELS,
|
|
||||||
calendar_users=_calendar_users_for_frame(db, frame_id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
|
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
|
||||||
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
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,
|
||||||
|
"text_font_families": text_widget.FONT_FAMILIES,
|
||||||
|
})
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|||||||
@@ -11,16 +11,22 @@ import logging
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, Response
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import photo_queue, quiet_hours
|
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 ..models import Frame, PhotoWidgetConfig
|
||||||
from .common import immich_client_for, list_assets, require_configured
|
from .common import (
|
||||||
|
immich_client_for,
|
||||||
|
immich_creds,
|
||||||
|
list_assets,
|
||||||
|
photo_widget_config_or_404,
|
||||||
|
photo_widgets_for_frame,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,15 +52,16 @@ def manage_page(manage_token: str, request: Request, db: Session = Depends(get_d
|
|||||||
|
|
||||||
@router.get("/api/m/{manage_token}/queue")
|
@router.get("/api/m/{manage_token}/queue")
|
||||||
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
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)
|
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:
|
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
||||||
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
photo_queue.get_current(locked_pcfg, assets, locked_frame,
|
||||||
photo_queue.sync_queue_length(cfg, assets)
|
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
|
||||||
current = cfg.current_asset_id
|
photo_queue.sync_queue_length(locked_pcfg, assets)
|
||||||
queue = list(cfg.queue)
|
current = locked_pcfg.current_asset_id
|
||||||
|
queue = list(locked_pcfg.queue)
|
||||||
|
|
||||||
def entry(asset_id: str) -> dict:
|
def entry(asset_id: str) -> dict:
|
||||||
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
|
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
|
||||||
@@ -76,7 +83,8 @@ def manage_promote(
|
|||||||
frame: Frame = Depends(require_manage),
|
frame: Frame = Depends(require_manage),
|
||||||
db: Session = Depends(get_db),
|
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:
|
if body.asset_id not in cfg.queue:
|
||||||
raise HTTPException(400, "That photo is no longer in the upcoming 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]
|
cfg.queue = [body.asset_id] + [a for a in cfg.queue if a != body.asset_id]
|
||||||
@@ -87,29 +95,30 @@ def manage_promote(
|
|||||||
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||||
"""Advances the server-side current photo; the panel itself updates
|
"""Advances the server-side current photo; the panel itself updates
|
||||||
on the device's next wake (or its next-photo button)."""
|
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)
|
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:
|
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
||||||
photo_queue.advance_forced(cfg, assets)
|
photo_queue.advance_forced(locked_pcfg, assets, locked_frame)
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/m/{manage_token}/back")
|
@router.post("/api/m/{manage_token}/back")
|
||||||
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
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)
|
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:
|
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
|
||||||
photo_queue.back_forced(cfg, assets)
|
photo_queue.back_forced(locked_pcfg, assets, locked_frame)
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
|
@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 --
|
"""Thumbnails scoped to what this frame is actually showing/queuing --
|
||||||
the manage token must not become a general Immich proxy."""
|
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")
|
raise HTTPException(404, "Not on this frame")
|
||||||
client = immich_client_for(frame)
|
client = immich_client_for(frame)
|
||||||
try:
|
try:
|
||||||
@@ -117,3 +126,37 @@ def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
|
|||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
||||||
return Response(content=content, media_type=content_type)
|
return Response(content=content, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/frame/share/{manage_token}")
|
||||||
|
def manage_share(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||||
|
"""Creates a 30-minute public Immich share link covering every photo
|
||||||
|
widget's currently-displayed asset on this frame, and redirects to it
|
||||||
|
-- what the manage overlay's bottom-left QR code points to. Lazily
|
||||||
|
created (only when someone actually scans it, not when the manage
|
||||||
|
button was pressed), so the 30-minute window starts at actual use.
|
||||||
|
Keyed on this frame's own manage_token, like the rest of this router,
|
||||||
|
rather than device credentials -- a phone scanning a QR code has no
|
||||||
|
way to supply the device's ?id=/?token=, which is why this used to
|
||||||
|
silently fall back to whichever frame happened to still carry the
|
||||||
|
legacy migration token instead of the frame that was actually
|
||||||
|
scanned."""
|
||||||
|
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||||
|
asset_ids: list[str] = []
|
||||||
|
for widget in photo_widgets:
|
||||||
|
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||||
|
if cfg.current_asset_id and cfg.current_asset_id not in asset_ids:
|
||||||
|
asset_ids.append(cfg.current_asset_id)
|
||||||
|
if not asset_ids:
|
||||||
|
raise HTTPException(404, "No photos currently showing on this frame")
|
||||||
|
|
||||||
|
url, key = immich_creds(frame)
|
||||||
|
if not url or not key:
|
||||||
|
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||||
|
|
||||||
|
client = immich_client_for(frame)
|
||||||
|
try:
|
||||||
|
share_url = client.create_share_link(asset_ids, expires_in_s=1800)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(502, f"Could not create share link: {e}") from e
|
||||||
|
return RedirectResponse(share_url)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import mail
|
from .. import caldav_client, mail
|
||||||
from ..auth import (
|
from ..auth import (
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
SESSION_LIFETIME_S,
|
SESSION_LIFETIME_S,
|
||||||
@@ -30,6 +30,7 @@ from ..auth import (
|
|||||||
destroy_session,
|
destroy_session,
|
||||||
get_server_settings,
|
get_server_settings,
|
||||||
hash_password,
|
hash_password,
|
||||||
|
require_user_api,
|
||||||
users_exist,
|
users_exist,
|
||||||
verify_password,
|
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
|
frame.owner_user_id == user.id or db.get(UserFrame, (user.id, frame.id)) is not None
|
||||||
):
|
):
|
||||||
status, pending_yours = "claimed_yours", False
|
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:
|
else:
|
||||||
status, pending_yours = "claimed", False
|
status, pending_yours = "claimed", False
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -424,6 +442,13 @@ def settings_submit(
|
|||||||
immich_url: str = Form(""),
|
immich_url: str = Form(""),
|
||||||
immich_api_key: str = Form(""),
|
immich_api_key: str = Form(""),
|
||||||
calendar_ics_url: 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(""),
|
current_password: str = Form(""),
|
||||||
new_password: str = Form(""),
|
new_password: str = Form(""),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -452,6 +477,36 @@ def settings_submit(
|
|||||||
else:
|
else:
|
||||||
user.calendar_ics_url = stripped_ics
|
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 new_password:
|
||||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||||
error = "Current password is wrong -- password not changed."
|
error = "Current password is wrong -- password not changed."
|
||||||
@@ -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:
|
def _require_admin_page(request: Request, db: Session) -> User:
|
||||||
user = current_user(request, db)
|
user = current_user(request, db)
|
||||||
if user is None or not user.is_admin:
|
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) {
|
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;
|
if (!el) return;
|
||||||
el.innerHTML = '<div class="status ' + (ok ? 'ok' : 'err') + '"></div>';
|
el.innerHTML = '<div class="status ' + (ok ? 'ok' : 'err') + '"></div>';
|
||||||
el.firstChild.textContent = message;
|
el.firstChild.textContent = message;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// Device status bar: always-visible strip (below the page title, above
|
// Device status bar: always-visible strip (below the page title, above
|
||||||
// the tabs -- see _device_status_bar.html) showing last-seen/firmware/
|
// 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
|
// 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;
|
let lastDeviceStatus = null;
|
||||||
|
|
||||||
@@ -27,9 +30,9 @@ function renderDeviceStatusBar(device) {
|
|||||||
if (device.battery) {
|
if (device.battery) {
|
||||||
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
||||||
// Shown as soon as there's any battery reading at all, even before
|
// 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%+
|
// battery_estimate_s has enough discharge samples in battery_log to
|
||||||
// drop within the current discharge cycle -- see common.py) -- so
|
// average (see common.py) -- so it's clear the number is coming, not
|
||||||
// it's clear the number is coming, not that the feature is broken.
|
// that the feature is broken.
|
||||||
const hasEstimate = device.battery_estimate_s !== null && device.battery_estimate_s !== undefined;
|
const hasEstimate = device.battery_estimate_s !== null && device.battery_estimate_s !== undefined;
|
||||||
rows.push([
|
rows.push([
|
||||||
'Est. battery life left',
|
'Est. battery life left',
|
||||||
@@ -51,7 +54,7 @@ function renderDeviceStatusBar(device) {
|
|||||||
|
|
||||||
async function loadDeviceStatusBar() {
|
async function loadDeviceStatusBar() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
const resp = await fetch(`${window.FRAME_BASE_API}/status`);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return;
|
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
|
// window.FRAME_API is set by the template. Checkboxes are always sent
|
||||||
// explicitly as "true"/"false" -- the server treats absent fields as
|
// explicitly as "true"/"false" -- the server treats absent fields as
|
||||||
// "leave unchanged", so a checkbox must never be simply omitted.
|
// "leave unchanged", so a checkbox must never be simply omitted.
|
||||||
@@ -6,12 +11,8 @@
|
|||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
||||||
const body = new URLSearchParams({
|
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,
|
orientation: document.getElementById('orientation').value,
|
||||||
refresh_interval_s: String(minutes * 60),
|
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_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||||||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07: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) => {
|
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
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 {
|
try {
|
||||||
await saveConfig();
|
await saveConfig();
|
||||||
|
lastSavedOrientation = newOrientation;
|
||||||
showStatus(true, 'Saved.');
|
showStatus(true, 'Saved.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showStatus(false, e.message);
|
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() {
|
async function takeControl() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||||
@@ -114,7 +73,7 @@ async function takeControl() {
|
|||||||
async function loadControl() {
|
async function loadControl() {
|
||||||
const banner = document.getElementById('control-banner');
|
const banner = document.getElementById('control-banner');
|
||||||
try {
|
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
|
if (!resp.ok) return; // unconfigured frame: control still works via 409s
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.control && !data.control.you) {
|
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 ----
|
// ---- 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() {
|
function loadPreview() {
|
||||||
|
if (!window.PHOTO_WIDGET_PREVIEW_API) return;
|
||||||
const bust = Date.now(); // avoid a stale cached image after settings change
|
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-original').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/original?_=${bust}`;
|
||||||
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${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();
|
loadPreview();
|
||||||
|
|
||||||
// ---- Battery alerts card ----
|
// ---- Battery alerts card ----
|
||||||
@@ -395,3 +362,204 @@ loadFirmwareCheck();
|
|||||||
// The server throttles actual Gitea API calls itself, so this poll is
|
// The server throttles actual Gitea API calls itself, so this poll is
|
||||||
// cheap either way.
|
// cheap either way.
|
||||||
setInterval(loadFirmwareCheck, 60000);
|
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
|
// machine below (hold-to-arm on touch so page scrolling still works) is
|
||||||
// battle-tested; treat changes with suspicion.
|
// battle-tested; treat changes with suspicion.
|
||||||
//
|
//
|
||||||
// Expects window.FRAME_API = '/api/frames/<id>' set by the page, and a
|
// Expects window.FRAME_API = '/api/frames/<id>/widgets/<widget_id>' (set
|
||||||
// loadQueue() global (frame_photos.js) to refetch authoritative state.
|
// by frame_layout.js when the photos dialog opens), and a loadQueue()
|
||||||
|
// global (widget_dialog_photos.js) to refetch authoritative state.
|
||||||
|
|
||||||
let upcomingItems = [];
|
let upcomingItems = [];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// Layout tab's "Saved layouts" card: save the current widget arrangement
|
||||||
|
// (placement, settings, button assignments -- see routers/api_layouts.py)
|
||||||
|
// under a name, then switch back to it later. Layouts are owned by the
|
||||||
|
// logged-in user, not this frame, so this always hits window.FRAME_BASE_API
|
||||||
|
// (the frame-level base) rather than window.FRAME_API, which the widget
|
||||||
|
// dialog machinery in frame_layout.js temporarily repoints at a specific
|
||||||
|
// widget while its gear-icon dialog is open.
|
||||||
|
|
||||||
|
let savedLayouts = [];
|
||||||
|
let editingLayoutId = null; // inline rename in progress, same pattern as frame_header.js's name pencil-edit
|
||||||
|
|
||||||
|
function renderSavedLayouts() {
|
||||||
|
const list = document.getElementById('saved-layout-list');
|
||||||
|
const emptyHint = document.getElementById('saved-layout-empty-hint');
|
||||||
|
list.innerHTML = '';
|
||||||
|
emptyHint.style.display = savedLayouts.length ? 'none' : '';
|
||||||
|
|
||||||
|
savedLayouts.forEach((layout) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'saved-layout-row';
|
||||||
|
|
||||||
|
if (editingLayoutId === layout.id) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.maxLength = 60;
|
||||||
|
input.value = layout.name;
|
||||||
|
input.className = 'saved-layout-rename-input';
|
||||||
|
|
||||||
|
const saveBtn = document.createElement('button');
|
||||||
|
saveBtn.type = 'button';
|
||||||
|
saveBtn.className = 'btn-inline';
|
||||||
|
saveBtn.textContent = 'Save';
|
||||||
|
saveBtn.addEventListener('click', () => renameSavedLayout(layout, input.value));
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.type = 'button';
|
||||||
|
cancelBtn.className = 'btn-inline secondary';
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
cancelBtn.addEventListener('click', () => { editingLayoutId = null; renderSavedLayouts(); });
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') renameSavedLayout(layout, input.value);
|
||||||
|
if (e.key === 'Escape') { editingLayoutId = null; renderSavedLayouts(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
li.appendChild(input);
|
||||||
|
li.appendChild(saveBtn);
|
||||||
|
li.appendChild(cancelBtn);
|
||||||
|
list.appendChild(li);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameWrap = document.createElement('span');
|
||||||
|
nameWrap.className = 'saved-layout-name';
|
||||||
|
const nameText = document.createElement('span');
|
||||||
|
nameText.textContent = layout.name;
|
||||||
|
nameWrap.appendChild(nameText);
|
||||||
|
if (!layout.compatible) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'saved-layout-badge';
|
||||||
|
badge.textContent = 'different orientation';
|
||||||
|
nameWrap.appendChild(badge);
|
||||||
|
}
|
||||||
|
li.appendChild(nameWrap);
|
||||||
|
|
||||||
|
const controls = document.createElement('span');
|
||||||
|
controls.className = 'saved-layout-controls';
|
||||||
|
|
||||||
|
const applyBtn = document.createElement('button');
|
||||||
|
applyBtn.type = 'button';
|
||||||
|
applyBtn.className = 'btn-inline';
|
||||||
|
applyBtn.textContent = 'Apply';
|
||||||
|
applyBtn.disabled = !layout.compatible;
|
||||||
|
applyBtn.title = layout.compatible
|
||||||
|
? `Replace the current arrangement with "${layout.name}"`
|
||||||
|
: "This layout was saved for a different orientation's grid";
|
||||||
|
applyBtn.addEventListener('click', () => applySavedLayout(layout));
|
||||||
|
controls.appendChild(applyBtn);
|
||||||
|
|
||||||
|
const renameBtn = document.createElement('button');
|
||||||
|
renameBtn.type = 'button';
|
||||||
|
renameBtn.className = 'icon-btn';
|
||||||
|
renameBtn.textContent = '✎'; // pencil
|
||||||
|
renameBtn.title = 'Rename';
|
||||||
|
renameBtn.addEventListener('click', () => { editingLayoutId = layout.id; renderSavedLayouts(); });
|
||||||
|
controls.appendChild(renameBtn);
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.type = 'button';
|
||||||
|
deleteBtn.className = 'icon-btn';
|
||||||
|
deleteBtn.textContent = '×';
|
||||||
|
deleteBtn.title = 'Delete';
|
||||||
|
deleteBtn.addEventListener('click', () => deleteSavedLayout(layout));
|
||||||
|
controls.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
li.appendChild(controls);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSavedLayouts() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_BASE_API}/layouts`);
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
savedLayouts = (await resp.json()).layouts;
|
||||||
|
renderSavedLayouts();
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrentLayout() {
|
||||||
|
const input = document.getElementById('saved-layout-name');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showStatus(false, 'Give this layout a name first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = savedLayouts.find((l) => l.name === name);
|
||||||
|
if (existing && !confirm(`You already have a saved layout named "${name}" -- overwrite it with the current arrangement?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_BASE_API}/layouts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
input.value = '';
|
||||||
|
showStatus(true, `Saved layout "${name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
} finally {
|
||||||
|
loadSavedLayouts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('save-layout-btn').addEventListener('click', saveCurrentLayout);
|
||||||
|
document.getElementById('saved-layout-name').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') saveCurrentLayout();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function applySavedLayout(layout) {
|
||||||
|
if (!confirm(`Replace the current arrangement with "${layout.name}"? Widgets not in that layout will be removed.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_BASE_API}/layouts/${layout.id}/apply`, { method: 'POST' });
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, `Applied "${layout.name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
} finally {
|
||||||
|
loadWidgets(); // see frame_layout.js -- reloads the placement canvas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameSavedLayout(layout, rawName) {
|
||||||
|
const name = rawName.trim();
|
||||||
|
if (!name || name === layout.name) {
|
||||||
|
editingLayoutId = null;
|
||||||
|
renderSavedLayouts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/layouts/${layout.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Renamed.');
|
||||||
|
editingLayoutId = null;
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
} finally {
|
||||||
|
loadSavedLayouts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSavedLayout(layout) {
|
||||||
|
if (!confirm(`Delete the saved layout "${layout.name}"? This can't be undone.`)) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/layouts/${layout.id}`, { method: 'DELETE' });
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Deleted.');
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
} finally {
|
||||||
|
loadSavedLayouts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSavedLayouts();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+299
-1
@@ -167,6 +167,8 @@ h2.card-title, summary.card-title {
|
|||||||
summary.card-title { cursor: pointer; margin-bottom: 0; }
|
summary.card-title { cursor: pointer; margin-bottom: 0; }
|
||||||
details.card[open] summary.card-title { margin-bottom: 14px; }
|
details.card[open] summary.card-title { margin-bottom: 14px; }
|
||||||
details.card .sub { margin-top: 8px; }
|
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-wrap { overflow-x: auto; margin-top: 14px; }
|
||||||
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
@@ -203,6 +205,12 @@ details.card .sub { margin-top: 8px; }
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
input[type="color"] {
|
||||||
|
width: 44px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -257,10 +265,117 @@ input:focus, select:focus {
|
|||||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
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; }
|
||||||
|
|
||||||
|
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||||
|
.saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; }
|
||||||
|
.saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.saved-layout-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);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.saved-layout-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.saved-layout-name > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.saved-layout-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--warn-bg);
|
||||||
|
color: var(--warn-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||||
|
.saved-layout-controls .btn-inline { margin: 0; }
|
||||||
|
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
|
||||||
|
.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 { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||||
.checkbox-row input { width: auto; margin-top: 0; }
|
.checkbox-row input { width: auto; margin-top: 0; }
|
||||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
.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 {
|
button {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
@@ -276,6 +391,7 @@ button {
|
|||||||
}
|
}
|
||||||
button:hover { background: var(--accent-hover); }
|
button:hover { background: var(--accent-hover); }
|
||||||
button:active { transform: translateY(1px); }
|
button:active { transform: translateY(1px); }
|
||||||
|
button:disabled { opacity: 0.5; cursor: default; pointer-events: none; }
|
||||||
button.btn-inline {
|
button.btn-inline {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
@@ -304,6 +420,111 @@ button.secondary:hover { background: var(--surface-alt); }
|
|||||||
}
|
}
|
||||||
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
|
.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 {
|
code {
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -373,7 +594,12 @@ code {
|
|||||||
}
|
}
|
||||||
.main-col, .side-col { display: flex; flex-direction: column; }
|
.main-col, .side-col { display: flex; flex-direction: column; }
|
||||||
@media (max-width: 860px) {
|
@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. */
|
/* App shell: left sidebar (frame list + account nav) + main content. */
|
||||||
@@ -468,6 +694,78 @@ code {
|
|||||||
}
|
}
|
||||||
.tabs a:hover { color: var(--text); }
|
.tabs a:hover { color: var(--text); }
|
||||||
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
.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 {
|
.control-banner {
|
||||||
display: flex;
|
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,141 @@
|
|||||||
|
// 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_family: document.getElementById('text_font_family').value,
|
||||||
|
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">
|
<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 }}/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>
|
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
|
||||||
</nav>
|
</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,55 @@
|
|||||||
|
<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-editor" id="text-editor" contenteditable="true"
|
||||||
|
data-content='{{ (text_cfg.content if text_cfg else none) | tojson }}'></div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Settings</h2>
|
||||||
|
<form id="text-config-form">
|
||||||
|
<label>Font family
|
||||||
|
<select id="text_font_family">
|
||||||
|
{% for value, label in text_font_families.items() %}
|
||||||
|
<option value="{{ value }}" {% if text_cfg and text_cfg.font_family == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user