From cd46a25f944f018bde1d23e48d074883ec9d63ba Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 23 Jun 2026 12:14:00 +0200 Subject: [PATCH 1/2] feat(books): autocomplete for the series/universe/cycle fields (#179) The cycle/universe/series fields on the book form were free-text inputs: a user had to retype the universe name for every book, and a single different character silently created a brand-new universe (or the wrong field got filled). Give the four fields the same Choices.js shape as the author/publisher pickers. Backend - CollaneController::searchApi gains a `field` param (whitelisted) that maps each field to its real suggestion source: - collana -> collane.nome (tipo=serie) + libri.collana - serie_padre -> collane.nome WHERE tipo='universo' (existing universes) - gruppo_serie -> collane.gruppo_serie - ciclo_serie -> collane.ciclo Tables/columns are whitelisted (never from input); the query value and tipo are bound. Frontend - each field becomes a single-select Choices.js mirrored into a hidden input that submits its value unchanged. Typing >=2 letters proposes existing values; the typed text is always offered too (and committed on blur/Enter) so a genuinely new value can still be created. The ISBN scraper fills collana through the exposed setter so the value shows in the control. New i18n key in all 4 locales. Tests: new issue-179 regression spec (suggest existing + create new, all 4 fields); series-cycles + full-test migrated to set these via the autocomplete. Validated: PHPStan L5, issue-179 5/5, series-cycles 15/15. --- app/Controllers/CollaneController.php | 81 ++++++++---- app/Views/libri/partials/book_form.php | 138 ++++++++++++++++++-- locale/de_DE.json | 3 +- locale/en_US.json | 3 +- locale/fr_FR.json | 3 +- locale/it_IT.json | 3 +- tests/full-test.spec.js | 15 ++- tests/issue-179-series-autocomplete.spec.js | 82 ++++++++++++ tests/series-cycles.spec.js | 25 +++- 9 files changed, 304 insertions(+), 49 deletions(-) create mode 100644 tests/issue-179-series-autocomplete.spec.js diff --git a/app/Controllers/CollaneController.php b/app/Controllers/CollaneController.php index 41d38a0e..354243ae 100644 --- a/app/Controllers/CollaneController.php +++ b/app/Controllers/CollaneController.php @@ -434,7 +434,33 @@ public function removeBook(Request $request, Response $response, mysqli $db): Re */ public function searchApi(Request $request, Response $response, mysqli $db): Response { - $q = trim((string) ($request->getQueryParams()['q'] ?? '')); + $params = $request->getQueryParams(); + $q = trim((string) ($params['q'] ?? '')); + + // Each book-form series field suggests from its own existing values (the + // denormalized libri column) plus, when available, curated collane of the + // matching tipo — so e.g. the "universe" field proposes existing universes + // instead of forcing the user to retype them (#179). The column is + // whitelisted via $fieldMap (never taken from user input) so it can be + // embedded in the SQL safely; everything else is bound. + // Each book-form series field maps to one or more (table, column[, tipo]) + // suggestion sources. Tables/columns are whitelisted here (never taken + // from user input) so they can be embedded in the SQL safely; the query + // value and any tipo filter are bound. This lets e.g. the "universe" + // field propose existing universes (collane.nome WHERE tipo='universo') + // instead of forcing the user to retype them (#179). + $sources = [ + 'collana' => [['t' => 'collane', 'c' => 'nome', 'tipos' => ['serie']], + ['t' => 'libri', 'c' => 'collana', 'tipos' => []]], + 'serie_padre' => [['t' => 'collane', 'c' => 'nome', 'tipos' => ['universo']]], + 'gruppo_serie' => [['t' => 'collane', 'c' => 'gruppo_serie', 'tipos' => []]], + 'ciclo_serie' => [['t' => 'collane', 'c' => 'ciclo', 'tipos' => []]], + ]; + $field = (string) ($params['field'] ?? 'collana'); + if (!isset($sources[$field])) { + $field = 'collana'; + } + $results = []; // SEC2-1 + SEC1-5 (review): require min 2 chars and escape LIKE @@ -443,31 +469,40 @@ public function searchApi(Request $request, Response $response, mysqli $db): Res if (mb_strlen($q) >= 2) { $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q); $search = '%' . $escaped . '%'; - if ($this->hasCollaneTable($db)) { - $stmt = $db->prepare(" - SELECT DISTINCT nome FROM ( - SELECT nome FROM collane WHERE nome LIKE ? - UNION - SELECT collana AS nome FROM libri WHERE collana LIKE ? AND collana IS NOT NULL AND collana != '' AND deleted_at IS NULL - ) AS combined ORDER BY nome LIMIT 10 - "); - if ($stmt) { - $stmt->bind_param('ss', $search, $search); - $stmt->execute(); - $res = $stmt->get_result(); - while ($row = $res->fetch_assoc()) { - $results[] = $row['nome']; + + $hasCollane = $this->hasCollaneTable($db); + $sqlParts = []; + $types = ''; + $binds = []; + + foreach ($sources[$field] as $src) { + if ($src['t'] === 'collane' && !$hasCollane) { + continue; // no collane table — skip its sources + } + $col = $src['c']; + $where = "`{$col}` LIKE ? AND `{$col}` IS NOT NULL AND `{$col}` != ''"; + $types .= 's'; + $binds[] = $search; + if ($src['t'] === 'libri') { + $where .= ' AND deleted_at IS NULL'; + } + if (!empty($src['tipos'])) { + $placeholders = implode(',', array_fill(0, count($src['tipos']), '?')); + $where .= " AND tipo IN ({$placeholders})"; + $types .= str_repeat('s', count($src['tipos'])); + foreach ($src['tipos'] as $t) { + $binds[] = $t; } - $stmt->close(); } - } else { - $stmt = $db->prepare(" - SELECT DISTINCT collana AS nome FROM libri - WHERE collana LIKE ? AND collana IS NOT NULL AND collana != '' AND deleted_at IS NULL - ORDER BY collana LIMIT 10 - "); + $sqlParts[] = "SELECT `{$col}` AS nome FROM {$src['t']} WHERE {$where}"; + } + + if (!empty($sqlParts)) { + $sql = 'SELECT DISTINCT nome FROM (' . implode(' UNION ', $sqlParts) + . ') AS combined ORDER BY nome LIMIT 10'; + $stmt = $db->prepare($sql); if ($stmt) { - $stmt->bind_param('s', $search); + $stmt->bind_param($types, ...$binds); $stmt->execute(); $res = $stmt->get_result(); while ($row = $res->fetch_assoc()) { diff --git a/app/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php index 8c2fd57b..7710ed21 100644 --- a/app/Views/libri/partials/book_form.php +++ b/app/Views/libri/partials/book_form.php @@ -504,13 +504,23 @@
- - + + + +

- - + + + +

@@ -523,8 +533,13 @@

- - + + + +

@@ -535,8 +550,13 @@
- - + + + +
@@ -1125,6 +1145,7 @@ function toggleLibraryThingAccordion() { initializeUppy(); initializeChoicesJS(); initializePublishersChoices(); + initializeSeriesAutocompletes(); initializeSweetAlert(); initializeGeneriDropdowns(); initializeFormValidation(); @@ -2129,6 +2150,96 @@ function initializeSweetAlert() { * * @returns {void} */ +/** + * Single-value series autocompletes (#179): give the "universe / group / cycle / + * series" book-form fields the same Choices.js shape as the author/publisher + * pickers, so existing values are proposed after a couple of letters instead of + * being retyped (a typo no longer silently spawns a new universe). Each control + * is a