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:
@@ -224,6 +224,35 @@
|
||||
replace_icon: 'Remplacer l’icône',
|
||||
no_icon: 'Aucune icône. Intune affichera l’icône par défaut.',
|
||||
hint_icon: 'PNG, exactement 256 × 256, 750 Ko maximum.',
|
||||
hint_any_image: 'N’importe quelle image — redimensionnée en PNG 256 × 256. 750 Ko maximum.',
|
||||
// Recherche d’images sur le web
|
||||
search_web_image: 'Chercher une image sur le web',
|
||||
search_btn: 'Chercher',
|
||||
searching: 'Recherche…',
|
||||
image_results_for: 'Résultats pour',
|
||||
image_pick_hint: 'Cliquez sur une image : elle est téléchargée puis redimensionnée en PNG 256 × 256.',
|
||||
no_image_results: 'Aucun résultat. Essayez un autre terme.',
|
||||
web_search_off: 'Les recherches web sont désactivées sur ce serveur.',
|
||||
unknown_target: 'Cible de recherche inconnue.',
|
||||
printer_not_found: 'Imprimante introuvable.',
|
||||
driver_not_found: 'Pilote introuvable.',
|
||||
type_to_search_image: 'Saisissez un terme à rechercher.',
|
||||
type_to_search_driver: 'Saisissez une marque ou un modèle d’imprimante.',
|
||||
// Pilotes : renommage et icône
|
||||
edit_driver_title: 'Modifier le pilote',
|
||||
section_driver_name: 'Nom',
|
||||
driver_display_name: 'Nom affiché',
|
||||
hint_driver_rename: 'Visible par tout le monde — la bibliothèque de pilotes est partagée. Laissez vide pour afficher le nom du ZIP.',
|
||||
no_driver_icon: 'Aucune icône.',
|
||||
// Recherche de pilotes sur le web
|
||||
find_driver_online: 'Trouver un pilote en ligne',
|
||||
find_driver_intro: 'Cherchez un pilote universel du constructeur, ou un modèle précis. ImpTune n’affiche que des liens : téléchargez le ZIP puis téléversez-le ci-dessus.',
|
||||
driver_search_label: 'Marque ou modèle d’imprimante',
|
||||
search_generic_btn: 'Chercher le pilote universel',
|
||||
search_exact_btn: 'Chercher le modèle exact',
|
||||
searched_for: 'Recherche effectuée pour',
|
||||
driver_search_warning: 'Résultats web bruts, sans filtrage ni validation. Ne téléchargez un pilote que depuis le site du constructeur, puis téléversez le ZIP ci-dessus.',
|
||||
no_driver_results: 'Aucun résultat. Essayez le nom exact du modèle.',
|
||||
back_to_printers_btn: 'Retour aux imprimantes',
|
||||
export_locked: 'Assignez un pilote à cette imprimante pour débloquer les scripts et l’export.',
|
||||
// Edit modal
|
||||
@@ -410,6 +439,35 @@
|
||||
replace_icon: 'Replace icon',
|
||||
no_icon: 'No icon. Intune will show its default.',
|
||||
hint_icon: 'PNG, exactly 256 × 256, 750 KB max.',
|
||||
hint_any_image: 'Any image — resized to 256 × 256 PNG. 750 KB max.',
|
||||
// Web image search
|
||||
search_web_image: 'Search the web for an image',
|
||||
search_btn: 'Search',
|
||||
searching: 'Searching…',
|
||||
image_results_for: 'Results for',
|
||||
image_pick_hint: 'Click an image — it is downloaded and resized to 256 × 256 PNG.',
|
||||
no_image_results: 'No results. Try another search term.',
|
||||
web_search_off: 'Web lookups are disabled on this server.',
|
||||
unknown_target: 'Unknown search target.',
|
||||
printer_not_found: 'Printer not found.',
|
||||
driver_not_found: 'Driver not found.',
|
||||
type_to_search_image: 'Type something to search for.',
|
||||
type_to_search_driver: 'Type a printer model or brand to search for.',
|
||||
// Driver rename + icon
|
||||
edit_driver_title: 'Edit driver',
|
||||
section_driver_name: 'Name',
|
||||
driver_display_name: 'Display name',
|
||||
hint_driver_rename: 'Shared with everyone — the driver library is global. Leave empty to show the ZIP filename.',
|
||||
no_driver_icon: 'No icon yet.',
|
||||
// Driver web search
|
||||
find_driver_online: 'Find a driver online',
|
||||
find_driver_intro: 'Search for a vendor-wide driver, or for an exact model. ImpTune only shows links — you download the ZIP and upload it above.',
|
||||
driver_search_label: 'Brand or printer model',
|
||||
search_generic_btn: 'Find generic driver',
|
||||
search_exact_btn: 'Search exact model',
|
||||
searched_for: 'Searched for',
|
||||
driver_search_warning: 'Unfiltered web results, not a vetted list. Download drivers only from the manufacturer’s own site, then upload the ZIP above.',
|
||||
no_driver_results: 'No results. Try the exact model name instead.',
|
||||
back_to_printers_btn: 'Back to printers',
|
||||
export_locked: 'Assign a driver to this printer to unlock scripts and export.',
|
||||
// Edit modal
|
||||
|
||||
@@ -38,6 +38,44 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if web_search_enabled() %}
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('search') }}
|
||||
<div>
|
||||
<h2 x-data x-text="$store.i18n.t('find_driver_online')">Find a driver online</h2>
|
||||
<p class="sub" x-data x-text="$store.i18n.t('find_driver_intro')">Search for a vendor-wide driver, or for an exact model. ImpTune only shows links — you download the ZIP and upload it above.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{# Two submit buttons, one form: the clicked button's `mode` value is what
|
||||
gets sent, so "generic" and "exact" are one round-trip apart. #}
|
||||
<form hx-get="/web/drivers"
|
||||
hx-target="#driver-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#driver-search-spinner">
|
||||
<label>
|
||||
<span class="label-text" x-data x-text="$store.i18n.t('driver_search_label')">Brand or printer model</span>
|
||||
<input type="search" name="q" list="driver-brands" placeholder="HP LaserJet M404">
|
||||
</label>
|
||||
<datalist id="driver-brands">
|
||||
{% for brand in brands %}<option value="{{ brand }}">{% endfor %}
|
||||
</datalist>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" name="mode" value="generic" x-data>
|
||||
{{ ico.i('search', 14) }}<span x-text="$store.i18n.t('search_generic_btn')">Find generic driver</span>
|
||||
</button>
|
||||
<button type="submit" class="btn ghost" name="mode" value="exact" x-data
|
||||
x-text="$store.i18n.t('search_exact_btn')">Search exact model</button>
|
||||
<span id="driver-search-spinner" class="htmx-indicator" aria-busy="true"
|
||||
x-data x-text="$store.i18n.t('searching')">Searching…</span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="driver-search-results"></div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="eyebrow" style="margin-bottom:.6rem" x-data x-text="$store.i18n.t('driver_library')">Driver library</h2>
|
||||
{% include "partials/driver_list.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
{#
|
||||
Rename + icon dialog for one driver row. `item` is a build_driver_data() entry.
|
||||
|
||||
The rename form and the icon forms are siblings, not nested — a form inside a
|
||||
form is invalid HTML and the browser drops the inner one. The footer's Save
|
||||
reaches the rename form through `form="..."`.
|
||||
|
||||
Trigger is located by `button[onclick*='showModal']` in E2E specs, never by its
|
||||
label: the default language follows navigator.language.
|
||||
#}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
{% set d = item.driver %}
|
||||
<button class="btn ghost sm"
|
||||
onclick="document.getElementById('driver-modal-{{ d.id }}').showModal()"
|
||||
x-data>
|
||||
{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('edit')">Edit</span>
|
||||
</button>
|
||||
|
||||
<dialog id="driver-modal-{{ d.id }}">
|
||||
<article>
|
||||
<div class="dialog-head">
|
||||
<h3 x-data x-text="$store.i18n.t('edit_driver_title')">Edit driver</h3>
|
||||
<button type="button" class="icon-btn close" x-data
|
||||
:aria-label="$store.i18n.t('cancel')"
|
||||
onclick="document.getElementById('driver-modal-{{ d.id }}').close()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<form id="driver-rename-{{ d.id }}"
|
||||
hx-patch="/drivers/{{ d.id }}"
|
||||
hx-target="#driver-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="document.getElementById('driver-modal-{{ d.id }}').close()">
|
||||
<fieldset class="form-section">
|
||||
<legend x-data x-text="$store.i18n.t('section_driver_name')">Name</legend>
|
||||
<label>
|
||||
<span class="label-text" x-data x-text="$store.i18n.t('driver_display_name')">Display name</span>
|
||||
<input type="text" name="display_name" maxlength="120"
|
||||
value="{{ d.display_name or '' }}"
|
||||
placeholder="{{ d.original_filename }}">
|
||||
</label>
|
||||
<span class="hint" x-data x-text="$store.i18n.t('hint_driver_rename')">Shared with everyone — the driver library is global. Leave empty to show the ZIP filename.</span>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<fieldset class="form-section">
|
||||
<legend x-data x-text="$store.i18n.t('icon_section')">Icon</legend>
|
||||
<div id="driver-icon-status-{{ d.id }}">
|
||||
{% if item.has_icon %}
|
||||
<div class="icon-preview">
|
||||
<img src="/drivers/{{ d.id }}/icon" width="56" height="56" alt="">
|
||||
<span class="meta">256×256 PNG</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="dim" x-data x-text="$store.i18n.t('no_driver_icon')">No icon yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form hx-post="/drivers/{{ d.id }}/icon"
|
||||
hx-target="#driver-icon-status-{{ d.id }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data">
|
||||
<div class="uploader">
|
||||
<input type="file" name="file" accept="image/*" required
|
||||
x-data :aria-label="$store.i18n.t('upload_icon')">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn ghost sm" x-data>
|
||||
{{ ico.i('upload', 13) }}<span x-text="$store.i18n.t('upload_icon')">Upload icon</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint" x-data x-text="$store.i18n.t('hint_any_image')">Any image — resized to 256 × 256 PNG. 750 KB max.</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if web_search_enabled() %}
|
||||
<div class="web-picker">
|
||||
<form hx-get="/web/images"
|
||||
hx-target="#driver-image-results-{{ d.id }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#driver-image-spinner-{{ d.id }}">
|
||||
<input type="hidden" name="target" value="driver">
|
||||
<input type="hidden" name="id" value="{{ d.id }}">
|
||||
<label>
|
||||
<span class="label-text" x-data x-text="$store.i18n.t('search_web_image')">Search the web for an image</span>
|
||||
<input type="search" name="q" value="{{ item.image_query }}">
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn ghost sm" x-data>
|
||||
{{ ico.i('search', 13) }}<span x-text="$store.i18n.t('search_btn')">Search</span>
|
||||
</button>
|
||||
<span id="driver-image-spinner-{{ d.id }}" class="htmx-indicator" aria-busy="true"
|
||||
x-data x-text="$store.i18n.t('searching')">Searching…</span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="driver-image-results-{{ d.id }}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="dialog-foot">
|
||||
<button type="button" class="btn ghost" x-data
|
||||
onclick="document.getElementById('driver-modal-{{ d.id }}').close()"
|
||||
x-text="$store.i18n.t('cancel')">Cancel</button>
|
||||
<button type="submit" class="btn" form="driver-rename-{{ d.id }}" x-data
|
||||
x-text="$store.i18n.t('save')">Save</button>
|
||||
</div>
|
||||
</article>
|
||||
</dialog>
|
||||
@@ -30,17 +30,34 @@
|
||||
<th class="col-arch" x-data x-text="$store.i18n.t('architecture')">Architecture</th>
|
||||
<th class="col-used" x-data x-text="$store.i18n.t('th_used_by')">Used by</th>
|
||||
<th class="col-added" x-data x-text="$store.i18n.t('uploaded_at')">Added</th>
|
||||
<th class="col-actions"><span x-data x-text="$store.i18n.t('th_actions')">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in driver_data %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="cell-name mono">{{ item.driver.original_filename }}</span>
|
||||
<span class="cell-sub">
|
||||
{% if item.driver.size_bytes >= 1048576 %}{{ (item.driver.size_bytes / 1048576) | round(1) }} MB{% elif item.driver.size_bytes >= 1024 %}{{ (item.driver.size_bytes / 1024) | round(0) | int }} KB{% else %}{{ item.driver.size_bytes }} B{% endif %}
|
||||
{% if item.driver.inf_filename %} · {{ item.driver.inf_filename }}{% endif %}
|
||||
</span>
|
||||
<div class="cell-with-thumb">
|
||||
{# Placeholder span keeps the id addressable so an icon saved in
|
||||
the dialog can be swapped in out of band. #}
|
||||
<span id="driver-thumb-{{ item.driver.id }}" class="driver-thumb">
|
||||
{% if item.has_icon %}
|
||||
<img src="/drivers/{{ item.driver.id }}/icon" width="24" height="24" alt="">
|
||||
{% endif %}
|
||||
</span>
|
||||
<span>
|
||||
{% if item.driver.display_name %}
|
||||
<span class="cell-name">{{ item.driver.display_name }}</span>
|
||||
<span class="cell-sub mono">{{ item.driver.original_filename }}</span>
|
||||
{% else %}
|
||||
<span class="cell-name mono">{{ item.driver.original_filename }}</span>
|
||||
{% endif %}
|
||||
<span class="cell-sub">
|
||||
{% if item.driver.size_bytes >= 1048576 %}{{ (item.driver.size_bytes / 1048576) | round(1) }} MB{% elif item.driver.size_bytes >= 1024 %}{{ (item.driver.size_bytes / 1024) | round(0) | int }} KB{% else %}{{ item.driver.size_bytes }} B{% endif %}
|
||||
{% if item.driver.inf_filename %} · {{ item.driver.inf_filename }}{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.names %}
|
||||
@@ -77,6 +94,9 @@
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}new</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions col-actions">
|
||||
{% include "partials/driver_edit_modal.html" %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{#
|
||||
Driver download search results — links only. ImpTune never downloads a driver
|
||||
for you: these are raw web results, and the warning below says so plainly.
|
||||
#}
|
||||
{% if hits %}
|
||||
<p class="hint">
|
||||
<span x-data x-text="$store.i18n.t('searched_for')">Searched for</span>
|
||||
<span class="mono">{{ query }}</span>
|
||||
</p>
|
||||
|
||||
<div class="notice warn">
|
||||
<p x-data x-text="$store.i18n.t('driver_search_warning')">
|
||||
Unfiltered web results, not a vetted list. Download drivers only from the
|
||||
manufacturer's own site, then upload the ZIP above.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="link-list">
|
||||
{% for h in hits %}
|
||||
<li>
|
||||
<a href="{{ h.url }}" target="_blank" rel="noopener noreferrer nofollow">{{ h.title }}</a>
|
||||
<span class="cell-sub mono">{{ h.host }}</span>
|
||||
{% if h.snippet %}<p class="dim">{{ h.snippet }}</p>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="dim" x-data x-text="$store.i18n.t('no_driver_results')">No results. Try the exact model name instead.</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{#
|
||||
Web image picker. Each result is a button that POSTs its URL to `post_url`;
|
||||
the server downloads and normalizes it, then returns the icon-status block
|
||||
that `status_target` points at.
|
||||
|
||||
Thumbnails load straight from the search engine's CDN, so the browser talks
|
||||
to it directly — `referrerpolicy` keeps ImpTune's own URL out of that request.
|
||||
#}
|
||||
{% if hits %}
|
||||
<p class="hint">
|
||||
<span x-data x-text="$store.i18n.t('image_results_for')">Results for</span>
|
||||
<span class="mono">{{ query }}</span>
|
||||
</p>
|
||||
<div class="image-grid">
|
||||
{% for h in hits %}
|
||||
<button type="button" class="image-option"
|
||||
hx-post="{{ post_url }}"
|
||||
hx-vals='{{ {"url": h.url} | tojson }}'
|
||||
hx-target="{{ status_target }}"
|
||||
hx-swap="innerHTML"
|
||||
title="{{ h.title }} — {{ h.source }}">
|
||||
<img src="{{ h.thumbnail }}" alt="" loading="lazy" referrerpolicy="no-referrer">
|
||||
<span class="image-meta mono">{{ h.width }}×{{ h.height }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="hint" x-data x-text="$store.i18n.t('image_pick_hint')">Pick one — it is downloaded and resized to 256 × 256 PNG.</p>
|
||||
{% else %}
|
||||
<p class="dim" x-data x-text="$store.i18n.t('no_image_results')">No results. Try another search term.</p>
|
||||
{% endif %}
|
||||
@@ -192,16 +192,40 @@
|
||||
hx-target="#icon-status" hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data">
|
||||
<div class="uploader">
|
||||
<input type="file" name="file" accept="image/png" required
|
||||
<input type="file" name="file" accept="image/*" required
|
||||
x-data :aria-label="$store.i18n.t('upload_icon')">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn ghost sm" x-data>
|
||||
{{ ico.i('upload', 13) }}<span x-text="$store.i18n.t('{{ 'replace_icon' if has_icon else 'upload_icon' }}')">Upload icon</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint" x-data x-text="$store.i18n.t('hint_icon')">PNG, exactly 256 × 256, 750 KB max.</p>
|
||||
<p class="hint" x-data x-text="$store.i18n.t('hint_any_image')">Any image — resized to 256 × 256 PNG. 750 KB max.</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if web_search_enabled() %}
|
||||
<div class="web-picker">
|
||||
<form hx-get="/web/images"
|
||||
hx-target="#printer-image-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#printer-image-spinner">
|
||||
<input type="hidden" name="target" value="printer">
|
||||
<input type="hidden" name="id" value="{{ printer.id }}">
|
||||
<label>
|
||||
<span class="label-text" x-data x-text="$store.i18n.t('search_web_image')">Search the web for an image</span>
|
||||
<input type="search" name="q" value="{{ image_query }}">
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn ghost sm" x-data>
|
||||
{{ ico.i('search', 13) }}<span x-text="$store.i18n.t('search_btn')">Search</span>
|
||||
</button>
|
||||
<span id="printer-image-spinner" class="htmx-indicator" aria-busy="true"
|
||||
x-data x-text="$store.i18n.t('searching')">Searching…</span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="printer-image-results"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user