feat: driver rename, driver icons, web image + driver search

Driver rename and icons: `Driver.display_name` plus a `DriverIcon` table, both
global/shared like the `Driver` row they hang off, so a rename or an icon is
what every Owner sees. The rename/icon dialog keeps its forms as siblings
(nested forms are invalid HTML) and the icon routes return an `hx-swap-oob`
thumbnail refresh rather than re-rendering the table, which would tear the open
`<dialog>` out of the DOM.

Web image picker: `GET /web/images` renders a pickable grid for a printer or a
driver icon, with the search term prefilled from the entity name and editable.
Picking one downloads it server-side and normalizes it.

Driver download search: `GET /web/drivers` searches for a vendor-wide driver
(the term is rewritten into the vendor's real product name for 15 brands) or for
the exact model as typed. Links only — nothing is downloaded, and the fragment
says the results are unvetted.

Icon uploads no longer reject off-size or non-PNG files: `normalize_icon()`
letterboxes any decodable raster into a 256x256 PNG. An already-exact 256x256
PNG is returned byte-identical, because icon storage is content-addressed and
re-encoding would move the file on every save.

`fetch_image()` makes the request from the server, so `assert_fetchable()`
refuses any URL resolving to a private, loopback, or link-local address, and
re-runs on every redirect. ImpTune sits on the same LAN as the printers it
configures; an unguarded fetcher would be a port scanner for anyone who can
reach the UI.

DuckDuckGo is scraped, not called through an API — no key needed, but fragile,
so both search functions swallow parse failures and return [] instead of 500ing
a page. `WEB_SEARCH=false` disables every outbound request and hides the
controls, for air-gapped installs.

Also: one shared `Jinja2Templates` in `templating.py` instead of five per-router
instances, so a template global is declared once; `_add_missing_columns()` in
`database.py` adds new nullable columns to a pre-existing table, which
`create_tables(safe=True)` skips; `db_env` in test_db.py now closes its
connection on teardown, or the next test's ORM writes land in the previous
test's DB file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 10:44:47 +02:00
co-authored by Claude Opus 5
parent 3f9cd1f266
commit 2c06806814
34 changed files with 2516 additions and 162 deletions
+102 -1
View File
@@ -1263,6 +1263,106 @@ div.error > p {
.icon-preview .meta { font-size: .812rem; color: var(--im-text-dim); }
.error-note {
margin: 0 0 .8rem;
font-size: .875rem;
color: var(--im-danger);
}
/* Driver-library thumbnail: fixed box whether or not an icon exists, so the
name column does not shift when one is added out of band. */
.cell-with-thumb {
display: flex;
align-items: flex-start;
gap: .6rem;
}
.driver-thumb {
flex: none;
width: 24px;
height: 24px;
margin-top: .1rem;
border-radius: var(--im-r-sm);
background: var(--im-surface-3);
overflow: hidden;
}
.driver-thumb img { width: 24px; height: 24px; display: block; }
/* ==========================================================================
Web lookups — image picker and driver-page results
========================================================================== */
.web-picker {
margin-top: .9rem;
padding-top: .9rem;
border-top: 1px dashed var(--im-line);
}
.web-picker label { margin: 0 0 .5rem; }
.web-picker .btn-row { margin: 0; }
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
gap: .5rem;
margin: .6rem 0;
}
/* Each result is a button, not an <img> with a click handler — keyboard
selection and focus rings come for free. */
.image-option {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1;
padding: 0;
border: 1px solid var(--im-line);
border-radius: var(--im-r-sm);
background: var(--im-surface-3);
overflow: hidden;
cursor: pointer;
}
.image-option:hover,
.image-option:focus-visible { border-color: var(--im-accent); }
.image-option img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.image-option .image-meta {
position: absolute;
inset: auto 0 0 0;
padding: .1rem .25rem;
font-size: .625rem;
line-height: 1.4;
color: #fff;
background: rgba(0, 0, 0, .55);
}
.link-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: .7rem;
}
.link-list li {
padding-bottom: .7rem;
border-bottom: 1px solid var(--im-line);
}
.link-list li:last-child { border-bottom: 0; padding-bottom: 0; }
.link-list a { font-size: .906rem; font-weight: 560; }
.link-list p { margin: .2rem 0 0; font-size: .812rem; }
.link-list .cell-sub { display: block; }
/* ==========================================================================
Dialogs
========================================================================== */
@@ -1406,7 +1506,8 @@ details.more-pills > summary::marker { content: ""; }
sidebar there is only room for the file and its parsed names. */
.form-aside .data-table .col-arch,
.form-aside .data-table .col-used,
.form-aside .data-table .col-added { display: none; }
.form-aside .data-table .col-added,
.form-aside .data-table .col-actions { display: none; }
.uploader label { margin: 0; }
.uploader .hint { margin-top: 0; }