From 8a2742edbf8d93776058785bb1d6cd60327de9c0 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 26 Oct 2026 10:08:07 +0100 Subject: [PATCH 01/24] Search: quoted terms match the exact spelling (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text columns use utf8mb4_unicode_ci, which folds accents, so a search for "haffner" also returned "Häffner". That tolerance stays the default: it lets people find Müller by typing muller. Wrapping a term in quotes now opts out: "haffner" matches only Haffner, "Häffner" only Häffner, and a quoted group with spaces ("thomas mann") is an exact phrase. Case is still ignored, an unclosed quote counts as closed, and typographic quotes („…“, “…”, «…») work like straight ones. The exact comparison is LOWER(expr) COLLATE utf8mb4_bin LIKE LOWER(?): the accent-sensitive case-insensitive collations have different names on MySQL 8 and MariaDB and do not exist on older servers, utf8mb4_bin exists everywhere. In the book search FULLTEXT still pre-filters the rows (quoted words required as whole words), then the binary LIKE keeps the exact spelling. New App\Support\ExactSearch holds the tokenizer and the SQL helpers. It is used by the book search (catalogue, admin book list, autocomplete, preview, mobile API, relevance ranking, pre-migration fallback), the admin author, publisher, user and loan lists, the unified search author/publisher results, the author/publisher/user pickers and the mobile API author/publisher filters. --- app/Controllers/AutoriApiController.php | 45 ++-- app/Controllers/EditoriApiController.php | 44 ++-- app/Controllers/PrestitiApiController.php | 17 +- app/Controllers/SearchController.php | 121 ++++----- app/Controllers/UtentiApiController.php | 30 +-- app/Support/ExactSearch.php | 160 ++++++++++++ app/Support/SearchIndexBuilder.php | 110 +++++---- .../src/Controllers/CatalogController.php | 38 +-- tests/exact-quoted-search-501.unit.php | 229 ++++++++++++++++++ tests/issue-237-regression.unit.php | 23 +- tests/issue-501-exact-quoted-search.spec.js | 161 ++++++++++++ 11 files changed, 762 insertions(+), 216 deletions(-) create mode 100644 app/Support/ExactSearch.php create mode 100644 tests/exact-quoted-search-501.unit.php create mode 100644 tests/issue-501-exact-quoted-search.spec.js diff --git a/app/Controllers/AutoriApiController.php b/app/Controllers/AutoriApiController.php index 578566258..f01c40692 100644 --- a/app/Controllers/AutoriApiController.php +++ b/app/Controllers/AutoriApiController.php @@ -55,33 +55,24 @@ public function list(Request $request, Response $response, mysqli $db): Response $params_for_where = []; $param_types_for_where = ''; - if ($search_text !== '') { - $where_prepared .= " AND (a.nome LIKE ? OR a.pseudonimo LIKE ? OR a.biografia LIKE ?) "; - $escaped = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search_text) . '%'; - $params_for_where[] = $escaped; - $params_for_where[] = $escaped; - $params_for_where[] = $escaped; - $param_types_for_where .= 'sss'; - } - if ($search_nome !== '') { - $where_prepared .= " AND a.nome LIKE ? "; - $params_for_where[] = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search_nome) . '%'; - $param_types_for_where .= 's'; - } - if ($search_pseudonimo !== '') { - $where_prepared .= " AND a.pseudonimo LIKE ? "; - $params_for_where[] = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search_pseudonimo) . '%'; - $param_types_for_where .= 's'; - } - if ($search_sito !== '') { - $where_prepared .= " AND a.sito_web LIKE ? "; - $params_for_where[] = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search_sito) . '%'; - $param_types_for_where .= 's'; - } - if ($search_naz !== '' && $colNaz !== null) { - $where_prepared .= " AND a.`$colNaz` LIKE ? "; - $params_for_where[] = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search_naz) . '%'; - $param_types_for_where .= 's'; + // A value wrapped in quotes matches the exact spelling, accents + // included (#501): "Haffner" no longer returns Häffner. + $textFilters = [ + [['a.nome', 'a.pseudonimo', 'a.biografia'], $search_text], + [['a.nome'], $search_nome], + [['a.pseudonimo'], $search_pseudonimo], + [['a.sito_web'], $search_sito], + ]; + if ($colNaz !== null) { + $textFilters[] = [["a.`$colNaz`"], $search_naz]; + } + foreach ($textFilters as [$exprs, $value]) { + $cond = $value !== '' ? \App\Support\ExactSearch::filterCondition($exprs, $value) : null; + if ($cond !== null) { + $where_prepared .= ' AND ' . $cond['sql'] . ' '; + array_push($params_for_where, ...$cond['params']); + $param_types_for_where .= $cond['types']; + } } if ($nascita_from !== '') { $where_prepared .= " AND a.data_nascita >= ? "; diff --git a/app/Controllers/EditoriApiController.php b/app/Controllers/EditoriApiController.php index d3256acb2..07f892206 100644 --- a/app/Controllers/EditoriApiController.php +++ b/app/Controllers/EditoriApiController.php @@ -34,33 +34,23 @@ public function list(Request $request, Response $response, mysqli $db): Response // HAVING clause for libri_count filter (applied after subquery) $having_clause = ''; - if ($search_text !== '') { - $where_prepared .= " AND e.nome LIKE ? "; - $params_for_where[] = "%$search_text%"; - $param_types_for_where .= 's'; - $params[] = "%$search_text%"; - $param_types .= 's'; - } - if ($search_sito !== '') { - $where_prepared .= " AND e.sito_web LIKE ? "; - $params_for_where[] = "%$search_sito%"; - $param_types_for_where .= 's'; - $params[] = "%$search_sito%"; - $param_types .= 's'; - } - if ($search_via !== '') { - $where_prepared .= " AND e.indirizzo LIKE ? "; - $params_for_where[] = "%$search_via%"; - $param_types_for_where .= 's'; - $params[] = "%$search_via%"; - $param_types .= 's'; - } - if ($search_cap !== '') { - $where_prepared .= " AND e.indirizzo LIKE ? "; - $params_for_where[] = "%$search_cap%"; - $param_types_for_where .= 's'; - $params[] = "%$search_cap%"; - $param_types .= 's'; + // A value wrapped in quotes matches the exact spelling, accents + // included (#501). The same fragment feeds both the count query + // ($params_for_where) and the page query ($params). + foreach ([ + ['e.nome', $search_text], + ['e.sito_web', $search_sito], + ['e.indirizzo', $search_via], + ['e.indirizzo', $search_cap], + ] as [$expr, $value]) { + $cond = $value !== '' ? \App\Support\ExactSearch::filterCondition([$expr], $value) : null; + if ($cond !== null) { + $where_prepared .= ' AND ' . $cond['sql'] . ' '; + array_push($params_for_where, ...$cond['params']); + $param_types_for_where .= $cond['types']; + array_push($params, ...$cond['params']); + $param_types .= $cond['types']; + } } // Book count filter - uses HAVING since libri_count is computed if ($filter_libri_count !== '') { diff --git a/app/Controllers/PrestitiApiController.php b/app/Controllers/PrestitiApiController.php index 67e2e9bf6..ff8894ebe 100644 --- a/app/Controllers/PrestitiApiController.php +++ b/app/Controllers/PrestitiApiController.php @@ -64,15 +64,14 @@ public function list(Request $request, Response $response, mysqli $db): Response $params[] = $statoSpecifico; $param_types .= 's'; } - if ($search_value !== '') { - $where_prepared .= " AND (l.titolo LIKE ? - OR CONCAT(u.nome, ' ', u.cognome) LIKE ? - OR CONCAT(staff.nome, ' ', staff.cognome) LIKE ?) "; - $search_param = "%$search_value%"; - $params[] = $search_param; - $params[] = $search_param; - $params[] = $search_param; - $param_types .= 'sss'; + // Quoted value = exact spelling, accents included (#501). + $search = $search_value !== '' + ? \App\Support\ExactSearch::filterCondition(['l.titolo', "CONCAT(u.nome, ' ', u.cognome)", "CONCAT(staff.nome, ' ', staff.cognome)"], $search_value) + : null; + if ($search !== null) { + $where_prepared .= ' AND ' . $search['sql'] . ' '; + array_push($params, ...$search['params']); + $param_types .= $search['types']; } // Count total records with prepared statement diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php index 0d4e5059d..b65a538e4 100644 --- a/app/Controllers/SearchController.php +++ b/app/Controllers/SearchController.php @@ -31,19 +31,13 @@ public function authors(Request $request, Response $response, mysqli $db): Respo // SessionRoleRefreshMiddleware) receive them, as on the admin-only /api/autori. $lifeDates = $this->isOperatorSession($request) ? ', data_nascita, data_morte' : ''; $select = "id, nome{$lifeDates}, {$label}"; - if ($q !== '') { - // Split query into words — each word must match (AND logic) - $words = preg_split('/\s+/', $q, -1, PREG_SPLIT_NO_EMPTY); - $conditions = []; - $params = []; - $types = ''; - foreach ($words as $word) { - $conditions[] = '(nome LIKE ? ESCAPE \'\\\\\' OR pseudonimo LIKE ? ESCAPE \'\\\\\')'; - $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $word) . '%'; - $params[] = $like; - $params[] = $like; - $types .= 'ss'; - } + // Every word must match (AND); a quoted group is matched with its + // exact spelling, accents included (#501). + $cond = $q !== '' ? \App\Support\ExactSearch::wordsCondition(['nome', 'pseudonimo'], $q) : null; + if ($cond !== null) { + $conditions = [$cond['sql']]; + $params = $cond['params']; + $types = $cond['types']; $sql = "SELECT {$select} FROM autori WHERE " . implode(' AND ', $conditions) . " ORDER BY " . \App\Support\AuthorName::preferredSql('autori'); $stmt = $db->prepare($sql); @@ -72,17 +66,13 @@ public function publishers(Request $request, Response $response, mysqli $db): Re { $q = trim((string)($request->getQueryParams()['q'] ?? '')); $rows = []; - if ($q !== '') { - // Split query into words — each word must match (AND logic) - $words = preg_split('/\s+/', $q, -1, PREG_SPLIT_NO_EMPTY); - $conditions = []; - $params = []; - $types = ''; - foreach ($words as $word) { - $conditions[] = 'nome LIKE ?'; - $params[] = '%' . $word . '%'; - $types .= 's'; - } + // Every word must match (AND); a quoted group is matched with its + // exact spelling, accents included (#501). + $cond = $q !== '' ? \App\Support\ExactSearch::wordsCondition(['nome'], $q) : null; + if ($cond !== null) { + $conditions = [$cond['sql']]; + $params = $cond['params']; + $types = $cond['types']; $sql = "SELECT id, nome AS label FROM editori WHERE " . implode(' AND ', $conditions) . " ORDER BY nome"; $stmt = $db->prepare($sql); $stmt->bind_param($types, ...$params); @@ -101,8 +91,11 @@ public function users(Request $request, Response $response, mysqli $db): Respons { $q = trim((string)($request->getQueryParams()['q'] ?? '')); $rows=[]; - if ($q !== '') { - $s = '%'.$q.'%'; + // Quoted value = exact spelling, accents included (#501). + $search = $q !== '' + ? \App\Support\ExactSearch::filterCondition(['nome', 'cognome', 'telefono', 'email', 'codice_tessera'], $q) + : null; + if ($search !== null) { $stmt = $db->prepare(" SELECT id, CONCAT( @@ -113,15 +106,11 @@ public function users(Request $request, Response $response, mysqli $db): Respons END ) AS label FROM utenti - WHERE (nome LIKE ? - OR cognome LIKE ? - OR telefono LIKE ? - OR email LIKE ? - OR codice_tessera LIKE ?)" . \App\Support\Permissions::accountScopeSql('', \App\Support\Permissions::SCOPE_BORROWER) . " + WHERE {$search['sql']}" . \App\Support\Permissions::accountScopeSql('', \App\Support\Permissions::SCOPE_BORROWER) . " ORDER BY cognome, nome LIMIT 20 "); - $stmt->bind_param('sssss', $s, $s, $s, $s, $s); + $stmt->bind_param($search['types'], ...$search['params']); $stmt->execute(); $res = $stmt->get_result(); while ($r = $res->fetch_assoc()) { @@ -399,17 +388,15 @@ private function searchBooks(mysqli $db, string $query, bool $includeRequests = private function searchAuthors(mysqli $db, string $query): array { $results = []; - $words = preg_split('/\s+/', $query, -1, PREG_SPLIT_NO_EMPTY); - $conditions = []; - $params = []; - $types = ''; - foreach ($words as $word) { - $conditions[] = '(nome LIKE ? ESCAPE \'\\\\\' OR pseudonimo LIKE ? ESCAPE \'\\\\\')'; - $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $word) . '%'; - $params[] = $like; - $params[] = $like; - $types .= 'ss'; + // Every word must match (AND); a quoted group is matched with its + // exact spelling, accents included (#501). + $cond = \App\Support\ExactSearch::wordsCondition(['nome', 'pseudonimo'], $query); + if ($cond === null) { + return []; } + $conditions = [$cond['sql']]; + $params = $cond['params']; + $types = $cond['types']; $sql = "SELECT id, " . \App\Support\AuthorName::displaySql('autori') . " AS label FROM autori WHERE " . implode(' AND ', $conditions) . " ORDER BY " . \App\Support\AuthorName::preferredSql('autori') . " LIMIT 5"; @@ -433,15 +420,15 @@ private function searchAuthors(mysqli $db, string $query): array private function searchPublishers(mysqli $db, string $query): array { $results = []; - $words = preg_split('/\s+/', $query, -1, PREG_SPLIT_NO_EMPTY); - $conditions = []; - $params = []; - $types = ''; - foreach ($words as $word) { - $conditions[] = 'nome LIKE ?'; - $params[] = '%' . $word . '%'; - $types .= 's'; + // Every word must match (AND); a quoted group is matched with its + // exact spelling, accents included (#501). + $cond = \App\Support\ExactSearch::wordsCondition(['nome'], $query); + if ($cond === null) { + return []; } + $conditions = [$cond['sql']]; + $params = $cond['params']; + $types = $cond['types']; $sql = "SELECT id, nome AS label FROM editori WHERE " . implode(' AND ', $conditions) . " ORDER BY nome LIMIT 5"; $stmt = $db->prepare($sql); $stmt->bind_param($types, ...$params); @@ -528,17 +515,15 @@ private function searchBooksWithDetails(mysqli $db, string $query): array private function searchAuthorsWithDetails(mysqli $db, string $query): array { $results = []; - $words = preg_split('/\s+/', $query, -1, PREG_SPLIT_NO_EMPTY); - $conditions = []; - $params = []; - $types = ''; - foreach ($words as $word) { - $conditions[] = '(a.nome LIKE ? ESCAPE \'\\\\\' OR a.pseudonimo LIKE ? ESCAPE \'\\\\\')'; - $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $word) . '%'; - $params[] = $like; - $params[] = $like; - $types .= 'ss'; + // Every word must match (AND); a quoted group is matched with its + // exact spelling, accents included (#501). + $cond = \App\Support\ExactSearch::wordsCondition(['a.nome', 'a.pseudonimo'], $query); + if ($cond === null) { + return []; } + $conditions = [$cond['sql']]; + $params = $cond['params']; + $types = $cond['types']; $sql = " SELECT a.id, a.nome, a.pseudonimo, a.biografia, @@ -572,15 +557,15 @@ private function searchAuthorsWithDetails(mysqli $db, string $query): array private function searchPublishersWithDetails(mysqli $db, string $query): array { $results = []; - $words = preg_split('/\s+/', $query, -1, PREG_SPLIT_NO_EMPTY); - $conditions = []; - $params = []; - $types = ''; - foreach ($words as $word) { - $conditions[] = 'e.nome LIKE ?'; - $params[] = '%' . $word . '%'; - $types .= 's'; + // Every word must match (AND); a quoted group is matched with its + // exact spelling, accents included (#501). + $cond = \App\Support\ExactSearch::wordsCondition(['e.nome'], $query); + if ($cond === null) { + return []; } + $conditions = [$cond['sql']]; + $params = $cond['params']; + $types = $cond['types']; // issue #143: count books where the publisher is primary OR a secondary // one in the junction; gate the subquery on table existence so the count diff --git a/app/Controllers/UtentiApiController.php b/app/Controllers/UtentiApiController.php index a635e9595..f842a5bf9 100644 --- a/app/Controllers/UtentiApiController.php +++ b/app/Controllers/UtentiApiController.php @@ -24,15 +24,14 @@ public function list(Request $request, Response $response, mysqli $db): Response $params = []; $types = ''; - if ($search_value !== '') { - $searchParam = "%$search_value%"; - $where .= " AND (u.nome LIKE ? - OR u.cognome LIKE ? - OR u.email LIKE ? - OR u.telefono LIKE ? - OR u.codice_tessera LIKE ?)"; - $params = array_fill(0, 5, $searchParam); - $types = 'sssss'; + // Quoted value = exact spelling, accents included (#501). + $search = $search_value !== '' + ? \App\Support\ExactSearch::filterCondition(['u.nome', 'u.cognome', 'u.email', 'u.telefono', 'u.codice_tessera'], $search_value) + : null; + if ($search !== null) { + $where .= ' AND ' . $search['sql']; + $params = $search['params']; + $types = $search['types']; } // Additional filters @@ -180,11 +179,14 @@ public function exportPdf(Request $request, Response $response, mysqli $db): Res $types = ''; $search_value = trim((string)($q['search_text'] ?? ($q['search_value'] ?? ''))); - if ($search_value !== '') { - $searchParam = "%$search_value%"; - $where .= ' AND (u.nome LIKE ? OR u.cognome LIKE ? OR u.email LIKE ? OR u.telefono LIKE ? OR u.codice_tessera LIKE ?)'; - $params = array_fill(0, 5, $searchParam); - $types = 'sssss'; + // Quoted value = exact spelling, accents included (#501). + $search = $search_value !== '' + ? \App\Support\ExactSearch::filterCondition(['u.nome', 'u.cognome', 'u.email', 'u.telefono', 'u.codice_tessera'], $search_value) + : null; + if ($search !== null) { + $where .= ' AND ' . $search['sql']; + $params = $search['params']; + $types = $search['types']; } $role = trim((string)($q['role_filter'] ?? '')); if ($role !== '') { diff --git a/app/Support/ExactSearch.php b/app/Support/ExactSearch.php new file mode 100644 index 000000000..c40b5f3be --- /dev/null +++ b/app/Support/ExactSearch.php @@ -0,0 +1,160 @@ + + */ + public static function tokenize(string $query): array + { + $q = '[' . preg_quote(self::QUOTES, '/') . ']'; + $parts = preg_split('/(' . $q . '[^' . preg_quote(self::QUOTES, '/') . ']*(?:' . $q . '|$))/u', $query, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + if ($parts === false) { + $parts = [$query]; + } + + $terms = []; + foreach ($parts as $part) { + if (preg_match('/^' . $q . '/u', $part) === 1) { + $inner = self::collapse(self::stripQuotes($part)); + if ($inner !== '') { + $terms[] = ['term' => $inner, 'exact' => true]; + } + continue; + } + foreach (preg_split('/\s+/u', $part, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $word) { + $terms[] = ['term' => $word, 'exact' => false]; + } + } + return $terms; + } + + /** + * Parse a single-value column filter (admin list "Name" box): the whole + * value is one term, exact when it starts with a quote. + * + * @return array{term: string, exact: bool} + */ + public static function parseFilter(string $value): array + { + $value = trim($value); + if ($value !== '' && preg_match('/^[' . preg_quote(self::QUOTES, '/') . ']/u', $value) === 1) { + return ['term' => self::collapse(self::stripQuotes($value)), 'exact' => true]; + } + return ['term' => $value, 'exact' => false]; + } + + /** + * WHERE fragment for a single-value filter matched against any of $exprs + * (OR), honouring quotes via parseFilter(). Null when nothing is left to + * match (e.g. the value was only a quote). + * + * @param list $exprs + * @return array{sql: string, params: list, types: string}|null + */ + public static function filterCondition(array $exprs, string $value): ?array + { + ['term' => $term, 'exact' => $exact] = self::parseFilter($value); + if ($term === '' || $exprs === []) { + return null; + } + $sql = []; + $params = []; + foreach ($exprs as $expr) { + $sql[] = self::likeSql($expr, $exact); + $params[] = self::likePattern($term); + } + return [ + 'sql' => '(' . implode(' OR ', $sql) . ')', + 'params' => $params, + 'types' => str_repeat('s', count($params)), + ]; + } + + /** + * WHERE fragment for a multi-word query: every term (see tokenize()) must + * match at least one of $exprs. Null when the query has no usable term. + * + * @param list $exprs + * @return array{sql: string, params: list, types: string}|null + */ + public static function wordsCondition(array $exprs, string $query): ?array + { + $sql = []; + $params = []; + foreach (self::tokenize($query) as ['term' => $term, 'exact' => $exact]) { + $or = []; + foreach ($exprs as $expr) { + $or[] = self::likeSql($expr, $exact); + $params[] = self::likePattern($term); + } + $sql[] = '(' . implode(' OR ', $or) . ')'; + } + if ($sql === [] || $exprs === []) { + return null; + } + return [ + 'sql' => '(' . implode(' AND ', $sql) . ')', + 'params' => $params, + 'types' => str_repeat('s', count($params)), + ]; + } + + /** + * SQL for "$expr contains the bound pattern". Bind likePattern($term). + */ + public static function likeSql(string $expr, bool $exact): string + { + return $exact + ? "LOWER({$expr}) COLLATE utf8mb4_bin LIKE LOWER(?) ESCAPE '\\\\'" + : "{$expr} LIKE ? ESCAPE '\\\\'"; + } + + /** `%term%` with LIKE metacharacters escaped (backslash is the ESCAPE char). */ + public static function likePattern(string $term): string + { + return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $term) . '%'; + } + + private static function stripQuotes(string $value): string + { + $q = preg_quote(self::QUOTES, '/'); + return (string) preg_replace('/^[' . $q . ']+|[' . $q . ']+$/u', '', trim($value)); + } + + private static function collapse(string $value): string + { + return trim((string) preg_replace('/\s+/u', ' ', $value)); + } +} diff --git a/app/Support/SearchIndexBuilder.php b/app/Support/SearchIndexBuilder.php index 1dd41d786..9a8c17f86 100644 --- a/app/Support/SearchIndexBuilder.php +++ b/app/Support/SearchIndexBuilder.php @@ -286,6 +286,10 @@ public static function rebuildMany(\mysqli $db, array $bookIds): void * match somewhere" behaviour without letting unindexable required terms zero * out otherwise valid results. * + * The column collation folds accents (`haffner` finds `Häffner`). A quoted + * group (`"haffner"`, `"thomas mann"`, unclosed quote = closed) is matched + * with its exact spelling and as a phrase instead — see ExactSearch (#501). + * * UPGRADE-WINDOW SAFETY: when the `search_index` column does not exist yet * (new PHP deployed but the admin has not run the 0.7.31 DB migration), * MATCH/LIKE on it would 500 every catalog search + autocomplete/preview @@ -311,16 +315,25 @@ public static function buildSearchCondition(\mysqli $db, string $column, string return self::buildLegacyCondition($db, $column, $searchQuery); } - $words = preg_split('/\s+/', $searchQuery, -1, PREG_SPLIT_NO_EMPTY) ?: []; - $booleanTerms = []; $parts = []; $params = []; $types = ''; - foreach ($words as $word) { - $wordBase = self::normalizeSearchWord($word); - if ($wordBase === '') { + foreach (self::searchTerms($searchQuery) as ['term' => $wordBase, 'exact' => $exact]) { + if ($exact) { + // Quoted term (#501): FULLTEXT still narrows the rows — every + // indexable word must be present as a whole word, accent-folded — + // and the binary-collation LIKE then keeps only the exact + // spelling/phrase. + foreach (self::fulltextTokens($wordBase) as $token) { + if (self::fulltextTermForWord($token) !== null) { + $booleanTerms[] = '+' . $token; + } + } + $parts[] = ExactSearch::likeSql($column, true); + $params[] = self::likePattern($wordBase); + $types .= 's'; continue; } $fulltextTerm = self::fulltextTermForWord($wordBase); @@ -369,33 +382,28 @@ private static function buildLegacyCondition(\mysqli $db, string $column, string $prefix = $dot === false ? '' : substr($column, 0, $dot + 1); $descExpr = self::descriptionExpr($db, $prefix); - $words = preg_split('/\s+/', $searchQuery, -1, PREG_SPLIT_NO_EMPTY) ?: []; - $parts = []; $params = []; $types = ''; $hasJunction = SchemaInfo::hasLibriEditori($db); - foreach ($words as $word) { - $wordBase = self::normalizeSearchWord($word); - if ($wordBase === '') { - continue; - } + foreach (self::searchTerms($searchQuery) as ['term' => $wordBase, 'exact' => $exact]) { $like = self::likePattern($wordBase); + $m = static fn(string $expr): string => ExactSearch::likeSql($expr, $exact); $bookIdExpr = $prefix . 'id'; $publisherIdExpr = $prefix . 'editore_id'; - $parts[] = "({$prefix}titolo LIKE ? ESCAPE '\\\\'" - . " OR {$prefix}sottotitolo LIKE ? ESCAPE '\\\\'" - . " OR {$descExpr} LIKE ? ESCAPE '\\\\'" - . " OR {$prefix}parole_chiave LIKE ? ESCAPE '\\\\'" - . " OR {$prefix}isbn10 LIKE ? ESCAPE '\\\\'" - . " OR {$prefix}isbn13 LIKE ? ESCAPE '\\\\'" - . " OR {$prefix}ean LIKE ? ESCAPE '\\\\'" - . " OR EXISTS (SELECT 1 FROM libri_autori la JOIN autori a ON la.autore_id = a.id WHERE la.libro_id = {$bookIdExpr} AND CONCAT_WS(' ', a.nome, a.pseudonimo) LIKE ? ESCAPE '\\\\')" - . " OR EXISTS (SELECT 1 FROM editori e WHERE e.id = {$publisherIdExpr} AND e.nome LIKE ? ESCAPE '\\\\')" + $parts[] = '(' . $m("{$prefix}titolo") + . ' OR ' . $m("{$prefix}sottotitolo") + . ' OR ' . $m($descExpr) + . ' OR ' . $m("{$prefix}parole_chiave") + . ' OR ' . $m("{$prefix}isbn10") + . ' OR ' . $m("{$prefix}isbn13") + . ' OR ' . $m("{$prefix}ean") + . " OR EXISTS (SELECT 1 FROM libri_autori la JOIN autori a ON la.autore_id = a.id WHERE la.libro_id = {$bookIdExpr} AND " . $m("CONCAT_WS(' ', a.nome, a.pseudonimo)") . ')' + . " OR EXISTS (SELECT 1 FROM editori e WHERE e.id = {$publisherIdExpr} AND " . $m('e.nome') . ')' . ($hasJunction - ? " OR EXISTS (SELECT 1 FROM libri_editori le JOIN editori e2 ON e2.id = le.editore_id WHERE le.libro_id = {$bookIdExpr} AND e2.nome LIKE ? ESCAPE '\\\\')" + ? " OR EXISTS (SELECT 1 FROM libri_editori le JOIN editori e2 ON e2.id = le.editore_id WHERE le.libro_id = {$bookIdExpr} AND " . $m('e2.nome') . ')' : '') . ')'; $paramsPerWord = $hasJunction ? 10 : 9; @@ -444,14 +452,7 @@ public static function buildRelevanceOrder( ?int $maxWords = null ): array { - $rawWords = preg_split('/\s+/', trim($searchQuery), -1, PREG_SPLIT_NO_EMPTY) ?: []; - $words = []; - foreach ($rawWords as $word) { - $wordBase = self::normalizeSearchWord($word); - if ($wordBase !== '') { - $words[] = $wordBase; - } - } + $words = self::searchTerms($searchQuery); if ($maxWords !== null) { $words = array_slice($words, 0, max(0, $maxWords)); } @@ -468,18 +469,21 @@ public static function buildRelevanceOrder( $params = []; $types = ''; - foreach ($words as $word) { + foreach ($words as ['term' => $word, 'exact' => $exact]) { $like = self::likePattern($word); + // A quoted term (#501) scores only on its exact spelling, matching + // the rows buildSearchCondition admitted. + $m = static fn(string $expr): string => ExactSearch::likeSql($expr, $exact); - $terms[] = "(CASE WHEN ({$prefix}isbn10 LIKE ? ESCAPE '\\\\'" - . " OR {$prefix}isbn13 LIKE ? ESCAPE '\\\\'" - . " OR {$prefix}ean LIKE ? ESCAPE '\\\\') THEN 120 ELSE 0 END)"; + $terms[] = '(CASE WHEN (' . $m("{$prefix}isbn10") + . ' OR ' . $m("{$prefix}isbn13") + . ' OR ' . $m("{$prefix}ean") . ') THEN 120 ELSE 0 END)'; $params[] = $like; $params[] = $like; $params[] = $like; $types .= 'sss'; - $terms[] = "(CASE WHEN {$titleExpr} LIKE ? ESCAPE '\\\\' THEN 100 ELSE 0 END)"; + $terms[] = "(CASE WHEN " . $m($titleExpr) . " THEN 100 ELSE 0 END)"; $params[] = $like; $types .= 's'; @@ -487,25 +491,25 @@ public static function buildRelevanceOrder( $terms[] = "(CASE WHEN EXISTS (SELECT 1 FROM libri_autori la_rel" . " JOIN autori a_rel ON a_rel.id = la_rel.autore_id" . " WHERE la_rel.libro_id = {$prefix}id" - . " AND {$authorExpr} LIKE ? ESCAPE '\\\\') THEN 60 ELSE 0 END)"; + . " AND " . $m($authorExpr) . ") THEN 60 ELSE 0 END)"; $params[] = $like; $types .= 's'; - $terms[] = "(CASE WHEN {$subtitleExpr} LIKE ? ESCAPE '\\\\' THEN 40 ELSE 0 END)"; + $terms[] = "(CASE WHEN " . $m($subtitleExpr) . " THEN 40 ELSE 0 END)"; $params[] = $like; $types .= 's'; $publisherExpr = self::decodedSearchExpr('e_rel.nome'); $publisherMatch = "EXISTS (SELECT 1 FROM editori e_rel" . " WHERE e_rel.id = {$prefix}editore_id" - . " AND {$publisherExpr} LIKE ? ESCAPE '\\\\')"; + . " AND " . $m($publisherExpr) . ")"; $publisherParams = 1; if ($hasJunction) { $publisher2Expr = self::decodedSearchExpr('e2_rel.nome'); $publisherMatch .= " OR EXISTS (SELECT 1 FROM libri_editori le_rel" . " JOIN editori e2_rel ON e2_rel.id = le_rel.editore_id" . " WHERE le_rel.libro_id = {$prefix}id" - . " AND {$publisher2Expr} LIKE ? ESCAPE '\\\\')"; + . " AND " . $m($publisher2Expr) . ")"; $publisherParams++; } $terms[] = "(CASE WHEN ({$publisherMatch}) THEN 25 ELSE 0 END)"; @@ -514,11 +518,11 @@ public static function buildRelevanceOrder( $types .= 's'; } - $terms[] = "(CASE WHEN {$keywordsExpr} LIKE ? ESCAPE '\\\\' THEN 10 ELSE 0 END)"; + $terms[] = "(CASE WHEN " . $m($keywordsExpr) . " THEN 10 ELSE 0 END)"; $params[] = $like; $types .= 's'; - $terms[] = "(CASE WHEN {$descExpr} LIKE ? ESCAPE '\\\\' THEN 3 ELSE 0 END)"; + $terms[] = "(CASE WHEN " . $m($descExpr) . " THEN 3 ELSE 0 END)"; $params[] = $like; $types .= 's'; } @@ -585,7 +589,7 @@ private static function isDefaultFulltextStopword(string $token): bool private static function likePattern(string $term): string { - return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $term) . '%'; + return ExactSearch::likePattern($term); } /** @@ -602,15 +606,25 @@ private static function decodedSearchExpr(string $expression): string } /** - * Normalize a raw user search word for LIKE/FULLTEXT matching: strip a - * trailing '*' (the wildcard the FULLTEXT boolean path adds itself) and - * surrounding whitespace. Shared by buildSearchCondition (the WHERE) and - * buildRelevanceOrder (the ORDER BY) so the two paths tokenize identically + * Split a raw user query into search terms: quoted groups become one exact + * term (#501, see ExactSearch), other words are tolerant terms with a + * trailing '*' stripped (the wildcard the FULLTEXT boolean path adds + * itself). Shared by buildSearchCondition (the WHERE), the legacy fallback + * and buildRelevanceOrder (the ORDER BY) so all paths tokenize identically * and ranking never diverges from the rows the WHERE admitted. + * + * @return list */ - private static function normalizeSearchWord(string $word): string + private static function searchTerms(string $searchQuery): array { - return trim(rtrim($word, '*')); + $terms = []; + foreach (ExactSearch::tokenize(trim($searchQuery)) as $term) { + $value = $term['exact'] ? $term['term'] : trim(rtrim($term['term'], '*')); + if ($value !== '') { + $terms[] = ['term' => $value, 'exact' => $term['exact']]; + } + } + return $terms; } private static ?bool $hasDescrizionePlain = null; diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index bf4fab312..ed1750a5b 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -674,14 +674,16 @@ private function buildFilters(array $params): array $bind[] = (int) $author; $types .= 'i'; } else { - $conditions[] = "EXISTS (SELECT 1 FROM libri_autori la_a JOIN autori a_a ON la_a.autore_id = a_a.id - WHERE la_a.libro_id = l.id - AND la_a.ruolo IN ('principale', 'co-autore') - AND (a_a.nome LIKE ? ESCAPE '\\\\' OR a_a.pseudonimo LIKE ? ESCAPE '\\\\'))"; - $authorLike = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $author) . '%'; - $bind[] = $authorLike; - $bind[] = $authorLike; - $types .= 'ss'; + // Quoted value = exact spelling, accents included (#501). + $authorCond = \App\Support\ExactSearch::filterCondition(['a_a.nome', 'a_a.pseudonimo'], $author); + if ($authorCond !== null) { + $conditions[] = "EXISTS (SELECT 1 FROM libri_autori la_a JOIN autori a_a ON la_a.autore_id = a_a.id + WHERE la_a.libro_id = l.id + AND la_a.ruolo IN ('principale', 'co-autore') + AND {$authorCond['sql']})"; + array_push($bind, ...$authorCond['params']); + $types .= $authorCond['types']; + } } } @@ -699,15 +701,21 @@ private function buildFilters(array $params): array $types .= 'i'; } } else { - $publisherLike = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $publisher) . '%'; - $conditions[] = $hasJunction - ? "(e.nome LIKE ? ESCAPE '\\\\' OR EXISTS (SELECT 1 FROM libri_editori le_p JOIN editori e_p ON e_p.id = le_p.editore_id WHERE le_p.libro_id = l.id AND e_p.nome LIKE ? ESCAPE '\\\\'))" - : "e.nome LIKE ? ESCAPE '\\\\'"; - $bind[] = $publisherLike; - $types .= 's'; - if ($hasJunction) { + // Quoted value = exact spelling, accents included (#501). + ['term' => $publisherTerm, 'exact' => $publisherExact] = \App\Support\ExactSearch::parseFilter($publisher); + if ($publisherTerm !== '') { + $publisherLike = \App\Support\ExactSearch::likePattern($publisherTerm); + $primaryMatch = \App\Support\ExactSearch::likeSql('e.nome', $publisherExact); + $conditions[] = $hasJunction + ? "({$primaryMatch} OR EXISTS (SELECT 1 FROM libri_editori le_p JOIN editori e_p ON e_p.id = le_p.editore_id WHERE le_p.libro_id = l.id AND " + . \App\Support\ExactSearch::likeSql('e_p.nome', $publisherExact) . '))' + : $primaryMatch; $bind[] = $publisherLike; $types .= 's'; + if ($hasJunction) { + $bind[] = $publisherLike; + $types .= 's'; + } } } } diff --git a/tests/exact-quoted-search-501.unit.php b/tests/exact-quoted-search-501.unit.php new file mode 100644 index 000000000..f40ea9bf6 --- /dev/null +++ b/tests/exact-quoted-search-501.unit.php @@ -0,0 +1,229 @@ + 'haffner', 'exact' => false]], 'plain word is tolerant'); +check(ExactSearch::tokenize('"haffner"') === [['term' => 'haffner', 'exact' => true]], 'quoted word is exact'); +check(ExactSearch::tokenize('"haffner') === [['term' => 'haffner', 'exact' => true]], 'unclosed quote is treated as closed'); +check(ExactSearch::tokenize('„Häffner“') === [['term' => 'Häffner', 'exact' => true]], 'German typographic quotes are recognised'); +check( + ExactSearch::tokenize('roman "thomas mann" 1929') === [ + ['term' => 'roman', 'exact' => false], + ['term' => 'thomas mann', 'exact' => true], + ['term' => '1929', 'exact' => false], + ], + 'phrase in the middle keeps the surrounding words tolerant and collapses spaces' +); +check(ExactSearch::tokenize('""') === [] && ExactSearch::tokenize('" "') === [], 'empty quotes yield no term'); +check(ExactSearch::parseFilter('"Haffner"') === ['term' => 'Haffner', 'exact' => true], 'filter value in quotes is exact'); +check(ExactSearch::parseFilter('van der Berg') === ['term' => 'van der Berg', 'exact' => false], 'filter value without quotes stays one tolerant term'); +check(ExactSearch::filterCondition(['a.nome'], '"') === null, 'a lone quote adds no filter'); + +// ── Database ─────────────────────────────────────────────────────────────── +function eqsLoadEnv(string $path): array +{ + $env = []; + foreach (preg_split('/\r?\n/', (string) @file_get_contents($path)) as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) { + continue; + } + [$k, $v] = explode('=', $line, 2); + $v = trim($v); + if (strlen($v) >= 2 && ($v[0] === '"' || $v[0] === "'") && $v[-1] === $v[0]) { + $v = substr($v, 1, -1); + } + $env[trim($k)] = $v; + } + return $env; +} + +$env = eqsLoadEnv($root . '/.env'); +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? '/opt/homebrew/var/mysql/mysql.sock'); +try { + if (is_string($socket) && $socket !== '' && file_exists($socket)) { + $db = new mysqli(null, $env['DB_USER'] ?? '', $env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? ''), $env['DB_NAME'] ?? '', 0, $socket); + } else { + $db = new mysqli($env['DB_HOST'] ?? '127.0.0.1', $env['DB_USER'] ?? '', $env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? ''), $env['DB_NAME'] ?? '', (int) ($env['DB_PORT'] ?? 3306)); + } +} catch (\Throwable $e) { + if (getenv('CI_STRICT_TESTS') === '1') { + fwrite(STDERR, "FAIL: database not reachable in strict mode\n"); + exit(1); + } + echo "SKIP: database not reachable (" . $e->getMessage() . ")\n"; + printf("\nALL %d PASS (database part skipped)\n", $TESTNO); + exit(0); +} +$db->set_charset('utf8mb4'); + +const EQS_BOOKS = 'zz_exact_search_501_books'; +const EQS_PEOPLE = 'zz_exact_search_501_people'; + +function eqsCleanup(mysqli $db): void +{ + $db->query('DROP TABLE IF EXISTS `' . EQS_BOOKS . '`'); + $db->query('DROP TABLE IF EXISTS `' . EQS_PEOPLE . '`'); +} + +set_exception_handler(static function (\Throwable $e) use ($db): void { + try { + eqsCleanup($db); + } catch (\Throwable $ignored) { + } + fwrite(STDERR, "FAIL: " . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); + exit(1); +}); + +/** @param array{sql:string, params:array, types:string}|null $cond */ +function eqsIds(mysqli $db, string $table, string $alias, ?array $cond): array +{ + if ($cond === null) { + return []; + } + $stmt = $db->prepare("SELECT id FROM `{$table}` {$alias} WHERE {$cond['sql']} ORDER BY id"); + $stmt->bind_param($cond['types'], ...$cond['params']); + $stmt->execute(); + $res = $stmt->get_result(); + $ids = []; + while ($row = $res->fetch_assoc()) { + $ids[] = (int) $row['id']; + } + $stmt->close(); + return $ids; +} + +eqsCleanup($db); +$db->query( + 'CREATE TABLE `' . EQS_BOOKS . '` ( + id INT NOT NULL PRIMARY KEY, + search_index MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + FULLTEXT KEY ft_search_index (search_index) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' +); +$db->query( + 'CREATE TABLE `' . EQS_PEOPLE . '` ( + id INT NOT NULL PRIMARY KEY, + nome VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + pseudonimo VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' +); + +$stmt = $db->prepare('INSERT INTO `' . EQS_BOOKS . '` (id, search_index) VALUES (?, ?)'); +foreach ([ + 1 => 'Die Brücke Rolf Haffner', + 2 => 'Gedichte Anna Häffner', + 3 => 'Der Zauberberg Thomas Mann S. Fischer', + 4 => 'Mann ohne Eigenschaften Thomas Bernhard', + 5 => 'Müller Lexikon', +] as $id => $value) { + $stmt->bind_param('is', $id, $value); + $stmt->execute(); +} +$stmt->close(); + +$stmt = $db->prepare('INSERT INTO `' . EQS_PEOPLE . '` (id, nome, pseudonimo) VALUES (?, ?, ?)'); +foreach ([ + 1 => ['Rolf Haffner', null], + 2 => ['Anna Häffner', null], + 3 => ['Hans Müller', 'H. Mueller'], + 4 => ['Jörg Haffner-Lenz', null], +] as $id => [$nome, $pseudo]) { + $stmt->bind_param('iss', $id, $nome, $pseudo); + $stmt->execute(); +} +$stmt->close(); + +$book = static fn(string $q): array => eqsIds($db, EQS_BOOKS, 's', SearchIndexBuilder::buildSearchCondition($db, 's.search_index', $q)); + +check($book('haffner') === [1, 2], 'unquoted haffner still finds Haffner and Häffner (tolerant default kept)'); +check($book('"haffner"') === [1], '"haffner" finds only Haffner'); +check($book('"Häffner"') === [2], '"Häffner" finds only Häffner'); +check($book('"HAFFNER"') === [1], 'case is still ignored inside quotes'); +check($book('"haffner') === [1], 'unclosed quote behaves like a closed one'); +check($book('„Häffner“') === [2], 'typographic quotes work like straight quotes'); +check($book('muller') === [5] && $book('"muller"') === [], 'muller finds Müller, "muller" does not'); +check($book('"thomas mann"') === [3], '"thomas mann" is an exact phrase (not Mann … Thomas)'); +check($book('thomas mann') === [3, 4], 'without quotes the two words may be anywhere'); +check($book('gedichte "häffner"') === [2] && $book('brücke "häffner"') === [], 'quoted and tolerant terms are ANDed'); +check($book('"') === [] && SearchIndexBuilder::buildSearchCondition($db, 's.search_index', '"') === null, 'a lone quote adds no condition'); + +$person = static fn(string $q): array => eqsIds($db, EQS_PEOPLE, 'a', ExactSearch::filterCondition(['a.nome', 'a.pseudonimo'], $q)); +check($person('haffner') === [1, 2, 4], 'admin filter: unquoted haffner finds every spelling'); +check($person('"haffner"') === [1, 4], 'admin filter: "haffner" excludes Häffner'); +check($person('"Häffner"') === [2], 'admin filter: "Häffner" finds only Häffner'); +check($person('"mueller"') === [3], 'admin filter: quoted value also matches the pseudonym column'); +check($person('50%') === [], 'admin filter: % is a literal, not a wildcard'); + +$words = static fn(string $q): array => eqsIds($db, EQS_PEOPLE, 'a', ExactSearch::wordsCondition(['a.nome', 'a.pseudonimo'], $q)); +check($words('rolf haffner') === [1], 'autocomplete: tolerant words are ANDed'); +check($words('anna "haffner"') === [], 'autocomplete: quoted term excludes the umlaut spelling'); +check($words('anna "häffner"') === [2], 'autocomplete: quoted umlaut spelling matches'); + +// ── Generated SQL against the real libri schema ──────────────────────────── +// Ranking and the pre-migration fallback reference libri/autori/editori, so +// prepare them against the real tables (LIMIT 0) to prove the SQL is valid. +$hasLibri = $db->query("SHOW TABLES LIKE 'libri'"); +if ($hasLibri && $hasLibri->num_rows > 0) { + $rel = SearchIndexBuilder::buildRelevanceOrder($db, 'roman "thomas mann"', 'l.'); + check(substr_count($rel['sql'], 'COLLATE utf8mb4_bin') > 0, 'relevance ORDER BY scores quoted terms with the exact comparison'); + $s = $db->prepare("SELECT l.id FROM libri l WHERE l.deleted_at IS NULL ORDER BY {$rel['sql']} LIMIT 0"); + $s->bind_param($rel['types'], ...$rel['params']); + check($s->execute(), 'relevance ORDER BY with a quoted phrase is valid SQL'); + $s->close(); + + $prop = new \ReflectionProperty(SearchIndexBuilder::class, 'columnExists'); + $saved = $prop->getValue(); + $prop->setValue(null, false); + try { + $legacy = SearchIndexBuilder::buildSearchCondition($db, 'l.search_index', 'roman "häffner"'); + } finally { + $prop->setValue(null, $saved); + } + check($legacy !== null && str_contains($legacy['sql'], 'COLLATE utf8mb4_bin'), 'pre-migration fallback honours quotes'); + $s = $db->prepare("SELECT l.id FROM libri l WHERE l.deleted_at IS NULL AND {$legacy['sql']} LIMIT 0"); + $s->bind_param($legacy['types'], ...$legacy['params']); + check($s->execute(), 'pre-migration fallback with a quoted term is valid SQL'); + $s->close(); +} elseif (getenv('CI_STRICT_TESTS') === '1') { + throw new \RuntimeException('libri table missing in strict mode'); +} + +eqsCleanup($db); +$db->close(); +printf("\nALL %d PASS\n", $TESTNO); diff --git a/tests/issue-237-regression.unit.php b/tests/issue-237-regression.unit.php index 52ade3c67..11153083e 100644 --- a/tests/issue-237-regression.unit.php +++ b/tests/issue-237-regression.unit.php @@ -146,23 +146,30 @@ // 8. Author autocomplete hardening: EVERY pseudonimo LIKE placeholder must be // paired with ESCAPE (exhaustive — a future path added without it fails -// here), and the wildcard-escaping bind pattern must be present. Caught a -// real gap on first run: two of the three SearchController paths shipped -// unescaped because a replace-all fix silently missed their indentation. +// here), and the bind must escape LIKE wildcards. Caught a real gap on +// first run: two of the three SearchController paths shipped unescaped +// because a replace-all fix silently missed their indentation. Since #501 +// the paths build their LIKEs through ExactSearch, so the invariant is +// checked there: the SQL it emits carries ESCAPE in both modes and the +// pattern escapes \, % and _. +$likeOk = str_contains(\App\Support\ExactSearch::likeSql('a.pseudonimo', false), "LIKE ? ESCAPE '\\\\'") + && str_contains(\App\Support\ExactSearch::likeSql('a.pseudonimo', true), "LIKE LOWER(?) ESCAPE '\\\\'") + && \App\Support\ExactSearch::likePattern('5%_\\') === '%5\\%\\_\\\\%'; +$check($likeOk, 'ExactSearch: LIKE carries ESCAPE and the bind escapes \\, % and _'); + $search = $src('app/Controllers/SearchController.php'); $check( preg_match('/pseudonimo LIKE \?(?! ESCAPE)/', $search) === 0 - && substr_count($search, "pseudonimo LIKE ? ESCAPE") >= 3 - && substr_count($search, "str_replace(['\\\\', '%', '_']") >= 3, - 'SearchController: every pseudonimo LIKE uses ESCAPE + wildcard-escaped bind' + && substr_count($search, "ExactSearch::wordsCondition(['nome', 'pseudonimo']") + substr_count($search, "ExactSearch::wordsCondition(['a.nome', 'a.pseudonimo']") >= 3, + 'SearchController: every pseudonimo LIKE goes through ExactSearch (ESCAPE + escaped bind)' ); // 9. Same exhaustive invariant in the mobile API author filter. $mobile = $src('storage/plugins/mobile-api/src/Controllers/CatalogController.php'); $check( preg_match('/pseudonimo LIKE \?(?! ESCAPE)/', $mobile) === 0 - && str_contains($mobile, "str_replace(['\\\\', '%', '_']"), - 'mobile-api CatalogController: every pseudonimo LIKE uses ESCAPE + escaped bind' + && str_contains($mobile, "ExactSearch::filterCondition(['a_a.nome', 'a_a.pseudonimo']"), + 'mobile-api CatalogController: every pseudonimo LIKE goes through ExactSearch' ); // 10. View-escaping rule: the contributor display in book-detail must never diff --git a/tests/issue-501-exact-quoted-search.spec.js b/tests/issue-501-exact-quoted-search.spec.js new file mode 100644 index 000000000..f7c05038a --- /dev/null +++ b/tests/issue-501-exact-quoted-search.spec.js @@ -0,0 +1,161 @@ +// @ts-check +/** + * Issue #501: "haffner" also found "Häffner" because the collation folds + * accents. Unquoted searches keep that tolerance; a quoted term matches the + * exact spelling. Driven through the real search boxes a librarian types in: + * the admin book list, the admin author list and the public catalogue. + */ + +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const path = require('path'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, + 'E2E credentials not configured (set E2E_ADMIN_EMAIL, E2E_ADMIN_PASS, E2E_DB_*)'); + +function dbQuery(sql) { + const args = []; + if (DB_HOST) { + args.push('-h', DB_HOST); + if (DB_PORT) args.push('-P', DB_PORT); + } else if (DB_SOCKET) { + args.push('-S', DB_SOCKET); + } + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '--default-character-set=utf8mb4', '-e', sql); + return execFileSync('mysql', args, { + encoding: 'utf-8', timeout: 10000, + env: { ...process.env, MYSQL_PWD: DB_PASS }, + }).trim(); +} + +// Letters only: the token becomes part of a FULLTEXT word in every title. +const TOKEN = 'zq' + Math.random().toString(36).replace(/[^a-z]/g, '').slice(0, 6); +const PLAIN = `Rolf Haffner ${TOKEN}`; +const UMLAUT = `Anna Häffner ${TOKEN}`; +const TITLE_PLAIN = `${TOKEN} Brückenbuch`; +const TITLE_UMLAUT = `${TOKEN} Gedichtband`; +const ids = { authors: [], books: [] }; + +/** Insert an author + one principal-author book; ids come from the same connection. */ +function seedBook(author, title) { + const out = dbQuery( + `INSERT INTO autori (nome) VALUES ('${author}'); SET @a = LAST_INSERT_ID();` + + ` INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES ('${title}', 1, 1); SET @b = LAST_INSERT_ID();` + + ` INSERT INTO libri_autori (libro_id, autore_id, ruolo) VALUES (@b, @a, 'principale');` + + ' SELECT @a, @b;', + ); + const [authorId, bookId] = out.split(/\s+/).map(Number); + ids.authors.push(authorId); + ids.books.push(bookId); + return bookId; +} + +async function loginAsAdmin(page) { + await page.goto(`${BASE}/admin/dashboard`); + const email = page.locator('input[name="email"]'); + if (await email.isVisible({ timeout: 4000 }).catch(() => false)) { + await email.fill(ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/.*(?:dashboard|admin).*/, { timeout: 15000 }); + } +} + +/** Type into a DataTables filter box and wait for the reload it triggers. */ +async function typeAndWait(page, selector, value, apiPath) { + const reloaded = page.waitForResponse((r) => r.url().includes(apiPath) && r.status() === 200, { timeout: 15000 }); + await page.fill(selector, value); + await reloaded; + // The table repaints after the JSON lands. + await page.waitForTimeout(300); +} + +test.describe.serial('#501 quoted search = exact spelling', () => { + test.beforeAll(() => { + seedBook(PLAIN, TITLE_PLAIN); + seedBook(UMLAUT, TITLE_UMLAUT); + // Same rebuild the save paths run, so search_index holds the author names. + const root = path.join(__dirname, '..'); + execFileSync('php', ['-r', ` + require '${root}/vendor/autoload.php'; + $db = getenv('E2E_DB_HOST') + ? new mysqli(getenv('E2E_DB_HOST'), getenv('E2E_DB_USER'), getenv('E2E_DB_PASS'), getenv('E2E_DB_NAME'), (int) (getenv('E2E_DB_PORT') ?: 3306)) + : new mysqli(null, getenv('E2E_DB_USER'), getenv('E2E_DB_PASS'), getenv('E2E_DB_NAME'), 0, getenv('E2E_DB_SOCKET')); + $db->set_charset('utf8mb4'); + App\\Support\\SearchIndexBuilder::rebuildMany($db, [${ids.books.join(',')}]); + `], { encoding: 'utf-8', timeout: 20000, env: process.env }); + }); + + test.afterAll(() => { + if (ids.books.length) { + dbQuery(`DELETE FROM libri_autori WHERE libro_id IN (${ids.books.join(',')})`); + dbQuery(`DELETE FROM libri WHERE id IN (${ids.books.join(',')})`); + } + if (ids.authors.length) { + dbQuery(`DELETE FROM autori WHERE id IN (${ids.authors.join(',')})`); + } + }); + + test('admin book list: unquoted finds both, quoted finds one spelling', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${BASE}/admin/books`); + const table = page.locator('table').first(); + + await typeAndWait(page, '#search_text', `${TOKEN} haffner`, '/api/libri'); + await expect(table).toContainText(TITLE_PLAIN); + await expect(table).toContainText(TITLE_UMLAUT); + + await typeAndWait(page, '#search_text', `${TOKEN} "haffner"`, '/api/libri'); + await expect(table).toContainText(TITLE_PLAIN); + await expect(table).not.toContainText(TITLE_UMLAUT); + + await typeAndWait(page, '#search_text', `${TOKEN} "Häffner`, '/api/libri'); + await expect(table).toContainText(TITLE_UMLAUT); + await expect(table).not.toContainText(TITLE_PLAIN); + }); + + test('admin author list: quoted name excludes the umlaut spelling', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${BASE}/admin/authors`); + const table = page.locator('table').first(); + + await typeAndWait(page, '#search_nome', `haffner ${TOKEN}`, '/api/autori'); + await expect(table).toContainText(PLAIN); + await expect(table).toContainText(UMLAUT); + + await typeAndWait(page, '#search_nome', `"haffner ${TOKEN}"`, '/api/autori'); + await expect(table).toContainText(PLAIN); + await expect(table).not.toContainText(UMLAUT); + }); + + test('public catalogue: quoted search from the search box', async ({ page }) => { + await page.goto(`${BASE}/catalogo`); + const grid = page.locator('main'); + + const search = async (value) => { + const done = page.waitForResponse((r) => r.url().includes('search=') && r.status() === 200, { timeout: 15000 }); + await page.fill('#search-input', value); + await page.locator('#search-input').press('End'); + await done; + await page.waitForTimeout(300); + }; + + await search(`${TOKEN} haffner`); + await expect(grid).toContainText(TITLE_PLAIN); + await expect(grid).toContainText(TITLE_UMLAUT); + + await search(`${TOKEN} "haffner"`); + await expect(grid).toContainText(TITLE_PLAIN); + await expect(grid).not.toContainText(TITLE_UMLAUT); + }); +}); From c83eabd9843aa84c91513046ef21b5cc24880829 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 26 Oct 2026 16:01:37 +0100 Subject: [PATCH 02/24] Fix exact searches across catalogue and admin surfaces --- app/Controllers/CollaneController.php | 10 +- app/Controllers/CsvImportController.php | 5 +- app/Controllers/DeweyApiController.php | 7 +- app/Controllers/FrontendController.php | 57 +++-- app/Controllers/GeneriApiController.php | 15 +- .../LibraryThingImportController.php | 18 +- app/Controllers/LibriApiController.php | 21 +- app/Controllers/LibriController.php | 13 +- app/Controllers/PublicApiController.php | 14 +- .../ReservationsAdminController.php | 28 ++- app/Controllers/SearchController.php | 22 +- app/Controllers/UsersController.php | 15 +- app/Controllers/UtentiApiController.php | 4 +- app/Models/AuthorRepository.php | 17 +- app/Models/PublisherRepository.php | 6 +- app/Services/BulkEnrichmentService.php | 10 +- app/Services/UnifiedCatalogService.php | 36 ++- app/Support/ExactSearch.php | 79 ++++++- app/Support/SearchIndexBuilder.php | 14 +- app/Views/autori/index.php | 6 +- app/Views/editori/index.php | 4 +- app/Views/frontend/catalog.php | 2 + app/Views/frontend/home-sections/hero.php | 1 + app/Views/frontend/layout.php | 4 +- app/Views/layout.php | 2 +- app/Views/libri/index.php | 2 +- app/Views/prenotazioni/index.php | 8 +- app/Views/prestiti/crea_prestito.php | 4 +- app/Views/utenti/index.php | 2 +- locale/da_DK.json | 1 + locale/de_DE.json | 1 + locale/en_US.json | 1 + locale/fr_FR.json | 1 + locale/it_IT.json | 1 + public/assets/catalog-pages.css | 8 + public/assets/pinakes-2026.css | 1 + storage/plugins/archives/ArchivesPlugin.php | 122 +++++++--- storage/plugins/book-club/src/Repo.php | 5 +- storage/plugins/emeroteca/EmerotecaPlugin.php | 101 ++++----- .../src/Controllers/PublicController.php | 18 +- .../src/Services/ContributionService.php | 66 ++++-- .../Services/PeriodicalPublisherService.php | 2 +- .../src/Controllers/CollectionsController.php | 10 +- .../Controllers/StaffArchivesController.php | 10 +- .../Controllers/StaffDesiderataController.php | 10 +- .../Controllers/StaffEntitiesController.php | 20 +- .../src/Support/StaffLoanReader.php | 40 ++-- ...tributor-plugin-compatibility-237.unit.php | 9 +- tests/emeroteca-412.unit.php | 26 +++ tests/exact-search-apis-501.unit.php | 125 +++++++++++ tests/exact-search-archives-501.unit.php | 126 +++++++++++ tests/exact-search-articles-501.unit.php | 141 ++++++++++++ tests/exact-search-backend-501.unit.php | 96 ++++++++ tests/exact-search-core-501.unit.php | 89 ++++++++ tests/exact-search-dedup-501.unit.php | 86 +++++++ tests/exact-search-fulltext-501.unit.php | 108 +++++++++ ...act-search-mobile-collections-501.unit.php | 41 ++++ tests/helpers/exact-search-501-seed.php | 109 +++++++++ tests/helpers/exact-search-501.js | 99 ++++++++ tests/helpers/exact-search-501.php | 169 ++++++++++++++ tests/issue-501-admin-lists.spec.js | 211 ++++++++++++++++++ tests/issue-501-exact-quoted-search.spec.js | 5 +- tests/issue-501-name-pages.spec.js | 188 ++++++++++++++++ tests/issue-501-search-fields.spec.js | 111 +++++++++ tests/issue-501-search-hint.spec.js | 83 +++++++ 65 files changed, 2364 insertions(+), 302 deletions(-) create mode 100644 tests/exact-search-apis-501.unit.php create mode 100644 tests/exact-search-archives-501.unit.php create mode 100644 tests/exact-search-articles-501.unit.php create mode 100644 tests/exact-search-backend-501.unit.php create mode 100644 tests/exact-search-core-501.unit.php create mode 100644 tests/exact-search-dedup-501.unit.php create mode 100644 tests/exact-search-fulltext-501.unit.php create mode 100644 tests/exact-search-mobile-collections-501.unit.php create mode 100644 tests/helpers/exact-search-501-seed.php create mode 100644 tests/helpers/exact-search-501.js create mode 100644 tests/helpers/exact-search-501.php create mode 100644 tests/issue-501-admin-lists.spec.js create mode 100644 tests/issue-501-name-pages.spec.js create mode 100644 tests/issue-501-search-fields.spec.js create mode 100644 tests/issue-501-search-hint.spec.js diff --git a/app/Controllers/CollaneController.php b/app/Controllers/CollaneController.php index d78478fad..cce08fffe 100644 --- a/app/Controllers/CollaneController.php +++ b/app/Controllers/CollaneController.php @@ -483,9 +483,9 @@ public function searchApi(Request $request, Response $response, mysqli $db): Res // SEC2-1 + SEC1-5 (review): require min 2 chars and escape LIKE // wildcards so callers can't broaden the match with `%`/`_` or // probe single-char prefixes for enumeration. - if (mb_strlen($q) >= 2) { - $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q); - $search = '%' . $escaped . '%'; + ['term' => $term, 'exact' => $exact] = \App\Support\ExactSearch::parseFilter($q); + if (mb_strlen($term) >= 2) { + $search = \App\Support\ExactSearch::likePattern($term); $hasCollane = $this->hasCollaneTable($db); $sqlParts = []; @@ -497,7 +497,7 @@ public function searchApi(Request $request, Response $response, mysqli $db): Res continue; // no collane table — skip its sources } $col = $src['c']; - $where = "`{$col}` LIKE ? AND `{$col}` IS NOT NULL AND `{$col}` != ''"; + $where = \App\Support\ExactSearch::likeSql("`{$col}`", $exact) . " AND `{$col}` IS NOT NULL AND `{$col}` != ''"; $types .= 's'; $binds[] = $search; if ($src['t'] === 'libri') { @@ -515,7 +515,7 @@ public function searchApi(Request $request, Response $response, mysqli $db): Res } if (!empty($sqlParts)) { - $sql = 'SELECT DISTINCT nome FROM (' . implode(' UNION ', $sqlParts) + $sql = 'SELECT DISTINCT nome COLLATE utf8mb4_bin AS nome FROM (' . implode(' UNION ALL ', $sqlParts) . ') AS combined ORDER BY nome LIMIT 10'; $stmt = $db->prepare($sql); if ($stmt) { diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index a62f45fdf..47b20edb7 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -1488,8 +1488,9 @@ private function mapColumnHeaders(array $headers): array */ private function getOrCreatePublisher(\mysqli $db, string $name): array { - $stmt = $db->prepare("SELECT id FROM editori WHERE nome = ? LIMIT 1"); - $stmt->bind_param('s', $name); + // Exact spelling first when the collation also admits "Haffner" for "Häffner" (#501). + $stmt = $db->prepare("SELECT id FROM editori WHERE nome = ? ORDER BY " . \App\Support\ExactSearch::exactFirstSql('nome') . ", id LIMIT 1"); + $stmt->bind_param('ss', $name, $name); $stmt->execute(); $result = $stmt->get_result(); diff --git a/app/Controllers/DeweyApiController.php b/app/Controllers/DeweyApiController.php index 76047bcb5..ee992e461 100644 --- a/app/Controllers/DeweyApiController.php +++ b/app/Controllers/DeweyApiController.php @@ -319,19 +319,20 @@ public function autocomplete(Request $request, Response $response): Response $query = trim((string) ($request->getQueryParams()['q'] ?? '')); $results = []; try { + ['term' => $query, 'exact' => $exact] = \App\Support\ExactSearch::parseFilter($query); if (mb_strlen($query) >= 2) { $data = $this->loadDeweyData(); if ($this->isNewFormat($data)) { - $needle = self::foldForSearch($query); + $needle = $exact ? mb_strtolower($query, 'UTF-8') : self::foldForSearch($query); $byCode = []; $byName = []; - $walk = function (array $nodes) use (&$walk, &$byCode, &$byName, $query, $needle): void { + $walk = function (array $nodes) use (&$walk, &$byCode, &$byName, $query, $needle, $exact): void { foreach ($nodes as $node) { $code = (string) ($node['code'] ?? ''); $name = (string) ($node['name'] ?? ''); if ($code !== '' && str_starts_with($code, $query)) { $byCode[$code] = ['code' => $code, 'name' => $name]; - } elseif ($name !== '' && str_contains(self::foldForSearch($name), $needle)) { + } elseif ($name !== '' && str_contains($exact ? mb_strtolower($name, 'UTF-8') : self::foldForSearch($name), $needle)) { $byName[$code] = ['code' => $code, 'name' => $name]; } if (!empty($node['children']) && is_array($node['children'])) { diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index eee4c3d2e..c90564cc7 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -1660,15 +1660,19 @@ private function buildWhereConditions(array $filters, mysqli $db): array // #143), so the catalog filter results agree with the publisher // facet count (which already counts secondaries). Gate the junction // subquery on table existence (pre-migration safety). + // The name must match exactly, accents included (#501): the + // collation alone would also return the books of "Häffner" for + // the publisher "Haffner". + $primary = \App\Support\ExactSearch::equalsCondition('e.nome', (string) $filters['editore']); if (\App\Support\SchemaInfo::hasLibriEditori($db)) { - $conditions[] = "(e.nome = ? OR EXISTS (SELECT 1 FROM libri_editori le2 JOIN editori e2 ON le2.editore_id = e2.id WHERE le2.libro_id = l.id AND e2.nome = ?))"; - $params[] = $filters['editore']; - $params[] = $filters['editore']; - $types .= 'ss'; + $secondary = \App\Support\ExactSearch::equalsCondition('e2.nome', (string) $filters['editore']); + $conditions[] = "({$primary['sql']} OR EXISTS (SELECT 1 FROM libri_editori le2 JOIN editori e2 ON le2.editore_id = e2.id WHERE le2.libro_id = l.id AND {$secondary['sql']}))"; + array_push($params, ...$primary['params'], ...$secondary['params']); + $types .= $primary['types'] . $secondary['types']; } else { - $conditions[] = "e.nome = ?"; - $params[] = $filters['editore']; - $types .= 's'; + $conditions[] = $primary['sql']; + array_push($params, ...$primary['params']); + $types .= $primary['types']; } } @@ -1714,12 +1718,17 @@ private function buildWhereConditions(array $filters, mysqli $db): array if (!empty($filters['autore'])) { $variants = \App\Services\UnifiedCatalogService::nameVariants((string)$filters['autore']); - $marks = implode(',', array_fill(0, count($variants), '?')); - $conditions[] = "EXISTS (SELECT 1 FROM libri_autori la_name JOIN autori a_name ON a_name.id=la_name.autore_id - WHERE la_name.libro_id=l.id AND la_name.ruolo IN ('principale','co-autore') - AND (TRIM(a_name.nome) IN ($marks) OR TRIM(a_name.pseudonimo) IN ($marks)))"; - array_push($params, ...$variants, ...$variants); - $types .= str_repeat('s', count($variants) * 2); + // Exact spelling, accents included (#501): "Haffner" must not + // also list the books of "Häffner". + $byName = \App\Support\ExactSearch::inCondition('TRIM(a_name.nome)', $variants); + $byPseudonym = \App\Support\ExactSearch::inCondition('TRIM(a_name.pseudonimo)', $variants); + if ($byName !== null && $byPseudonym !== null) { + $conditions[] = "EXISTS (SELECT 1 FROM libri_autori la_name JOIN autori a_name ON a_name.id=la_name.autore_id + WHERE la_name.libro_id=l.id AND la_name.ruolo IN ('principale','co-autore') + AND ({$byName['sql']} OR {$byPseudonym['sql']}))"; + array_push($params, ...$byName['params'], ...$byPseudonym['params']); + $types .= $byName['types'] . $byPseudonym['types']; + } } if (!empty($filters['autore_id'])) { @@ -1982,7 +1991,7 @@ private function computeFilterOptions(mysqli $db, array $filters = []): array ? " OR EXISTS (SELECT 1 FROM libri_editori le WHERE le.libro_id = l.id AND le.editore_id = e.id)" : ""; $queryEditori = " - SELECT e.nome, COUNT(DISTINCT l.id) AS cnt + SELECT MIN(e.nome) AS nome, COUNT(DISTINCT l.id) AS cnt FROM editori e JOIN libri l ON (e.id = l.editore_id{$facetExists}) AND l.deleted_at IS NULL AND " . $visibility . " @@ -2000,8 +2009,10 @@ private function computeFilterOptions(mysqli $db, array $filters = []): array // (editori.nome has no UNIQUE constraint — same-named rows are legitimate), // so grouping per id would render duplicate labels each with a partial count // that no single filtered result could match. Grouping by name merges them - // and makes each badge equal the count the name filter returns. - $queryEditori .= " GROUP BY e.nome HAVING cnt > 0 ORDER BY e.nome"; + // and makes each badge equal the count the name filter returns. The group + // key keeps accents distinct (#501) — "Haffner" and "Häffner" are two + // publishers — while still ignoring case, exactly like the filter does. + $queryEditori .= " GROUP BY LOWER(e.nome) COLLATE utf8mb4_bin HAVING cnt > 0 ORDER BY nome"; $stmt = $db->prepare($queryEditori); if (!empty($paramsEd)) { @@ -2346,9 +2357,13 @@ public function authorArchive(Request $request, Response $response, mysqli $db, // UNIQUE on nome by design), so resolving a single id with LIMIT 1 would // show an arbitrary homonym and hide the others' bibliography — mirrors // publisherArchive(). The header shows the lowest id, deterministically. - $authorQuery = "SELECT id, nome, pseudonimo, biografia, sito_web, foto, collegamenti FROM autori WHERE nome = ? ORDER BY id"; + // Homonyms only: same spelling, accents included (#501). The collation + // alone would treat "Haffner" and "Häffner" as one name and merge two + // people's bibliographies on one page. + $byName = \App\Support\ExactSearch::equalsCondition('nome', $authorName); + $authorQuery = "SELECT id, nome, pseudonimo, biografia, sito_web, foto, collegamenti FROM autori WHERE {$byName['sql']} ORDER BY id"; $stmt = $db->prepare($authorQuery); - $stmt->bind_param('s', $authorName); + $stmt->bind_param($byName['types'], ...$byName['params']); $stmt->execute(); $authorResult = $stmt->get_result(); @@ -2382,9 +2397,11 @@ public function publisherArchive(Request $request, Response $response, mysqli $d // Find EVERY publisher with this name. Duplicates legitimately share a // name (the merge feature exists because of that), so resolving a single // id with LIMIT 1 would hide the books of the other same-named publishers. - $publisherQuery = "SELECT id, nome, indirizzo, sito_web FROM editori WHERE nome = ? ORDER BY id"; + // Same spelling, accents included (#501): see authorArchive(). + $byName = \App\Support\ExactSearch::equalsCondition('nome', $publisherName); + $publisherQuery = "SELECT id, nome, indirizzo, sito_web FROM editori WHERE {$byName['sql']} ORDER BY id"; $stmt = $db->prepare($publisherQuery); - $stmt->bind_param('s', $publisherName); + $stmt->bind_param($byName['types'], ...$byName['params']); $stmt->execute(); $publisherResult = $stmt->get_result(); diff --git a/app/Controllers/GeneriApiController.php b/app/Controllers/GeneriApiController.php index cbdae404f..15000f7fb 100644 --- a/app/Controllers/GeneriApiController.php +++ b/app/Controllers/GeneriApiController.php @@ -14,22 +14,25 @@ public function search(Request $request, Response $response, \mysqli $db): Respo $limit = min(50, (int) ($request->getQueryParams()['limit'] ?? 20)); $results = []; - if (strlen($query) >= 1) { - $searchTerm = "%{$query}%"; + $search = \App\Support\ExactSearch::filterCondition(['g.nome'], $query); + if ($search !== null) { + ['term' => $term, 'exact' => $exact] = \App\Support\ExactSearch::parseFilter($query); + $prefixMatch = \App\Support\ExactSearch::likeSql('g.nome', $exact); $stmt = $db->prepare(" SELECT g.id, g.nome, g.parent_id, p.nome AS parent_nome, CASE WHEN g.parent_id IS NULL THEN 'genere' ELSE 'sottogenere' END AS tipo FROM generi g LEFT JOIN generi p ON g.parent_id = p.id - WHERE g.nome LIKE ? + WHERE {$search['sql']} ORDER BY - CASE WHEN g.nome LIKE ? THEN 0 ELSE 1 END, + CASE WHEN {$prefixMatch} THEN 0 ELSE 1 END, g.parent_id IS NULL DESC, g.nome LIMIT ? "); - $exactMatch = "{$query}%"; + $searchTerm = $search['params'][0]; + $exactMatch = substr(\App\Support\ExactSearch::likePattern($term), 1); $stmt->bind_param('ssi', $searchTerm, $exactMatch, $limit); $stmt->execute(); $result = $stmt->get_result(); @@ -251,4 +254,4 @@ public function getSottogeneri(Request $request, Response $response, \mysqli $db $response->getBody()->write(json_encode($results, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } -} \ No newline at end of file +} diff --git a/app/Controllers/LibraryThingImportController.php b/app/Controllers/LibraryThingImportController.php index f15dd14b9..138772a8d 100644 --- a/app/Controllers/LibraryThingImportController.php +++ b/app/Controllers/LibraryThingImportController.php @@ -1245,8 +1245,9 @@ private function parseDate(string|int|float|null $dateString): ?string // Reuse methods from CsvImportController private function getOrCreatePublisher(\mysqli $db, string $name): array { - $stmt = $db->prepare("SELECT id FROM editori WHERE nome = ? LIMIT 1"); - $stmt->bind_param('s', $name); + // Exact spelling first when the collation also admits "Haffner" for "Häffner" (#501). + $stmt = $db->prepare("SELECT id FROM editori WHERE nome = ? ORDER BY " . \App\Support\ExactSearch::exactFirstSql('nome') . ", id LIMIT 1"); + $stmt->bind_param('ss', $name, $name); $stmt->execute(); $result = $stmt->get_result(); @@ -2156,13 +2157,12 @@ public function exportToLibraryThing(Request $request, Response $response, \mysq $bindTypes = ''; $bindValues = []; - if (!empty($search)) { - $whereClauses[] = "(l.titolo LIKE ? OR l.sottotitolo LIKE ? OR l.isbn13 LIKE ? OR l.isbn10 LIKE ? OR a.nome LIKE ? OR e.nome LIKE ?)"; - $searchParam = "%{$search}%"; - for ($i = 0; $i < 6; $i++) { - $bindTypes .= 's'; - $bindValues[] = $searchParam; - } + // Quoted value = exact spelling, accents included (#501), as in the list. + $searchCond = \App\Support\SearchIndexBuilder::buildSearchCondition($db, 'l.search_index', (string) $search); + if ($searchCond !== null) { + $whereClauses[] = $searchCond['sql']; + $bindTypes .= $searchCond['types']; + array_push($bindValues, ...$searchCond['params']); } if (!empty($stato)) { diff --git a/app/Controllers/LibriApiController.php b/app/Controllers/LibriApiController.php index c5ee3ed47..27964ae12 100644 --- a/app/Controllers/LibriApiController.php +++ b/app/Controllers/LibriApiController.php @@ -49,12 +49,12 @@ public function list(Request $request, Response $response, mysqli $db): Response } } if ($search_isbn !== '') { - $where .= " AND (l.isbn10 LIKE ? OR l.isbn13 LIKE ? OR l.ean LIKE ?) "; - $isbnParam = '%' . $search_isbn . '%'; - $params[] = $isbnParam; - $params[] = $isbnParam; - $params[] = $isbnParam; - $types .= 'sss'; + $isbn = \App\Support\ExactSearch::filterCondition(['l.isbn10', 'l.isbn13', 'l.ean'], $search_isbn); + if ($isbn !== null) { + $where .= ' AND ' . $isbn['sql']; + array_push($params, ...$isbn['params']); + $types .= $isbn['types']; + } } if ($genere_id) { // Search both genere_id and sottogenere_id, plus children of the selected genre @@ -137,9 +137,12 @@ public function list(Request $request, Response $response, mysqli $db): Response $types .= 'i'; } if ($collana !== '') { - $where .= " AND l.collana LIKE ?"; - $params[] = '%' . $collana . '%'; - $types .= 's'; + $series = \App\Support\ExactSearch::filterCondition(['l.collana'], $collana); + if ($series !== null) { + $where .= ' AND ' . $series['sql']; + array_push($params, ...$series['params']); + $types .= $series['types']; + } } if ($tipo_media !== '' && $this->hasColumn($db, 'tipo_media')) { if ($tipo_media === 'libro') { diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index dacbdf7c5..5ca8e94d9 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -3409,13 +3409,12 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res $bindValues = []; // Global search filter - if (!empty($search)) { - $whereClauses[] = "(l.titolo LIKE ? OR l.sottotitolo LIKE ? OR l.descrizione LIKE ? OR l.isbn13 LIKE ? OR l.isbn10 LIKE ? OR a.nome LIKE ? OR e.nome LIKE ?)"; - $searchParam = "%{$search}%"; - for ($i = 0; $i < 7; $i++) { - $bindTypes .= 's'; - $bindValues[] = $searchParam; - } + // Quoted value = exact spelling, accents included (#501), as in the list. + $searchCond = \App\Support\SearchIndexBuilder::buildSearchCondition($db, 'l.search_index', (string) $search); + if ($searchCond !== null) { + $whereClauses[] = $searchCond['sql']; + $bindTypes .= $searchCond['types']; + array_push($bindValues, ...$searchCond['params']); } // Status filter diff --git a/app/Controllers/PublicApiController.php b/app/Controllers/PublicApiController.php index 621b09ceb..616648e07 100644 --- a/app/Controllers/PublicApiController.php +++ b/app/Controllers/PublicApiController.php @@ -80,18 +80,20 @@ private function findBooks(mysqli $db, ?string $ean, ?string $isbn13, ?string $i $types .= 's'; } - if ($author !== null && $author !== '') { - $authorLike = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $author) . '%'; + // Quoted value = exact spelling, accents included (#501). + $authorCond = ($author !== null && $author !== '') + ? \App\Support\ExactSearch::filterCondition(['a.nome', 'a.pseudonimo'], $author) + : null; + if ($authorCond !== null) { $conditions[] = 'EXISTS ( SELECT 1 FROM libri_autori la JOIN autori a ON la.autore_id = a.id WHERE la.libro_id = l.id AND la.ruolo IN (\'principale\', \'co-autore\') - AND (a.nome LIKE ? ESCAPE \'\\\\\' OR a.pseudonimo LIKE ? ESCAPE \'\\\\\') + AND ' . $authorCond['sql'] . ' )'; - $params[] = $authorLike; - $params[] = $authorLike; - $types .= 'ss'; + array_push($params, ...$authorCond['params']); + $types .= $authorCond['types']; } if (empty($conditions)) { diff --git a/app/Controllers/ReservationsAdminController.php b/app/Controllers/ReservationsAdminController.php index 9c89c3aba..6a1810f73 100644 --- a/app/Controllers/ReservationsAdminController.php +++ b/app/Controllers/ReservationsAdminController.php @@ -35,15 +35,17 @@ public function index(Request $request, Response $response, mysqli $db): Respons $types .= 'i'; $params[] = $utenteId; } - if ($libroId <= 0 && $qLibro !== '') { - $conds[] = 'l.titolo LIKE ?'; - $types .= 's'; - $params[] = '%' . $qLibro . '%'; - } - if ($utenteId <= 0 && $qUtente !== '') { - $conds[] = "CONCAT(u.nome,' ',u.cognome) LIKE ?"; - $types .= 's'; - $params[] = '%' . $qUtente . '%'; + // Quoted value = exact spelling, accents included (#501). + foreach ([ + ['l.titolo', $libroId <= 0 ? $qLibro : ''], + ["CONCAT(u.nome,' ',u.cognome)", $utenteId <= 0 ? $qUtente : ''], + ] as [$expr, $value]) { + $cond = $value !== '' ? \App\Support\ExactSearch::filterCondition([$expr], $value) : null; + if ($cond !== null) { + $conds[] = $cond['sql']; + $types .= $cond['types']; + array_push($params, ...$cond['params']); + } } if ($conds) { $sql .= ' WHERE ' . implode(' AND ', $conds); @@ -68,6 +70,14 @@ public function index(Request $request, Response $response, mysqli $db): Respons } } + // The view reads these names. They were never set, so the list always + // rendered empty and the filter boxes lost what had been typed. + $items = $rows; + $q_libro = $qLibro; + $q_utente = $qUtente; + $libro_id = $libroId; + $utente_id = $utenteId; + ob_start(); require __DIR__ . '/../Views/prenotazioni/index.php'; $content = ob_get_clean(); diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php index b65a538e4..47c217aa1 100644 --- a/app/Controllers/SearchController.php +++ b/app/Controllers/SearchController.php @@ -91,9 +91,11 @@ public function users(Request $request, Response $response, mysqli $db): Respons { $q = trim((string)($request->getQueryParams()['q'] ?? '')); $rows=[]; - // Quoted value = exact spelling, accents included (#501). + // Quoted value = exact spelling, accents included (#501). The full + // name in either order is searched too, so "Mario Rossi" — what the + // label shows — finds Mario Rossi, as the mobile staff picker does. $search = $q !== '' - ? \App\Support\ExactSearch::filterCondition(['nome', 'cognome', 'telefono', 'email', 'codice_tessera'], $q) + ? \App\Support\ExactSearch::filterCondition(['nome', 'cognome', 'telefono', 'email', 'codice_tessera', "CONCAT(nome, ' ', cognome)", "CONCAT(cognome, ' ', nome)"], $q) : null; if ($search !== null) { $stmt = $db->prepare(" @@ -607,10 +609,10 @@ public function genres(Request $request, Response $response, mysqli $db): Respon { $q = trim((string)($request->getQueryParams()['q'] ?? '')); $rows=[]; - if ($q !== '') { - $s = '%'.$q.'%'; - $stmt = $db->prepare("SELECT id, nome AS label FROM generi WHERE nome LIKE ? ORDER BY nome LIMIT 20"); - $stmt->bind_param('s', $s); + $search = \App\Support\ExactSearch::filterCondition(['nome'], $q); + if ($search !== null) { + $stmt = $db->prepare("SELECT id, nome AS label FROM generi WHERE {$search['sql']} ORDER BY nome LIMIT 20"); + $stmt->bind_param($search['types'], ...$search['params']); $stmt->execute(); $res = $stmt->get_result(); while ($r = $res->fetch_assoc()) { @@ -626,8 +628,8 @@ public function locations(Request $request, Response $response, mysqli $db): Res { $q = trim((string)($request->getQueryParams()['q'] ?? '')); $rows = []; - if ($q !== '') { - $s = '%' . $q . '%'; + $search = \App\Support\ExactSearch::filterCondition(['s.nome', 's.codice', 'CAST(m.numero_livello AS CHAR)'], $q); + if ($search !== null) { $stmt = $db->prepare(" SELECT p.id, CONCAT( @@ -638,11 +640,11 @@ public function locations(Request $request, Response $response, mysqli $db): Res FROM posizioni p JOIN scaffali s ON p.scaffale_id = s.id JOIN mensole m ON p.mensola_id = m.id - WHERE s.nome LIKE ? OR s.codice LIKE ? OR CAST(m.numero_livello AS CHAR) LIKE ? + WHERE {$search['sql']} ORDER BY s.ordine, m.numero_livello LIMIT 20 "); - $stmt->bind_param('sss', $s, $s, $s); + $stmt->bind_param($search['types'], ...$search['params']); $stmt->execute(); $res = $stmt->get_result(); while ($r = $res->fetch_assoc()) { diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index cb9de24fa..01202f669 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -967,13 +967,14 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res $bindValues = []; // Search text filter (nome, cognome, email) - if (!empty($searchText)) { - $whereClauses[] = "(nome LIKE ? OR cognome LIKE ? OR email LIKE ?)"; - $searchParam = "%{$searchText}%"; - for ($i = 0; $i < 3; $i++) { - $bindTypes .= 's'; - $bindValues[] = $searchParam; - } + // Quoted value = exact spelling, accents included (#501), as in the list. + $search = !empty($searchText) + ? \App\Support\ExactSearch::filterCondition(['nome', 'cognome', 'email', 'telefono', 'codice_tessera', "CONCAT(nome, ' ', cognome)", "CONCAT(cognome, ' ', nome)"], (string) $searchText) + : null; + if ($search !== null) { + $whereClauses[] = $search['sql']; + $bindTypes .= $search['types']; + array_push($bindValues, ...$search['params']); } // Role filter diff --git a/app/Controllers/UtentiApiController.php b/app/Controllers/UtentiApiController.php index f842a5bf9..79c425f76 100644 --- a/app/Controllers/UtentiApiController.php +++ b/app/Controllers/UtentiApiController.php @@ -26,7 +26,7 @@ public function list(Request $request, Response $response, mysqli $db): Response // Quoted value = exact spelling, accents included (#501). $search = $search_value !== '' - ? \App\Support\ExactSearch::filterCondition(['u.nome', 'u.cognome', 'u.email', 'u.telefono', 'u.codice_tessera'], $search_value) + ? \App\Support\ExactSearch::filterCondition(['u.nome', 'u.cognome', 'u.email', 'u.telefono', 'u.codice_tessera', "CONCAT(u.nome, ' ', u.cognome)", "CONCAT(u.cognome, ' ', u.nome)"], $search_value) : null; if ($search !== null) { $where .= ' AND ' . $search['sql']; @@ -181,7 +181,7 @@ public function exportPdf(Request $request, Response $response, mysqli $db): Res $search_value = trim((string)($q['search_text'] ?? ($q['search_value'] ?? ''))); // Quoted value = exact spelling, accents included (#501). $search = $search_value !== '' - ? \App\Support\ExactSearch::filterCondition(['u.nome', 'u.cognome', 'u.email', 'u.telefono', 'u.codice_tessera'], $search_value) + ? \App\Support\ExactSearch::filterCondition(['u.nome', 'u.cognome', 'u.email', 'u.telefono', 'u.codice_tessera', "CONCAT(u.nome, ' ', u.cognome)", "CONCAT(u.cognome, ' ', u.nome)"], $search_value) : null; if ($search !== null) { $where .= ' AND ' . $search['sql']; diff --git a/app/Models/AuthorRepository.php b/app/Models/AuthorRepository.php index 98fad3646..3319b2ea0 100644 --- a/app/Models/AuthorRepository.php +++ b/app/Models/AuthorRepository.php @@ -310,9 +310,14 @@ public function findByCanonicalName(string $name): ?int // Normalize the input name first (this is the key fix!) $normalizedInput = \App\Support\AuthorNormalizer::normalize($name); + // The collation folds accents, so `nome = ?` also admits "Haffner" + // for "Häffner". When both are on file the exact spelling must win + // (#501): order the byte-identical row first in every equality step. + $exactFirst = \App\Support\ExactSearch::exactFirstSql('nome'); + // First try exact match with normalized name - $stmt = $this->db->prepare("SELECT id FROM autori WHERE nome = ? LIMIT 1"); - $stmt->bind_param('s', $normalizedInput); + $stmt = $this->db->prepare("SELECT id FROM autori WHERE nome = ? ORDER BY {$exactFirst}, id LIMIT 1"); + $stmt->bind_param('ss', $normalizedInput, $normalizedInput); $stmt->execute(); $res = $stmt->get_result(); $row = $res->fetch_assoc(); @@ -323,8 +328,8 @@ public function findByCanonicalName(string $name): ?int // Also try the original name (in case DB has unnormalized entries) if ($normalizedInput !== $name) { - $stmt = $this->db->prepare("SELECT id FROM autori WHERE nome = ? LIMIT 1"); - $stmt->bind_param('s', $name); + $stmt = $this->db->prepare("SELECT id FROM autori WHERE nome = ? ORDER BY {$exactFirst}, id LIMIT 1"); + $stmt->bind_param('ss', $name, $name); $stmt->execute(); $res = $stmt->get_result(); $row = $res->fetch_assoc(); @@ -336,8 +341,8 @@ public function findByCanonicalName(string $name): ?int // Try case-insensitive match with normalized name $lowerNormalized = mb_strtolower($normalizedInput, 'UTF-8'); - $stmt = $this->db->prepare("SELECT id FROM autori WHERE LOWER(nome) = ? LIMIT 1"); - $stmt->bind_param('s', $lowerNormalized); + $stmt = $this->db->prepare("SELECT id FROM autori WHERE LOWER(nome) = ? ORDER BY (LOWER(nome) COLLATE utf8mb4_bin = ?) DESC, id LIMIT 1"); + $stmt->bind_param('ss', $lowerNormalized, $lowerNormalized); $stmt->execute(); $res = $stmt->get_result(); $row = $res->fetch_assoc(); diff --git a/app/Models/PublisherRepository.php b/app/Models/PublisherRepository.php index 9d37cb224..160b05495 100644 --- a/app/Models/PublisherRepository.php +++ b/app/Models/PublisherRepository.php @@ -283,8 +283,10 @@ public function findByName(string $name): ?int { $name = trim($name); if ($name === '') return null; - $stmt = $this->db->prepare('SELECT id FROM editori WHERE nome = ? LIMIT 1'); - $stmt->bind_param('s', $name); + // The collation folds accents, so "Häffner" also matches "Haffner"; + // when both are on file the exact spelling wins (#501). + $stmt = $this->db->prepare('SELECT id FROM editori WHERE nome = ? ORDER BY ' . \App\Support\ExactSearch::exactFirstSql('nome') . ', id LIMIT 1'); + $stmt->bind_param('ss', $name, $name); $stmt->execute(); $stmt->bind_result($id); if ($stmt->fetch()) { return (int)$id; } diff --git a/app/Services/BulkEnrichmentService.php b/app/Services/BulkEnrichmentService.php index fede3b7f4..1cce05885 100644 --- a/app/Services/BulkEnrichmentService.php +++ b/app/Services/BulkEnrichmentService.php @@ -695,12 +695,13 @@ private function findOrCreatePublisher(string $name): ?int } // Try to find existing publisher - $stmt = $this->db->prepare("SELECT id FROM editori WHERE nome = ? LIMIT 1"); + // Exact spelling first when the collation also admits "Haffner" for "Häffner" (#501). + $stmt = $this->db->prepare("SELECT id FROM editori WHERE nome = ? ORDER BY " . \App\Support\ExactSearch::exactFirstSql('nome') . ", id LIMIT 1"); if ($stmt === false) { return null; } - $stmt->bind_param('s', $name); + $stmt->bind_param('ss', $name, $name); $stmt->execute(); $result = $stmt->get_result(); $row = $result->fetch_assoc(); @@ -792,12 +793,13 @@ private function findOrCreateAuthor(string $name): ?int } // Try to find existing author - $stmt = $this->db->prepare("SELECT id FROM autori WHERE nome = ? LIMIT 1"); + // Exact spelling first when the collation also admits "Haffner" for "Häffner" (#501). + $stmt = $this->db->prepare("SELECT id FROM autori WHERE nome = ? ORDER BY " . \App\Support\ExactSearch::exactFirstSql('nome') . ", id LIMIT 1"); if ($stmt === false) { return null; } - $stmt->bind_param('s', $name); + $stmt->bind_param('ss', $name, $name); $stmt->execute(); $result = $stmt->get_result(); $row = $result->fetch_assoc(); diff --git a/app/Services/UnifiedCatalogService.php b/app/Services/UnifiedCatalogService.php index 6f74bbb57..8c60c4bd9 100644 --- a/app/Services/UnifiedCatalogService.php +++ b/app/Services/UnifiedCatalogService.php @@ -180,13 +180,39 @@ private function articleQuery(array $filters): ?array if ($term !== '') { // Same word-wise semantics as the book index; punctuation in an // inverted personal name must not prevent a natural-order search. - $words = preg_split('/[^\p{L}\p{N}_%]+/u', mb_substr($term, 0, 200), -1, PREG_SPLIT_NO_EMPTY) ?: []; - foreach (array_slice($words, 0, 20) as $word) { - $textMatch = "CONCAT_WS(' ', c.titolo, c.sottotitolo, c.autori, c.contenitore_titolo, c.keywords, c.abstract, c.issn) LIKE ? ESCAPE '='"; - $pattern = '%' . strtr($word, ['=' => '==', '%' => '=%', '_' => '=_']) . '%'; + // A quoted group is one exact term (#501), like in the book search, + // so articles by "Häffner" do not answer "haffner" in quotes. + $terms = []; + $tokens = \App\Support\ExactSearch::tokenize(mb_substr($term, 0, 200)); + foreach ($tokens as $token) { + if ($token['exact']) { + $terms[] = $token; + continue; + } + foreach (preg_split('/[^\p{L}\p{N}_%]+/u', $token['term'], -1, PREG_SPLIT_NO_EMPTY) ?: [] as $word) { + $terms[] = ['term' => $word, 'exact' => false]; + } + } + if ($tokens !== [] && $terms === []) { + // Only punctuation was typed ("!!!", a garbled "??"): the book + // search looks for it literally and finds nothing, so the + // articles must not answer with the whole collection. + $where[] = '1=0'; + } + $articleText = "CONCAT_WS(' ', c.titolo, c.sottotitolo, c.autori, c.contenitore_titolo, c.keywords, c.abstract, c.issn)"; + foreach (array_slice($terms, 0, 20) as ['term' => $word, 'exact' => $exact]) { + if ($exact) { + $textMatch = \App\Support\ExactSearch::likeSql($articleText, true); + $authorMatch = \App\Support\ExactSearch::likeSql("CONCAT_WS(' ',a.nome,a.pseudonimo)", true); + $pattern = \App\Support\ExactSearch::likePattern($word); + } else { + $textMatch = "$articleText LIKE ? ESCAPE '='"; + $authorMatch = "CONCAT_WS(' ',a.nome,a.pseudonimo) LIKE ? ESCAPE '='"; + $pattern = '%' . strtr($word, ['=' => '==', '%' => '=%', '_' => '=_']) . '%'; + } $params[] = $pattern; if ($linkedAuthors) { - $textMatch = "($textMatch OR EXISTS (SELECT 1 FROM emeroteca_contributi_autori ca JOIN autori a ON a.id=ca.autore_id WHERE ca.contributo_id=c.id AND CONCAT_WS(' ',a.nome,a.pseudonimo) LIKE ? ESCAPE '='))"; + $textMatch = "($textMatch OR EXISTS (SELECT 1 FROM emeroteca_contributi_autori ca JOIN autori a ON a.id=ca.autore_id WHERE ca.contributo_id=c.id AND $authorMatch))"; $params[] = $pattern; } $where[] = $textMatch; diff --git a/app/Support/ExactSearch.php b/app/Support/ExactSearch.php index c40b5f3be..47f504227 100644 --- a/app/Support/ExactSearch.php +++ b/app/Support/ExactSearch.php @@ -38,6 +38,7 @@ private function __construct() */ public static function tokenize(string $query): array { + $query = self::scrub($query); $q = '[' . preg_quote(self::QUOTES, '/') . ']'; $parts = preg_split('/(' . $q . '[^' . preg_quote(self::QUOTES, '/') . ']*(?:' . $q . '|$))/u', $query, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); if ($parts === false) { @@ -62,19 +63,73 @@ public static function tokenize(string $query): array /** * Parse a single-value column filter (admin list "Name" box): the whole - * value is one term, exact when it starts with a quote. + * value is one term, exact when it starts with a quote. Several quoted + * groups in the box (`"Thomas" "Mann"`) are joined into one phrase, so a + * quote never ends up inside the LIKE pattern as a literal character. * * @return array{term: string, exact: bool} */ public static function parseFilter(string $value): array { - $value = trim($value); + $value = trim(self::scrub($value)); if ($value !== '' && preg_match('/^[' . preg_quote(self::QUOTES, '/') . ']/u', $value) === 1) { - return ['term' => self::collapse(self::stripQuotes($value)), 'exact' => true]; + $words = array_column(self::tokenize($value), 'term'); + return ['term' => self::collapse(implode(' ', $words)), 'exact' => true]; } return ['term' => $value, 'exact' => false]; } + /** + * WHERE fragment for "$expr is exactly $value": accents distinct, case + * ignored. For lookups by a name taken from a link (author page, publisher + * filter), where the collation would otherwise treat `Haffner` and + * `Häffner` as the same name. The plain `=` stays first so an index on + * $expr still narrows the rows; the binary comparison then drops the + * accent-folded matches. + * + * @return array{sql: string, params: list, types: string} + */ + public static function equalsCondition(string $expr, string $value): array + { + return [ + 'sql' => "({$expr} = ? AND LOWER({$expr}) COLLATE utf8mb4_bin = LOWER(?))", + 'params' => [$value, $value], + 'types' => 'ss', + ]; + } + + /** + * Same as equalsCondition() for a list of accepted values (IN). + * + * @param list $values + * @return array{sql: string, params: list, types: string}|null + */ + public static function inCondition(string $expr, array $values): ?array + { + if ($values === []) { + return null; + } + $plain = implode(',', array_fill(0, count($values), '?')); + $lowered = implode(',', array_fill(0, count($values), 'LOWER(?)')); + return [ + 'sql' => "({$expr} IN ({$plain}) AND LOWER({$expr}) COLLATE utf8mb4_bin IN ({$lowered}))", + 'params' => array_merge($values, $values), + 'types' => str_repeat('s', 2 * count($values)), + ]; + } + + /** + * ORDER BY fragment that puts the exact spelling (accents included, case + * ignored) first among rows an accent-folding `$expr = ?` admitted. Bind + * the same value again. Used by find-by-name lookups so an existing + * `Häffner` wins over `Haffner` when both are on file, also when the + * incoming name differs only in case. + */ + public static function exactFirstSql(string $expr): string + { + return "(LOWER({$expr}) COLLATE utf8mb4_bin = LOWER(?)) DESC"; + } + /** * WHERE fragment for a single-value filter matched against any of $exprs * (OR), honouring quotes via parseFilter(). Null when nothing is left to @@ -109,11 +164,15 @@ public static function filterCondition(array $exprs, string $value): ?array * @param list $exprs * @return array{sql: string, params: list, types: string}|null */ - public static function wordsCondition(array $exprs, string $query): ?array + public static function wordsCondition(array $exprs, string $query, ?int $maxTerms = null): ?array { $sql = []; $params = []; - foreach (self::tokenize($query) as ['term' => $term, 'exact' => $exact]) { + $terms = self::tokenize($query); + if ($maxTerms !== null) { + $terms = array_slice($terms, 0, max(0, $maxTerms)); + } + foreach ($terms as ['term' => $term, 'exact' => $exact]) { $or = []; foreach ($exprs as $expr) { $or[] = self::likeSql($expr, $exact); @@ -147,6 +206,16 @@ public static function likePattern(string $term): string return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $term) . '%'; } + /** + * Replace invalid UTF-8 so the /u regexes cannot fail. A failed split + * would yield no term at all, and "no term" means "no filter": a garbled + * query would then list the whole catalogue instead of nothing. + */ + private static function scrub(string $value): string + { + return mb_check_encoding($value, 'UTF-8') ? $value : mb_scrub($value, 'UTF-8'); + } + private static function stripQuotes(string $value): string { $q = preg_quote(self::QUOTES, '/'); diff --git a/app/Support/SearchIndexBuilder.php b/app/Support/SearchIndexBuilder.php index 9a8c17f86..04eb72a34 100644 --- a/app/Support/SearchIndexBuilder.php +++ b/app/Support/SearchIndexBuilder.php @@ -323,12 +323,14 @@ public static function buildSearchCondition(\mysqli $db, string $column, string foreach (self::searchTerms($searchQuery) as ['term' => $wordBase, 'exact' => $exact]) { if ($exact) { // Quoted term (#501): FULLTEXT still narrows the rows — every - // indexable word must be present as a whole word, accent-folded — - // and the binary-collation LIKE then keeps only the exact - // spelling/phrase. + // indexable word must start a word, accent-folded, the same + // prefix rule an unquoted word follows — and the binary-collation + // LIKE then keeps only the exact spelling/phrase. Without the + // `*`, "haff" would demand the whole word "haff" and find + // nothing while the autocomplete above it lists Haffner. foreach (self::fulltextTokens($wordBase) as $token) { if (self::fulltextTermForWord($token) !== null) { - $booleanTerms[] = '+' . $token; + $booleanTerms[] = '+' . $token . '*'; } } $parts[] = ExactSearch::likeSql($column, true); @@ -564,7 +566,9 @@ private static function fulltextTermForWord(string $word): ?string */ private static function fulltextTokens(string $word): array { - if (preg_match_all('/[\p{L}\p{N}_]+/u', $word, $matches) !== 1) { + // Every token, not only a lone one: a quoted phrase needs each of its + // words (fulltextTermForWord() still rejects a multi-token word). + if (!preg_match_all('/[\p{L}\p{N}_]+/u', $word, $matches)) { return []; } return array_values(array_filter($matches[0], static fn(string $token): bool => $token !== '')); diff --git a/app/Views/autori/index.php b/app/Views/autori/index.php index c62ceb3a5..f99f5795e 100644 --- a/app/Views/autori/index.php +++ b/app/Views/autori/index.php @@ -56,10 +56,10 @@
-
@@ -237,7 +237,7 @@ class="form-input" /> url: (window.BASE_PATH || '') + '/api/autori', type: 'GET', data: function(d) { - d.search_nome = document.getElementById('search_nome').value; + d.search_text = document.getElementById('search_nome').value; d.search_pseudonimo = document.getElementById('search_pseudonimo').value; d.search_nazionalita = document.getElementById('search_nazionalita').value; d.search_sito = document.getElementById('search_sito').value; diff --git a/app/Views/editori/index.php b/app/Views/editori/index.php index 1bf0c43cc..094335b40 100644 --- a/app/Views/editori/index.php +++ b/app/Views/editori/index.php @@ -56,10 +56,10 @@
-
diff --git a/app/Views/frontend/catalog.php b/app/Views/frontend/catalog.php index cbd3ce302..b8697b790 100644 --- a/app/Views/frontend/catalog.php +++ b/app/Views/frontend/catalog.php @@ -237,6 +237,7 @@ class="filters-mobile-toggle" +

diff --git a/app/Views/frontend/home-sections/hero.php b/app/Views/frontend/home-sections/hero.php index 59ed44d86..0e0c88d41 100644 --- a/app/Views/frontend/home-sections/hero.php +++ b/app/Views/frontend/home-sections/hero.php @@ -55,6 +55,7 @@ - " aria-label="" class="w-full pl-12 pr-4 py-3 lg:py-3.5 text-sm text-gray-800 bg-gray-50 border border-gray-300 rounded-2xl shadow-sm hover:shadow-md focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-500/20 focus:bg-white transition-all duration-200 placeholder:text-gray-400" autocomplete="off"> diff --git a/app/Views/libri/index.php b/app/Views/libri/index.php index b1c1d4526..78051e3c0 100644 --- a/app/Views/libri/index.php +++ b/app/Views/libri/index.php @@ -138,7 +138,7 @@ - " id="search_text" type="text" placeholder="" class="form-input" />
diff --git a/app/Views/prenotazioni/index.php b/app/Views/prenotazioni/index.php index 524d728b4..53ed04d3e 100644 --- a/app/Views/prenotazioni/index.php +++ b/app/Views/prenotazioni/index.php @@ -31,14 +31,14 @@
- - + + " type="text" id="admin_filter_libro" name="q_libro" class="form-input" placeholder="" value="" autocomplete="off">
    - - + + " type="text" id="admin_filter_utente" name="q_utente" class="form-input" placeholder="" value="" autocomplete="off">
      diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index acc43c7ce..94afd4dab 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -149,7 +149,7 @@ class="space-y-6 bg-white p-6 rounded-2xl border border-gray-200 shadow">
      - > + " type="text" id="utente_search" placeholder="" class="block w-full rounded-lg border border-gray-300 px-4 py-2 dark:border-gray-700 dark:bg-gray-900 dark:text-white " autocomplete="off" value="" >
      0 && !$presetUserLocked): ?> @@ -172,7 +172,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2.5 bg-gray-100
      - + " type="text" id="libro_search" placeholder="" class="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 dark:border-gray-700 dark:bg-gray-900 dark:text-white" autocomplete="off" value="">
      diff --git a/app/Views/utenti/index.php b/app/Views/utenti/index.php index fe1edc237..62443edff 100644 --- a/app/Views/utenti/index.php +++ b/app/Views/utenti/index.php @@ -193,7 +193,7 @@ class="btn-icon btn-secondary p-2 text-gray-500 hover:text-gray-700 hover:bg-gra - + " id="search_text" placeholder="" class="form-input" />
      diff --git a/locale/da_DK.json b/locale/da_DK.json index 6f22831d9..44d0a0a0b 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -814,6 +814,7 @@ "Cerca testo": "Søg tekst", "Cerca titoli": "Søg titler", "Cerca titoli, autori, ISBN...": "Søg titler, forfattere, ISBN...", + "Usa le virgolette per la grafia esatta: \"Häffner\" trova solo Häffner, non Haffner.": "Brug anførselstegn for den nøjagtige stavemåde: \"Häffner\" finder kun Häffner, ikke Haffner.", "Cerca un authority record…": "Søg efter en normpost…", "Cerca un soggetto e premi Invio per aggiungere…": "Søg efter et emne og tryk på Enter for at tilføje…", "Cerca...": "Søg...", diff --git a/locale/de_DE.json b/locale/de_DE.json index ce1ecce77..5f325ba27 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -814,6 +814,7 @@ "Cerca testo": "Text suchen", "Cerca titoli": "Titel suchen", "Cerca titoli, autori, ISBN...": "Titel, Autoren, ISBN suchen...", + "Usa le virgolette per la grafia esatta: \"Häffner\" trova solo Häffner, non Haffner.": "Anführungszeichen suchen die genaue Schreibweise: \"Häffner\" findet nur Häffner, nicht Haffner.", "Cerca un authority record…": "Normdatensatz suchen…", "Cerca un soggetto e premi Invio per aggiungere…": "Schlagwort suchen und mit Enter hinzufügen…", "Cerca...": "Suchen...", diff --git a/locale/en_US.json b/locale/en_US.json index 22c255445..e80586f45 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -814,6 +814,7 @@ "Cerca testo": "Search text", "Cerca titoli": "Search Titles", "Cerca titoli, autori, ISBN...": "Search titles, authors, ISBN...", + "Usa le virgolette per la grafia esatta: \"Häffner\" trova solo Häffner, non Haffner.": "Use quotes for the exact spelling: \"Häffner\" finds only Häffner, not Haffner.", "Cerca un authority record…": "Search an authority record…", "Cerca un soggetto e premi Invio per aggiungere…": "Search a subject and press Enter to add…", "Cerca...": "Search...", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index f1b9d12dd..146ce52b7 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -814,6 +814,7 @@ "Cerca testo": "Rechercher un texte", "Cerca titoli": "Rechercher des titres", "Cerca titoli, autori, ISBN...": "Rechercher titres, auteurs, ISBN...", + "Usa le virgolette per la grafia esatta: \"Häffner\" trova solo Häffner, non Haffner.": "Utilisez des guillemets pour l’orthographe exacte : \"Häffner\" ne trouve que Häffner, pas Haffner.", "Cerca un authority record…": "Rechercher une notice d'autorité…", "Cerca un soggetto e premi Invio per aggiungere…": "Recherchez un sujet et appuyez sur Entrée pour l'ajouter…", "Cerca...": "Rechercher...", diff --git a/locale/it_IT.json b/locale/it_IT.json index a25b40902..0cc3e40c2 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -814,6 +814,7 @@ "Cerca testo": "Cerca testo", "Cerca titoli": "Cerca titoli", "Cerca titoli, autori, ISBN...": "Cerca titoli, autori, ISBN...", + "Usa le virgolette per la grafia esatta: \"Häffner\" trova solo Häffner, non Haffner.": "Usa le virgolette per la grafia esatta: \"Häffner\" trova solo Häffner, non Haffner.", "Cerca un authority record…": "Cerca un authority record…", "Cerca un soggetto e premi Invio per aggiungere…": "Cerca un soggetto e premi Invio per aggiungere…", "Cerca...": "Cerca...", diff --git a/public/assets/catalog-pages.css b/public/assets/catalog-pages.css index 43e2b0e72..250fb1ff6 100644 --- a/public/assets/catalog-pages.css +++ b/public/assets/catalog-pages.css @@ -178,6 +178,14 @@ width: 18px; } +/* Under the catalogue search box: quotes = exact spelling (#501). */ +.search-hint { + margin: 0.375rem 0 0; + font-size: 0.75rem; + line-height: 1.4; + color: var(--text-muted); +} + .filter-options { display: flex; flex-direction: column; diff --git a/public/assets/pinakes-2026.css b/public/assets/pinakes-2026.css index b77d1a61c..94881cc85 100644 --- a/public/assets/pinakes-2026.css +++ b/public/assets/pinakes-2026.css @@ -624,6 +624,7 @@ body.pk main :is(.pk-filters, .filters-panel) :is(.search-box, .pk-filter-search body.pk main :is(.pk-filters, .filters-panel) :is(.search-box, .pk-filter-search) input:not([type=checkbox], [type=radio]) { font-size: 16px; } } body.pk :is(.pk-filters, .filters-panel) .search-box:focus-within, body.pk :is(.pk-filters, .filters-panel) .pk-filter-search:focus-within { border-color: var(--pk-accent); } +body.pk :is(.pk-filters, .filters-panel) .search-hint { margin: 6px 2px 0; font-size: 12px; line-height: 1.4; color: var(--pk-muted); } /* Authors, publishers, genres: a list of checkbox rows in a white box. */ body.pk :is(.pk-filters, .filters-panel) #authors-filter, body.pk :is(.pk-filters, .filters-panel) #publishers-filter, body.pk :is(.pk-filters, .filters-panel) #genres-filter { diff --git a/storage/plugins/archives/ArchivesPlugin.php b/storage/plugins/archives/ArchivesPlugin.php index a53612dfa..74adefc33 100644 --- a/storage/plugins/archives/ArchivesPlugin.php +++ b/storage/plugins/archives/ArchivesPlugin.php @@ -1411,10 +1411,10 @@ public function indexAction( $bindValues = []; if ($q !== '') { - $pattern = $this->archiveSearchPattern($q); - $whereParts[] = '(reference_code LIKE ? OR constructed_title LIKE ? OR formal_title LIKE ? OR scope_content LIKE ?)'; - $bindTypes .= 'ssss'; - $bindValues = array_merge($bindValues, [$pattern, $pattern, $pattern, $pattern]); + $match = $this->archiveSearchCondition($q); + $whereParts[] = $match['sql']; + $bindTypes .= $match['types']; + $bindValues = array_merge($bindValues, $match['params']); } if ($level !== '') { $whereParts[] = 'level = ?'; @@ -2964,14 +2964,19 @@ public function searchAutori(string $q, int $limit = 50): array // collapsing the predicate to `LIKE '%%%'` or matching any // single-char name. Mirrors the addcslashes pattern applied at // CQL parser (3510) and searchArchivalUnits (4679). - $like = '%' . addcslashes($q, '%_\\') . '%'; + // Quoted value = exact spelling, accents included (#501). + $match = \App\Support\ExactSearch::filterCondition(['a.nome', 'a.pseudonimo'], $q); + if ($match === null) { + return $rows; + } $stmt = $this->db->prepare("SELECT a.id, {$display} AS nome FROM autori a - WHERE a.nome LIKE ? OR a.pseudonimo LIKE ? + WHERE {$match['sql']} ORDER BY nome LIMIT ?"); if ($stmt === false) { return $rows; } - $stmt->bind_param('ssi', $like, $like, $limit); + $bindValues = [...$match['params'], $limit]; + $stmt->bind_param($match['types'] . 'i', ...$bindValues); $stmt->execute(); $result = $stmt->get_result(); if ($result instanceof \mysqli_result) { @@ -3230,10 +3235,10 @@ public function publicIndexAction( $parts[] = 'parent_id IS NULL'; } if ($q !== '') { - $pattern = $this->archiveSearchPattern($q); - $parts[] = '(reference_code LIKE ? OR constructed_title LIKE ? OR formal_title LIKE ? OR scope_content LIKE ?)'; - $types .= 'ssss'; - array_push($values, $pattern, $pattern, $pattern, $pattern); + $match = $this->archiveSearchCondition($q); + $parts[] = $match['sql']; + $types .= $match['types']; + array_push($values, ...$match['params']); } if ($withLevel && $level !== '') { $parts[] = 'level = ?'; @@ -5764,6 +5769,31 @@ public function authorityTypeaheadAction( return $response; } + private function hasExactSearchTerm(string $query): bool + { + return in_array(true, array_column(\App\Support\ExactSearch::tokenize($query), 'exact'), true); + } + + /** + * Exact terms must be checked after tokenization rather than delegated to + * the accent-folding FULLTEXT parser used by the default archive search. + * @param list $columns + * @return list> + */ + private function quotedSearchRows(string $select, array $columns, string $query, string $order, int $limit): array + { + $condition = \App\Support\ExactSearch::wordsCondition($columns, $query, 20); + if ($condition === null) { return []; } + $stmt = $this->db->prepare($select . ' AND ' . $condition['sql'] . ' ORDER BY ' . $order . ' LIMIT ?'); + if ($stmt === false) { return []; } + $params = [...$condition['params'], $limit]; + $stmt->bind_param($condition['types'] . 'i', ...$params); + $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $stmt->close(); + return $rows; + } + /** * Type-ahead friendly search: LIKE-prefix first (always produces hits * for short queries that FULLTEXT would ignore below min_token_size), @@ -5774,6 +5804,9 @@ public function authorityTypeaheadAction( */ private function searchAuthorityRecordsForTypeahead(string $q, int $limit): array { + if ($this->hasExactSearchTerm($q)) { + return array_map(static function (array $row): array { unset($row['score']); return $row; }, $this->searchAuthorityRecords($q, $limit)); + } $rows = []; $seen = []; @@ -5833,6 +5866,9 @@ private function searchAuthorityRecordsForTypeahead(string $q, int $limit): arra */ private function searchArchivalUnits(string $q, int $limit): array { + if ($this->hasExactSearchTerm($q)) { + return $this->quotedSearchRows('SELECT id, reference_code, level, constructed_title, formal_title, date_start, date_end, extent FROM archival_units WHERE deleted_at IS NULL', ['reference_code', 'formal_title', 'constructed_title', 'scope_content', 'archival_history'], $q, 'constructed_title', $limit); + } $rows = []; $seen = []; @@ -5911,6 +5947,9 @@ private function searchArchivalUnits(string $q, int $limit): array */ private function searchAuthorityRecords(string $q, int $limit): array { + if ($this->hasExactSearchTerm($q)) { + return $this->quotedSearchRows('SELECT id, type, authorised_form, dates_of_existence, 0 AS score FROM authority_records WHERE deleted_at IS NULL', ['authorised_form', 'parallel_forms', 'history', 'functions'], $q, 'authorised_form', $limit); + } $rows = []; $stmt = $this->db->prepare( 'SELECT id, type, authorised_form, dates_of_existence, @@ -5946,6 +5985,9 @@ private function searchAuthorityRecords(string $q, int $limit): array */ private function searchReconciledAutori(string $q, int $limit): array { + if ($this->hasExactSearchTerm($q)) { + return $this->quotedSearchRows('SELECT a.id, a.nome, aal.authority_id, ar.authorised_form, ar.type AS authority_type, (SELECT COUNT(*) FROM libri_autori la WHERE la.autore_id = a.id) AS book_count, 0 AS score FROM autori a JOIN autori_authority_link aal ON aal.autori_id = a.id JOIN authority_records ar ON ar.id = aal.authority_id WHERE ar.deleted_at IS NULL', ['a.nome', 'a.pseudonimo'], $q, 'a.nome', $limit); + } $rows = []; $stmt = $this->db->prepare( 'SELECT a.id, a.nome, aal.authority_id, @@ -10923,6 +10965,41 @@ private function oaiError(\XMLWriter $xw, string $code, string $message): void $xw->endElement(); } + /** + * WHERE fragment matching $q against an archival unit's reference code, + * titles and scope note — the one rule the admin list, the public listing + * and the unified search share. Unquoted, $q goes through the stemming + * pattern below. Wrapped in quotes it is the exact spelling, accents + * included and with no stem trimmed (#501): `"Brücke"` must not find + * `Brucke`, and `"disegno"` must not find `disegni`. + * + * $unitIdExpr, when given, also matches the authorised form of an + * authority record linked to the unit (the catalogue's archive box). + * + * @return array{sql: string, params: list, types: string} + */ + private function archiveSearchCondition(string $q, ?string $unitIdExpr = null): array + { + $columns = ['reference_code', 'constructed_title', 'formal_title', 'scope_content']; + ['term' => $term, 'exact' => $exact] = \App\Support\ExactSearch::parseFilter($q); + $exact = $exact && $term !== ''; + $match = static fn(string $column): string => $exact + ? \App\Support\ExactSearch::likeSql($column, true) + : "{$column} LIKE ?"; + $pattern = $exact ? \App\Support\ExactSearch::likePattern($term) : $this->archiveSearchPattern($q); + $sql = array_map($match, $columns); + if ($unitIdExpr !== null) { + $sql[] = 'EXISTS (SELECT 1 FROM archival_unit_authority aua JOIN authority_records ar ON ar.id = aua.authority_id' + . " WHERE aua.archival_unit_id = {$unitIdExpr} AND ar.deleted_at IS NULL AND " . $match('ar.authorised_form') . ')'; + $columns[] = 'ar.authorised_form'; + } + return [ + 'sql' => '(' . implode(' OR ', $sql) . ')', + 'params' => array_fill(0, count($columns), $pattern), + 'types' => str_repeat('s', count($columns)), + ]; + } + /** * Returns a LIKE pattern for $q, stripping the last char when len >= 5 * so that "disegno" matches "disegni" (shared root "disegn-"). @@ -10968,25 +11045,20 @@ public function addArchivalSources(array $results, string $q, string $context = // ever sees published units. $isAdmin = $context === 'admin'; $archiveBase = \App\Support\RouteTranslator::route('archives') ?: '/archive'; - $searchPattern = $this->archiveSearchPattern($q); + $match = $this->archiveSearchCondition($q); $stmt = $this->db->prepare( 'SELECT id, reference_code, constructed_title, published FROM archival_units WHERE deleted_at IS NULL' . ($isAdmin ? '' : ' AND published = 1') . ' - AND ( - reference_code LIKE ? - OR constructed_title LIKE ? - OR formal_title LIKE ? - OR scope_content LIKE ? - ) + AND ' . $match['sql'] . ' ORDER BY constructed_title LIMIT 5' ); if ($stmt === false) { return $results; } - $stmt->bind_param('ssss', $searchPattern, $searchPattern, $searchPattern, $searchPattern); + $stmt->bind_param($match['types'], ...$match['params']); $stmt->execute(); $res = $stmt->get_result(); if ($res instanceof \mysqli_result) { @@ -11024,26 +11096,20 @@ public function getPublicArchiveResults(array $results, string $q): array return $results; } $archiveBase = \App\Support\RouteTranslator::route('archives') ?: '/archive'; - $searchPattern = $this->archiveSearchPattern($q); + $match = $this->archiveSearchCondition($q, 'u.id'); $stmt = $this->db->prepare( 'SELECT id, reference_code, level, constructed_title, scope_content FROM archival_units u WHERE u.deleted_at IS NULL AND u.published = 1 - AND ( - reference_code LIKE ? - OR constructed_title LIKE ? - OR formal_title LIKE ? - OR scope_content LIKE ? - OR EXISTS (SELECT 1 FROM archival_unit_authority aua JOIN authority_records ar ON ar.id = aua.authority_id WHERE aua.archival_unit_id = u.id AND ar.deleted_at IS NULL AND ar.authorised_form LIKE ?) - ) + AND ' . $match['sql'] . ' ORDER BY FIELD(level,\'fonds\',\'series\',\'file\',\'item\'), constructed_title LIMIT 6' ); if ($stmt === false) { return $results; } - $stmt->bind_param('sssss', $searchPattern, $searchPattern, $searchPattern, $searchPattern, $searchPattern); + $stmt->bind_param($match['types'], ...$match['params']); $stmt->execute(); $res = $stmt->get_result(); if ($res instanceof \mysqli_result) { diff --git a/storage/plugins/book-club/src/Repo.php b/storage/plugins/book-club/src/Repo.php index bd8aa863d..727f36f4b 100644 --- a/storage/plugins/book-club/src/Repo.php +++ b/storage/plugins/book-club/src/Repo.php @@ -183,11 +183,12 @@ private function findOrCreatePublisher(?string $name): ?int $lockName = $this->scopedPublisherLockName(); $this->acquirePublisherLock($lockName); try { - $sel = $this->db->prepare('SELECT id FROM editori WHERE nome = ? ORDER BY id ASC LIMIT 1'); + // Exact spelling first when the collation also admits "Haffner" for "Häffner" (#501). + $sel = $this->db->prepare('SELECT id FROM editori WHERE nome = ? ORDER BY ' . \App\Support\ExactSearch::exactFirstSql('nome') . ', id ASC LIMIT 1'); if ($sel === false) { throw new \RuntimeException('publisher lookup prepare failed'); } - $sel->bind_param('s', $name); + $sel->bind_param('ss', $name, $name); if (!$sel->execute()) { $err = $sel->error; $sel->close(); diff --git a/storage/plugins/emeroteca/EmerotecaPlugin.php b/storage/plugins/emeroteca/EmerotecaPlugin.php index e3659b545..7de067c4c 100644 --- a/storage/plugins/emeroteca/EmerotecaPlugin.php +++ b/storage/plugins/emeroteca/EmerotecaPlugin.php @@ -2695,31 +2695,38 @@ private function likePattern(string $term): string * is a product decision about what a search is expected to find, and it * should be made deliberately rather than inside a performance fix. */ - public static function testataSearchWhere(bool $withArticles): string + public static function testataSearchWhere(bool $withArticles, bool $exact = false): string { - $where = "t.titolo LIKE ? ESCAPE '\\\\' - OR t.sottotitolo LIKE ? ESCAPE '\\\\' - OR t.issn LIKE ? ESCAPE '\\\\'"; + $match = static fn(string $column): string => \App\Support\ExactSearch::likeSql($column, $exact); + $where = $match('t.titolo') . ' OR ' . $match('t.sottotitolo') . ' OR ' . $match('t.issn'); if ($withArticles) { - $where .= " - OR EXISTS ( - SELECT 1 - FROM emeroteca_articoli ar - JOIN emeroteca_fascicoli ef ON ef.id = ar.fascicolo_id - JOIN emeroteca_annate ea ON ea.id = ef.annata_id - WHERE ea.testata_id = t.id AND ef.stato <> 'scartato' - AND ( - MATCH(ar.titolo, ar.autori, ar.keywords) - AGAINST (? IN NATURAL LANGUAGE MODE) - OR ar.titolo LIKE ? ESCAPE '\\\\' - OR ar.autori LIKE ? ESCAPE '\\\\' - OR ar.keywords LIKE ? ESCAPE '\\\\' - ) - )"; + // An accent-insensitive MATCH must never bypass an exact search. + // Keep the seven-placeholder contract used by the listing/hints. + $articleMatch = $exact + ? 'LOWER(ar.titolo) COLLATE utf8mb4_bin = LOWER(?)' + : 'MATCH(ar.titolo, ar.autori, ar.keywords) AGAINST (? IN NATURAL LANGUAGE MODE)'; + $where .= " OR EXISTS (SELECT 1 FROM emeroteca_articoli ar + JOIN emeroteca_fascicoli ef ON ef.id = ar.fascicolo_id + JOIN emeroteca_annate ea ON ea.id = ef.annata_id + WHERE ea.testata_id = t.id AND ef.stato <> 'scartato' AND (" . $articleMatch + . ' OR ' . $match('ar.titolo') . ' OR ' . $match('ar.autori') . ' OR ' . $match('ar.keywords') . '))'; } return '(' . $where . ')'; } + /** @return array{sql:string,params:list,types:string}|null */ + public static function testataSearchCondition(string $query, bool $withArticles): ?array + { + $parts = []; $params = []; + foreach (array_slice(\App\Support\ExactSearch::tokenize(mb_substr($query, 0, 200)), 0, 20) as ['term' => $term, 'exact' => $exact]) { + $parts[] = self::testataSearchWhere($withArticles, $exact); + $pattern = \App\Support\ExactSearch::likePattern($term); + array_push($params, $pattern, $pattern, $pattern); + if ($withArticles) { array_push($params, $term, $pattern, $pattern, $pattern); } + } + return $parts === [] ? null : ['sql' => '(' . implode(' AND ', $parts) . ')', 'params' => $params, 'types' => str_repeat('s', count($params))]; + } + /** * Published standalone articles matching the term: the first 5 by recency, * plus how many there are in all. @@ -2737,25 +2744,12 @@ private function emerotecaArticleHits(string $term, bool $includeUnpublished = f if (!$this->emerotecaTableExists('emeroteca_contributi')) { return $empty; } - $pattern = $this->likePattern($term); - // These columns must stay the same set ContributionService::search() - // uses, in the same order: this counter labels a link, and the number - // beside a link has to be the number of results that link opens. When - // sottotitolo became searchable on the article page (#412) and not - // here, a term living only in a subtitle produced no suggestion at all - // while the linked page listed the article. + require_once __DIR__ . '/src/Services/ContributionService.php'; $authors = new \App\Services\ArticleAuthorService($this->db); - $extraAuthors = $authors->available() - ? " OR EXISTS (SELECT 1 FROM emeroteca_contributi_autori ca JOIN autori a ON a.id=ca.autore_id WHERE ca.contributo_id=c.id AND (a.nome LIKE ? ESCAPE '\\\\' OR a.pseudonimo LIKE ? ESCAPE '\\\\'))" : ''; - $where = ($includeUnpublished ? '1=1' : 'c.pubblico = 1') . " - AND (c.titolo LIKE ? ESCAPE '\\\\' - OR c.sottotitolo LIKE ? ESCAPE '\\\\' - OR c.autori LIKE ? ESCAPE '\\\\' - OR c.contenitore_titolo LIKE ? ESCAPE '\\\\' - OR c.keywords LIKE ? ESCAPE '\\\\' - OR c.issn = ?$extraAuthors)"; - $params = [$pattern, $pattern, $pattern, $pattern, $pattern, $term]; - if ($extraAuthors !== '') { array_push($params,$pattern,$pattern); } + $search = \App\Plugins\Emeroteca\Services\ContributionService::searchCondition($term, $authors->available()); + if ($search === null) { return $empty; } + $where = ($includeUnpublished ? '1=1' : 'c.pubblico = 1') . ' AND ' . $search['sql']; + $params = $search['params']; // Fetch first, count only when the page comes back saturated: a term // with five or fewer matches already knows its own total, and this @@ -2885,15 +2879,14 @@ private function emerotecaTestataHits(string $term): array if (!$this->emerotecaTableExists('emeroteca_testate')) { return $empty; } - $pattern = $this->likePattern($term); $withArticles = $this->emerotecaTableExists('emeroteca_articoli') && $this->emerotecaTableExists('emeroteca_fascicoli') && $this->emerotecaTableExists('emeroteca_annate'); - $where = self::testataSearchWhere($withArticles); - $params = $withArticles - ? [$pattern, $pattern, $pattern, $term, $pattern, $pattern, $pattern] - : [$pattern, $pattern, $pattern]; - $types = str_repeat('s', count($params)); + $search = self::testataSearchCondition($term, $withArticles); + if ($search === null) { return $empty; } + $where = $search['sql']; + $params = $search['params']; + $types = $search['types']; // Fetch first, count only when saturated — see emerotecaArticleHits(). $rows = $this->emerotecaRows( @@ -2945,18 +2938,18 @@ private function emerotecaIndexedArticleMatches(string $term): bool || !$this->emerotecaTableExists('emeroteca_fascicoli')) { return false; } - $pattern = $this->likePattern($term); + ['term' => $needle, 'exact' => $exact] = \App\Support\ExactSearch::parseFilter($term); + if ($needle === '') { return false; } + $pattern = $this->likePattern($needle); + $match = static fn(string $column): string => \App\Support\ExactSearch::likeSql($column, $exact); + $indexed = $exact + ? 'LOWER(ar.titolo) COLLATE utf8mb4_bin = LOWER(?)' + : 'MATCH(ar.titolo, ar.autori, ar.keywords) AGAINST (? IN NATURAL LANGUAGE MODE)'; return $this->emerotecaRows( - "SELECT 1 FROM emeroteca_articoli ar - JOIN emeroteca_fascicoli f ON f.id = ar.fascicolo_id - WHERE f.stato <> 'scartato' - AND (MATCH(ar.titolo, ar.autori, ar.keywords) AGAINST (? IN NATURAL LANGUAGE MODE) - OR ar.titolo LIKE ? ESCAPE '\\\\' - OR ar.autori LIKE ? ESCAPE '\\\\' - OR ar.keywords LIKE ? ESCAPE '\\\\') - LIMIT 1", - 'ssss', - [$term, $pattern, $pattern, $pattern] + "SELECT 1 FROM emeroteca_articoli ar JOIN emeroteca_fascicoli f ON f.id = ar.fascicolo_id + WHERE f.stato <> 'scartato' AND (" . $indexed . ' OR ' . $match('ar.titolo') + . ' OR ' . $match('ar.autori') . ' OR ' . $match('ar.keywords') . ') LIMIT 1', + 'ssss', [$needle, $pattern, $pattern, $pattern] ) !== []; } diff --git a/storage/plugins/emeroteca/src/Controllers/PublicController.php b/storage/plugins/emeroteca/src/Controllers/PublicController.php index 6f9f34b0c..5f13d683c 100644 --- a/storage/plugins/emeroteca/src/Controllers/PublicController.php +++ b/storage/plugins/emeroteca/src/Controllers/PublicController.php @@ -111,13 +111,12 @@ public function index( // One owner for "does this masthead answer that term": the // catalogue hint counts with the same fragment, so the number // it prints next to this link is the number this link opens. - $where[] = \EmerotecaPlugin::testataSearchWhere($withArticles); - $pattern = '%' . $this->escapeLike($q) . '%'; - $termBinds = $withArticles - ? [$pattern, $pattern, $pattern, $q, $pattern, $pattern, $pattern] - : [$pattern, $pattern, $pattern]; - $types .= str_repeat('s', count($termBinds)); - array_push($binds, ...$termBinds); + $search = \EmerotecaPlugin::testataSearchCondition($q, $withArticles); + if ($search !== null) { + $where[] = $search['sql']; + $types .= $search['types']; + array_push($binds, ...$search['params']); + } } if ($tipo !== '' && $skip !== 'tipo') { $where[] = 't.tipo = ?'; @@ -627,11 +626,6 @@ private function tableExists(string $table): bool return $this->tableCache[$table] = $exists; } - /** Escape LIKE metacharacters in user input (backslash-escaped). */ - private function escapeLike(string $value): string - { - return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); - } /** Site base URL with no trailing slash, for building canonical links. */ private function baseUrl(): string diff --git a/storage/plugins/emeroteca/src/Services/ContributionService.php b/storage/plugins/emeroteca/src/Services/ContributionService.php index ce219e6a3..738d59072 100644 --- a/storage/plugins/emeroteca/src/Services/ContributionService.php +++ b/storage/plugins/emeroteca/src/Services/ContributionService.php @@ -608,6 +608,36 @@ public static function authorList(?string $autori): array return array_values(array_filter($parts, static fn (string $name): bool => $name !== '')); } + /** + * Shared by the article lists and header suggestions. Every word must + * match; quoted phrases keep their spelling, accents and word order. + * @return array{sql:string,params:list,types:string}|null + */ + public static function searchCondition(string $query, bool $linkedAuthors): ?array + { + $parts = []; + $params = []; + foreach (array_slice(\App\Support\ExactSearch::tokenize(mb_substr($query, 0, 200)), 0, 20) as ['term' => $term, 'exact' => $exact]) { + $columns = ['c.titolo', 'c.sottotitolo', 'c.autori', 'c.contenitore_titolo', 'c.keywords']; + $matches = []; + $pattern = \App\Support\ExactSearch::likePattern($term); + foreach ($columns as $column) { + $matches[] = \App\Support\ExactSearch::likeSql($column, $exact); + $params[] = $pattern; + } + $matches[] = 'c.issn = ?'; + $params[] = $term; + if ($linkedAuthors) { + $matches[] = 'EXISTS (SELECT 1 FROM emeroteca_contributi_autori ca JOIN autori a ON a.id=ca.autore_id WHERE ca.contributo_id=c.id AND (' + . \App\Support\ExactSearch::likeSql('a.nome', $exact) . ' OR ' + . \App\Support\ExactSearch::likeSql('a.pseudonimo', $exact) . '))'; + array_push($params, $pattern, $pattern); + } + $parts[] = '(' . implode(' OR ', $matches) . ')'; + } + return $parts === [] ? null : ['sql' => '(' . implode(' AND ', $parts) . ')', 'params' => $params, 'types' => str_repeat('s', count($params))]; + } + /** * @param array $filters subset of FILTER_FIELDS values ⇒ the * text to match; an empty or unknown key is ignored @@ -621,7 +651,6 @@ public function search(string $term = '', int $testata = 0, bool $public = false $perPage = max(1, min(200, $perPage)); $where = ['1=1']; $linkedAuthors = (new \App\Services\ArticleAuthorService($this->db))->available(); - $authorMatch = "EXISTS (SELECT 1 FROM emeroteca_contributi_autori ca JOIN autori a ON a.id=ca.autore_id WHERE ca.contributo_id=c.id AND (a.nome LIKE ? ESCAPE '=' OR a.pseudonimo LIKE ? ESCAPE '='))"; $params = []; if ($public) { $where[] = 'c.pubblico=1'; @@ -642,17 +671,22 @@ public function search(string $term = '', int $testata = 0, bool $public = false // Substring, not equality: keywords arrive as one comma-separated // string, and an author field holding two names must still answer // for each of them. - $where[] = $column === 'autori' && $linkedAuthors ? "(c.$column LIKE ? ESCAPE '=' OR $authorMatch)" : "c.$column LIKE ? ESCAPE '='"; - $pattern = '%' . strtr(mb_substr($value, 0, 200), ['=' => '==','%' => '=%','_' => '=_']) . '%'; - $params[] = $pattern; - if ($column === 'autori' && $linkedAuthors) { array_push($params, $pattern, $pattern); } - } - if ($term !== '') { - $extraAuthors = $linkedAuthors ? " OR $authorMatch" : ''; - $where[] = "(c.titolo LIKE ? ESCAPE '=' OR c.sottotitolo LIKE ? ESCAPE '=' OR c.autori LIKE ? ESCAPE '=' OR c.contenitore_titolo LIKE ? ESCAPE '=' OR c.keywords LIKE ? ESCAPE '=' OR c.issn=?$extraAuthors)"; - $pattern = '%' . strtr(mb_substr($term, 0, 200), ['=' => '==','%' => '=%','_' => '=_']) . '%'; - array_push($params, $pattern, $pattern, $pattern, $pattern, $pattern, $term); - if ($linkedAuthors) { array_push($params, $pattern, $pattern); } + $condition = \App\Support\ExactSearch::filterCondition(["c.$column"], mb_substr($value, 0, 200)); + if ($condition === null) { continue; } + if ($column === 'autori' && $linkedAuthors) { + ['exact' => $exact] = \App\Support\ExactSearch::parseFilter($value); + $condition['sql'] = '(' . $condition['sql'] . ' OR EXISTS (SELECT 1 FROM emeroteca_contributi_autori ca JOIN autori a ON a.id=ca.autore_id WHERE ca.contributo_id=c.id AND (' + . \App\Support\ExactSearch::likeSql('a.nome', $exact) . ' OR ' + . \App\Support\ExactSearch::likeSql('a.pseudonimo', $exact) . ')))'; + array_push($condition['params'], $condition['params'][0], $condition['params'][0]); + } + $where[] = $condition['sql']; + array_push($params, ...$condition['params']); + } + $search = self::searchCondition($term, $linkedAuthors); + if ($search !== null) { + $where[] = $search['sql']; + array_push($params, ...$search['params']); } $sql = implode(' AND ', $where); $total = (int)$this->rows("SELECT COUNT(*) n FROM emeroteca_contributi c WHERE $sql", $params)[0]['n']; @@ -770,10 +804,10 @@ public function indexedSearch(string $term = '', int $testata = 0, int $page = 1 $where[] = 't.id=?'; $params[] = $testata; } - if ($term !== '') { - $pattern = '%'.strtr(mb_substr($term, 0, 200), ['=' => '==','%' => '=%','_' => '=_']).'%'; - $where[] = "(ar.titolo LIKE ? ESCAPE '=' OR ar.autori LIKE ? ESCAPE '=' OR t.titolo LIKE ? ESCAPE '=' OR ar.keywords LIKE ? ESCAPE '=')"; - array_push($params, $pattern, $pattern, $pattern, $pattern); + $search = \App\Support\ExactSearch::wordsCondition(['ar.titolo', 'ar.autori', 't.titolo', 'ar.keywords'], mb_substr($term, 0, 200), 20); + if ($search !== null) { + $where[] = $search['sql']; + array_push($params, ...$search['params']); } $from = ' FROM emeroteca_articoli ar JOIN emeroteca_fascicoli f ON f.id=ar.fascicolo_id JOIN emeroteca_annate a ON a.id=f.annata_id JOIN emeroteca_testate t ON t.id=a.testata_id WHERE '.implode(' AND ', $where); $total = (int)$this->rows('SELECT COUNT(*) n'.$from, $params)[0]['n']; diff --git a/storage/plugins/emeroteca/src/Services/PeriodicalPublisherService.php b/storage/plugins/emeroteca/src/Services/PeriodicalPublisherService.php index 94e89eb99..5d9b9e9a6 100644 --- a/storage/plugins/emeroteca/src/Services/PeriodicalPublisherService.php +++ b/storage/plugins/emeroteca/src/Services/PeriodicalPublisherService.php @@ -85,7 +85,7 @@ public function resolve(array $values, ?int $testataId = null): array foreach ($this->validate($values, $testataId) as $value) { if (str_starts_with($value, 'new:')) { $name = substr($value, 4); - $existing = $this->rows('SELECT id FROM editori WHERE nome=? ORDER BY id LIMIT 1', [$name]); + $existing = $this->rows('SELECT id FROM editori WHERE nome=? ORDER BY ' . \App\Support\ExactSearch::exactFirstSql('nome') . ', id LIMIT 1', [$name, $name]); $id = $existing !== [] ? (int)$existing[0]['id'] : (new PublisherRepository($this->db))->create(['nome'=>$name]); } else { $id = (int)$value; } if (!in_array($id, $ids, true)) { $ids[] = $id; } diff --git a/storage/plugins/mobile-api/src/Controllers/CollectionsController.php b/storage/plugins/mobile-api/src/Controllers/CollectionsController.php index 47bbe24e5..3491cc0f6 100644 --- a/storage/plugins/mobile-api/src/Controllers/CollectionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/CollectionsController.php @@ -138,10 +138,12 @@ private function archives(Request $request, Response $response): Response $where = ['u.deleted_at IS NULL' . $this->publishedSql('u.')]; $values = []; if ($parent !== null) { $where[] = 'u.parent_id = ?'; $values[] = $parent; } elseif ($query === '' && $level === '' && $from === null && $to === null) { $where[] = 'u.parent_id IS NULL'; } - if ($query !== '') { - $where[] = "(u.reference_code LIKE ? ESCAPE '!' OR u.constructed_title LIKE ? ESCAPE '!' OR u.formal_title LIKE ? ESCAPE '!' OR u.scope_content LIKE ? ESCAPE '!' OR EXISTS (SELECT 1 FROM archival_unit_authority aua JOIN authority_records ar ON ar.id = aua.authority_id WHERE aua.archival_unit_id = u.id AND ar.deleted_at IS NULL AND ar.authorised_form LIKE ? ESCAPE '!'))"; - $pattern = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $query) . '%'; - array_push($values, $pattern, $pattern, $pattern, $pattern, $pattern); + $search = \App\Support\ExactSearch::filterCondition(['u.reference_code', 'u.constructed_title', 'u.formal_title', 'u.scope_content'], $query); + if ($search !== null) { + ['exact' => $exact] = \App\Support\ExactSearch::parseFilter($query); + $where[] = '(' . $search['sql'] . ' OR EXISTS (SELECT 1 FROM archival_unit_authority aua JOIN authority_records ar ON ar.id = aua.authority_id WHERE aua.archival_unit_id = u.id AND ar.deleted_at IS NULL AND ' + . \App\Support\ExactSearch::likeSql('ar.authorised_form', $exact) . '))'; + array_push($values, ...[...$search['params'], $search['params'][0]]); } if ($level !== '') { $where[] = 'u.level = ?'; $values[] = $level; } if ($from !== null) { $where[] = 'u.date_start IS NOT NULL AND GREATEST(u.date_start, COALESCE(u.date_end, u.date_start)) >= ?'; $values[] = $from; } diff --git a/storage/plugins/mobile-api/src/Controllers/StaffArchivesController.php b/storage/plugins/mobile-api/src/Controllers/StaffArchivesController.php index 0c92f7dd3..037445bfc 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffArchivesController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffArchivesController.php @@ -410,11 +410,11 @@ public function authorities(ServerRequestInterface $request, ResponseInterface $ $types .= 's'; $params[] = $type; } - if ($q !== '') { - $where[] = '(ar.authorised_form LIKE ? OR ar.parallel_forms LIKE ? OR ar.other_forms LIKE ?)'; - $like = '%' . $q . '%'; - $types .= 'sss'; - array_push($params, $like, $like, $like); + $search = \App\Support\ExactSearch::filterCondition(['ar.authorised_form', 'ar.parallel_forms', 'ar.other_forms'], $q); + if ($search !== null) { + $where[] = $search['sql']; + $types .= $search['types']; + array_push($params, ...$search['params']); } $sql = 'SELECT ar.id, ar.type, ar.authorised_form, ar.dates_of_existence, (SELECT COUNT(DISTINCT aua.archival_unit_id) FROM archival_unit_authority aua diff --git a/storage/plugins/mobile-api/src/Controllers/StaffDesiderataController.php b/storage/plugins/mobile-api/src/Controllers/StaffDesiderataController.php index 1fb6c91fe..44ca71ce4 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffDesiderataController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffDesiderataController.php @@ -58,11 +58,11 @@ public function offers(ServerRequestInterface $request, ResponseInterface $respo $types .= 's'; $params[] = $status; } - if ($q !== '') { - $like = '%' . $q . '%'; - $where[] = '(o.title LIKE ? OR o.author LIKE ? OR o.donor_name LIKE ? OR o.donor_email LIKE ? OR o.isbn LIKE ?)'; - $types .= 'sssss'; - array_push($params, $like, $like, $like, $like, $like); + $search = \App\Support\ExactSearch::filterCondition(['o.title', 'o.author', 'o.donor_name', 'o.donor_email', 'o.isbn'], $q); + if ($search !== null) { + $where[] = $search['sql']; + $types .= $search['types']; + array_push($params, ...$search['params']); } $whereSql = $where === [] ? '' : ' WHERE ' . implode(' AND ', $where); $total = (int) ($this->rows('SELECT COUNT(*) AS n FROM desiderata_offers o' . $whereSql, $types, $params)[0]['n'] ?? 0); diff --git a/storage/plugins/mobile-api/src/Controllers/StaffEntitiesController.php b/storage/plugins/mobile-api/src/Controllers/StaffEntitiesController.php index b879403b8..3142d8a1f 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffEntitiesController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffEntitiesController.php @@ -764,22 +764,10 @@ private function wordsWhere(string $q, array $columns): array if ($q === '') { return ['', '', []]; } - $words = preg_split('/\s+/', mb_substr($q, 0, 200), -1, PREG_SPLIT_NO_EMPTY) ?: []; - $conditions = []; - $bind = []; - $types = ''; - foreach (array_slice($words, 0, 8) as $word) { - $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $word) . '%'; - $or = []; - foreach ($columns as $column) { - $or[] = "{$column} LIKE ?"; - $bind[] = $like; - $types .= 's'; - } - $conditions[] = '(' . implode(' OR ', $or) . ')'; - } - - return ['WHERE ' . implode(' AND ', $conditions), $types, $bind]; + $condition = \App\Support\ExactSearch::wordsCondition($columns, mb_substr($q, 0, 200), 8); + return $condition === null + ? ['', '', []] + : ['WHERE ' . $condition['sql'], $condition['types'], $condition['params']]; } /** diff --git a/storage/plugins/mobile-api/src/Support/StaffLoanReader.php b/storage/plugins/mobile-api/src/Support/StaffLoanReader.php index 84c1288bb..5eb6e9007 100644 --- a/storage/plugins/mobile-api/src/Support/StaffLoanReader.php +++ b/storage/plugins/mobile-api/src/Support/StaffLoanReader.php @@ -104,12 +104,14 @@ public function loans(string $bucket, array $filters, int $offset, int $limit): } $q = trim((string) ($filters['q'] ?? '')); - if ($q !== '') { - $like = '%' . $q . '%'; - $where[] = "(l.titolo LIKE ? OR u.nome LIKE ? OR u.cognome LIKE ? OR CONCAT(u.nome, ' ', u.cognome) LIKE ? - OR u.email LIKE ? OR u.codice_tessera LIKE ? OR c.numero_inventario LIKE ?)"; - $types .= 'sssssss'; - array_push($params, $like, $like, $like, $like, $like, $like, $like); + // Quoted value = exact spelling, accents included (#501). + $search = $q !== '' + ? \App\Support\ExactSearch::filterCondition(['l.titolo', 'u.nome', 'u.cognome', "CONCAT(u.nome, ' ', u.cognome)", 'u.email', 'u.codice_tessera', 'c.numero_inventario'], $q) + : null; + if ($search !== null) { + $where[] = $search['sql']; + $types .= $search['types']; + array_push($params, ...$search['params']); } foreach (['user_id' => 'p.utente_id', 'book_id' => 'p.libro_id'] as $key => $column) { $value = (int) ($filters[$key] ?? 0); @@ -291,11 +293,14 @@ public function reservations(string $status, array $filters, int $offset, int $l $params[] = $status; } $q = trim((string) ($filters['q'] ?? '')); - if ($q !== '') { - $like = '%' . $q . '%'; - $where[] = "(l.titolo LIKE ? OR u.nome LIKE ? OR u.cognome LIKE ? OR CONCAT(u.nome, ' ', u.cognome) LIKE ? OR u.email LIKE ? OR u.codice_tessera LIKE ?)"; - $types .= 'ssssss'; - array_push($params, $like, $like, $like, $like, $like, $like); + // Quoted value = exact spelling, accents included (#501). + $search = $q !== '' + ? \App\Support\ExactSearch::filterCondition(['l.titolo', 'u.nome', 'u.cognome', "CONCAT(u.nome, ' ', u.cognome)", 'u.email', 'u.codice_tessera'], $q) + : null; + if ($search !== null) { + $where[] = $search['sql']; + $types .= $search['types']; + array_push($params, ...$search['params']); } foreach (['user_id' => 'r.utente_id', 'book_id' => 'r.libro_id'] as $key => $column) { $value = (int) ($filters[$key] ?? 0); @@ -358,13 +363,16 @@ public function resolveNewReservationId(int $before, int $userId, int $bookId): */ public function users(string $q, int $limit): array { - $like = '%' . $q . '%'; + // Quoted value = exact spelling, accents included (#501). + $search = \App\Support\ExactSearch::filterCondition(['u.nome', 'u.cognome', 'u.telefono', 'u.email', 'u.codice_tessera', "CONCAT(u.nome, ' ', u.cognome)"], $q); + if ($search === null) { + return []; + } $rows = $this->all( - self::USER_SELECT . " WHERE (u.nome LIKE ? OR u.cognome LIKE ? OR u.telefono LIKE ? OR u.email LIKE ? - OR u.codice_tessera LIKE ? OR CONCAT(u.nome, ' ', u.cognome) LIKE ?)" + self::USER_SELECT . ' WHERE ' . $search['sql'] . \App\Support\Permissions::accountScopeSql('u', \App\Support\Permissions::SCOPE_BORROWER) . " ORDER BY u.cognome, u.nome, u.id LIMIT ?", - 'ssssssi', - [$like, $like, $like, $like, $like, $like, $limit] + $search['types'] . 'i', + [...$search['params'], $limit] ); return array_map(fn (array $r): array => $this->mapUser($r), $rows); diff --git a/tests/contributor-plugin-compatibility-237.unit.php b/tests/contributor-plugin-compatibility-237.unit.php index 412897b01..03ccd0e29 100644 --- a/tests/contributor-plugin-compatibility-237.unit.php +++ b/tests/contributor-plugin-compatibility-237.unit.php @@ -195,12 +195,17 @@ $frbr = $read('storage/plugins/frbr-lrm/OpereRepository.php') . $read('storage/plugins/frbr-lrm/EspressioniRepository.php'); $check(str_contains($frbr, 'AuthorName::displaySql'), 'FRBR/LRM views use preferred display names'); $archives = $read('storage/plugins/archives/ArchivesPlugin.php'); -$check(str_contains($archives, 'a.pseudonimo LIKE ?') && str_contains($archives, 'MATCH(a.nome)'), 'Archives UI searches pseudonyms while authority reconciliation remains canonical'); +$check(str_contains($archives, "ExactSearch::filterCondition(['a.nome', 'a.pseudonimo']") && str_contains($archives, 'MATCH(a.nome)'), 'Archives UI searches pseudonyms while authority reconciliation remains canonical'); $frontend = $read('app/Controllers/FrontendController.php'); $check(str_contains($frontend, 'la_match.autore_id IN') && str_contains($frontend, 'a_all.autore_id'), 'related-book matching keeps the complete creator list'); $publicApi = $read('app/Controllers/PublicApiController.php'); -$check(str_contains($publicApi, "str_replace(['\\\\', '%', '_']") && str_contains($publicApi, 'ESCAPE'), 'public API treats author wildcard characters literally'); +$authorSearch = \App\Support\ExactSearch::filterCondition(['a.nome', 'a.pseudonimo'], '5%_\\'); +$check(str_contains($publicApi, "ExactSearch::filterCondition(['a.nome', 'a.pseudonimo']") + && $authorSearch !== null + && substr_count($authorSearch['sql'], 'ESCAPE ') === 2 + && $authorSearch['params'] === [\App\Support\ExactSearch::likePattern('5%_\\'), \App\Support\ExactSearch::likePattern('5%_\\')], + 'public API treats author wildcard characters literally through ExactSearch'); echo "\n" . ($failed === 0 ? "ALL {$passed} PASS\n" : "{$passed} passed, {$failed} FAILED\n"); exit($failed === 0 ? 0 : 1); diff --git a/tests/emeroteca-412.unit.php b/tests/emeroteca-412.unit.php index 622968c10..664935f28 100644 --- a/tests/emeroteca-412.unit.php +++ b/tests/emeroteca-412.unit.php @@ -593,6 +593,32 @@ function rejects412(callable $fn,string $label): void { try{$fn();}catch(Invalid $before=(int)$svc->rows('SELECT COUNT(*) n FROM emeroteca_contributi')[0]['n']; (new EmerotecaPlugin($db,new \App\Support\HookManager($db)))->onUninstall(); check412($before>0 && (int)$svc->rows('SELECT COUNT(*) n FROM emeroteca_contributi')[0]['n']===$before,'uninstalling the plugin keeps the standalone articles'); + // #501: the page, its field filters and the header must agree on quotes. + $quotedIds = []; + foreach (['Haffner','Häffner'] as $surname) { + $quotedIds[] = $svc->save(['titolo'=>"Zquoted501 $surname", 'autori'=>"Zquoted501 $surname", 'contenitore_titolo'=>"Zquoted501 $surname", 'keywords'=>"Zquoted501 $surname", 'pubblico'=>1]); + } + foreach (['zquoted501 haffner'=>2, 'zquoted501 "häffner"'=>1, '"zquoted501 haffner"'=>1, '"ZQUOTED501 HÄFFNER"'=>1] as $term=>$expected) { + $result=$svc->search($term,0,true); + check412($result['total']===$expected,"#501 article search: $term"); + $hits=(new ReflectionMethod(EmerotecaPlugin::class,'emerotecaArticleHits'))->invoke($plugin,$term); + check412($hits['total']===$expected,"#501 header articles agree with search: $term"); + } + foreach (['autore','pubblicazione','keyword'] as $filter) { + check412($svc->search('',0,true,1,[$filter=>'zquoted501 haffner'])['total']===2,"#501 tolerant article $filter filter"); + $result=$svc->search('',0,true,1,[$filter=>'"zquoted501 häffner"']); + check412($result['total']===1 && (int)$result['rows'][0]['id']===$quotedIds[1],"#501 exact article $filter filter"); + } + foreach (['Haffner','Häffner'] as $surname) { + $svc->rows('INSERT INTO emeroteca_testate (titolo) VALUES (?)',["Zquoted501 $surname"]); + } + foreach (['zquoted501 haffner'=>2,'"zquoted501 häffner"'=>1,'"zquoted501 haffner"'=>1] as $term=>$expected) { + $condition=EmerotecaPlugin::testataSearchCondition($term,true); + $count=(int)$svc->rows('SELECT COUNT(*) n FROM emeroteca_testate t WHERE '.$condition['sql'],$condition['params'])[0]['n']; + check412($count===$expected,"#501 periodical listing: $term"); + $hits=(new ReflectionMethod(EmerotecaPlugin::class,'emerotecaTestataHits'))->invoke($plugin,$term); + check412($hits['total']===$count,"#501 periodical header/list parity: $term"); + } echo "SUCCESS $n behavioural checks\n"; } finally { foreach(['emeroteca_testate_editori','emeroteca_contributi_autori','emeroteca_contributi','emeroteca_articoli','emeroteca_abbonamenti','emeroteca_fascicoli','emeroteca_annate','emeroteca_testate','plugin_hooks','plugin_settings','plugins'] as $t) { $db->query('DROP TABLE IF EXISTS '.$t); } diff --git a/tests/exact-search-apis-501.unit.php b/tests/exact-search-apis-501.unit.php new file mode 100644 index 000000000..3c73bea85 --- /dev/null +++ b/tests/exact-search-apis-501.unit.php @@ -0,0 +1,125 @@ + [], 'editori' => [], 'libri' => [], 'utenti' => [], 'prestiti' => [], 'prenotazioni' => []]; +Exact501::onCleanup(static function () use ($db, &$ids): void { + foreach (['prenotazioni', 'prestiti'] as $table) { + if ($ids[$table] !== []) { + $db->query("DELETE FROM {$table} WHERE id IN (" . implode(',', $ids[$table]) . ')'); + } + } + if ($ids['libri'] !== []) { + $db->query('DELETE FROM libri_autori WHERE libro_id IN (' . implode(',', $ids['libri']) . ')'); + } + foreach (['libri', 'autori', 'editori', 'utenti'] as $table) { + if ($ids[$table] !== []) { + $db->query("DELETE FROM {$table} WHERE id IN (" . implode(',', $ids[$table]) . ')'); + } + } +}); + +foreach (['plain' => ['Haffner', 'Brucke'], 'umlaut' => ['Häffner', 'Brücke']] as $key => [$surname, $word]) { + $author = Exact501::exec($db, 'INSERT INTO autori (nome) VALUES (?)', ["{$t} {$surname}"]); + $publisher = Exact501::exec($db, 'INSERT INTO editori (nome) VALUES (?)', ["Verlag {$t} {$surname}"]); + $book = Exact501::exec($db, 'INSERT INTO libri (titolo, editore_id, copie_totali, copie_disponibili) VALUES (?, ?, 1, 1)', ["{$t} {$word}", $publisher]); + Exact501::exec($db, "INSERT INTO libri_autori (libro_id, autore_id, ruolo) VALUES (?, ?, 'principale')", [$book, $author]); + $user = Exact501::exec( + $db, + 'INSERT INTO utenti (codice_tessera, nome, cognome, email, password) VALUES (?, ?, ?, ?, ?)', + [substr($t, 0, 10) . $key[0], $t, $surname, strtolower($t) . ".{$key}@example.test", password_hash('x', PASSWORD_DEFAULT)] + ); + $ids['autori'][] = $author; + $ids['editori'][] = $publisher; + $ids['libri'][] = $book; + $ids['utenti'][] = $user; + $ids['prestiti'][] = Exact501::exec( + $db, + "INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, stato, attivo) VALUES (?, ?, CURDATE(), CURDATE() + INTERVAL 30 DAY, 'pendente', 0)", + [$book, $user] + ); + $ids['prenotazioni'][] = Exact501::exec($db, 'INSERT INTO prenotazioni (libro_id, utente_id) VALUES (?, ?)', [$book, $user]); +} +\App\Support\SearchIndexBuilder::rebuildMany($db, $ids['libri']); +[$bookPlain, $bookUmlaut] = $ids['libri']; +[$userPlain, $userUmlaut] = $ids['utenti']; +[$loanPlain, $loanUmlaut] = $ids['prestiti']; +[$resPlain, $resUmlaut] = $ids['prenotazioni']; + +$ofItems = static fn(array $items, array $mine): array => array_values(array_intersect( + array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $items), + $mine +)); + +// ── Mobile API: staff borrower picker ────────────────────────────────────── +$reader = new StaffLoanReader($db); +$users = static fn(string $q): array => $ofItems($reader->users($q, 50), $ids['utenti']); +$same([$userPlain, $userUmlaut], $users("{$t} haffner"), 'staff borrower picker: unquoted finds both spellings'); +$same([$userPlain], $users("\"{$t} haffner\""), 'staff borrower picker: quoted Haffner excludes Häffner'); +$same([$userUmlaut], $users("\"{$t} Häffner\""), 'staff borrower picker: quoted Häffner excludes Haffner'); +$same([$userUmlaut], $users('"' . mb_strtoupper("{$t} häffner") . '"'), 'staff borrower picker: case ignored inside quotes'); +$same([$userUmlaut], $users("\"{$t}\" \"häffner\""), 'staff borrower picker: two quoted groups form one phrase'); + +// ── Mobile API: staff loans and reservations ─────────────────────────────── +$loans = static fn(string $q): array => $ofItems($reader->loans('pending', ['q' => $q], 0, 100)['items'], $ids['prestiti']); +$same([$loanPlain, $loanUmlaut], $loans("{$t} brucke"), 'staff loans: unquoted title finds both spellings'); +$same([$loanUmlaut], $loans("\"{$t} brücke\""), 'staff loans: quoted Brücke excludes Brucke'); +$same([$loanPlain], $loans("\"{$t} haffner\""), 'staff loans: quoted borrower name excludes the umlaut spelling'); +$same([$loanPlain, $loanUmlaut], $loans("{$t} haffner"), 'staff loans: unquoted borrower name finds both'); + +$reservations = static fn(string $q): array => $ofItems($reader->reservations('all', ['q' => $q], 0, 100)['items'], $ids['prenotazioni']); +$same([$resPlain, $resUmlaut], $reservations("{$t} haffner"), 'staff reservations: unquoted finds both spellings'); +$same([$resUmlaut], $reservations("\"{$t} Häffner\""), 'staff reservations: quoted borrower Häffner excludes Haffner'); +$same([$resPlain], $reservations("\"{$t} brucke\""), 'staff reservations: quoted title Brucke excludes Brücke'); + +// ── Public REST API: author filter ───────────────────────────────────────── +$public = new \App\Controllers\PublicApiController(); +$findBooks = new \ReflectionMethod($public, 'findBooks'); +$publicIds = static fn(string $author): array => $ofItems($findBooks->invoke($public, $db, null, null, null, $author), $ids['libri']); +$same([$bookPlain, $bookUmlaut], $publicIds("{$t} haffner"), 'public API: unquoted author finds both spellings'); +$same([$bookPlain], $publicIds("\"{$t} haffner\""), 'public API: quoted Haffner excludes Häffner'); +$same([$bookUmlaut], $publicIds("\"{$t} Häffner\""), 'public API: quoted Häffner excludes Haffner'); +$same([$bookUmlaut], $publicIds('"' . mb_strtoupper("{$t} häffner") . '"'), 'public API: case ignored inside quotes'); + +// ── Mobile API: catalogue filters ────────────────────────────────────────── +$catalog = new CatalogController($db, true); +$buildFilters = new \ReflectionMethod($catalog, 'buildFilters'); +$catalogIds = static function (array $params) use ($db, $catalog, $buildFilters, $ids): array { + [$conditions, $bind, $types] = $buildFilters->invoke($catalog, $params); + $conditions[] = 'l.id IN (' . implode(',', $ids['libri']) . ')'; + $stmt = $db->prepare('SELECT l.id FROM libri l LEFT JOIN editori e ON e.id = l.editore_id WHERE l.deleted_at IS NULL AND ' . implode(' AND ', $conditions) . ' ORDER BY l.id'); + if ($types !== '') { + $stmt->bind_param($types, ...$bind); + } + $stmt->execute(); + return array_map('intval', array_column($stmt->get_result()->fetch_all(MYSQLI_ASSOC), 'id')); +}; +$same([$bookPlain, $bookUmlaut], $catalogIds(['author' => "{$t} haffner"]), 'mobile catalogue: unquoted author finds both spellings'); +$same([$bookPlain], $catalogIds(['author' => "\"{$t} haffner\""]), 'mobile catalogue: quoted author Haffner excludes Häffner'); +$same([$bookUmlaut], $catalogIds(['author' => "\"{$t} Häffner\""]), 'mobile catalogue: quoted author Häffner excludes Haffner'); +$same([$bookPlain, $bookUmlaut], $catalogIds(['publisher' => "Verlag {$t} haffner"]), 'mobile catalogue: unquoted publisher finds both spellings'); +$same([$bookPlain], $catalogIds(['publisher' => "\"Verlag {$t} haffner\""]), 'mobile catalogue: quoted publisher Haffner excludes Häffner'); +$same([$bookUmlaut], $catalogIds(['publisher' => "\"Verlag {$t} Häffner\""]), 'mobile catalogue: quoted publisher Häffner excludes Haffner'); +$same([$bookUmlaut], $catalogIds(['author' => (string) $ids['autori'][1]]), 'mobile catalogue: a numeric author id is still an id, not a name'); +$same([$bookUmlaut], $catalogIds(['q' => "{$t} \"brücke\""]), 'mobile catalogue: a quoted word in q follows the book search rule'); +$same([$bookPlain, $bookUmlaut], $catalogIds(['publisher' => '"']), 'mobile catalogue: a lone quote adds no publisher filter'); + +Exact501::finish(25); diff --git a/tests/exact-search-archives-501.unit.php b/tests/exact-search-archives-501.unit.php new file mode 100644 index 000000000..546afcba1 --- /dev/null +++ b/tests/exact-search-archives-501.unit.php @@ -0,0 +1,126 @@ +query("CREATE TEMPORARY TABLE archival_units ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + parent_id BIGINT UNSIGNED NULL, + reference_code VARCHAR(64) NOT NULL DEFAULT '', + level ENUM('fonds','series','file','item') NOT NULL DEFAULT 'fonds', + constructed_title VARCHAR(500) NOT NULL DEFAULT '', + formal_title VARCHAR(500) NULL, + scope_content TEXT NULL, + archival_history TEXT NULL, + date_start INT NULL, + date_end INT NULL, + extent TEXT NULL, + published TINYINT(1) NOT NULL DEFAULT 1, + deleted_at DATETIME NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); +$db->query('CREATE TEMPORARY TABLE authority_records ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + authorised_form VARCHAR(500) NOT NULL, + type VARCHAR(30) NULL, + dates_of_existence TEXT NULL, + parallel_forms TEXT NULL, + history TEXT NULL, + functions TEXT NULL, + deleted_at DATETIME NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'); +$db->query('CREATE TEMPORARY TABLE archival_unit_authority ( + archival_unit_id BIGINT UNSIGNED NOT NULL, + authority_id BIGINT UNSIGNED NOT NULL +) ENGINE=InnoDB'); +foreach ([ + [1, 'U-1', 'Die Brücke', null, null, 1, null], + [2, 'U-2', 'Die Brucke', null, null, 1, null], + [3, 'U-3', 'Disegni architettonici', null, null, 1, null], + [4, 'IT-MI-1943', 'Fotografie', null, null, 1, null], + [5, 'U-5', 'Corrispondenza', null, 'Briefe an Häffner', 1, null], + [6, 'U-6', 'Carte', 'Haffner Nachlass', null, 1, null], + [7, 'U-7', 'Raccolta Rossi', null, null, 0, null], + [8, 'U-8', 'Fondo eliminato Brücke', null, null, 1, '2026-01-01 00:00:00'], +] as $row) { + Exact501::exec($db, 'INSERT INTO archival_units (id, reference_code, constructed_title, formal_title, scope_content, published, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)', $row); +} +Exact501::exec($db, "INSERT INTO authority_records (id, authorised_form) VALUES (1, 'Häffner, Anna'), (2, 'Haffner, Rolf')"); +Exact501::exec($db, 'INSERT INTO archival_unit_authority (archival_unit_id, authority_id) VALUES (3, 1), (4, 2)'); + +$plugin = new \App\Plugins\Archives\ArchivesPlugin($db, new \App\Support\HookManager($db)); +$condition = new \ReflectionMethod($plugin, 'archiveSearchCondition'); +$units = static fn(string $q, ?string $unitId = null): array => Exact501::ids($db, 'archival_units u', $condition->invoke($plugin, $q, $unitId)); +$resultIds = static function (array $rows): array { + $ids = array_map(static fn(array $r): int => (int) $r['id'], $rows); + sort($ids); + return $ids; +}; + +// ── The shared condition ─────────────────────────────────────────────────── +$same([1, 2, 8], $units('brücke'), 'unquoted brücke finds both spellings'); +$same([1, 8], $units('"brücke"'), '"brücke" excludes Brucke'); +$same([2], $units('"brucke"'), '"brucke" excludes Brücke'); +$same([1, 8], $units('"BRÜCKE"'), 'case is ignored inside quotes'); +$same([3], $units('disegno'), 'unquoted search keeps the stemming (disegno → disegni)'); +$same([], $units('"disegno"'), 'quoted search trims no stem: "disegno" is not "disegni"'); +$same([3], $units('"disegni"'), 'the exact word is found'); +$same([4], $units('"IT-MI-1943"'), 'a quoted reference code matches literally'); +$same([5, 6], $units('häffner'), 'unquoted häffner finds the scope note and the formal title'); +$same([5], $units('"häffner"'), '"häffner" keeps the umlaut'); +$same([6], $units('"haffner"'), '"haffner" excludes the umlaut'); +$same([3, 5], $units('"häffner"', 'u.id'), 'with authorities: a linked authority record counts, exactly'); +$same([3, 4, 5, 6], $units('haffner', 'u.id'), 'with authorities: unquoted matches every linked spelling'); +$same([], $units('"'), 'a lone quote does not match every unit'); + +// ── Catalogue archive box (frontend.catalog.archive_results) ────────────── +$same([3, 5], $resultIds($plugin->getPublicArchiveResults([], '"häffner"')), 'catalogue box: quoted Häffner, authority included'); +$same([1, 2], $resultIds($plugin->getPublicArchiveResults([], 'brücke')), 'catalogue box: unquoted finds both, deleted unit left out'); +$same([1], $resultIds($plugin->getPublicArchiveResults([], '"brücke"')), 'catalogue box: quoted Brücke only'); + +// ── Unified search (search.unified.sources) ──────────────────────────────── +$same([2], $resultIds($plugin->addArchivalSources([], '"brucke"')), 'unified search: quoted Brucke only'); +$same([], $resultIds($plugin->addArchivalSources([], '"raccolta rossi"')), 'unified search: an unpublished unit stays hidden from the public'); +$same([7], $resultIds($plugin->addArchivalSources([], '"raccolta rossi"', 'admin')), 'unified search: an operator finds the unpublished unit'); + +// ── Author picker (searchAutori) on real authors ─────────────────────────── +$t = ucfirst(Exact501::token()); +$a1 = Exact501::exec($db, 'INSERT INTO autori (nome) VALUES (?)', ["{$t} Haffner"]); +$a2 = Exact501::exec($db, 'INSERT INTO autori (nome) VALUES (?)', ["{$t} Häffner"]); +Exact501::onCleanup(static fn() => $db->query("DELETE FROM autori WHERE id IN ({$a1}, {$a2})")); +$authors = static fn(string $q): array => $resultIds($plugin->searchAutori($q)); +$same([$a1, $a2], $authors("{$t} haffner"), 'author picker: unquoted finds both spellings'); +$same([$a1], $authors("\"{$t} haffner\""), 'author picker: quoted Haffner excludes Häffner'); +$same([$a2], $authors("\"{$t} Häffner\""), 'author picker: quoted Häffner excludes Haffner'); +$same([$a2], $authors('"' . mb_strtoupper("{$t} häffner") . '"'), 'author picker: case ignored inside quotes'); +$same([$a2], $authors("\"{$t}\" \"häffner\""), 'author picker: two quoted groups form one phrase'); + +// The archive's dedicated cross-entity bar and authority typeahead use +// separate FULLTEXT paths: they must not bypass the quoted spelling either. +foreach (['searchArchivalUnits'=>['"brücke"'=>[1],'"brucke"'=>[2]], 'searchAuthorityRecords'=>['"häffner"'=>[1],'"haffner"'=>[2]], 'searchAuthorityRecordsForTypeahead'=>['"häffner"'=>[1],'"haffner"'=>[2]]] as $method=>$cases) { + foreach ($cases as $q=>$expected) { + $same($expected,$resultIds((new ReflectionMethod($plugin,$method))->invoke($plugin,$q,20)),"archive $method: $q"); + } +} +$db->query('CREATE TEMPORARY TABLE autori_authority_link(autori_id INT,authority_id INT)'); +Exact501::exec($db,'INSERT INTO autori_authority_link VALUES (?,2),(?,1)',[$a1,$a2]); +$reconciled=new ReflectionMethod($plugin,'searchReconciledAutori'); +$same([$a1],$resultIds($reconciled->invoke($plugin,"{$t} \"haffner\"",20)),'reconciled-author search keeps the plain spelling'); +$same([$a2],$resultIds($reconciled->invoke($plugin,"{$t} \"häffner\"",20)),'reconciled-author search keeps the umlaut spelling'); +Exact501::finish(33); diff --git a/tests/exact-search-articles-501.unit.php b/tests/exact-search-articles-501.unit.php new file mode 100644 index 000000000..0c209eaf3 --- /dev/null +++ b/tests/exact-search-articles-501.unit.php @@ -0,0 +1,141 @@ + "id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + reference_key VARCHAR(191) NOT NULL, + titolo VARCHAR(500) NOT NULL, + sottotitolo VARCHAR(500) NULL, + autori VARCHAR(1000) NULL, + contenitore_titolo VARCHAR(500) NULL, + keywords VARCHAR(1000) NULL, + abstract TEXT NULL, + issn VARCHAR(20) NULL, + pubblico TINYINT(1) NOT NULL DEFAULT 1, + anno_pubblicazione INT NULL, + created_at DATETIME NULL", + 'emeroteca_contributi_autori' => 'contributo_id INT NOT NULL, + ordine_credito SMALLINT UNSIGNED NOT NULL, + nome_credito VARCHAR(255) NOT NULL, + autore_id INT NULL, + PRIMARY KEY (contributo_id, ordine_credito)', +]; +$created = []; +foreach ($minimal as $table => $columns) { + if ($db->query("SHOW TABLES LIKE '{$table}'")->num_rows === 0) { + $db->query("CREATE TABLE `{$table}` ({$columns}) {$charset}"); + $created[] = $table; + } +} +Exact501::onCleanup(static function () use ($db, $created): void { + foreach (array_reverse($created) as $table) { + $db->query("DROP TABLE IF EXISTS `{$table}`"); + } +}); +$active = $db->query("SELECT is_active FROM plugins WHERE name = 'emeroteca'")->fetch_column(); +if ($active === null || $active === false) { + throw new \RuntimeException('the emeroteca plugin is not registered in this database'); +} +$db->query("UPDATE plugins SET is_active = 1 WHERE name = 'emeroteca'"); +Exact501::onCleanup(static fn() => Exact501::exec($db, "UPDATE plugins SET is_active = ? WHERE name = 'emeroteca'", [(int) $active])); + +$t = ucfirst(Exact501::token()); +$authorPlain = Exact501::exec($db, 'INSERT INTO autori (nome) VALUES (?)', ["{$t} Haffner"]); +$authorUmlaut = Exact501::exec($db, 'INSERT INTO autori (nome) VALUES (?)', ["{$t} Häffner"]); + +/** @var array $real local label (1..11) => real article id */ +$real = []; +Exact501::onCleanup(static function () use ($db, &$real, $authorPlain, $authorUmlaut): void { + if ($real !== []) { + $db->query('DELETE FROM emeroteca_contributi_autori WHERE contributo_id IN (' . implode(',', $real) . ')'); + $db->query('DELETE FROM emeroteca_contributi WHERE id IN (' . implode(',', $real) . ')'); + } + $db->query("DELETE FROM autori WHERE id IN ({$authorPlain}, {$authorUmlaut})"); +}); +foreach ([ + 1 => ['Die Brücke', 'Rolf Haffner', null, null, 1], + 2 => ['Die Brucke', 'Anna Häffner', null, null, 1], + 3 => ['Thomas Mann und die Zeit', null, null, null, 1], + 4 => ['Mann ohne Thomas', null, null, null, 1], + 5 => ['Il disegno', null, 'Müller', null, 1], + 6 => ['Die Brücke privat', null, null, null, 0], + 7 => ['Aufsatz', null, null, null, 1], + 8 => ['Essay', null, null, null, 1], + 9 => ['Rivista', null, null, '1234-5678', 1], + 10 => ['Saggio', 'Levi, Primo', null, null, 1], + 11 => ['Sconto 50% oggi', null, null, null, 1], +] as $label => $row) { + $real[$label] = Exact501::exec( + $db, + 'INSERT INTO emeroteca_contributi (reference_key, titolo, autori, keywords, issn, pubblico) VALUES (?, ?, ?, ?, ?, ?)', + ["{$t}-{$label}", ...$row] + ); +} +foreach ([[7, $authorUmlaut, "{$t} Häffner"], [8, $authorPlain, "{$t} Haffner"]] as [$label, $authorId, $credit]) { + Exact501::exec($db, 'INSERT INTO emeroteca_contributi_autori (contributo_id, ordine_credito, nome_credito, autore_id) VALUES (?, 1, ?, ?)', [$real[$label], $credit, $authorId]); +} + +$service = new \App\Services\UnifiedCatalogService($db); +$ok($service->enabled(), 'the unified catalogue sees the periodicals plugin for this run'); +$query = new \ReflectionMethod($service, 'articleQuery'); +$labels = array_flip($real); +$articles = static function (string $search) use ($db, $service, $query, $real, $labels): array { + [$from, $types, $params] = $query->invoke($service, ['search' => $search]); + $stmt = $db->prepare('SELECT c.id' . $from . ' AND c.id IN (' . implode(',', $real) . ') ORDER BY c.id'); + if ($types !== '') { + $stmt->bind_param($types, ...$params); + } + $stmt->execute(); + $found = array_map(static fn(array $r): int => $labels[(int) $r['id']], $stmt->get_result()->fetch_all(MYSQLI_ASSOC)); + sort($found); + return $found; +}; + +$same([1, 2], $articles('brücke'), 'unquoted brücke finds both spellings'); +$same([1], $articles('"brücke"'), '"brücke" excludes Brucke'); +$same([2], $articles('"brucke"'), '"brucke" excludes Brücke'); +$same([1], $articles('"BRÜCKE"'), 'case is ignored inside quotes'); +$same([1, 2, 7, 8], $articles('haffner'), 'unquoted haffner finds both authors, written or linked'); +$same([1, 8], $articles('"haffner"'), '"haffner" excludes Häffner, written or linked'); +$same([2, 7], $articles('"häffner"'), '"häffner" excludes Haffner, written or linked'); +$same([3], $articles('"thomas mann"'), 'a quoted phrase must appear in that order'); +$same([3, 4], $articles('thomas mann'), 'without quotes the words may be anywhere'); +$same([5], $articles('muller'), 'unquoted muller finds Müller'); +$same([], $articles('"muller"'), '"muller" does not find Müller'); +$same([5], $articles('"müller"'), '"müller" finds Müller'); +$same([1], $articles('brücke "rolf haffner"'), 'a tolerant word and a quoted phrase are ANDed'); +$same([], $articles('brücke "anna haffner"'), 'the quoted phrase filters out the umlaut author'); +$same([7], $articles("\"{$t} Häffner\""), 'a linked author record answers the quoted spelling'); +$same([8], $articles("\"{$t} haffner\""), 'a linked author record excludes the other spelling'); +$same([7, 8], $articles("{$t} haffner"), 'unquoted, both linked authors answer'); +$same([], $articles('"brücke privat"'), 'an unpublished article stays hidden'); +$same([10], $articles('primo levi'), 'unquoted words still ignore the comma of an inverted name'); +$same([10], $articles('"levi, primo"'), 'a quoted inverted name matches with its comma'); +$same([9], $articles('"1234-5678"'), 'a quoted ISSN matches literally'); +$same([11], $articles('"50%"'), 'a quoted % is literal'); +$same([1, 2, 3, 4, 5, 7, 8, 9, 10, 11], $articles('"'), 'a lone quote adds no search condition'); +$same([], $articles("\xff\xfe"), 'a garbled query matches no article'); +$same([], $articles('!!!'), 'a query of punctuation only matches no article, like the book search'); + +Exact501::finish(26); diff --git a/tests/exact-search-backend-501.unit.php b/tests/exact-search-backend-501.unit.php new file mode 100644 index 000000000..8bcad3e7f --- /dev/null +++ b/tests/exact-search-backend-501.unit.php @@ -0,0 +1,96 @@ + 'admin']; +$t = Exact501::token(); +$ids = array_fill_keys(['autori','editori','libri','utenti','generi','scaffali','mensole','posizioni'], []); +Exact501::onCleanup(static function () use ($db, &$ids): void { + foreach (['posizioni','mensole','scaffali','libri','autori','editori','utenti','generi'] as $table) { + if ($ids[$table]) { $db->query("DELETE FROM `$table` WHERE id IN (" . implode(',', $ids[$table]) . ')'); } + } +}); +foreach (['Haffner', 'Häffner'] as $i => $surname) { + $name = "$t $surname"; + $ids['autori'][] = Exact501::exec($db, 'INSERT INTO autori (nome,pseudonimo,biografia,sito_web,`nazionalità`) VALUES (?,?,?,?,?)', [$name,$name,$name,$name,$name]); + $ids['editori'][] = Exact501::exec($db, 'INSERT INTO editori (nome,sito_web,indirizzo) VALUES (?,?,?)', [$name,$name,$name]); + $ids['libri'][] = Exact501::exec($db, 'INSERT INTO libri (titolo,collana,isbn13) VALUES (?,?,?)', [$name,$name,$t . $i]); + $ids['utenti'][] = Exact501::exec($db, 'INSERT INTO utenti (nome,cognome,email,password,codice_tessera) VALUES (?,?,?,?,?)', [$t,$surname,"$t.$i@example.test",'x',strtoupper($t).$i]); + $genre = $ids['generi'][] = Exact501::exec($db, 'INSERT INTO generi (nome) VALUES (?)', [$name]); + $shelf = $ids['scaffali'][] = Exact501::exec($db, 'INSERT INTO scaffali (nome,codice,lettera) VALUES (?,?,?)', [$name,$t.$i,'Z']); + $level = $ids['mensole'][] = Exact501::exec($db, 'INSERT INTO mensole (scaffale_id,numero_livello) VALUES (?,1)', [$shelf]); + $ids['posizioni'][] = Exact501::exec($db, 'INSERT INTO posizioni (scaffale_id,mensola_id,genere_id) VALUES (?,?,?)', [$shelf,$level,$genre]); +} +App\Support\SearchIndexBuilder::rebuildMany($db, $ids['libri']); +$request = static fn(array $params) => (new ServerRequestFactory())->createServerRequest('GET', '/')->withQueryParams($params); +$read = static function (object $controller, string $method, array $params, bool $mobile = false) use ($request, $db): array { + $response = $mobile ? $controller->$method($request($params), new Response()) : $controller->$method($request($params), new Response(), $db); + Exact501::same(200, $response->getStatusCode(), get_class($controller) . "::$method returns 200"); + return json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR); +}; +$myIds = static function (array $rows, string $table) use (&$ids): array { + $found = array_values(array_intersect(array_map('intval', array_column($rows, 'id')), $ids[$table])); + sort($found); return $found; +}; +// Both modes for each individual column: a failure identifies the exact box. +foreach ([ + [new App\Controllers\AutoriApiController(), 'autori', ['search_text','search_nome','search_pseudonimo','search_sito','search_nazionalita']], + [new App\Controllers\EditoriApiController(), 'editori', ['search_text','search_sito','search_via','search_cap']], + [new App\Controllers\LibriApiController(), 'libri', ['search_text','collana']], +] as [$controller,$table,$fields]) { + foreach ($fields as $field) { + foreach (["$t haffner" => $ids[$table], "\"$t häffner\"" => [$ids[$table][1]]] as $value => $expected) { + $data = $read($controller, 'list', [$field => $value, 'length' => 100]); + Exact501::same($expected, $myIds($data['data'], $table), "$table $field: $value"); + Exact501::same(count($expected), $data['recordsFiltered'], "$table $field: filtered count agrees with page"); + } + } +} +$books = new App\Controllers\LibriApiController(); +foreach ([$t => $ids['libri'], '"'.$t.'1"' => [$ids['libri'][1]], '%' => []] as $value => $expected) { + Exact501::same($expected, $myIds($read($books,'list',['search_isbn'=>$value])['data'],'libri'), "ISBN filter: $value"); +} +$users = new App\Controllers\UtentiApiController(); +foreach (["$t haffner" => $ids['utenti'], '"'.$t.' Häffner"' => [$ids['utenti'][1]]] as $value => $expected) { + Exact501::same($expected, $myIds($read($users,'list',['search_text'=>$value])['data'],'utenti'), "user list accepts full names: $value"); +} +$search = new SearchController(); +foreach (['authors'=>'autori','publishers'=>'editori','users'=>'utenti','genres'=>'generi','locations'=>'posizioni'] as $method=>$table) { + foreach (["$t haffner" => $ids[$table], '"'.$t.' Häffner"' => [$ids[$table][1]]] as $value=>$expected) { + Exact501::same($expected, $myIds($read($search,$method,['q'=>$value]),$table), "backend $method picker: $value"); + } + Exact501::same($read($search,$method,['q'=>'']), $read($search,$method,['q'=>'"']), "$method picker: a lone quote behaves like empty input"); +} +$genres = new App\Controllers\GeneriApiController(); +foreach (["$t haffner" => $ids['generi'], '"'.$t.' Häffner"' => [$ids['generi'][1]]] as $value=>$expected) { + Exact501::same($expected, $myIds($read($genres,'search',['q'=>$value]),'generi'), "book-form genre picker: $value"); +} +$series = new App\Controllers\CollaneController(); +Exact501::same(["$t Haffner","$t Häffner"], $read($series,'searchApi',['q'=>"$t haffner"]), 'series suggestions preserve the two accented names'); +Exact501::same(["$t Häffner"], $read($series,'searchApi',['q'=>'"'.$t.' Häffner"']), 'series suggestions honour quotes'); +Exact501::same([], $read($series,'searchApi',['q'=>'""']), 'empty quoted series does not enumerate the collection'); +$staff = new App\Plugins\MobileApi\Controllers\StaffEntitiesController($db); +foreach (['searchAuthors'=>'autori','searchPublishers'=>'editori'] as $method=>$table) { + foreach (["$t haffner"=>$ids[$table],$t.' "häffner"'=>[$ids[$table][1]]] as $value=>$expected) { + Exact501::same($expected, $myIds($read($staff,$method,['q'=>$value],true)['data'],$table), "mobile backend $method: $value"); + } +} +// Controlled Dewey data, restored afterwards; no edits to the real taxonomy. +$cache = new ReflectionProperty(App\Controllers\DeweyApiController::class, 'deweyDataCache'); +$saved = $cache->getValue(); +$cache->setValue(null, [App\Support\I18n::getLocale()=>[['code'=>'501','name'=>'Società','level'=>1,'children'=>[]],['code'=>'502','name'=>'Societa','level'=>1,'children'=>[]]]]); +Exact501::onCleanup(static fn()=> $cache->setValue(null,$saved)); +$dewey = new App\Controllers\DeweyApiController(); +foreach (['societa'=>['501','502'],'"società"'=>['501'],'"societa"'=>['502']] as $value=>$expected) { + // autocomplete takes no database argument. + $response = $dewey->autocomplete($request(['q'=>$value]), new Response()); + Exact501::same($expected,array_column(json_decode((string)$response->getBody(),true),'code'),"Dewey picker: $value"); +} +Exact501::finish(132); diff --git a/tests/exact-search-core-501.unit.php b/tests/exact-search-core-501.unit.php new file mode 100644 index 000000000..e80b63650 --- /dev/null +++ b/tests/exact-search-core-501.unit.php @@ -0,0 +1,89 @@ + 'haffner', 'exact' => false]], E::tokenize('haffner'), 'a plain word is tolerant'); +$same([['term' => 'haffner', 'exact' => true]], E::tokenize('"haffner"'), 'a quoted word is exact'); +$same([['term' => 'haffner', 'exact' => true]], E::tokenize('"haffner'), 'an unclosed quote counts as closed'); +$same([['term' => 'Häffner', 'exact' => true]], E::tokenize('„Häffner“'), 'German low-high quotes are recognised'); +$same([['term' => 'Häffner', 'exact' => true]], E::tokenize('“Häffner”'), 'English curly quotes are recognised'); +$same([['term' => 'Häffner', 'exact' => true]], E::tokenize('«Häffner»'), 'guillemets are recognised'); +$same( + [['term' => 'roman', 'exact' => false], ['term' => 'thomas mann', 'exact' => true], ['term' => '1929', 'exact' => false]], + E::tokenize('roman "thomas mann" 1929'), + 'a phrase between plain words collapses its spaces and leaves the others tolerant' +); +$ok(E::tokenize('""') === [] && E::tokenize('" "') === [], 'empty quotes yield no term'); +$garbled = E::tokenize("\xff\xfe haffner"); +$ok(count($garbled) === 2 && $garbled[1]['term'] === 'haffner', 'invalid UTF-8 is scrubbed instead of discarding the whole query'); + +// ── Single-value filter parser ───────────────────────────────────────────── +$same(['term' => 'Haffner', 'exact' => true], E::parseFilter(' "Haffner" '), 'a quoted filter value is exact and trimmed'); +$same(['term' => 'van der Berg', 'exact' => false], E::parseFilter('van der Berg'), 'an unquoted filter value stays one tolerant term'); +$same(['term' => 'Thomas Mann', 'exact' => true], E::parseFilter('"Thomas" "Mann"'), 'several quoted groups in one box become one phrase without literal quotes'); +$same(['term' => 'Haffner"', 'exact' => false], E::parseFilter('Haffner"'), 'a value that does not start with a quote is matched as typed'); +$ok(E::filterCondition(['a.nome'], '"') === null, 'a lone quote adds no filter'); +$cond = E::filterCondition(['a.nome', 'a.pseudonimo'], '"x"'); +$ok($cond !== null && count($cond['params']) === 2 && $cond['types'] === 'ss', 'one bound pattern per searched column'); + +// ── SQL fragments ────────────────────────────────────────────────────────── +$same('%50\\%\\_a\\\\b%', E::likePattern('50%_a\\b'), 'likePattern escapes %, _ and the backslash'); +$ok(!str_contains(E::likeSql('x', false), 'COLLATE') && str_contains(E::likeSql('x', true), 'LOWER(x) COLLATE utf8mb4_bin'), 'only the exact LIKE uses the binary collation'); + +// ── Behaviour on the server ──────────────────────────────────────────────── +$db = Exact501::db(); +$db->query('CREATE TEMPORARY TABLE zz501_core ( + id INT NOT NULL PRIMARY KEY, + nome VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + pseudonimo VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL +) ENGINE=InnoDB'); +foreach ([ + 1 => ['Rolf Haffner', null], 2 => ['Rolf Häffner', null], 3 => ['Sconto 50% Rossi', null], + 4 => ['a_b Verdi', null], 5 => ['C:\\dir Bianchi', null], 6 => ['Hans Müller', 'H. Mueller'], +] as $id => [$nome, $pseudo]) { + Exact501::exec($db, 'INSERT INTO zz501_core (id, nome, pseudonimo) VALUES (?, ?, ?)', [$id, $nome, $pseudo]); +} +$f = static fn(string $v): array => Exact501::ids($db, 'zz501_core', E::filterCondition(['nome', 'pseudonimo'], $v)); + +$same([1, 2], $f('haffner'), 'unquoted haffner still finds both spellings (tolerant default kept)'); +$same([1], $f('"haffner"'), '"haffner" excludes Häffner'); +$same([2], $f('"HÄFFNER"'), 'case is ignored inside quotes, also on an umlaut'); +$same([3], $f('50%'), '% is a literal character, not a wildcard'); +$same([4], $f('a_b'), '_ is a literal character, not a one-letter wildcard'); +$same([5], $f('C:\\dir'), 'a backslash is a literal character'); +$same([6], $f('"mueller"'), 'a quoted value also searches the pseudonym column'); + +$eq = E::equalsCondition('nome', 'rolf haffner'); +$same([1], Exact501::ids($db, 'zz501_core', $eq), 'equalsCondition matches the exact name, case ignored, accents distinct'); +$in = E::inCondition('nome', ['Rolf Häffner', 'Nobody']); +$same([2], Exact501::ids($db, 'zz501_core', $in), 'inCondition keeps accents distinct across the list'); + +$stmt = $db->prepare('SELECT id FROM zz501_core WHERE nome = ? ORDER BY ' . E::exactFirstSql('nome') . ', id LIMIT 1'); +$probe = 'rolf häffner'; +$stmt->bind_param('ss', $probe, $probe); +$stmt->execute(); +$same(2, (int) $stmt->get_result()->fetch_column(), 'exactFirstSql ranks the exact spelling above the accent-folded row'); +$stmt->close(); + +$w = static fn(string $v): array => Exact501::ids($db, 'zz501_core', E::wordsCondition(['nome', 'pseudonimo'], $v)); +$same([2], $w('rolf "häffner"'), 'wordsCondition ANDs a tolerant word with a quoted one'); +$same([], $f("\xff\xfe"), 'a garbled value matches nothing instead of every row'); + +$limited = \App\Support\ExactSearch::wordsCondition(['nome'], '"thomas mann" other words', 1); +$same(['%thomas mann%'], $limited['params'], 'a search budget counts a quoted phrase as one term'); +$same(null, \App\Support\ExactSearch::wordsCondition(['nome'], 'mann', 0), 'a zero search budget produces no invalid empty predicate'); +Exact501::finish(31); diff --git a/tests/exact-search-dedup-501.unit.php b/tests/exact-search-dedup-501.unit.php new file mode 100644 index 000000000..0b8a6bbe4 --- /dev/null +++ b/tests/exact-search-dedup-501.unit.php @@ -0,0 +1,86 @@ +findByCanonicalName("{$given} Häffner"), 'author: the exact umlaut spelling wins over the older Haffner'); +$same($authorPlain, $authors->findByCanonicalName("{$given} Haffner"), 'author: Haffner still resolves to Haffner'); +$same($authorUmlaut, $authors->findByCanonicalName(mb_strtolower("{$given} Häffner")), 'author: a lower-case Häffner resolves to Häffner'); +$same($authorUmlaut, $authors->findByCanonicalName(mb_strtoupper("{$given} Häffner")), 'author: an upper-case HÄFFNER resolves to Häffner'); +$same($authorUmlaut, $authors->findByCanonicalName("Häffner, {$given}"), 'author: the inverted "Häffner, Given" form resolves to Häffner'); +$same($authorPlain, $authors->findByCanonicalName("Haffner, {$given}"), 'author: the inverted "Haffner, Given" form resolves to Haffner'); +$same($authorUmlaut, $authors->findByName("{$given} Häffner"), 'author: findByName() follows the same rule'); + +// ── PublisherRepository (book form, bulk edit, mobile API) ───────────────── +$publishers = new PublisherRepository($db); +$same($pubUmlaut, $publishers->findByName("Verlag {$given} Häffner"), 'publisher: the exact umlaut spelling wins'); +$same($pubPlain, $publishers->findByName("Verlag {$given} Haffner"), 'publisher: Haffner still resolves to Haffner'); +$same($pubUmlaut, $publishers->findByName(mb_strtolower("Verlag {$given} Häffner")), 'publisher: case is ignored while the umlaut is kept'); + +// ── BulkEnrichmentService (cover/metadata enrichment) ────────────────────── +$bulk = new \App\Services\BulkEnrichmentService($db); +$bulkPublisher = new \ReflectionMethod($bulk, 'findOrCreatePublisher'); +$bulkAuthor = new \ReflectionMethod($bulk, 'findOrCreateAuthor'); +$same($pubUmlaut, $bulkPublisher->invoke($bulk, "Verlag {$given} Häffner"), 'bulk enrichment: publisher Häffner is not merged into Haffner'); +$same($pubPlain, $bulkPublisher->invoke($bulk, "Verlag {$given} Haffner"), 'bulk enrichment: publisher Haffner keeps its own row'); +$same($authorUmlaut, $bulkAuthor->invoke($bulk, "{$given} Häffner"), 'bulk enrichment: author Häffner is not merged into Haffner'); +$same($authorPlain, $bulkAuthor->invoke($bulk, "{$given} Haffner"), 'bulk enrichment: author Haffner keeps its own row'); + +// ── CSV and LibraryThing importers ───────────────────────────────────────── +foreach ([\App\Controllers\CsvImportController::class => 'CSV import', \App\Controllers\LibraryThingImportController::class => 'LibraryThing import'] as $class => $label) { + $importer = (new \ReflectionClass($class))->newInstanceWithoutConstructor(); + $getOrCreate = new \ReflectionMethod($importer, 'getOrCreatePublisher'); + $same(['id' => $pubUmlaut, 'created' => false], $getOrCreate->invoke($importer, $db, "Verlag {$given} Häffner"), "{$label}: publisher Häffner links to Häffner"); + $same(['id' => $pubPlain, 'created' => false], $getOrCreate->invoke($importer, $db, "Verlag {$given} Haffner"), "{$label}: publisher Haffner links to Haffner"); +} + +// ── Plugins: book club and periodicals ───────────────────────────────────── +$club = new \App\Plugins\BookClub\Repo($db); +$clubPublisher = new \ReflectionMethod($club, 'findOrCreatePublisher'); +$same($pubUmlaut, $clubPublisher->invoke($club, "Verlag {$given} Häffner"), 'book club: publisher Häffner links to Häffner'); +$same($pubPlain, $clubPublisher->invoke($club, "Verlag {$given} Haffner"), 'book club: publisher Haffner links to Haffner'); + +$periodicals = new \App\Plugins\Emeroteca\Services\PeriodicalPublisherService($db); +$same([$pubUmlaut], $periodicals->resolve(["new:Verlag {$given} Häffner"]), 'periodicals: a typed publisher Häffner links to Häffner'); +$same([$pubPlain], $periodicals->resolve(["new:Verlag {$given} Haffner"]), 'periodicals: a typed publisher Haffner links to Haffner'); + +// ── The tolerant fallback is deliberate and stays ───────────────────────── +Exact501::exec($db, 'DELETE FROM autori WHERE id = ?', [$authorUmlaut]); +Exact501::exec($db, 'DELETE FROM editori WHERE id = ?', [$pubUmlaut]); +$same($authorPlain, $authors->findByCanonicalName("{$given} Häffner"), 'author: with only Haffner on file, an incoming Häffner still reuses it (tolerant dedup kept)'); +$same($pubPlain, $publishers->findByName("Verlag {$given} Häffner"), 'publisher: with only Haffner on file, an incoming Häffner still reuses it (tolerant dedup kept)'); +$ok($authors->findByCanonicalName("{$given} Hoffner") === null, 'author: a different letter is never merged'); + +Exact501::finish(25); diff --git a/tests/exact-search-fulltext-501.unit.php b/tests/exact-search-fulltext-501.unit.php new file mode 100644 index 000000000..a85abb7ab --- /dev/null +++ b/tests/exact-search-fulltext-501.unit.php @@ -0,0 +1,108 @@ +query('DROP TABLE IF EXISTS `' . ZZ501_FT . '`'); +Exact501::onCleanup(static fn() => $db->query('DROP TABLE IF EXISTS `' . ZZ501_FT . '`')); +$db->query('CREATE TABLE `' . ZZ501_FT . '` ( + id INT NOT NULL PRIMARY KEY, + search_index MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + FULLTEXT KEY ft (search_index) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'); +foreach ([ + 1 => 'Die Brücke Rolf Haffner', + 2 => 'Gedichte Anna Häffner', + 3 => 'Der Zauberberg Thomas Mann', + 4 => 'Mann ohne Eigenschaften Thomas Bernhard', + 5 => 'Müller Lexikon', + 6 => 'Schaffner Werke', + 7 => "L'orologio di Häffner", + 8 => 'C++ für Haffner', +] as $id => $text) { + Exact501::exec($db, 'INSERT INTO `' . ZZ501_FT . '` (id, search_index) VALUES (?, ?)', [$id, $text]); +} +$b = static fn(string $q): array => Exact501::ids($db, '`' . ZZ501_FT . '` s', S::buildSearchCondition($db, 's.search_index', $q)); + +$same([1, 2, 7, 8], $b('haffner'), 'unquoted haffner finds every spelling'); +$same([1, 8], $b('"haffner"'), '"haffner" finds only Haffner'); +$same([2, 7], $b('"Häffner"'), '"Häffner" finds only Häffner'); +$same([1, 8], $b('"HAFFNER"'), 'case is ignored inside quotes'); +$same([1, 8], $b('"haff'), 'an unclosed quote counts as closed, and a quoted word start is enough'); +$same([1, 2, 7, 8], $b('haff'), 'unquoted word start keeps finding both spellings'); +$same([2, 7], $b('"häff"'), 'a quoted word start keeps the umlaut distinct'); +$ok(!in_array(6, $b('"haff"'), true), 'a quoted word start does not match inside another word (Schaffner), like the unquoted search'); +$same([2, 7], $b('„Häffner“'), 'typographic quotes behave like straight ones'); +$same([3], $b('"thomas mann"'), 'a quoted phrase must appear in that order'); +$same([3, 4], $b('thomas mann'), 'without quotes the words may be anywhere'); +$same([3], $b('"thomas man"'), 'a phrase whose last word is still being typed matches'); +$same([5], $b('muller'), 'unquoted muller still finds Müller'); +$same([], $b('"muller"'), '"muller" does not find Müller'); +$same([5], $b('"müller"'), '"müller" finds Müller'); +$same([2], $b('gedichte "häffner"'), 'quoted and tolerant terms are ANDed'); +$same([], $b('brücke "häffner"'), 'the quoted term filters out books where only the tolerant word matches'); +$same([7], $b("\"l'orologio\""), 'an apostrophe inside quotes is matched literally'); +$same([8], $b('"c++ für"'), 'punctuation and short words inside quotes are matched literally'); +$ok(S::buildSearchCondition($db, 's.search_index', '"') === null, 'a lone quote adds no condition'); +$phrase = S::buildSearchCondition($db, 's.search_index', '"thomas mann"'); +$ok($phrase !== null && str_contains($phrase['sql'], 'MATCH(') && $phrase['params'][0] === '+thomas* +mann*', 'a quoted phrase is pre-filtered by FULLTEXT on each of its words'); +$same([], $b("\xff\xfe"), 'a garbled query matches nothing instead of the whole catalogue'); +$explain = $db->prepare('EXPLAIN SELECT s.id FROM `' . ZZ501_FT . '` s WHERE ' . $phrase['sql']); +$explain->bind_param($phrase['types'], ...$phrase['params']); +$explain->execute(); +$plan = strtolower((string) json_encode($explain->get_result()->fetch_all(MYSQLI_ASSOC))); +$explain->close(); +$ok(str_contains($plan, 'fulltext') || str_contains($plan, 'full-text'), 'EXPLAIN confirms a quoted phrase uses the FULLTEXT index'); + + +// ── Ranking and the pre-migration fallback, on real books ───────────────── +$token = Exact501::token(); +$bookIds = []; +foreach (["{$token} Häffner Studien", "{$token} Haffner Studien"] as $title) { + $bookIds[] = Exact501::exec($db, 'INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES (?, 1, 1)', [$title]); +} +Exact501::onCleanup(static fn() => $db->query('DELETE FROM libri WHERE id IN (' . implode(',', $bookIds) . ')')); +S::rebuildMany($db, $bookIds); +[$umlautId, $plainId] = $bookIds; + +$rel = S::buildRelevanceOrder($db, "{$token} \"häffner\"", 'l.'); +$stmt = $db->prepare("SELECT l.id, {$rel['score_sql']} AS score FROM libri l WHERE l.id IN (?, ?) ORDER BY l.id"); +$stmt->bind_param($rel['types'] . 'ii', ...[...$rel['params'], $umlautId, $plainId]); +$stmt->execute(); +$scores = array_column($stmt->get_result()->fetch_all(MYSQLI_ASSOC), 'score', 'id'); +$stmt->close(); +$ok((int) $scores[$umlautId] > (int) $scores[$plainId], 'ranking scores the quoted term only on the exact spelling'); + +$capped = S::buildRelevanceOrder($db, '"thomas mann" roman', 'l.', 1); +$ok(substr_count($capped['sql'], 'THEN 100') === 1 && str_contains($capped['sql'], 'COLLATE utf8mb4_bin'), 'the word cap counts a quoted phrase as one term'); + +$prop = new \ReflectionProperty(S::class, 'columnExists'); +$saved = $prop->getValue(); +$prop->setValue(null, false); +try { + $legacyExact = S::buildSearchCondition($db, 'l.search_index', "{$token} \"häffner\""); + $legacyTolerant = S::buildSearchCondition($db, 'l.search_index', "{$token} haffner"); +} finally { + $prop->setValue(null, $saved); +} +$same([$umlautId], Exact501::ids($db, 'libri l', $legacyExact, 'AND l.deleted_at IS NULL'), 'pre-migration fallback: the quoted spelling finds only its book'); +$same([$umlautId, $plainId], Exact501::ids($db, 'libri l', $legacyTolerant, 'AND l.deleted_at IS NULL'), 'pre-migration fallback: unquoted still finds both'); + +Exact501::finish(27); diff --git a/tests/exact-search-mobile-collections-501.unit.php b/tests/exact-search-mobile-collections-501.unit.php new file mode 100644 index 000000000..ff4e1d8c5 --- /dev/null +++ b/tests/exact-search-mobile-collections-501.unit.php @@ -0,0 +1,41 @@ +query("CREATE TEMPORARY TABLE plugins(id INT,name VARCHAR(30),is_active INT) $charset"); +$db->query("INSERT INTO plugins VALUES(1,'archives',1),(2,'desiderata',1)"); +$db->query("CREATE TEMPORARY TABLE archival_units(id INT,parent_id INT,reference_code VARCHAR(50),level VARCHAR(20),constructed_title TEXT,formal_title TEXT,scope_content TEXT,date_start INT,date_end INT,extent TEXT,specific_material TEXT,cover_image_path TEXT,published INT,deleted_at DATETIME) $charset"); +$db->query("CREATE TEMPORARY TABLE authority_records(id INT,type VARCHAR(20),authorised_form TEXT,parallel_forms TEXT,other_forms TEXT,dates_of_existence TEXT,deleted_at DATETIME) $charset"); +$db->query("CREATE TEMPORARY TABLE archival_unit_authority(archival_unit_id INT,authority_id INT) $charset"); +$db->query("CREATE TEMPORARY TABLE desiderata_offers(id INT,title TEXT,author TEXT,donor_name TEXT,donor_email TEXT,isbn VARCHAR(20),status VARCHAR(20),book_id INT,received_book_id INT,created_at DATETIME) $charset"); +foreach(['Haffner','Häffner'] as $i=>$name){ + $id=$i+1; + Exact501::exec($db,"INSERT INTO archival_units(id,reference_code,level,constructed_title,published) VALUES(?,?,'fonds',?,1)",[$id,"U$id",$name]); + Exact501::exec($db,"INSERT INTO authority_records(id,type,authorised_form) VALUES(?,'person',?)",[$id,$name]); + Exact501::exec($db,"INSERT INTO archival_unit_authority VALUES(?,?)",[$id,$id]); + Exact501::exec($db,"INSERT INTO desiderata_offers(id,title,author,donor_name,donor_email,status,created_at) VALUES(?,?,?,?,?,'pending',NOW())",[$id,$name,$name,$name,$name]); +} +$hooks=new App\Support\HookManager($db); +$public=new App\Plugins\MobileApi\Controllers\CollectionsController($db,$hooks); +$staff=new App\Plugins\MobileApi\Controllers\StaffArchivesController($db,$hooks); +$offers=new App\Plugins\MobileApi\Controllers\StaffDesiderataController($db,$hooks); +foreach(['haffner'=>[1,2],'"haffner"'=>[1],'"HÄFFNER"'=>[2],'%'=>[]] as $q=>$expected){ + $request=(new ServerRequestFactory())->createServerRequest('GET','/')->withQueryParams(['q'=>$q]); + foreach([ + 'archive collection'=>fn()=> $public->handle('archives',$request,new Response(),'archives'), + 'staff authorities'=>fn()=> $staff->authorities($request,new Response()), + 'staff donation offers'=>fn()=> $offers->offers($request,new Response()), + ] as $label=>$read){ + $response=$read(); + Exact501::same(200,$response->getStatusCode(),"$label: $q returns 200"); + $payload=json_decode((string)$response->getBody(),true,512,JSON_THROW_ON_ERROR); + $found=array_map('intval',array_column($payload['data'],'id'));sort($found); + Exact501::same($expected,$found,"$label: $q"); + } +} +Exact501::finish(24); diff --git a/tests/helpers/exact-search-501-seed.php b/tests/helpers/exact-search-501-seed.php new file mode 100644 index 000000000..ccd9ba313 --- /dev/null +++ b/tests/helpers/exact-search-501-seed.php @@ -0,0 +1,109 @@ + + * php tests/helpers/exact-search-501-seed.php clean '' + * + * Connection: the E2E_DB_* variables set by the E2E runner. + */ + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; + +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + +$host = getenv('E2E_DB_HOST') ?: ''; +$db = $host !== '' + ? new mysqli($host, (string) getenv('E2E_DB_USER'), (string) getenv('E2E_DB_PASS'), (string) getenv('E2E_DB_NAME'), (int) (getenv('E2E_DB_PORT') ?: 3306)) + : new mysqli(null, (string) getenv('E2E_DB_USER'), (string) getenv('E2E_DB_PASS'), (string) getenv('E2E_DB_NAME'), 0, (string) getenv('E2E_DB_SOCKET')); +$db->set_charset('utf8mb4'); + +/** @param list $params */ +function seed501(mysqli $db, string $sql, array $params): int +{ + $stmt = $db->prepare($sql); + $stmt->bind_param(str_repeat('s', count($params)), ...array_map('strval', $params)); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; +} + +$mode = $argv[1] ?? ''; +if ($mode === 'clean') { + $ids = json_decode((string) ($argv[2] ?? '{}'), true) ?: []; + $list = static fn(string $k): string => implode(',', array_map('intval', $ids[$k] ?? [0])); + foreach ([ + 'DELETE FROM prenotazioni WHERE id IN (' . $list('reservations') . ')', + 'DELETE FROM prestiti WHERE id IN (' . $list('loans') . ')', + 'DELETE FROM libri_autori WHERE libro_id IN (' . $list('books') . ')', + 'DELETE FROM libri WHERE id IN (' . $list('books') . ')', + 'DELETE FROM autori WHERE id IN (' . $list('authors') . ')', + 'DELETE FROM editori WHERE id IN (' . $list('publishers') . ')', + 'DELETE FROM utenti WHERE id IN (' . $list('users') . ')', + 'DELETE FROM posizioni WHERE id IN (' . $list('positions') . ')', + 'DELETE FROM mensole WHERE id IN (' . $list('levels') . ')', + 'DELETE FROM scaffali WHERE id IN (' . $list('shelves') . ')', + 'DELETE FROM generi WHERE id IN (' . $list('genres') . ')', + ] as $sql) { + $db->query($sql); + } + echo "{}\n"; + exit(0); +} +if (!in_array($mode, ['seed', 'seed-fields'], true) || ($argv[2] ?? '') === '') { + fwrite(STDERR, "usage: seed | clean \n"); + exit(2); +} + +$t = $argv[2]; +$out = ['authors' => [], 'publishers' => [], 'books' => [], 'users' => [], 'loans' => [], 'reservations' => []]; +$fixtures = [ + ['author' => "{$t} Haffner", 'publisher' => "Verlag {$t} Haffner", 'title' => "{$t} Brückenbuch", 'surname' => 'Haffner'], + ['author' => "{$t} Häffner", 'publisher' => "Verlag {$t} Häffner", 'title' => "{$t} Gedichtband", 'surname' => 'Häffner'], + ['author' => "{$t} HAFFNER", 'publisher' => "Verlag {$t} 50% Rabatt", 'title' => "{$t} Großbuch", 'surname' => null], +]; +foreach ($fixtures as $i => $f) { + $author = seed501($db, 'INSERT INTO autori (nome) VALUES (?)', [$f['author']]); + $publisher = seed501($db, 'INSERT INTO editori (nome) VALUES (?)', [$f['publisher']]); + $book = seed501($db, 'INSERT INTO libri (titolo, editore_id, copie_totali, copie_disponibili) VALUES (?, ?, 1, 1)', [$f['title'], $publisher]); + seed501($db, "INSERT INTO libri_autori (libro_id, autore_id, ruolo) VALUES (?, ?, 'principale')", [$book, $author]); + $out['authors'][] = $author; + $out['publishers'][] = $publisher; + $out['books'][] = $book; + if ($f['surname'] === null) { + continue; + } + $user = seed501( + $db, + "INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata) + VALUES (?, ?, ?, ?, ?, 'standard', 'attivo', 1, 1)", + [substr(strtoupper($t), 0, 14) . $i, $t, $f['surname'], strtolower($t) . ".{$i}@example.test", password_hash('x', PASSWORD_DEFAULT)] + ); + $out['users'][] = $user; + $out['loans'][] = seed501( + $db, + "INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, stato, attivo) + VALUES (?, ?, CURDATE(), CURDATE() + INTERVAL 30 DAY, 'pendente', 0)", + [$book, $user] + ); + $out['reservations'][] = seed501($db, 'INSERT INTO prenotazioni (libro_id, utente_id) VALUES (?, ?)', [$book, $user]); +} +if ($mode === 'seed-fields') { + foreach (['Haffner', 'Häffner', 'HAFFNER'] as $i => $surname) { + $value = "$t $surname"; + seed501($db, 'UPDATE autori SET pseudonimo=?, biografia=?, sito_web=?, `nazionalità`=? WHERE id=?', [$value,$value,$value,$value,$out['authors'][$i]]); + seed501($db, 'UPDATE editori SET sito_web=?, indirizzo=? WHERE id=?', [$value,$value,$out['publishers'][$i]]); + seed501($db, 'UPDATE libri SET collana=?, isbn13=? WHERE id=?', [$value,strtolower($t).$i,$out['books'][$i]]); + $genre = $out['genres'][] = seed501($db, 'INSERT INTO generi (nome) VALUES (?)', [$value]); + $shelf = $out['shelves'][] = seed501($db, 'INSERT INTO scaffali (nome,codice,lettera) VALUES (?,?,?)', [$value,$t.$i,'Z']); + $level = $out['levels'][] = seed501($db, 'INSERT INTO mensole (scaffale_id,numero_livello) VALUES (?,1)', [$shelf]); + $out['positions'][] = seed501($db, 'INSERT INTO posizioni (scaffale_id,mensola_id,genere_id) VALUES (?,?,?)', [$shelf,$level,$genre]); + } +} +App\Support\SearchIndexBuilder::rebuildMany($db, $out['books']); +echo json_encode($out), "\n"; diff --git a/tests/helpers/exact-search-501.js b/tests/helpers/exact-search-501.js new file mode 100644 index 000000000..7534df32a --- /dev/null +++ b/tests/helpers/exact-search-501.js @@ -0,0 +1,99 @@ +// @ts-check +/** + * Shared fixtures for the issue #501 E2E suites (quoted search = exact + * spelling): a per-run token, the seeded rows that differ only by an umlaut, + * and the waits a librarian's typing triggers. + */ +const path = require('path'); +const { execFileSync } = require('child_process'); +const { expect } = require('@playwright/test'); + +const ROOT = path.join(__dirname, '..', '..'); +const SEED = path.join(__dirname, 'exact-search-501-seed.php'); + +/** Letters only, capitalised: the token becomes part of FULLTEXT words. */ +function makeToken() { + const letters = Math.random().toString(36).replace(/[^a-z]/g, '').slice(0, 6).padEnd(6, 'x'); + return 'Zq' + letters; +} + +/** + * Seed the fixtures and describe them. + * @param {string} token + */ +function seed(token, fields = false) { + const out = execFileSync('php', [SEED, fields ? 'seed-fields' : 'seed', token], { cwd: ROOT, encoding: 'utf-8', timeout: 30000, env: process.env }); + const ids = JSON.parse(out.trim()); + return { + token, + ids, + authorPlain: `${token} Haffner`, + authorUmlaut: `${token} Häffner`, + authorUpper: `${token} HAFFNER`, + publisherPlain: `Verlag ${token} Haffner`, + publisherUmlaut: `Verlag ${token} Häffner`, + publisherPercent: `Verlag ${token} 50% Rabatt`, + titlePlain: `${token} Brückenbuch`, + titleUmlaut: `${token} Gedichtband`, + titleUpper: `${token} Großbuch`, + userPlain: `${token} Haffner`, + userUmlaut: `${token} Häffner`, + }; +} + +/** @param {{ids: object}} fixtures */ +function clean(fixtures) { + if (!fixtures) return; + execFileSync('php', [SEED, 'clean', JSON.stringify(fixtures.ids)], { cwd: ROOT, encoding: 'utf-8', timeout: 30000, env: process.env }); +} + +/** + * Log in through the real login form. + * @param {import('@playwright/test').Page} page + * @param {string} base + * @param {string} email + * @param {string} password + */ +async function login(page, base, email, password) { + await page.goto(`${base}/accedi`); + await page.fill('input[name="email"]', email); + await page.fill('input[name="password"]', password); + await Promise.all([ + page.waitForURL((url) => !url.pathname.endsWith('/accedi'), { timeout: 20000 }), + page.click('button[type="submit"]'), + ]); +} + +/** + * Type into a filter box the way a person does and wait for the request the + * typing triggers. Assertions afterwards use Playwright's auto-waiting + * expectations, positive first, so no fixed sleep is needed. + * @param {import('@playwright/test').Page} page + * @param {string} selector + * @param {string} value + * @param {string|RegExp} urlPart + */ +async function typeAndWait(page, selector, value, urlPart) { + const matches = (/** @type {string} */ url) => (typeof urlPart === 'string' ? url.includes(urlPart) : urlPart.test(url)); + const done = page.waitForResponse((r) => matches(r.url()) && r.status() === 200, { timeout: 20000 }); + await page.fill(selector, value); + await page.locator(selector).press('End'); + await done; +} + +/** + * Expect a region to show every title in `present` and none in `absent`. + * @param {import('@playwright/test').Locator} region + * @param {string[]} present + * @param {string[]} absent + */ +async function expectTitles(region, present, absent) { + for (const title of present) { + await expect(region).toContainText(title); + } + for (const title of absent) { + await expect(region).not.toContainText(title); + } +} + +module.exports = { makeToken, seed, clean, login, typeAndWait, expectTitles }; diff --git a/tests/helpers/exact-search-501.php b/tests/helpers/exact-search-501.php new file mode 100644 index 000000000..f527ca208 --- /dev/null +++ b/tests/helpers/exact-search-501.php @@ -0,0 +1,169 @@ + */ + private static array $cleanups = []; + + public static function check(bool $cond, string $desc): void + { + if (!$cond) { + throw new \RuntimeException("assertion failed: {$desc}"); + } + self::$count++; + printf("[%02d] PASS: %s\n", self::$count, $desc); + } + + public static function same(mixed $expected, mixed $actual, string $desc): void + { + if ($expected !== $actual) { + throw new \RuntimeException(sprintf( + "assertion failed: %s\n expected: %s\n actual: %s", + $desc, + json_encode($expected, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), + json_encode($actual, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) + )); + } + self::check(true, $desc); + } + + public static function onCleanup(callable $fn): void + { + self::$cleanups[] = $fn; + } + + public static function cleanup(): void + { + foreach (array_reverse(self::$cleanups) as $fn) { + try { + $fn(); + } catch (\Throwable $ignored) { + } + } + self::$cleanups = []; + } + + /** @return array */ + private static function env(): array + { + $env = []; + $path = dirname(__DIR__, 2) . '/.env'; + foreach (preg_split('/\r?\n/', (string) @file_get_contents($path)) ?: [] as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) { + continue; + } + [$k, $v] = explode('=', $line, 2); + $v = trim($v); + if (strlen($v) >= 2 && ($v[0] === '"' || $v[0] === "'") && $v[-1] === $v[0]) { + $v = substr($v, 1, -1); + } + $env[trim($k)] = $v; + } + return $env; + } + + /** + * Connect, or end the suite: SKIP locally, FAIL in strict CI mode. + */ + public static function db(): \mysqli + { + $env = self::env(); + $user = $env['DB_USER'] ?? ''; + $pass = $env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? ''); + $name = $env['DB_NAME'] ?? ''; + $socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? '/opt/homebrew/var/mysql/mysql.sock'); + try { + $db = (is_string($socket) && $socket !== '' && file_exists($socket)) + ? new \mysqli(null, $user, $pass, $name, 0, $socket) + : new \mysqli($env['DB_HOST'] ?? '127.0.0.1', $user, $pass, $name, (int) ($env['DB_PORT'] ?? 3306)); + } catch (\Throwable $e) { + if (getenv('CI_STRICT_TESTS') === '1') { + fwrite(STDERR, "FAIL: database not reachable in strict mode\n"); + exit(1); + } + echo 'SKIP: database not reachable (' . $e->getMessage() . ")\n"; + exit(0); + } + $db->set_charset('utf8mb4'); + return $db; + } + + /** A token unique to this run, letters only (it becomes part of FULLTEXT words). */ + public static function token(): string + { + $letters = ''; + foreach (str_split(bin2hex(random_bytes(4))) as $c) { + $letters .= chr(ord('a') + hexdec($c) % 26); + } + return 'zq' . $letters; + } + + /** + * Ids returned by "SELECT id FROM $from WHERE $cond". + * + * @param array{sql:string, params:array, types:string}|null $cond + * @return list + */ + public static function ids(\mysqli $db, string $from, ?array $cond, string $extra = ''): array + { + if ($cond === null) { + return []; + } + $stmt = $db->prepare("SELECT id FROM {$from} WHERE {$cond['sql']} {$extra} ORDER BY id"); + if ($cond['types'] !== '') { + $stmt->bind_param($cond['types'], ...$cond['params']); + } + $stmt->execute(); + $ids = array_map('intval', array_column($stmt->get_result()->fetch_all(MYSQLI_ASSOC), 'id')); + $stmt->close(); + return $ids; + } + + /** @param list $params */ + public static function exec(\mysqli $db, string $sql, array $params = []): int + { + $stmt = $db->prepare($sql); + if ($params !== []) { + $stmt->bind_param(str_repeat('s', count($params)), ...array_map(static fn($p) => $p === null ? null : (string) $p, $params)); + } + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; + } + + public static function finish(int $expected): void + { + self::cleanup(); + if (self::$count !== $expected) { + fwrite(STDERR, sprintf("FAIL: ran %d checks, expected %d\n", self::$count, $expected)); + exit(1); + } + printf("\nALL %d PASS\n", self::$count); + } +} + +// A failed check (or any error) cleans up the seeded rows and exits 1 with a +// readable FAIL line, whether it happens before or after the database is up. +set_exception_handler(static function (\Throwable $e): void { + Exact501::cleanup(); + fwrite(STDERR, 'FAIL: ' . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); + exit(1); +}); diff --git a/tests/issue-501-admin-lists.spec.js b/tests/issue-501-admin-lists.spec.js new file mode 100644 index 000000000..fc12f5c23 --- /dev/null +++ b/tests/issue-501-admin-lists.spec.js @@ -0,0 +1,211 @@ +// @ts-check +/** + * Issue #501, part 5 of 9: the back office. Every list a librarian filters by + * typing — books, authors, publishers, users, loans, reservations — plus the + * global search, the pickers of the new-loan form and the CSV exports, which + * must export what the filtered list shows. Unquoted keeps finding every + * spelling; a quoted value finds only its exact spelling. + */ +const { test, expect } = require('@playwright/test'); +const { createTempAdminUser, deleteTempAdminUser } = require('./helpers/e2e-fixtures'); +const { makeToken, seed, clean, login, typeAndWait, expectTitles } = require('./helpers/exact-search-501'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +test.skip(!process.env.E2E_DB_USER || !process.env.E2E_DB_NAME, 'E2E database not configured (use /tmp/run-e2e.sh)'); + +const enc = encodeURIComponent; + +test.describe.serial('#501 admin lists honour quotes', () => { + /** @type {ReturnType} */ + let f; + /** @type {{id:number,email:string,password:string}} */ + let admin; + /** @type {import('@playwright/test').BrowserContext} */ + let context; + /** @type {import('@playwright/test').Page} */ + let page; + + test.beforeAll(async ({ browser }) => { + f = seed(makeToken()); + admin = createTempAdminUser('it_IT'); + context = await browser.newContext(); + page = await context.newPage(); + await login(page, BASE, admin.email, admin.password); + }); + test.afterAll(async () => { + await context?.close(); + clean(f); + deleteTempAdminUser(admin?.id); + }); + + const table = () => page.locator('table').first(); + // DataTables with horizontal scroll splits header and body into two tables. + const main = () => page.locator('main'); + + // ── Books ──────────────────────────────────────────────────────────────── + test('01 books: unquoted author name finds every spelling', async () => { + await page.goto(`${BASE}/admin/books`); + await typeAndWait(page, '#search_text', `${f.token} haffner`, '/api/libri'); + await expectTitles(table(), [f.titlePlain, f.titleUmlaut, f.titleUpper], []); + }); + + test('02 books: quoted "haffner" drops the Häffner book', async () => { + await typeAndWait(page, '#search_text', `${f.token} "haffner"`, '/api/libri'); + await expectTitles(table(), [f.titlePlain, f.titleUpper], [f.titleUmlaut]); + }); + + test('03 books: quoted "Häffner" keeps only the Häffner book', async () => { + await typeAndWait(page, '#search_text', `${f.token} "Häffner"`, '/api/libri'); + await expectTitles(table(), [f.titleUmlaut], [f.titlePlain, f.titleUpper]); + }); + + // ── Authors ────────────────────────────────────────────────────────────── + test('04 authors: unquoted finds every spelling', async () => { + await page.goto(`${BASE}/admin/authors`); + await typeAndWait(page, '#search_nome', `${f.token} haffner`, '/api/autori'); + await expectTitles(table(), [f.authorPlain, f.authorUmlaut, f.authorUpper], []); + }); + + test('05 authors: quoted "haffner" drops Häffner, keeps the case variant', async () => { + await typeAndWait(page, '#search_nome', `"${f.token} haffner"`, '/api/autori'); + await expectTitles(table(), [f.authorPlain, f.authorUpper], [f.authorUmlaut]); + }); + + test('06 authors: quoted "Häffner" keeps only Häffner', async () => { + await typeAndWait(page, '#search_nome', `"${f.token} Häffner"`, '/api/autori'); + await expectTitles(table(), [f.authorUmlaut], [f.authorPlain]); + }); + + test('07 authors: two quoted groups in the box are one phrase', async () => { + await typeAndWait(page, '#search_nome', `"${f.token}" "häffner"`, '/api/autori'); + await expectTitles(table(), [f.authorUmlaut], [f.authorPlain]); + }); + + // ── Publishers ─────────────────────────────────────────────────────────── + test('08 publishers: unquoted finds every spelling', async () => { + await page.goto(`${BASE}/admin/publishers`); + await typeAndWait(page, '#search_nome', `verlag ${f.token} haffner`, '/api/editori'); + await expectTitles(table(), [f.publisherPlain, f.publisherUmlaut], []); + }); + + test('09 publishers: quoted Haffner drops Häffner', async () => { + await typeAndWait(page, '#search_nome', `"verlag ${f.token} haffner"`, '/api/editori'); + await expectTitles(table(), [f.publisherPlain], [f.publisherUmlaut]); + }); + + test('10 publishers: quoted Häffner drops Haffner', async () => { + await typeAndWait(page, '#search_nome', `"verlag ${f.token} Häffner"`, '/api/editori'); + await expectTitles(table(), [f.publisherUmlaut], [f.publisherPlain]); + }); + + test('11 publishers: a typed % is a literal character', async () => { + await typeAndWait(page, '#search_nome', `${f.token} 50%`, '/api/editori'); + await expectTitles(table(), [f.publisherPercent], [f.publisherPlain, f.publisherUmlaut]); + }); + + // ── Users ──────────────────────────────────────────────────────────────── + test('12 users: unquoted surname finds both borrowers', async () => { + await page.goto(`${BASE}/admin/users`); + await typeAndWait(page, '#search_text', 'haffner', '/api/utenti'); + await expect(main()).toContainText(f.token); + await expect(main()).toContainText('Häffner'); + }); + + test('13 users: quoted "Häffner" drops Haffner', async () => { + await typeAndWait(page, '#search_text', '"Häffner"', '/api/utenti'); + await expect(main()).toContainText('Häffner'); + await expect(main().locator('tr', { hasText: f.token }).filter({ hasText: /Haffner/ })).toHaveCount(0); + }); + + test('14 users: quoted "Haffner" drops Häffner', async () => { + await typeAndWait(page, '#search_text', '"Haffner"', '/api/utenti'); + await expect(main().locator('tr', { hasText: f.token }).filter({ hasText: 'Haffner' })).toHaveCount(1); + await expect(main().locator('tr', { hasText: f.token }).filter({ hasText: 'Häffner' })).toHaveCount(0); + }); + + // ── Loans ──────────────────────────────────────────────────────────────── + // The pending-requests panel above the table lists every pending loan + // whatever is searched, so the checks look at the loans table only. + test('15 loans: unquoted title finds both loans', async () => { + await page.goto(`${BASE}/admin/loans`); + await typeAndWait(page, '.dt-search input', `${f.token}`, '/api/prestiti'); + await expectTitles(page.locator('#prestiti-table'), [f.titlePlain, f.titleUmlaut], []); + }); + + test('16 loans: quoted borrower "Häffner" keeps only that loan', async () => { + await typeAndWait(page, '.dt-search input', `"${f.userUmlaut}"`, '/api/prestiti'); + await expectTitles(page.locator('#prestiti-table'), [f.titleUmlaut], [f.titlePlain]); + }); + + // ── Reservations ───────────────────────────────────────────────────────── + /** @param {string} field @param {string} value */ + async function filterReservations(field, value) { + await page.goto(`${BASE}/admin/reservations`); + await page.fill(field, value); + await Promise.all([page.waitForURL(/[?&]q_/), page.getByRole('button', { name: 'Cerca' }).click()]); + } + + test('17 reservations: unquoted borrower finds both', async () => { + await filterReservations('#admin_filter_utente', `${f.token} haffner`); + await expectTitles(page.locator('main'), [f.titlePlain, f.titleUmlaut], []); + }); + + test('18 reservations: quoted borrower "Häffner" keeps only that reservation', async () => { + await filterReservations('#admin_filter_utente', `"${f.userUmlaut}"`); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain]); + }); + + test('19 reservations: quoted title "Brückenbuch" keeps only that reservation', async () => { + await filterReservations('#admin_filter_libro', `"${f.titlePlain}"`); + await expectTitles(page.locator('main'), [f.titlePlain], [f.titleUmlaut]); + }); + + // ── Global search and the new-loan pickers ─────────────────────────────── + test('20 global search: unquoted lists both authors', async () => { + await page.goto(`${BASE}/admin/dashboard`); + await typeAndWait(page, '#global-search', `${f.token} haffner`, '/api/search/unified'); + await expectTitles(page.locator('#global-search-results'), [f.authorPlain, f.authorUmlaut], []); + }); + + test('21 global search: quoted "Häffner" lists only Häffner', async () => { + await typeAndWait(page, '#global-search', `${f.token} "häffner"`, '/api/search/unified'); + await expectTitles(page.locator('#global-search-results'), [f.authorUmlaut], [f.authorPlain]); + }); + + test('22 new loan: the borrower picker honours quotes', async () => { + await page.goto(`${BASE}/admin/loans/create`); + await typeAndWait(page, '#utente_search', `"${f.userUmlaut}"`, '/api/search/utenti'); + await expect(page.locator('#utente_suggest')).toContainText('Häffner'); + await expect(page.locator('#utente_suggest')).not.toContainText(`${f.token} Haffner`); + }); + + test('23 new loan: the book picker honours quotes', async () => { + await typeAndWait(page, '#libro_search', `${f.token} "häffner"`, '/api/search/libri'); + await expectTitles(page.locator('#libro_suggest'), [f.titleUmlaut], [f.titlePlain]); + }); + + // ── CSV exports ────────────────────────────────────────────────────────── + test('24 books CSV export follows the quoted search', async () => { + const res = await page.request.get(`${BASE}/admin/books/export/csv?search=${enc(`"${f.titleUmlaut}"`)}`); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toContain(f.titleUmlaut); + expect(body).not.toContain(f.titlePlain); + }); + + test('25 users CSV export follows the quoted search', async () => { + const res = await page.request.get(`${BASE}/admin/users/export/csv?search_text=${enc('"Häffner"')}`); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toContain(f.token.toLowerCase() + '.1@example.test'); + expect(body).not.toContain(f.token.toLowerCase() + '.0@example.test'); + }); + + test('26 LibraryThing export follows the quoted search', async () => { + const res = await page.request.get(`${BASE}/admin/books/export/librarything?search=${enc(`"${f.titlePlain}"`)}`); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toContain(f.titlePlain); + expect(body).not.toContain(f.titleUmlaut); + }); +}); diff --git a/tests/issue-501-exact-quoted-search.spec.js b/tests/issue-501-exact-quoted-search.spec.js index f7c05038a..437f86475 100644 --- a/tests/issue-501-exact-quoted-search.spec.js +++ b/tests/issue-501-exact-quoted-search.spec.js @@ -76,8 +76,8 @@ async function typeAndWait(page, selector, value, apiPath) { const reloaded = page.waitForResponse((r) => r.url().includes(apiPath) && r.status() === 200, { timeout: 15000 }); await page.fill(selector, value); await reloaded; - // The table repaints after the JSON lands. - await page.waitForTimeout(300); + // No fixed sleep: every check below asserts a present title first, and + // Playwright's expectations wait for the repaint on their own. } test.describe.serial('#501 quoted search = exact spelling', () => { @@ -147,7 +147,6 @@ test.describe.serial('#501 quoted search = exact spelling', () => { await page.fill('#search-input', value); await page.locator('#search-input').press('End'); await done; - await page.waitForTimeout(300); }; await search(`${TOKEN} haffner`); diff --git a/tests/issue-501-name-pages.spec.js b/tests/issue-501-name-pages.spec.js new file mode 100644 index 000000000..02b2c3026 --- /dev/null +++ b/tests/issue-501-name-pages.spec.js @@ -0,0 +1,188 @@ +// @ts-check +/** + * Issue #501, part 3 of 9: public pages and filters that look a person up BY + * NAME. The collation folds accents, so the author page of "Haffner" also + * listed the books of "Häffner" (two people merged as homonyms), and so did + * the publisher page, the catalogue publisher/author filters and the + * publisher facet. A name is now matched with its exact spelling (case still + * ignored). Driven like a reader: direct links, clicks from a book page, the + * facet list and the search boxes. + */ +const { test, expect } = require('@playwright/test'); +const { makeToken, seed, clean, typeAndWait, expectTitles } = require('./helpers/exact-search-501'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +test.skip(!process.env.E2E_DB_USER || !process.env.E2E_DB_NAME, 'E2E database not configured (use /tmp/run-e2e.sh)'); + +const enc = encodeURIComponent; + +test.describe.serial('#501 name pages and filters keep accents distinct', () => { + /** @type {ReturnType} */ + let f; + + test.beforeAll(() => { + f = seed(makeToken()); + }); + test.afterAll(() => { + clean(f); + }); + + /** @param {import('@playwright/test').Page} page @param {string} title */ + async function openBook(page, title) { + await page.goto(`${BASE}/catalogo?q=${enc(`"${title}"`)}`); + await page.locator('main').getByRole('link', { name: title }).first().click(); + await expect(page.locator('main h1').first()).toContainText(title); + } + + // ── Author pages ───────────────────────────────────────────────────────── + test('01 author page "Haffner" lists the Haffner book', async ({ page }) => { + await page.goto(`${BASE}/autore/${enc(f.authorPlain)}`); + await expect(page.locator('main')).toContainText(f.titlePlain); + }); + + test('02 author page "Haffner" no longer lists the Häffner book', async ({ page }) => { + await page.goto(`${BASE}/autore/${enc(f.authorPlain)}`); + await expectTitles(page.locator('main'), [f.titlePlain], [f.titleUmlaut]); + }); + + test('03 author page "Haffner" still merges a homonym that differs only in case', async ({ page }) => { + await page.goto(`${BASE}/autore/${enc(f.authorPlain)}`); + await expectTitles(page.locator('main'), [f.titlePlain, f.titleUpper], []); + }); + + test('04 author page "Häffner" lists only the Häffner book', async ({ page }) => { + await page.goto(`${BASE}/autore/${enc(f.authorUmlaut)}`); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain, f.titleUpper]); + }); + + test('05 author page "Häffner" shows the umlaut name in its heading', async ({ page }) => { + await page.goto(`${BASE}/autore/${enc(f.authorUmlaut)}`); + await expect(page.locator('#archive-title')).toContainText(f.authorUmlaut); + }); + + test('06 a lower-case author URL still opens the right person', async ({ page }) => { + await page.goto(`${BASE}/autore/${enc(f.authorUmlaut.toLowerCase())}`); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain]); + }); + + test('07 a spelling nobody has is a 404, not the nearest homonym', async ({ page }) => { + const res = await page.goto(`${BASE}/autore/${enc(`${f.token} Haeffner`)}`); + expect(res?.status()).toBe(404); + }); + + // ── Publisher pages ────────────────────────────────────────────────────── + test('08 publisher page "Haffner" lists only its own book', async ({ page }) => { + await page.goto(`${BASE}/editore/${enc(f.publisherPlain)}`); + await expectTitles(page.locator('main'), [f.titlePlain], [f.titleUmlaut]); + }); + + test('09 publisher page "Häffner" lists only its own book', async ({ page }) => { + await page.goto(`${BASE}/editore/${enc(f.publisherUmlaut)}`); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain]); + }); + + test('10 publisher page by id resolves to the exact name', async ({ page }) => { + await page.goto(`${BASE}/editore/${f.ids.publishers[1]}`); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain]); + }); + + test('11 a publisher name with % opens its page (the % is not a wildcard)', async ({ page }) => { + await page.goto(`${BASE}/editore/${enc(f.publisherPercent)}`); + await expectTitles(page.locator('main'), [f.titleUpper], [f.titlePlain, f.titleUmlaut]); + }); + + // ── Clicks from a book page ────────────────────────────────────────────── + test('12 clicking the author on the Häffner book opens only Häffner', async ({ page }) => { + await openBook(page, f.titleUmlaut); + await page.locator('main').getByRole('link', { name: f.authorUmlaut }).first().click(); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain]); + }); + + test('13 clicking the publisher on the Häffner book opens only Häffner', async ({ page }) => { + await openBook(page, f.titleUmlaut); + await page.locator('main').getByRole('link', { name: f.publisherUmlaut }).first().click(); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain]); + }); + + test('14 clicking the publisher on the Haffner book opens only Haffner', async ({ page }) => { + await openBook(page, f.titlePlain); + await page.locator('main').getByRole('link', { name: f.publisherPlain }).first().click(); + await expectTitles(page.locator('main'), [f.titlePlain], [f.titleUmlaut]); + }); + + // ── Catalogue filters ──────────────────────────────────────────────────── + test('15 catalogue publisher filter "Haffner" shows only its book', async ({ page }) => { + await page.goto(`${BASE}/catalogo?editore=${enc(f.publisherPlain)}`); + await expectTitles(page.locator('main'), [f.titlePlain], [f.titleUmlaut]); + }); + + test('16 catalogue publisher filter "Häffner" shows only its book', async ({ page }) => { + await page.goto(`${BASE}/catalogo?editore=${enc(f.publisherUmlaut)}`); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain]); + }); + + test('17 catalogue author filter "Haffner" keeps the case homonym, drops Häffner', async ({ page }) => { + await page.goto(`${BASE}/catalogo?autore=${enc(f.authorPlain)}`); + await expectTitles(page.locator('main'), [f.titlePlain, f.titleUpper], [f.titleUmlaut]); + }); + + test('18 catalogue author filter "Häffner" shows only its book', async ({ page }) => { + await page.goto(`${BASE}/catalogo?autore=${enc(f.authorUmlaut)}`); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain, f.titleUpper]); + }); + + // ── Publisher facet ────────────────────────────────────────────────────── + test('19 the publisher facet lists Haffner and Häffner as two publishers', async ({ page }) => { + await page.goto(`${BASE}/catalogo?q=${enc(f.token)}`); + const facet = page.locator('#publishers-filter'); + await expect(facet.getByText(f.publisherPlain, { exact: false })).toHaveCount(1); + await expect(facet.getByText(f.publisherUmlaut, { exact: false })).toHaveCount(1); + }); + + test('20 each publisher facet counts only its own book', async ({ page }) => { + await page.goto(`${BASE}/catalogo?q=${enc(f.token)}`); + const facet = page.locator('#publishers-filter'); + await expect(facet.locator('a', { hasText: f.publisherPlain })).toContainText('1'); + await expect(facet.locator('a', { hasText: f.publisherUmlaut })).toContainText('1'); + }); + + test('21 clicking the Häffner facet filters to the Häffner book', async ({ page }) => { + await page.goto(`${BASE}/catalogo?q=${enc(f.token)}`); + const done = page.waitForResponse((r) => r.url().includes('editore=') && r.status() === 200, { timeout: 20000 }); + await page.locator('#publishers-filter a', { hasText: f.publisherUmlaut }).click(); + await done; + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain]); + }); + + // ── Search boxes ───────────────────────────────────────────────────────── + test('22 typing the name without quotes finds every spelling', async ({ page }) => { + await page.goto(`${BASE}/catalogo`); + await typeAndWait(page, '#search-input', `${f.token} haffner`, 'search='); + await expectTitles(page.locator('main'), [f.titlePlain, f.titleUmlaut, f.titleUpper], []); + }); + + test('23 typing "häffner" in quotes finds only Häffner', async ({ page }) => { + await page.goto(`${BASE}/catalogo`); + await typeAndWait(page, '#search-input', `${f.token} "häffner"`, 'search='); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain, f.titleUpper]); + }); + + test('24 a quoted word still being typed ("haff) matches by its start', async ({ page }) => { + await page.goto(`${BASE}/catalogo`); + await typeAndWait(page, '#search-input', `${f.token} "haff`, 'search='); + await expectTitles(page.locator('main'), [f.titlePlain, f.titleUpper], [f.titleUmlaut]); + }); + + test('25 the header search box honours quotes too', async ({ page }) => { + await page.goto(`${BASE}/catalogo`); + const box = page.locator('form.search-form input[name="q"]:visible'); + await box.fill(`"${f.titleUmlaut}"`); + await Promise.all([page.waitForURL(/[?&]q=/), box.press('Enter')]); + await expectTitles(page.locator('main'), [f.titleUmlaut], [f.titlePlain, f.titleUpper]); + }); + + test('26 a garbled query shows none of the books instead of all of them', async ({ page }) => { + await page.goto(`${BASE}/catalogo?q=%FF%FE`); + await expectTitles(page.locator('main'), [], [f.titlePlain, f.titleUmlaut, f.titleUpper]); + }); +}); diff --git a/tests/issue-501-search-fields.spec.js b/tests/issue-501-search-fields.spec.js new file mode 100644 index 000000000..08abfa91c --- /dev/null +++ b/tests/issue-501-search-fields.spec.js @@ -0,0 +1,111 @@ +// @ts-check +/** Each advanced backend text box and every book-list suggestion bar. */ +const { test, expect } = require('@playwright/test'); +const { createTempAdminUser, deleteTempAdminUser } = require('./helpers/e2e-fixtures'); +const { makeToken, seed, clean, login, typeAndWait, expectTitles } = require('./helpers/exact-search-501'); +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const enc = encodeURIComponent; + +test.describe.serial('#501 individual backend search fields', () => { + let f, admin, context, page; + test.beforeAll(async ({ browser }) => { + f = seed(makeToken(), true); + admin = createTempAdminUser('it_IT'); + context = await browser.newContext(); page = await context.newPage(); + await login(page, BASE, admin.email, admin.password); + }); + test.afterAll(async () => { await context?.close(); clean(f); deleteTempAdminUser(admin?.id); }); + + for (const [route, field, advanced, api, kind] of [ + ['authors', 'search_pseudonimo', true, '/api/autori', 'author'], + ['authors', 'search_nazionalita', false, '/api/autori', 'author'], + ['authors', 'search_sito', true, '/api/autori', 'author'], + ['publishers', 'search_sito', true, '/api/editori', 'publisher'], + ['publishers', 'search_via', true, '/api/editori', 'publisher'], + ['publishers', 'search_cap', true, '/api/editori', 'publisher'], + ]) { + for (const quoted of [false, true]) { + test(`${route} #${field}: ${quoted ? 'quoted' : 'tolerant'}`, async () => { + await page.goto(`${BASE}/admin/${route}`); + if (advanced) await page.locator('#toggle-advanced').click(); + const value = `${f.token} haffner`; + await typeAndWait(page, `#${field}`, quoted ? `"${value}"` : value, api); + const plain = kind === 'author' ? f.authorPlain : f.publisherPlain; + const umlaut = kind === 'author' ? f.authorUmlaut : f.publisherUmlaut; + await expectTitles(page.locator('main'), quoted ? [plain] : [plain, umlaut], quoted ? [umlaut] : []); + }); + } + } + for (const [field, list, api, plain, umlaut] of [ + ['filter_autore', 'filter_autore_suggest', '/api/search/autori', 'authorPlain', 'authorUmlaut'], + ['filter_editore', 'filter_editore_suggest', '/api/search/editori', 'publisherPlain', 'publisherUmlaut'], + ]) { + for (const quoted of [false,true]) { + test(`book list #${field}: ${quoted ? 'quoted' : 'tolerant'}`, async () => { + await page.goto(`${BASE}/admin/books`); + await typeAndWait(page, `#${field}`, `${f.token} ${quoted ? '"häffner"' : 'haffner'}`, api); + await expectTitles(page.locator(`#${list}`), quoted ? [f[umlaut]] : [f[plain],f[umlaut]], quoted ? [f[plain]] : []); + }); + } + } + for (const [field,list,api] of [ + ['filter_genere','filter_genere_suggest','/api/search/generi'], + ['filter_posizione','filter_posizione_suggest','/api/search/collocazione'], + ]) { + for (const quoted of [false,true]) { + test(`book list #${field}: ${quoted ? 'quoted' : 'tolerant'}`,async () => { + await page.goto(`${BASE}/admin/books`); + if(field==='filter_posizione') await page.locator('#toggle-advanced').click(); + const value=`${f.token} haffner`; + await typeAndWait(page,`#${field}`,quoted ? `"${value}"` : value,api); + const region=page.locator(`#${list}`); + if(field==='filter_genere') { + await expectTitles(region,quoted ? [f.authorPlain] : [f.authorPlain,f.authorUmlaut],quoted ? [f.authorUmlaut] : []); + } else { + await expect(region).toContainText(f.token+'0'); + if(quoted) await expect(region).not.toContainText(f.token+'1'); + else await expect(region).toContainText(f.token+'1'); + } + }); + } + } + test('ISBN box accepts quotes and treats % literally', async () => { + await page.goto(`${BASE}/admin/books`); + await typeAndWait(page,'#search_isbn',`"${f.token.toLowerCase()}1"`,'/api/libri'); + await expectTitles(page.locator('main'),[f.titleUmlaut],[f.titlePlain]); + await typeAndWait(page,'#search_isbn','%','/api/libri'); + await expectTitles(page.locator('main'),[],[f.titlePlain,f.titleUmlaut]); + }); + for (const format of ['csv','librarything']) { + test(`${format} export accepts mixed quoted and unquoted terms`, async () => { + const q = `${f.token} "häffner"`; + const response = await page.request.get(`${BASE}/admin/books/export/${format}?search=${enc(q)}`); + expect(response.status()).toBe(200); + const body=await response.text(); expect(body).toContain(f.titleUmlaut); expect(body).not.toContain(f.titlePlain); + }); + } + test('user list and CSV accept the full displayed borrower name',async () => { + const q=`"${f.userUmlaut}"`; + await page.goto(`${BASE}/admin/users`); + await typeAndWait(page,'#search_text',q,'/api/utenti'); + await expect(page.locator('main')).toContainText('Häffner'); + await expect(page.locator('main tr').filter({hasText:f.token}).filter({hasText:'Haffner'})).toHaveCount(0); + const response=await page.request.get(`${BASE}/admin/users/export/csv?search_text=${enc(q)}`); + expect(response.status()).toBe(200); + const body=await response.text();expect(body).toContain(f.token.toLowerCase()+'.1@example.test');expect(body).not.toContain(f.token.toLowerCase()+'.0@example.test'); + }); + test('mobile backend global search uses the same quoted terms',async () => { + await page.setViewportSize({width:390,height:844}); + await page.goto(`${BASE}/admin/dashboard`); + await page.locator('#mobile-search-button').click(); + await typeAndWait(page,'#mobile-global-search',`${f.token} "häffner"`,'/api/search/unified'); + await expectTitles(page.locator('#mobile-search-results'),[f.authorUmlaut],[f.authorPlain]); + }); + test('homepage hero search submits the quoted query to the catalogue',async () => { + await page.setViewportSize({width:1280,height:900});await page.goto(BASE); + const field=page.locator('.hero-search-input'); + await field.fill(`${f.token} "häffner"`); + await Promise.all([page.waitForURL(/catalogo/),field.press('Enter')]); + await expectTitles(page.locator('main'),[f.titleUmlaut],[f.titlePlain]); + }); +}); diff --git a/tests/issue-501-search-hint.spec.js b/tests/issue-501-search-hint.spec.js new file mode 100644 index 000000000..df03b2a5a --- /dev/null +++ b/tests/issue-501-search-hint.spec.js @@ -0,0 +1,83 @@ +// @ts-check +/** + * Issue #501, part 9 of 9: people must be able to find out that quotes exist. + * The catalogue shows a hint under its search box; the header search and the + * back-office filter boxes carry it as their accessible description (tooltip). + * Checked in every shipped locale, logged in as a user of that locale, and + * always as a DESCRIPTION: the hint must never replace the field's name for a + * screen reader. + */ +const fs = require('fs'); +const path = require('path'); +const { test, expect } = require('@playwright/test'); +const { createTempAdminUser, deleteTempAdminUser } = require('./helpers/e2e-fixtures'); +const { login } = require('./helpers/exact-search-501'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +test.skip(!process.env.E2E_DB_USER || !process.env.E2E_DB_NAME, 'E2E database not configured (use /tmp/run-e2e.sh)'); + +const KEY = 'Usa le virgolette per la grafia esatta: "Häffner" trova solo Häffner, non Haffner.'; +const LOCALES = ['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK']; + +/** @param {string} locale */ +function hintFor(locale) { + const file = path.join(__dirname, '..', 'locale', `${locale}.json`); + const value = JSON.parse(fs.readFileSync(file, 'utf-8'))[KEY]; + if (typeof value !== 'string' || value === '') { + throw new Error(`missing translation of the quote hint in ${locale}`); + } + return value; +} + +for (const locale of LOCALES) { + test.describe.serial(`#501 quote hint — ${locale}`, () => { + const hint = hintFor(locale); + /** @type {{id:number,email:string,password:string}} */ + let user; + /** @type {import('@playwright/test').BrowserContext} */ + let context; + /** @type {import('@playwright/test').Page} */ + let page; + + test.beforeAll(async ({ browser }) => { + user = createTempAdminUser(locale); + context = await browser.newContext(); + page = await context.newPage(); + await login(page, BASE, user.email, user.password); + }); + test.afterAll(async () => { + await context?.close(); + deleteTempAdminUser(user?.id); + }); + + test(`${locale} 1/5 the catalogue shows the hint under its search box`, async () => { + await page.goto(`${BASE}/catalogo`); + await expect(page.locator('#search-hint')).toBeVisible(); + await expect(page.locator('#search-hint')).toHaveText(hint); + }); + + test(`${locale} 2/5 the catalogue search box is described by the hint`, async () => { + await expect(page.locator('#search-input')).toHaveAccessibleDescription(hint); + }); + + test(`${locale} 3/5 the header search box carries the hint as its description`, async () => { + const box = page.locator('form.search-form input[name="q"]:visible'); + await expect(box).toHaveAttribute('title', hint); + await expect(box).not.toHaveAccessibleName(hint); + }); + + test(`${locale} 4/5 the admin book filter carries the hint without losing its label`, async () => { + await page.goto(`${BASE}/admin/books`); + const box = page.locator('#search_text'); + await expect(box).toHaveAccessibleDescription(hint); + await expect(box).not.toHaveAccessibleName(hint); + }); + + test(`${locale} 5/5 the admin author filter carries the hint without losing its label`, async () => { + await page.goto(`${BASE}/admin/authors`); + const box = page.locator('#search_nome'); + await expect(box).toHaveAccessibleDescription(hint); + await expect(box).not.toHaveAccessibleName(hint); + }); + }); +} From 89dfe6beba5bd60c6e2098643b7e67b40b6bd672 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 27 Oct 2026 00:51:35 +0100 Subject: [PATCH 03/24] Close the CodeRabbit findings on quoted search - Series autocomplete: one suggestion per spelling. Names that differ only in case are merged again (as UNION did), accents stay apart, and the list sorts with the tolerant collation instead of byte order. - A filter box that mixes quoted and plain words ("haffner" archivio, archivio "haffner") now ANDs the words, each with its own rule, instead of gluing them into one phrase or searching the quotes as literal characters. The archives search and its authority match follow the same rule, with the authority link tables referenced once. - Mobile archive collection: a query of quotes only returns an empty list instead of dropping the root-only filter and listing the whole archive. - E2E specs load e2e-fixtures only when the E2E environment is configured, so they skip cleanly instead of failing at import time. --- app/Controllers/CollaneController.php | 8 ++- app/Support/ExactSearch.php | 10 ++++ storage/plugins/archives/ArchivesPlugin.php | 55 ++++++++++++++----- .../src/Controllers/CollectionsController.php | 17 ++++-- tests/exact-search-archives-501.unit.php | 10 +++- tests/exact-search-backend-501.unit.php | 10 +++- tests/exact-search-core-501.unit.php | 5 +- ...act-search-mobile-collections-501.unit.php | 12 +++- tests/issue-501-admin-lists.spec.js | 8 ++- tests/issue-501-search-fields.spec.js | 7 ++- tests/issue-501-search-hint.spec.js | 8 ++- 11 files changed, 122 insertions(+), 28 deletions(-) diff --git a/app/Controllers/CollaneController.php b/app/Controllers/CollaneController.php index cce08fffe..82a6320bd 100644 --- a/app/Controllers/CollaneController.php +++ b/app/Controllers/CollaneController.php @@ -515,8 +515,12 @@ public function searchApi(Request $request, Response $response, mysqli $db): Res } if (!empty($sqlParts)) { - $sql = 'SELECT DISTINCT nome COLLATE utf8mb4_bin AS nome FROM (' . implode(' UNION ALL ', $sqlParts) - . ') AS combined ORDER BY nome LIMIT 10'; + // One suggestion per spelling: accents keep "Brücke" and + // "Brucke" apart (#501), case does not ("Serie" = "serie", as + // UNION used to merge them). MIN(nome) keeps the column's + // tolerant collation, so the list sorts like the rest of the UI. + $sql = 'SELECT MIN(nome) AS nome FROM (' . implode(' UNION ALL ', $sqlParts) + . ') AS combined GROUP BY LOWER(nome) COLLATE utf8mb4_bin ORDER BY nome LIMIT 10'; $stmt = $db->prepare($sql); if ($stmt) { $stmt->bind_param($types, ...$binds); diff --git a/app/Support/ExactSearch.php b/app/Support/ExactSearch.php index 47f504227..c8abe08ce 100644 --- a/app/Support/ExactSearch.php +++ b/app/Support/ExactSearch.php @@ -135,11 +135,21 @@ public static function exactFirstSql(string $expr): string * (OR), honouring quotes via parseFilter(). Null when nothing is left to * match (e.g. the value was only a quote). * + * A box that mixes quoted and unquoted words (`archivio "haffner"`, + * `"haffner" archivio`) is not one phrase: every word must match, each + * with its own rule, as in wordsCondition(). Otherwise the quotes of a + * value that does not start with one would be searched as literal + * characters and find nothing. + * * @param list $exprs * @return array{sql: string, params: list, types: string}|null */ public static function filterCondition(array $exprs, string $value): ?array { + $tokens = self::tokenize($value); + if (count($tokens) > 1 && in_array(true, array_column($tokens, 'exact'), true)) { + return self::wordsCondition($exprs, $value); + } ['term' => $term, 'exact' => $exact] = self::parseFilter($value); if ($term === '' || $exprs === []) { return null; diff --git a/storage/plugins/archives/ArchivesPlugin.php b/storage/plugins/archives/ArchivesPlugin.php index 74adefc33..3f6be45c5 100644 --- a/storage/plugins/archives/ArchivesPlugin.php +++ b/storage/plugins/archives/ArchivesPlugin.php @@ -10980,23 +10980,52 @@ private function oaiError(\XMLWriter $xw, string $code, string $message): void */ private function archiveSearchCondition(string $q, ?string $unitIdExpr = null): array { - $columns = ['reference_code', 'constructed_title', 'formal_title', 'scope_content']; - ['term' => $term, 'exact' => $exact] = \App\Support\ExactSearch::parseFilter($q); - $exact = $exact && $term !== ''; - $match = static fn(string $column): string => $exact - ? \App\Support\ExactSearch::likeSql($column, true) - : "{$column} LIKE ?"; - $pattern = $exact ? \App\Support\ExactSearch::likePattern($term) : $this->archiveSearchPattern($q); - $sql = array_map($match, $columns); + // Qualify the unit columns when an alias is in play: inside the + // authority EXISTS below they must still resolve to the outer unit. + $prefix = $unitIdExpr !== null && str_contains($unitIdExpr, '.') + ? substr($unitIdExpr, 0, (int) strrpos($unitIdExpr, '.') + 1) + : ''; + $columns = array_map( + static fn(string $column): string => $prefix . $column, + ['reference_code', 'constructed_title', 'formal_title', 'scope_content'] + ); + $authorityJoin = 'SELECT 1 FROM archival_unit_authority aua JOIN authority_records ar ON ar.id = aua.authority_id' + . " WHERE aua.archival_unit_id = {$unitIdExpr} AND ar.deleted_at IS NULL AND "; + + // Any quoted group: every term must match, each with its own rule — + // the same as the archive's other searches (wordsCondition), so + // `"haffner" archivio` and `archivio "haffner"` both mean + // "haffner exactly AND archivio", never one phrase or literal quotes. + if (in_array(true, array_column(\App\Support\ExactSearch::tokenize($q), 'exact'), true)) { + $own = \App\Support\ExactSearch::wordsCondition($columns, $q, 20); + if ($own !== null) { + if ($unitIdExpr === null) { + return $own; + } + // A term may also be met by a linked authority record. One + // EXISTS holds the whole word list, so the link tables are + // referenced once whatever the number of terms. + $withAuthority = \App\Support\ExactSearch::wordsCondition([...$columns, 'ar.authorised_form'], $q, 20); + if ($withAuthority !== null) { + return [ + 'sql' => "({$own['sql']} OR EXISTS ({$authorityJoin}{$withAuthority['sql']}))", + 'params' => [...$own['params'], ...$withAuthority['params']], + 'types' => $own['types'] . $withAuthority['types'], + ]; + } + } + } + + // Unquoted: one stemmed pattern ("disegno" also finds "disegni"). + $pattern = $this->archiveSearchPattern($q); + $sql = array_map(static fn(string $column): string => "{$column} LIKE ?", $columns); if ($unitIdExpr !== null) { - $sql[] = 'EXISTS (SELECT 1 FROM archival_unit_authority aua JOIN authority_records ar ON ar.id = aua.authority_id' - . " WHERE aua.archival_unit_id = {$unitIdExpr} AND ar.deleted_at IS NULL AND " . $match('ar.authorised_form') . ')'; - $columns[] = 'ar.authorised_form'; + $sql[] = "EXISTS ({$authorityJoin}ar.authorised_form LIKE ?)"; } return [ 'sql' => '(' . implode(' OR ', $sql) . ')', - 'params' => array_fill(0, count($columns), $pattern), - 'types' => str_repeat('s', count($columns)), + 'params' => array_fill(0, count($sql), $pattern), + 'types' => str_repeat('s', count($sql)), ]; } diff --git a/storage/plugins/mobile-api/src/Controllers/CollectionsController.php b/storage/plugins/mobile-api/src/Controllers/CollectionsController.php index 3491cc0f6..401ca9e2b 100644 --- a/storage/plugins/mobile-api/src/Controllers/CollectionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/CollectionsController.php @@ -138,12 +138,21 @@ private function archives(Request $request, Response $response): Response $where = ['u.deleted_at IS NULL' . $this->publishedSql('u.')]; $values = []; if ($parent !== null) { $where[] = 'u.parent_id = ?'; $values[] = $parent; } elseif ($query === '' && $level === '' && $from === null && $to === null) { $where[] = 'u.parent_id IS NULL'; } - $search = \App\Support\ExactSearch::filterCondition(['u.reference_code', 'u.constructed_title', 'u.formal_title', 'u.scope_content'], $query); + $unitColumns = ['u.reference_code', 'u.constructed_title', 'u.formal_title', 'u.scope_content']; + $search = \App\Support\ExactSearch::filterCondition($unitColumns, $query); + if ($query !== '' && $search === null) { + // Only quotes were typed: nothing to match yet. Without this the + // root-only filter above is skipped (the query is not empty) and + // the whole archive would come back. + return ResponseEnvelope::success($response, [], ['next_cursor' => null, 'total_count' => 0])->withHeader('Cache-Control', 'no-store'); + } if ($search !== null) { - ['exact' => $exact] = \App\Support\ExactSearch::parseFilter($query); + // A linked authority record may meet the terms too, by the same + // rule (one EXISTS holds every term, see ArchivesPlugin). + $withAuthority = \App\Support\ExactSearch::filterCondition([...$unitColumns, 'ar.authorised_form'], $query) ?? $search; $where[] = '(' . $search['sql'] . ' OR EXISTS (SELECT 1 FROM archival_unit_authority aua JOIN authority_records ar ON ar.id = aua.authority_id WHERE aua.archival_unit_id = u.id AND ar.deleted_at IS NULL AND ' - . \App\Support\ExactSearch::likeSql('ar.authorised_form', $exact) . '))'; - array_push($values, ...[...$search['params'], $search['params'][0]]); + . $withAuthority['sql'] . '))'; + array_push($values, ...$search['params'], ...$withAuthority['params']); } if ($level !== '') { $where[] = 'u.level = ?'; $values[] = $level; } if ($from !== null) { $where[] = 'u.date_start IS NOT NULL AND GREATEST(u.date_start, COALESCE(u.date_end, u.date_start)) >= ?'; $values[] = $from; } diff --git a/tests/exact-search-archives-501.unit.php b/tests/exact-search-archives-501.unit.php index 546afcba1..a726639fd 100644 --- a/tests/exact-search-archives-501.unit.php +++ b/tests/exact-search-archives-501.unit.php @@ -88,6 +88,14 @@ functions TEXT NULL, $same([3, 5], $units('"häffner"', 'u.id'), 'with authorities: a linked authority record counts, exactly'); $same([3, 4, 5, 6], $units('haffner', 'u.id'), 'with authorities: unquoted matches every linked spelling'); $same([], $units('"'), 'a lone quote does not match every unit'); +// CodeRabbit (PR #502): a quoted word next to plain words is not one phrase, +// and quotes that do not open the query are not literal characters. +$same([1], $units('die "brücke"'), 'mixed: plain word then quoted word, each with its own rule'); +$same([1], $units('"brücke" die'), 'mixed: quoted word first is not glued into one phrase'); +$same([2], $units('die "brucke"'), 'mixed: the quoted spelling still excludes the umlaut'); +$same([3], $units('"häffner" anna', 'u.id'), 'mixed with authorities: both words met by the linked record'); +$same([3], $units('disegni "häffner"', 'u.id'), 'mixed with authorities: one word in the unit, one in its authority'); +$same([], $units('disegni "haffner"', 'u.id'), 'mixed with authorities: the plain spelling is not the umlaut authority'); // ── Catalogue archive box (frontend.catalog.archive_results) ────────────── $same([3, 5], $resultIds($plugin->getPublicArchiveResults([], '"häffner"')), 'catalogue box: quoted Häffner, authority included'); @@ -123,4 +131,4 @@ functions TEXT NULL, $reconciled=new ReflectionMethod($plugin,'searchReconciledAutori'); $same([$a1],$resultIds($reconciled->invoke($plugin,"{$t} \"haffner\"",20)),'reconciled-author search keeps the plain spelling'); $same([$a2],$resultIds($reconciled->invoke($plugin,"{$t} \"häffner\"",20)),'reconciled-author search keeps the umlaut spelling'); -Exact501::finish(33); +Exact501::finish(39); diff --git a/tests/exact-search-backend-501.unit.php b/tests/exact-search-backend-501.unit.php index 8bcad3e7f..515214357 100644 --- a/tests/exact-search-backend-501.unit.php +++ b/tests/exact-search-backend-501.unit.php @@ -76,6 +76,14 @@ Exact501::same(["$t Haffner","$t Häffner"], $read($series,'searchApi',['q'=>"$t haffner"]), 'series suggestions preserve the two accented names'); Exact501::same(["$t Häffner"], $read($series,'searchApi',['q'=>'"'.$t.' Häffner"']), 'series suggestions honour quotes'); Exact501::same([], $read($series,'searchApi',['q'=>'""']), 'empty quoted series does not enumerate the collection'); +// CodeRabbit (PR #502): one suggestion per spelling ignoring case, and the +// list sorted with the tolerant collation, not byte order (Ä after Z). +$ids['libri'][] = Exact501::exec($db, 'INSERT INTO libri (titolo,collana) VALUES (?,?)', ["$t extra 1", "$t HAFFNER"]); +$ids['libri'][] = Exact501::exec($db, 'INSERT INTO libri (titolo,collana) VALUES (?,?)', ["$t extra 2", "$t Ärger"]); +$suggested = $read($series,'searchApi',['q'=>"$t haffner"]); +Exact501::same(2, count($suggested), 'series suggestions merge names that differ only in case'); +Exact501::same(["$t haffner", "$t häffner"], array_values(array_unique(array_map('mb_strtolower', $suggested))), 'series suggestions keep the umlaut spelling apart'); +Exact501::same("$t Ärger", $read($series,'searchApi',['q'=>$t])[0] ?? null, 'series suggestions sort Ä with A, not after Z'); $staff = new App\Plugins\MobileApi\Controllers\StaffEntitiesController($db); foreach (['searchAuthors'=>'autori','searchPublishers'=>'editori'] as $method=>$table) { foreach (["$t haffner"=>$ids[$table],$t.' "häffner"'=>[$ids[$table][1]]] as $value=>$expected) { @@ -93,4 +101,4 @@ $response = $dewey->autocomplete($request(['q'=>$value]), new Response()); Exact501::same($expected,array_column(json_decode((string)$response->getBody(),true),'code'),"Dewey picker: $value"); } -Exact501::finish(132); +Exact501::finish(137); diff --git a/tests/exact-search-core-501.unit.php b/tests/exact-search-core-501.unit.php index e80b63650..bd835f87a 100644 --- a/tests/exact-search-core-501.unit.php +++ b/tests/exact-search-core-501.unit.php @@ -66,6 +66,9 @@ $same([4], $f('a_b'), '_ is a literal character, not a one-letter wildcard'); $same([5], $f('C:\\dir'), 'a backslash is a literal character'); $same([6], $f('"mueller"'), 'a quoted value also searches the pseudonym column'); +$same([2], $f('rolf "häffner"'), 'a filter box mixing words keeps each word\'s rule (no literal quotes)'); +$same([2], $f('"häffner" rolf'), 'a filter box starting with a quote is not glued into one phrase'); +$same([1], $f('rolf "haffner"'), 'a mixed filter still keeps the umlaut out'); $eq = E::equalsCondition('nome', 'rolf haffner'); $same([1], Exact501::ids($db, 'zz501_core', $eq), 'equalsCondition matches the exact name, case ignored, accents distinct'); @@ -86,4 +89,4 @@ $limited = \App\Support\ExactSearch::wordsCondition(['nome'], '"thomas mann" other words', 1); $same(['%thomas mann%'], $limited['params'], 'a search budget counts a quoted phrase as one term'); $same(null, \App\Support\ExactSearch::wordsCondition(['nome'], 'mann', 0), 'a zero search budget produces no invalid empty predicate'); -Exact501::finish(31); +Exact501::finish(34); diff --git a/tests/exact-search-mobile-collections-501.unit.php b/tests/exact-search-mobile-collections-501.unit.php index ff4e1d8c5..e1235751a 100644 --- a/tests/exact-search-mobile-collections-501.unit.php +++ b/tests/exact-search-mobile-collections-501.unit.php @@ -38,4 +38,14 @@ Exact501::same($expected,$found,"$label: $q"); } } -Exact501::finish(24); +// CodeRabbit (PR #502): a query of quotes only must not drop the root-only +// filter and return the whole archive; mixed words keep each word's own rule. +foreach(['"'=>[],'""'=>[],'u1 "haffner"'=>[1],'"haffner" u1'=>[1],'u2 "haffner"'=>[],'u2 "häffner"'=>[2]] as $q=>$expected){ + $request=(new ServerRequestFactory())->createServerRequest('GET','/')->withQueryParams(['q'=>$q]); + $response=$public->handle('archives',$request,new Response(),'archives'); + Exact501::same(200,$response->getStatusCode(),"archive collection: $q returns 200"); + $payload=json_decode((string)$response->getBody(),true,512,JSON_THROW_ON_ERROR); + $found=array_map('intval',array_column($payload['data'],'id'));sort($found); + Exact501::same($expected,$found,"archive collection: $q"); +} +Exact501::finish(36); diff --git a/tests/issue-501-admin-lists.spec.js b/tests/issue-501-admin-lists.spec.js index fc12f5c23..a9ee123c6 100644 --- a/tests/issue-501-admin-lists.spec.js +++ b/tests/issue-501-admin-lists.spec.js @@ -7,11 +7,15 @@ * spelling; a quoted value finds only its exact spelling. */ const { test, expect } = require('@playwright/test'); -const { createTempAdminUser, deleteTempAdminUser } = require('./helpers/e2e-fixtures'); +// e2e-fixtures throws while loading when the E2E environment is missing, so +// it is required only when the suite is going to run; the skip reports why. +const E2E_READY = Boolean(process.env.E2E_BASE_URL && process.env.E2E_DB_USER && process.env.E2E_DB_NAME + && (process.env.E2E_DB_HOST || process.env.E2E_DB_SOCKET)); +test.skip(!E2E_READY, 'E2E environment not configured (use /tmp/run-e2e.sh)'); +const { createTempAdminUser, deleteTempAdminUser } = E2E_READY ? require('./helpers/e2e-fixtures') : {}; const { makeToken, seed, clean, login, typeAndWait, expectTitles } = require('./helpers/exact-search-501'); const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; -test.skip(!process.env.E2E_DB_USER || !process.env.E2E_DB_NAME, 'E2E database not configured (use /tmp/run-e2e.sh)'); const enc = encodeURIComponent; diff --git a/tests/issue-501-search-fields.spec.js b/tests/issue-501-search-fields.spec.js index 08abfa91c..bbd548796 100644 --- a/tests/issue-501-search-fields.spec.js +++ b/tests/issue-501-search-fields.spec.js @@ -1,7 +1,12 @@ // @ts-check /** Each advanced backend text box and every book-list suggestion bar. */ const { test, expect } = require('@playwright/test'); -const { createTempAdminUser, deleteTempAdminUser } = require('./helpers/e2e-fixtures'); +// e2e-fixtures throws while loading when the E2E environment is missing, so +// it is required only when the suite is going to run; the skip reports why. +const E2E_READY = Boolean(process.env.E2E_BASE_URL && process.env.E2E_DB_USER && process.env.E2E_DB_NAME + && (process.env.E2E_DB_HOST || process.env.E2E_DB_SOCKET)); +test.skip(!E2E_READY, 'E2E environment not configured (use /tmp/run-e2e.sh)'); +const { createTempAdminUser, deleteTempAdminUser } = E2E_READY ? require('./helpers/e2e-fixtures') : {}; const { makeToken, seed, clean, login, typeAndWait, expectTitles } = require('./helpers/exact-search-501'); const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; const enc = encodeURIComponent; diff --git a/tests/issue-501-search-hint.spec.js b/tests/issue-501-search-hint.spec.js index df03b2a5a..195403a4d 100644 --- a/tests/issue-501-search-hint.spec.js +++ b/tests/issue-501-search-hint.spec.js @@ -10,11 +10,15 @@ const fs = require('fs'); const path = require('path'); const { test, expect } = require('@playwright/test'); -const { createTempAdminUser, deleteTempAdminUser } = require('./helpers/e2e-fixtures'); +// e2e-fixtures throws while loading when the E2E environment is missing, so +// it is required only when the suite is going to run; the skip reports why. +const E2E_READY = Boolean(process.env.E2E_BASE_URL && process.env.E2E_DB_USER && process.env.E2E_DB_NAME + && (process.env.E2E_DB_HOST || process.env.E2E_DB_SOCKET)); +test.skip(!E2E_READY, 'E2E environment not configured (use /tmp/run-e2e.sh)'); +const { createTempAdminUser, deleteTempAdminUser } = E2E_READY ? require('./helpers/e2e-fixtures') : {}; const { login } = require('./helpers/exact-search-501'); const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; -test.skip(!process.env.E2E_DB_USER || !process.env.E2E_DB_NAME, 'E2E database not configured (use /tmp/run-e2e.sh)'); const KEY = 'Usa le virgolette per la grafia esatta: "Häffner" trova solo Häffner, non Haffner.'; const LOCALES = ['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK']; From 2ac57750ae263f4b7e63a7eec43819ee83e7f085 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 27 Oct 2026 00:53:51 +0100 Subject: [PATCH 04/24] Cap the words a mixed filter box turns into conditions A filter value with a quote now ANDs its words, one condition per word and column. Without a budget a pasted wall of words became thousands of placeholders; the mixed branch now keeps the first 20 words, the same budget as the other word searches. --- app/Support/ExactSearch.php | 7 ++++++- tests/exact-search-core-501.unit.php | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/Support/ExactSearch.php b/app/Support/ExactSearch.php index c8abe08ce..741a17180 100644 --- a/app/Support/ExactSearch.php +++ b/app/Support/ExactSearch.php @@ -25,6 +25,9 @@ final class ExactSearch /** Straight and typographic quotes (mobile keyboards type „…“ and “…”). */ private const QUOTES = '"“”„«»'; + /** Most words a single filter box turns into separate conditions. */ + public const MAX_FILTER_TERMS = 20; + private function __construct() { } @@ -148,7 +151,9 @@ public static function filterCondition(array $exprs, string $value): ?array { $tokens = self::tokenize($value); if (count($tokens) > 1 && in_array(true, array_column($tokens, 'exact'), true)) { - return self::wordsCondition($exprs, $value); + // Same budget as the other word searches: a pasted wall of words + // must not turn into thousands of placeholders. + return self::wordsCondition($exprs, $value, self::MAX_FILTER_TERMS); } ['term' => $term, 'exact' => $exact] = self::parseFilter($value); if ($term === '' || $exprs === []) { diff --git a/tests/exact-search-core-501.unit.php b/tests/exact-search-core-501.unit.php index bd835f87a..f2acc8b69 100644 --- a/tests/exact-search-core-501.unit.php +++ b/tests/exact-search-core-501.unit.php @@ -69,6 +69,8 @@ $same([2], $f('rolf "häffner"'), 'a filter box mixing words keeps each word\'s rule (no literal quotes)'); $same([2], $f('"häffner" rolf'), 'a filter box starting with a quote is not glued into one phrase'); $same([1], $f('rolf "haffner"'), 'a mixed filter still keeps the umlaut out'); +$flood = \App\Support\ExactSearch::filterCondition(['nome', 'pseudonimo'], '"x" ' . str_repeat('word ', 5000)); +$same(\App\Support\ExactSearch::MAX_FILTER_TERMS * 2, count($flood['params']), 'a pasted wall of words in a filter box is capped, not one condition per word'); $eq = E::equalsCondition('nome', 'rolf haffner'); $same([1], Exact501::ids($db, 'zz501_core', $eq), 'equalsCondition matches the exact name, case ignored, accents distinct'); @@ -89,4 +91,4 @@ $limited = \App\Support\ExactSearch::wordsCondition(['nome'], '"thomas mann" other words', 1); $same(['%thomas mann%'], $limited['params'], 'a search budget counts a quoted phrase as one term'); $same(null, \App\Support\ExactSearch::wordsCondition(['nome'], 'mann', 0), 'a zero search budget produces no invalid empty predicate'); -Exact501::finish(34); +Exact501::finish(35); From 0f92b172238800dd45b80d1ffdbec8db47fff9de Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 27 Oct 2026 01:18:53 +0100 Subject: [PATCH 05/24] Archives unit test: check the unified search invariant, not a type string The source-level check looked for a literal bind_param('ssss'). The unified search now binds whatever the shared archive condition builds (one placeholder per column and term), so the literal is gone while the behaviour is intact and exercised by tests/exact-search-archives-501.unit.php. The check now asserts that the unified search uses that builder, binds its own types and params, and that the builder covers reference_code. --- tests/archives-plugin.unit.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/archives-plugin.unit.php b/tests/archives-plugin.unit.php index 3c3bdafcd..1bb4f3aff 100644 --- a/tests/archives-plugin.unit.php +++ b/tests/archives-plugin.unit.php @@ -70,7 +70,17 @@ $check($source !== false && str_contains($source, "'admin.menu.render'"), 'plannedHooks lists admin.menu.render'); $check($source !== false && str_contains($source, "'libri.authority.resolve'"), 'plannedHooks lists libri.authority.resolve'); $check($source !== false && str_contains($source, 'reference_code LIKE ?'), 'unified search matches archival reference_code'); -$check($source !== false && str_contains($source, "bind_param('ssss'"), 'unified search binds reference_code LIKE placeholder'); +// The unified search binds whatever the shared condition builds (one +// placeholder per column and term, #501); the real query is exercised in +// tests/exact-search-archives-501.unit.php. Here: it uses that builder and +// binds its own types/params instead of a hard-coded type string. +$unified = $source !== false ? (string) strstr((string) strstr($source, 'public function addArchivalSources('), 'public function getPublicArchiveResults(', true) : ''; +$check( + str_contains($unified, '$this->archiveSearchCondition($q)') + && str_contains($unified, "bind_param(\$match['types'], ...\$match['params'])") + && str_contains($source, "['reference_code', 'constructed_title', 'formal_title', 'scope_content']"), + 'unified search binds reference_code LIKE placeholder' +); echo "\nOAI-PMH interoperability regressions:\n"; $check($source !== false && str_contains($source, "\$app->get('/archives/{id:[0-9]+}/dc.xml'"), 'public Dublin Core route exists'); From 976872eb2980876ff74d265ef9e078b007e43917 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 27 Oct 2026 07:51:13 +0100 Subject: [PATCH 06/24] A name typed in quotes is a separate author or publisher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When only "Haffner" is on file, a book saved with author "Häffner" is still linked to Haffner: the accent-tolerant dedup is what merges "Garcia Marquez" with "García Márquez", and it stays the default. Typed in quotes, the name now means that exact spelling (accents distinct, case ignored): only an author or publisher written the same way is reused, otherwise a new one is created, stored without the quotes. Once both spellings exist, the exact one wins with or without quotes. This lives in AuthorRepository and PublisherRepository, so the book form, the CSV and LibraryThing imports, the mobile API and the periodical publisher picker all follow it; the importers' own publisher lookups now go through PublisherRepository::findByName(). Bulk enrichment is left alone: its names come from external APIs and never carry typed quotes. --- app/Controllers/CsvImportController.php | 17 +-- .../LibraryThingImportController.php | 16 +-- app/Models/AuthorRepository.php | 32 +++++ app/Models/PublisherRepository.php | 13 ++ app/Support/ExactSearch.php | 12 ++ .../Services/PeriodicalPublisherService.php | 6 +- tests/exact-search-dedup-501.unit.php | 32 ++++- tests/issue-501-quoted-new-author.spec.js | 114 ++++++++++++++++++ 8 files changed, 218 insertions(+), 24 deletions(-) create mode 100644 tests/issue-501-quoted-new-author.spec.js diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 47b20edb7..049e25c19 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -1488,18 +1488,13 @@ private function mapColumnHeaders(array $headers): array */ private function getOrCreatePublisher(\mysqli $db, string $name): array { - // Exact spelling first when the collation also admits "Haffner" for "Häffner" (#501). - $stmt = $db->prepare("SELECT id FROM editori WHERE nome = ? ORDER BY " . \App\Support\ExactSearch::exactFirstSql('nome') . ", id LIMIT 1"); - $stmt->bind_param('ss', $name, $name); - $stmt->execute(); - $result = $stmt->get_result(); - - if ($row = $result->fetch_assoc()) { - $publisherId = (int) $row['id']; - $stmt->close(); - return ['id' => $publisherId, 'created' => false]; + // Same lookup as the book form: the exact spelling wins, and a name in + // quotes reuses only that spelling (#501). The quotes are not stored. + $existingId = (new \App\Models\PublisherRepository($db))->findByName($name); + if ($existingId !== null) { + return ['id' => $existingId, 'created' => false]; } - $stmt->close(); + $name = \App\Support\ExactSearch::quotedValue($name) ?? $name; // Crea nuovo editore $stmt = $db->prepare("INSERT INTO editori (nome, created_at) VALUES (?, NOW())"); diff --git a/app/Controllers/LibraryThingImportController.php b/app/Controllers/LibraryThingImportController.php index 138772a8d..9bf7612bb 100644 --- a/app/Controllers/LibraryThingImportController.php +++ b/app/Controllers/LibraryThingImportController.php @@ -1245,17 +1245,13 @@ private function parseDate(string|int|float|null $dateString): ?string // Reuse methods from CsvImportController private function getOrCreatePublisher(\mysqli $db, string $name): array { - // Exact spelling first when the collation also admits "Haffner" for "Häffner" (#501). - $stmt = $db->prepare("SELECT id FROM editori WHERE nome = ? ORDER BY " . \App\Support\ExactSearch::exactFirstSql('nome') . ", id LIMIT 1"); - $stmt->bind_param('ss', $name, $name); - $stmt->execute(); - $result = $stmt->get_result(); - - if ($row = $result->fetch_assoc()) { - $stmt->close(); - return ['id' => (int) $row['id'], 'created' => false]; + // Same lookup as the book form: the exact spelling wins, and a name in + // quotes reuses only that spelling (#501). The quotes are not stored. + $existingId = (new \App\Models\PublisherRepository($db))->findByName($name); + if ($existingId !== null) { + return ['id' => $existingId, 'created' => false]; } - $stmt->close(); + $name = \App\Support\ExactSearch::quotedValue($name) ?? $name; $stmt = $db->prepare("INSERT INTO editori (nome, created_at) VALUES (?, NOW())"); $stmt->bind_param('s', $name); diff --git a/app/Models/AuthorRepository.php b/app/Models/AuthorRepository.php index 3319b2ea0..f2b65e942 100644 --- a/app/Models/AuthorRepository.php +++ b/app/Models/AuthorRepository.php @@ -137,6 +137,9 @@ public function create(array $data): int // Decode HTML entities to prevent double encoding $nome = \App\Support\HtmlHelper::decode($data['nome']); + // Quotes typed around a new name mean "exact spelling" (#501); they + // are never part of the stored name. + $nome = \App\Support\ExactSearch::quotedValue($nome) ?? $nome; $pseudonimo = \App\Support\HtmlHelper::decode($data['pseudonimo'] ?? ''); $nazionalita = \App\Support\HtmlHelper::decode($data['nazionalita'] ?? ''); $biografia = \App\Support\HtmlHelper::decode($data['biografia'] ?? ''); @@ -300,6 +303,26 @@ public function findByName(string $name): ?int * @param string $name Author name in any format * @return int|null Author ID if found, null otherwise */ + /** + * Author written exactly like $name (accents distinct, case ignored), as + * typed or in canonical "Name Surname" form; null when there is none. + */ + private function findByExactName(string $name): ?int + { + foreach (array_unique([$name, \App\Support\AuthorNormalizer::normalize($name)]) as $candidate) { + $match = \App\Support\ExactSearch::equalsCondition('nome', $candidate); + $stmt = $this->db->prepare("SELECT id FROM autori WHERE {$match['sql']} ORDER BY id LIMIT 1"); + $stmt->bind_param($match['types'], ...$match['params']); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if ($row) { + return (int) $row['id']; + } + } + return null; + } + public function findByCanonicalName(string $name): ?int { $name = trim($name); @@ -307,6 +330,15 @@ public function findByCanonicalName(string $name): ?int return null; } + // A name typed in quotes asks for that exact spelling (#501): only an + // author written the same way (accents included, case ignored) is + // reused, and the accent-tolerant steps below are skipped, so + // "Häffner" in quotes becomes a new author when only Haffner exists. + $quoted = \App\Support\ExactSearch::quotedValue($name); + if ($quoted !== null) { + return $this->findByExactName($quoted); + } + // Normalize the input name first (this is the key fix!) $normalizedInput = \App\Support\AuthorNormalizer::normalize($name); diff --git a/app/Models/PublisherRepository.php b/app/Models/PublisherRepository.php index 160b05495..61ac68b32 100644 --- a/app/Models/PublisherRepository.php +++ b/app/Models/PublisherRepository.php @@ -143,6 +143,8 @@ public function create(array $data): int // Decode HTML entities to prevent double encoding $nome = \App\Support\HtmlHelper::decode($data['nome']); + // Quotes typed around a new name mean "exact spelling" (#501). + $nome = \App\Support\ExactSearch::quotedValue($nome) ?? $nome; $sito_web = \App\Support\HtmlHelper::decode($data['sito_web'] ?? ''); $indirizzo = \App\Support\HtmlHelper::decode($data['indirizzo'] ?? ''); $telefono = \App\Support\HtmlHelper::decode($data['telefono'] ?? ''); @@ -283,6 +285,17 @@ public function findByName(string $name): ?int { $name = trim($name); if ($name === '') return null; + // Typed in quotes: only that exact spelling (accents included, case + // ignored) is reused, so a new "Häffner" is not merged into Haffner. + $quoted = \App\Support\ExactSearch::quotedValue($name); + if ($quoted !== null) { + $match = \App\Support\ExactSearch::equalsCondition('nome', $quoted); + $stmt = $this->db->prepare("SELECT id FROM editori WHERE {$match['sql']} ORDER BY id LIMIT 1"); + $stmt->bind_param($match['types'], ...$match['params']); + $stmt->execute(); + $stmt->bind_result($id); + return $stmt->fetch() ? (int) $id : null; + } // The collation folds accents, so "Häffner" also matches "Haffner"; // when both are on file the exact spelling wins (#501). $stmt = $this->db->prepare('SELECT id FROM editori WHERE nome = ? ORDER BY ' . \App\Support\ExactSearch::exactFirstSql('nome') . ', id LIMIT 1'); diff --git a/app/Support/ExactSearch.php b/app/Support/ExactSearch.php index 741a17180..8e64e74fa 100644 --- a/app/Support/ExactSearch.php +++ b/app/Support/ExactSearch.php @@ -82,6 +82,18 @@ public static function parseFilter(string $value): array return ['term' => $value, 'exact' => false]; } + /** + * The name inside a value typed wholly in quotes (`"Rolf Häffner"`), or + * null for an unquoted value. Used where a name is SAVED rather than + * searched: quotes there mean "this exact spelling is a different + * person/publisher", and they are never part of the stored name. + */ + public static function quotedValue(string $value): ?string + { + ['term' => $term, 'exact' => $exact] = self::parseFilter($value); + return $exact && $term !== '' ? $term : null; + } + /** * WHERE fragment for "$expr is exactly $value": accents distinct, case * ignored. For lookups by a name taken from a link (author page, publisher diff --git a/storage/plugins/emeroteca/src/Services/PeriodicalPublisherService.php b/storage/plugins/emeroteca/src/Services/PeriodicalPublisherService.php index 5d9b9e9a6..e0aa3864c 100644 --- a/storage/plugins/emeroteca/src/Services/PeriodicalPublisherService.php +++ b/storage/plugins/emeroteca/src/Services/PeriodicalPublisherService.php @@ -85,8 +85,10 @@ public function resolve(array $values, ?int $testataId = null): array foreach ($this->validate($values, $testataId) as $value) { if (str_starts_with($value, 'new:')) { $name = substr($value, 4); - $existing = $this->rows('SELECT id FROM editori WHERE nome=? ORDER BY ' . \App\Support\ExactSearch::exactFirstSql('nome') . ', id LIMIT 1', [$name, $name]); - $id = $existing !== [] ? (int)$existing[0]['id'] : (new PublisherRepository($this->db))->create(['nome'=>$name]); + // Same lookup as the book form: exact spelling first, and a + // name typed in quotes reuses only that spelling (#501). + $publishers = new PublisherRepository($this->db); + $id = $publishers->findByName($name) ?? $publishers->create(['nome'=>$name]); } else { $id = (int)$value; } if (!in_array($id, $ids, true)) { $ids[] = $id; } } diff --git a/tests/exact-search-dedup-501.unit.php b/tests/exact-search-dedup-501.unit.php index 0b8a6bbe4..60abeed3f 100644 --- a/tests/exact-search-dedup-501.unit.php +++ b/tests/exact-search-dedup-501.unit.php @@ -83,4 +83,34 @@ $same($pubPlain, $publishers->findByName("Verlag {$given} Häffner"), 'publisher: with only Haffner on file, an incoming Häffner still reuses it (tolerant dedup kept)'); $ok($authors->findByCanonicalName("{$given} Hoffner") === null, 'author: a different letter is never merged'); -Exact501::finish(25); +// ── Quotes opt out of the tolerant fallback (Fabio, 2026-10-27) ───────────── +// Only Haffner is on file now. Typed WITHOUT quotes, Häffner is still merged +// into it (above). Typed IN quotes, it is that exact spelling: a different +// person or publisher, created without the quotes. +$nameOf = static fn(string $table, int $id): string => (string) $db->query("SELECT nome FROM {$table} WHERE id = {$id}")->fetch_column(); +$ok($authors->findByCanonicalName("\"{$given} Häffner\"") === null, 'author: quoted Häffner is not merged into Haffner'); +$same($authorPlain, $authors->findByCanonicalName("\"{$given} Haffner\""), 'author: quoted Haffner still finds Haffner'); +$same($authorPlain, $authors->findByCanonicalName('"' . mb_strtolower("{$given} haffner") . '"'), 'author: case is ignored inside quotes'); +$newAuthor = $authors->create(['nome' => "\"{$given} Häffner\""]); +$same("{$given} Häffner", $nameOf('autori', $newAuthor), 'author: a quoted new name is stored without the quotes'); +$same($newAuthor, $authors->findByCanonicalName("\"{$given} Häffner\""), 'author: the quoted spelling now finds the new author'); +$same($newAuthor, $authors->findByCanonicalName("{$given} Häffner"), 'author: unquoted, the exact spelling wins once both exist'); +$same($newAuthor, $authors->findByCanonicalName("\"Häffner, {$given}\""), 'author: a quoted inverted name is canonicalised before the exact match'); +$ok($newAuthor !== $authorPlain, 'author: quoted and unquoted spellings are two people'); + +$ok($publishers->findByName("\"Verlag {$given} Häffner\"") === null, 'publisher: quoted Häffner is not merged into Haffner'); +$newPublisher = $publishers->create(['nome' => "\"Verlag {$given} Häffner\""]); +$same("Verlag {$given} Häffner", $nameOf('editori', $newPublisher), 'publisher: a quoted new name is stored without the quotes'); +$same($newPublisher, $publishers->findByName("\"Verlag {$given} Häffner\""), 'publisher: the quoted spelling now finds the new publisher'); + +foreach ([\App\Controllers\CsvImportController::class => 'CSV import', \App\Controllers\LibraryThingImportController::class => 'LibraryThing import'] as $class => $label) { + $importer = (new \ReflectionClass($class))->newInstanceWithoutConstructor(); + $getOrCreate = new \ReflectionMethod($importer, 'getOrCreatePublisher'); + $first = $getOrCreate->invoke($importer, $db, "\"Verlag {$given} Höffner {$label}\""); + $ok($first['created'] === true && $nameOf('editori', (int) $first['id']) === "Verlag {$given} Höffner {$label}", "{$label}: a quoted publisher is created without quotes"); + $same(['id' => $first['id'], 'created' => false], $getOrCreate->invoke($importer, $db, "\"Verlag {$given} Höffner {$label}\""), "{$label}: the same quoted publisher is reused, not duplicated"); +} +$typed = $periodicals->resolve(["new:\"Verlag {$given} Hüffner\""]); +$ok(count($typed) === 1 && $nameOf('editori', $typed[0]) === "Verlag {$given} Hüffner" && $typed[0] !== $pubPlain, 'periodicals: a quoted typed publisher is a new one, stored without quotes'); + +Exact501::finish(41); diff --git a/tests/issue-501-quoted-new-author.spec.js b/tests/issue-501-quoted-new-author.spec.js new file mode 100644 index 000000000..3dfac7097 --- /dev/null +++ b/tests/issue-501-quoted-new-author.spec.js @@ -0,0 +1,114 @@ +// @ts-check +/** + * Issue #501, decision of 2026-10-27: when only "Haffner" is on file, a book + * saved with author "Häffner" is still linked to Haffner — the accent-tolerant + * dedup is what merges "Garcia Marquez" with "García Márquez". Typed IN + * QUOTES, the name means that exact spelling: a new author is created, stored + * without the quotes. Driven through the real book form (Choices.js + save). + */ +const { test, expect } = require('@playwright/test'); + +// e2e-fixtures throws while loading when the E2E environment is missing, so +// it is required only when the suite is going to run; the skip reports why. +const E2E_READY = Boolean(process.env.E2E_BASE_URL && process.env.E2E_DB_USER && process.env.E2E_DB_NAME + && (process.env.E2E_DB_HOST || process.env.E2E_DB_SOCKET)); +test.skip(!E2E_READY, 'E2E environment not configured (use /tmp/run-e2e.sh)'); +const { createTempAdminUser, deleteTempAdminUser, dbQuery, dbExec } = E2E_READY ? require('./helpers/e2e-fixtures') : {}; +const { makeToken, login } = require('./helpers/exact-search-501'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; + +test.describe.serial('#501 quoted new author = separate person', () => { + const token = makeToken(); + const plain = `${token} Haffner`; + const umlaut = `${token} Häffner`; + /** @type {{id:number,email:string,password:string}} */ + let admin; + /** @type {import('@playwright/test').BrowserContext} */ + let context; + /** @type {import('@playwright/test').Page} */ + let page; + let plainId = 0; + + test.beforeAll(async ({ browser }) => { + dbExec(`INSERT INTO autori (nome) VALUES ('${plain}')`); + plainId = Number(dbQuery(`SELECT id FROM autori WHERE nome = '${plain}' ORDER BY id DESC LIMIT 1`)); + admin = createTempAdminUser('it_IT'); + context = await browser.newContext(); + page = await context.newPage(); + await login(page, BASE, admin.email, admin.password); + }); + + test.afterAll(async () => { + await context?.close(); + try { + dbExec(`DELETE la FROM libri_autori la JOIN libri l ON l.id = la.libro_id WHERE l.titolo LIKE '${token}%'`); + dbExec(`DELETE FROM libri WHERE titolo LIKE '${token}%'`); + dbExec(`DELETE FROM autori WHERE nome LIKE '${token}%' OR nome LIKE '"${token}%'`); + } finally { + deleteTempAdminUser(admin?.id); + } + }); + + /** + * Create a book from the admin form with one author typed in the picker, + * and return the ids of the authors it was saved with. + * @param {string} title + * @param {string} typedAuthor + */ + async function saveBookWithAuthor(title, typedAuthor) { + await page.goto(`${BASE}/admin/books/create`); + await page.fill('#titolo', title); + const authorInput = page.locator('label[for="autori_select"]') + .locator('xpath=following::input[contains(@class,"choices__input--cloned")][1]'); + await expect(authorInput).toBeVisible({ timeout: 10000 }); + await authorInput.click(); + await authorInput.fill(typedAuthor); + await expect(authorInput).toHaveValue(typedAuthor); + await authorInput.press('Enter'); + await expect(page.locator('.choices__list--multiple .choices__item').first()).toBeVisible({ timeout: 10000 }); + const genre = page.locator('#radice_select'); + if (await genre.count()) { + await page.waitForFunction(() => { + const sel = /** @type {HTMLSelectElement|null} */ (document.querySelector('#radice_select')); + return !sel || sel.options.length > 1; + }, null, { timeout: 10000 }); + const options = await genre.locator('option').allInnerTexts(); + if (options.length > 1) { + await genre.selectOption({ index: 1 }); + } + } + await page.locator('#bookForm button[type="submit"]').click(); + await page.locator('.swal2-confirm').click({ timeout: 10000 }); + await page.waitForURL(/admin\/books(?!.*create)/, { timeout: 20000 }); + const bookId = Number(dbQuery(`SELECT id FROM libri WHERE titolo = '${title}' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1`)); + expect(bookId).toBeGreaterThan(0); + return dbQuery(`SELECT la.autore_id FROM libri_autori la WHERE la.libro_id = ${bookId} ORDER BY la.autore_id`) + .split(/\s+/).filter(Boolean).map(Number); + } + + test('1/4 without quotes, Häffner is still merged into the only Haffner', async () => { + const authors = await saveBookWithAuthor(`${token} Buch eins`, umlaut); + expect(authors).toEqual([plainId]); + }); + + test('2/4 in quotes, Häffner becomes a new author', async () => { + const authors = await saveBookWithAuthor(`${token} Buch zwei`, `"${umlaut}"`); + expect(authors).toHaveLength(1); + expect(authors[0]).not.toBe(plainId); + }); + + test('3/4 the new author is stored without the quotes', async () => { + // Compared as hex bytes: the mysql CLI behind dbQuery does not speak + // utf8mb4, so a plain read would garble the umlaut. + const stored = dbQuery(`SELECT HEX(nome) FROM autori WHERE nome LIKE '%${token}%' ORDER BY id`).split('\n'); + const hex = (/** @type {string} */ v) => Buffer.from(v, 'utf8').toString('hex').toUpperCase(); + expect(stored).toEqual([hex(plain), hex(umlaut)]); + }); + + test('4/4 once both exist, unquoted Häffner links to Häffner, not Haffner', async () => { + const umlautId = Number(dbQuery(`SELECT id FROM autori WHERE HEX(nome) = '${Buffer.from(umlaut, 'utf8').toString('hex').toUpperCase()}' LIMIT 1`)); + const authors = await saveBookWithAuthor(`${token} Buch drei`, umlaut); + expect(authors).toEqual([umlautId]); + }); +}); From 12f05750f2c817b6a29e0e63f9dfd4948adcbfe8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 00:15:35 +0100 Subject: [PATCH 07/24] Archives relation typeahead: quoted terms match the exact spelling The entities typeahead behind the relation picker (archival units, authority records, activities, places) still built its own LIKE pattern, so a quoted name folded accents like an unquoted one. It now goes through ExactSearch::filterCondition(), the same rule as the other archive searches. The archives suite checks the invariant on the real action: quoted keeps the exact spelling, unquoted still folds case and accents, and a bare % or a lone pair of quotes matches nothing. --- storage/plugins/archives/ArchivesPlugin.php | 25 +++++++++++------ tests/exact-search-archives-501.unit.php | 31 +++++++++++++++++++-- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/storage/plugins/archives/ArchivesPlugin.php b/storage/plugins/archives/ArchivesPlugin.php index 3f6be45c5..26b9eafc4 100644 --- a/storage/plugins/archives/ArchivesPlugin.php +++ b/storage/plugins/archives/ArchivesPlugin.php @@ -9564,19 +9564,28 @@ public function entitiesAutocompleteAction( $response->getBody()->write(json_encode($payload, JSON_THROW_ON_ERROR)); return $response->withHeader('Content-Type', 'application/json; charset=utf-8'); } - // Escape LIKE wildcards (% _ \) in user input to prevent - // wildcard-injection enumeration (e.g. q=% returning everything). - $qEscaped = $q === '' ? '' : addcslashes($q, '%_\\'); - $like = $q === '' ? '%' : '%' . $qEscaped . '%'; // Column names are from a static map — safe to interpolate. - $sql = "SELECT id, `{$spec['label']}` AS label, `{$spec['extra']}` AS extra + // The label filter goes through ExactSearch: LIKE metacharacters are + // escaped (q=% cannot enumerate everything) and a quoted value is the + // exact spelling, accents included (#501). An empty q lists all rows. + $label = "`{$spec['label']}`"; + $match = $q === '' ? null : \App\Support\ExactSearch::filterCondition([$label], $q); + if ($q !== '' && $match === null) { + // Only quotes typed: nothing left to match, so nothing matches. + $response->getBody()->write(json_encode($payload, JSON_THROW_ON_ERROR)); + return $response->withHeader('Content-Type', 'application/json; charset=utf-8'); + } + $where = $match !== null ? $match['sql'] : '1=1'; + $sql = "SELECT id, {$label} AS label, `{$spec['extra']}` AS extra FROM `{$spec['table']}` - WHERE `{$spec['label']}` LIKE ? AND deleted_at IS NULL - ORDER BY `{$spec['label']}` ASC + WHERE {$where} AND deleted_at IS NULL + ORDER BY {$label} ASC LIMIT 20"; $stmt = $this->db->prepare($sql); if ($stmt !== false) { - $stmt->bind_param('s', $like); + if ($match !== null) { + $stmt->bind_param($match['types'], ...$match['params']); + } if ($stmt->execute()) { $r = $stmt->get_result(); if ($r instanceof \mysqli_result) { diff --git a/tests/exact-search-archives-501.unit.php b/tests/exact-search-archives-501.unit.php index a726639fd..477fa87d3 100644 --- a/tests/exact-search-archives-501.unit.php +++ b/tests/exact-search-archives-501.unit.php @@ -4,7 +4,7 @@ /** * Issue #501, part 7 of 9: the archives plugin. Its searches (admin list, * public listing, unified search, the catalogue's archive box, the author - * picker) honour quotes like the book search: unquoted keeps the stemming + * picker, the relation typeahead) honour quotes like the book search: unquoted keeps the stemming * ("disegno" finds "disegni") and the accent folding, quoted is the exact * spelling with nothing trimmed. * @@ -131,4 +131,31 @@ functions TEXT NULL, $reconciled=new ReflectionMethod($plugin,'searchReconciledAutori'); $same([$a1],$resultIds($reconciled->invoke($plugin,"{$t} \"haffner\"",20)),'reconciled-author search keeps the plain spelling'); $same([$a2],$resultIds($reconciled->invoke($plugin,"{$t} \"häffner\"",20)),'reconciled-author search keeps the umlaut spelling'); -Exact501::finish(39); + +// A quoted word next to a plain one on the dedicated archive bar: each word +// keeps its own rule. (The unquoted bar runs on FULLTEXT, which TEMPORARY +// tables cannot carry; its folding is covered by the E2E spec.) +$unitSearch = new ReflectionMethod($plugin, 'searchArchivalUnits'); +$same([1], $resultIds($unitSearch->invoke($plugin, 'die "brücke"', 20)), 'archive searchArchivalUnits: mixed plain + quoted word, each with its own rule'); + +// ── Relation typeahead (GET /api/archives/entities) ──────────────────────── +$db->query('ALTER TABLE authority_records ADD COLUMN ric_type VARCHAR(40) NULL'); +$entities = static function (string $type, string $q) use ($plugin, $resultIds): array { + $request = (new \Slim\Psr7\Factory\ServerRequestFactory()) + ->createServerRequest('GET', '/api/archives/entities') + ->withQueryParams(['type' => $type, 'q' => $q]); + $response = $plugin->entitiesAutocompleteAction($request, (new \Slim\Psr7\Factory\ResponseFactory())->createResponse()); + $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + return $resultIds($payload['results']); +}; +$same([1, 2], $entities('archival_unit', 'brücke'), 'relation typeahead: unquoted brücke finds both spellings, deleted unit left out'); +$same([1, 2], $entities('archival_unit', 'BRUCKE'), 'relation typeahead: unquoted ignores case and accents'); +$same([1], $entities('archival_unit', '"brücke"'), 'relation typeahead: "brücke" excludes Brucke'); +$same([2], $entities('archival_unit', '"brucke"'), 'relation typeahead: "brucke" excludes Brücke'); +$same([1], $entities('archival_unit', 'die "Brücke"'), 'relation typeahead: mixed plain + quoted word'); +$same([1], $entities('authority_record', '"häffner"'), 'relation typeahead: quoted authority keeps the umlaut'); +$same([2], $entities('authority_record', '"haffner"'), 'relation typeahead: quoted authority excludes the umlaut'); +$same([1, 2], $entities('authority_record', 'haffner'), 'relation typeahead: unquoted authority finds both spellings'); +$same([], $entities('archival_unit', '%'), 'relation typeahead: a bare % is a literal, not a wildcard'); +$same([], $entities('archival_unit', '""'), 'relation typeahead: only quotes match nothing'); +Exact501::finish(50); From 1ded99724dc561949da854c0657a16430d0487e8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 00:36:11 +0100 Subject: [PATCH 08/24] Show the quote hint under the admin book, author and publisher filters The hint lived only in a title tooltip, which nobody sees without hovering and touch devices never show. It is now a small caption under each search box (the text-xs text-gray-500 mt-1 help line used elsewhere in the back office), tied to the field with aria-describedby so screen readers still read it as the field's description, not its name. --- app/Views/autori/index.php | 3 ++- app/Views/editori/index.php | 3 ++- app/Views/libri/index.php | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/Views/autori/index.php b/app/Views/autori/index.php index f99f5795e..bda7b9b1e 100644 --- a/app/Views/autori/index.php +++ b/app/Views/autori/index.php @@ -59,8 +59,9 @@ - " id="search_nome" type="text" placeholder="" + +

      diff --git a/app/Views/editori/index.php b/app/Views/editori/index.php index 094335b40..f76cc8d28 100644 --- a/app/Views/editori/index.php +++ b/app/Views/editori/index.php @@ -59,8 +59,9 @@ - " id="search_nome" type="text" placeholder="" + +

      diff --git a/app/Views/libri/index.php b/app/Views/libri/index.php index 78051e3c0..1e59b6b5b 100644 --- a/app/Views/libri/index.php +++ b/app/Views/libri/index.php @@ -138,8 +138,9 @@ - " id="search_text" type="text" placeholder="" + +

      From c13ac84cf762187114550469614de18614204257 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 00:36:13 +0100 Subject: [PATCH 09/24] Tell archive searchers that quotes mean the exact spelling The admin archive list, the admin unified archive search and the public archive filter column now show the same quote hint as the catalogue. The shared filter sidebar takes an optional hint, rendered with the catalogue's search-hint class and linked as the box's description; only the archive passes one for now. --- app/Views/frontend/partials/filters-sidebar.php | 10 +++++++--- storage/plugins/archives/views/index.php | 2 ++ storage/plugins/archives/views/public/index.php | 1 + storage/plugins/archives/views/search.php | 4 +++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/Views/frontend/partials/filters-sidebar.php b/app/Views/frontend/partials/filters-sidebar.php index a258875b6..c95169835 100644 --- a/app/Views/frontend/partials/filters-sidebar.php +++ b/app/Views/frontend/partials/filters-sidebar.php @@ -4,7 +4,8 @@ * link and the search box is a GET form, so it works without JavaScript. On * small screens it collapses behind its toggle exactly as /catalogo does. * - * Input $filterSearch: array{action: string, name?: string, value?: string, placeholder?: string, label?: string, hidden?: array}|null + * Input $filterSearch: array{action: string, name?: string, value?: string, placeholder?: string, label?: string, hint?: string, hidden?: array}|null + * `hint` is a short help line shown under the box and tied to it as its description. * Input $filterSections: list}> * Sections with no options are skipped. `grid` lays short labels (an * A–Z index) out as a compact grid of tiles instead of a list. @@ -32,18 +33,21 @@ - +
      - + maxlength="200" value="" placeholder=""> $fsValue): if ((string) $fsValue === '') { continue; } ?> + +

      +
      diff --git a/storage/plugins/archives/views/index.php b/storage/plugins/archives/views/index.php index 25410254d..93bdd6572 100644 --- a/storage/plugins/archives/views/index.php +++ b/storage/plugins/archives/views/index.php @@ -221,8 +221,10 @@ class="card archive-filter-form mb-6"> " class="form-input"> +