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:
@@ -1,21 +1,15 @@
|
||||
"""Client CRUD API — POST /clients, GET /clients."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from peewee import IntegrityError
|
||||
|
||||
from imptune.db.models import Client
|
||||
from imptune.templating import templates
|
||||
|
||||
router = APIRouter(prefix="/clients")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
|
||||
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
"""Return an HTMX-friendly error fragment swapped into #client-list."""
|
||||
|
||||
+143
-15
@@ -1,27 +1,31 @@
|
||||
"""Driver upload API — POST /drivers/upload."""
|
||||
"""Driver API — upload, rename, and driver-library icons."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
import imptune.config as _cfg
|
||||
from imptune.db.models import Driver
|
||||
from imptune.api.pages import build_driver_data, printer_counts_by_driver
|
||||
from imptune.db.models import Driver, DriverIcon
|
||||
from imptune.services import websearch
|
||||
from imptune.services.icons import (
|
||||
MAX_ICON_BYTES,
|
||||
ImageError,
|
||||
IconTooLarge,
|
||||
icon_path,
|
||||
set_driver_icon,
|
||||
)
|
||||
from imptune.services.inf_parser import _detect_encoding, parse_inf
|
||||
from imptune.storage.driver_store import DriverStore
|
||||
from imptune.templating import templates
|
||||
|
||||
router = APIRouter(prefix="/drivers")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||
MAX_DISPLAY_NAME = 120
|
||||
|
||||
|
||||
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
@@ -32,6 +36,19 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
)
|
||||
|
||||
|
||||
def _render_driver_list(request: Request, **extra) -> HTMLResponse:
|
||||
"""Re-render the whole `#driver-list` table — the target every form swaps."""
|
||||
drivers = Driver.select().order_by(Driver.uploaded_at.desc())
|
||||
context = {
|
||||
"driver_data": build_driver_data(drivers),
|
||||
"usage": printer_counts_by_driver(request.state.owner),
|
||||
}
|
||||
context.update(extra)
|
||||
return templates.TemplateResponse(
|
||||
request=request, name="partials/driver_list.html", context=context
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_class=HTMLResponse)
|
||||
def upload_driver(
|
||||
request: Request,
|
||||
@@ -103,11 +120,7 @@ def upload_driver(
|
||||
)
|
||||
|
||||
# Build driver_data for template
|
||||
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = []
|
||||
for d in drivers:
|
||||
names = json.loads(d.driver_desc) if d.driver_desc else []
|
||||
driver_data.append({"driver": d, "names": names})
|
||||
driver_data = build_driver_data(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
|
||||
# When called from the printer form, emit primary fragment + OOB select refresh
|
||||
if caller == "printer_form":
|
||||
@@ -128,5 +141,120 @@ def upload_driver(
|
||||
context={
|
||||
"driver_data": driver_data,
|
||||
"parsed": parsed,
|
||||
"usage": printer_counts_by_driver(request.state.owner),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{driver_id}", response_class=HTMLResponse)
|
||||
def rename_driver(
|
||||
request: Request, driver_id: int, display_name: str = Form("")
|
||||
) -> HTMLResponse:
|
||||
"""Set or clear a driver's display name.
|
||||
|
||||
Drivers are global/shared, so this rename is what every Owner sees — the
|
||||
same as the driver row itself. An empty value clears the rename and the
|
||||
listing falls back to the ZIP filename.
|
||||
"""
|
||||
driver = Driver.get_or_none(Driver.id == driver_id)
|
||||
if driver is None:
|
||||
return _error_response(f"Driver {driver_id} not found.", status_code=404)
|
||||
|
||||
name = display_name.strip()
|
||||
if len(name) > MAX_DISPLAY_NAME:
|
||||
return _error_response(
|
||||
f"Name must be at most {MAX_DISPLAY_NAME} characters."
|
||||
)
|
||||
|
||||
driver.display_name = name or None
|
||||
driver.save()
|
||||
return _render_driver_list(request)
|
||||
|
||||
|
||||
def _driver_icon_status(driver_id: int, sha256: str) -> HTMLResponse:
|
||||
"""Inline confirmation for the rename/icon dialog, plus an OOB row refresh.
|
||||
|
||||
The dialog stays open after picking an icon, so the thumbnail in the table
|
||||
row behind it is swapped out of band instead of re-rendering the table and
|
||||
tearing the open `<dialog>` out of the DOM.
|
||||
"""
|
||||
src = f"/drivers/{driver_id}/icon?v={sha256[:8]}"
|
||||
return HTMLResponse(
|
||||
content=(
|
||||
'<p class="ok-note" x-data x-text="$store.i18n.t(\'icon_uploaded\')">'
|
||||
"Icon saved</p>"
|
||||
f'<div class="icon-preview"><img src="{src}" width="56" height="56" alt="">'
|
||||
'<span class="meta">256×256 PNG</span></div>'
|
||||
f'<span id="driver-thumb-{driver_id}" class="driver-thumb" hx-swap-oob="true">'
|
||||
f'<img src="{src}" width="24" height="24" alt=""></span>'
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
def _icon_error(message: str, status_code: int = 422) -> HTMLResponse:
|
||||
return HTMLResponse(
|
||||
content=f"<p class='error-note'>{message}</p>", status_code=status_code
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{driver_id}/icon", response_class=HTMLResponse)
|
||||
def upload_driver_icon(
|
||||
request: Request, driver_id: int, file: UploadFile
|
||||
) -> HTMLResponse:
|
||||
"""Attach an icon to a driver. Normalized to 256x256 PNG like printer icons."""
|
||||
if Driver.get_or_none(Driver.id == driver_id) is None:
|
||||
return _icon_error("Driver not found.", status_code=404)
|
||||
|
||||
data = file.file.read(MAX_ICON_BYTES + 1)
|
||||
try:
|
||||
icon = set_driver_icon(driver_id, data, file.filename or "icon.png")
|
||||
except IconTooLarge as exc:
|
||||
return _icon_error(str(exc))
|
||||
except ImageError:
|
||||
return _icon_error("That file is not a readable image.")
|
||||
|
||||
return _driver_icon_status(driver_id, icon.sha256)
|
||||
|
||||
|
||||
@router.post("/{driver_id}/icon/from-web", response_class=HTMLResponse)
|
||||
def driver_icon_from_web(
|
||||
request: Request, driver_id: int, url: str = Form(...)
|
||||
) -> HTMLResponse:
|
||||
"""Download a search-result image server-side and use it as the driver icon."""
|
||||
if Driver.get_or_none(Driver.id == driver_id) is None:
|
||||
return _icon_error("Driver not found.", status_code=404)
|
||||
|
||||
try:
|
||||
data = websearch.fetch_image(url)
|
||||
except websearch.WebSearchError as exc:
|
||||
return _icon_error(str(exc), status_code=400)
|
||||
|
||||
try:
|
||||
icon = set_driver_icon(
|
||||
driver_id, data, url.rsplit("/", 1)[-1][:120] or "web.png"
|
||||
)
|
||||
except IconTooLarge as exc:
|
||||
return _icon_error(str(exc))
|
||||
except ImageError:
|
||||
return _icon_error("That URL did not return a readable image.")
|
||||
|
||||
return _driver_icon_status(driver_id, icon.sha256)
|
||||
|
||||
|
||||
@router.get("/{driver_id}/icon")
|
||||
def get_driver_icon(driver_id: int) -> Response:
|
||||
"""Serve a driver icon. Not owner-scoped — the driver library is shared."""
|
||||
icon = DriverIcon.get_or_none(DriverIcon.driver == driver_id)
|
||||
if icon is None:
|
||||
return Response(status_code=404)
|
||||
|
||||
path = icon_path(icon.sha256)
|
||||
if not path.exists():
|
||||
return Response(status_code=404)
|
||||
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "private, max-age=300"},
|
||||
)
|
||||
|
||||
+75
-81
@@ -1,99 +1,96 @@
|
||||
"""Icon upload API — POST /printers/{printer_id}/icon."""
|
||||
"""Printer icon API — upload, fetch-from-web, and serve."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, UploadFile
|
||||
from fastapi import APIRouter, Form, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
from PIL import Image
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Icon, Printer
|
||||
from imptune.services import websearch
|
||||
from imptune.services.icons import (
|
||||
MAX_ICON_BYTES,
|
||||
ImageError,
|
||||
IconTooLarge,
|
||||
icon_path,
|
||||
set_printer_icon,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
MAX_ICON_BYTES = 750 * 1024 # 750 KB
|
||||
|
||||
def _status_fragment(printer_id: int, sha256: str) -> HTMLResponse:
|
||||
"""The `#icon-status` block, re-rendered after a successful save.
|
||||
|
||||
`?v=` busts the 5-minute private cache the GET route sets — without it the
|
||||
preview keeps showing the icon that was just replaced.
|
||||
"""
|
||||
return HTMLResponse(
|
||||
content=(
|
||||
# Swapped-in fragments are normally English-only; this one is on the
|
||||
# everyday path, so it goes through the i18n store with the English
|
||||
# text as its fallback body.
|
||||
'<p class="ok-note" x-data x-text="$store.i18n.t(\'icon_uploaded\')">'
|
||||
"Icon uploaded successfully</p>"
|
||||
f'<div class="icon-preview"><img src="/printers/{printer_id}/icon?v={sha256[:8]}"'
|
||||
' width="56" height="56" alt="">'
|
||||
'<span class="meta">256×256 PNG</span></div>'
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 422) -> HTMLResponse:
|
||||
return HTMLResponse(content=f"<p class='error-note'>{message}</p>", status_code=status_code)
|
||||
|
||||
|
||||
def _owned_printer(request: Request, printer_id: int) -> Printer | None:
|
||||
return Printer.get_or_none(
|
||||
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
|
||||
def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResponse:
|
||||
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
|
||||
"""Accept a printer icon, normalize it to 256x256 PNG, and store it.
|
||||
|
||||
Validation rules:
|
||||
- Format: PNG only
|
||||
- Dimensions: exactly 256x256 pixels
|
||||
- Size: at most 750 KB
|
||||
|
||||
Replaces any previously uploaded icon for this printer.
|
||||
Returns an HTMX-friendly HTML fragment.
|
||||
Any raster Pillow can decode is accepted and letterboxed into the box —
|
||||
only an unreadable file or one over 750 KB is rejected. Replaces the
|
||||
printer's previous icon.
|
||||
"""
|
||||
# Check printer exists and belongs to this owner
|
||||
printer = Printer.get_or_none(
|
||||
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
|
||||
)
|
||||
if printer is None:
|
||||
return HTMLResponse(
|
||||
content="<p>Printer not found.</p>",
|
||||
status_code=404,
|
||||
)
|
||||
if _owned_printer(request, printer_id) is None:
|
||||
return HTMLResponse(content="<p>Printer not found.</p>", status_code=404)
|
||||
|
||||
# Read file (read one byte extra to detect oversized files)
|
||||
data = file.file.read(MAX_ICON_BYTES + 1)
|
||||
if len(data) > MAX_ICON_BYTES:
|
||||
return HTMLResponse(
|
||||
content="<p>Icon exceeds 750 KB limit.</p>",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
# Validate with Pillow
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
except Exception:
|
||||
return HTMLResponse(
|
||||
content="<p>Icon must be PNG format.</p>",
|
||||
status_code=422,
|
||||
)
|
||||
icon = set_printer_icon(printer_id, data, file.filename or "icon.png")
|
||||
except IconTooLarge as exc:
|
||||
return _error(str(exc))
|
||||
except ImageError:
|
||||
return _error("That file is not a readable image.")
|
||||
|
||||
if img.format != "PNG":
|
||||
return HTMLResponse(
|
||||
content="<p>Icon must be PNG format.</p>",
|
||||
status_code=422,
|
||||
)
|
||||
return _status_fragment(printer_id, icon.sha256)
|
||||
|
||||
if img.size != (256, 256):
|
||||
return HTMLResponse(
|
||||
content=f"<p>Icon must be 256x256 pixels, got {img.size}.</p>",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
# Store SHA256-addressed on disk. cfg.ICONS_DIR is the same path the
|
||||
# .intunewin export reads from — deriving it a second time from DATA_DIR
|
||||
# would silently diverge if the layout ever changes.
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
icons_dir = Path(cfg.ICONS_DIR)
|
||||
icons_dir.mkdir(parents=True, exist_ok=True)
|
||||
icon_path = icons_dir / sha256
|
||||
icon_path.write_bytes(data)
|
||||
@router.post("/{printer_id}/icon/from-web", response_class=HTMLResponse)
|
||||
def upload_icon_from_web(
|
||||
request: Request, printer_id: int, url: str = Form(...)
|
||||
) -> HTMLResponse:
|
||||
"""Download a search-result image server-side and use it as the icon."""
|
||||
if _owned_printer(request, printer_id) is None:
|
||||
return HTMLResponse(content="<p>Printer not found.</p>", status_code=404)
|
||||
|
||||
# Replace existing Icon record for this printer
|
||||
Icon.delete().where(Icon.printer == printer_id).execute()
|
||||
Icon.create(
|
||||
printer=printer_id,
|
||||
sha256=sha256,
|
||||
original_filename=file.filename or "icon.png",
|
||||
size_bytes=len(data),
|
||||
)
|
||||
try:
|
||||
data = websearch.fetch_image(url)
|
||||
except websearch.WebSearchError as exc:
|
||||
return _error(str(exc), status_code=400)
|
||||
|
||||
return HTMLResponse(
|
||||
content=(
|
||||
'<p class="ok-note">Icon uploaded successfully</p>'
|
||||
f'<div class="icon-preview"><img src="/printers/{printer_id}/icon?v={sha256[:8]}"'
|
||||
f' width="56" height="56" alt=""><span class="meta">{img.size[0]}×{img.size[1]} PNG</span></div>'
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
try:
|
||||
icon = set_printer_icon(printer_id, data, url.rsplit("/", 1)[-1][:120] or "web.png")
|
||||
except IconTooLarge as exc:
|
||||
return _error(str(exc))
|
||||
except ImageError:
|
||||
return _error("That URL did not return a readable image.")
|
||||
|
||||
return _status_fragment(printer_id, icon.sha256)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/icon")
|
||||
@@ -102,22 +99,19 @@ def get_icon(request: Request, printer_id: int) -> Response:
|
||||
|
||||
Owner-scoped: a printer belonging to another owner reads as missing.
|
||||
"""
|
||||
printer = Printer.get_or_none(
|
||||
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
|
||||
)
|
||||
if printer is None:
|
||||
if _owned_printer(request, printer_id) is None:
|
||||
return Response(status_code=404)
|
||||
|
||||
icon = Icon.get_or_none(Icon.printer == printer_id)
|
||||
if icon is None:
|
||||
return Response(status_code=404)
|
||||
|
||||
icon_path = Path(cfg.ICONS_DIR) / icon.sha256
|
||||
if not icon_path.exists():
|
||||
path = icon_path(icon.sha256)
|
||||
if not path.exists():
|
||||
return Response(status_code=404)
|
||||
|
||||
return FileResponse(
|
||||
icon_path,
|
||||
path,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "private, max-age=300"},
|
||||
)
|
||||
|
||||
+35
-14
@@ -1,17 +1,14 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from peewee import JOIN, fn
|
||||
|
||||
from imptune.templating import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
# Group label for printers with no client — the template translates it.
|
||||
UNASSIGNED_GROUP = "Unassigned"
|
||||
|
||||
@@ -57,12 +54,31 @@ def group_printers_by_client(printers) -> dict[str, list]:
|
||||
return ordered
|
||||
|
||||
|
||||
def _driver_data(drivers) -> list[dict]:
|
||||
"""Attach the parsed driver names to each Driver row for template use."""
|
||||
return [
|
||||
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
|
||||
for d in drivers
|
||||
]
|
||||
def build_driver_data(drivers) -> list[dict]:
|
||||
"""Attach parsed driver names + icon presence to each Driver row.
|
||||
|
||||
Shared by every template that lists drivers (library table, printer form
|
||||
select, edit modal) so a rename or a new icon shows up in all of them.
|
||||
"""
|
||||
from imptune.services.icons import driver_icon_ids, driver_search_text
|
||||
from imptune.services.websearch import image_query
|
||||
|
||||
drivers = list(drivers)
|
||||
with_icons = driver_icon_ids(drivers)
|
||||
data = []
|
||||
for d in drivers:
|
||||
names = json.loads(d.driver_desc) if d.driver_desc else []
|
||||
search_text = driver_search_text(d, names)
|
||||
data.append(
|
||||
{
|
||||
"driver": d,
|
||||
"names": names,
|
||||
"has_icon": d.id in with_icons,
|
||||
"search_text": search_text,
|
||||
"image_query": image_query(search_text),
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
@@ -109,13 +125,16 @@ def dashboard(request: Request):
|
||||
def drivers_page(request: Request):
|
||||
from imptune.db.models import Driver
|
||||
|
||||
from imptune.services.websearch import BRAND_SUGGESTIONS
|
||||
|
||||
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="drivers.html",
|
||||
context={
|
||||
"driver_data": _driver_data(drivers),
|
||||
"driver_data": build_driver_data(drivers),
|
||||
"usage": printer_counts_by_driver(request.state.owner),
|
||||
"brands": BRAND_SUGGESTIONS,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -145,7 +164,7 @@ def printers_page(request: Request):
|
||||
context={
|
||||
"grouped": grouped,
|
||||
"clients": clients,
|
||||
"driver_data": _driver_data(all_drivers),
|
||||
"driver_data": build_driver_data(all_drivers),
|
||||
"printer_count": printer_count,
|
||||
"ready_count": ready_count,
|
||||
},
|
||||
@@ -161,13 +180,14 @@ def printers_new_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="printers_new.html",
|
||||
context={"clients": clients, "driver_data": _driver_data(all_drivers)},
|
||||
context={"clients": clients, "driver_data": build_driver_data(all_drivers)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/printers/{printer_id}", response_class=HTMLResponse)
|
||||
def printer_detail(request: Request, printer_id: int):
|
||||
from imptune.db.models import Client, Driver, Icon, Printer
|
||||
from imptune.services.websearch import image_query
|
||||
|
||||
printer = (
|
||||
Printer.select(Printer, Client, Driver)
|
||||
@@ -201,6 +221,7 @@ def printer_detail(request: Request, printer_id: int):
|
||||
"driver_names": driver_names,
|
||||
"has_driver": has_driver,
|
||||
"has_icon": icon is not None,
|
||||
"image_query": image_query(printer.name),
|
||||
"install_cmd": install_cmd,
|
||||
"uninstall_cmd": uninstall_cmd,
|
||||
},
|
||||
@@ -251,7 +272,7 @@ def client_detail(request: Request, client_id: int):
|
||||
"client": client,
|
||||
"grouped": grouped,
|
||||
"clients": clients,
|
||||
"driver_data": _driver_data(all_drivers),
|
||||
"driver_data": build_driver_data(all_drivers),
|
||||
"printer_count": len(printers),
|
||||
"ready_count": sum(1 for p in printers if p.driver_id),
|
||||
},
|
||||
|
||||
+4
-15
@@ -1,22 +1,16 @@
|
||||
"""Printer CRUD API — POST /printers, DELETE /printers/{id}, PATCH /printers/{id}."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from peewee import JOIN
|
||||
|
||||
from imptune.api.pages import group_printers_by_client
|
||||
from imptune.api.pages import build_driver_data, group_printers_by_client
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
from imptune.templating import templates
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
_VALID_DUPLEX = {"OneSided", "LongEdge", "ShortEdge"}
|
||||
_VALID_PAPER = {"A4", "Letter", "Legal"}
|
||||
|
||||
@@ -84,8 +78,6 @@ def _resolve_driver(raw: str) -> tuple[int | None, HTMLResponse | None]:
|
||||
|
||||
def _render_printer_list(request: Request) -> HTMLResponse:
|
||||
"""Query printers with LEFT JOIN on client and render grouped partial."""
|
||||
import json
|
||||
|
||||
owner = request.state.owner
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
@@ -96,11 +88,8 @@ def _render_printer_list(request: Request) -> HTMLResponse:
|
||||
grouped = group_printers_by_client(query)
|
||||
|
||||
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = [
|
||||
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
|
||||
for d in all_drivers
|
||||
]
|
||||
all_drivers = Driver.select().order_by(Driver.uploaded_at.desc())
|
||||
driver_data = build_driver_data(all_drivers)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
"""Owner session routes — backup-key download and restore-on-new-browser."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Owner
|
||||
from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
|
||||
from imptune.templating import templates
|
||||
|
||||
router = APIRouter(prefix="/session")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
|
||||
def _require_cookie_sessions() -> None:
|
||||
"""404 these routes in single-user mode — keys have no meaning without a cookie.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Web lookup routes — image picker and driver-download search.
|
||||
|
||||
Both return HTML fragments for HTMX, never JSON: the results are only ever
|
||||
rendered into a page, and keeping the shaping server-side means the templates
|
||||
own the "unverified source" warnings.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from imptune.db.models import Driver, Printer
|
||||
from imptune.services import websearch
|
||||
from imptune.templating import templates
|
||||
|
||||
router = APIRouter(prefix="/web")
|
||||
|
||||
MAX_RESULTS = 12
|
||||
_TARGETS = ("printer", "driver")
|
||||
|
||||
|
||||
def _notice(key: str, fallback: str, status_code: int = 200) -> HTMLResponse:
|
||||
"""A one-line status fragment that still speaks French.
|
||||
|
||||
Server-rendered fragments are normally English-only, but these are the
|
||||
everyday path (empty search box, disabled feature), so they go through the
|
||||
i18n store like the rest of the picker — Alpine initializes the swapped-in
|
||||
node, and the English body is the fallback if it does not.
|
||||
"""
|
||||
return HTMLResponse(
|
||||
content=f"<p class='dim' x-data x-text=\"$store.i18n.t('{key}')\">{fallback}</p>",
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/images", response_class=HTMLResponse)
|
||||
def search_images(
|
||||
request: Request,
|
||||
q: str = "",
|
||||
target: str = "printer",
|
||||
id: int = 0,
|
||||
) -> HTMLResponse:
|
||||
"""Image results for `q`, each pickable as the icon of `target`/`id`.
|
||||
|
||||
`target` decides which POST endpoint the result buttons hit, so the picker
|
||||
is the same partial for a printer icon and a driver icon.
|
||||
"""
|
||||
if not websearch.enabled():
|
||||
return _notice("web_search_off", "Web lookups are disabled on this server.")
|
||||
if target not in _TARGETS:
|
||||
return _notice("unknown_target", "Unknown search target.", status_code=400)
|
||||
|
||||
# Confirm the caller may write to this entity before spending a search on
|
||||
# it — an unowned printer id must not even reveal that it exists.
|
||||
if target == "printer":
|
||||
owned = Printer.get_or_none(
|
||||
(Printer.id == id) & (Printer.owner == request.state.owner)
|
||||
)
|
||||
if owned is None:
|
||||
return _notice("printer_not_found", "Printer not found.", status_code=404)
|
||||
post_url = f"/printers/{id}/icon/from-web"
|
||||
status_target = "#icon-status"
|
||||
else:
|
||||
if Driver.get_or_none(Driver.id == id) is None:
|
||||
return _notice("driver_not_found", "Driver not found.", status_code=404)
|
||||
post_url = f"/drivers/{id}/icon/from-web"
|
||||
status_target = f"#driver-icon-status-{id}"
|
||||
|
||||
query = q.strip()
|
||||
if not query:
|
||||
return _notice("type_to_search_image", "Type something to search for.")
|
||||
|
||||
hits = websearch.search_images(query, limit=MAX_RESULTS)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/image_results.html",
|
||||
context={
|
||||
"hits": hits,
|
||||
"query": query,
|
||||
"post_url": post_url,
|
||||
"status_target": status_target,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drivers", response_class=HTMLResponse)
|
||||
def search_driver_pages(request: Request, q: str = "", mode: str = "generic") -> HTMLResponse:
|
||||
"""Search the web for a driver download page.
|
||||
|
||||
`mode=generic` rewrites the term into the vendor's universal-driver product
|
||||
name (HP UPD, Xerox Global, …); `mode=exact` searches the text as typed.
|
||||
Results are plain links — nothing is downloaded, and the partial says so.
|
||||
"""
|
||||
if not websearch.enabled():
|
||||
return _notice("web_search_off", "Web lookups are disabled on this server.")
|
||||
|
||||
typed = q.strip()
|
||||
if not typed:
|
||||
return _notice("type_to_search_driver", "Type a printer model or brand to search for.")
|
||||
|
||||
query = websearch.generic_driver_query(typed) if mode == "generic" else typed
|
||||
hits = websearch.search_pages(query, limit=MAX_RESULTS)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/driver_search_results.html",
|
||||
context={
|
||||
"hits": hits,
|
||||
"query": query,
|
||||
"typed": typed,
|
||||
"mode": mode,
|
||||
"brand": websearch.detect_brand(typed),
|
||||
},
|
||||
)
|
||||
@@ -30,6 +30,11 @@ def parse_cookie_mode(raw: str) -> tuple[bool, bool]:
|
||||
|
||||
COOKIE_SECURE, SINGLE_USER = parse_cookie_mode(os.environ.get("COOKIE_SECURE", "true"))
|
||||
|
||||
# Outbound lookups (image search, driver-page search, image download). The only
|
||||
# feature that leaves the box — set false for an air-gapped deployment and the
|
||||
# UI hides every search control instead of timing out on each one.
|
||||
WEB_SEARCH = os.environ.get("WEB_SEARCH", "true").strip().lower() != "false"
|
||||
|
||||
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
|
||||
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
|
||||
ICONS_DIR = str(Path(DATA_DIR) / "icons")
|
||||
|
||||
+18
-2
@@ -16,7 +16,7 @@ def init_db() -> None:
|
||||
Closes any existing connection before re-initializing so that test
|
||||
fixtures can monkeypatch DB_PATH between test runs.
|
||||
"""
|
||||
from imptune.db.models import Client, Driver, Owner, Printer, Icon
|
||||
from imptune.db.models import Client, Driver, DriverIcon, Owner, Printer, Icon
|
||||
|
||||
# Re-read DB_PATH each time so tests can patch imptune.config.DB_PATH
|
||||
import imptune.config as cfg
|
||||
@@ -33,8 +33,24 @@ def init_db() -> None:
|
||||
},
|
||||
)
|
||||
db.connect(reuse_if_open=True)
|
||||
db.create_tables([Owner, Client, Driver, Printer, Icon], safe=True)
|
||||
db.create_tables([Owner, Client, Driver, Printer, Icon, DriverIcon], safe=True)
|
||||
_migrate_owner_column(cfg.DATA_DIR)
|
||||
_add_missing_columns()
|
||||
|
||||
|
||||
def _add_missing_columns() -> None:
|
||||
"""Add columns introduced after a DB was first created.
|
||||
|
||||
`create_tables(safe=True)` skips an existing table entirely, so a new field
|
||||
on an old model never lands without this. Plain ADD COLUMN of a nullable
|
||||
field — no backfill needed.
|
||||
"""
|
||||
added: dict[str, str] = {"driver": "display_name VARCHAR(255)"}
|
||||
for table, column_def in added.items():
|
||||
column = column_def.split()[0]
|
||||
columns = {row[1] for row in db.execute_sql(f"PRAGMA table_info({table})")}
|
||||
if column not in columns:
|
||||
db.execute_sql(f"ALTER TABLE {table} ADD COLUMN {column_def}")
|
||||
|
||||
|
||||
def _migrate_owner_column(data_dir: str) -> None:
|
||||
|
||||
@@ -58,10 +58,18 @@ class Driver(BaseModel):
|
||||
inf_filename = CharField(null=True)
|
||||
architecture = CharField(null=True)
|
||||
has_cat_file = BooleanField(default=False)
|
||||
# Operator-chosen label. Drivers are global/shared, so a rename is visible
|
||||
# to every Owner — same as the rest of this row.
|
||||
display_name = CharField(null=True)
|
||||
|
||||
class Meta:
|
||||
table_name = "driver"
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""What the UI shows: the rename if there is one, else the ZIP name."""
|
||||
return self.display_name or self.original_filename
|
||||
|
||||
|
||||
class Printer(BaseModel):
|
||||
"""Printer configuration record."""
|
||||
@@ -94,3 +102,21 @@ class Icon(BaseModel):
|
||||
|
||||
class Meta:
|
||||
table_name = "icon"
|
||||
|
||||
|
||||
class DriverIcon(BaseModel):
|
||||
"""Icon image for a driver package (one per driver).
|
||||
|
||||
Separate table rather than a nullable FK on `Icon`: printer icons ship in
|
||||
the `.intunewin` export and driver icons are library decoration only, so
|
||||
the two never share a query. Global/shared, like `Driver` itself.
|
||||
"""
|
||||
|
||||
driver = ForeignKeyField(Driver, unique=True, backref="icons", on_delete="CASCADE")
|
||||
sha256 = CharField()
|
||||
original_filename = CharField()
|
||||
size_bytes = IntegerField()
|
||||
uploaded_at = DateTimeField(default=_utcnow)
|
||||
|
||||
class Meta:
|
||||
table_name = "driver_icon"
|
||||
|
||||
+13
-1
@@ -5,7 +5,18 @@ from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from imptune.api import clients, drivers, health, icons, pages, packages, printers, scripts, session
|
||||
from imptune.api import (
|
||||
clients,
|
||||
drivers,
|
||||
health,
|
||||
icons,
|
||||
packages,
|
||||
pages,
|
||||
printers,
|
||||
scripts,
|
||||
session,
|
||||
web,
|
||||
)
|
||||
from imptune.config import DATA_DIR, DRIVERS_DIR, ICONS_DIR
|
||||
from imptune.db.database import db, init_db
|
||||
from imptune.services.session import OwnerSessionMiddleware
|
||||
@@ -40,3 +51,4 @@ app.include_router(scripts.router)
|
||||
app.include_router(packages.router)
|
||||
app.include_router(icons.router)
|
||||
app.include_router(session.router)
|
||||
app.include_router(web.router)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Icon storage shared by printer icons and driver icons.
|
||||
|
||||
Both live in `ICONS_DIR`, content-addressed by the SHA256 of the *normalized*
|
||||
bytes, so two entities that end up with the same 256x256 PNG share one file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Driver, DriverIcon, Icon
|
||||
from imptune.services.image_utils import ImageError, normalize_icon
|
||||
|
||||
MAX_ICON_BYTES = 750 * 1024 # 750 KB, on the bytes as uploaded
|
||||
|
||||
__all__ = [
|
||||
"ImageError",
|
||||
"IconTooLarge",
|
||||
"MAX_ICON_BYTES",
|
||||
"driver_icon_ids",
|
||||
"icon_path",
|
||||
"set_driver_icon",
|
||||
"set_printer_icon",
|
||||
"store_bytes",
|
||||
]
|
||||
|
||||
|
||||
class IconTooLarge(ValueError):
|
||||
"""Raised when the source file is over `MAX_ICON_BYTES`."""
|
||||
|
||||
|
||||
def icon_path(sha256: str) -> Path:
|
||||
"""On-disk location of a stored icon. Read `cfg` late — tests patch it."""
|
||||
return Path(cfg.ICONS_DIR) / sha256
|
||||
|
||||
|
||||
def store_bytes(data: bytes) -> tuple[str, int]:
|
||||
"""Normalize `data` to a 256x256 PNG, write it, return `(sha256, size)`.
|
||||
|
||||
Raises `IconTooLarge` or `ImageError` before anything touches the disk.
|
||||
"""
|
||||
if len(data) > MAX_ICON_BYTES:
|
||||
raise IconTooLarge(f"Icon exceeds {MAX_ICON_BYTES // 1024} KB limit.")
|
||||
|
||||
png = normalize_icon(data)
|
||||
sha256 = hashlib.sha256(png).hexdigest()
|
||||
path = icon_path(sha256)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(png)
|
||||
return sha256, len(png)
|
||||
|
||||
|
||||
def set_printer_icon(printer_id: int, data: bytes, filename: str) -> Icon:
|
||||
"""Replace this printer's icon. One `Icon` row per printer, always."""
|
||||
sha256, size = store_bytes(data)
|
||||
Icon.delete().where(Icon.printer == printer_id).execute()
|
||||
return Icon.create(
|
||||
printer=printer_id,
|
||||
sha256=sha256,
|
||||
original_filename=filename or "icon.png",
|
||||
size_bytes=size,
|
||||
)
|
||||
|
||||
|
||||
def set_driver_icon(driver_id: int, data: bytes, filename: str) -> DriverIcon:
|
||||
"""Replace this driver's icon. Driver rows are global, so this icon is too."""
|
||||
sha256, size = store_bytes(data)
|
||||
DriverIcon.delete().where(DriverIcon.driver == driver_id).execute()
|
||||
return DriverIcon.create(
|
||||
driver=driver_id,
|
||||
sha256=sha256,
|
||||
original_filename=filename or "icon.png",
|
||||
size_bytes=size,
|
||||
)
|
||||
|
||||
|
||||
def driver_icon_ids(drivers=None) -> set[int]:
|
||||
"""Ids of drivers that have an icon, for the library listing."""
|
||||
query = DriverIcon.select(DriverIcon.driver)
|
||||
if drivers is not None:
|
||||
ids = [d.id for d in drivers] or [0]
|
||||
query = query.where(DriverIcon.driver.in_(ids))
|
||||
return {row.driver_id for row in query}
|
||||
|
||||
|
||||
def driver_search_text(driver: Driver, names: list[str]) -> str:
|
||||
"""Best guess at what this driver is, for prefilling a search box."""
|
||||
if driver.display_name:
|
||||
return driver.display_name
|
||||
if names:
|
||||
return names[0]
|
||||
return Path(driver.original_filename).stem.replace("_", " ").replace("-", " ")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Icon normalization — anything Pillow can decode becomes a 256x256 PNG.
|
||||
|
||||
Intune wants exactly 256x256 PNG, but nothing a human picks (a photo from the
|
||||
web, a vendor logo, a screenshot) arrives that way. Rather than reject it, fit
|
||||
it into the box: aspect ratio preserved, transparent letterbox around it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
ICON_SIZE = (256, 256)
|
||||
|
||||
|
||||
class ImageError(ValueError):
|
||||
"""Raised when the bytes are not a decodable raster image."""
|
||||
|
||||
|
||||
def normalize_icon(data: bytes) -> bytes:
|
||||
"""Return `data` as exactly-256x256 PNG bytes.
|
||||
|
||||
A file that *already* is a 256x256 PNG is returned byte-identical — the
|
||||
icon store is content-addressed by SHA256, so re-encoding an unchanged
|
||||
upload would move it to a new path on every save for no reason.
|
||||
|
||||
Raises `ImageError` if the bytes cannot be decoded as an image.
|
||||
"""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.load()
|
||||
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
raise ImageError("Not a readable image file.") from exc
|
||||
|
||||
if img.format == "PNG" and img.size == ICON_SIZE:
|
||||
return data
|
||||
|
||||
# `contain` scales down to fit inside the box without cropping; a smaller
|
||||
# source is left at its own size rather than blown up into mush.
|
||||
fitted = ImageOps.contain(img.convert("RGBA"), ICON_SIZE, Image.LANCZOS)
|
||||
|
||||
canvas = Image.new("RGBA", ICON_SIZE, (0, 0, 0, 0))
|
||||
canvas.paste(
|
||||
fitted,
|
||||
((ICON_SIZE[0] - fitted.width) // 2, (ICON_SIZE[1] - fitted.height) // 2),
|
||||
)
|
||||
|
||||
out = io.BytesIO()
|
||||
canvas.save(out, format="PNG", optimize=True)
|
||||
return out.getvalue()
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Web lookups: image search, driver-page search, and guarded image download.
|
||||
|
||||
The only place in ImpTune that talks to the internet. Everything here is
|
||||
best-effort: a failed lookup returns an empty list or raises `WebSearchError`,
|
||||
and the rest of the app keeps working offline.
|
||||
|
||||
DuckDuckGo is scraped rather than called through an API — no key, no signup.
|
||||
That makes it fragile by nature: `search_images` / `search_pages` swallow parse
|
||||
failures and return `[]` instead of a 500. Kill the whole feature with
|
||||
`WEB_SEARCH=false`.
|
||||
|
||||
Fetching a URL the user picked means the *server* makes the request, so
|
||||
`fetch_image` refuses anything that resolves to a private address. ImpTune runs
|
||||
on the same LAN as the printers it configures — an unguarded fetcher would be a
|
||||
port scanner for whoever can reach the UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qs, quote_plus, urlparse
|
||||
|
||||
import imptune.config as cfg
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/125.0 Safari/537.36"
|
||||
)
|
||||
TIMEOUT_SECONDS = 8
|
||||
MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
MAX_HTML_BYTES = 2 * 1024 * 1024
|
||||
MAX_REDIRECTS = 3
|
||||
|
||||
DDG_HOME = "https://duckduckgo.com/"
|
||||
DDG_IMAGES = "https://duckduckgo.com/i.js"
|
||||
DDG_HTML = "https://html.duckduckgo.com/html/"
|
||||
|
||||
# Brands with a real vendor-wide driver. The search term is what a technician
|
||||
# would type; the live results are whatever the engine returns for it.
|
||||
GENERIC_DRIVER_TERMS: dict[str, str] = {
|
||||
"hp": "HP Universal Print Driver PCL6 download",
|
||||
"konica": "Konica Minolta Universal PCL Print Driver download",
|
||||
"konica minolta": "Konica Minolta Universal PCL Print Driver download",
|
||||
"xerox": "Xerox Global Print Driver download",
|
||||
"ricoh": "Ricoh PCL6 Universal Print Driver download",
|
||||
"lexmark": "Lexmark Universal Print Driver download",
|
||||
"brother": "Brother universal printer driver download",
|
||||
"canon": "Canon Generic PCL6 Printer Driver download",
|
||||
"epson": "Epson Universal Print Driver download",
|
||||
"kyocera": "Kyocera Classic Universal Print Driver download",
|
||||
"sharp": "Sharp Universal Print Driver download",
|
||||
"toshiba": "Toshiba Universal Printer 2 driver download",
|
||||
"oki": "OKI PCL universal print driver download",
|
||||
"samsung": "Samsung Universal Print Driver download",
|
||||
"dell": "Dell universal printer driver download",
|
||||
}
|
||||
|
||||
|
||||
# Display spellings for the search box's datalist, in the order a French shop
|
||||
# is likeliest to need them.
|
||||
BRAND_SUGGESTIONS = (
|
||||
"HP",
|
||||
"Konica Minolta",
|
||||
"Xerox",
|
||||
"Ricoh",
|
||||
"Canon",
|
||||
"Kyocera",
|
||||
"Brother",
|
||||
"Lexmark",
|
||||
"Epson",
|
||||
"Sharp",
|
||||
"Toshiba",
|
||||
"OKI",
|
||||
"Samsung",
|
||||
"Dell",
|
||||
)
|
||||
|
||||
|
||||
class WebSearchError(RuntimeError):
|
||||
"""A lookup or download could not be completed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageHit:
|
||||
"""One image search result."""
|
||||
|
||||
url: str
|
||||
thumbnail: str
|
||||
title: str
|
||||
width: int
|
||||
height: int
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageHit:
|
||||
"""One web page search result."""
|
||||
|
||||
url: str
|
||||
title: str
|
||||
snippet: str
|
||||
host: str
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
"""False when the deployment opted out of outbound requests."""
|
||||
return bool(getattr(cfg, "WEB_SEARCH", True))
|
||||
|
||||
|
||||
def _require_enabled() -> None:
|
||||
if not enabled():
|
||||
raise WebSearchError("Web lookups are disabled (WEB_SEARCH=false).")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SSRF guard
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _is_public_ip(raw: str) -> bool:
|
||||
try:
|
||||
ip = ipaddress.ip_address(raw)
|
||||
except ValueError:
|
||||
return False
|
||||
return not (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def assert_fetchable(url: str) -> None:
|
||||
"""Raise `WebSearchError` unless `url` is an https(s) URL on a public host.
|
||||
|
||||
Resolution happens here *and* the socket connects by hostname afterwards, so
|
||||
a DNS-rebinding host could still slip through between the two. Accepted:
|
||||
the payload is decoded by Pillow and discarded unless it is an image.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise WebSearchError("Only http and https URLs can be fetched.")
|
||||
if not parsed.hostname:
|
||||
raise WebSearchError("URL has no host.")
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(parsed.hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise WebSearchError(f"Cannot resolve {parsed.hostname}.") from exc
|
||||
|
||||
for info in infos:
|
||||
if not _is_public_ip(info[4][0]):
|
||||
raise WebSearchError(
|
||||
f"{parsed.hostname} resolves to a private address — refused."
|
||||
)
|
||||
|
||||
|
||||
class _GuardedRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Re-run the SSRF guard on every redirect target."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
||||
assert_fetchable(newurl)
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
_opener = urllib.request.build_opener(_GuardedRedirectHandler)
|
||||
|
||||
|
||||
def _get(
|
||||
url: str,
|
||||
*,
|
||||
referer: str | None = None,
|
||||
max_bytes: int,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> bytes:
|
||||
"""GET `url` with the SSRF guard applied and the response body capped."""
|
||||
assert_fetchable(url)
|
||||
headers = {"User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.7"}
|
||||
if referer:
|
||||
headers["Referer"] = referer
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with _opener.open(request, timeout=TIMEOUT_SECONDS) as response:
|
||||
return response.read(max_bytes + 1)[: max_bytes + 1]
|
||||
except urllib.error.URLError as exc:
|
||||
raise WebSearchError(f"Request failed: {exc}") from exc
|
||||
except (TimeoutError, socket.timeout) as exc: # noqa: UP041 — socket.timeout on 3.9 paths
|
||||
raise WebSearchError("Request timed out.") from exc
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Image search
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_VQD_RE = re.compile(rb"vqd=[\"']?([\w-]{8,})[\"']?")
|
||||
|
||||
# The image endpoint is an XHR route: without the fetch metadata a real browser
|
||||
# would send, it answers 403 even with a valid vqd token. Verified header set —
|
||||
# dropping any of these brings the 403 back.
|
||||
_XHR_HEADERS = {
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
}
|
||||
|
||||
|
||||
def _image_vqd(query: str) -> str:
|
||||
"""Scrape the per-query token DuckDuckGo's image endpoint demands."""
|
||||
body = _get(
|
||||
f"{DDG_HOME}?q={quote_plus(query)}&iax=images&ia=images",
|
||||
max_bytes=MAX_HTML_BYTES,
|
||||
)
|
||||
match = _VQD_RE.search(body)
|
||||
if not match:
|
||||
raise WebSearchError("Could not read the search token.")
|
||||
return match.group(1).decode("ascii", "ignore")
|
||||
|
||||
|
||||
def search_images(query: str, limit: int = 12) -> list[ImageHit]:
|
||||
"""Image results for `query`, newest-first as the engine ranked them.
|
||||
|
||||
Returns `[]` on any parse or transport failure — a dead scrape must not
|
||||
take a page down with it.
|
||||
"""
|
||||
_require_enabled()
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
try:
|
||||
vqd = _image_vqd(query)
|
||||
raw = _get(
|
||||
f"{DDG_IMAGES}?l=us-en&o=json&q={quote_plus(query)}&vqd={vqd}&f=,,,&p=1",
|
||||
referer=DDG_HOME,
|
||||
max_bytes=MAX_HTML_BYTES,
|
||||
extra_headers=_XHR_HEADERS,
|
||||
)
|
||||
payload = json.loads(raw.decode("utf-8", "replace"))
|
||||
except (WebSearchError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
hits: list[ImageHit] = []
|
||||
for item in payload.get("results", []):
|
||||
url = item.get("image") or ""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
continue
|
||||
hits.append(
|
||||
ImageHit(
|
||||
url=url,
|
||||
thumbnail=item.get("thumbnail") or url,
|
||||
title=(item.get("title") or "").strip(),
|
||||
width=int(item.get("width") or 0),
|
||||
height=int(item.get("height") or 0),
|
||||
source=urlparse(url).hostname or "",
|
||||
)
|
||||
)
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
def fetch_image(url: str) -> bytes:
|
||||
"""Download `url` and return its bytes (caller validates it is an image)."""
|
||||
_require_enabled()
|
||||
data = _get(url, max_bytes=MAX_IMAGE_BYTES)
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise WebSearchError("Image is larger than 8 MB.")
|
||||
if not data:
|
||||
raise WebSearchError("Server returned an empty response.")
|
||||
return data
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Page search (driver downloads)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_RESULT_RE = re.compile(
|
||||
r'<a[^>]+class="result__a"[^>]+href="(?P<href>[^"]+)"[^>]*>(?P<title>.*?)</a>',
|
||||
re.DOTALL,
|
||||
)
|
||||
_SNIPPET_RE = re.compile(
|
||||
r'<a[^>]+class="result__snippet"[^>]*>(?P<snippet>.*?)</a>', re.DOTALL
|
||||
)
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _text(fragment: str) -> str:
|
||||
return html.unescape(_TAG_RE.sub("", fragment)).strip()
|
||||
|
||||
|
||||
def _snippet_text(fragment: str) -> str:
|
||||
"""Snippet text, minus the literal "undefined " DuckDuckGo's own template
|
||||
prefixes onto some results."""
|
||||
text = _text(fragment)
|
||||
return text[len("undefined ") :].lstrip() if text.startswith("undefined ") else text
|
||||
|
||||
|
||||
def _unwrap(href: str) -> str:
|
||||
"""Unwrap DuckDuckGo's `/l/?uddg=` click-tracking redirect."""
|
||||
if href.startswith("//"):
|
||||
href = "https:" + href
|
||||
parsed = urlparse(href)
|
||||
if "duckduckgo.com" in (parsed.hostname or "") and parsed.path.startswith("/l/"):
|
||||
target = parse_qs(parsed.query).get("uddg", [""])[0]
|
||||
if target:
|
||||
return target
|
||||
return href
|
||||
|
||||
|
||||
def search_pages(query: str, limit: int = 8) -> list[PageHit]:
|
||||
"""Web page results for `query`. `[]` on any failure, same as image search."""
|
||||
_require_enabled()
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
try:
|
||||
body = _get(
|
||||
f"{DDG_HTML}?q={quote_plus(query)}", max_bytes=MAX_HTML_BYTES
|
||||
).decode("utf-8", "replace")
|
||||
except WebSearchError:
|
||||
return []
|
||||
|
||||
snippets = [_snippet_text(m.group("snippet")) for m in _SNIPPET_RE.finditer(body)]
|
||||
hits: list[PageHit] = []
|
||||
for index, match in enumerate(_RESULT_RE.finditer(body)):
|
||||
url = _unwrap(html.unescape(match.group("href")))
|
||||
if not url.startswith(("http://", "https://")):
|
||||
continue
|
||||
hits.append(
|
||||
PageHit(
|
||||
url=url,
|
||||
title=_text(match.group("title")) or url,
|
||||
snippet=snippets[index] if index < len(snippets) else "",
|
||||
host=urlparse(url).hostname or "",
|
||||
)
|
||||
)
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Query builders
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def detect_brand(text: str) -> str | None:
|
||||
"""Longest brand in `GENERIC_DRIVER_TERMS` that appears in `text`."""
|
||||
lowered = (text or "").lower()
|
||||
matches = [brand for brand in GENERIC_DRIVER_TERMS if brand in lowered]
|
||||
return max(matches, key=len) if matches else None
|
||||
|
||||
|
||||
def generic_driver_query(text: str) -> str:
|
||||
"""Search term for a vendor-wide driver, derived from a model name.
|
||||
|
||||
A recognised brand gets that vendor's actual product name ("HP Universal
|
||||
Print Driver PCL6"); anything else falls back to the raw model plus
|
||||
"universal print driver download", which is what a technician would type.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
brand = detect_brand(text)
|
||||
if brand:
|
||||
return GENERIC_DRIVER_TERMS[brand]
|
||||
return f"{text} universal print driver download".strip()
|
||||
|
||||
|
||||
def image_query(text: str) -> str:
|
||||
"""Default search term for a printer or driver icon."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return f"{text} printer"
|
||||
+102
-1
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""The one Jinja2 environment every router renders through.
|
||||
|
||||
Each `api/*` module used to build its own `Jinja2Templates`, which meant a
|
||||
template global had to be declared five times or a page rendered by the wrong
|
||||
router would blow up on an undefined name. One instance, one place.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from imptune.services import websearch
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||
|
||||
# Passed as the function, not its result: `WEB_SEARCH` is read per call, so a
|
||||
# test (or a config reload) that flips it takes effect without a restart.
|
||||
templates.env.globals["web_search_enabled"] = websearch.enabled
|
||||
Reference in New Issue
Block a user