From 8a2742edbf8d93776058785bb1d6cd60327de9c0 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 26 Oct 2026 10:08:07 +0100 Subject: [PATCH 001/398] 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 002/398] 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 003/398] 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 004/398] 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 005/398] 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 006/398] 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 007/398] 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 008/398] 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 009/398] 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"> +

      + +
      + +
      +
      +
      +
      +
      +
      + __('Impostazioni'), - preg_match('#^/admin/(books|authors|publishers|genres|series|placement|opere|espressioni)#', $workspacePath) === 1 => __('Catalogo'), + preg_match('#^/admin/(books|authors|publishers|genres|series|placement|inventory|opere|espressioni)#', $workspacePath) === 1 => __('Catalogo'), preg_match('#^/admin/(loans|reservations|users)#', $workspacePath) === 1 => __('Circolazione'), str_starts_with($workspacePath, '/admin/periodicals') => __('Emeroteca'), str_starts_with($workspacePath, '/admin/archives') => __('Archivi'), @@ -29,7 +29,7 @@ '/admin/books', '/admin/authors', '/admin/publishers', '/admin/genres', '/admin/series', '/admin/users', '/admin/loans', '/admin/reservations', '/admin/archives', '/admin/periodicals', '/admin/book-club', '/admin/plugins', '/admin/languages', - '/admin/opere', '/admin/espressioni', '/admin/themes', + '/admin/opere', '/admin/espressioni', '/admin/themes', '/admin/inventory', ]; foreach ($workspaceParentPaths as $workspaceParentPath) { if (str_starts_with($workspacePath, $workspaceParentPath . '/')) { diff --git a/locale/da_DK.json b/locale/da_DK.json index 44d0a0a0b..628987012 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8409,5 +8409,93 @@ "Sfoglia il catalogo della biblioteca e filtra per genere, autore, editore e anno per trovare la tua prossima lettura.": "Gennemse bibliotekets katalog og filtrér efter genre, forfatter, forlag og år for at finde din næste læsning.", "Fascicolo %s di «%s» conservato in emeroteca": "Nummer %s af »%s« i tidsskriftssamlingen", "Articoli: %d.": "Artikler: %d.", - "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "»%s« i tidsskriftssamlingen: årgange, numre og artikler fra tidsskriftet i biblioteket." + "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "»%s« i tidsskriftssamlingen: årgange, numre og artikler fra tidsskriftet i biblioteket.", + "Trovata": "Fundet", + "Fuori posto": "Forkert placeret", + "Codice sconosciuto": "Ukendt kode", + "Fuori ambito": "Uden for omfang", + "Già scansionata": "Allerede scannet", + "Impossibile aprire la revisione. Riprova più tardi.": "Optællingen kunne ikke åbnes. Prøv igen senere.", + "Revisione aperta: le copie attese sono state fissate. Puoi iniziare a scansionare.": "Optælling åbnet: de forventede eksemplarer er fastlagt. Du kan begynde at scanne.", + "Revisione non trovata.": "Optællingen blev ikke fundet.", + "Impossibile registrare la scansione. Riprova.": "Scanningen kunne ikke registreres. Prøv igen.", + "Seleziona un file .txt o .csv con un codice per riga.": "Vælg en .txt- eller .csv-fil med én kode pr. linje.", + "Il file deve essere un .txt o .csv di al massimo 2 MB.": "Filen skal være en .txt eller .csv på højst 2 MB.", + "Impossibile elaborare il file. Riprova.": "Filen kunne ikke behandles. Prøv igen.", + "Elaborati %d codici dal file.": "%d koder behandlet fra filen.", + "Solo un amministratore può chiudere la revisione segnando come perse le copie mancanti.": "Kun en administrator kan lukke optællingen og markere de manglende eksemplarer som tabt.", + "Impossibile chiudere la revisione. Riprova più tardi.": "Optællingen kunne ikke lukkes. Prøv igen senere.", + "Revisione chiusa: %d copie mancanti segnate come perse.": "Optælling lukket: %d manglende eksemplarer markeret som tabt.", + "Revisione chiusa: le copie mancanti sono rimaste invariate.": "Optælling lukket: de manglende eksemplarer er uændrede.", + "%d copie non sono state aggiornate: controlla il registro degli errori.": "%d eksemplarer blev ikke opdateret: tjek fejlloggen.", + "Impossibile annullare la revisione. Riprova più tardi.": "Optællingen kunne ikke annulleres. Prøv igen senere.", + "Revisione annullata. Nessuna copia è stata modificata.": "Optælling annulleret. Ingen eksemplarer er ændret.", + "Esito": "Resultat", + "Codice copia": "Eksemplarkode", + "Scaffale atteso": "Forventet reol", + "Stato copia": "Eksemplarstatus", + "Data scansione": "Scannet", + "Indica un nome per la revisione.": "Giv optællingen et navn.", + "La revisione non è più aperta: non si possono registrare altre scansioni.": "Optællingen er ikke længere åben: der kan ikke registreres flere scanninger.", + "Codice mancante.": "Kode mangler.", + "Il file contiene troppi codici: al massimo %d per caricamento.": "Filen indeholder for mange koder: højst %d pr. upload.", + "Revisione inventariale": "Lageroptælling", + "Aperta il": "Åbnet", + "Sede": "Afdeling", + "Riepilogo": "Oversigt", + "Copie attese": "Forventede eksemplarer", + "Confronta le copie presenti sugli scaffali con quelle a catalogo": "Sammenlign eksemplarerne på reolerne med dem i kataloget", + "Nuova revisione": "Ny optælling", + "All'apertura viene fissato l'elenco delle copie attese: le modifiche successive al catalogo non cambiano il risultato.": "Når den åbnes, fastlægges listen over forventede eksemplarer: senere ændringer i kataloget påvirker ikke resultatet.", + "Revisione scaffale A, ottobre": "Optælling reol A, oktober", + "Tutte le sedi": "Alle afdelinger", + "Collocazione da": "Opstilling fra", + "A.1": "A.1", + "Collocazione a": "Opstilling til", + "A.9": "A.9", + "Apri revisione": "Åbn optælling", + "Lascia vuoti i filtri per includere tutte le copie a catalogo. Il genere comprende anche i suoi sottogeneri.": "Lad filtrene være tomme for at medtage alle eksemplarer i kataloget. En genre omfatter også dens undergenrer.", + "Revisioni": "Optællinger", + "Scansioni": "Scanninger", + "Aperta da": "Åbnet af", + "Nessuna revisione. Aprine una per iniziare.": "Ingen optællinger endnu. Åbn en for at begynde.", + "Codici sconosciuti": "Ukendte koder", + "Scansioni ripetute": "Gentagne scanninger", + "Report CSV": "CSV-rapport", + "Report PDF": "PDF-rapport", + "Scansione": "Scanning", + "Leggi il codice con un lettore di codici a barre o con la fotocamera, oppure digitalo e premi Invio.": "Læs koden med en stregkodelæser eller kameraet, eller skriv den og tryk Enter.", + "Scaffale in cui stai lavorando": "Reol du arbejder på", + "Non indicato": "Ikke angivet", + "Inserisci o scansiona il codice inventario": "Skriv eller scan inventarkoden", + "Registra": "Registrér", + "Carica un elenco di codici": "Upload en liste med koder", + "Un file .txt con un codice per riga, o un .csv con il codice nella prima colonna (lettori offline, inventari fatti con altre app).": "En .txt-fil med én kode pr. linje eller en .csv med koden i første kolonne (offline-scannere, optællinger lavet med andre apps).", + "Ultime scansioni": "Seneste scanninger", + "Le mie scansioni": "Mine scanninger", + "Solo le mie scansioni": "Kun mine scanninger", + "Nessuna scansione registrata.": "Ingen scanninger registreret.", + "Chiudi la revisione": "Luk optællingen", + "Scegli cosa fare con le copie mancanti. La disponibilità dei libri viene ricalcolata.": "Vælg, hvad der skal ske med de manglende eksemplarer. Bøgernes tilgængelighed genberegnes.", + "Chiudere la revisione? Non sarà più possibile registrare scansioni.": "Luk optællingen? Derefter kan der ikke registreres flere scanninger.", + "Chiudi revisione": "Luk optælling", + "Lascia le copie mancanti come sono": "Lad de manglende eksemplarer være som de er", + "Segna come perse le copie mancanti": "Markér de manglende eksemplarer som tabt", + "Le copie in prestito, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Udlånte eksemplarer og eksemplarer under restaurering, overførsel eller vedligeholdelse røres ikke.", + "Solo un amministratore può segnare come perse le copie mancanti.": "Kun en administrator kan markere de manglende eksemplarer som tabt.", + "Annullare la revisione? Le scansioni restano registrate ma nessuna copia viene modificata.": "Annullér optællingen? Scanningerne bevares, men ingen eksemplarer ændres.", + "Annulla revisione": "Annullér optælling", + "Report finale": "Slutrapport", + "Il codice non corrisponde a nessuna copia a catalogo.": "Koden svarer ikke til noget eksemplar i kataloget.", + "Questa copia è già stata registrata in questa revisione.": "Dette eksemplar er allerede registreret i denne optælling.", + "La copia esiste ma non rientra nel filtro della revisione.": "Eksemplaret findes, men er uden for optællingens filter.", + "La copia risulta in prestito: verifica il prestito.": "Eksemplaret står som udlånt: tjek udlånet.", + "La revisione è stata chiusa da un altro operatore.": "Optællingen er lukket af en anden medarbejder.", + "Revisore inventario": "Optællingsmedarbejder", + "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Startprofil til at deltage i lageroptællinger: åbne en session og scanne eksemplarer uden at ændre kataloget eller erklære eksemplarer tabt.", + "Verifica le copie sugli scaffali": "Kontrollér eksemplarerne på reolerne", + "Copia dichiarata persa in revisione inventariale": "Eksemplar erklæret tabt ved optælling", + "Revisione inventariale aperta": "Optælling åbnet", + "Revisione inventariale chiusa": "Optælling lukket", + "Revisione inventariale annullata": "Optælling annulleret" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 5f325ba27..b45afda31 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8409,5 +8409,93 @@ "Sfoglia il catalogo della biblioteca e filtra per genere, autore, editore e anno per trovare la tua prossima lettura.": "Durchsuche den Katalog der Bibliothek und filtere nach Genre, Autor, Verlag und Jahr, um deine nächste Lektüre zu finden.", "Fascicolo %s di «%s» conservato in emeroteca": "Ausgabe %s von „%s“ in der Zeitschriftensammlung", "Articoli: %d.": "Artikel: %d.", - "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "„%s“ in der Zeitschriftensammlung: Jahrgänge, Ausgaben und Artikel der Zeitschrift in der Bibliothek." + "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "„%s“ in der Zeitschriftensammlung: Jahrgänge, Ausgaben und Artikel der Zeitschrift in der Bibliothek.", + "Trovata": "Gefunden", + "Fuori posto": "Falsch eingestellt", + "Codice sconosciuto": "Unbekannter Code", + "Fuori ambito": "Außerhalb des Umfangs", + "Già scansionata": "Bereits gescannt", + "Impossibile aprire la revisione. Riprova più tardi.": "Die Revision konnte nicht eröffnet werden. Versuche es später erneut.", + "Revisione aperta: le copie attese sono state fissate. Puoi iniziare a scansionare.": "Revision eröffnet: Die erwarteten Exemplare wurden festgehalten. Du kannst mit dem Scannen beginnen.", + "Revisione non trovata.": "Revision nicht gefunden.", + "Impossibile registrare la scansione. Riprova.": "Der Scan konnte nicht gespeichert werden. Versuche es erneut.", + "Seleziona un file .txt o .csv con un codice per riga.": "Wähle eine .txt- oder .csv-Datei mit einem Code pro Zeile.", + "Il file deve essere un .txt o .csv di al massimo 2 MB.": "Die Datei muss eine .txt- oder .csv-Datei von höchstens 2 MB sein.", + "Impossibile elaborare il file. Riprova.": "Die Datei konnte nicht verarbeitet werden. Versuche es erneut.", + "Elaborati %d codici dal file.": "%d Codes aus der Datei verarbeitet.", + "Solo un amministratore può chiudere la revisione segnando come perse le copie mancanti.": "Nur ein Administrator kann die Revision schließen und dabei die fehlenden Exemplare als verloren markieren.", + "Impossibile chiudere la revisione. Riprova più tardi.": "Die Revision konnte nicht geschlossen werden. Versuche es später erneut.", + "Revisione chiusa: %d copie mancanti segnate come perse.": "Revision geschlossen: %d fehlende Exemplare als verloren markiert.", + "Revisione chiusa: le copie mancanti sono rimaste invariate.": "Revision geschlossen: Die fehlenden Exemplare bleiben unverändert.", + "%d copie non sono state aggiornate: controlla il registro degli errori.": "%d Exemplare wurden nicht aktualisiert: Prüfe das Fehlerprotokoll.", + "Impossibile annullare la revisione. Riprova più tardi.": "Die Revision konnte nicht abgebrochen werden. Versuche es später erneut.", + "Revisione annullata. Nessuna copia è stata modificata.": "Revision abgebrochen. Kein Exemplar wurde geändert.", + "Esito": "Ergebnis", + "Codice copia": "Exemplarcode", + "Scaffale atteso": "Erwartetes Regal", + "Stato copia": "Exemplarstatus", + "Data scansione": "Gescannt am", + "Indica un nome per la revisione.": "Gib der Revision einen Namen.", + "La revisione non è più aperta: non si possono registrare altre scansioni.": "Die Revision ist nicht mehr offen: Es können keine weiteren Scans erfasst werden.", + "Codice mancante.": "Code fehlt.", + "Il file contiene troppi codici: al massimo %d per caricamento.": "Die Datei enthält zu viele Codes: höchstens %d pro Upload.", + "Revisione inventariale": "Bestandsrevision", + "Aperta il": "Eröffnet am", + "Sede": "Zweigstelle", + "Riepilogo": "Zusammenfassung", + "Copie attese": "Erwartete Exemplare", + "Confronta le copie presenti sugli scaffali con quelle a catalogo": "Vergleiche die Exemplare in den Regalen mit denen im Katalog", + "Nuova revisione": "Neue Revision", + "All'apertura viene fissato l'elenco delle copie attese: le modifiche successive al catalogo non cambiano il risultato.": "Beim Eröffnen wird die Liste der erwarteten Exemplare festgehalten: Spätere Katalogänderungen verändern das Ergebnis nicht.", + "Revisione scaffale A, ottobre": "Revision Regal A, Oktober", + "Tutte le sedi": "Alle Zweigstellen", + "Collocazione da": "Signatur von", + "A.1": "A.1", + "Collocazione a": "Signatur bis", + "A.9": "A.9", + "Apri revisione": "Revision eröffnen", + "Lascia vuoti i filtri per includere tutte le copie a catalogo. Il genere comprende anche i suoi sottogeneri.": "Lass die Filter leer, um alle Exemplare des Katalogs einzubeziehen. Ein Genre umfasst auch seine Untergenres.", + "Revisioni": "Revisionen", + "Scansioni": "Scans", + "Aperta da": "Eröffnet von", + "Nessuna revisione. Aprine una per iniziare.": "Noch keine Revision. Eröffne eine, um zu beginnen.", + "Codici sconosciuti": "Unbekannte Codes", + "Scansioni ripetute": "Wiederholte Scans", + "Report CSV": "CSV-Bericht", + "Report PDF": "PDF-Bericht", + "Scansione": "Scannen", + "Leggi il codice con un lettore di codici a barre o con la fotocamera, oppure digitalo e premi Invio.": "Lies den Code mit einem Barcodescanner oder der Kamera ein oder tippe ihn ein und drücke die Eingabetaste.", + "Scaffale in cui stai lavorando": "Regal, an dem du arbeitest", + "Non indicato": "Nicht angegeben", + "Inserisci o scansiona il codice inventario": "Inventarcode eingeben oder scannen", + "Registra": "Erfassen", + "Carica un elenco di codici": "Eine Codeliste hochladen", + "Un file .txt con un codice per riga, o un .csv con il codice nella prima colonna (lettori offline, inventari fatti con altre app).": "Eine .txt-Datei mit einem Code pro Zeile oder eine .csv mit dem Code in der ersten Spalte (Offline-Scanner, mit anderen Apps erstellte Revisionen).", + "Ultime scansioni": "Letzte Scans", + "Le mie scansioni": "Meine Scans", + "Solo le mie scansioni": "Nur meine Scans", + "Nessuna scansione registrata.": "Keine Scans erfasst.", + "Chiudi la revisione": "Revision schließen", + "Scegli cosa fare con le copie mancanti. La disponibilità dei libri viene ricalcolata.": "Wähle, was mit den fehlenden Exemplaren geschehen soll. Die Verfügbarkeit der Bücher wird neu berechnet.", + "Chiudere la revisione? Non sarà più possibile registrare scansioni.": "Revision schließen? Danach können keine Scans mehr erfasst werden.", + "Chiudi revisione": "Revision schließen", + "Lascia le copie mancanti come sono": "Fehlende Exemplare unverändert lassen", + "Segna come perse le copie mancanti": "Fehlende Exemplare als verloren markieren", + "Le copie in prestito, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Ausgeliehene Exemplare sowie Exemplare in Restaurierung, im Transfer oder in Wartung bleiben unberührt.", + "Solo un amministratore può segnare come perse le copie mancanti.": "Nur ein Administrator kann fehlende Exemplare als verloren markieren.", + "Annullare la revisione? Le scansioni restano registrate ma nessuna copia viene modificata.": "Revision abbrechen? Die Scans bleiben gespeichert, aber kein Exemplar wird geändert.", + "Annulla revisione": "Revision abbrechen", + "Report finale": "Abschlussbericht", + "Il codice non corrisponde a nessuna copia a catalogo.": "Der Code entspricht keinem Exemplar im Katalog.", + "Questa copia è già stata registrata in questa revisione.": "Dieses Exemplar wurde in dieser Revision bereits erfasst.", + "La copia esiste ma non rientra nel filtro della revisione.": "Das Exemplar existiert, liegt aber außerhalb des Filters der Revision.", + "La copia risulta in prestito: verifica il prestito.": "Das Exemplar ist als ausgeliehen erfasst: Prüfe die Ausleihe.", + "La revisione è stata chiusa da un altro operatore.": "Die Revision wurde von einem anderen Mitarbeiter geschlossen.", + "Revisore inventario": "Bestandsprüfer", + "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Ausgangsprofil für die Teilnahme an Bestandsrevisionen: eine Sitzung eröffnen und Exemplare scannen, ohne den Katalog zu ändern oder Exemplare als verloren zu erklären.", + "Verifica le copie sugli scaffali": "Exemplare in den Regalen prüfen", + "Copia dichiarata persa in revisione inventariale": "Exemplar bei einer Bestandsrevision als verloren erklärt", + "Revisione inventariale aperta": "Bestandsrevision eröffnet", + "Revisione inventariale chiusa": "Bestandsrevision geschlossen", + "Revisione inventariale annullata": "Bestandsrevision abgebrochen" } diff --git a/locale/en_US.json b/locale/en_US.json index e80586f45..cb1d1c550 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8409,5 +8409,93 @@ "Sfoglia il catalogo della biblioteca e filtra per genere, autore, editore e anno per trovare la tua prossima lettura.": "Browse the library catalogue and filter by genre, author, publisher and year to find your next read.", "Fascicolo %s di «%s» conservato in emeroteca": "Issue %s of “%s” held in the periodicals collection", "Articoli: %d.": "Articles: %d.", - "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "“%s” in the periodicals collection: volumes, issues and articles of the title held by the library." + "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "“%s” in the periodicals collection: volumes, issues and articles of the title held by the library.", + "Trovata": "Found", + "Fuori posto": "Misplaced", + "Codice sconosciuto": "Unknown code", + "Fuori ambito": "Out of scope", + "Già scansionata": "Already scanned", + "Impossibile aprire la revisione. Riprova più tardi.": "Could not open the stocktake. Try again later.", + "Revisione aperta: le copie attese sono state fissate. Puoi iniziare a scansionare.": "Stocktake opened: the expected copies have been fixed. You can start scanning.", + "Revisione non trovata.": "Stocktake not found.", + "Impossibile registrare la scansione. Riprova.": "Could not record the scan. Try again.", + "Seleziona un file .txt o .csv con un codice per riga.": "Choose a .txt or .csv file with one code per line.", + "Il file deve essere un .txt o .csv di al massimo 2 MB.": "The file must be a .txt or .csv of at most 2 MB.", + "Impossibile elaborare il file. Riprova.": "Could not process the file. Try again.", + "Elaborati %d codici dal file.": "Processed %d codes from the file.", + "Solo un amministratore può chiudere la revisione segnando come perse le copie mancanti.": "Only an administrator can close the stocktake marking the missing copies as lost.", + "Impossibile chiudere la revisione. Riprova più tardi.": "Could not close the stocktake. Try again later.", + "Revisione chiusa: %d copie mancanti segnate come perse.": "Stocktake closed: %d missing copies marked as lost.", + "Revisione chiusa: le copie mancanti sono rimaste invariate.": "Stocktake closed: the missing copies were left unchanged.", + "%d copie non sono state aggiornate: controlla il registro degli errori.": "%d copies were not updated: check the error log.", + "Impossibile annullare la revisione. Riprova più tardi.": "Could not cancel the stocktake. Try again later.", + "Revisione annullata. Nessuna copia è stata modificata.": "Stocktake cancelled. No copy was changed.", + "Esito": "Outcome", + "Codice copia": "Copy code", + "Scaffale atteso": "Expected shelf", + "Stato copia": "Copy status", + "Data scansione": "Scanned at", + "Indica un nome per la revisione.": "Give the stocktake a name.", + "La revisione non è più aperta: non si possono registrare altre scansioni.": "The stocktake is no longer open: no more scans can be recorded.", + "Codice mancante.": "Missing code.", + "Il file contiene troppi codici: al massimo %d per caricamento.": "The file contains too many codes: at most %d per upload.", + "Revisione inventariale": "Inventory stocktake", + "Aperta il": "Opened on", + "Sede": "Branch", + "Riepilogo": "Summary", + "Copie attese": "Expected copies", + "Confronta le copie presenti sugli scaffali con quelle a catalogo": "Compare the copies on the shelves with those in the catalogue", + "Nuova revisione": "New stocktake", + "All'apertura viene fissato l'elenco delle copie attese: le modifiche successive al catalogo non cambiano il risultato.": "Opening it fixes the list of expected copies: later catalogue changes do not alter the result.", + "Revisione scaffale A, ottobre": "Shelf A stocktake, October", + "Tutte le sedi": "All branches", + "Collocazione da": "Shelf mark from", + "A.1": "A.1", + "Collocazione a": "Shelf mark to", + "A.9": "A.9", + "Apri revisione": "Open stocktake", + "Lascia vuoti i filtri per includere tutte le copie a catalogo. Il genere comprende anche i suoi sottogeneri.": "Leave the filters empty to include every copy in the catalogue. A genre includes its subgenres.", + "Revisioni": "Stocktakes", + "Scansioni": "Scans", + "Aperta da": "Opened by", + "Nessuna revisione. Aprine una per iniziare.": "No stocktakes yet. Open one to start.", + "Codici sconosciuti": "Unknown codes", + "Scansioni ripetute": "Repeated scans", + "Report CSV": "CSV report", + "Report PDF": "PDF report", + "Scansione": "Scanning", + "Leggi il codice con un lettore di codici a barre o con la fotocamera, oppure digitalo e premi Invio.": "Read the code with a barcode scanner or the camera, or type it and press Enter.", + "Scaffale in cui stai lavorando": "Shelf you are working on", + "Non indicato": "Not specified", + "Inserisci o scansiona il codice inventario": "Type or scan the inventory code", + "Registra": "Record", + "Carica un elenco di codici": "Upload a list of codes", + "Un file .txt con un codice per riga, o un .csv con il codice nella prima colonna (lettori offline, inventari fatti con altre app).": "A .txt file with one code per line, or a .csv with the code in the first column (offline scanners, stocktakes made with other apps).", + "Ultime scansioni": "Latest scans", + "Le mie scansioni": "My scans", + "Solo le mie scansioni": "Only my scans", + "Nessuna scansione registrata.": "No scans recorded.", + "Chiudi la revisione": "Close the stocktake", + "Scegli cosa fare con le copie mancanti. La disponibilità dei libri viene ricalcolata.": "Choose what to do with the missing copies. Book availability is recalculated.", + "Chiudere la revisione? Non sarà più possibile registrare scansioni.": "Close the stocktake? No more scans can be recorded afterwards.", + "Chiudi revisione": "Close stocktake", + "Lascia le copie mancanti come sono": "Leave the missing copies as they are", + "Segna come perse le copie mancanti": "Mark the missing copies as lost", + "Le copie in prestito, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Copies on loan, being restored, in transit or in maintenance are not touched.", + "Solo un amministratore può segnare come perse le copie mancanti.": "Only an administrator can mark the missing copies as lost.", + "Annullare la revisione? Le scansioni restano registrate ma nessuna copia viene modificata.": "Cancel the stocktake? The scans stay recorded but no copy is changed.", + "Annulla revisione": "Cancel stocktake", + "Report finale": "Final report", + "Il codice non corrisponde a nessuna copia a catalogo.": "The code does not match any copy in the catalogue.", + "Questa copia è già stata registrata in questa revisione.": "This copy was already recorded in this stocktake.", + "La copia esiste ma non rientra nel filtro della revisione.": "The copy exists but is outside the stocktake's filter.", + "La copia risulta in prestito: verifica il prestito.": "The copy is recorded as on loan: check the loan.", + "La revisione è stata chiusa da un altro operatore.": "The stocktake was closed by another operator.", + "Revisore inventario": "Stocktake reviewer", + "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Starting profile for taking part in stocktakes: open a session and scan copies, without changing the catalogue or declaring copies lost.", + "Verifica le copie sugli scaffali": "Check the copies on the shelves", + "Copia dichiarata persa in revisione inventariale": "Copy declared lost in a stocktake", + "Revisione inventariale aperta": "Stocktake opened", + "Revisione inventariale chiusa": "Stocktake closed", + "Revisione inventariale annullata": "Stocktake cancelled" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 146ce52b7..475fb1558 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8409,5 +8409,93 @@ "Sfoglia il catalogo della biblioteca e filtra per genere, autore, editore e anno per trovare la tua prossima lettura.": "Parcourez le catalogue de la bibliothèque et filtrez par genre, auteur, éditeur et année pour trouver votre prochaine lecture.", "Fascicolo %s di «%s» conservato in emeroteca": "Numéro %s de « %s » conservé à l’hémérothèque", "Articoli: %d.": "Articles : %d.", - "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "« %s » à l’hémérothèque : années, numéros et articles du titre conservés à la bibliothèque." + "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "« %s » à l’hémérothèque : années, numéros et articles du titre conservés à la bibliothèque.", + "Trovata": "Trouvé", + "Fuori posto": "Mal rangé", + "Codice sconosciuto": "Code inconnu", + "Fuori ambito": "Hors périmètre", + "Già scansionata": "Déjà scanné", + "Impossibile aprire la revisione. Riprova più tardi.": "Impossible d'ouvrir le récolement. Réessayez plus tard.", + "Revisione aperta: le copie attese sono state fissate. Puoi iniziare a scansionare.": "Récolement ouvert : les exemplaires attendus ont été figés. Vous pouvez commencer à scanner.", + "Revisione non trovata.": "Récolement introuvable.", + "Impossibile registrare la scansione. Riprova.": "Impossible d'enregistrer le scan. Réessayez.", + "Seleziona un file .txt o .csv con un codice per riga.": "Choisissez un fichier .txt ou .csv avec un code par ligne.", + "Il file deve essere un .txt o .csv di al massimo 2 MB.": "Le fichier doit être un .txt ou .csv de 2 Mo maximum.", + "Impossibile elaborare il file. Riprova.": "Impossible de traiter le fichier. Réessayez.", + "Elaborati %d codici dal file.": "%d codes traités depuis le fichier.", + "Solo un amministratore può chiudere la revisione segnando come perse le copie mancanti.": "Seul un administrateur peut clôturer le récolement en déclarant perdus les exemplaires manquants.", + "Impossibile chiudere la revisione. Riprova più tardi.": "Impossible de clôturer le récolement. Réessayez plus tard.", + "Revisione chiusa: %d copie mancanti segnate come perse.": "Récolement clôturé : %d exemplaires manquants déclarés perdus.", + "Revisione chiusa: le copie mancanti sono rimaste invariate.": "Récolement clôturé : les exemplaires manquants n'ont pas été modifiés.", + "%d copie non sono state aggiornate: controlla il registro degli errori.": "%d exemplaires n'ont pas été mis à jour : consultez le journal des erreurs.", + "Impossibile annullare la revisione. Riprova più tardi.": "Impossible d'annuler le récolement. Réessayez plus tard.", + "Revisione annullata. Nessuna copia è stata modificata.": "Récolement annulé. Aucun exemplaire n'a été modifié.", + "Esito": "Résultat", + "Codice copia": "Code d'exemplaire", + "Scaffale atteso": "Étagère attendue", + "Stato copia": "État de l'exemplaire", + "Data scansione": "Scanné le", + "Indica un nome per la revisione.": "Donnez un nom au récolement.", + "La revisione non è più aperta: non si possono registrare altre scansioni.": "Le récolement n'est plus ouvert : aucun autre scan ne peut être enregistré.", + "Codice mancante.": "Code manquant.", + "Il file contiene troppi codici: al massimo %d per caricamento.": "Le fichier contient trop de codes : %d au maximum par envoi.", + "Revisione inventariale": "Récolement", + "Aperta il": "Ouvert le", + "Sede": "Site", + "Riepilogo": "Synthèse", + "Copie attese": "Exemplaires attendus", + "Confronta le copie presenti sugli scaffali con quelle a catalogo": "Comparez les exemplaires en rayon avec ceux du catalogue", + "Nuova revisione": "Nouveau récolement", + "All'apertura viene fissato l'elenco delle copie attese: le modifiche successive al catalogo non cambiano il risultato.": "À l'ouverture, la liste des exemplaires attendus est figée : les modifications ultérieures du catalogue ne changent pas le résultat.", + "Revisione scaffale A, ottobre": "Récolement étagère A, octobre", + "Tutte le sedi": "Tous les sites", + "Collocazione da": "Cote de", + "A.1": "A.1", + "Collocazione a": "Cote à", + "A.9": "A.9", + "Apri revisione": "Ouvrir le récolement", + "Lascia vuoti i filtri per includere tutte le copie a catalogo. Il genere comprende anche i suoi sottogeneri.": "Laissez les filtres vides pour inclure tous les exemplaires du catalogue. Un genre comprend ses sous-genres.", + "Revisioni": "Récolements", + "Scansioni": "Scans", + "Aperta da": "Ouvert par", + "Nessuna revisione. Aprine una per iniziare.": "Aucun récolement. Ouvrez-en un pour commencer.", + "Codici sconosciuti": "Codes inconnus", + "Scansioni ripetute": "Scans répétés", + "Report CSV": "Rapport CSV", + "Report PDF": "Rapport PDF", + "Scansione": "Scan", + "Leggi il codice con un lettore di codici a barre o con la fotocamera, oppure digitalo e premi Invio.": "Lisez le code avec une douchette ou la caméra, ou saisissez-le et appuyez sur Entrée.", + "Scaffale in cui stai lavorando": "Étagère sur laquelle vous travaillez", + "Non indicato": "Non indiqué", + "Inserisci o scansiona il codice inventario": "Saisissez ou scannez le code d'inventaire", + "Registra": "Enregistrer", + "Carica un elenco di codici": "Importer une liste de codes", + "Un file .txt con un codice per riga, o un .csv con il codice nella prima colonna (lettori offline, inventari fatti con altre app).": "Un fichier .txt avec un code par ligne, ou un .csv avec le code en première colonne (douchettes hors ligne, récolements faits avec d'autres applications).", + "Ultime scansioni": "Derniers scans", + "Le mie scansioni": "Mes scans", + "Solo le mie scansioni": "Seulement mes scans", + "Nessuna scansione registrata.": "Aucun scan enregistré.", + "Chiudi la revisione": "Clôturer le récolement", + "Scegli cosa fare con le copie mancanti. La disponibilità dei libri viene ricalcolata.": "Choisissez quoi faire des exemplaires manquants. La disponibilité des livres est recalculée.", + "Chiudere la revisione? Non sarà più possibile registrare scansioni.": "Clôturer le récolement ? Plus aucun scan ne pourra être enregistré.", + "Chiudi revisione": "Clôturer le récolement", + "Lascia le copie mancanti come sono": "Laisser les exemplaires manquants tels quels", + "Segna come perse le copie mancanti": "Déclarer perdus les exemplaires manquants", + "Le copie in prestito, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Les exemplaires en prêt, en restauration, en transfert ou en maintenance ne sont pas modifiés.", + "Solo un amministratore può segnare come perse le copie mancanti.": "Seul un administrateur peut déclarer perdus les exemplaires manquants.", + "Annullare la revisione? Le scansioni restano registrate ma nessuna copia viene modificata.": "Annuler le récolement ? Les scans restent enregistrés mais aucun exemplaire n'est modifié.", + "Annulla revisione": "Annuler le récolement", + "Report finale": "Rapport final", + "Il codice non corrisponde a nessuna copia a catalogo.": "Le code ne correspond à aucun exemplaire du catalogue.", + "Questa copia è già stata registrata in questa revisione.": "Cet exemplaire a déjà été enregistré dans ce récolement.", + "La copia esiste ma non rientra nel filtro della revisione.": "L'exemplaire existe mais n'entre pas dans le filtre du récolement.", + "La copia risulta in prestito: verifica il prestito.": "L'exemplaire est enregistré comme prêté : vérifiez le prêt.", + "La revisione è stata chiusa da un altro operatore.": "Le récolement a été clôturé par un autre opérateur.", + "Revisore inventario": "Agent de récolement", + "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Profil de départ pour participer aux récolements : ouvrir une session et scanner les exemplaires, sans modifier le catalogue ni déclarer des exemplaires perdus.", + "Verifica le copie sugli scaffali": "Vérifier les exemplaires en rayon", + "Copia dichiarata persa in revisione inventariale": "Exemplaire déclaré perdu lors d'un récolement", + "Revisione inventariale aperta": "Récolement ouvert", + "Revisione inventariale chiusa": "Récolement clôturé", + "Revisione inventariale annullata": "Récolement annulé" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 0cc3e40c2..4dee0d323 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8409,5 +8409,93 @@ "Sfoglia il catalogo della biblioteca e filtra per genere, autore, editore e anno per trovare la tua prossima lettura.": "Sfoglia il catalogo della biblioteca e filtra per genere, autore, editore e anno per trovare la tua prossima lettura.", "Fascicolo %s di «%s» conservato in emeroteca": "Fascicolo %s di «%s» conservato in emeroteca", "Articoli: %d.": "Articoli: %d.", - "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca." + "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.": "«%s» in emeroteca: annate, fascicoli e articoli della testata conservati in biblioteca.", + "Trovata": "Trovata", + "Fuori posto": "Fuori posto", + "Codice sconosciuto": "Codice sconosciuto", + "Fuori ambito": "Fuori ambito", + "Già scansionata": "Già scansionata", + "Impossibile aprire la revisione. Riprova più tardi.": "Impossibile aprire la revisione. Riprova più tardi.", + "Revisione aperta: le copie attese sono state fissate. Puoi iniziare a scansionare.": "Revisione aperta: le copie attese sono state fissate. Puoi iniziare a scansionare.", + "Revisione non trovata.": "Revisione non trovata.", + "Impossibile registrare la scansione. Riprova.": "Impossibile registrare la scansione. Riprova.", + "Seleziona un file .txt o .csv con un codice per riga.": "Seleziona un file .txt o .csv con un codice per riga.", + "Il file deve essere un .txt o .csv di al massimo 2 MB.": "Il file deve essere un .txt o .csv di al massimo 2 MB.", + "Impossibile elaborare il file. Riprova.": "Impossibile elaborare il file. Riprova.", + "Elaborati %d codici dal file.": "Elaborati %d codici dal file.", + "Solo un amministratore può chiudere la revisione segnando come perse le copie mancanti.": "Solo un amministratore può chiudere la revisione segnando come perse le copie mancanti.", + "Impossibile chiudere la revisione. Riprova più tardi.": "Impossibile chiudere la revisione. Riprova più tardi.", + "Revisione chiusa: %d copie mancanti segnate come perse.": "Revisione chiusa: %d copie mancanti segnate come perse.", + "Revisione chiusa: le copie mancanti sono rimaste invariate.": "Revisione chiusa: le copie mancanti sono rimaste invariate.", + "%d copie non sono state aggiornate: controlla il registro degli errori.": "%d copie non sono state aggiornate: controlla il registro degli errori.", + "Impossibile annullare la revisione. Riprova più tardi.": "Impossibile annullare la revisione. Riprova più tardi.", + "Revisione annullata. Nessuna copia è stata modificata.": "Revisione annullata. Nessuna copia è stata modificata.", + "Esito": "Esito", + "Codice copia": "Codice copia", + "Scaffale atteso": "Scaffale atteso", + "Stato copia": "Stato copia", + "Data scansione": "Data scansione", + "Indica un nome per la revisione.": "Indica un nome per la revisione.", + "La revisione non è più aperta: non si possono registrare altre scansioni.": "La revisione non è più aperta: non si possono registrare altre scansioni.", + "Codice mancante.": "Codice mancante.", + "Il file contiene troppi codici: al massimo %d per caricamento.": "Il file contiene troppi codici: al massimo %d per caricamento.", + "Revisione inventariale": "Revisione inventariale", + "Aperta il": "Aperta il", + "Sede": "Sede", + "Riepilogo": "Riepilogo", + "Copie attese": "Copie attese", + "Confronta le copie presenti sugli scaffali con quelle a catalogo": "Confronta le copie presenti sugli scaffali con quelle a catalogo", + "Nuova revisione": "Nuova revisione", + "All'apertura viene fissato l'elenco delle copie attese: le modifiche successive al catalogo non cambiano il risultato.": "All'apertura viene fissato l'elenco delle copie attese: le modifiche successive al catalogo non cambiano il risultato.", + "Revisione scaffale A, ottobre": "Revisione scaffale A, ottobre", + "Tutte le sedi": "Tutte le sedi", + "Collocazione da": "Collocazione da", + "A.1": "A.1", + "Collocazione a": "Collocazione a", + "A.9": "A.9", + "Apri revisione": "Apri revisione", + "Lascia vuoti i filtri per includere tutte le copie a catalogo. Il genere comprende anche i suoi sottogeneri.": "Lascia vuoti i filtri per includere tutte le copie a catalogo. Il genere comprende anche i suoi sottogeneri.", + "Revisioni": "Revisioni", + "Scansioni": "Scansioni", + "Aperta da": "Aperta da", + "Nessuna revisione. Aprine una per iniziare.": "Nessuna revisione. Aprine una per iniziare.", + "Codici sconosciuti": "Codici sconosciuti", + "Scansioni ripetute": "Scansioni ripetute", + "Report CSV": "Report CSV", + "Report PDF": "Report PDF", + "Scansione": "Scansione", + "Leggi il codice con un lettore di codici a barre o con la fotocamera, oppure digitalo e premi Invio.": "Leggi il codice con un lettore di codici a barre o con la fotocamera, oppure digitalo e premi Invio.", + "Scaffale in cui stai lavorando": "Scaffale in cui stai lavorando", + "Non indicato": "Non indicato", + "Inserisci o scansiona il codice inventario": "Inserisci o scansiona il codice inventario", + "Registra": "Registra", + "Carica un elenco di codici": "Carica un elenco di codici", + "Un file .txt con un codice per riga, o un .csv con il codice nella prima colonna (lettori offline, inventari fatti con altre app).": "Un file .txt con un codice per riga, o un .csv con il codice nella prima colonna (lettori offline, inventari fatti con altre app).", + "Ultime scansioni": "Ultime scansioni", + "Le mie scansioni": "Le mie scansioni", + "Solo le mie scansioni": "Solo le mie scansioni", + "Nessuna scansione registrata.": "Nessuna scansione registrata.", + "Chiudi la revisione": "Chiudi la revisione", + "Scegli cosa fare con le copie mancanti. La disponibilità dei libri viene ricalcolata.": "Scegli cosa fare con le copie mancanti. La disponibilità dei libri viene ricalcolata.", + "Chiudere la revisione? Non sarà più possibile registrare scansioni.": "Chiudere la revisione? Non sarà più possibile registrare scansioni.", + "Chiudi revisione": "Chiudi revisione", + "Lascia le copie mancanti come sono": "Lascia le copie mancanti come sono", + "Segna come perse le copie mancanti": "Segna come perse le copie mancanti", + "Le copie in prestito, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Le copie in prestito, in restauro, in trasferimento o in manutenzione non vengono toccate.", + "Solo un amministratore può segnare come perse le copie mancanti.": "Solo un amministratore può segnare come perse le copie mancanti.", + "Annullare la revisione? Le scansioni restano registrate ma nessuna copia viene modificata.": "Annullare la revisione? Le scansioni restano registrate ma nessuna copia viene modificata.", + "Annulla revisione": "Annulla revisione", + "Report finale": "Report finale", + "Il codice non corrisponde a nessuna copia a catalogo.": "Il codice non corrisponde a nessuna copia a catalogo.", + "Questa copia è già stata registrata in questa revisione.": "Questa copia è già stata registrata in questa revisione.", + "La copia esiste ma non rientra nel filtro della revisione.": "La copia esiste ma non rientra nel filtro della revisione.", + "La copia risulta in prestito: verifica il prestito.": "La copia risulta in prestito: verifica il prestito.", + "La revisione è stata chiusa da un altro operatore.": "La revisione è stata chiusa da un altro operatore.", + "Revisore inventario": "Revisore inventario", + "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.", + "Verifica le copie sugli scaffali": "Verifica le copie sugli scaffali", + "Copia dichiarata persa in revisione inventariale": "Copia dichiarata persa in revisione inventariale", + "Revisione inventariale aperta": "Revisione inventariale aperta", + "Revisione inventariale chiusa": "Revisione inventariale chiusa", + "Revisione inventariale annullata": "Revisione inventariale annullata" } From 0fa153e49e61e9a7146c19af8524fa6717c7d95f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:06:03 +0100 Subject: [PATCH 019/398] E2E: stocktake sessions driven from the admin pages --- tests/inventory-stocktake.spec.js | 381 ++++++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 tests/inventory-stocktake.spec.js diff --git a/tests/inventory-stocktake.spec.js b/tests/inventory-stocktake.spec.js new file mode 100644 index 000000000..aeffdcac9 --- /dev/null +++ b/tests/inventory-stocktake.spec.js @@ -0,0 +1,381 @@ +// @ts-check +// +// E2E — Inventory stocktake sessions (plan §8, /admin/inventory). +// +// Everything a librarian does is clicked in the browser: open a session on a +// shelf, read codes with the keyboard (a wedge scanner types and presses +// Enter), upload a list, watch the live counters, filter "only my scans", +// close marking the missing copies as lost (admin only) or leaving them, take +// the PDF/CSV report, cancel a session. The DB is used only to provision the +// fixtures and to verify the effects. +// +// Self-provisions an admin, a staff operator, two shelves, three books and +// their copies (tokenised), and removes them in afterAll. + +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const BASE = process.env.E2E_BASE_URL || process.env.APP_URL || 'http://localhost:8081'; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +test.skip(!DB_USER || !DB_NAME, 'E2E DB credentials not configured (E2E_DB_USER, E2E_DB_NAME)'); +test.describe.configure({ mode: 'serial' }); + +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', '-e', sql); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 15000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} +const S = (v) => "'" + String(v).replace(/'/g, "''") + "'"; +const phpHash = (pw) => execFileSync('php', ['-r', `echo password_hash(${JSON.stringify(pw)}, PASSWORD_DEFAULT);`], { encoding: 'utf-8' }).trim(); + +const RUN = crypto.randomBytes(4).toString('hex'); +const TAG = `ST${RUN.toUpperCase()}`; +const PASS = 'Stocktake!2026'; +const ADMIN = { email: `st-admin-${RUN}@stocktake.test.local`, id: 0 }; +const STAFF = { email: `st-staff-${RUN}@stocktake.test.local`, id: 0 }; +const code = (c) => `${TAG}-${c}`; +const fx = { shelfX: 0, shelfY: 0, books: {}, copies: {}, borrower: 0, loan: 0, sessions: [] }; + +function makeUser(user, role, name) { + dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata, locale) + VALUES (${S(TAG + role.slice(0, 2).toUpperCase())}, ${S(name)}, ${S(TAG)}, ${S(user.email)}, ${S(phpHash(PASS))}, ${S(role)}, 'attivo', 1, 1, 'it_IT')`); + user.id = Number(dbQuery(`SELECT id FROM utenti WHERE email=${S(user.email)}`)); +} + +async function login(page, user) { + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', user.email); + await page.fill('input[name="password"]', PASS); + await page.locator('form button[type="submit"]').first().click(); + await page.waitForURL((url) => !url.toString().includes('/accedi'), { timeout: 15000 }); +} + +/** Minimal RFC 4180 reader: quoted fields, doubled quotes, CRLF or LF. */ +function parseCsv(text) { + const rows = []; + let row = []; + let field = ''; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quoted) { + if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (ch === '"') { quoted = false; } else { field += ch; } + } else if (ch === '"') { quoted = true; } else if (ch === ',') { row.push(field); field = ''; } else if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i++; + row.push(field); rows.push(row); row = []; field = ''; + } else { field += ch; } + } + if (field !== '' || row.length) { row.push(field); rows.push(row); } + return rows; +} + +const lastSession = () => fx.sessions[fx.sessions.length - 1]; +const copyState = (c) => dbQuery(`SELECT stato FROM copie WHERE id=${fx.copies[c]}`); +const counter = (page, key) => page.locator(`#inventory-counters [data-counter="${key}"]`); + +/** Type a code like a wedge scanner (characters + Enter) and wait for the server's answer. */ +async function scan(page, value) { + const answered = page.waitForResponse((r) => r.url().includes('/scan') && r.request().method() === 'POST'); + await page.locator('#inventory-code').pressSequentially(value, { delay: 10 }); + await page.locator('#inventory-code').press('Enter'); + const response = await answered; + expect(response.status()).toBe(200); + const body = await response.json(); + await expect(page.locator('#inventory-feedback')).toHaveAttribute('data-esito', body.result.esito); + return body.result.esito; +} + +async function openSession(page, name, shelfId) { + await page.goto(`${BASE}/admin/inventory`); + await page.fill('#inventory-name', name); + if (shelfId) await page.selectOption('#inventory-shelf', String(shelfId)); + await page.locator('#inventory-open-form button[type="submit"]').click(); + await page.waitForURL(/\/admin\/inventory\/\d+$/, { timeout: 15000 }); + const id = Number(new URL(page.url()).pathname.split('/').pop()); + fx.sessions.push(id); + return id; +} + +async function confirmSwal(page) { + const button = page.locator('.swal2-confirm'); + await expect(button).toBeVisible({ timeout: 10000 }); + await button.click(); +} + +test.beforeAll(() => { + makeUser(ADMIN, 'admin', 'Admin'); + makeUser(STAFF, 'staff', 'Staff'); + for (const [key, letter] of [['shelfX', 'X'], ['shelfY', 'Y']]) { + dbQuery(`INSERT INTO scaffali (codice, nome, lettera) VALUES (${S(TAG + letter)}, ${S('Stocktake ' + letter)}, ${S(letter)})`); + fx[key] = Number(dbQuery(`SELECT id FROM scaffali WHERE codice=${S(TAG + letter)}`)); + } + const book = (key, shelf, copies) => { + const title = `${TAG} ${key}`; + dbQuery(`INSERT INTO libri (titolo, scaffale_id, collocazione, copie_totali, copie_disponibili) VALUES (${S(title)}, ${shelf}, ${S(TAG + '.' + key)}, ${copies.length}, ${copies.length})`); + fx.books[key] = Number(dbQuery(`SELECT id FROM libri WHERE titolo=${S(title)}`)); + for (const c of copies) { + dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (${fx.books[key]}, ${S(code(c))}, 'disponibile')`); + fx.copies[c] = Number(dbQuery(`SELECT id FROM copie WHERE numero_inventario=${S(code(c))}`)); + } + }; + book('B1', fx.shelfX, ['A1', 'A2', 'A3', 'A4']); + book('B2', fx.shelfX, ['L1']); + book('B3', fx.shelfY, ['Y1']); + // L1 is out on a real active loan. + dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata, locale) + VALUES (${S(TAG + 'BR')}, 'Borrower', ${S(TAG)}, ${S(`st-b-${RUN}@stocktake.test.local`)}, ${S(phpHash(PASS))}, 'standard', 'attivo', 1, 1, 'it_IT')`); + fx.borrower = Number(dbQuery(`SELECT id FROM utenti WHERE email=${S(`st-b-${RUN}@stocktake.test.local`)}`)); + dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo) + VALUES (${fx.books.B2}, ${fx.copies.L1}, ${fx.borrower}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 14 DAY), 'in_corso', 1)`); + fx.loan = Number(dbQuery(`SELECT id FROM prestiti WHERE copia_id=${fx.copies.L1} ORDER BY id DESC LIMIT 1`)); + dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${fx.copies.L1}`); + dbQuery(`UPDATE libri SET copie_disponibili=0 WHERE id=${fx.books.B2}`); +}); + +test.afterAll(() => { + if (fx.sessions.length) { + dbQuery(`DELETE FROM log_modifiche WHERE tabella='inventario_sessioni' AND record_id IN (${fx.sessions.join(',')})`); + dbQuery(`DELETE FROM inventario_sessioni WHERE id IN (${fx.sessions.join(',')})`); + } + const books = Object.values(fx.books); + if (books.length) { + dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id IN (${books.join(',')})`); + dbQuery(`DELETE FROM prestiti WHERE libro_id IN (${books.join(',')})`); + dbQuery(`DELETE FROM copie WHERE libro_id IN (${books.join(',')})`); + dbQuery(`DELETE FROM libri WHERE id IN (${books.join(',')})`); + } + dbQuery(`DELETE FROM scaffali WHERE codice IN (${S(TAG + 'X')}, ${S(TAG + 'Y')})`); + dbQuery(`DELETE FROM utenti WHERE email LIKE ${S(`%-${RUN}@stocktake.test.local`)}`); +}); + +test('1. the librarian opens a session on a shelf from the sidebar and the expected copies are frozen', async ({ page }) => { + await login(page, ADMIN); + await page.goto(`${BASE}/admin/dashboard`); + await page.locator('#sidebar a[href$="/admin/inventory"]').click(); + await expect(page).toHaveURL(/\/admin\/inventory$/); + const id = await openSession(page, `${TAG} scaffale X`, fx.shelfX); + await expect(page.locator('[role="alert"]').first()).toContainText('Revisione aperta'); + await expect(counter(page, 'attese')).toHaveText('5'); + await expect(counter(page, 'mancanti')).toHaveText('4'); + await expect(counter(page, 'in_prestito')).toHaveText('1'); + await expect(page.locator('#inventory-code')).toBeFocused(); + expect(Number(dbQuery(`SELECT totale_attese FROM inventario_sessioni WHERE id=${id}`))).toBe(5); + expect(dbQuery(`SELECT GROUP_CONCAT(copia_id ORDER BY copia_id) FROM inventario_attese WHERE sessione_id=${id}`)) + .toBe(['A1', 'A2', 'A3', 'A4', 'L1'].map((c) => fx.copies[c]).sort((a, b) => a - b).join(',')); + // The current-shelf selector starts on the session's shelf. + await expect(page.locator('#inventory-current-shelf')).toHaveValue(String(fx.shelfX)); +}); + +test('2. codes typed like a wedge scanner get the right outcome, colour and live counters', async ({ page }) => { + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await login(page, ADMIN); + await page.goto(`${BASE}/admin/inventory/${lastSession()}`); + // The self-hosted camera scanner (#238) is wired to the code field. + await expect(page.locator('button[data-copy-scan][data-copy-scan-target="inventory-code"]')).toHaveAttribute('data-copy-scan-bound', '1'); + expect(await scan(page, code('A1'))).toBe('trovata'); + await expect(page.locator('#inventory-feedback')).toHaveClass(/bg-green-50/); + await expect(counter(page, 'trovate')).toHaveText('1'); + expect(await scan(page, code('A1'))).toBe('duplicata'); + expect(await scan(page, code('Y1'))).toBe('fuori_ambito'); + expect(await scan(page, code('L1'))).toBe('in_prestito'); + expect(await scan(page, `${TAG}-NOPE`)).toBe('sconosciuta'); + await expect(page.locator('#inventory-feedback')).toHaveClass(/bg-red-50/); + // The operator moves to shelf Y and finds A2 there: misplaced. + await page.selectOption('#inventory-current-shelf', String(fx.shelfY)); + expect(await scan(page, code('A2'))).toBe('fuori_posto'); + await expect(page.locator('#inventory-feedback')).toContainText(`[${TAG}X]`); + await page.selectOption('#inventory-current-shelf', String(fx.shelfX)); + + await expect(counter(page, 'trovate')).toHaveText('1'); + await expect(counter(page, 'fuori_posto')).toHaveText('1'); + await expect(counter(page, 'in_prestito')).toHaveText('1'); + await expect(counter(page, 'mancanti')).toHaveText('2'); + await expect(counter(page, 'sconosciute')).toHaveText('1'); + await expect(counter(page, 'fuori_ambito')).toHaveText('1'); + await expect(counter(page, 'duplicate')).toHaveText('1'); + await expect(page.locator('#inventory-scans-body tr')).toHaveCount(6); + expect(dbQuery(`SELECT GROUP_CONCAT(esito ORDER BY id) FROM inventario_scansioni WHERE sessione_id=${lastSession()}`)) + .toBe('trovata,duplicata,fuori_ambito,in_prestito,sconosciuta,fuori_posto'); + expect(errors).toEqual([]); +}); + +test('3. an uploaded .txt list is recorded line by line', async ({ page }) => { + await login(page, ADMIN); + await page.goto(`${BASE}/admin/inventory/${lastSession()}`); + const file = path.join(os.tmpdir(), `stocktake-${RUN}.txt`); + fs.writeFileSync(file, `codice\r\n${code('A3')}\r\n${code('A3')}\r\n\r\n`); + try { + await page.setInputFiles('#inventory-file', file); + await page.locator('#inventory-upload-form button[type="submit"]').click(); + await page.waitForURL(new RegExp(`/admin/inventory/${lastSession()}$`)); + } finally { + fs.unlinkSync(file); + } + await expect(page.locator('[role="alert"]').first()).toContainText('Elaborati 2 codici dal file.'); + await expect(counter(page, 'trovate')).toHaveText('2'); + await expect(counter(page, 'duplicate')).toHaveText('2'); + await expect(counter(page, 'mancanti')).toHaveText('1'); +}); + +test('4. a second operator scans in parallel: live counters and "only my scans"', async ({ page, browser }) => { + const staffContext = await browser.newContext(); + const staffPage = await staffContext.newPage(); + try { + await login(staffPage, STAFF); + await staffPage.goto(`${BASE}/admin/inventory/${lastSession()}`); + expect(await scan(staffPage, `${TAG}-STAFF1`)).toBe('sconosciuta'); + await expect(staffPage.locator('[data-counter="mie"]')).toHaveText('1'); + } finally { + await staffContext.close(); + } + + await login(page, ADMIN); + await page.goto(`${BASE}/admin/inventory/${lastSession()}`); + await expect(counter(page, 'sconosciute')).toHaveText('2'); + await expect(page.locator('[data-counter="scansioni"]')).toHaveText('9'); + await expect(page.locator('[data-counter="mie"]')).toHaveText('8'); + await expect(page.locator('#inventory-scans-body')).toContainText(`${TAG}-STAFF1`); + + await page.locator('#inventory-mine').check(); + await expect(page.locator('#inventory-scans-body')).not.toContainText(`${TAG}-STAFF1`); + await expect(page.locator('#inventory-scans-body tr')).toHaveCount(8); + await expect(page).toHaveURL(/mine=1/); + await page.locator('#inventory-mine').uncheck(); + await expect(page.locator('#inventory-scans-body')).toContainText(`${TAG}-STAFF1`); +}); + +test('5. staff can scan but cannot close marking the missing copies as lost', async ({ page }) => { + await login(page, STAFF); + await page.goto(`${BASE}/admin/inventory/${lastSession()}`); + const lostRadio = page.locator('#inventory-close-form input[value="perso"]'); + await expect(lostRadio).toBeDisabled(); + await expect(page.locator('#inventory-close-form')).toContainText('Solo un amministratore'); + // Even a forged submission is refused on the server. + await page.evaluate(() => { + const radio = /** @type {HTMLInputElement} */ (document.querySelector('#inventory-close-form input[value="perso"]')); + radio.disabled = false; + radio.checked = true; + /** @type {HTMLFormElement} */ (document.getElementById('inventory-close-form')).submit(); + }); + await page.waitForURL(new RegExp(`/admin/inventory/${lastSession()}$`)); + await expect(page.locator('[role="alert"]').first()).toContainText('Solo un amministratore'); + expect(dbQuery(`SELECT stato FROM inventario_sessioni WHERE id=${lastSession()}`)).toBe('aperta'); + expect(copyState('A4')).toBe('disponibile'); +}); + +test('6. the admin closes marking the missing copy as lost: state, availability and audit log follow', async ({ page }) => { + await login(page, ADMIN); + await page.goto(`${BASE}/admin/inventory/${lastSession()}`); + await page.locator('#inventory-close-form input[value="perso"]').check(); + await page.locator('#inventory-close-form button[type="submit"]').click(); + await confirmSwal(page); + await page.waitForURL(new RegExp(`/admin/inventory/${lastSession()}$`)); + await expect(page.locator('[role="alert"]').first()).toContainText('1 copie mancanti segnate come perse'); + await expect(page.locator('#inventory-state')).toHaveText('Chiusa'); + await expect(page.locator('#inventory-code')).toHaveCount(0); + await expect(page.locator(`#inventory-report tr[data-categoria="mancante"]`)).toContainText(code('A4')); + + expect(copyState('A4')).toBe('perso'); + for (const c of ['A1', 'A2', 'A3']) expect(copyState(c)).toBe('disponibile'); + expect(copyState('L1')).toBe('prestato'); + expect(Number(dbQuery(`SELECT copie_disponibili FROM libri WHERE id=${fx.books.B1}`))).toBe(3); + expect(Number(dbQuery(`SELECT COUNT(*) FROM log_modifiche WHERE tabella='libri' AND record_id=${fx.books.B1} + AND JSON_UNQUOTE(JSON_EXTRACT(dati_nuovi, '$._activity.event'))='copy.inventory_lost'`))).toBe(1); + expect(dbQuery(`SELECT CONCAT(stato, ':', chiusa_da) FROM inventario_sessioni WHERE id=${lastSession()}`)).toBe(`chiusa:${ADMIN.id}`); + + // The book's activity feed shows the event to the librarian. + await page.goto(`${BASE}/admin/books/${fx.books.B1}`); + await expect(page.locator('body')).toContainText('Copia dichiarata persa in revisione inventariale'); +}); + +test('7. the PDF and CSV reports download with the expected content', async ({ page }) => { + await login(page, ADMIN); + await page.goto(`${BASE}/admin/inventory/${lastSession()}`); + const [csvDownload] = await Promise.all([page.waitForEvent('download'), page.locator('#inventory-csv').click()]); + const csv = fs.readFileSync(await csvDownload.path(), 'utf-8'); + expect(csvDownload.suggestedFilename()).toMatch(new RegExp(`^revisione-${lastSession()}-.*\\.csv$`)); + expect(csv.charCodeAt(0)).toBe(0xfeff); // BOM for spreadsheet apps + const rows = parseCsv(csv.slice(1)); + expect(rows[0].slice(0, 5)).toEqual(['Esito', 'Codice copia', 'Titolo', 'Collocazione', 'Scaffale atteso']); + const byCode = Object.fromEntries(rows.slice(1).map((r) => [r[1], r])); + expect(byCode[code('A4')].slice(0, 3)).toEqual(['Mancante', code('A4'), `${TAG} B1`]); + expect(byCode[code('A2')][0]).toBe('Fuori posto'); + expect(byCode[code('A2')][4]).toBe(`[${TAG}X] Stocktake X`); + expect(byCode[code('L1')][0]).toBe('In prestito'); + expect(byCode[`${TAG}-NOPE`][0]).toBe('Codice sconosciuto'); + expect(byCode[code('Y1')][0]).toBe('Fuori ambito'); + expect(rows.filter((r) => r[0] === 'Trovata').map((r) => r[1]).sort()).toEqual([code('A1'), code('A3')]); + + const pdf = await page.request.get(`${BASE}/admin/inventory/${lastSession()}/report.pdf`); + expect(pdf.status()).toBe(200); + expect(pdf.headers()['content-type']).toContain('application/pdf'); + const bytes = await pdf.body(); + expect(bytes.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + // The document title carries the session name (TCPDF writes it as UTF-16BE). + const name = `${TAG} scaffale X`; + const utf16be = Buffer.from(name, 'utf16le').swap16(); + expect(bytes.includes(utf16be) || bytes.includes(Buffer.from(name, 'latin1'))).toBe(true); +}); + +test('8. closing while leaving the missing copies as they are changes no copy', async ({ page }) => { + await login(page, ADMIN); + const id = await openSession(page, `${TAG} lascia`, fx.shelfX); + expect(await scan(page, code('A1'))).toBe('trovata'); + // A camera read fills the field and fires input/change, as copy-scanner.js does: it is recorded at once. + const cameraAnswer = page.waitForResponse((r) => r.url().includes('/scan') && r.request().method() === 'POST'); + await page.evaluate((value) => { + const input = /** @type {HTMLInputElement} */ (document.getElementById('inventory-code')); + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }, code('A2')); + expect((await (await cameraAnswer).json()).result.esito).toBe('trovata'); + await expect(page.locator('#inventory-code')).toHaveValue(''); + await expect(counter(page, 'trovate')).toHaveText('2'); + const before = dbQuery(`SELECT GROUP_CONCAT(CONCAT(id, ':', stato) ORDER BY id) FROM copie WHERE libro_id IN (${Object.values(fx.books).join(',')})`); + await page.locator('#inventory-close-form button[type="submit"]').click(); + await confirmSwal(page); + await page.waitForURL(new RegExp(`/admin/inventory/${id}$`)); + await expect(page.locator('[role="alert"]').first()).toContainText('le copie mancanti sono rimaste invariate'); + expect(dbQuery(`SELECT GROUP_CONCAT(CONCAT(id, ':', stato) ORDER BY id) FROM copie WHERE libro_id IN (${Object.values(fx.books).join(',')})`)).toBe(before); + expect(dbQuery(`SELECT stato FROM inventario_sessioni WHERE id=${id}`)).toBe('chiusa'); + expect(dbQuery(`SELECT JSON_UNQUOTE(JSON_EXTRACT(dati_nuovi, '$.azione_mancanti')) FROM log_modifiche WHERE tabella='inventario_sessioni' AND record_id=${id} + AND JSON_UNQUOTE(JSON_EXTRACT(dati_nuovi, '$._activity.event'))='inventory.closed'`)).toBe('nessuna'); +}); + +test('9. a session can be cancelled: it stays listed as cancelled and refuses scans', async ({ page }) => { + await login(page, ADMIN); + const id = await openSession(page, `${TAG} annulla`, fx.shelfY); + await expect(counter(page, 'attese')).toHaveText('1'); + await page.locator('#inventory-cancel-form button[type="submit"]').click(); + await confirmSwal(page); + await page.waitForURL(/\/admin\/inventory$/); + await expect(page.locator('[role="alert"]').first()).toContainText('Revisione annullata'); + await expect(page.locator(`#inventory-sessions tr[data-session-id="${id}"]`)).toContainText('Annullata'); + expect(dbQuery(`SELECT stato FROM inventario_sessioni WHERE id=${id}`)).toBe('annullata'); + expect(copyState('Y1')).toBe('disponibile'); + + const refused = await page.request.post(`${BASE}/admin/inventory/${id}/scan`, { + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': await page.evaluate(() => /** @type {HTMLMetaElement|null} */ (document.querySelector('meta[name="csrf-token"]'))?.content || '') }, + data: { codice: code('Y1') }, + }); + expect(refused.status()).toBe(422); + expect(Number(dbQuery(`SELECT COUNT(*) FROM inventario_scansioni WHERE sessione_id=${id}`))).toBe(0); +}); From c010a3a0a7e6089f7dab0b546e8d1169709e0a93 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:06:48 +0100 Subject: [PATCH 020/398] Changelog and permission checks for stocktake sessions --- CHANGELOG.md | 12 +++++++ tests/stocktake-permission.unit.php | 49 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/stocktake-permission.unit.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 54062985e..017d43ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ Full version-by-version history for Pinakes. The README shows only the last three releases; everything older lives here. +## [0.9.0] - Unreleased + +### Inventory stocktake +- **Stocktake sessions** (Admin → Revisione inventariale, `/admin/inventory`). A session is opened with a name, an optional branch and a filter (bookcase, shelf level, genre with its subgenres, media type, collocation range). Opening it freezes the list of expected copies in `inventario_attese`, books removed from the catalogue excluded, so moving a book, adding or deleting a copy while the stocktake runs does not change what the session looks for. +- **Scanning** works with keyboard-wedge barcode readers (the code field keeps the focus, Enter records), with the self-hosted camera scanner already used at the loan desk, and with an uploaded `.txt` (one code per line) or `.csv` (first column) list. Each read is classified as found, misplaced (found on a bookcase other than the expected one, as declared by "Scaffale in cui stai lavorando"), on loan, unknown code, out of scope or repeated, with its own colour and sound. Several operators can scan the same session at once: the counters refresh every few seconds and "Solo le mie scansioni" narrows the list to one's own reads. +- **Live report**: expected copies always add up as found + misplaced + on loan + missing; unknown, out-of-scope and repeated reads are counted on the side. PDF and CSV reports are available while the session is open and after it ends. +- **Closing** either leaves the missing copies as they are or marks them `perso`. Marking them lost is an administrator action, checked on the server: Staff and custom roles can scan and close a session, but not declare copies lost. Copies on loan, being restored, in transit, in maintenance or already lost are never touched; holds on a copy declared lost move to another copy as with a manual change of state; book availability is recalculated through `DataIntegrity`, and each copy gets a "Copia dichiarata persa in revisione inventariale" entry in the book's activity log. A session can also be cancelled without changing any copy. +- **Permission** `inventory.manage` for custom roles, and a new starting profile "Revisore inventario" (backend access + stocktakes). Staff and administrators have it already. + +### Migration +- `migrate_0.9.0.sql`, section "Inventory stocktake": creates `inventario_sessioni`, `inventario_attese` and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes. + ## [0.8.9] ### Security diff --git a/tests/stocktake-permission.unit.php b/tests/stocktake-permission.unit.php new file mode 100644 index 000000000..8aeaf19f2 --- /dev/null +++ b/tests/stocktake-permission.unit.php @@ -0,0 +1,49 @@ + 'standard', 'stato' => 'attivo', 'permissions' => ['backend.access', 'inventory.manage']]; +$check(P::allowsPath('/admin/inventory/3/scan', $reviewer, 'POST'), 'the stocktake role reaches the scan endpoint'); +foreach (['/admin/books', '/admin/copies/by-code', '/admin/loans', '/admin/users', '/admin/settings'] as $path) { + $check(!P::allowsPath($path, $reviewer), "the stocktake role does not reach {$path}"); +} +$cataloguer = ['tipo_utente' => 'standard', 'stato' => 'attivo', 'permissions' => ['backend.access', 'catalog.manage']]; +$check(!P::allowsPath('/admin/inventory', $cataloguer), 'cataloguing alone does not open stocktakes'); +$check(P::can('inventory.manage', ['tipo_utente' => 'staff', 'stato' => 'attivo']), 'Staff can run stocktakes'); +$check(P::can('inventory.manage', ['tipo_utente' => 'admin', 'stato' => 'attivo']), 'administrators can run stocktakes'); +$check(!P::can('inventory.manage', ['tipo_utente' => 'staff', 'stato' => 'sospeso']), 'a suspended operator cannot'); + +$check(RolePresets::displayName('Revisore inventario') !== '' && RolePresets::description('Revisore inventario') !== '', 'the preset has a display name and a description'); +$schema = (string) file_get_contents(dirname(__DIR__) . '/installer/database/schema.sql'); +$check(str_contains($schema, "('Revisore inventario', '[\"backend.access\",\"inventory.manage\"]')"), 'the preset grants backend access and stocktakes only'); + +echo "SUCCESS {$checks} stocktake permission checks\n"; From 794afb1213f0a7e20968eb6fa6d7586ac696860c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:09:36 +0100 Subject: [PATCH 021/398] Run every 0.9.0 migration section test from the release entry point --- tests/migration-0.9.0.unit.php | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/migration-0.9.0.unit.php diff --git a/tests/migration-0.9.0.unit.php b/tests/migration-0.9.0.unit.php new file mode 100644 index 000000000..ce8ef1a41 --- /dev/null +++ b/tests/migration-0.9.0.unit.php @@ -0,0 +1,37 @@ + ──"). Each section has its own behavioural test, + * tests/migration-0.9.0-.unit.php; this entry point, the name + * scripts/verify-schema.sh derives from version.json, runs them all and fails + * when one fails or when a section ships without its test. + * + * Run: php tests/migration-0.9.0.unit.php + */ + +$root = dirname(__DIR__); +$sql = (string) file_get_contents($root . '/installer/database/migrations/migrate_0.9.0.sql'); +preg_match_all('/^-- ── (?!end )(.+?) ──\s*$/mu', $sql, $m); +$sections = $m[1]; +$tests = glob($root . '/tests/migration-0.9.0-*.unit.php') ?: []; +sort($tests); +if ($sections === [] || $tests === []) { + fwrite(STDERR, "FAIL migrate_0.9.0.sql needs delimited sections, each with a tests/migration-0.9.0-.unit.php\n"); + exit(1); +} +if (count($tests) < count($sections)) { + fwrite(STDERR, sprintf("FAIL %d migration sections (%s) but only %d section tests\n", count($sections), implode(', ', $sections), count($tests))); + exit(1); +} +$php = PHP_BINARY; +foreach ($tests as $test) { + echo '── ' . basename($test) . "\n"; + passthru(escapeshellarg($php) . ' ' . escapeshellarg($test), $code); + if ($code !== 0) { + fwrite(STDERR, 'FAIL ' . basename($test) . " exited {$code}\n"); + exit(1); + } +} +echo 'SUCCESS ' . count($tests) . ' migration section tests for ' . count($sections) . " sections\n"; From 271d6d6cc1aa2d1f84927a41f0ba554d35c0ee76 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:11:13 +0100 Subject: [PATCH 022/398] Show the session note on the stocktake page --- app/Views/inventario/show.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/Views/inventario/show.php b/app/Views/inventario/show.php index 12efa343a..92639fea7 100644 --- a/app/Views/inventario/show.php +++ b/app/Views/inventario/show.php @@ -59,6 +59,9 @@ · :

      + +

      + Date: Wed, 28 Oct 2026 02:34:39 +0100 Subject: [PATCH 023/398] README: lead for v0.9.0, v0.8.9 becomes a highlight --- README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 35167b7c4..5a379cf47 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,17 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri [![Documentation](https://img.shields.io/badge/Documentazione-Docsify-4285f4?style=for-the-badge&logo=readthedocs&logoColor=white)](https://fabiodalez-dev.github.io/Pinakes/) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/fabiodalez) -## What's New in v0.8.9 +## What's New in v0.9.0 -**Search Console verification, a pickup reminder, and an SEO pass.** Settings → Advanced has a field for the `` verification tags of Google, Bing and the other search engines ([#497](https://github.com/fabiodalez-dev/Pinakes/pull/497)). Readers get a reminder before the pickup deadline passes, "Loan approved" can say how many days they have to pick the book up, and the staff is told when a pickup expires ([#498](https://github.com/fabiodalez-dev/Pinakes/pull/498)); the new approval text ships to new installations only, existing ones keep their template and can add `{{giorni_ritiro}}` and `{{scadenza_ritiro}}`. The 404 page is no longer indexed, empty pages past the last one answer 404, structured data and the sitemap are more complete, and the loan request calendar fills its row. One additive migration (`migrate_0.8.9.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**. +**Stocktake, MARC in and out, and the circulation tools of a full library system.** One additive migration (`migrate_0.9.0.sql`); an installation that never opens the new pages and settings behaves exactly as before. Full details in **[CHANGELOG.md](CHANGELOG.md)**. + +- **Inventory stocktake** (`/admin/inventory`): sessions over a bookcase, shelf, genre or collocation range, scanning by barcode reader, camera or uploaded list, a live found / misplaced / on loan / missing report with PDF and CSV, and an optional "mark missing as lost" reserved to administrators. +- **MARC bulk import and export, copy cataloguing**: ISO 2709 and MARCXML (MARC 21, UNIMARC, danMARC2, MARC-8) imported in chunks with a preview, the catalogue or a selection exported as MARC 21, MARCXML or UNIMARC, and records searched and imported from other catalogues over SRU. +- **Visitor log, catalogue cards, suppliers and RDA types**: a kiosk visitor log with GDPR retention, ISBD catalogue cards as PDF, a suppliers register linked to books, RDA content/media/carrier overrides, and new statistics sections. +- **Library cards and user import**: printable patron cards (CR80 or A4 sheets, Code128 or QR), patron photo from the webcam, and user import from CSV with column mapping. +- **Patron types and circulation rules**: loan duration, renewals, request window and limits per patron type and material, from one circulation policy. +- **Closures calendar**: weekly closed days and closures; due dates and pickup deadlines move to the next open day. +- **Fines and patron ledger**: optional late fines with grace days and a cap, an append-only ledger with payments, waivers and receipts, and an optional debt block. --- @@ -53,13 +61,13 @@ The showcase contains 50 catalogue books, 8 desiderata, 3 periodicals with 10 is Highlights of the two releases before the current one are below; the README never lists more than the last three versions. The full version-by-version history lives in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.8.8 +### v0.8.9 -**A French sentence that now includes the last day of a loan.** "retour avant le D" left out the return day the server still accepts; it now says "retour au plus tard le D" ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)). No migration. +**Search Console verification, a pickup reminder, and an SEO pass.** Settings → Advanced has a field for the `` verification tags of Google, Bing and the other search engines ([#497](https://github.com/fabiodalez-dev/Pinakes/pull/497)). Readers get a reminder before the pickup deadline passes, "Loan approved" can say how many days they have to pick the book up, and the staff is told when a pickup expires ([#498](https://github.com/fabiodalez-dev/Pinakes/pull/498)); the new approval text ships to new installations only, existing ones keep their template and can add `{{giorni_ritiro}}` and `{{scadenza_ritiro}}`. The 404 page is no longer indexed, empty pages past the last one answer 404, structured data and the sitemap are more complete, and the loan request calendar fills its row. One additive migration (`migrate_0.8.9.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.8.7 +### v0.8.8 -**A security notice for PHP, loan windows that explain themselves, and Desiderata codes that follow the book.** CVE-2026-109618 and CVE-2026-109619 are in the PHP runtime, not in Pinakes: the dashboard and the updates page tell administrators when their server's PHP is older than 8.5.12, 8.4.27, 8.3.36 or 8.2.35 ([#491](https://github.com/fabiodalez-dev/Pinakes/issues/491)). Loan requests stop before a scheduled loan and say the longest possible loan ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)), renewals say until when they are possible ([#493](https://github.com/fabiodalez-dev/Pinakes/pull/493)), and Desiderata copies get the book's inventory prefix ([#494](https://github.com/fabiodalez-dev/Pinakes/pull/494)). No migration; see **[CHANGELOG.md](CHANGELOG.md)**. +**A French sentence that now includes the last day of a loan.** "retour avant le D" left out the return day the server still accepts; it now says "retour au plus tard le D" ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)). No migration. Older releases are in **[CHANGELOG.md](CHANGELOG.md)**. From 16fe1517e32d14b0f535c134de88dc99eeb0dc96 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 04:06:27 +0100 Subject: [PATCH 024/398] Stocktake: claim the session before closing, count the summary in SQL close() and cancel() now flip the session state with one conditional UPDATE before doing anything else and stop when it changes no row, so a concurrent cancel or second close cannot also succeed and no copy is marked lost on a session that ends up cancelled. Scans are written under a shared lock on the session row, so a scan either lands before the close and is seen by it, or is refused. summary() counts the missing and lent copies with one aggregate query instead of loading every unscanned expected copy on each scan and poll. --- app/Services/StocktakeService.php | 112 +++++++++++++++++++++--------- tests/stocktake-snapshot.unit.php | 48 +++++++++++++ 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index 18d610d0d..443bb38be 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -442,16 +442,28 @@ private function record(int $sessionId, string $code, ?int $operatorId, ?int $sc $outcome = self::classify($copyId !== null, $alreadyScanned, is_array($expected), $onLoan, $expectedShelf, $scanShelf); $storedCopyId = in_array($outcome, ['sconosciuta', 'duplicata'], true) ? null : $copyId; + // The scan is written under a shared lock on the session row. close() + // and cancel() flip the state with an UPDATE that waits for that lock, + // so a scan either lands before the session leaves 'aperta' (and close() + // sees it before marking copies lost) or is refused afterwards. + $this->db->begin_transaction(); try { - $this->insertScan($sessionId, $storedCopyId, $code, $outcome, $operatorId); - } catch (\mysqli_sql_exception $e) { - // Two operators scanned the same copy at the same instant: the UNIQUE - // (sessione_id, copia_id) let one win, the other is a duplicate. - if ($e->getCode() !== 1062 || $storedCopyId === null) { - throw $e; + $this->lockOpenSession($sessionId); + try { + $this->insertScan($sessionId, $storedCopyId, $code, $outcome, $operatorId); + } catch (\mysqli_sql_exception $e) { + // Two operators scanned the same copy at the same instant: the UNIQUE + // (sessione_id, copia_id) let one win, the other is a duplicate. + if ($e->getCode() !== 1062 || $storedCopyId === null) { + throw $e; + } + $outcome = 'duplicata'; + $this->insertScan($sessionId, null, $code, $outcome, $operatorId); } - $outcome = 'duplicata'; - $this->insertScan($sessionId, null, $code, $outcome, $operatorId); + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollback(); + throw $e; } return [ @@ -466,6 +478,42 @@ private function record(int $sessionId, string $code, ?int $operatorId, ?int $sc ]; } + private function lockOpenSession(int $sessionId): void + { + $stmt = $this->db->prepare('SELECT stato FROM inventario_sessioni WHERE id = ? LOCK IN SHARE MODE'); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('i', $sessionId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_row(); + $stmt->close(); + if ($row === null || $row[0] !== 'aperta') { + throw new \DomainException(__('La revisione non è più aperta: non si possono registrare altre scansioni.')); + } + } + + /** + * Move an open session to $state in one conditional UPDATE. Only the caller + * whose UPDATE changes the row may go on: a concurrent close or cancel that + * lost the race gets a DomainException and must not touch anything. + */ + private function claimTransition(int $sessionId, string $state, ?int $operatorId): void + { + $stmt = $this->db->prepare("UPDATE inventario_sessioni SET stato = ?, chiusa_da = ?, chiusa_il = NOW() WHERE id = ? AND stato = 'aperta'"); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('sii', $state, $operatorId, $sessionId); + $stmt->execute(); + $claimed = $stmt->affected_rows === 1; + $stmt->close(); + if (!$claimed) { + $this->assertOpen($sessionId); // "not found" when the row is gone + throw new \DomainException(__('La revisione non è più aperta: non si possono registrare altre scansioni.')); + } + } + private function insertScan(int $sessionId, ?int $copyId, string $code, string $outcome, ?int $operatorId): void { $stmt = $this->db->prepare( @@ -540,15 +588,26 @@ public function summary(int $sessionId, ?int $operatorId = null): array } $stmt->close(); - $unscannedOnLoan = 0; - $missing = 0; - foreach ($this->unscannedExpected($sessionId) as $row) { - if ((int) $row['in_prestito'] === 1) { - $unscannedOnLoan++; - } else { - $missing++; - } + // Counted in SQL: the scan page asks for this on every scan and every + // poll, so it must not pull the unscanned rows into PHP. A deleted copy + // (c.stato NULL) is not on loan and stays among the missing ones. + $stmt = $this->db->prepare( + 'SELECT COUNT(*) AS n, COALESCE(SUM(t.in_prestito), 0) AS lent FROM (' + . "SELECT COALESCE(c.stato = 'prestato' OR EXISTS (SELECT 1 FROM prestiti p WHERE p.copia_id = a.copia_id AND p.attivo = 1 AND p.stato IN ('in_corso','in_ritardo')), 0) AS in_prestito " + . 'FROM inventario_attese a ' + . 'LEFT JOIN inventario_scansioni s ON s.sessione_id = a.sessione_id AND s.copia_id = a.copia_id ' + . 'LEFT JOIN copie c ON c.id = a.copia_id ' + . 'WHERE a.sessione_id = ? AND s.id IS NULL) t' + ); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); } + $stmt->bind_param('i', $sessionId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + $unscannedOnLoan = (int) ($row['lent'] ?? 0); + $missing = (int) ($row['n'] ?? 0) - $unscannedOnLoan; return [ 'attese' => $this->expectedCount($sessionId), @@ -675,7 +734,10 @@ public function scans(int $sessionId, ?int $operatorId = null, int $limit = 200) */ public function close(int $sessionId, bool $markLost, ?int $operatorId): array { - $this->assertOpen($sessionId); + // Claim the session first: from here on scans are refused, and a + // concurrent cancel or second close cannot also succeed. Marking copies + // lost reads inventario_scansioni, not the session state. + $this->claimTransition($sessionId, 'chiusa', $operatorId); $marked = 0; $failed = 0; @@ -705,13 +767,6 @@ public function close(int $sessionId, bool $markLost, ?int $operatorId): array } $summary = $this->summary($sessionId); - $stmt = $this->db->prepare("UPDATE inventario_sessioni SET stato = 'chiusa', chiusa_da = ?, chiusa_il = NOW() WHERE id = ? AND stato = 'aperta'"); - if ($stmt === false) { - throw new \RuntimeException($this->db->error); - } - $stmt->bind_param('ii', $operatorId, $sessionId); - $stmt->execute(); - $stmt->close(); ActivityLog::recordEntityEvent($this->db, 'inventario_sessioni', $sessionId, 'inventory.closed', [], [ 'azione_mancanti' => $markLost ? 'perso' : 'nessuna', @@ -835,14 +890,7 @@ private function markBookCopiesLost(int $bookId, array $copies, int $sessionId, public function cancel(int $sessionId, ?int $operatorId): void { - $this->assertOpen($sessionId); - $stmt = $this->db->prepare("UPDATE inventario_sessioni SET stato = 'annullata', chiusa_da = ?, chiusa_il = NOW() WHERE id = ? AND stato = 'aperta'"); - if ($stmt === false) { - throw new \RuntimeException($this->db->error); - } - $stmt->bind_param('ii', $operatorId, $sessionId); - $stmt->execute(); - $stmt->close(); + $this->claimTransition($sessionId, 'annullata', $operatorId); ActivityLog::recordEntityEvent($this->db, 'inventario_sessioni', $sessionId, 'inventory.cancelled', [], [], 'aggiornamento', 'inventory', $operatorId); } } diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index d52a4a69e..8c54b80ae 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -47,6 +47,7 @@ $shelves = []; $books = []; $sessionIds = []; +$triggers = []; try { foreach (['X', 'Y'] as $tag) { $db->query("INSERT INTO scaffali (codice, nome, lettera) VALUES ('{$p}{$tag}', 'Stocktake {$tag}', '{$tag}')"); @@ -103,7 +104,16 @@ $categories = array_count_values(array_column($service->reportRows($sessionId), 'categoria')); $check(($categories['mancante'] ?? 0) === 2 && ($categories['trovata'] ?? 0) === 1 && ($categories['in_prestito'] ?? 0) === 1, 'the report rows list the same buckets'); + // Probe: record the session state at the instant a copy turns 'perso'. + // close() must claim the session before it marks anything, so scans that + // arrive while copies are being marked are refused (review #504). + $probe = 'zzst_probe_' . strtolower($run); + $db->query("SET @zzst_session = {$sessionId}, @zzst_state_at_lost = NULL"); + $db->query("CREATE TRIGGER {$probe} BEFORE UPDATE ON copie FOR EACH ROW BEGIN IF NEW.stato = 'perso' AND OLD.stato <> 'perso' AND @zzst_state_at_lost IS NULL THEN SET @zzst_state_at_lost = (SELECT stato FROM inventario_sessioni WHERE id = @zzst_session); END IF; END"); + $triggers[] = $probe; $result = $service->close($sessionId, true, null); + $db->query("DROP TRIGGER IF EXISTS {$probe}"); + $check($one('SELECT @zzst_state_at_lost') === 'chiusa', 'the session is already closed when the first copy is marked lost'); $check($result === ['marked' => 1, 'failed' => 0], 'closing with "mark lost" marks only C6 (C2 no longer exists, C3 is lent)'); $check($one("SELECT stato FROM copie WHERE id = {$c6}") === 'perso', 'C6 is now lost'); $check($one("SELECT stato FROM copie WHERE id = {$c1}") === 'disponibile' && $one("SELECT stato FROM copie WHERE id = {$c3}") === 'prestato', 'found and lent copies are untouched'); @@ -126,8 +136,46 @@ $service->cancel($second, null); $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$second}") === 'annullata' && $one("SELECT stato FROM copie WHERE id = {$c5}") === 'disponibile', 'cancelling leaves every copy as it was'); + // A close or cancel that loses the race against another transition does nothing. + $refused = static function (callable $fn): bool { + try { + $fn(); + return false; + } catch (DomainException) { + return true; + } + }; + $check($refused(static fn () => $service->close($second, true, null)), 'closing a cancelled session is refused'); + $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$second}") === 'annullata' && $one("SELECT stato FROM copie WHERE id = {$c5}") === 'disponibile', 'the refused close marks nothing and keeps the cancelled state'); + $check($refused(static fn () => $service->cancel($sessionId, null)), 'cancelling a closed session is refused'); + $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$sessionId}") === 'chiusa', 'the closed session stays closed'); + $check((int) $one("SELECT COUNT(*) FROM log_modifiche WHERE tabella = 'inventario_sessioni' AND record_id IN ({$sessionId}, {$second}) AND JSON_UNQUOTE(JSON_EXTRACT(dati_nuovi, '$._activity.event')) IN ('inventory.closed', 'inventory.cancelled')") === 2, 'only the winning transitions are logged'); + + // A scan in flight while another connection is closing the session waits + // for the close instead of landing on a closed session. + $third = $service->open('ZZST3 ' . $run, null, ['collocazione_da' => "{$p}.B2", 'collocazione_a' => "{$p}.B2"], null); + $sessionIds[] = $third; + $other = new mysqli($env['DB_HOST'] ?? '127.0.0.1', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null)); + $other->begin_transaction(); + $other->query("UPDATE inventario_sessioni SET stato = 'chiusa' WHERE id = {$third} AND stato = 'aperta'"); + $db->query('SET SESSION innodb_lock_wait_timeout = 1'); + try { + $service->scan($third, "{$p}-C5", null, null); + $blocked = false; + } catch (\Throwable) { + $blocked = true; + } + $db->query('SET SESSION innodb_lock_wait_timeout = DEFAULT'); + $other->commit(); + $other->close(); + $check($blocked && (int) $one("SELECT COUNT(*) FROM inventario_scansioni WHERE sessione_id = {$third}") === 0, 'a scan racing a pending close is not recorded'); + $check($refused(static fn () => $service->scan($third, "{$p}-C5", null, null)), 'once the close commits the scan is refused'); + echo "SUCCESS {$checks} stocktake snapshot checks\n"; } finally { + foreach ($triggers as $trigger) { + $db->query("DROP TRIGGER IF EXISTS {$trigger}"); + } foreach ($sessionIds as $sid) { $db->query("DELETE FROM log_modifiche WHERE tabella = 'inventario_sessioni' AND record_id = {$sid}"); $db->query("DELETE FROM inventario_sessioni WHERE id = {$sid}"); From 607db85049536a230d170f014cb5b111bd16d526 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 00:34:50 +0100 Subject: [PATCH 025/398] Record the OAI-PMH MARC serializations as golden files The OAI plugin's MARCXML, UNIMARC/MARCXchange and ISO 2709 output is about to move onto a shared MARC library in core. These goldens are taken from the current code, so the move can be proved byte for byte. --- .gitignore | 1 + .../marc/oai-golden/minimal.marcxml.xml | 2 + .../marc/oai-golden/minimal.unimarc.mrc | 1 + .../marc/oai-golden/minimal.unimarc.xml | 2 + .../marc/oai-golden/monograph.marcxml.xml | 2 + .../marc/oai-golden/monograph.unimarc.mrc | 1 + .../marc/oai-golden/monograph.unimarc.xml | 2 + .../marc/oai-golden/serial.marcxml.xml | 2 + .../marc/oai-golden/serial.unimarc.mrc | 1 + .../marc/oai-golden/serial.unimarc.xml | 2 + tests/marc-oai-byte-identity.unit.php | 140 ++++++++++++++++++ 11 files changed, 156 insertions(+) create mode 100644 tests/fixtures/marc/oai-golden/minimal.marcxml.xml create mode 100644 tests/fixtures/marc/oai-golden/minimal.unimarc.mrc create mode 100644 tests/fixtures/marc/oai-golden/minimal.unimarc.xml create mode 100644 tests/fixtures/marc/oai-golden/monograph.marcxml.xml create mode 100644 tests/fixtures/marc/oai-golden/monograph.unimarc.mrc create mode 100644 tests/fixtures/marc/oai-golden/monograph.unimarc.xml create mode 100644 tests/fixtures/marc/oai-golden/serial.marcxml.xml create mode 100644 tests/fixtures/marc/oai-golden/serial.unimarc.mrc create mode 100644 tests/fixtures/marc/oai-golden/serial.unimarc.xml create mode 100644 tests/marc-oai-byte-identity.unit.php diff --git a/.gitignore b/.gitignore index 13ce1ae29..2a9be0c06 100644 --- a/.gitignore +++ b/.gitignore @@ -473,6 +473,7 @@ tests/fixtures/* !tests/fixtures/*.tsv !tests/fixtures/sru/ !tests/fixtures/htaccess-history/ +!tests/fixtures/marc/ !tests/helpers/ tests/helpers/* !tests/helpers/*.php diff --git a/tests/fixtures/marc/oai-golden/minimal.marcxml.xml b/tests/fixtures/marc/oai-golden/minimal.marcxml.xml new file mode 100644 index 000000000..26b31293e --- /dev/null +++ b/tests/fixtures/marc/oai-golden/minimal.marcxml.xml @@ -0,0 +1,2 @@ + +00000nam a2200000 45001IT-Pinakes20220203020202.0220202s xx d0306406152IT-PinakesitardaIT-PinakesPlatoneauthorRossi, MarioillustratorÆgir — ein ÖlbuchLaterzan.d.Serie0317-8471 diff --git a/tests/fixtures/marc/oai-golden/minimal.unimarc.mrc b/tests/fixtures/marc/oai-golden/minimal.unimarc.mrc new file mode 100644 index 000000000..57be228a2 --- /dev/null +++ b/tests/fixtures/marc/oai-golden/minimal.unimarc.mrc @@ -0,0 +1 @@ +00459nam 2200193 45000010002000000030029000020050017000310100015000480730018000631000041000811010008001221020007001302000035001372100018001722250010001907000017002007020022002178010026002391https://library.example.org/20220203020202.0 a0306406152 a4006381333931 a20220202u y0itay50 ba0 aund aIT1 aÆgir — ein ÖlbuchfPlatone cLaterzadn.d.0 aSerie 0aPlatone4070 1aRossibMario4440 0aITbPinakescYYYYMMDD \ No newline at end of file diff --git a/tests/fixtures/marc/oai-golden/minimal.unimarc.xml b/tests/fixtures/marc/oai-golden/minimal.unimarc.xml new file mode 100644 index 000000000..f43010014 --- /dev/null +++ b/tests/fixtures/marc/oai-golden/minimal.unimarc.xml @@ -0,0 +1,2 @@ + +00000nam 2200000 45001https://library.example.org/20220203020202.00306406152400638133393120220202u y0itay50 baundITÆgir — ein ÖlbuchPlatoneLaterzan.d.SeriePlatone070RossiMario440ITPinakesYYYYMMDD diff --git a/tests/fixtures/marc/oai-golden/monograph.marcxml.xml b/tests/fixtures/marc/oai-golden/monograph.marcxml.xml new file mode 100644 index 000000000..f278bca55 --- /dev/null +++ b/tests/fixtures/marc/oai-golden/monograph.marcxml.xml @@ -0,0 +1,2 @@ + +00000nam a2200000 45004711IT-Pinakes20250602111213.0240301s1980 xx ita d97888452926138845292614IT-PinakesitardaIT-Pinakesita853.91423Umberto EcoauthorMario RossiauthorWilliam WeavertranslatorLùcia FerréeditorIl nome della rosaRomanzo «storico»Seconda edizioneMilanoBompianiFabbri Editori1980503 pages21 cmTascabili12Un monaco &amp; un delitto nell’abbazia.NarrativamedioevogialloabbaziaUgo Disegniillustrator diff --git a/tests/fixtures/marc/oai-golden/monograph.unimarc.mrc b/tests/fixtures/marc/oai-golden/monograph.unimarc.mrc new file mode 100644 index 000000000..e2a14fe70 --- /dev/null +++ b/tests/fixtures/marc/oai-golden/monograph.unimarc.mrc @@ -0,0 +1 @@ +00788nam 2200289 45000010005000000030029000050050017000340100018000511000041000691010008001101020007001182000057001252050021001822100043002032150018002462250018002643300047002826060014003296060013003436060011003566060012003677000022003797010022004017020025004237020024004488010026004724711https://library.example.org/20250602111213.0 a9788845292613 a20240301d1980 y0itay50 ba0 aita aIT1 aIl nome della rosaeRomanzo «storico»fUmberto Eco aSeconda edizione aMilanocBompianicFabbri Editorid1980 a503 p.d21 cm0 aTascabiliv12 aUn monaco & un delitto nell’abbazia. aNarrativa amedioevo agiallo aabbazia 1aEcobUmberto4070 1aRossibMario4070 1aWeaverbWilliam4730 1aFerrébLùcia4340 0aITbPinakescYYYYMMDD \ No newline at end of file diff --git a/tests/fixtures/marc/oai-golden/monograph.unimarc.xml b/tests/fixtures/marc/oai-golden/monograph.unimarc.xml new file mode 100644 index 000000000..40aea7433 --- /dev/null +++ b/tests/fixtures/marc/oai-golden/monograph.unimarc.xml @@ -0,0 +1,2 @@ + +00000nam 2200000 45004711https://library.example.org/20250602111213.0978884529261320240301d1980 y0itay50 baitaITIl nome della rosaRomanzo «storico»Umberto EcoSeconda edizioneMilanoBompianiFabbri Editori1980503 p.21 cmTascabili12Un monaco &amp; un delitto nell’abbazia.NarrativamedioevogialloabbaziaEcoUmberto070RossiMario070WeaverWilliam730FerréLùcia340ITPinakesYYYYMMDD diff --git a/tests/fixtures/marc/oai-golden/serial.marcxml.xml b/tests/fixtures/marc/oai-golden/serial.marcxml.xml new file mode 100644 index 000000000..c79650ffc --- /dev/null +++ b/tests/fixtures/marc/oai-golden/serial.marcxml.xml @@ -0,0 +1,2 @@ + +00000nas a2200000 45009IT-Pinakes20230102000000.0230101c 9999xx eng d1234-5679IT-PinakesitardaIT-PinakesengRivista di studitraduzione di Ann TranslatorAnn TranslatortranslatorEd Itoreditor diff --git a/tests/fixtures/marc/oai-golden/serial.unimarc.mrc b/tests/fixtures/marc/oai-golden/serial.unimarc.mrc new file mode 100644 index 000000000..0b416b7ca --- /dev/null +++ b/tests/fixtures/marc/oai-golden/serial.unimarc.mrc @@ -0,0 +1 @@ +00273nam 2200121 45000010002000000030029000020050017000311000041000481010008000891020007000972000021001048010026001259https://library.example.org/20230102000000.0 a20230101u y0itay50 ba0 aeng aIT1 aRivista di studi 0aITbPinakescYYYYMMDD \ No newline at end of file diff --git a/tests/fixtures/marc/oai-golden/serial.unimarc.xml b/tests/fixtures/marc/oai-golden/serial.unimarc.xml new file mode 100644 index 000000000..1630e13d7 --- /dev/null +++ b/tests/fixtures/marc/oai-golden/serial.unimarc.xml @@ -0,0 +1,2 @@ + +00000nam 2200000 45009https://library.example.org/20230102000000.020230101u y0itay50 baengITRivista di studiITPinakesYYYYMMDD diff --git a/tests/marc-oai-byte-identity.unit.php b/tests/marc-oai-byte-identity.unit.php new file mode 100644 index 000000000..7b2a1d2b4 --- /dev/null +++ b/tests/marc-oai-byte-identity.unit.php @@ -0,0 +1,140 @@ +set_charset('utf8mb4'); +if (!isset($_ENV['APP_CANONICAL_URL'])) { + $_ENV['APP_CANONICAL_URL'] = 'https://library.example.org'; +} + +$plugin = new OaiPmhServerPlugin($db, new HookManager($db)); +$call = static function (string $method, array $args) use ($plugin) { + $m = new ReflectionMethod(OaiPmhServerPlugin::class, $method); + return $m->invokeArgs($plugin, $args); +}; +$xml = static function (string $method, array $args) use ($call): string { + $xw = new XMLWriter(); + $xw->openMemory(); + $xw->startDocument('1.0', 'UTF-8'); + $call($method, array_merge([$xw], $args)); + $xw->endDocument(); + return $xw->outputMemory(); +}; + +$cases = [ + 'monograph' => [ + [ + 'id' => 4711, 'titolo' => 'Il nome della rosa', 'sottotitolo' => 'Romanzo «storico»', + 'isbn13' => '9788845292613', 'isbn10' => '8845292614', 'ean' => '9788845292613', 'issn' => '', + 'lingua' => 'italiano', 'classificazione_dewey' => '853.914', 'edizione' => 'Seconda edizione', + 'luogo_pubblicazione' => 'Milano', 'anno_pubblicazione' => '1980', 'numero_pagine' => '503', + 'dimensioni' => '21 cm', 'collana' => 'Tascabili', 'numero_serie' => '12', + 'descrizione' => '

      Un monaco & un delitto nell’abbazia.

      ', 'descrizione_plain' => '', + 'parole_chiave' => 'medioevo, giallo, abbazia', 'traduttore' => '', 'illustratore' => 'Ugo Disegni', 'curatore' => '', + 'tipo_media' => 'libro', 'created_at' => '2024-03-01 10:00:00', 'updated_at' => '2025-06-02 11:12:13', + ], + [ + ['nome' => 'Umberto Eco', 'ruolo' => 'principale'], + ['nome' => 'Mario Rossi', 'ruolo' => 'co-autore'], + ['nome' => 'William Weaver', 'ruolo' => 'traduttore'], + ['nome' => 'Lùcia Ferré', 'ruolo' => 'curatore'], + ], + [['nome' => 'Bompiani'], ['nome' => 'Fabbri Editori']], + ['id' => 3, 'nome' => 'Narrativa'], + ], + 'serial' => [ + [ + 'id' => 9, 'titolo' => 'Rivista di studi', 'sottotitolo' => '', 'isbn13' => '', 'isbn10' => '', + 'ean' => '', 'issn' => '1234-5679', 'lingua' => 'inglese, italiano', 'classificazione_dewey' => '', + 'edizione' => '', 'luogo_pubblicazione' => '', 'anno_pubblicazione' => '', 'numero_pagine' => '', + 'dimensioni' => '', 'collana' => '', 'numero_serie' => '', 'descrizione' => '', 'descrizione_plain' => '', + 'parole_chiave' => '', 'traduttore' => 'Ann Translator', 'illustratore' => '', 'curatore' => 'Ed Itor', + 'tipo_media' => 'rivista', 'created_at' => '2023-01-01 00:00:00', 'updated_at' => '2023-01-02 00:00:00', + ], + [], + [], + null, + ], + 'minimal' => [ + [ + 'id' => 1, 'titolo' => 'Ægir — ein Ölbuch', 'isbn13' => '', 'isbn10' => '0306406152', 'ean' => '4006381333931', + 'lingua' => '', 'anno_pubblicazione' => 'n.d.', 'collana' => 'Serie', 'issn' => '0317-8471', + 'created_at' => '2022-02-02 02:02:02', 'updated_at' => '2022-02-03 02:02:02', + ], + [['nome' => 'Platone', 'ruolo' => 'principale'], ['nome' => 'Rossi, Mario', 'ruolo' => 'illustratore']], + [['nome' => 'Laterza']], + null, + ], +]; + +$today = gmdate('Ymd'); +$normalise = static fn (string $s): string => str_replace($today, 'YYYYMMDD', $s); + +$pass = 0; +$fail = 0; +foreach ($cases as $label => [$row, $authors, $publishers, $genre]) { + $outputs = [ + "{$label}.marcxml.xml" => $xml('writeBookMarcXml', [$row, $authors, $publishers, $genre]), + "{$label}.unimarc.xml" => $xml('writeBookUnimarc', [$row, $authors, $publishers, $genre]), + "{$label}.unimarc.mrc" => $call('bookToIso2709', [$row, $authors, $publishers, $genre]), + ]; + foreach ($outputs as $file => $bytes) { + $bytes = $normalise((string) $bytes); + $path = $goldenDir . '/' . $file; + if ($record) { + file_put_contents($path, $bytes); + echo " REC {$file} (" . strlen($bytes) . " bytes)\n"; + continue; + } + $golden = is_file($path) ? (string) file_get_contents($path) : null; + if ($golden !== null && $golden === $bytes) { + $pass++; + echo " OK {$file} identical (" . strlen($bytes) . " bytes)\n"; + } else { + $fail++; + echo " FAIL {$file} differs from the golden file\n"; + } + } +} +if ($record) { + exit(0); +} +echo "\n{$pass} PASS, {$fail} FAIL\n"; +exit($fail === 0 ? 0 : 1); From 0e408e5096fadcaef728ae4f104c8b27a941b1ec Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 04:06:30 +0100 Subject: [PATCH 026/398] Stocktake E2E cleans up sessions it never saw; changelog heading spacing --- CHANGELOG.md | 2 ++ tests/inventory-stocktake.spec.js | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 017d43ce6..ba36d4597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre ## [0.9.0] - Unreleased ### Inventory stocktake + - **Stocktake sessions** (Admin → Revisione inventariale, `/admin/inventory`). A session is opened with a name, an optional branch and a filter (bookcase, shelf level, genre with its subgenres, media type, collocation range). Opening it freezes the list of expected copies in `inventario_attese`, books removed from the catalogue excluded, so moving a book, adding or deleting a copy while the stocktake runs does not change what the session looks for. - **Scanning** works with keyboard-wedge barcode readers (the code field keeps the focus, Enter records), with the self-hosted camera scanner already used at the loan desk, and with an uploaded `.txt` (one code per line) or `.csv` (first column) list. Each read is classified as found, misplaced (found on a bookcase other than the expected one, as declared by "Scaffale in cui stai lavorando"), on loan, unknown code, out of scope or repeated, with its own colour and sound. Several operators can scan the same session at once: the counters refresh every few seconds and "Solo le mie scansioni" narrows the list to one's own reads. - **Live report**: expected copies always add up as found + misplaced + on loan + missing; unknown, out-of-scope and repeated reads are counted on the side. PDF and CSV reports are available while the session is open and after it ends. @@ -12,6 +13,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Permission** `inventory.manage` for custom roles, and a new starting profile "Revisore inventario" (backend access + stocktakes). Staff and administrators have it already. ### Migration + - `migrate_0.9.0.sql`, section "Inventory stocktake": creates `inventario_sessioni`, `inventario_attese` and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes. ## [0.8.9] diff --git a/tests/inventory-stocktake.spec.js b/tests/inventory-stocktake.spec.js index aeffdcac9..143165e19 100644 --- a/tests/inventory-stocktake.spec.js +++ b/tests/inventory-stocktake.spec.js @@ -149,9 +149,16 @@ test.beforeAll(() => { }); test.afterAll(() => { - if (fx.sessions.length) { - dbQuery(`DELETE FROM log_modifiche WHERE tabella='inventario_sessioni' AND record_id IN (${fx.sessions.join(',')})`); - dbQuery(`DELETE FROM inventario_sessioni WHERE id IN (${fx.sessions.join(',')})`); + // A session saved before a navigation failed never reaches fx.sessions: + // collect every session opened by the test users or named after this run too. + const users = [ADMIN.id, STAFF.id].filter(Boolean); + const found = dbQuery(`SELECT id FROM inventario_sessioni WHERE nome LIKE ${S(TAG + '%')}` + + (users.length ? ` OR aperta_da IN (${users.join(',')})` : '')).split('\n').filter(Boolean).map(Number); + const sessions = [...new Set([...fx.sessions, ...found])]; + if (sessions.length) { + // inventario_attese and inventario_scansioni go with the session (ON DELETE CASCADE). + dbQuery(`DELETE FROM log_modifiche WHERE tabella='inventario_sessioni' AND record_id IN (${sessions.join(',')})`); + dbQuery(`DELETE FROM inventario_sessioni WHERE id IN (${sessions.join(',')})`); } const books = Object.values(fx.books); if (books.length) { From 4bb9513b8a2b8399bcabb636473640958ac22df3 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 00:51:38 +0100 Subject: [PATCH 027/398] Add a shared MARC library in core App\Support\Marc holds a neutral MarcRecord, streaming ISO 2709 and MARCXML readers and writers (MARC-8 to UTF-8 for Basic and Extended Latin, malformed records reported one by one without stopping the file), format detection, and mappers from MARC 21, UNIMARC and danMARC2 to book data. BookMarcBuilder turns a book into a MARC 21 or UNIMARC record for the OAI server and the bulk export alike. RelatorRoles, the danMARC2 reader and the UNIMARC reader move here from the z39-server plugin so the importer can share them. --- app/Support/Marc/BookMarcBuilder.php | 552 +++++++++++++++++++++++ app/Support/Marc/ContributorSorter.php | 68 +++ app/Support/Marc/Danmarc2LibriMapper.php | 34 ++ app/Support/Marc/Danmarc2Parser.php | 250 ++++++++++ app/Support/Marc/Iso2709Reader.php | 197 ++++++++ app/Support/Marc/Iso2709Writer.php | 97 ++++ app/Support/Marc/Marc21LibriMapper.php | 172 +++++++ app/Support/Marc/Marc8Decoder.php | 126 ++++++ app/Support/Marc/MarcBookData.php | 184 ++++++++ app/Support/Marc/MarcField.php | 76 ++++ app/Support/Marc/MarcFormat.php | 87 ++++ app/Support/Marc/MarcLanguage.php | 88 ++++ app/Support/Marc/MarcLibriMapper.php | 15 + app/Support/Marc/MarcParseException.php | 9 + app/Support/Marc/MarcReadError.php | 30 ++ app/Support/Marc/MarcRecord.php | 183 ++++++++ app/Support/Marc/MarcXmlReader.php | 139 ++++++ app/Support/Marc/MarcXmlWriter.php | 137 ++++++ app/Support/Marc/RelatorRoles.php | 206 +++++++++ app/Support/Marc/UnimarcLibriMapper.php | 92 ++++ app/Support/Marc/UnimarcLibriParser.php | 293 ++++++++++++ 21 files changed, 3035 insertions(+) create mode 100644 app/Support/Marc/BookMarcBuilder.php create mode 100644 app/Support/Marc/ContributorSorter.php create mode 100644 app/Support/Marc/Danmarc2LibriMapper.php create mode 100644 app/Support/Marc/Danmarc2Parser.php create mode 100644 app/Support/Marc/Iso2709Reader.php create mode 100644 app/Support/Marc/Iso2709Writer.php create mode 100644 app/Support/Marc/Marc21LibriMapper.php create mode 100644 app/Support/Marc/Marc8Decoder.php create mode 100644 app/Support/Marc/MarcBookData.php create mode 100644 app/Support/Marc/MarcField.php create mode 100644 app/Support/Marc/MarcFormat.php create mode 100644 app/Support/Marc/MarcLanguage.php create mode 100644 app/Support/Marc/MarcLibriMapper.php create mode 100644 app/Support/Marc/MarcParseException.php create mode 100644 app/Support/Marc/MarcReadError.php create mode 100644 app/Support/Marc/MarcRecord.php create mode 100644 app/Support/Marc/MarcXmlReader.php create mode 100644 app/Support/Marc/MarcXmlWriter.php create mode 100644 app/Support/Marc/RelatorRoles.php create mode 100644 app/Support/Marc/UnimarcLibriMapper.php create mode 100644 app/Support/Marc/UnimarcLibriParser.php diff --git a/app/Support/Marc/BookMarcBuilder.php b/app/Support/Marc/BookMarcBuilder.php new file mode 100644 index 000000000..e27210b60 --- /dev/null +++ b/app/Support/Marc/BookMarcBuilder.php @@ -0,0 +1,552 @@ + $row + * @param list> $authors + * @param list> $publishers + * @param array|null $genre + */ + public static function marc21(array $row, array $authors, array $publishers, ?array $genre, bool $extended = false): MarcRecord + { + $isSerial = self::isSerialRecord($row); + // Leader: type 'a' (language material), bibliographic level 'm' + // (monograph), 's' for a serial. Leader/18 stays blank: the record + // carries no ISBD punctuation, so 'i' (ISBD) would be a false claim. + $record = new MarcRecord('00000na' . ($isSerial ? 's' : 'm') . ' a2200000 4500'); + + // 001 — Control number (book id); 003 — MARC organization code + $record->addControlField('001', (string) $row['id']); + $record->addControlField('003', 'IT-Pinakes'); + // 005 — Date/time of latest transaction + $ts = strtotime((string) ($row['updated_at'] ?? 'now')) ?: time(); + $record->addControlField('005', gmdate('YmdHis', $ts) . '.0'); + // 008 — Fixed-length data elements (exactly 40 characters) + $record->addControlField('008', self::marc21Field008($row)); + + // 020 — ISBN + if (!empty($row['isbn13'])) { + $record->addDataField('020', ' ', ' ', [['a', (string) $row['isbn13']]]); + } + if (!empty($row['isbn10'])) { + $record->addDataField('020', ' ', ' ', [['a', (string) $row['isbn10']]]); + } + + // 022 — ISSN of the resource itself: serials only. On a monograph the + // ISSN belongs to its series and is carried in 490 $x below. + if (!empty($row['issn']) && $isSerial) { + $record->addDataField('022', ' ', ' ', [['a', (string) $row['issn']]]); + } + + // 024 — EAN (extended), when it is not the ISBN-13 again + if ($extended) { + $ean = trim((string) ($row['ean'] ?? '')); + if ($ean !== '' && preg_replace('/\D/', '', $ean) !== preg_replace('/\D/', '', (string) ($row['isbn13'] ?? ''))) { + $record->addDataField('024', '3', ' ', [['a', $ean]]); + } + } + + // 040 — Cataloging source + $record->addDataField('040', ' ', ' ', [ + ['a', 'IT-Pinakes'], + ['b', 'ita'], + ['e', 'rda'], + ['c', 'IT-Pinakes'], + ]); + + // 041 — Language + if (!empty($row['lingua'])) { + $record->addDataField('041', '0', ' ', [['a', MarcLanguage::marcCodeOf((string) $row['lingua'])]]); + } + + // 050 — LC classification (extended) + if ($extended && trim((string) ($row['lc_classification'] ?? '')) !== '') { + $record->addDataField('050', ' ', '4', [['a', trim((string) $row['lc_classification'])]]); + } + + // 082 — Dewey classification + if (!empty($row['classificazione_dewey'])) { + $record->addDataField('082', '0', '4', [ + ['a', (string) $row['classificazione_dewey']], + ['2', '23'], + ]); + } + + // 100/700 — creators and role-aware contributors. + $primaryCreatorIndex = self::primaryCreatorIndex($authors); + foreach ($authors as $index => $a) { + $name = (string) $a['nome']; + $relator = match ((string) ($a['ruolo'] ?? '')) { + 'traduttore' => 'translator', + 'illustratore' => 'illustrator', + 'curatore' => 'editor', + 'colorista' => 'colorist', + default => 'author', + }; + $record->addDataField($index === $primaryCreatorIndex ? '100' : '700', '1', ' ', [ + ['a', $name], + ['e', $relator], + ]); + } + + // 245 — Title statement + $titleSubs = [['a', (string) ($row['titolo'] ?? '')]]; + if (!empty($row['sottotitolo'])) { + $titleSubs[] = ['b', (string) $row['sottotitolo']]; + } + $entityRoles = array_map(static fn (array $a): string => (string) ($a['ruolo'] ?? ''), $authors); + if (!empty($row['traduttore']) && !in_array('traduttore', $entityRoles, true)) { + $titleSubs[] = ['c', 'traduzione di ' . (string) $row['traduttore']]; + } + $record->addDataField('245', $primaryCreatorIndex !== null ? '1' : '0', '0', $titleSubs); + + // 250 — Edition + if (!empty($row['edizione'])) { + $record->addDataField('250', ' ', ' ', [['a', (string) $row['edizione']]]); + } + + // 264 — Production/Publication (RDA). MARC21 $b (publisher name) is + // repeatable, so multiple publishers become repeated $b in one 264. + $pubSubs = []; + if (!empty($row['luogo_pubblicazione'])) { + $pubSubs[] = ['a', (string) $row['luogo_pubblicazione']]; + } + foreach ($publishers as $pub) { + if (!empty($pub['nome'])) { + $pubSubs[] = ['b', (string) $pub['nome']]; + } + } + if (!empty($row['anno_pubblicazione'])) { + $pubSubs[] = ['c', (string) $row['anno_pubblicazione']]; + } + if ($pubSubs !== []) { + $record->addDataField('264', ' ', '1', $pubSubs); + } + + // 300 — Physical description + $physSubs = []; + if (!empty($row['numero_pagine'])) { + $physSubs[] = ['a', (string) $row['numero_pagine'] . ' pages']; + } + if (!empty($row['dimensioni'])) { + $physSubs[] = ['c', (string) $row['dimensioni']]; + } + if ($physSubs !== []) { + $record->addDataField('300', ' ', ' ', $physSubs); + } + + // 490 — Series statement + if (!empty($row['collana'])) { + $serSubs = [['a', (string) $row['collana']]]; + if (!empty($row['issn']) && !$isSerial) { + $serSubs[] = ['x', (string) $row['issn']]; + } + if (!empty($row['numero_serie'])) { + $serSubs[] = ['v', (string) $row['numero_serie']]; + } + $record->addDataField('490', '0', ' ', $serSubs); + } + + // 520 — Summary + $desc = self::plainDescription($row); + if ($desc !== null) { + $record->addDataField('520', ' ', ' ', [['a', $desc]]); + } + + // 650 — Subject: genre, then keywords + if ($genre !== null && !empty($genre['nome'])) { + $record->addDataField('650', ' ', '4', [['a', (string) $genre['nome']]]); + } + foreach (self::keywords($row) as $kw) { + $record->addDataField('650', ' ', '4', [['a', $kw]]); + } + + // 700 — legacy contributors not already represented by entities. + foreach (['traduttore' => 'translator', 'illustratore' => 'illustrator', 'curatore' => 'editor'] as $col => $role) { + if (!empty($row[$col]) && !in_array($col, $entityRoles, true)) { + $record->addDataField('700', '1', ' ', [ + ['a', (string) $row[$col]], + ['e', $role], + ]); + } + } + + // 856 — cover image (extended) + if ($extended) { + $cover = self::coverUrl($row); + if ($cover !== '') { + $record->addDataField('856', '4', '2', [['3', 'Cover image'], ['u', $cover]]); + } + } + + return $record; + } + + /** + * @param array $row + * @param list> $authors + * @param list> $publishers + * @param array|null $genre + */ + public static function unimarc(array $row, array $authors, array $publishers, ?array $genre, bool $extended = false): MarcRecord + { + $record = new MarcRecord(self::UNIMARC_LEADER_TEMPLATE); + $record->setFormat('UNIMARC'); + + // 001 — Record identifier + $record->addControlField('001', (string) $row['id']); + // 003 — Persistent record identifier source (repository base URL) + $record->addControlField('003', function_exists('absoluteUrl') ? \absoluteUrl('/') : '/'); + // 005 — Version identifier: date/time of the latest modification + $updated = strtotime((string) ($row['updated_at'] ?? '')) ?: time(); + $record->addControlField('005', gmdate('YmdHis', $updated) . '.0'); + + // 010 — ISBN only. 073 — EAN (an EAN that is just the ISBN-13 again + // is not repeated). + foreach (['isbn13', 'isbn10'] as $col) { + if (!empty($row[$col])) { + $record->addDataField('010', ' ', ' ', [['a', (string) $row[$col]]]); + break; + } + } + $ean = trim((string) ($row['ean'] ?? '')); + $isbn13Digits = preg_replace('/\D/', '', (string) ($row['isbn13'] ?? '')); + if ($ean !== '' && preg_replace('/\D/', '', $ean) !== $isbn13Digits) { + $record->addDataField('073', ' ', ' ', [['a', $ean]]); + } + + // 100 — General processing data + $langCode = MarcLanguage::marcCodeOf((string) ($row['lingua'] ?? 'italiano')); + $record->addDataField('100', ' ', ' ', [['a', self::unimarcField100a($row)]]); + + // 101 — Language of the item; 102 — Country of publication + $record->addDataField('101', '0', ' ', [['a', $langCode]]); + $record->addDataField('102', ' ', ' ', [['a', 'IT']]); + + // 200 — Title and statement of responsibility + $subs200 = [['a', (string) ($row['titolo'] ?? '')]]; + if (!empty($row['sottotitolo'])) { + $subs200[] = ['e', (string) $row['sottotitolo']]; + } + $primaryCreatorIndex = self::primaryCreatorIndex($authors); + if ($primaryCreatorIndex !== null) { + $subs200[] = ['f', (string) $authors[$primaryCreatorIndex]['nome']]; + } + $record->addDataField('200', '1', ' ', $subs200); + + // 205 — Edition statement + if (!empty($row['edizione'])) { + $record->addDataField('205', ' ', ' ', [['a', (string) $row['edizione']]]); + } + + // 210 — Publication, distribution. $c (publisher name) is repeatable, + // so multiple publishers become repeated $c in one 210. + $subs210 = []; + if (!empty($row['luogo_pubblicazione'])) { + $subs210[] = ['a', (string) $row['luogo_pubblicazione']]; + } + foreach ($publishers as $pub) { + if (!empty($pub['nome'])) { + $subs210[] = ['c', (string) $pub['nome']]; + } + } + $year = (string) ($row['anno_pubblicazione'] ?? ''); + if ($year !== '') { + $subs210[] = ['d', $year]; + } + if ($subs210 !== []) { + $record->addDataField('210', ' ', ' ', $subs210); + } + + // 215 — Physical description + $subs215 = []; + if (!empty($row['numero_pagine'])) { + $subs215[] = ['a', $row['numero_pagine'] . ' p.']; + } + if (!empty($row['dimensioni'])) { + $subs215[] = ['d', (string) $row['dimensioni']]; + } + if ($subs215 !== []) { + $record->addDataField('215', ' ', ' ', $subs215); + } + + // 225 — Series (UNIMARC equivalent of MARC21 490) + if (!empty($row['collana'])) { + $subs225 = [['a', (string) $row['collana']]]; + if (!empty($row['numero_serie'])) { + $subs225[] = ['v', (string) $row['numero_serie']]; + } + $record->addDataField('225', '0', ' ', $subs225); + } + + // 330 — Summary / abstract + $desc = self::plainDescription($row); + if ($desc !== null) { + $record->addDataField('330', ' ', ' ', [['a', $desc]]); + } + + // 606 — Subject: genre, then keywords + if ($genre !== null && !empty($genre['nome'])) { + $record->addDataField('606', ' ', ' ', [['a', (string) $genre['nome']]]); + } + foreach (self::keywords($row) as $kw) { + $record->addDataField('606', ' ', ' ', [['a', $kw]]); + } + + // 676 — Dewey (extended) + if ($extended && !empty($row['classificazione_dewey'])) { + $record->addDataField('676', ' ', ' ', [['a', (string) $row['classificazione_dewey']]]); + } + + // 700/701 — primary and alternative intellectual responsibility. + // 702 — secondary responsibility (translator, illustrator, etc.). + $responsibility = []; + foreach ($authors as $index => $a) { + $role = (string) ($a['ruolo'] ?? ''); + $isCreator = in_array($role, ['principale', 'co-autore'], true); + $tag = $isCreator ? ($index === $primaryCreatorIndex ? '700' : '701') : '702'; + $relCode = match ($role) { + 'traduttore' => '730', + 'curatore' => '340', + 'illustratore' => '440', + 'colorista' => '410', + default => '070', + }; + [$ind2, $nameSubs] = self::unimarcPersonalName((string) $a['nome']); + $nameSubs[] = ['4', $relCode]; + $responsibility[$tag][] = [$ind2, $nameSubs]; + } + foreach (['700', '701', '702'] as $tag) { + foreach ($responsibility[$tag] ?? [] as [$ind2, $nameSubs]) { + $record->addDataField($tag, ' ', $ind2, $nameSubs); + } + } + + // 801 — Originating source + $record->addDataField('801', ' ', '0', [ + ['a', 'IT'], + ['b', 'Pinakes'], + ['c', gmdate('Ymd')], + ]); + + // 856 — cover image (extended) + if ($extended) { + $cover = self::coverUrl($row); + if ($cover !== '') { + $record->addDataField('856', '4', ' ', [['u', $cover], ['2', 'http'], ['z', 'Cover image']]); + } + } + + return $record; + } + + /** + * The index of the book's primary creator: the first 'principale', else + * the first 'co-autore'; null when the book has neither. + * + * @param list> $authors + */ + public static function primaryCreatorIndex(array $authors): ?int + { + $coauthorIndex = null; + foreach ($authors as $index => $author) { + $role = (string) ($author['ruolo'] ?? ''); + if ($role === 'principale') { + return $index; + } + if ($role === 'co-autore' && $coauthorIndex === null) { + $coauthorIndex = $index; + } + } + return $coauthorIndex; + } + + /** Whether a libri row describes a serial (MARC bibliographic level 's'). */ + public static function isSerialRecord(array $row): bool + { + return in_array(strtolower((string) ($row['tipo_media'] ?? '')), ['periodical', 'serial', 'periodico', 'rivista'], true); + } + + /** + * MARC 21 008 for books/serials: exactly 40 positions. + * 00-05 date entered (YYMMDD), 06 date type, 07-10 date 1, 11-14 date 2, + * 15-17 place of publication, 35-37 language, 39 cataloguing source. + * + * @param array $row + */ + public static function marc21Field008(array $row): string + { + $field = str_repeat(' ', 40); + $created = strtotime((string) ($row['created_at'] ?? '')) ?: time(); + $field = substr_replace($field, date('ymd', $created), 0, 6); + + $isSerial = self::isSerialRecord($row); + $field = substr_replace($field, $isSerial ? 'c' : 's', 6, 1); + + $year = trim((string) ($row['anno_pubblicazione'] ?? '')); + if ($year !== '' && ctype_digit($year) && strlen($year) <= 4) { + $field = substr_replace($field, str_pad($year, 4, '0', STR_PAD_LEFT), 7, 4); + } + if ($isSerial) { + // Continuing resource still published: date 2 = 9999. + $field = substr_replace($field, '9999', 11, 4); + } + + // Place of publication unknown/unspecified. + $field = substr_replace($field, 'xx ', 15, 3); + + if (trim((string) ($row['lingua'] ?? '')) !== '') { + $field = substr_replace($field, MarcLanguage::marcCodeOf((string) $row['lingua']), 35, 3); + } + + // 39 — cataloguing source: 'd' (other than a national agency). + return substr_replace($field, 'd', 39, 1); + } + + /** + * UNIMARC 100 $a — general processing data, exactly 36 positions: + * 0-7 date entered (YYYYMMDD) · 8 type of publication date (d single + * known date, u unknown) · 9-12 date 1 · 13-16 date 2 · 17-19 target + * audience · 20 government publication (y not a government + * publication) · 21 modified record (0) · 22-24 language of + * cataloguing · 25 transliteration (y none) · 26-29 character set + * (50 = ISO 10646) · 30-33 additional character sets · 34-35 script + * of title (ba = Latin). + * + * @param array $row + */ + public static function unimarcField100a(array $row): string + { + $entered = strtotime((string) ($row['created_at'] ?? '')) ?: time(); + $year = trim((string) ($row['anno_pubblicazione'] ?? '')); + $known = strlen($year) === 4 && ctype_digit($year); + + return date('Ymd', $entered) + . ($known ? 'd' : 'u') + . ($known ? $year : ' ') + . ' ' + . ' ' + . 'y' + . '0' + . self::cataloguingLanguage() + . 'y' + . '50 ' + . ' ' + . 'ba'; + } + + /** ISO 639-2/B language of cataloguing, from the installation locale. */ + public static function cataloguingLanguage(): string + { + try { + $locale = \App\Support\I18n::getInstallationLocale(); + } catch (\Throwable) { + $locale = 'it_IT'; + } + return match (strtolower(substr($locale, 0, 2))) { + 'en' => 'eng', + 'de' => 'ger', + 'fr' => 'fre', + 'da' => 'dan', + default => 'ita', + }; + } + + /** + * UNIMARC personal name (7XX): [ind2, subfields]. A name that can be + * inverted ("Surname, Forename", or a multi-word name whose last word is + * taken as the surname) is entered under the surname — ind2 1, $a surname + * $b forename; a single-word name stays in direct order — ind2 0, $a. + * + * @return array{0: string, 1: list} + */ + public static function unimarcPersonalName(string $name): array + { + $name = trim(preg_replace('/\s+/u', ' ', $name) ?? $name); + if (str_contains($name, ',')) { + [$surname, $forename] = array_map('trim', explode(',', $name, 2)); + if ($surname !== '' && $forename !== '') { + return ['1', [['a', $surname], ['b', $forename]]]; + } + } + $words = $name === '' ? [] : explode(' ', $name); + if (count($words) >= 2) { + $surname = (string) array_pop($words); + return ['1', [['a', $surname], ['b', implode(' ', $words)]]]; + } + return ['0', [['a', $name]]]; + } + + /** + * The summary as plain text; null when the book has none (a description + * that is only markup still yields the empty summary it always did). + * + * @param array $row + */ + private static function plainDescription(array $row): ?string + { + $desc = !empty($row['descrizione_plain']) ? $row['descrizione_plain'] : ($row['descrizione'] ?? ''); + return $desc !== '' ? strip_tags((string) $desc) : null; + } + + /** + * @param array $row + * @return list + */ + private static function keywords(array $row): array + { + $out = []; + if (!empty($row['parole_chiave'])) { + foreach (explode(',', (string) $row['parole_chiave']) as $kw) { + $kw = trim($kw); + if ($kw !== '') { + $out[] = $kw; + } + } + } + return $out; + } + + /** + * The absolute URL of the book's cover (stored as a site path such as + * /uploads/copertine/x.jpg, or already absolute). + * + * @param array $row + */ + public static function coverUrl(array $row): string + { + $url = trim((string) ($row['copertina_url'] ?? '')); + if ($url === '' || preg_match('#^https?://#i', $url) === 1) { + return $url; + } + return function_exists('absoluteUrl') ? \absoluteUrl('/' . ltrim($url, '/')) : $url; + } +} diff --git a/app/Support/Marc/ContributorSorter.php b/app/Support/Marc/ContributorSorter.php new file mode 100644 index 000000000..bcc404275 --- /dev/null +++ b/app/Support/Marc/ContributorSorter.php @@ -0,0 +1,68 @@ + $book + * @param array> $contributors + */ + public static function sortContributor(array &$book, array &$contributors, string $name, string $role): void + { + if ($role === 'author') { + if (!in_array($name, $book['authors'], true)) { + $book['authors'][] = $name; + } + } elseif (isset($contributors[$role]) && !in_array($name, $contributors[$role], true)) { + $contributors[$role][] = $name; + } + } + + /** + * Editors as a list, translator, illustrator and colorist as the first + * name: the shape the book form reads. + * + * @param array $book + * @param array{editor: list, translator: list, illustrator: list, colorist: list} $contributors + */ + public static function applyContributors(array &$book, array $contributors): void + { + if ($contributors['editor'] !== []) { + $book['editor'] = $contributors['editor']; + } + foreach (['translator', 'illustrator', 'colorist'] as $role) { + if ($contributors[$role] !== []) { + $book[$role] = $contributors[$role][0]; + } + } + } +} diff --git a/app/Support/Marc/Danmarc2LibriMapper.php b/app/Support/Marc/Danmarc2LibriMapper.php new file mode 100644 index 000000000..fd504f7d8 --- /dev/null +++ b/app/Support/Marc/Danmarc2LibriMapper.php @@ -0,0 +1,34 @@ +format()), 'danmarc')) { + $copy = MarcRecord::fromArray($record->toArray()); + $copy->setFormat('danMARC2'); + $record = $copy; + } + $doc = new \DOMDocument(); + if (!$doc->loadXML(MarcXmlWriter::recordToXml($record, MarcXmlWriter::NS_MARCXCHANGE), LIBXML_NONET) + || !$doc->documentElement instanceof \DOMElement + ) { + return MarcBookData::empty(); + } + $book = Danmarc2Parser::parse( + new \DOMXPath($doc), + $doc->documentElement, + static fn (string $code): string => MarcLanguage::nameFor($code) + ); + $data = MarcBookData::fromScrape($book); + $data['copertina_url'] = MarcBookData::coverUrlOf($record); + return $data; + } +} diff --git a/app/Support/Marc/Danmarc2Parser.php b/app/Support/Marc/Danmarc2Parser.php new file mode 100644 index 000000000..3deb3e550 --- /dev/null +++ b/app/Support/Marc/Danmarc2Parser.php @@ -0,0 +1,250 @@ +`), turned into the book data the form reads. + * + * danMARC2 shares tag numbers with MARC 21 but not its subfields, so neither + * the MARC 21 nor the UNIMARC reader can read it (kat-format.dk, 2nd edition): + * + * 021 *e ISBN-13, *a ISBN-10 + * 008 *a year, *l language (ISO 639-2/B) + * 100 / 700 *a surname, *h forenames, *k forenames in full, *4 function + * codes (appendix J: the Library of Congress list plus dk… codes; "led" + * only marks the main responsibility), *b function term ("red.") + * 720 *o a name in direct order ("Hanna Lützen"), *4 function code + * 245 *a title, *b rest of title, *c subtitle, *e / *f statement of + * responsibility + * 250 *a edition 260 *a place, *b publisher, *c year 300 *a extent + * 440 *a series, *v number 504 *a summary 666 *s / *o subject terms + * + * Shared by the SRU client and the MARC bulk importer; the z39-server plugin + * keeps its class name as a subclass. + */ +class Danmarc2Parser +{ + /** A MARCXchange record that declares itself danMARC2. */ + public static function isDanmarc2(DOMNode $record): bool + { + return $record instanceof \DOMElement && stripos($record->getAttribute('format'), 'danmarc') !== false; + } + + /** + * The danMARC2 record of the response that carries the ISBN searched for, + * else the first one. A Danish catalogue answers an ISBN with every + * manifestation it groups with it (the audiobook, the e-book, other + * printings), not only the edition asked about. + */ + public static function selectRecord(DOMXPath $xpath, string $isbn): ?DOMNode + { + $records = $xpath->query("//*[local-name()='record'][@format]"); + if ($records === false) { + return null; + } + $first = null; + $isbn = strtoupper((string) preg_replace('/[^0-9Xx]/', '', $isbn)); + foreach ($records as $record) { + if (!self::isDanmarc2($record)) { + continue; + } + $first ??= $record; + if ($isbn === '') { + break; + } + foreach (array_merge(self::all($xpath, $record, '021', 'e'), self::all($xpath, $record, '021', 'a')) as $value) { + if (strtoupper((string) preg_replace('/[^0-9Xx]/', '', $value)) === $isbn) { + return $record; + } + } + } + return $first; + } + + /** @return array */ + public static function parse(DOMXPath $xpath, DOMNode $record, callable $languageName): array + { + $get = static fn(string $tag, string $code): ?string => self::all($xpath, $record, $tag, $code)[0] ?? null; + + $book = [ + 'title' => '', + 'subtitle' => '', + 'authors' => [], + 'publisher' => '', + 'pubDate' => '', + 'year' => '', + 'isbn13' => '', + 'isbn10' => '', + 'language' => '', + 'pages' => '', + 'description' => '', + 'classificazione_dewey' => '', + 'source' => 'Z39.50/SRU (danMARC2)', + ]; + + $title = trim(implode(' ', array_filter([$get('245', 'a'), $get('245', 'b')]))); + $book['title'] = self::clean($title); + $book['subtitle'] = self::clean((string) $get('245', 'c')); + + // Names: 100 is the main author; 700 and 720 follow their function + // codes, their term or the statement of responsibility. + $statement = implode(' ; ', array_merge(self::all($xpath, $record, '245', 'e'), self::all($xpath, $record, '245', 'f'))); + $contributors = ['editor' => [], 'translator' => [], 'illustrator' => [], 'colorist' => []]; + foreach (self::fields($xpath, $record, '100') as $field) { + $name = self::invertedName($field); + if ($name !== '') { + ContributorSorter::sortContributor($book, $contributors, $name, 'author'); + } + } + foreach (['700', '720'] as $tag) { + foreach (self::fields($xpath, $record, $tag) as $field) { + $subs = self::subfields($field); + $name = $tag === '720' && isset($subs['o']) ? self::clean($subs['o'][0]) : self::invertedName($field); + if ($name === '' || isset($subs['t'])) { + // *t makes the field a name-title entry: a work, not a person's part + continue; + } + // *b is the function term ("red."); *f, the addition to the + // name, often says it too ("pædagogisk note") + $terms = array_merge($subs['b'] ?? [], $subs['f'] ?? []); + $term = $terms !== [] ? implode(' ', $terms) : null; + $role = RelatorRoles::resolve($subs['4'] ?? [], $term, $statement !== '' ? $statement : null, $name, 'marc21'); + ContributorSorter::sortContributor($book, $contributors, $name, $role); + } + } + ContributorSorter::applyContributors($book, $contributors); + + $edition = \App\Support\EditionStatement::clean(self::clean((string) $get('250', 'a'))); + if ($edition !== '') { + $book['edition'] = $edition; + } + $place = \App\Support\PublicationPlace::clean(self::clean((string) $get('260', 'a'))); + if ($place !== '') { + $book['place'] = $place; + } + $book['publisher'] = self::clean((string) $get('260', 'b')); + + $year = $get('260', 'c') ?? $get('008', 'a'); + if ($year !== null && preg_match('/\d{4}/', $year, $m) === 1) { + $book['year'] = $m[0]; + $book['pubDate'] = $m[0] . '-01-01'; + } + + // Every ISBN of the record, for the client to tell whether the one + // searched for is among them + foreach (array_merge(self::all($xpath, $record, '021', 'e'), self::all($xpath, $record, '021', 'a')) as $value) { + $book['_isbns'][] = strtoupper((string) preg_replace('/[^0-9Xx]/', '', $value)); + } + foreach (self::all($xpath, $record, '021', 'e') as $value) { + $value = (string) preg_replace('/[^0-9Xx]/', '', $value); + if (strlen($value) === 13) { + $book['isbn13'] = $value; + break; + } + } + foreach (self::all($xpath, $record, '021', 'a') as $value) { + $value = strtoupper((string) preg_replace('/[^0-9Xx]/', '', $value)); + if (strlen($value) === 10) { + $book['isbn10'] = $value; + break; + } + } + + // Extent: pages only ("355 sider", "288 s."), never the minutes of an audiobook + $pages = ContributorSorter::pagesOf((string) $get('300', 'a'), false); + if ($pages !== '') { + $book['pages'] = $pages; + } + + $language = $get('008', 'l'); + if ($language !== null && $language !== '') { + $book['language'] = $languageName(strtolower(substr($language, 0, 3))); + } + + $book['description'] = self::clean((string) $get('504', 'a')); + + $series = $get('440', 'a'); + if ($series !== null && trim($series) !== '') { + $book['collana'] = self::clean($series); + $number = $get('440', 'v'); + if ($number !== null && trim($number) !== '') { + $book['numero_serie'] = self::clean($number); + } + } + + $subjects = []; + foreach (['s', 'o'] as $code) { + foreach (self::all($xpath, $record, '666', $code) as $value) { + $value = self::clean($value); + if ($value !== '' && !in_array($value, $subjects, true)) { + $subjects[] = $value; + } + } + } + if ($subjects !== []) { + $book['keywords'] = implode(', ', $subjects); + } + + if ($book['authors'] !== []) { + $book['author'] = implode(', ', $book['authors']); + } + return $book; + } + + /** "Lützen, Hanna" from *a Lützen *h Hanna; the full forenames of *k when given. */ + private static function invertedName(DOMNode $field): string + { + $subs = self::subfields($field); + $surname = self::clean($subs['a'][0] ?? ''); + $forenames = self::clean($subs['k'][0] ?? $subs['h'][0] ?? ''); + if ($surname === '') { + return $forenames; + } + return $forenames !== '' ? $surname . ', ' . $forenames : $surname; + } + + /** @return list */ + private static function fields(DOMXPath $xpath, DOMNode $record, string $tag): array + { + $nodes = $xpath->query("./*[local-name()='datafield'][@tag='$tag']", $record); + return $nodes === false ? [] : iterator_to_array($nodes, false); + } + + /** @return list */ + private static function all(DOMXPath $xpath, DOMNode $record, string $tag, string $code): array + { + $nodes = $xpath->query("./*[local-name()='datafield'][@tag='$tag']/*[local-name()='subfield'][@code='$code']", $record); + $out = []; + foreach ($nodes === false ? [] : $nodes as $node) { + $value = trim((string) $node->nodeValue); + if ($value !== '') { + $out[] = $value; + } + } + return $out; + } + + /** @return array> */ + private static function subfields(DOMNode $field): array + { + $out = []; + foreach ($field->childNodes as $sub) { + if ($sub instanceof \DOMElement && $sub->localName === 'subfield') { + $out[$sub->getAttribute('code')][] = trim((string) $sub->nodeValue); + } + } + return $out; + } + + private static function clean(string $text): string + { + $text = (string) preg_replace('/[\x{0080}-\x{009F}]/u', '', $text); + $text = trim((string) preg_replace('/\s+/u', ' ', $text)); + return rtrim($text, ' /:;=,'); + } +} diff --git a/app/Support/Marc/Iso2709Reader.php b/app/Support/Marc/Iso2709Reader.php new file mode 100644 index 000000000..c9e0e4a49 --- /dev/null +++ b/app/Support/Marc/Iso2709Reader.php @@ -0,0 +1,197 @@ + + */ +final class Iso2709Reader implements \IteratorAggregate +{ + public const ENCODING_AUTO = 'auto'; + public const ENCODING_UTF8 = 'utf8'; + public const ENCODING_MARC8 = 'marc8'; + + /** Longest record ISO 2709 can describe, plus slack for junk between records. */ + private const MAX_RECORD_BYTES = 200000; + + /** @var resource */ + private $handle; + private bool $ownsHandle; + private int $position; + private int $offset; + + /** + * @param resource $handle + * @param int $position 1-based number of the next record, for error reports + */ + private function __construct($handle, bool $ownsHandle, private string $encoding, int $position, int $offset) + { + $this->handle = $handle; + $this->ownsHandle = $ownsHandle; + $this->position = $position; + $this->offset = $offset; + } + + public function __destruct() + { + if ($this->ownsHandle && is_resource($this->handle)) { + fclose($this->handle); + } + } + + /** + * @param int $offset byte offset to start from (one offset() returned) + * @param int $position number of records before that offset + */ + public static function fromPath(string $path, string $encoding = self::ENCODING_AUTO, int $offset = 0, int $position = 0): self + { + $handle = @fopen($path, 'rb'); + if ($handle === false) { + throw new \RuntimeException('Cannot open MARC file'); + } + if ($offset > 0) { + fseek($handle, $offset); + } + return new self($handle, true, $encoding, $position, $offset); + } + + public static function fromString(string $data, string $encoding = self::ENCODING_AUTO): self + { + $handle = fopen('php://memory', 'w+b'); + if ($handle === false) { + throw new \RuntimeException('Cannot open memory stream'); + } + fwrite($handle, $data); + rewind($handle); + return new self($handle, true, $encoding, 0, 0); + } + + /** Byte offset just after the last record read: where a later read can resume. */ + public function offset(): int + { + return $this->offset; + } + + /** @return \Generator */ + public function getIterator(): \Generator + { + while (!feof($this->handle)) { + $raw = stream_get_line($this->handle, self::MAX_RECORD_BYTES, Iso2709Writer::RECORD_TERMINATOR); + if ($raw === false) { + break; + } + $after = ftell($this->handle); + $this->offset = $after === false ? $this->offset + strlen($raw) + 1 : $after; + // Line breaks or padding between records are not part of them. + $raw = ltrim($raw, "\r\n\t \x00\x1A"); + if ($raw === '') { + continue; + } + $this->position++; + try { + yield $this->position => self::parse($raw, $this->encoding); + } catch (MarcParseException $e) { + yield $this->position => new MarcReadError($this->position, $e->getMessage(), MarcReadError::excerptOf($raw)); + } + } + } + + /** + * One record, without its record terminator. + * + * @throws MarcParseException + */ + public static function parse(string $raw, string $encoding = self::ENCODING_AUTO): MarcRecord + { + $length = strlen($raw); + if ($length < 25) { + throw new MarcParseException('Record too short to hold a leader and a directory'); + } + $leader = substr($raw, 0, 24); + $directoryEnd = strpos($raw, Iso2709Writer::FIELD_TERMINATOR, 24); + if ($directoryEnd === false) { + throw new MarcParseException('Record has no directory terminator'); + } + // The data starts after the directory terminator; a leader base + // address (12-16) that disagrees is wrong, the terminator is not. + $base = $directoryEnd + 1; + $directory = substr($raw, 24, $directoryEnd - 24); + if ($directory === '' || strlen($directory) % 12 !== 0) { + throw new MarcParseException('Directory length is not a multiple of 12'); + } + + $utf8 = match ($encoding) { + self::ENCODING_UTF8 => true, + self::ENCODING_MARC8 => false, + default => $leader[9] === 'a' || mb_check_encoding($raw, 'UTF-8'), + }; + if ($encoding === self::ENCODING_UTF8 && !mb_check_encoding($raw, 'UTF-8')) { + throw new MarcParseException('Record declares UTF-8 but is not valid UTF-8'); + } + if (!$utf8 && $leader[9] === 'a') { + throw new MarcParseException('Record declares UTF-8 (leader/09 = a) but is not valid UTF-8'); + } + $decode = $utf8 + ? static fn (string $s): string => $s + : static fn (string $s): string => Marc8Decoder::decode($s); + if (!$utf8) { + $leader[9] = 'a'; + } + + $record = new MarcRecord($leader); + $dataLength = $length - $base; + foreach (str_split($directory, 12) as $entry) { + $tag = substr($entry, 0, 3); + $fieldLength = substr($entry, 3, 4); + $start = substr($entry, 7, 5); + if (!ctype_digit($fieldLength) || !ctype_digit($start)) { + throw new MarcParseException("Directory entry for tag {$tag} is not numeric"); + } + if ((int) $start + (int) $fieldLength > $dataLength) { + throw new MarcParseException("Field {$tag} runs past the end of the record"); + } + $data = substr($raw, $base + (int) $start, (int) $fieldLength); + if (str_ends_with($data, Iso2709Writer::FIELD_TERMINATOR)) { + $data = substr($data, 0, -1); + } + $isControl = ctype_digit($tag) && (int) $tag < 10 && !str_contains($data, Iso2709Writer::SUBFIELD_DELIMITER); + if ($isControl) { + $record->addControlField($tag, $decode($data)); + continue; + } + if (strlen($data) < 2) { + throw new MarcParseException("Data field {$tag} has no indicators"); + } + $ind1 = $data[0]; + $ind2 = $data[1]; + $subfields = []; + $chunks = explode(Iso2709Writer::SUBFIELD_DELIMITER, substr($data, 2)); + array_shift($chunks); // whatever precedes the first delimiter + foreach ($chunks as $chunk) { + if ($chunk === '') { + continue; + } + $subfields[] = [$chunk[0], $decode(substr($chunk, 1))]; + } + $record->addDataField($tag, $ind1 === "\x00" ? ' ' : $ind1, $ind2 === "\x00" ? ' ' : $ind2, $subfields); + } + return $record; + } +} diff --git a/app/Support/Marc/Iso2709Writer.php b/app/Support/Marc/Iso2709Writer.php new file mode 100644 index 000000000..c4dbb8d03 --- /dev/null +++ b/app/Support/Marc/Iso2709Writer.php @@ -0,0 +1,97 @@ +handle = $handle; + } + + public function write(MarcRecord $record): void + { + if (!is_resource($this->handle)) { + throw new \LogicException('Iso2709Writer has no stream to write to'); + } + fwrite($this->handle, self::serialize($record)); + } + + public static function serialize(MarcRecord $record): string + { + $directory = ''; + $fieldData = ''; + $pos = 0; + foreach ($record->fields() as $field) { + if ($field->data !== null) { + $content = $field->data; + } else { + $content = $field->ind1 . $field->ind2; + foreach ($field->subfields as [$code, $value]) { + $content .= self::SUBFIELD_DELIMITER . $code . $value; + } + } + $content = self::fitField($content) . self::FIELD_TERMINATOR; + $length = strlen($content); + $directory .= str_pad(substr($field->tag, 0, 3), 3, '0', STR_PAD_LEFT) + . sprintf('%04d', $length) + . sprintf('%05d', $pos); + $fieldData .= $content; + $pos += $length; + } + $directory .= self::FIELD_TERMINATOR; + + $baseAddress = 24 + strlen($directory); + $recordLength = $baseAddress + strlen($fieldData) + 1; + if ($recordLength > self::MAX_RECORD_LENGTH) { + throw new \OverflowException(sprintf('ISO 2709 record of %d bytes exceeds the %d-byte limit', $recordLength, self::MAX_RECORD_LENGTH)); + } + + $leader = $record->leader(); + $leader = sprintf('%05d', $recordLength) + . substr($leader, 5, 7) + . sprintf('%05d', $baseAddress) + . substr($leader, 17); + + return $leader . $directory . $fieldData . self::RECORD_TERMINATOR; + } + + /** + * A field longer than the directory can describe is cut on a UTF-8 + * character boundary (a long summary is the usual case) rather than + * producing a record no reader can open. + */ + private static function fitField(string $content): string + { + $limit = self::MAX_FIELD_LENGTH - 1; // the field terminator counts + if (strlen($content) <= $limit) { + return $content; + } + return mb_strcut($content, 0, $limit, 'UTF-8'); + } +} diff --git a/app/Support/Marc/Marc21LibriMapper.php b/app/Support/Marc/Marc21LibriMapper.php new file mode 100644 index 000000000..7c6cadbd4 --- /dev/null +++ b/app/Support/Marc/Marc21LibriMapper.php @@ -0,0 +1,172 @@ + 'curatore', + 'translator' => 'traduttore', + 'illustrator' => 'illustratore', + 'colorist' => 'colorista', + ]; + + public function map(MarcRecord $record): array + { + $data = MarcBookData::empty(); + + $data['titolo'] = MarcBookData::clean($record->subfield('245', 'a')); + $data['sottotitolo'] = MarcBookData::clean($record->subfield('245', 'b')); + + $statement = $record->subfield('245', 'c'); + $authors = []; + $contributors = array_fill_keys(array_values(self::CONTRIBUTOR_COLUMNS), []); + $file = static function (string $name, string $role) use (&$authors, &$contributors): void { + if ($name === '') { + return; + } + if ($role === 'author') { + if (!in_array($name, $authors, true)) { + $authors[] = $name; + } + return; + } + $column = self::CONTRIBUTOR_COLUMNS[$role] ?? null; + if ($column !== null && !in_array($name, $contributors[$column], true)) { + $contributors[$column][] = $name; + } + }; + foreach ($record->fieldsByTag('100') as $field) { + $name = MarcBookData::cleanName($field->subfield('a')); + $codes = $field->subfieldValues('4'); + $term = $field->subfieldValues('e'); + // The main entry is the author unless its own relator says otherwise. + $role = ($codes === [] && $term === []) + ? 'author' + : RelatorRoles::resolve($codes, $term === [] ? null : implode(' ', $term), null, $name, 'marc21'); + $file($name, $role === 'other' ? 'author' : $role); + } + foreach ($record->fieldsByTag('700') as $field) { + if ($field->subfield('t') !== null) { + continue; // a name-title entry is a work, not a person's part + } + $name = MarcBookData::cleanName($field->subfield('a')); + if ($name === '') { + continue; + } + $term = $field->subfieldValues('e'); + $role = RelatorRoles::resolve($field->subfieldValues('4'), $term === [] ? null : implode(' ', $term), $statement, $name, 'marc21'); + $file($name, $role); + } + $data['autori'] = $authors; + foreach ($contributors as $column => $names) { + $data[$column] = $names; + } + + $data['edizione'] = EditionStatement::clean(MarcBookData::clean($record->subfield('250', 'a'))); + + // Publication: 260, or the 264 that states publication (ind2 1), + // else any 264. + $publication = $record->fieldsByTag('260'); + $published264 = array_values(array_filter($record->fieldsByTag('264'), static fn (MarcField $f): bool => $f->ind2 === '1')); + $publication = array_merge($publication, $published264 !== [] ? $published264 : $record->fieldsByTag('264')); + foreach ($publication as $field) { + if ($data['luogo_pubblicazione'] === '') { + $data['luogo_pubblicazione'] = PublicationPlace::clean(MarcBookData::clean($field->subfield('a'))); + } + foreach ($field->subfieldValues('b') as $publisher) { + $publisher = MarcBookData::clean($publisher); + if ($publisher !== '' && !in_array($publisher, $data['editori'], true)) { + $data['editori'][] = $publisher; + } + } + if ($data['anno_pubblicazione'] === '') { + $data['anno_pubblicazione'] = MarcBookData::yearOf($field->subfield('c')); + } + } + $f008 = (string) $record->controlField('008'); + if ($data['anno_pubblicazione'] === '' && strlen($f008) >= 11 && ctype_digit(substr($f008, 7, 4))) { + $data['anno_pubblicazione'] = MarcBookData::yearOf(substr($f008, 7, 4)); + } + + $data['numero_pagine'] = ContributorSorter::pagesOf((string) $record->subfield('300', 'a')); + $data['dimensioni'] = MarcBookData::clean($record->subfield('300', 'c'), ' /:;=,+'); + + $data['collana'] = MarcBookData::clean($record->subfield('490', 'a')); + $data['numero_serie'] = MarcBookData::clean($record->subfield('490', 'v')); + if ($data['collana'] === '') { + $data['collana'] = MarcBookData::clean($record->subfield('830', 'a'), ' /:;=,.'); + $data['numero_serie'] = MarcBookData::clean($record->subfield('830', 'v')); + } + + $data['descrizione'] = trim(implode("\n\n", array_map( + static fn (string $s): string => trim($s), + $record->subfieldValues('520', 'a') + ))); + + $keywords = []; + foreach (['650', '651', '653'] as $tag) { + foreach ($record->subfieldValues($tag, 'a') as $term) { + $term = MarcBookData::clean($term, ' /:;=,.'); + if ($term !== '' && !in_array($term, $keywords, true)) { + $keywords[] = $term; + } + } + } + $data['parole_chiave'] = $keywords; + + $dewey = (string) $record->subfield('082', 'a'); + if (preg_match('/(\d{3}(?:\.\d+)?)/', $dewey, $m) === 1) { + $data['classificazione_dewey'] = $m[1]; + } + $lc = trim(MarcBookData::clean($record->subfield('050', 'a')) . ' ' . MarcBookData::clean($record->subfield('050', 'b'))); + $data['lc_classification'] = mb_substr($lc, 0, 100); + + foreach ($record->subfieldValues('020', 'a') as $value) { + $isbn = MarcBookData::isbnOf($value); + if (strlen($isbn) === 13 && $data['isbn13'] === '' && IsbnFormatter::isValid($isbn)) { + $data['isbn13'] = $isbn; + } elseif (strlen($isbn) === 10 && $data['isbn10'] === '' && IsbnFormatter::isValid($isbn)) { + $data['isbn10'] = $isbn; + } + } + foreach ($record->fieldsByTag('024') as $field) { + $ean = (string) preg_replace('/\D/', '', (string) $field->subfield('a')); + if (strlen($ean) === 13 && ($field->ind1 === '3' || $field->ind1 === ' ') && $ean !== $data['isbn13']) { + $data['ean'] = $ean; + break; + } + } + $data['issn'] = MarcBookData::clean($record->subfield('022', 'a')); + + $language = (string) $record->subfield('041', 'a'); + if ($language === '' && strlen($f008) >= 38) { + $language = substr($f008, 35, 3); + } + $data['lingua'] = MarcLanguage::nameFor(substr($language, 0, 3)); + + $data['copertina_url'] = MarcBookData::coverUrlOf($record); + + return $data; + } +} diff --git a/app/Support/Marc/Marc8Decoder.php b/app/Support/Marc/Marc8Decoder.php new file mode 100644 index 000000000..03ccd22a0 --- /dev/null +++ b/app/Support/Marc/Marc8Decoder.php @@ -0,0 +1,126 @@ + 0x0141, 0xA2 => 0x00D8, 0xA3 => 0x0110, 0xA4 => 0x00DE, 0xA5 => 0x00C6, + 0xA6 => 0x0152, 0xA7 => 0x02B9, 0xA8 => 0x00B7, 0xA9 => 0x266D, 0xAA => 0x00AE, + 0xAB => 0x00B1, 0xAC => 0x01A0, 0xAD => 0x01AF, 0xAE => 0x02BC, 0xB0 => 0x02BB, + 0xB1 => 0x0142, 0xB2 => 0x00F8, 0xB3 => 0x0111, 0xB4 => 0x00FE, 0xB5 => 0x00E6, + 0xB6 => 0x0153, 0xB7 => 0x02BA, 0xB8 => 0x0131, 0xB9 => 0x00A3, 0xBA => 0x00F0, + 0xBC => 0x01A1, 0xBD => 0x01B0, 0xC0 => 0x00B0, 0xC1 => 0x2113, 0xC2 => 0x2117, + 0xC3 => 0x00A9, 0xC4 => 0x266F, 0xC5 => 0x00BF, 0xC6 => 0x00A1, 0xC7 => 0x00DF, + 0xC8 => 0x20AC, + ]; + + /** ANSEL combining diacritics, G1 (0xE0-0xFE). */ + private const COMBINING = [ + 0xE0 => 0x0309, 0xE1 => 0x0300, 0xE2 => 0x0301, 0xE3 => 0x0302, 0xE4 => 0x0303, + 0xE5 => 0x0304, 0xE6 => 0x0306, 0xE7 => 0x0307, 0xE8 => 0x0308, 0xE9 => 0x030C, + 0xEA => 0x030A, 0xEB => 0xFE20, 0xEC => 0xFE21, 0xED => 0x0315, 0xEE => 0x030B, + 0xEF => 0x0310, 0xF0 => 0x0327, 0xF1 => 0x0328, 0xF2 => 0x0323, 0xF3 => 0x0324, + 0xF4 => 0x0325, 0xF5 => 0x0333, 0xF6 => 0x0332, 0xF7 => 0x0326, 0xF8 => 0x031C, + 0xF9 => 0x032E, 0xFA => 0xFE22, 0xFB => 0xFE23, 0xFE => 0x0313, + ]; + + /** + * Non-sorting begin/end (0x88/0x89) and the joiners (0x8D/0x8E): markup, + * not text. Dropped. + */ + private const DROPPED = [0x88, 0x89, 0x8D, 0x8E]; + + public static function decode(string $bytes): string + { + $out = ''; + $pending = ''; + $length = strlen($bytes); + for ($i = 0; $i < $length; $i++) { + $byte = ord($bytes[$i]); + if ($byte === 0x1B) { + $i = self::escape($bytes, $i); + continue; + } + if ($byte < 0x80) { + $char = chr($byte); + $out .= $char . $pending; + $pending = ''; + continue; + } + if (in_array($byte, self::DROPPED, true)) { + continue; + } + if (isset(self::COMBINING[$byte])) { + $pending .= mb_chr(self::COMBINING[$byte], 'UTF-8'); + continue; + } + if (isset(self::SPACING[$byte])) { + $out .= mb_chr(self::SPACING[$byte], 'UTF-8') . $pending; + $pending = ''; + continue; + } + throw new MarcParseException(sprintf('MARC-8: byte 0x%02X is not a character of Basic or Extended Latin', $byte)); + } + // A diacritic with nothing after it stays, on its own. + $out .= $pending; + if (class_exists(\Normalizer::class)) { + $normalised = \Normalizer::normalize($out, \Normalizer::FORM_C); + if (is_string($normalised)) { + return $normalised; + } + } + return $out; + } + + /** + * Skip an escape sequence that selects Basic or Extended Latin; any other + * set cannot be decoded here. + * + * @return int index of the last byte of the sequence + */ + private static function escape(string $bytes, int $i): int + { + $next = $bytes[$i + 1] ?? ''; + // ESC s: back to ASCII (after a technique-1 switch) + if ($next === 's') { + return $i + 1; + } + // ESC ( B / ESC , B: ASCII as G0; ESC ) E / ESC - E: ANSEL as G1; + // ESC ( ! E: ANSEL designated with an intermediate + if (in_array($next, ['(', ',', ')', '-', '$'], true)) { + $final = $bytes[$i + 2] ?? ''; + if ($final === '!') { + $final = $bytes[$i + 3] ?? ''; + if ($final === 'E') { + return $i + 3; + } + } elseif ($final === 'B' || $final === 'E') { + return $i + 2; + } + throw new MarcParseException('MARC-8: character set "' . self::printable($final) . '" (Greek, Cyrillic, Hebrew, Arabic or CJK) is not supported'); + } + throw new MarcParseException('MARC-8: character set "' . self::printable($next) . '" is not supported'); + } + + private static function printable(string $char): string + { + return $char !== '' && ctype_print($char) ? $char : sprintf('0x%02X', $char === '' ? 0 : ord($char)); + } +} diff --git a/app/Support/Marc/MarcBookData.php b/app/Support/Marc/MarcBookData.php new file mode 100644 index 000000000..9939b81df --- /dev/null +++ b/app/Support/Marc/MarcBookData.php @@ -0,0 +1,184 @@ +> */ + public static function empty(): array + { + return [ + 'titolo' => '', 'sottotitolo' => '', + 'autori' => [], 'curatore' => [], 'traduttore' => [], 'illustratore' => [], 'colorista' => [], + 'editori' => [], 'anno_pubblicazione' => '', 'lingua' => '', 'edizione' => '', + 'luogo_pubblicazione' => '', 'numero_pagine' => '', 'dimensioni' => '', + 'collana' => '', 'numero_serie' => '', 'descrizione' => '', 'parole_chiave' => [], + 'classificazione_dewey' => '', 'lc_classification' => '', + 'isbn13' => '', 'isbn10' => '', 'ean' => '', 'issn' => '', 'copertina_url' => '', + ]; + } + + /** + * Collapse whitespace, drop MARC-8 C1 leftovers and the ISBD punctuation a + * record leaves at the end of a value (" /", " :", " ;", " =", ","). + */ + public static function clean(?string $value, string $trailing = ' /:;=,'): string + { + if ($value === null) { + return ''; + } + $value = (string) preg_replace('/[\x{0080}-\x{009F}]/u', '', $value); + $value = trim((string) preg_replace('/\s+/u', ' ', $value)); + return trim(rtrim($value, $trailing)); + } + + /** A name as the record gives it, without the closing comma or period ("Eco, Umberto,"). */ + public static function cleanName(?string $value): string + { + $name = self::clean($value, ' /:;=,'); + // A final period closes the field, unless it ends an initial ("Tolkien, J.R.R.") + if (str_ends_with($name, '.') && preg_match('/(?:^|[\s.])\p{Lu}\.$/u', $name) !== 1) { + $name = rtrim($name, '.'); + } + return trim($name); + } + + /** The first year a date statement names ("c1998", "[2004?]", "1980-1983"). */ + public static function yearOf(?string $value): string + { + if ($value !== null && preg_match('/(1[0-9]{3}|20[0-9]{2})/', $value, $m) === 1) { + return $m[1]; + } + return ''; + } + + /** + * The ISBN of an 020 $a / 010 $a value: its leading digits (and final X), + * without the qualifier a record may add ("9788845292613 (pbk.)"). + */ + public static function isbnOf(?string $value): string + { + $value = strtoupper(trim((string) $value)); + $value = (string) preg_replace('/^([0-9X\- ]+).*$/', '$1', $value); + return (string) preg_replace('/[^0-9X]/', '', $value); + } + + /** + * Book data from the book shape the catalogue readers return (title, + * subtitle, authors, publisher, year, language, editor, translator, ...). + * + * @param array $book + * @return array> + */ + public static function fromScrape(array $book): array + { + $data = self::empty(); + $string = static fn (string $key): string => is_scalar($book[$key] ?? null) ? trim((string) $book[$key]) : ''; + $list = static function (mixed $value): array { + if (is_string($value)) { + $value = $value === '' ? [] : [$value]; + } + if (!is_array($value)) { + return []; + } + return array_values(array_filter(array_map(static fn ($v): string => is_scalar($v) ? trim((string) $v) : '', $value), static fn (string $v): bool => $v !== '')); + }; + $data['titolo'] = $string('title'); + $data['sottotitolo'] = $string('subtitle'); + $data['autori'] = $list($book['authors'] ?? []); + $data['curatore'] = $list($book['editor'] ?? []); + $data['traduttore'] = $list($book['translator'] ?? []); + $data['illustratore'] = $list($book['illustrator'] ?? []); + $data['colorista'] = $list($book['colorist'] ?? []); + $data['editori'] = $list($book['publisher'] ?? []); + $data['anno_pubblicazione'] = self::yearOf($string('year')); + $data['lingua'] = $string('language'); + $data['edizione'] = $string('edition'); + $data['luogo_pubblicazione'] = $string('place'); + $data['numero_pagine'] = ctype_digit($string('pages')) ? $string('pages') : ''; + $data['descrizione'] = $string('description'); + $data['collana'] = $string('collana'); + $data['numero_serie'] = $string('numero_serie'); + $keywords = $book['keywords'] ?? []; + $data['parole_chiave'] = is_string($keywords) + ? array_values(array_filter(array_map('trim', explode(',', $keywords)), static fn (string $k): bool => $k !== '')) + : $list($keywords); + $data['classificazione_dewey'] = $string('classificazione_dewey'); + $data['isbn13'] = $string('isbn13'); + $data['isbn10'] = $string('isbn10'); + $data['ean'] = $string('ean'); + $data['copertina_url'] = $string('image') !== '' ? $string('image') : $string('cover'); + return $data; + } + + /** + * The canonical import row (the column names of the CSV import) the + * shared row pipeline normalises: names joined with ";", keywords with + * ", ". A contributor column is present only when the record names + * someone in that role, so a record never clears contributors it says + * nothing about. + * + * @param array> $data + * @return array + */ + public static function toImportRow(array $data): array + { + $join = static fn (string $key, string $glue = ';'): string => implode($glue, (array) ($data[$key] ?? [])); + $row = [ + 'titolo' => (string) ($data['titolo'] ?? ''), + 'sottotitolo' => (string) ($data['sottotitolo'] ?? ''), + 'autori' => $join('autori'), + 'editore' => $join('editori'), + 'anno_pubblicazione' => (string) ($data['anno_pubblicazione'] ?? ''), + 'lingua' => (string) ($data['lingua'] ?? ''), + 'edizione' => (string) ($data['edizione'] ?? ''), + 'numero_pagine' => (string) ($data['numero_pagine'] ?? ''), + 'descrizione' => (string) ($data['descrizione'] ?? ''), + 'collana' => (string) ($data['collana'] ?? ''), + 'numero_serie' => (string) ($data['numero_serie'] ?? ''), + 'parole_chiave' => $join('parole_chiave', ', '), + 'classificazione_dewey' => (string) ($data['classificazione_dewey'] ?? ''), + 'isbn10' => (string) ($data['isbn10'] ?? ''), + 'isbn13' => (string) ($data['isbn13'] ?? ''), + 'ean' => (string) ($data['ean'] ?? ''), + ]; + foreach (['traduttore', 'illustratore', 'curatore', 'colorista'] as $role) { + if (((array) ($data[$role] ?? [])) !== []) { + $row[$role] = $join($role); + } + } + return $row; + } + + /** + * The cover image URL among a record's 856 fields: one labelled as a + * cover ($3 / $y / $z), typed as an image ($q), or pointing at an image file. + */ + public static function coverUrlOf(MarcRecord $record): string + { + foreach ($record->fieldsByTag('856') as $field) { + $url = trim((string) $field->subfield('u')); + if ($url === '' || preg_match('#^https?://#i', $url) !== 1) { + continue; + } + $label = implode(' ', array_merge($field->subfieldValues('3'), $field->subfieldValues('y'), $field->subfieldValues('z'))); + $type = (string) $field->subfield('q'); + if (preg_match('/cover|copertin|umschlag|couverture|omslag|portada|thumbnail|image de couverture/iu', $label) === 1 + || str_starts_with(strtolower($type), 'image/') + || preg_match('#\.(jpe?g|png|gif|webp)(\?.*)?$#i', $url) === 1 + ) { + return $url; + } + } + return ''; + } +} diff --git a/app/Support/Marc/MarcField.php b/app/Support/Marc/MarcField.php new file mode 100644 index 000000000..e9ab278a6 --- /dev/null +++ b/app/Support/Marc/MarcField.php @@ -0,0 +1,76 @@ + $subfields + */ + public function __construct( + public readonly string $tag, + public readonly ?string $data = null, + public readonly string $ind1 = ' ', + public readonly string $ind2 = ' ', + public readonly array $subfields = [] + ) { + } + + public function isControl(): bool + { + return $this->data !== null; + } + + /** The first value of a subfield, or null. */ + public function subfield(string $code): ?string + { + foreach ($this->subfields as [$c, $value]) { + if ($c === $code) { + return $value; + } + } + return null; + } + + /** @return list every value of a subfield, in order */ + public function subfieldValues(string $code): array + { + $out = []; + foreach ($this->subfields as [$c, $value]) { + if ($c === $code) { + $out[] = $value; + } + } + return $out; + } + + /** + * Subfields keyed by code, as the catalogue parsers read them. Numeric + * codes become integer keys, as PHP does with any numeric string. + * + * @return array> + */ + public function subfieldMap(): array + { + $out = []; + foreach ($this->subfields as [$code, $value]) { + $out[$code][] = $value; + } + return $out; + } + + /** @return array{tag: string, data?: string, ind1?: string, ind2?: string, subfields?: list} */ + public function toArray(): array + { + if ($this->data !== null) { + return ['tag' => $this->tag, 'data' => $this->data]; + } + return ['tag' => $this->tag, 'ind1' => $this->ind1, 'ind2' => $this->ind2, 'subfields' => $this->subfields]; + } +} diff --git a/app/Support/Marc/MarcFormat.php b/app/Support/Marc/MarcFormat.php new file mode 100644 index 000000000..b564e7d3a --- /dev/null +++ b/app/Support/Marc/MarcFormat.php @@ -0,0 +1,87 @@ + */ + public const FLAVOURS = [self::MARC21, self::UNIMARC, self::DANMARC2]; + + /** The container of a file from its first bytes; null when it is neither. */ + public static function detectContainer(string $head): ?string + { + $head = (string) preg_replace('/^\xEF\xBB\xBF/', '', $head); + $head = ltrim($head, " \t\r\n"); + if ($head === '') { + return null; + } + if ($head[0] === '<') { + return self::CONTAINER_XML; + } + // An ISO 2709 record opens with its 5-digit length and, at 10-11, + // the indicator and subfield-code counts ("22"). + if (strlen($head) >= 24 && ctype_digit(substr($head, 0, 5)) && str_contains($head, Iso2709Writer::FIELD_TERMINATOR)) { + return self::CONTAINER_ISO2709; + } + return null; + } + + public static function containerOfFile(string $path): ?string + { + $handle = @fopen($path, 'rb'); + if ($handle === false) { + return null; + } + $head = (string) fread($handle, 4096); + fclose($handle); + return self::detectContainer($head); + } + + /** + * The flavour of one record. A MARCXchange record that says it is + * danMARC2, or whose 001 is a data field (danMARC2 carries the record + * number in 001 *a), is danMARC2; a record with a 200 title and no 245 + * is UNIMARC; anything else is MARC 21. + */ + public static function detectFlavour(MarcRecord $record): string + { + $declared = strtolower($record->format()); + if (str_contains($declared, 'danmarc')) { + return self::DANMARC2; + } + if (str_contains($declared, 'unimarc')) { + return self::UNIMARC; + } + $first001 = $record->first('001'); + if ($first001 !== null && !$first001->isControl()) { + return self::DANMARC2; + } + if (!$record->hasField('245') && $record->hasField('200')) { + return self::UNIMARC; + } + return self::MARC21; + } + + public static function mapperFor(string $flavour): MarcLibriMapper + { + return match ($flavour) { + self::UNIMARC => new UnimarcLibriMapper(), + self::DANMARC2 => new Danmarc2LibriMapper(), + default => new Marc21LibriMapper(), + }; + } +} diff --git a/app/Support/Marc/MarcLanguage.php b/app/Support/Marc/MarcLanguage.php new file mode 100644 index 000000000..cbce5f847 --- /dev/null +++ b/app/Support/Marc/MarcLanguage.php @@ -0,0 +1,88 @@ + 'Italiano', 'eng' => 'English', 'fre' => 'Français', 'fra' => 'Français', + 'ger' => 'Deutsch', 'deu' => 'Deutsch', 'spa' => 'Español', 'por' => 'Português', + 'rus' => 'Русский', 'chi' => '中文', 'zho' => '中文', 'jpn' => '日本語', 'ara' => 'العربية', + 'dut' => 'Nederlands', 'nld' => 'Nederlands', 'swe' => 'Svenska', 'nor' => 'Norsk', + 'nob' => 'Norsk', 'nno' => 'Norsk', 'dan' => 'Dansk', 'fin' => 'Suomi', 'pol' => 'Polski', + 'cze' => 'Čeština', 'ces' => 'Čeština', 'hun' => 'Magyar', 'rum' => 'Română', 'ron' => 'Română', + 'gre' => 'Ελληνικά', 'ell' => 'Ελληνικά', 'tur' => 'Türkçe', 'heb' => 'עברית', 'hin' => 'हिन्दी', + 'kor' => '한국어', 'tha' => 'ไทย', 'lat' => 'Latina', 'cat' => 'Català', 'grc' => 'Greco antico', + 'ice' => 'Íslenska', 'isl' => 'Íslenska', + ]; + + /** Codes that name no language. */ + private const NONE = ['und', 'mul', 'zxx', 'mis', '|||', ' ', 'xxx']; + + /** The stored name for a code; '' for "undetermined"; the code itself when unknown. */ + public static function nameFor(string $code): string + { + $code = strtolower(trim($code)); + if ($code === '' || in_array($code, self::NONE, true) || strlen($code) !== 3) { + return ''; + } + return self::NAMES[$code] ?? $code; + } + /** + * ISO 639-2/B code for one stored language (Italian/English/native name, + * ISO 639-1 or 639-2 code), or null when it is not recognised. + */ + public static function codeFor(string $lang): ?string + { + static $map = [ + 'italiano' => 'ita', 'italian' => 'ita', 'it' => 'ita', 'ita' => 'ita', + 'inglese' => 'eng', 'english' => 'eng', 'en' => 'eng', 'eng' => 'eng', + 'francese' => 'fre', 'français' => 'fre', 'french' => 'fre', 'fr' => 'fre', 'fre' => 'fre', 'fra' => 'fre', + 'tedesco' => 'ger', 'deutsch' => 'ger', 'german' => 'ger', 'de' => 'ger', 'ger' => 'ger', 'deu' => 'ger', + 'spagnolo' => 'spa', 'español' => 'spa', 'spanish' => 'spa', 'es' => 'spa', 'spa' => 'spa', + 'portoghese' => 'por', 'português' => 'por', 'portuguese' => 'por', 'pt' => 'por', 'por' => 'por', + 'danese' => 'dan', 'dansk' => 'dan', 'danish' => 'dan', 'da' => 'dan', 'dan' => 'dan', + 'olandese' => 'dut', 'nederlands' => 'dut', 'dutch' => 'dut', 'nl' => 'dut', 'dut' => 'dut', 'nld' => 'dut', + 'svedese' => 'swe', 'svenska' => 'swe', 'swedish' => 'swe', 'sv' => 'swe', 'swe' => 'swe', + 'norvegese' => 'nor', 'norsk' => 'nor', 'norwegian' => 'nor', 'no' => 'nor', 'nor' => 'nor', + 'russo' => 'rus', 'russian' => 'rus', 'ru' => 'rus', 'rus' => 'rus', + 'polacco' => 'pol', 'polski' => 'pol', 'polish' => 'pol', 'pl' => 'pol', 'pol' => 'pol', + 'greco' => 'gre', 'greek' => 'gre', 'el' => 'gre', 'gre' => 'gre', 'ell' => 'gre', + 'greco antico' => 'grc', 'grc' => 'grc', + 'latino' => 'lat', 'latin' => 'lat', 'la' => 'lat', 'lat' => 'lat', + 'cinese' => 'chi', 'chinese' => 'chi', 'zh' => 'chi', 'chi' => 'chi', 'zho' => 'chi', + 'giapponese' => 'jpn', 'japanese' => 'jpn', 'ja' => 'jpn', 'jpn' => 'jpn', + 'arabo' => 'ara', 'arabic' => 'ara', 'ar' => 'ara', 'ara' => 'ara', + 'catalano' => 'cat', 'català' => 'cat', 'catalan' => 'cat', 'ca' => 'cat', 'cat' => 'cat', + ]; + $key = mb_strtolower(trim($lang)); + return $map[$key] ?? null; + } + + /** + * The individual languages of a stored lingua value ("italiano, inglese"). + * + * @return list + */ + public static function listOf(string $lang): array + { + $parts = preg_split('/\s*[,;\/]\s*/', trim($lang)) ?: []; + return array_values(array_filter(array_map('trim', $parts), static fn (string $p): bool => $p !== '')); + } + + /** + * The MARC code of a stored lingua value ('und' when unknown). A + * multi-language value uses its first language. + */ + public static function marcCodeOf(string $lang): string + { + $first = self::listOf($lang)[0] ?? ''; + return self::codeFor($first) ?? 'und'; + } +} diff --git a/app/Support/Marc/MarcLibriMapper.php b/app/Support/Marc/MarcLibriMapper.php new file mode 100644 index 000000000..d512e25e8 --- /dev/null +++ b/app/Support/Marc/MarcLibriMapper.php @@ -0,0 +1,15 @@ +> */ + public function map(MarcRecord $record): array; +} diff --git a/app/Support/Marc/MarcParseException.php b/app/Support/Marc/MarcParseException.php new file mode 100644 index 000000000..9289ac012 --- /dev/null +++ b/app/Support/Marc/MarcParseException.php @@ -0,0 +1,9 @@ + */ + private array $fields = []; + + /** The MARCXchange `format` attribute ("danMARC2", "UNIMARC"), when given. */ + private string $format = ''; + + public function __construct(string $leader = self::DEFAULT_LEADER) + { + $this->leader = self::normaliseLeader($leader); + } + + /** A leader of exactly 24 characters: padded with blanks, or cut. */ + public static function normaliseLeader(string $leader): string + { + $leader = str_replace(["\r", "\n", "\t"], ' ', $leader); + if (strlen($leader) < 24) { + return str_pad($leader, 24, ' '); + } + return substr($leader, 0, 24); + } + + public function leader(): string + { + return $this->leader; + } + + public function setLeader(string $leader): void + { + $this->leader = self::normaliseLeader($leader); + } + + public function format(): string + { + return $this->format; + } + + public function setFormat(string $format): void + { + $this->format = $format; + } + + public function addControlField(string $tag, string $data): self + { + $this->fields[] = new MarcField($tag, $data); + return $this; + } + + /** + * @param list $subfields + */ + public function addDataField(string $tag, string $ind1, string $ind2, array $subfields): self + { + $this->fields[] = new MarcField($tag, null, self::indicator($ind1), self::indicator($ind2), $subfields); + return $this; + } + + public function addField(MarcField $field): self + { + $this->fields[] = $field; + return $this; + } + + private static function indicator(string $value): string + { + return $value === '' ? ' ' : substr($value, 0, 1); + } + + /** @return list */ + public function fields(): array + { + return $this->fields; + } + + /** @return list */ + public function fieldsByTag(string $tag): array + { + return array_values(array_filter($this->fields, static fn (MarcField $f): bool => $f->tag === $tag)); + } + + public function first(string $tag): ?MarcField + { + foreach ($this->fields as $field) { + if ($field->tag === $tag) { + return $field; + } + } + return null; + } + + public function hasField(string $tag): bool + { + return $this->first($tag) !== null; + } + + public function controlField(string $tag): ?string + { + foreach ($this->fields as $field) { + if ($field->tag === $tag && $field->data !== null) { + return $field->data; + } + } + return null; + } + + /** The first value of a subfield across every field with the tag. */ + public function subfield(string $tag, string $code): ?string + { + foreach ($this->fields as $field) { + if ($field->tag === $tag && $field->data === null) { + $value = $field->subfield($code); + if ($value !== null) { + return $value; + } + } + } + return null; + } + + /** @return list */ + public function subfieldValues(string $tag, string $code): array + { + $out = []; + foreach ($this->fields as $field) { + if ($field->tag === $tag && $field->data === null) { + foreach ($field->subfieldValues($code) as $value) { + $out[] = $value; + } + } + } + return $out; + } + + /** @return array{leader: string, format: string, fields: list>} */ + public function toArray(): array + { + return [ + 'leader' => $this->leader, + 'format' => $this->format, + 'fields' => array_map(static fn (MarcField $f): array => $f->toArray(), $this->fields), + ]; + } + + /** @param array $data the shape toArray() returns */ + public static function fromArray(array $data): self + { + $record = new self((string) ($data['leader'] ?? self::DEFAULT_LEADER)); + $record->setFormat((string) ($data['format'] ?? '')); + foreach ((array) ($data['fields'] ?? []) as $field) { + if (!is_array($field) || !isset($field['tag'])) { + continue; + } + $tag = (string) $field['tag']; + if (array_key_exists('data', $field)) { + $record->addControlField($tag, (string) $field['data']); + continue; + } + $subfields = []; + foreach ((array) ($field['subfields'] ?? []) as $sub) { + if (is_array($sub) && count($sub) >= 2) { + $subfields[] = [(string) $sub[0], (string) $sub[1]]; + } + } + $record->addDataField($tag, (string) ($field['ind1'] ?? ' '), (string) ($field['ind2'] ?? ' '), $subfields); + } + return $record; + } +} diff --git a/app/Support/Marc/MarcXmlReader.php b/app/Support/Marc/MarcXmlReader.php new file mode 100644 index 000000000..5fc236e99 --- /dev/null +++ b/app/Support/Marc/MarcXmlReader.php @@ -0,0 +1,139 @@ +, or wrapped in an SRU or OAI-PMH + * response. A `record` element counts as a MARC record when it has a leader, + * controlfield or datafield child, so the SRU/OAI envelope around it is + * walked through, not mistaken for one. + * + * A record the converter cannot read is yielded as a MarcReadError in its + * place. XML that stops being well-formed ends the file with one error. + * + * @implements \IteratorAggregate + */ +final class MarcXmlReader implements \IteratorAggregate +{ + private const MARC_CHILDREN = ['leader', 'controlfield', 'datafield']; + + private function __construct(private \XMLReader $reader) + { + } + + public static function fromPath(string $path): self + { + $reader = new \XMLReader(); + if (!@$reader->open($path, null, LIBXML_NONET | LIBXML_COMPACT | LIBXML_PARSEHUGE)) { + throw new \RuntimeException('Cannot open MARCXML file'); + } + return new self($reader); + } + + public static function fromString(string $xml): self + { + $reader = \XMLReader::XML($xml, null, LIBXML_NONET | LIBXML_COMPACT | LIBXML_PARSEHUGE); + if (!$reader instanceof \XMLReader) { + throw new \RuntimeException('Cannot read MARCXML'); + } + return new self($reader); + } + + /** @return \Generator */ + public function getIterator(): \Generator + { + $previous = libxml_use_internal_errors(true); + $position = 0; + try { + $more = $this->reader->read(); + while ($more) { + if ($this->reader->nodeType === \XMLReader::ELEMENT && $this->reader->localName === 'record') { + $node = $this->reader->expand(); + if ($node instanceof \DOMElement && self::isMarcRecord($node)) { + $position++; + try { + yield $position => self::toRecord($node); + } catch (MarcParseException $e) { + yield $position => new MarcReadError($position, $e->getMessage(), MarcReadError::excerptOf((string) $node->textContent)); + } + $more = $this->reader->next(); + continue; + } + } + $more = $this->reader->read(); + } + $error = libxml_get_last_error(); + if ($error !== false && $error->level >= LIBXML_ERR_ERROR) { + yield $position + 1 => new MarcReadError( + $position + 1, + sprintf('XML is not well-formed at line %d: %s', $error->line, trim($error->message)) + ); + } + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + $this->reader->close(); + } + } + + private static function isMarcRecord(\DOMElement $node): bool + { + foreach ($node->childNodes as $child) { + if ($child instanceof \DOMElement && in_array($child->localName, self::MARC_CHILDREN, true)) { + return true; + } + } + return false; + } + + /** + * A MARCXML / MARCXchange element as a MarcRecord. + * + * @throws MarcParseException + */ + public static function toRecord(\DOMElement $node): MarcRecord + { + $record = new MarcRecord(MarcRecord::DEFAULT_LEADER); + $record->setFormat($node->getAttribute('format')); + $hasLeader = false; + foreach ($node->childNodes as $child) { + if (!$child instanceof \DOMElement) { + continue; + } + switch ($child->localName) { + case 'leader': + $record->setLeader((string) $child->textContent); + $hasLeader = true; + break; + case 'controlfield': + $tag = $child->getAttribute('tag'); + if ($tag === '') { + throw new MarcParseException('controlfield without a tag'); + } + $record->addControlField($tag, (string) $child->textContent); + break; + case 'datafield': + $tag = $child->getAttribute('tag'); + if ($tag === '') { + throw new MarcParseException('datafield without a tag'); + } + $subfields = []; + foreach ($child->childNodes as $sub) { + if ($sub instanceof \DOMElement && $sub->localName === 'subfield') { + $subfields[] = [$sub->getAttribute('code'), (string) $sub->textContent]; + } + } + $record->addDataField($tag, $child->getAttribute('ind1'), $child->getAttribute('ind2'), $subfields); + break; + } + } + if (!$hasLeader && $record->fields() === []) { + throw new MarcParseException('Empty record'); + } + return $record; + } +} diff --git a/app/Support/Marc/MarcXmlWriter.php b/app/Support/Marc/MarcXmlWriter.php new file mode 100644 index 000000000..d785a37ee --- /dev/null +++ b/app/Support/Marc/MarcXmlWriter.php @@ -0,0 +1,137 @@ +handle = $handle; + $this->xw = new \XMLWriter(); + $this->xw->openMemory(); + } + + public function startCollection(): void + { + $this->xw->startDocument('1.0', 'UTF-8'); + $this->xw->startElementNs(null, 'collection', $this->namespace); + $this->xw->writeAttributeNs('xmlns', 'xsi', null, 'http://www.w3.org/2001/XMLSchema-instance'); + $this->xw->writeAttributeNs('xsi', 'schemaLocation', null, $this->namespace . ' ' + . ($this->namespace === self::NS_MARCXCHANGE ? self::SCHEMA_MARCXCHANGE : self::SCHEMA_MARC21)); + $this->flush(); + } + + /** @param string $type MARCXchange record type ("Bibliographic"); ignored for MARCXML */ + public function writeRecord(MarcRecord $record, string $type = 'Bibliographic'): void + { + $this->xw->startElement('record'); + if ($this->namespace === self::NS_MARCXCHANGE) { + $this->xw->writeAttribute('type', $type); + if ($record->format() !== '') { + $this->xw->writeAttribute('format', $record->format()); + } + } + $this->xw->writeElement('leader', $record->leader()); + self::writeFields($this->xw, $record); + $this->xw->endElement(); + $this->flush(); + } + + public function endCollection(): void + { + $this->xw->endElement(); + $this->xw->endDocument(); + $this->flush(); + } + + /** The XML written so far (memory mode), or '' once flushed to a stream. */ + public function output(): string + { + return $this->xw->outputMemory(true); + } + + private function flush(): void + { + if (is_resource($this->handle)) { + fwrite($this->handle, $this->xw->outputMemory(true)); + } + } + + /** One standalone record as an XML document (MARCXchange records carry their format). */ + public static function recordToXml(MarcRecord $record, string $namespace = self::NS_MARC21): string + { + $xw = new \XMLWriter(); + $xw->openMemory(); + $xw->startDocument('1.0', 'UTF-8'); + $xw->startElementNs(null, 'record', $namespace); + if ($namespace === self::NS_MARCXCHANGE) { + $xw->writeAttribute('type', 'Bibliographic'); + if ($record->format() !== '') { + $xw->writeAttribute('format', $record->format()); + } + } + $xw->writeElement('leader', $record->leader()); + self::writeFields($xw, $record); + $xw->endElement(); + $xw->endDocument(); + return $xw->outputMemory(); + } + + /** The control and data fields of a record, in order. */ + public static function writeFields(\XMLWriter $xw, MarcRecord $record): void + { + foreach ($record->fields() as $field) { + if ($field->data !== null) { + self::controlField($xw, $field->tag, $field->data); + } else { + self::dataField($xw, $field->tag, $field->ind1, $field->ind2, $field->subfields); + } + } + } + + public static function controlField(\XMLWriter $xw, string $tag, string $text): void + { + $xw->startElement('controlfield'); + $xw->writeAttribute('tag', $tag); + $xw->text($text); + $xw->endElement(); + } + + /** + * @param list $subfields + */ + public static function dataField(\XMLWriter $xw, string $tag, string $ind1, string $ind2, array $subfields): void + { + $xw->startElement('datafield'); + $xw->writeAttribute('tag', $tag); + $xw->writeAttribute('ind1', $ind1); + $xw->writeAttribute('ind2', $ind2); + foreach ($subfields as [$code, $value]) { + $xw->startElement('subfield'); + $xw->writeAttribute('code', $code); + $xw->text($value); + $xw->endElement(); + } + $xw->endElement(); + } +} diff --git a/app/Support/Marc/RelatorRoles.php b/app/Support/Marc/RelatorRoles.php new file mode 100644 index 000000000..97cb66dfb --- /dev/null +++ b/app/Support/Marc/RelatorRoles.php @@ -0,0 +1,206 @@ + 'author', 'cre' => 'author', + 'edt' => 'editor', 'edc' => 'editor', 'dkmdt' => 'editor', + 'trl' => 'translator', + 'ill' => 'illustrator', + 'clr' => 'colorist', 'dkfvl' => 'colorist', + // Part of the book, or not its making at all + 'aui' => 'other', 'aft' => 'other', 'win' => 'other', 'wpr' => 'other', 'wfw' => 'other', + 'wac' => 'other', 'wst' => 'other', 'wat' => 'other', 'cwt' => 'other', 'cmm' => 'other', + 'dktek' => 'other', 'pbl' => 'other', 'isb' => 'other', 'prt' => 'other', 'bnd' => 'other', + 'nrt' => 'other', 'dkind' => 'other', 'cov' => 'other', 'bjd' => 'other', 'bkd' => 'other', + 'bdd' => 'other', 'pfr' => 'other', 'crr' => 'other', 'hnr' => 'other', 'dte' => 'other', + 'fmo' => 'other', 'dnr' => 'other', 'rev' => 'other', 'sad' => 'other', 'ths' => 'other', + ]; + + /** UNIMARC (IFLA) codes. */ + private const NUMERIC_CODES = [ + '070' => 'author', + '340' => 'editor', + '730' => 'translator', + '440' => 'illustrator', + '080' => 'other', '075' => 'other', '650' => 'other', '610' => 'other', + '390' => 'other', '320' => 'other', + ]; + + /** + * Terms, in English, Italian, German, French, Spanish, Danish, Norwegian, + * Swedish, Dutch and Polish; full words and the usual abbreviations. + * Tried in this order, so "translated with an introduction" is a + * translation and "edited and translated" an edition. + */ + private const TERMS = [ + 'editor' => '(editor|editors|eds?|edited|edit|hrsg|herausgeber\w*|herausgegeben|red|redaktør|redaktor|redaktör|redacteur|rédacteur|redactor|redigeret|redigerad|redigert|curatore|curatrice|curat[oa]|a cura|cura di|éditeur scientifique|editeur scientifique|edición|edicion|ed lit)', + 'translator' => '(translat\w*|trans|tr|trad|traduttor\w*|tradott\w*|traduzione|traducteur|traductrice|traduit\w*|traduction|traductor\w*|traducid\w*|traducción|übers\w*|ubers\w*|oversætter\w*|oversat\w*|oversetter\w*|oversatt\w*|översätt\w*|översatt\w*|översättning|vertaler|vertaald|vertaling|tłumacz\w*|przeł\w*)', + 'illustrator' => '(illustrat\w*|ill|illus|ilustrad\w*|ilustraci\w*|illustrateur|illustratrice|illustratore|illustré\w*|illustriert\w*|illustreret|illustrerad|illustrert|illustratør|illustratör|tegninger|tegnet af|drawings)', + 'colorist' => '(colou?rist\w*|colou?red by|colou?rs by|kolorist\w*|colorista|coloriste|farvelægger|farvelagt)', + 'other' => '(introduction|introduced|preface|foreword|afterword|postface|commentary|supplementary|notes?|noter|efterskrift|annotat\w*|publisher|einleitung|vorwort|nachwort|kommentar\w*|anmerkungen|forord|efterord|indledning|kommenteret|förord|inledning|préface|présentation|introducción|prólogo|epílogo|introduzione|prefazione|postfazione|commento|editore|verlag|forlag|förlag|éditeur)', + ]; + + /** + * The role of a name, from its codes, its term and the statement of + * responsibility of the record, in that order. 'author' when none of them + * says otherwise. + * + * @param list $codes + */ + public static function resolve(array $codes, ?string $term, ?string $statement, string $name, string $scheme): string + { + // The first code this list knows decides ("aut" before "trl" keeps a + // self-translating author an author); a code it does not know still + // says the name has a part, which keeps it an author when nothing + // else is recognised. + $unknownCode = false; + foreach ($codes as $code) { + $code = self::normaliseCode($code); + if ($code === '' || in_array($code, self::NEUTRAL_CODES, true)) { + continue; + } + if ($scheme === 'unimarc' && isset(self::NUMERIC_CODES[$code])) { + return self::NUMERIC_CODES[$code]; + } + if (isset(self::LETTER_CODES[$code])) { + return self::LETTER_CODES[$code]; + } + $unknownCode = true; + } + if ($unknownCode) { + return 'author'; + } + $term = self::normaliseText((string) $term); + if ($term !== '') { + return self::roleOfText($term) ?? 'author'; + } + if ($statement !== null && $name !== '') { + return self::roleInStatement($statement, $name) ?? 'author'; + } + return 'author'; + } + + /** + * The role a statement of responsibility gives a name: the role words + * between the name and the punctuation before it ("…; translated with an + * introduction by David McDuff" → translator). Null when the statement + * does not mention the name, or mentions it with no role word, as the + * first author usually is ("Fyodor Dostoyevsky; …"). + */ + public static function roleInStatement(string $statement, string $name): ?string + { + $surname = self::surnameOf($name); + if (mb_strlen($surname) < 2) { + return null; + } + $text = self::normaliseText(self::expandAbbreviations($statement), false); + if (preg_match('/(? match (mb_strtolower($m[1])) { + 'ed', 'eds', 'edit' => 'edited ', + 'tr', 'trans' => 'translated ', + 'red' => 'redigeret ', + default => 'illustrated ', + }, + $statement + ); + } + + /** A relator code as the lists above spell it: "edt" from "Edt." or from a relator URI. */ + private static function normaliseCode(string $code): string + { + $code = (string) preg_replace('#^.*/#', '', trim($code)); + return strtolower(trim($code, " .\t")); + } + + private static function normaliseText(string $text, bool $trim = true): string + { + $text = mb_strtolower(trim((string) preg_replace('/\s+/u', ' ', $text))); + // "hrsg." and "hrsg" alike; brackets and parentheses are not part of a role + $text = (string) preg_replace('/[()\[\].]/u', ' ', $text); + $text = trim((string) preg_replace('/\s+/u', ' ', $text)); + return $trim ? trim($text, ' ,;:') : $text; + } + + private static function roleOfText(string $text): ?string + { + $text = self::normaliseText($text); + if ($text === '') { + return null; + } + foreach (self::TERMS as $role => $pattern) { + if (preg_match('/(? 'traduttore', + 'curatore' => 'curatore', + 'illustratore' => 'illustratore', + 'colorista' => 'colorista', + ]; + + public function map(MarcRecord $record): array + { + $parsed = (new UnimarcLibriParser())->fromUnimarcXml( + MarcXmlWriter::recordToXml($record, MarcXmlWriter::NS_MARCXCHANGE) + ); + $data = MarcBookData::empty(); + $data['titolo'] = MarcBookData::clean((string) ($parsed['titolo'] ?? '')); + $data['sottotitolo'] = MarcBookData::clean((string) ($parsed['sottotitolo'] ?? '')); + $data['isbn13'] = (string) ($parsed['isbn13'] ?? ''); + $data['isbn10'] = (string) ($parsed['isbn10'] ?? ''); + $data['anno_pubblicazione'] = isset($parsed['anno_pubblicazione']) ? (string) $parsed['anno_pubblicazione'] : ''; + $data['edizione'] = \App\Support\EditionStatement::clean(MarcBookData::clean((string) ($parsed['edizione'] ?? ''))); + $data['numero_pagine'] = isset($parsed['numero_pagine']) ? (string) $parsed['numero_pagine'] : ''; + $data['collana'] = MarcBookData::clean((string) ($parsed['collana'] ?? '')); + $data['numero_serie'] = MarcBookData::clean((string) ($parsed['numero_serie'] ?? '')); + $data['classificazione_dewey'] = (string) ($parsed['classificazione_dewey'] ?? ''); + $data['lingua'] = MarcLanguage::nameFor((string) $record->subfield('101', 'a')); + if ($data['lingua'] === '' && !empty($parsed['lingua'])) { + $data['lingua'] = (string) $parsed['lingua']; + } + + $subjects = []; + foreach ((array) ($parsed['soggetti'] ?? []) as $subject) { + $term = MarcBookData::clean(is_array($subject) ? (string) ($subject['termine'] ?? '') : '', ' /:;=,.'); + if ($term !== '' && !in_array($term, $subjects, true)) { + $subjects[] = $term; + } + } + $data['parole_chiave'] = $subjects; + + foreach ((array) ($parsed['authors'] ?? []) as $author) { + if (!is_array($author)) { + continue; + } + $name = MarcBookData::cleanName((string) ($author['name'] ?? '')); + if ($name === '') { + continue; + } + $column = self::ROLE_COLUMNS[(string) ($author['ruolo'] ?? '')] ?? 'autori'; + if (!in_array($name, $data[$column], true)) { + $data[$column][] = $name; + } + } + + foreach (array_merge($record->fieldsByTag('210'), $record->fieldsByTag('214')) as $field) { + if ($data['luogo_pubblicazione'] === '') { + $data['luogo_pubblicazione'] = \App\Support\PublicationPlace::clean(MarcBookData::clean($field->subfield('a'))); + } + foreach ($field->subfieldValues('c') as $publisher) { + $publisher = MarcBookData::clean($publisher); + if ($publisher !== '' && !in_array($publisher, $data['editori'], true)) { + $data['editori'][] = $publisher; + } + } + } + $data['dimensioni'] = MarcBookData::clean($record->subfield('215', 'd')); + $data['descrizione'] = trim(implode("\n\n", array_map('trim', $record->subfieldValues('330', 'a')))); + $ean = (string) preg_replace('/\D/', '', (string) $record->subfield('073', 'a')); + if (strlen($ean) === 13 && $ean !== $data['isbn13']) { + $data['ean'] = $ean; + } + $data['issn'] = MarcBookData::clean($record->subfield('011', 'a')); + $data['copertina_url'] = MarcBookData::coverUrlOf($record); + + return $data; + } +} diff --git a/app/Support/Marc/UnimarcLibriParser.php b/app/Support/Marc/UnimarcLibriParser.php new file mode 100644 index 000000000..2dcbc72b0 --- /dev/null +++ b/app/Support/Marc/UnimarcLibriParser.php @@ -0,0 +1,293 @@ + Keys: titolo, sottotitolo, isbn13, isbn10, + * anno_pubblicazione, editore, edizione, numero_pagine, collana, + * numero_serie, lingua, classificazione_dewey, parole_chiave, + * soggetti (list), authors (list of [name, ruolo]). + */ + public function fromUnimarcXml(string $xml): array + { + $out = []; + $authors = []; + $subjects = []; + + $doc = new \DOMDocument(); + $prev = libxml_use_internal_errors(true); + $loaded = $doc->loadXML($xml); + libxml_clear_errors(); + libxml_use_internal_errors($prev); + if (!$loaded) { + return $out; + } + + foreach ($this->elementsByLocalName($doc, 'datafield') as $field) { + $tag = $field->getAttribute('tag'); + $subs = $this->subfieldsOf($field); + + switch ($tag) { + case '010': // ISBN + $isbn = preg_replace('/[^0-9X]/i', '', $subs['a'][0] ?? '') ?? ''; + if ($isbn !== '') { + if (strlen($isbn) === 13) { + $out['isbn13'] = $isbn; + } else { + $out['isbn10'] = $isbn; + } + } + break; + case '101': // Language of document + if (isset($subs['a'][0])) { + $out['lingua'] = $this->langName($subs['a'][0]); + } + break; + case '200': // Title + statement of responsibility + if (isset($subs['a'][0])) { + $out['titolo'] = $subs['a'][0]; + } + if (isset($subs['e'][0])) { + $out['sottotitolo'] = $subs['e'][0]; + } + break; + case '205': // Edition + if (isset($subs['a'][0])) { + $out['edizione'] = $subs['a'][0]; + } + break; + case '210': // Publication (UNIMARC pre-2014) + case '214': // Publication (UNIMARC 2014+) + if (isset($subs['c'][0]) && !isset($out['editore'])) { + $out['editore'] = $subs['c'][0]; + } + if (isset($subs['d'][0]) && !isset($out['anno_pubblicazione'])) { + $year = $this->extractYear($subs['d'][0]); + if ($year !== null) { + $out['anno_pubblicazione'] = $year; + } + } + break; + case '215': // Physical description + if (isset($subs['a'][0])) { + $pages = $this->extractPages($subs['a'][0]); + if ($pages > 0) { + $out['numero_pagine'] = $pages; + } + } + break; + case '225': // Series + if (isset($subs['a'][0])) { + $out['collana'] = $subs['a'][0]; + } + if (isset($subs['v'][0])) { + $out['numero_serie'] = $subs['v'][0]; + } + break; + case '606': // Subject + if (isset($subs['a'][0])) { + $subjects[] = [ + 'termine' => $subs['a'][0], + 'bncf_id' => $subs['3'][0] ?? null, + ]; + } + break; + case '676': // Dewey + if (isset($subs['a'][0])) { + $out['classificazione_dewey'] = trim($subs['a'][0]); + } + break; + case '700': // Primary author + case '701': // Alternative author + case '702': // Secondary intellectual responsibility + $name = $this->composeName($subs); + if ($name !== '') { + $relator = $subs['4'][0] ?? '070'; + $ruolo = $this->roleForRelator($relator); + if ($tag === '700' && $authors === []) { + $ruolo = 'principale'; + } + $authors[] = ['name' => $name, 'ruolo' => $ruolo]; + } + break; + } + } + + if ($authors !== []) { + $out['authors'] = $authors; + $out['autori'] = implode('; ', array_map(static fn(array $a): string => $a['name'], $authors)); + } + if ($subjects !== []) { + $out['soggetti'] = $subjects; + $out['parole_chiave'] = implode(', ', array_map( + static fn(array $s): string => (string) $s['termine'], + $subjects + )); + } + + return $out; + } + + /** + * Map a core libri_autori.ruolo to its UNIMARC $4 relator code. + */ + public static function relatorForRole(string $ruolo): string + { + return match ($ruolo) { + 'traduttore' => '730', // Translator + 'curatore' => '340', // Editor + 'illustratore' => '440', // Illustrator + 'colorista' => '410', // Graphic technician (includes colourists) + default => '070', // Author (principale / co-autore / unknown) + }; + } + + /** + * Map a UNIMARC $4 relator code to a core libri_autori.ruolo. + */ + private function roleForRelator(string $relator): string + { + return match ($relator) { + '730' => 'traduttore', + '340' => 'curatore', + '440' => 'illustratore', + '410' => 'colorista', + default => 'co-autore', + }; + } + + // ── helpers ──────────────────────────────────────────────────────────── + + /** + * @param list $subfields + */ + protected function datafield(\DOMDocument $doc, string $tag, string $ind1, string $ind2, array $subfields): \DOMElement + { + $field = $doc->createElement('datafield'); + $field->setAttribute('tag', $tag); + $field->setAttribute('ind1', $ind1); + $field->setAttribute('ind2', $ind2); + foreach ($subfields as [$code, $value]) { + $sub = $doc->createElement('subfield'); + $sub->setAttribute('code', $code); + $sub->appendChild($doc->createTextNode($value)); + $field->appendChild($sub); + } + return $field; + } + + /** + * @return list<\DOMElement> + */ + private function elementsByLocalName(\DOMDocument $doc, string $localName): array + { + $out = []; + foreach ($doc->getElementsByTagName('*') as $node) { + if ($node->localName === $localName) { + $out[] = $node; + } + } + return $out; + } + + /** + * Collect subfields of a datafield keyed by code (a code may repeat). + * + * Numeric subfield codes ('3', '4', ...) become int array keys in PHP, so + * the key type is int|string. + * + * @return array> + */ + private function subfieldsOf(\DOMElement $field): array + { + $subs = []; + foreach ($field->childNodes as $child) { + if ($child instanceof \DOMElement && $child->localName === 'subfield') { + $code = $child->getAttribute('code'); + $subs[$code][] = trim($child->textContent); + } + } + return $subs; + } + + /** + * Compose a personal name from $a (entry element) + $b (rest of name). + * + * @param array> $subs + */ + private function composeName(array $subs): string + { + $a = trim($subs['a'][0] ?? ''); + $b = trim($subs['b'][0] ?? ''); + if ($a === '') { + return ''; + } + // UNIMARC entry under surname: $a=surname, $b=forename → "Forename Surname" + if ($b !== '') { + $a = rtrim($a, ', '); + return trim($b . ' ' . $a); + } + // Single $a may itself be "Surname, Forename". + if (str_contains($a, ',')) { + [$surname, $forename] = array_map('trim', explode(',', $a, 2)); + if ($surname !== '' && $forename !== '') { + return $forename . ' ' . $surname; + } + } + return $a; + } + + private function extractYear(string $value): ?int + { + if (preg_match('/\b(1[0-9]{3}|20[0-9]{2})\b/', $value, $m)) { + return (int) $m[1]; + } + return null; + } + + private function extractPages(string $desc): int + { + if (preg_match('/(\d+)\s*p/i', $desc, $m)) { + return (int) $m[1]; + } + return 0; + } + + private function langName(string $code): string + { + return match (strtolower(trim($code))) { + 'ita' => 'italiano', + 'eng' => 'inglese', + 'ger', 'deu' => 'tedesco', + 'fre', 'fra' => 'francese', + 'spa' => 'spagnolo', + 'por' => 'portoghese', + 'rus' => 'russo', + 'lat' => 'latino', + default => $code, + }; + } +} From a571f0037f5b9a15e6383312d0557962b7047386 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 06:28:24 +0100 Subject: [PATCH 028/398] Stocktake: a mark-lost close runs to the end The session is claimed before the copies are marked, so a close cut off by the PHP time limit or by the browser going away could not be retried from the page. With "mark lost" the close request now lifts the time limit and ignores a client abort, as the other long admin operations do. --- app/Controllers/InventoryController.php | 7 +++++++ tests/stocktake-snapshot.unit.php | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/Controllers/InventoryController.php b/app/Controllers/InventoryController.php index 5fc21a741..f33040922 100644 --- a/app/Controllers/InventoryController.php +++ b/app/Controllers/InventoryController.php @@ -301,6 +301,13 @@ public function close(Request $request, Response $response, mysqli $db, int $id) $_SESSION['error_message'] = __('Solo un amministratore può chiudere la revisione segnando come perse le copie mancanti.'); return $this->redirect($response, $back)->withStatus(302); } + if ($markLost) { + // The session is claimed before the copies are marked, so a close + // cut off halfway cannot be retried from the page: let it finish + // even on a whole-catalogue session or if the browser goes away. + @set_time_limit(0); + ignore_user_abort(true); + } try { $result = (new StocktakeService($db))->close($id, $markLost, $this->operatorId()); } catch (\DomainException $e) { diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index 8c54b80ae..56bd4a05a 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -12,6 +12,7 @@ */ require dirname(__DIR__) . '/vendor/autoload.php'; +require_once dirname(__DIR__) . '/app/helpers.php'; mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); use App\Services\StocktakeService; @@ -171,6 +172,18 @@ $check($blocked && (int) $one("SELECT COUNT(*) FROM inventario_scansioni WHERE sessione_id = {$third}") === 0, 'a scan racing a pending close is not recorded'); $check($refused(static fn () => $service->scan($third, "{$p}-C5", null, null)), 'once the close commits the scan is refused'); + // Closing with "mark lost" from the page must run to the end: the session + // is claimed first, so a request cut off halfway could not be retried. + $fourth = $service->open('ZZST4 ' . $run, null, ['collocazione_da' => "{$p}.B2", 'collocazione_a' => "{$p}.B2"], null); + $sessionIds[] = $fourth; + ignore_user_abort(false); + $_SESSION = ['user' => ['id' => 0, 'tipo_utente' => 'admin']]; + $request = (new Slim\Psr7\Factory\ServerRequestFactory())->createServerRequest('POST', '/admin/inventory/' . $fourth . '/close') + ->withParsedBody(['azione_mancanti' => 'perso']); + (new App\Controllers\InventoryController())->close($request, new Slim\Psr7\Response(), $db, $fourth); + $check(ignore_user_abort() === 1, 'a mark-lost close keeps running if the browser goes away'); + $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$fourth}") === 'chiusa', 'and closes the session'); + echo "SUCCESS {$checks} stocktake snapshot checks\n"; } finally { foreach ($triggers as $trigger) { From aa61500782784fc0e6920f7ea985786616b0762f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 00:51:45 +0100 Subject: [PATCH 029/398] Move the OAI and Z39 plugins onto the shared MARC classes The OAI server now builds its MARCXML, UNIMARC and ISO 2709 records with BookMarcBuilder and the shared writers; the golden files prove the output is byte for byte the same. The z39-server plugin keeps its RelatorRoles, Danmarc2Parser and UnimarcLibriParser names as thin subclasses of the core classes. The CSV import's row pipeline becomes importParsedRow(), so the MARC import can write books exactly as the CSV import does. --- app/Controllers/CsvImportController.php | 424 +++++++----- app/Support/ContributorSync.php | 2 +- .../oai-pmh-server/OaiPmhServerPlugin.php | 609 +----------------- .../z39-server/classes/Danmarc2Parser.php | 243 +------ .../z39-server/classes/RelatorRoles.php | 198 +----- .../plugins/z39-server/classes/SruClient.php | 25 +- .../z39-server/classes/UnimarcLibriParser.php | 286 +------- 7 files changed, 286 insertions(+), 1501 deletions(-) diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 049e25c19..23fdacedd 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -449,184 +449,10 @@ public function processChunk(Request $request, Response $response, \mysqli $db): } $parsedData = $this->parseCsvRow($row); - $db->begin_transaction(); - - // Resolve the target before creating any related entity. On an - // existing book an unchecked publisher/genre family must be a - // true no-op: creating an unlinked editor/genre from the CSV is - // still a catalogue mutation and leaves orphan rows behind. - $existingBookId = $this->findExistingBook($db, $parsedData); - $isExistingBook = $existingBookId !== null; - - // A title is mandatory for new books and when the title family - // is selected. For a row matched by ID/ISBN/EAN with Titolo - // unchecked, however, a blank cell must be a true no-op: the - // existing NOT NULL title is restored by updateBook(). - if (empty($parsedData['titolo']) && (!$isExistingBook || $updateFields['titolo'])) { - throw new \Exception(__('Titolo mancante')); - } - - // Resolve publisher(s). The exporter joins ALL publishers - // (primary + co-publishers from libri_editori, #143) into the - // single `editore` cell with ';'. Treating the whole cell as ONE - // publisher name created a bogus "A;B" editore on round-trip - // (#12): split it, first = primary, the rest = co-publishers. - $editorId = null; - $coPublisherIds = []; - $publisherCellHasMultiple = false; - if ((!$isExistingBook || $updateFields['publisher']) && !empty($parsedData['editore'])) { - $publisherNames = array_values(array_filter( - array_map('trim', explode(';', (string) $parsedData['editore'])), - static fn (string $n): bool => $n !== '' - )); - $publisherCellHasMultiple = count($publisherNames) > 1; - foreach ($publisherNames as $pIndex => $pName) { - $publisherResult = $this->getOrCreatePublisher($db, $pName); - if ($publisherResult['created']) { - $importData['publishers_created']++; - } - if ($pIndex === 0) { - $editorId = $publisherResult['id']; - } elseif ((int) $publisherResult['id'] !== (int) $editorId) { - $coPublisherIds[] = (int) $publisherResult['id']; - } - } - } - - // Resolve genre, creating it when the CSV names a genre absent - // from the catalogue (#16). Previously getGenreId() only looked up - // existing rows and returned NULL for anything new — so a genre - // present in the export vanished on re-import — unlike the - // authors/publishers auto-create paths. - $genreId = (!$isExistingBook || $updateFields['genre']) - ? $this->getOrCreateGenre($db, $parsedData['genere'] ?? '', $importData) - : null; - - // Upsert book - $upsertResult = $this->upsertBook( - $db, - $parsedData, - $editorId, - $genreId, - $updateFields, - $existingBookId - ); + $upsertResult = $this->importParsedRow($db, $parsedData, $updateFields, $importData); $bookId = $upsertResult['id']; $action = $upsertResult['action']; - // #380: on an existing book each unchecked family is fully - // preserved. Created books always import everything. - $applyFamily = static function (string $family) use ($action, $updateFields): bool { - return $action === 'created' || $updateFields[$family]; - }; - - // Import the co-publishers (#12/#143) ONLY when the cell actually - // carried more than one publisher, so a single-publisher CSV never - // wipes co-publishers a librarian curated in the admin form. - if ($publisherCellHasMultiple && $applyFamily('publisher')) { - $this->syncImportedCoPublishers($db, $bookId, $editorId, $coPublisherIds); - } - - // Remove old PRINCIPAL author links if updating — scoped to - // ruolo='principale' so a re-import re-writes the authors the CSV - // owns without wiping co-author/illustrator/translator/curator/ - // colorist ENTITY links (#237): every explicit contributor - // column is synchronized below with importer provenance. A - // blanket delete-all silently lost every contributor entity on - // each re-import. - // Only rewrite the principal links when the row actually carries - // an author. An EMPTY author cell must NOT wipe the existing - // principal authors — a blanket delete-when-updated left the book - // permanently authorless whenever a re-imported row omitted the - // author (#22). - // #380: with the Autori checkbox off, an existing book's - // principal links are fully preserved (no delete, no rewrite). - if ($action === 'updated' && $applyFamily('authors') && !empty($parsedData['autori'])) { - $stmt = $db->prepare("DELETE FROM libri_autori WHERE libro_id = ? AND ruolo = 'principale'"); - $stmt->bind_param('i', $bookId); - $stmt->execute(); - $stmt->close(); - } - - // Handle authors - if (!empty($parsedData['autori']) && $applyFamily('authors')) { - $separator = strpos($parsedData['autori'], ';') !== false ? ';' : '|'; - $authors = array_map('trim', explode($separator, $parsedData['autori'])); - $authorOrder = 1; - - foreach ($authors as $authorName) { - if (empty($authorName)) continue; - - $authorResult = $this->getOrCreateAuthor($db, $authorName); - $authorId = $authorResult['id']; - if ($authorResult['created']) { - $importData['authors_created']++; - } - - \App\Support\ContributorSync::persistImportedPrincipal( - $db, - $bookId, - $authorId, - $authorOrder - ); - $authorOrder++; - } - } - - // Contributor roles are entities too (#237). Replace only - // links owned by the CSV importer and only for columns that - // were actually present. This distinction matters for old - // exports: an absent column must preserve existing data, while - // an explicit empty cell clears this importer's stale links. - $contributorValues = []; - foreach ([ - 'co-autore' => 'co_autori', - 'traduttore' => 'traduttore', - 'illustratore' => 'illustratore', - 'curatore' => 'curatore', - 'colorista' => 'colorista', - ] as $role => $field) { - if (($parsedData[$field . '_provided'] ?? false) === true) { - $contributorValues[$role] = $parsedData[$field] ?? null; - } - } - // #380: with the Contributori checkbox off, an existing book's - // contributor links are fully preserved (no sync at all). - if ($contributorValues !== [] && $applyFamily('contributors')) { - $importData['authors_created'] += \App\Support\ContributorSync::syncImportedLegacyValues( - $db, - $bookId, - $contributorValues, - 'csv' - ); - } - - $db->commit(); - \App\Support\ContentCache::deferBooksChanged(); - - if ($action === 'created') { - $importData['imported']++; - } else { - $importData['updated']++; - } - - // Plugin hook: after book save. Fired OUTSIDE the per-row - // transaction (it just committed) because listeners such as the - // book-club external-proposal reconciler open their own - // transaction — nesting begin_transaction() would implicitly - // commit. Mirrors LibriController's create/edit dispatch so an - // imported book that matches an outstanding club proposal is - // reconciled at import time, not only on the next maintenance run. - // Isolated in its own try/catch: the row is already committed and - // counted, so a throwing listener must NOT reach the per-row catch - // below (which would rollback — a no-op here — and additionally - // record the committed row as a failed import, double-counting it). - try { - \App\Support\Hooks::do('book.save.after', [$bookId, $parsedData]); - } catch (\Throwable $hookError) { - \App\Support\SecureLogger::warning('[CsvImport] book.save.after listener failed (row already committed)', ['book_id' => $bookId, 'error' => $hookError->getMessage()]); - } - // Scraping (if enabled and ISBN exists) if ($enableScraping && !empty($parsedData['isbn13'])) { try { @@ -848,6 +674,250 @@ public function processChunk(Request $request, Response $response, \mysqli $db): return $response->withHeader('Content-Type', 'application/json'); } + /** + * Provenance of the books this importer writes: 'csv' for the CSV import, + * 'marc' for the MARC bulk import and copy cataloguing, which reuse the + * row pipeline below. Recorded in the activity log and on the + * contributor links the importer owns. + */ + private string $importSource = 'csv'; + + public function setImportSource(string $source): void + { + $this->importSource = $source; + } + + /** + * The row pipeline of the CSV import, shared with the MARC bulk import and + * copy cataloguing: one book written in one transaction — publishers, + * genre, the book itself (insert, or update of the families selected in + * $updateFields), co-publishers, principal authors and contributor links + * — then the book.save.after hook. The counters in $importData (imported, + * updated, authors_created, publishers_created, genres_created) are + * advanced. On failure the transaction is rolled back and the exception + * rethrown, so the caller records the row as failed and goes on. + * + * @param array $parsedData a row as parseCsvRow() returns it + * @param array $updateFields one entry per UPDATE_FIELD_FAMILIES family + * @param array $importData + * @param int|null $existingBookId the book to update when $resolveExisting is false + * @param bool $resolveExisting true: match by ID/ISBN-13/EAN/title+author as the CSV import does + * @return array{id: int, action: string} + */ + public function importParsedRow( + \mysqli $db, + array $parsedData, + array $updateFields, + array &$importData, + ?int $existingBookId = null, + bool $resolveExisting = true + ): array { + foreach (self::UPDATE_FIELD_FAMILIES as $family) { + $updateFields[$family] = (bool) ($updateFields[$family] ?? true); + } + foreach (['imported', 'updated', 'authors_created', 'publishers_created'] as $counter) { + $importData[$counter] = (int) ($importData[$counter] ?? 0); + } + $db->begin_transaction(); + try { + // Resolve the target before creating any related entity. On an + // existing book an unchecked publisher/genre family must be a + // true no-op: creating an unlinked editor/genre from the CSV is + // still a catalogue mutation and leaves orphan rows behind. + if ($resolveExisting) { + $existingBookId = $this->findExistingBook($db, $parsedData); + } + $isExistingBook = $existingBookId !== null; + + // A title is mandatory for new books and when the title family + // is selected. For a row matched by ID/ISBN/EAN with Titolo + // unchecked, however, a blank cell must be a true no-op: the + // existing NOT NULL title is restored by updateBook(). + if (empty($parsedData['titolo']) && (!$isExistingBook || $updateFields['titolo'])) { + throw new \Exception(__('Titolo mancante')); + } + + // Resolve publisher(s). The exporter joins ALL publishers + // (primary + co-publishers from libri_editori, #143) into the + // single `editore` cell with ';'. Treating the whole cell as ONE + // publisher name created a bogus "A;B" editore on round-trip + // (#12): split it, first = primary, the rest = co-publishers. + $editorId = null; + $coPublisherIds = []; + $publisherCellHasMultiple = false; + if ((!$isExistingBook || $updateFields['publisher']) && !empty($parsedData['editore'])) { + $publisherNames = array_values(array_filter( + array_map('trim', explode(';', (string) $parsedData['editore'])), + static fn (string $n): bool => $n !== '' + )); + $publisherCellHasMultiple = count($publisherNames) > 1; + foreach ($publisherNames as $pIndex => $pName) { + $publisherResult = $this->getOrCreatePublisher($db, $pName); + if ($publisherResult['created']) { + $importData['publishers_created']++; + } + if ($pIndex === 0) { + $editorId = $publisherResult['id']; + } elseif ((int) $publisherResult['id'] !== (int) $editorId) { + $coPublisherIds[] = (int) $publisherResult['id']; + } + } + } + + // Resolve genre, creating it when the CSV names a genre absent + // from the catalogue (#16). Previously getGenreId() only looked up + // existing rows and returned NULL for anything new — so a genre + // present in the export vanished on re-import — unlike the + // authors/publishers auto-create paths. + $genreId = (!$isExistingBook || $updateFields['genre']) + ? $this->getOrCreateGenre($db, $parsedData['genere'] ?? '', $importData) + : null; + + // Upsert book + $upsertResult = $this->upsertBook( + $db, + $parsedData, + $editorId, + $genreId, + $updateFields, + $existingBookId + ); + $bookId = $upsertResult['id']; + $action = $upsertResult['action']; + + // #380: on an existing book each unchecked family is fully + // preserved. Created books always import everything. + $applyFamily = static function (string $family) use ($action, $updateFields): bool { + return $action === 'created' || $updateFields[$family]; + }; + + // Import the co-publishers (#12/#143) ONLY when the cell actually + // carried more than one publisher, so a single-publisher CSV never + // wipes co-publishers a librarian curated in the admin form. + if ($publisherCellHasMultiple && $applyFamily('publisher')) { + $this->syncImportedCoPublishers($db, $bookId, $editorId, $coPublisherIds); + } + + // Remove old PRINCIPAL author links if updating — scoped to + // ruolo='principale' so a re-import re-writes the authors the CSV + // owns without wiping co-author/illustrator/translator/curator/ + // colorist ENTITY links (#237): every explicit contributor + // column is synchronized below with importer provenance. A + // blanket delete-all silently lost every contributor entity on + // each re-import. + // Only rewrite the principal links when the row actually carries + // an author. An EMPTY author cell must NOT wipe the existing + // principal authors — a blanket delete-when-updated left the book + // permanently authorless whenever a re-imported row omitted the + // author (#22). + // #380: with the Autori checkbox off, an existing book's + // principal links are fully preserved (no delete, no rewrite). + if ($action === 'updated' && $applyFamily('authors') && !empty($parsedData['autori'])) { + $stmt = $db->prepare("DELETE FROM libri_autori WHERE libro_id = ? AND ruolo = 'principale'"); + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $stmt->close(); + } + + // Handle authors + if (!empty($parsedData['autori']) && $applyFamily('authors')) { + $separator = strpos($parsedData['autori'], ';') !== false ? ';' : '|'; + $authors = array_map('trim', explode($separator, $parsedData['autori'])); + $authorOrder = 1; + + foreach ($authors as $authorName) { + if (empty($authorName)) continue; + + $authorResult = $this->getOrCreateAuthor($db, $authorName); + $authorId = $authorResult['id']; + if ($authorResult['created']) { + $importData['authors_created']++; + } + + \App\Support\ContributorSync::persistImportedPrincipal( + $db, + $bookId, + $authorId, + $authorOrder + ); + $authorOrder++; + } + } + + // Contributor roles are entities too (#237). Replace only + // links owned by the CSV importer and only for columns that + // were actually present. This distinction matters for old + // exports: an absent column must preserve existing data, while + // an explicit empty cell clears this importer's stale links. + $contributorValues = []; + foreach ([ + 'co-autore' => 'co_autori', + 'traduttore' => 'traduttore', + 'illustratore' => 'illustratore', + 'curatore' => 'curatore', + 'colorista' => 'colorista', + ] as $role => $field) { + if (($parsedData[$field . '_provided'] ?? false) === true) { + $contributorValues[$role] = $parsedData[$field] ?? null; + } + } + // #380: with the Contributori checkbox off, an existing book's + // contributor links are fully preserved (no sync at all). + if ($contributorValues !== [] && $applyFamily('contributors')) { + $importData['authors_created'] += \App\Support\ContributorSync::syncImportedLegacyValues( + $db, + $bookId, + $contributorValues, + $this->importSource + ); + } + + $db->commit(); + } catch (\Throwable $e) { + $db->rollback(); + throw $e; + } + \App\Support\ContentCache::deferBooksChanged(); + + if ($action === 'created') { + $importData['imported']++; + } else { + $importData['updated']++; + } + + // Plugin hook: after book save. Fired OUTSIDE the per-row + // transaction (it just committed) because listeners such as the + // book-club external-proposal reconciler open their own + // transaction — nesting begin_transaction() would implicitly + // commit. Mirrors LibriController's create/edit dispatch so an + // imported book that matches an outstanding club proposal is + // reconciled at import time, not only on the next maintenance run. + // Isolated in its own try/catch: the row is already committed and + // counted, so a throwing listener must NOT reach the per-row catch + // below (which would rollback — a no-op here — and additionally + // record the committed row as a failed import, double-counting it). + try { + \App\Support\Hooks::do('book.save.after', [$bookId, $parsedData]); + } catch (\Throwable $hookError) { + \App\Support\SecureLogger::warning('[' . $this->importSource . ' import] book.save.after listener failed (row already committed)', ['book_id' => $bookId, 'error' => $hookError->getMessage()]); + } + + return ['id' => (int) $bookId, 'action' => (string) $action]; + } + + /** + * Normalise a canonical import row (the keys mapColumnHeaders() produces) + * into the book data importParsedRow() writes: ISBN/EAN validation, the + * language, the author list, the contributor columns. + * + * @param array $row + * @return array + */ + public function normalizeImportRow(array $row): array + { + return $this->parseCsvRow($row); + } + /** * Genera CSV di esempio con intestazioni e dati di esempio * @@ -1978,7 +2048,7 @@ classificazione_dewey = ?, $activityBefore, $activityAfter, bookTitle: (string) ($activityAfter['titolo'] ?? $activityBefore['titolo'] ?? ''), - source: 'csv' + source: $this->importSource ); } @@ -2249,7 +2319,7 @@ classificazione_dewey, stato, created_at{$desiderataCol}{$cataloguedCol} [], $activityAfter, bookTitle: (string) ($activityAfter['titolo'] ?? $titolo), - source: 'csv' + source: $this->importSource ); return $bookId; diff --git a/app/Support/ContributorSync.php b/app/Support/ContributorSync.php index 768f72978..b970400eb 100644 --- a/app/Support/ContributorSync.php +++ b/app/Support/ContributorSync.php @@ -20,7 +20,7 @@ final class ContributorSync private const ROLES = ['co-autore', 'traduttore', 'illustratore', 'curatore', 'colorista']; /** @var list */ - private const IMPORT_SOURCES = ['csv', 'librarything', 'legacy-backfill']; + private const IMPORT_SOURCES = ['csv', 'librarything', 'marc', 'legacy-backfill']; /** * Split a legacy contributor value into individual names. diff --git a/storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php b/storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php index 8604d203a..4b36aa6c5 100644 --- a/storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php +++ b/storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php @@ -14,6 +14,10 @@ public function __construct(string $prefix) } use App\Support\HookManager; +use App\Support\Marc\BookMarcBuilder; +use App\Support\Marc\Iso2709Writer; +use App\Support\Marc\MarcLanguage; +use App\Support\Marc\MarcXmlWriter; use App\Support\SecureLogger; use mysqli; use Psr\Http\Message\ResponseInterface; @@ -91,13 +95,6 @@ class OaiPmhServerPlugin private const MAG_NS = 'http://www.iccu.sbn.it/metaAG1.pdf'; private const MAG_SCHEMA = 'https://www.iccu.sbn.it/pagine/metadigit.xsd'; private const NS_MARCXCHANGE = 'info:lc/xmlns/marcxchange-v2'; - /** - * UNIMARC leader template (record length and base address are filled by - * the ISO 2709 serializer): n a m, pos 8-9 blank (pos 9 undefined in - * UNIMARC), indicator/subfield counts 2 2, pos 17-19 blank (full level, - * pos 18 blank because ISBD punctuation is not emitted), entry map 4500. - */ - private const UNIMARC_LEADER_TEMPLATE = '00000nam 2200000 4500'; private const SCHEMA_MARCXCHANGE = 'http://www.loc.gov/standards/iso25577/marcxchange-2-0.xsd'; public function setPluginId(int $pluginId): void @@ -1765,28 +1762,6 @@ private function writeBookOaiDc( // ── MARCXML for books ───────────────────────────────────────────────────── - /** - * Return the responsibility entry that must own the main-entry field. - * A principal creator always wins even if a caller supplied contributors - * or co-authors first; co-author is only the legacy fallback. - * - * @param list> $authors - */ - private function primaryCreatorIndex(array $authors): ?int - { - $coauthorIndex = null; - foreach ($authors as $index => $author) { - $role = (string) ($author['ruolo'] ?? ''); - if ($role === 'principale') { - return $index; - } - if ($role === 'co-autore' && $coauthorIndex === null) { - $coauthorIndex = $index; - } - } - return $coauthorIndex; - } - /** * @param array $row * @param list> $authors @@ -1800,209 +1775,18 @@ private function writeBookMarcXml( array $publishers, ?array $genre ): void { - $xw->startElementNs(null, 'record', 'http://www.loc.gov/MARC21/slim'); - $xw->writeAttributeNs('xsi', 'schemaLocation', null, - 'http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd'); - - // Leader: type 'a' (language material), bibliographic level 'm' - // (monograph), 's' for a serial. Leader/18 stays blank: the record - // carries no ISBD punctuation, so 'i' (ISBD) would be a false claim. - $xw->writeElement('leader', '00000na' . ($this->isSerialRecord($row) ? 's' : 'm') . ' a2200000 4500'); - - // 001 — Control number (book id) - $this->marcControlField($xw, '001', (string) $row['id']); - - // 003 — MARC organization code - $this->marcControlField($xw, '003', 'IT-Pinakes'); - - // 005 — Date/time of latest transaction - $ts = strtotime((string) ($row['updated_at'] ?? 'now')) ?: time(); - $this->marcControlField($xw, '005', gmdate('YmdHis', $ts) . '.0'); - - // 008 — Fixed-length data elements (exactly 40 characters) - $this->marcControlField($xw, '008', $this->marc21Field008($row)); - - // 020 — ISBN - if (!empty($row['isbn13'])) { - $this->marcDataField($xw, '020', ' ', ' ', [['a', (string) $row['isbn13']]]); - } - if (!empty($row['isbn10'])) { - $this->marcDataField($xw, '020', ' ', ' ', [['a', (string) $row['isbn10']]]); - } - - // 022 — ISSN of the resource itself: serials only. On a monograph the - // ISSN belongs to its series and is carried in 490 $x below. - if (!empty($row['issn']) && $this->isSerialRecord($row)) { - $this->marcDataField($xw, '022', ' ', ' ', [['a', (string) $row['issn']]]); - } - - // 040 — Cataloging source - $this->marcDataField($xw, '040', ' ', ' ', [ - ['a', 'IT-Pinakes'], - ['b', 'ita'], - ['e', 'rda'], - ['c', 'IT-Pinakes'], - ]); - - // 041 — Language - if (!empty($row['lingua'])) { - $this->marcDataField($xw, '041', '0', ' ', [ - ['a', $this->iso639_3ToMarc((string) $row['lingua'])], - ]); - } - - // 082 — Dewey classification - if (!empty($row['classificazione_dewey'])) { - $this->marcDataField($xw, '082', '0', '4', [ - ['a', (string) $row['classificazione_dewey']], - ['2', '23'], - ]); - } - - // 100/700 — creators and role-aware contributors. - $primaryCreatorIndex = $this->primaryCreatorIndex($authors); - foreach ($authors as $index => $a) { - $name = (string) $a['nome']; - $role = (string) ($a['ruolo'] ?? ''); - $relator = match ($role) { - 'traduttore' => 'translator', - 'illustratore' => 'illustrator', - 'curatore' => 'editor', - 'colorista' => 'colorist', - default => 'author', - }; - if ($index === $primaryCreatorIndex) { - $this->marcDataField($xw, '100', '1', ' ', [ - ['a', $name], - ['e', $relator], - ]); - } else { - $this->marcDataField($xw, '700', '1', ' ', [ - ['a', $name], - ['e', $relator], - ]); - } - } - - // 245 — Title statement - $titleSubs = [['a', (string) ($row['titolo'] ?? '')]]; - if (!empty($row['sottotitolo'])) { - $titleSubs[] = ['b', (string) $row['sottotitolo']]; - } - $entityRoles = array_map(static fn (array $a): string => (string) ($a['ruolo'] ?? ''), $authors); - if (!empty($row['traduttore']) && !in_array('traduttore', $entityRoles, true)) { - $titleSubs[] = ['c', 'traduzione di ' . (string) $row['traduttore']]; - } - $ind1 = $primaryCreatorIndex !== null ? '1' : '0'; - $this->marcDataField($xw, '245', $ind1, '0', $titleSubs); - - // 250 — Edition - if (!empty($row['edizione'])) { - $this->marcDataField($xw, '250', ' ', ' ', [['a', (string) $row['edizione']]]); - } - - // 264 — Production/Publication (RDA). MARC21 $b (publisher name) is - // repeatable, so multiple publishers become repeated $b in one 264. - $pubSubs = []; - // $a — place of publication, as printed (core 0.7.89). - if (!empty($row['luogo_pubblicazione'])) { - $pubSubs[] = ['a', (string) $row['luogo_pubblicazione']]; - } - foreach ($publishers as $pub) { - if (!empty($pub['nome'])) { - $pubSubs[] = ['b', (string) $pub['nome']]; - } - } - if (!empty($row['anno_pubblicazione'])) { - $pubSubs[] = ['c', (string) $row['anno_pubblicazione']]; - } - if (!empty($pubSubs)) { - $this->marcDataField($xw, '264', ' ', '1', $pubSubs); - } - - // 300 — Physical description - $physSubs = []; - if (!empty($row['numero_pagine'])) { - $physSubs[] = ['a', (string) $row['numero_pagine'] . ' pages']; - } - if (!empty($row['dimensioni'])) { - $physSubs[] = ['c', (string) $row['dimensioni']]; - } - if (!empty($physSubs)) { - $this->marcDataField($xw, '300', ' ', ' ', $physSubs); - } - - // 490 — Series statement - if (!empty($row['collana'])) { - $serSubs = [['a', (string) $row['collana']]]; - if (!empty($row['issn']) && !$this->isSerialRecord($row)) { - $serSubs[] = ['x', (string) $row['issn']]; - } - if (!empty($row['numero_serie'])) { - $serSubs[] = ['v', (string) $row['numero_serie']]; - } - $this->marcDataField($xw, '490', '0', ' ', $serSubs); - } - - // 520 — Summary - $desc = !empty($row['descrizione_plain']) ? $row['descrizione_plain'] : ($row['descrizione'] ?? ''); - if ($desc !== '') { - $this->marcDataField($xw, '520', ' ', ' ', [['a', strip_tags((string) $desc)]]); - } - - // 650 — Subject - if ($genre !== null && !empty($genre['nome'])) { - $this->marcDataField($xw, '650', ' ', '4', [['a', (string) $genre['nome']]]); - } - if (!empty($row['parole_chiave'])) { - foreach (explode(',', (string) $row['parole_chiave']) as $kw) { - $kw = trim($kw); - if ($kw !== '') { - $this->marcDataField($xw, '650', ' ', '4', [['a', $kw]]); - } - } - } - - // 700 — legacy contributors not already represented by entities. - $entityRoles = array_map(static fn (array $a): string => (string) ($a['ruolo'] ?? ''), $authors); - foreach (['traduttore' => 'translator', 'illustratore' => 'illustrator', 'curatore' => 'editor'] as $col => $role) { - if (!empty($row[$col]) && !in_array($col, $entityRoles, true)) { - $this->marcDataField($xw, '700', '1', ' ', [ - ['a', (string) $row[$col]], - ['e', $role], - ]); - } - } + // The MARC 21 record itself comes from the shared builder, which the + // bulk MARC export also uses (App\Support\Marc\BookMarcBuilder). + $record = BookMarcBuilder::marc21($row, $authors, $publishers, $genre); + $xw->startElementNs(null, 'record', MarcXmlWriter::NS_MARC21); + $xw->writeAttributeNs('xsi', 'schemaLocation', null, + MarcXmlWriter::NS_MARC21 . ' ' . MarcXmlWriter::SCHEMA_MARC21); + $xw->writeElement('leader', $record->leader()); + MarcXmlWriter::writeFields($xw, $record); $xw->endElement(); // record } - private function marcControlField(\XMLWriter $xw, string $tag, string $text): void - { - $xw->startElement('controlfield'); - $xw->writeAttribute('tag', $tag); - $xw->text($text); - $xw->endElement(); - } - - /** - * @param list $subfields - */ - private function marcDataField(\XMLWriter $xw, string $tag, string $ind1, string $ind2, array $subfields): void - { - $xw->startElement('datafield'); - $xw->writeAttribute('tag', $tag); - $xw->writeAttribute('ind1', $ind1); - $xw->writeAttribute('ind2', $ind2); - foreach ($subfields as [$code, $value]) { - $xw->startElement('subfield'); - $xw->writeAttribute('code', $code); - $xw->text($value); - $xw->endElement(); - } - $xw->endElement(); - } - // ── UNIMARC for books ───────────────────────────────────────────────────── /** @@ -2034,252 +1818,13 @@ private function writeBookUnimarc( $xw->writeAttributeNs('xsi', 'schemaLocation', null, self::NS_MARCXCHANGE . ' ' . self::SCHEMA_MARCXCHANGE); - $xw->writeElement('leader', self::UNIMARC_LEADER_TEMPLATE); - - foreach ($this->unimarcFields($row, $authors, $publishers, $genre) as [$tag, $ind1, $ind2, $data]) { - if ($ind1 === null || $ind2 === null || is_string($data)) { - $this->marcControlField($xw, $tag, is_string($data) ? $data : ''); - } else { - $this->marcDataField($xw, $tag, $ind1, $ind2, $data); - } - } + $record = BookMarcBuilder::unimarc($row, $authors, $publishers, $genre); + $xw->writeElement('leader', $record->leader()); + MarcXmlWriter::writeFields($xw, $record); $xw->endElement(); // record } - /** - * The UNIMARC Bibliographic fields of a book, in tag order — one source - * for the MARCXchange writer and the ISO 2709 serializer, so the two - * downloads can never disagree. Control fields (001-005) carry their - * value as a string and null indicators. - * - * @param array $row - * @param list> $authors - * @param list> $publishers - * @param array|null $genre - * @return list}> - */ - private function unimarcFields(array $row, array $authors, array $publishers, ?array $genre): array - { - $fields = []; - - // 001 — Record identifier - $fields[] = ['001', null, null, (string) $row['id']]; - // 003 — Persistent record identifier source (repository base URL) - $fields[] = ['003', null, null, absoluteUrl('/')]; - // 005 — Version identifier: date/time of the latest modification - $updated = strtotime((string) ($row['updated_at'] ?? '')) ?: time(); - $fields[] = ['005', null, null, gmdate('YmdHis', $updated) . '.0']; - - // 010 — ISBN only. 073 — EAN (an EAN that is just the ISBN-13 again - // is not repeated). - foreach (['isbn13', 'isbn10'] as $col) { - if (!empty($row[$col])) { - $fields[] = ['010', ' ', ' ', [['a', (string) $row[$col]]]]; - break; - } - } - $ean = trim((string) ($row['ean'] ?? '')); - $isbn13Digits = preg_replace('/\D/', '', (string) ($row['isbn13'] ?? '')); - if ($ean !== '' && preg_replace('/\D/', '', $ean) !== $isbn13Digits) { - $fields[] = ['073', ' ', ' ', [['a', $ean]]]; - } - - // 100 — General processing data - $langCode = $this->iso639_3ToMarc((string) ($row['lingua'] ?? 'italiano')); - $fields[] = ['100', ' ', ' ', [['a', $this->unimarcField100a($row)]]]; - - // 101 — Language of the item - $fields[] = ['101', '0', ' ', [['a', $langCode]]]; - - // 102 — Country of publication - $fields[] = ['102', ' ', ' ', [['a', 'IT']]]; - - // 200 — Title and statement of responsibility - $subs200 = [['a', (string) ($row['titolo'] ?? '')]]; - if (!empty($row['sottotitolo'])) { - $subs200[] = ['e', (string) $row['sottotitolo']]; - } - $primaryCreatorIndex = $this->primaryCreatorIndex($authors); - if ($primaryCreatorIndex !== null) { - $subs200[] = ['f', (string) $authors[$primaryCreatorIndex]['nome']]; - } - $fields[] = ['200', '1', ' ', $subs200]; - - // 205 — Edition statement - if (!empty($row['edizione'])) { - $fields[] = ['205', ' ', ' ', [['a', (string) $row['edizione']]]]; - } - - // 210 — Publication, distribution. $c (publisher name) is repeatable, - // so multiple publishers become repeated $c in one 210. - $subs210 = []; - if (!empty($row['luogo_pubblicazione'])) { - $subs210[] = ['a', (string) $row['luogo_pubblicazione']]; - } - foreach ($publishers as $pub) { - if (!empty($pub['nome'])) { - $subs210[] = ['c', (string) $pub['nome']]; - } - } - $year = (string) ($row['anno_pubblicazione'] ?? ''); - if ($year !== '') { - $subs210[] = ['d', $year]; - } - if ($subs210 !== []) { - $fields[] = ['210', ' ', ' ', $subs210]; - } - - // 215 — Physical description - $subs215 = []; - if (!empty($row['numero_pagine'])) { - $subs215[] = ['a', $row['numero_pagine'] . ' p.']; - } - if (!empty($row['dimensioni'])) { - $subs215[] = ['d', (string) $row['dimensioni']]; - } - if ($subs215 !== []) { - $fields[] = ['215', ' ', ' ', $subs215]; - } - - // 225 — Series (UNIMARC equivalent of MARC21 490) - if (!empty($row['collana'])) { - $subs225 = [['a', (string) $row['collana']]]; - if (!empty($row['numero_serie'])) { - $subs225[] = ['v', (string) $row['numero_serie']]; - } - $fields[] = ['225', '0', ' ', $subs225]; - } - - // 330 — Summary / abstract - $desc = !empty($row['descrizione_plain']) ? $row['descrizione_plain'] : ($row['descrizione'] ?? ''); - if ($desc !== '') { - $fields[] = ['330', ' ', ' ', [['a', strip_tags((string) $desc)]]]; - } - - // 606 — Subject: genre, then keywords - if ($genre !== null && !empty($genre['nome'])) { - $fields[] = ['606', ' ', ' ', [['a', (string) $genre['nome']]]]; - } - if (!empty($row['parole_chiave'])) { - foreach (explode(',', (string) $row['parole_chiave']) as $kw) { - $kw = trim($kw); - if ($kw !== '') { - $fields[] = ['606', ' ', ' ', [['a', $kw]]]; - } - } - } - - // 700/701 — primary and alternative intellectual responsibility. - // 702 — secondary responsibility (translator, illustrator, etc.). - $responsibility = []; - foreach ($authors as $index => $a) { - $role = (string) ($a['ruolo'] ?? ''); - $isCreator = in_array($role, ['principale', 'co-autore'], true); - $tag = $isCreator ? ($index === $primaryCreatorIndex ? '700' : '701') : '702'; - $relCode = match ($role) { - 'traduttore' => '730', - 'curatore' => '340', - 'illustratore' => '440', - 'colorista' => '410', - default => '070', - }; - [$ind2, $nameSubs] = $this->unimarcPersonalName((string) $a['nome']); - $nameSubs[] = ['4', $relCode]; - $responsibility[$tag][] = [$tag, ' ', $ind2, $nameSubs]; - } - foreach (['700', '701', '702'] as $tag) { - foreach ($responsibility[$tag] ?? [] as $field) { - $fields[] = $field; - } - } - - // 801 — Originating source - $fields[] = ['801', ' ', '0', [ - ['a', 'IT'], - ['b', 'Pinakes'], - ['c', gmdate('Ymd')], - ]]; - - return $fields; - } - - /** - * UNIMARC 100 $a — general processing data, exactly 36 positions: - * 0-7 date entered (YYYYMMDD) · 8 type of publication date (d single - * known date, u unknown) · 9-12 date 1 · 13-16 date 2 · 17-19 target - * audience · 20 government publication (y not a government - * publication) · 21 modified record (0) · 22-24 language of - * cataloguing · 25 transliteration (y none) · 26-29 character set - * (50 = ISO 10646) · 30-33 additional character sets · 34-35 script - * of title (ba = Latin). - * - * @param array $row - */ - private function unimarcField100a(array $row): string - { - $entered = strtotime((string) ($row['created_at'] ?? '')) ?: time(); - $year = trim((string) ($row['anno_pubblicazione'] ?? '')); - $known = strlen($year) === 4 && ctype_digit($year); - - $value = date('Ymd', $entered) - . ($known ? 'd' : 'u') - . ($known ? $year : ' ') - . ' ' - . ' ' - . 'y' - . '0' - . $this->cataloguingLanguage() - . 'y' - . '50 ' - . ' ' - . 'ba'; - - return $value; - } - - /** ISO 639-2/B language of cataloguing, from the installation locale. */ - private function cataloguingLanguage(): string - { - try { - $locale = \App\Support\I18n::getInstallationLocale(); - } catch (\Throwable) { - $locale = 'it_IT'; - } - return match (strtolower(substr($locale, 0, 2))) { - 'en' => 'eng', - 'de' => 'ger', - 'fr' => 'fre', - 'da' => 'dan', - default => 'ita', - }; - } - - /** - * UNIMARC personal name (7XX): [ind2, subfields]. A name that can be - * inverted ("Surname, Forename", or a multi-word name whose last word is - * taken as the surname) is entered under the surname — ind2 1, $a surname - * $b forename; a single-word name stays in direct order — ind2 0, $a. - * - * @return array{0: string, 1: list} - */ - private function unimarcPersonalName(string $name): array - { - $name = trim(preg_replace('/\s+/u', ' ', $name) ?? $name); - if (str_contains($name, ',')) { - [$surname, $forename] = array_map('trim', explode(',', $name, 2)); - if ($surname !== '' && $forename !== '') { - return ['1', [['a', $surname], ['b', $forename]]]; - } - } - $words = $name === '' ? [] : explode(' ', $name); - if (count($words) >= 2) { - $surname = (string) array_pop($words); - return ['1', [['a', $surname], ['b', implode(' ', $words)]]]; - } - return ['0', [['a', $name]]]; - } - // ── MODS 3.7 for books ──────────────────────────────────────────────────── /** @@ -4298,45 +3843,13 @@ private function recordDatestamp(array $rec): string return $ts !== false ? gmdate('Y-m-d\TH:i:s\Z', $ts) : gmdate('Y-m-d\TH:i:s\Z'); } - /** - * Convert common language name/code to ISO 639-2/B three-letter MARC code - * ('und' when unknown). A multi-language value uses its first language. - */ - private function iso639_3ToMarc(string $lang): string - { - $first = $this->languageList($lang)[0] ?? ''; - return $this->languageCode($first) ?? 'und'; - } - /** * ISO 639-2/B code for one stored language (Italian/English/native name, * ISO 639-1 or 639-2 code), or null when it is not recognised. */ private function languageCode(string $lang): ?string { - static $map = [ - 'italiano' => 'ita', 'italian' => 'ita', 'it' => 'ita', 'ita' => 'ita', - 'inglese' => 'eng', 'english' => 'eng', 'en' => 'eng', 'eng' => 'eng', - 'francese' => 'fre', 'français' => 'fre', 'french' => 'fre', 'fr' => 'fre', 'fre' => 'fre', 'fra' => 'fre', - 'tedesco' => 'ger', 'deutsch' => 'ger', 'german' => 'ger', 'de' => 'ger', 'ger' => 'ger', 'deu' => 'ger', - 'spagnolo' => 'spa', 'español' => 'spa', 'spanish' => 'spa', 'es' => 'spa', 'spa' => 'spa', - 'portoghese' => 'por', 'português' => 'por', 'portuguese' => 'por', 'pt' => 'por', 'por' => 'por', - 'danese' => 'dan', 'dansk' => 'dan', 'danish' => 'dan', 'da' => 'dan', 'dan' => 'dan', - 'olandese' => 'dut', 'nederlands' => 'dut', 'dutch' => 'dut', 'nl' => 'dut', 'dut' => 'dut', 'nld' => 'dut', - 'svedese' => 'swe', 'svenska' => 'swe', 'swedish' => 'swe', 'sv' => 'swe', 'swe' => 'swe', - 'norvegese' => 'nor', 'norsk' => 'nor', 'norwegian' => 'nor', 'no' => 'nor', 'nor' => 'nor', - 'russo' => 'rus', 'russian' => 'rus', 'ru' => 'rus', 'rus' => 'rus', - 'polacco' => 'pol', 'polski' => 'pol', 'polish' => 'pol', 'pl' => 'pol', 'pol' => 'pol', - 'greco' => 'gre', 'greek' => 'gre', 'el' => 'gre', 'gre' => 'gre', 'ell' => 'gre', - 'greco antico' => 'grc', 'grc' => 'grc', - 'latino' => 'lat', 'latin' => 'lat', 'la' => 'lat', 'lat' => 'lat', - 'cinese' => 'chi', 'chinese' => 'chi', 'zh' => 'chi', 'chi' => 'chi', 'zho' => 'chi', - 'giapponese' => 'jpn', 'japanese' => 'jpn', 'ja' => 'jpn', 'jpn' => 'jpn', - 'arabo' => 'ara', 'arabic' => 'ara', 'ar' => 'ara', 'ara' => 'ara', - 'catalano' => 'cat', 'català' => 'cat', 'catalan' => 'cat', 'ca' => 'cat', 'cat' => 'cat', - ]; - $key = mb_strtolower(trim($lang)); - return $map[$key] ?? null; + return MarcLanguage::codeFor($lang); } /** @@ -4346,8 +3859,7 @@ private function languageCode(string $lang): ?string */ private function languageList(string $lang): array { - $parts = preg_split('/\s*[,;\/]\s*/', trim($lang)) ?: []; - return array_values(array_filter(array_map('trim', $parts), static fn (string $p): bool => $p !== '')); + return MarcLanguage::listOf($lang); } /** DCMI Type Vocabulary term for a libri.tipo_media value. */ @@ -4368,43 +3880,6 @@ private function isSerialRecord(array $row): bool return in_array(strtolower((string) ($row['tipo_media'] ?? '')), ['periodical', 'serial', 'periodico', 'rivista'], true); } - /** - * MARC 21 008 for books/serials: exactly 40 positions (same layout as the - * Z39.50 server's MARCXMLFormatter::generateField008()). - * 00-05 date entered (YYMMDD), 06 date type, 07-10 date 1, 11-14 date 2, - * 15-17 place of publication, 35-37 language, 39 cataloguing source. - * - * @param array $row - */ - private function marc21Field008(array $row): string - { - $field = str_repeat(' ', 40); - $created = strtotime((string) ($row['created_at'] ?? '')) ?: time(); - $field = substr_replace($field, date('ymd', $created), 0, 6); - - $isSerial = $this->isSerialRecord($row); - $field = substr_replace($field, $isSerial ? 'c' : 's', 6, 1); - - $year = trim((string) ($row['anno_pubblicazione'] ?? '')); - if ($year !== '' && ctype_digit($year) && strlen($year) <= 4) { - $field = substr_replace($field, str_pad($year, 4, '0', STR_PAD_LEFT), 7, 4); - } - if ($isSerial) { - // Continuing resource still published: date 2 = 9999. - $field = substr_replace($field, '9999', 11, 4); - } - - // Place of publication unknown/unspecified. - $field = substr_replace($field, 'xx ', 15, 3); - - if (trim((string) ($row['lingua'] ?? '')) !== '') { - $field = substr_replace($field, $this->iso639_3ToMarc((string) $row['lingua']), 35, 3); - } - - // 39 — cataloguing source: 'd' (other than a national agency). - return substr_replace($field, 'd', 39, 1); - } - private function oaiError(\XMLWriter $xw, string $code, string $message): void { $xw->startElement('error'); @@ -4602,52 +4077,10 @@ private function bookToIso2709( array $publishers, ?array $genre ): string { - $FT = "\x1E"; // Field terminator - $RT = "\x1D"; // Record terminator - $SF = "\x1F"; // Subfield delimiter - - // Each entry: [tag, ind1|null, ind2|null, data_string] - // Control fields (001-009): ind1/ind2 = null - /** @var list $fields */ - $fields = []; - foreach ($this->unimarcFields($row, $authors, $publishers, $genre) as [$tag, $ind1, $ind2, $data]) { - if (is_string($data)) { - $fields[] = [$tag, null, null, $data]; - continue; - } - $encoded = ''; - foreach ($data as [$code, $value]) { - $encoded .= $SF . $code . $value; - } - $fields[] = [$tag, $ind1, $ind2, $encoded]; - } - - // ── Build directory and field data section ───────────────────────────── - $directory = ''; - $fieldData = ''; - $pos = 0; - - foreach ($fields as [$tag, $ind1, $ind2, $data]) { - $isControl = ($ind1 === null); - $fieldContent = $isControl ? ($data . $FT) : ($ind1 . $ind2 . $data . $FT); - $len = strlen($fieldContent); - $directory .= $tag . sprintf('%04d', $len) . sprintf('%05d', $pos); - $fieldData .= $fieldContent; - $pos += $len; - } - $directory .= $FT; // directory block ends with field terminator - - $baseAddr = 24 + strlen($directory); - $recordLength = $baseAddr + strlen($fieldData) + 1; // +1 for record terminator - - // Same leader as the MARCXchange record (UNIMARC_LEADER_TEMPLATE), - // with the record length (00-04) and base address (12-16) filled in. - $leader = sprintf('%05d', $recordLength) - . substr(self::UNIMARC_LEADER_TEMPLATE, 5, 7) - . sprintf('%05d', $baseAddr) - . substr(self::UNIMARC_LEADER_TEMPLATE, 17); - - return $leader . $directory . $fieldData . $RT; + // Same record as the MARCXchange download, serialized by the shared + // ISO 2709 writer: leader length (00-04) and base address (12-16) + // filled in, every other leader position as the template says. + return Iso2709Writer::serialize(BookMarcBuilder::unimarc($row, $authors, $publishers, $genre)); } // ── Digital-assets admin UI ─────────────────────────────────────────────── diff --git a/storage/plugins/z39-server/classes/Danmarc2Parser.php b/storage/plugins/z39-server/classes/Danmarc2Parser.php index cb8440ec4..b9d5855a8 100644 --- a/storage/plugins/z39-server/classes/Danmarc2Parser.php +++ b/storage/plugins/z39-server/classes/Danmarc2Parser.php @@ -3,245 +3,12 @@ namespace Plugins\Z39Server\Classes; -use DOMNode; -use DOMXPath; - /** - * A danMARC2 record, as Danish catalogues deliver it inside MARCXchange - * (``), turned into the book data the form reads. - * - * danMARC2 shares tag numbers with MARC 21 but not its subfields, so neither - * the MARC 21 nor the UNIMARC reader can read it (kat-format.dk, 2nd edition): - * - * 021 *e ISBN-13, *a ISBN-10 - * 008 *a year, *l language (ISO 639-2/B) - * 100 / 700 *a surname, *h forenames, *k forenames in full, *4 function - * codes (appendix J: the Library of Congress list plus dk… codes; "led" - * only marks the main responsibility), *b function term ("red.") - * 720 *o a name in direct order ("Hanna Lützen"), *4 function code - * 245 *a title, *b rest of title, *c subtitle, *e / *f statement of - * responsibility - * 250 *a edition 260 *a place, *b publisher, *c year 300 *a extent - * 440 *a series, *v number 504 *a summary 666 *s / *o subject terms + * danMARC2 records (MARCXchange `format="danMARC2"`) read into the book data + * the form reads. The reader lives in core + * ({@see \App\Support\Marc\Danmarc2Parser}), shared with the MARC bulk + * importer; this name stays for the plugin's callers. */ -final class Danmarc2Parser +final class Danmarc2Parser extends \App\Support\Marc\Danmarc2Parser { - /** A MARCXchange record that declares itself danMARC2. */ - public static function isDanmarc2(DOMNode $record): bool - { - return $record instanceof \DOMElement && stripos($record->getAttribute('format'), 'danmarc') !== false; - } - - /** - * The danMARC2 record of the response that carries the ISBN searched for, - * else the first one. A Danish catalogue answers an ISBN with every - * manifestation it groups with it (the audiobook, the e-book, other - * printings), not only the edition asked about. - */ - public static function selectRecord(DOMXPath $xpath, string $isbn): ?DOMNode - { - $records = $xpath->query("//*[local-name()='record'][@format]"); - if ($records === false) { - return null; - } - $first = null; - $isbn = strtoupper((string) preg_replace('/[^0-9Xx]/', '', $isbn)); - foreach ($records as $record) { - if (!self::isDanmarc2($record)) { - continue; - } - $first ??= $record; - if ($isbn === '') { - break; - } - foreach (array_merge(self::all($xpath, $record, '021', 'e'), self::all($xpath, $record, '021', 'a')) as $value) { - if (strtoupper((string) preg_replace('/[^0-9Xx]/', '', $value)) === $isbn) { - return $record; - } - } - } - return $first; - } - - /** @return array */ - public static function parse(DOMXPath $xpath, DOMNode $record, callable $languageName): array - { - $get = static fn(string $tag, string $code): ?string => self::all($xpath, $record, $tag, $code)[0] ?? null; - - $book = [ - 'title' => '', - 'subtitle' => '', - 'authors' => [], - 'publisher' => '', - 'pubDate' => '', - 'year' => '', - 'isbn13' => '', - 'isbn10' => '', - 'language' => '', - 'pages' => '', - 'description' => '', - 'classificazione_dewey' => '', - 'source' => 'Z39.50/SRU (danMARC2)', - ]; - - $title = trim(implode(' ', array_filter([$get('245', 'a'), $get('245', 'b')]))); - $book['title'] = self::clean($title); - $book['subtitle'] = self::clean((string) $get('245', 'c')); - - // Names: 100 is the main author; 700 and 720 follow their function - // codes, their term or the statement of responsibility. - $statement = implode(' ; ', array_merge(self::all($xpath, $record, '245', 'e'), self::all($xpath, $record, '245', 'f'))); - $contributors = ['editor' => [], 'translator' => [], 'illustrator' => [], 'colorist' => []]; - foreach (self::fields($xpath, $record, '100') as $field) { - $name = self::invertedName($field); - if ($name !== '') { - SruClient::sortContributor($book, $contributors, $name, 'author'); - } - } - foreach (['700', '720'] as $tag) { - foreach (self::fields($xpath, $record, $tag) as $field) { - $subs = self::subfields($field); - $name = $tag === '720' && isset($subs['o']) ? self::clean($subs['o'][0]) : self::invertedName($field); - if ($name === '' || isset($subs['t'])) { - // *t makes the field a name-title entry: a work, not a person's part - continue; - } - // *b is the function term ("red."); *f, the addition to the - // name, often says it too ("pædagogisk note") - $terms = array_merge($subs['b'] ?? [], $subs['f'] ?? []); - $term = $terms !== [] ? implode(' ', $terms) : null; - $role = RelatorRoles::resolve($subs['4'] ?? [], $term, $statement !== '' ? $statement : null, $name, 'marc21'); - SruClient::sortContributor($book, $contributors, $name, $role); - } - } - SruClient::applyContributors($book, $contributors); - - $edition = \App\Support\EditionStatement::clean(self::clean((string) $get('250', 'a'))); - if ($edition !== '') { - $book['edition'] = $edition; - } - $place = \App\Support\PublicationPlace::clean(self::clean((string) $get('260', 'a'))); - if ($place !== '') { - $book['place'] = $place; - } - $book['publisher'] = self::clean((string) $get('260', 'b')); - - $year = $get('260', 'c') ?? $get('008', 'a'); - if ($year !== null && preg_match('/\d{4}/', $year, $m) === 1) { - $book['year'] = $m[0]; - $book['pubDate'] = $m[0] . '-01-01'; - } - - // Every ISBN of the record, for the client to tell whether the one - // searched for is among them - foreach (array_merge(self::all($xpath, $record, '021', 'e'), self::all($xpath, $record, '021', 'a')) as $value) { - $book['_isbns'][] = strtoupper((string) preg_replace('/[^0-9Xx]/', '', $value)); - } - foreach (self::all($xpath, $record, '021', 'e') as $value) { - $value = (string) preg_replace('/[^0-9Xx]/', '', $value); - if (strlen($value) === 13) { - $book['isbn13'] = $value; - break; - } - } - foreach (self::all($xpath, $record, '021', 'a') as $value) { - $value = strtoupper((string) preg_replace('/[^0-9Xx]/', '', $value)); - if (strlen($value) === 10) { - $book['isbn10'] = $value; - break; - } - } - - // Extent: pages only ("355 sider", "288 s."), never the minutes of an audiobook - $pages = SruClient::pagesOf((string) $get('300', 'a'), false); - if ($pages !== '') { - $book['pages'] = $pages; - } - - $language = $get('008', 'l'); - if ($language !== null && $language !== '') { - $book['language'] = $languageName(strtolower(substr($language, 0, 3))); - } - - $book['description'] = self::clean((string) $get('504', 'a')); - - $series = $get('440', 'a'); - if ($series !== null && trim($series) !== '') { - $book['collana'] = self::clean($series); - $number = $get('440', 'v'); - if ($number !== null && trim($number) !== '') { - $book['numero_serie'] = self::clean($number); - } - } - - $subjects = []; - foreach (['s', 'o'] as $code) { - foreach (self::all($xpath, $record, '666', $code) as $value) { - $value = self::clean($value); - if ($value !== '' && !in_array($value, $subjects, true)) { - $subjects[] = $value; - } - } - } - if ($subjects !== []) { - $book['keywords'] = implode(', ', $subjects); - } - - if ($book['authors'] !== []) { - $book['author'] = implode(', ', $book['authors']); - } - return $book; - } - - /** "Lützen, Hanna" from *a Lützen *h Hanna; the full forenames of *k when given. */ - private static function invertedName(DOMNode $field): string - { - $subs = self::subfields($field); - $surname = self::clean($subs['a'][0] ?? ''); - $forenames = self::clean($subs['k'][0] ?? $subs['h'][0] ?? ''); - if ($surname === '') { - return $forenames; - } - return $forenames !== '' ? $surname . ', ' . $forenames : $surname; - } - - /** @return list */ - private static function fields(DOMXPath $xpath, DOMNode $record, string $tag): array - { - $nodes = $xpath->query("./*[local-name()='datafield'][@tag='$tag']", $record); - return $nodes === false ? [] : iterator_to_array($nodes, false); - } - - /** @return list */ - private static function all(DOMXPath $xpath, DOMNode $record, string $tag, string $code): array - { - $nodes = $xpath->query("./*[local-name()='datafield'][@tag='$tag']/*[local-name()='subfield'][@code='$code']", $record); - $out = []; - foreach ($nodes === false ? [] : $nodes as $node) { - $value = trim((string) $node->nodeValue); - if ($value !== '') { - $out[] = $value; - } - } - return $out; - } - - /** @return array> */ - private static function subfields(DOMNode $field): array - { - $out = []; - foreach ($field->childNodes as $sub) { - if ($sub instanceof \DOMElement && $sub->localName === 'subfield') { - $out[$sub->getAttribute('code')][] = trim((string) $sub->nodeValue); - } - } - return $out; - } - - private static function clean(string $text): string - { - $text = (string) preg_replace('/[\x{0080}-\x{009F}]/u', '', $text); - $text = trim((string) preg_replace('/\s+/u', ' ', $text)); - return rtrim($text, ' /:;=,'); - } } diff --git a/storage/plugins/z39-server/classes/RelatorRoles.php b/storage/plugins/z39-server/classes/RelatorRoles.php index 746e3bb75..ad7e95b90 100644 --- a/storage/plugins/z39-server/classes/RelatorRoles.php +++ b/storage/plugins/z39-server/classes/RelatorRoles.php @@ -4,200 +4,10 @@ namespace Plugins\Z39Server\Classes; /** - * The part a name played in a book, read from a catalogue record. - * - * A record says it in up to three ways, tried in this order: - * - * 1. a relator code: MARC 21 and danMARC2 `$4`/`*4` (the Library of Congress - * list, plus the Danish dk… codes of danMARC2 appendix J), UNIMARC `$4` - * (IFLA numeric codes); - * 2. a relator term: MARC 21 `$e`, danMARC2 `*b` ("red.", "redigeret af"), - * in any of the languages below; - * 3. the statement of responsibility (MARC 21 245 `$c`, danMARC2 245 `*e`/`*f`, - * UNIMARC 200 `$f`/`$g`), for a record that only names the person in an - * added entry and says what they did on the title page: - * "translated with an introduction and notes by David McDuff". - * - * Only roles recognised with certainty take a name out of the authors: an - * editor, a translator, an illustrator, a colorist, the writer of part of the - * book (an introduction, a preface, notes) and the publisher. A joint author, a - * composer, a compiler or a code not listed here stays an author, as every - * added entry was before roles were read. + * The part a name played in a book, read from a catalogue record: the logic + * lives in core ({@see \App\Support\Marc\RelatorRoles}), shared with the + * MARC bulk importer; this name stays for the plugin's callers. */ -final class RelatorRoles +final class RelatorRoles extends \App\Support\Marc\RelatorRoles { - /** Roles the book form has a field for. */ - public const CONTRIBUTOR_ROLES = ['editor', 'translator', 'illustrator', 'colorist']; - - /** - * Codes that say nothing about the part played: "lead" (danMARC2 marks the - * main responsibility with it, next to the real code), "other", - * "contributor", "associated name". The term or the statement decides. - */ - private const NEUTRAL_CODES = ['led', 'oth', 'ctb', 'asn']; - - /** MARC 21 / danMARC2 codes. */ - private const LETTER_CODES = [ - 'aut' => 'author', 'cre' => 'author', - 'edt' => 'editor', 'edc' => 'editor', 'dkmdt' => 'editor', - 'trl' => 'translator', - 'ill' => 'illustrator', - 'clr' => 'colorist', 'dkfvl' => 'colorist', - // Part of the book, or not its making at all - 'aui' => 'other', 'aft' => 'other', 'win' => 'other', 'wpr' => 'other', 'wfw' => 'other', - 'wac' => 'other', 'wst' => 'other', 'wat' => 'other', 'cwt' => 'other', 'cmm' => 'other', - 'dktek' => 'other', 'pbl' => 'other', 'isb' => 'other', 'prt' => 'other', 'bnd' => 'other', - 'nrt' => 'other', 'dkind' => 'other', 'cov' => 'other', 'bjd' => 'other', 'bkd' => 'other', - 'bdd' => 'other', 'pfr' => 'other', 'crr' => 'other', 'hnr' => 'other', 'dte' => 'other', - 'fmo' => 'other', 'dnr' => 'other', 'rev' => 'other', 'sad' => 'other', 'ths' => 'other', - ]; - - /** UNIMARC (IFLA) codes. */ - private const NUMERIC_CODES = [ - '070' => 'author', - '340' => 'editor', - '730' => 'translator', - '440' => 'illustrator', - '080' => 'other', '075' => 'other', '650' => 'other', '610' => 'other', - '390' => 'other', '320' => 'other', - ]; - - /** - * Terms, in English, Italian, German, French, Spanish, Danish, Norwegian, - * Swedish, Dutch and Polish; full words and the usual abbreviations. - * Tried in this order, so "translated with an introduction" is a - * translation and "edited and translated" an edition. - */ - private const TERMS = [ - 'editor' => '(editor|editors|eds?|edited|edit|hrsg|herausgeber\w*|herausgegeben|red|redaktør|redaktor|redaktör|redacteur|rédacteur|redactor|redigeret|redigerad|redigert|curatore|curatrice|curat[oa]|a cura|cura di|éditeur scientifique|editeur scientifique|edición|edicion|ed lit)', - 'translator' => '(translat\w*|trans|tr|trad|traduttor\w*|tradott\w*|traduzione|traducteur|traductrice|traduit\w*|traduction|traductor\w*|traducid\w*|traducción|übers\w*|ubers\w*|oversætter\w*|oversat\w*|oversetter\w*|oversatt\w*|översätt\w*|översatt\w*|översättning|vertaler|vertaald|vertaling|tłumacz\w*|przeł\w*)', - 'illustrator' => '(illustrat\w*|ill|illus|ilustrad\w*|ilustraci\w*|illustrateur|illustratrice|illustratore|illustré\w*|illustriert\w*|illustreret|illustrerad|illustrert|illustratør|illustratör|tegninger|tegnet af|drawings)', - 'colorist' => '(colou?rist\w*|colou?red by|colou?rs by|kolorist\w*|colorista|coloriste|farvelægger|farvelagt)', - 'other' => '(introduction|introduced|preface|foreword|afterword|postface|commentary|supplementary|notes?|noter|efterskrift|annotat\w*|publisher|einleitung|vorwort|nachwort|kommentar\w*|anmerkungen|forord|efterord|indledning|kommenteret|förord|inledning|préface|présentation|introducción|prólogo|epílogo|introduzione|prefazione|postfazione|commento|editore|verlag|forlag|förlag|éditeur)', - ]; - - /** - * The role of a name, from its codes, its term and the statement of - * responsibility of the record, in that order. 'author' when none of them - * says otherwise. - * - * @param list $codes - */ - public static function resolve(array $codes, ?string $term, ?string $statement, string $name, string $scheme): string - { - // The first code this list knows decides ("aut" before "trl" keeps a - // self-translating author an author); a code it does not know still - // says the name has a part, which keeps it an author when nothing - // else is recognised. - $unknownCode = false; - foreach ($codes as $code) { - $code = self::normaliseCode($code); - if ($code === '' || in_array($code, self::NEUTRAL_CODES, true)) { - continue; - } - if ($scheme === 'unimarc' && isset(self::NUMERIC_CODES[$code])) { - return self::NUMERIC_CODES[$code]; - } - if (isset(self::LETTER_CODES[$code])) { - return self::LETTER_CODES[$code]; - } - $unknownCode = true; - } - if ($unknownCode) { - return 'author'; - } - $term = self::normaliseText((string) $term); - if ($term !== '') { - return self::roleOfText($term) ?? 'author'; - } - if ($statement !== null && $name !== '') { - return self::roleInStatement($statement, $name) ?? 'author'; - } - return 'author'; - } - - /** - * The role a statement of responsibility gives a name: the role words - * between the name and the punctuation before it ("…; translated with an - * introduction by David McDuff" → translator). Null when the statement - * does not mention the name, or mentions it with no role word, as the - * first author usually is ("Fyodor Dostoyevsky; …"). - */ - public static function roleInStatement(string $statement, string $name): ?string - { - $surname = self::surnameOf($name); - if (mb_strlen($surname) < 2) { - return null; - } - $text = self::normaliseText(self::expandAbbreviations($statement), false); - if (preg_match('/(? match (mb_strtolower($m[1])) { - 'ed', 'eds', 'edit' => 'edited ', - 'tr', 'trans' => 'translated ', - 'red' => 'redigeret ', - default => 'illustrated ', - }, - $statement - ); - } - - /** A relator code as the lists above spell it: "edt" from "Edt." or from a relator URI. */ - private static function normaliseCode(string $code): string - { - $code = (string) preg_replace('#^.*/#', '', trim($code)); - return strtolower(trim($code, " .\t")); - } - - private static function normaliseText(string $text, bool $trim = true): string - { - $text = mb_strtolower(trim((string) preg_replace('/\s+/u', ' ', $text))); - // "hrsg." and "hrsg" alike; brackets and parentheses are not part of a role - $text = (string) preg_replace('/[()\[\].]/u', ' ', $text); - $text = trim((string) preg_replace('/\s+/u', ' ', $text)); - return $trim ? trim($text, ' ,;:') : $text; - } - - private static function roleOfText(string $text): ?string - { - $text = self::normaliseText($text); - if ($text === '') { - return null; - } - foreach (self::TERMS as $role => $pattern) { - if (preg_match('/(?saveXML(); return $xml !== false ? $xml : ''; } - - /** - * Parse a UNIMARC/MARCXchange record into the libri field shape. - * - * Namespace-agnostic: reads `datafield`/`controlfield`/`subfield` by local - * name so it accepts both MARCXchange and bare MARC-slim serialisations. - * - * @return array Keys: titolo, sottotitolo, isbn13, isbn10, - * anno_pubblicazione, editore, edizione, numero_pagine, collana, - * numero_serie, lingua, classificazione_dewey, parole_chiave, - * soggetti (list), authors (list of [name, ruolo]). - */ - public function fromUnimarcXml(string $xml): array - { - $out = []; - $authors = []; - $subjects = []; - - $doc = new \DOMDocument(); - $prev = libxml_use_internal_errors(true); - $loaded = $doc->loadXML($xml); - libxml_clear_errors(); - libxml_use_internal_errors($prev); - if (!$loaded) { - return $out; - } - - foreach ($this->elementsByLocalName($doc, 'datafield') as $field) { - $tag = $field->getAttribute('tag'); - $subs = $this->subfieldsOf($field); - - switch ($tag) { - case '010': // ISBN - $isbn = preg_replace('/[^0-9X]/i', '', $subs['a'][0] ?? '') ?? ''; - if ($isbn !== '') { - if (strlen($isbn) === 13) { - $out['isbn13'] = $isbn; - } else { - $out['isbn10'] = $isbn; - } - } - break; - case '101': // Language of document - if (isset($subs['a'][0])) { - $out['lingua'] = $this->langName($subs['a'][0]); - } - break; - case '200': // Title + statement of responsibility - if (isset($subs['a'][0])) { - $out['titolo'] = $subs['a'][0]; - } - if (isset($subs['e'][0])) { - $out['sottotitolo'] = $subs['e'][0]; - } - break; - case '205': // Edition - if (isset($subs['a'][0])) { - $out['edizione'] = $subs['a'][0]; - } - break; - case '210': // Publication (UNIMARC pre-2014) - case '214': // Publication (UNIMARC 2014+) - if (isset($subs['c'][0]) && !isset($out['editore'])) { - $out['editore'] = $subs['c'][0]; - } - if (isset($subs['d'][0]) && !isset($out['anno_pubblicazione'])) { - $year = $this->extractYear($subs['d'][0]); - if ($year !== null) { - $out['anno_pubblicazione'] = $year; - } - } - break; - case '215': // Physical description - if (isset($subs['a'][0])) { - $pages = $this->extractPages($subs['a'][0]); - if ($pages > 0) { - $out['numero_pagine'] = $pages; - } - } - break; - case '225': // Series - if (isset($subs['a'][0])) { - $out['collana'] = $subs['a'][0]; - } - if (isset($subs['v'][0])) { - $out['numero_serie'] = $subs['v'][0]; - } - break; - case '606': // Subject - if (isset($subs['a'][0])) { - $subjects[] = [ - 'termine' => $subs['a'][0], - 'bncf_id' => $subs['3'][0] ?? null, - ]; - } - break; - case '676': // Dewey - if (isset($subs['a'][0])) { - $out['classificazione_dewey'] = trim($subs['a'][0]); - } - break; - case '700': // Primary author - case '701': // Alternative author - case '702': // Secondary intellectual responsibility - $name = $this->composeName($subs); - if ($name !== '') { - $relator = $subs['4'][0] ?? '070'; - $ruolo = $this->roleForRelator($relator); - if ($tag === '700' && $authors === []) { - $ruolo = 'principale'; - } - $authors[] = ['name' => $name, 'ruolo' => $ruolo]; - } - break; - } - } - - if ($authors !== []) { - $out['authors'] = $authors; - $out['autori'] = implode('; ', array_map(static fn(array $a): string => $a['name'], $authors)); - } - if ($subjects !== []) { - $out['soggetti'] = $subjects; - $out['parole_chiave'] = implode(', ', array_map( - static fn(array $s): string => (string) $s['termine'], - $subjects - )); - } - - return $out; - } - - /** - * Map a core libri_autori.ruolo to its UNIMARC $4 relator code. - */ - public static function relatorForRole(string $ruolo): string - { - return match ($ruolo) { - 'traduttore' => '730', // Translator - 'curatore' => '340', // Editor - 'illustratore' => '440', // Illustrator - 'colorista' => '410', // Graphic technician (includes colourists) - default => '070', // Author (principale / co-autore / unknown) - }; - } - - /** - * Map a UNIMARC $4 relator code to a core libri_autori.ruolo. - */ - private function roleForRelator(string $relator): string - { - return match ($relator) { - '730' => 'traduttore', - '340' => 'curatore', - '440' => 'illustratore', - '410' => 'colorista', - default => 'co-autore', - }; - } - - // ── helpers ──────────────────────────────────────────────────────────── - - /** - * @param list $subfields - */ - private function datafield(\DOMDocument $doc, string $tag, string $ind1, string $ind2, array $subfields): \DOMElement - { - $field = $doc->createElement('datafield'); - $field->setAttribute('tag', $tag); - $field->setAttribute('ind1', $ind1); - $field->setAttribute('ind2', $ind2); - foreach ($subfields as [$code, $value]) { - $sub = $doc->createElement('subfield'); - $sub->setAttribute('code', $code); - $sub->appendChild($doc->createTextNode($value)); - $field->appendChild($sub); - } - return $field; - } - - /** - * @return list<\DOMElement> - */ - private function elementsByLocalName(\DOMDocument $doc, string $localName): array - { - $out = []; - foreach ($doc->getElementsByTagName('*') as $node) { - if ($node->localName === $localName) { - $out[] = $node; - } - } - return $out; - } - - /** - * Collect subfields of a datafield keyed by code (a code may repeat). - * - * Numeric subfield codes ('3', '4', ...) become int array keys in PHP, so - * the key type is int|string. - * - * @return array> - */ - private function subfieldsOf(\DOMElement $field): array - { - $subs = []; - foreach ($field->childNodes as $child) { - if ($child instanceof \DOMElement && $child->localName === 'subfield') { - $code = $child->getAttribute('code'); - $subs[$code][] = trim($child->textContent); - } - } - return $subs; - } - - /** - * Compose a personal name from $a (entry element) + $b (rest of name). - * - * @param array> $subs - */ - private function composeName(array $subs): string - { - $a = trim($subs['a'][0] ?? ''); - $b = trim($subs['b'][0] ?? ''); - if ($a === '') { - return ''; - } - // UNIMARC entry under surname: $a=surname, $b=forename → "Forename Surname" - if ($b !== '') { - $a = rtrim($a, ', '); - return trim($b . ' ' . $a); - } - // Single $a may itself be "Surname, Forename". - if (str_contains($a, ',')) { - [$surname, $forename] = array_map('trim', explode(',', $a, 2)); - if ($surname !== '' && $forename !== '') { - return $forename . ' ' . $surname; - } - } - return $a; - } - - private function extractYear(string $value): ?int - { - if (preg_match('/\b(1[0-9]{3}|20[0-9]{2})\b/', $value, $m)) { - return (int) $m[1]; - } - return null; - } - - private function extractPages(string $desc): int - { - if (preg_match('/(\d+)\s*p/i', $desc, $m)) { - return (int) $m[1]; - } - return 0; - } - - private function langName(string $code): string - { - return match (strtolower(trim($code))) { - 'ita' => 'italiano', - 'eng' => 'inglese', - 'ger', 'deu' => 'tedesco', - 'fre', 'fra' => 'francese', - 'spa' => 'spagnolo', - 'por' => 'portoghese', - 'rus' => 'russo', - 'lat' => 'latino', - default => $code, - }; - } } From 812471915304647adc79ddf23fef4a2e6198fb4f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 00:57:04 +0100 Subject: [PATCH 030/398] Test the MARC library on committed MARC 21, MARC-8 and UNIMARC files The fixtures are assembled by a serializer of their own, so the reader is not only tested against what the writer produces. The suite covers byte-for-byte and read-write-read round trips, MARC-8 diacritics, malformed records that must not stop the file, resuming at an offset, MARCXML inside an SRU envelope, format detection and the MARC 21, UNIMARC and danMARC2 mappings field by field. The role reader also learns the Italian "illustrazioni di". --- app/Support/Marc/Iso2709Reader.php | 6 +- app/Support/Marc/MarcXmlReader.php | 2 +- app/Support/Marc/RelatorRoles.php | 2 +- tests/fixtures/marc/malformed.mrc | 2 + tests/fixtures/marc/marc21-collection.xml | 36 +++ tests/fixtures/marc/marc21-marc8.mrc | 1 + tests/fixtures/marc/marc21-utf8.mrc | 1 + tests/fixtures/marc/unimarc-utf8.mrc | 1 + tests/helpers/marc-fixtures.php | 260 ++++++++++++++++++++++ tests/marc-library.unit.php | 232 +++++++++++++++++++ 10 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/marc/malformed.mrc create mode 100644 tests/fixtures/marc/marc21-collection.xml create mode 100644 tests/fixtures/marc/marc21-marc8.mrc create mode 100644 tests/fixtures/marc/marc21-utf8.mrc create mode 100644 tests/fixtures/marc/unimarc-utf8.mrc create mode 100644 tests/helpers/marc-fixtures.php create mode 100644 tests/marc-library.unit.php diff --git a/app/Support/Marc/Iso2709Reader.php b/app/Support/Marc/Iso2709Reader.php index c9e0e4a49..a186acef9 100644 --- a/app/Support/Marc/Iso2709Reader.php +++ b/app/Support/Marc/Iso2709Reader.php @@ -15,7 +15,8 @@ * reader moves on to the next record. * * Character coding (leader/09): 'a' is UTF-8; blank is MARC-8 in MARC 21. A - * blank position on a record whose bytes are valid UTF-8 is read as UTF-8, + * blank position on a record whose bytes are valid UTF-8 (and carry no MARC-8 + * escape sequence) is read as UTF-8, * since UNIMARC leaves the position undefined (its 100 $a says the coding) * and many exports leave it blank on UTF-8 data. A record decoded from MARC-8 * comes out with leader/09 'a', which is what its values now are. @@ -140,7 +141,8 @@ public static function parse(string $raw, string $encoding = self::ENCODING_AUTO $utf8 = match ($encoding) { self::ENCODING_UTF8 => true, self::ENCODING_MARC8 => false, - default => $leader[9] === 'a' || mb_check_encoding($raw, 'UTF-8'), + // An escape (0x1B) only occurs in MARC-8, where it switches sets. + default => $leader[9] === 'a' || (!str_contains($raw, "\x1B") && mb_check_encoding($raw, 'UTF-8')), }; if ($encoding === self::ENCODING_UTF8 && !mb_check_encoding($raw, 'UTF-8')) { throw new MarcParseException('Record declares UTF-8 but is not valid UTF-8'); diff --git a/app/Support/Marc/MarcXmlReader.php b/app/Support/Marc/MarcXmlReader.php index 5fc236e99..e1047daf1 100644 --- a/app/Support/Marc/MarcXmlReader.php +++ b/app/Support/Marc/MarcXmlReader.php @@ -52,7 +52,7 @@ public function getIterator(): \Generator $more = $this->reader->read(); while ($more) { if ($this->reader->nodeType === \XMLReader::ELEMENT && $this->reader->localName === 'record') { - $node = $this->reader->expand(); + $node = @$this->reader->expand(); if ($node instanceof \DOMElement && self::isMarcRecord($node)) { $position++; try { diff --git a/app/Support/Marc/RelatorRoles.php b/app/Support/Marc/RelatorRoles.php index 97cb66dfb..b2d43812a 100644 --- a/app/Support/Marc/RelatorRoles.php +++ b/app/Support/Marc/RelatorRoles.php @@ -74,7 +74,7 @@ class RelatorRoles private const TERMS = [ 'editor' => '(editor|editors|eds?|edited|edit|hrsg|herausgeber\w*|herausgegeben|red|redaktør|redaktor|redaktör|redacteur|rédacteur|redactor|redigeret|redigerad|redigert|curatore|curatrice|curat[oa]|a cura|cura di|éditeur scientifique|editeur scientifique|edición|edicion|ed lit)', 'translator' => '(translat\w*|trans|tr|trad|traduttor\w*|tradott\w*|traduzione|traducteur|traductrice|traduit\w*|traduction|traductor\w*|traducid\w*|traducción|übers\w*|ubers\w*|oversætter\w*|oversat\w*|oversetter\w*|oversatt\w*|översätt\w*|översatt\w*|översättning|vertaler|vertaald|vertaling|tłumacz\w*|przeł\w*)', - 'illustrator' => '(illustrat\w*|ill|illus|ilustrad\w*|ilustraci\w*|illustrateur|illustratrice|illustratore|illustré\w*|illustriert\w*|illustreret|illustrerad|illustrert|illustratør|illustratör|tegninger|tegnet af|drawings)', + 'illustrator' => '(illustrat\w*|illustrazion\w*|ill|illus|ilustrad\w*|ilustraci\w*|illustrateur|illustratrice|illustratore|illustré\w*|illustriert\w*|illustreret|illustrerad|illustrert|illustratør|illustratör|tegninger|tegnet af|drawings)', 'colorist' => '(colou?rist\w*|colou?red by|colou?rs by|kolorist\w*|colorista|coloriste|farvelægger|farvelagt)', 'other' => '(introduction|introduced|preface|foreword|afterword|postface|commentary|supplementary|notes?|noter|efterskrift|annotat\w*|publisher|einleitung|vorwort|nachwort|kommentar\w*|anmerkungen|forord|efterord|indledning|kommenteret|förord|inledning|préface|présentation|introducción|prólogo|epílogo|introduzione|prefazione|postfazione|commento|editore|verlag|forlag|förlag|éditeur)', ]; diff --git a/tests/fixtures/marc/malformed.mrc b/tests/fixtures/marc/malformed.mrc new file mode 100644 index 000000000..1e458bca4 --- /dev/null +++ b/tests/fixtures/marc/malformed.mrc @@ -0,0 +1,2 @@ +00135nam a2200061 4500001000900000008004100009245002300050ocm00003990101s1999 xx 000 0 eng d00aUntitled anthology00421nam a2200133 i 4500001999900000008004100009020002200050100002100072245007400093260005200167300002500219700001700244830002600261ocm00002150101s2015 gw 000 0 ger d a978-3-16-148410-01 aMüller, Jürgen10aÖkologie der Straße /cJürgen Müller ; übersetzt von Zoë Øster aBerlin :bVerlag Grün ;bEdition Blau,cc2015. axii, 288 S. :c24 cm1 aØster, Zoë 0aStudien zur Stadt.v7 +00042nam a22000 450000930nam a2200289 i 450024500100000 aBroken00930nam a2200289 i 4500001000900000003000600009008004100015020004000056020001500096024001800111041000800129050002300137082001600160100002700176245010700203250002000310264003200330300002400362490002900386520006200415650002400477653001300501700002400514700001800538700003100556856005300587ocm00001OCoLC200615s1980 it a 000 1 ita d a9788845292613 (brossura)qpaperback a88452926143 a40063813339310 aita 4aPQ4865.C6bN6 198004a853.9142231 aEco, Umberto,eauthor.14aIl nome della rosa :bromanzo /cUmberto Eco ; a cura di Lùcia Ferré ; illustrazioni di Ugo Disegni. aPrima edizione. 1aMilano :bBompiani,c[1980] a503 pagine ;c21 cm1 aTascabili Bompiani ;v12 aUn monaco indaga su una serie di delitti in un’abbazia. 0aMonasterivRomanzi. amedioevo1 aFerré, Lùcia4edt1 aDisegni, Ugo.1 aRossi, Marioejoint author423Cover imageuhttps://covers.example.org/rosa.jpg \ No newline at end of file diff --git a/tests/fixtures/marc/marc21-collection.xml b/tests/fixtures/marc/marc21-collection.xml new file mode 100644 index 000000000..5b540e30f --- /dev/null +++ b/tests/fixtures/marc/marc21-collection.xml @@ -0,0 +1,36 @@ + + + + 00000nam a2200000 i 4500 + x1 + + 9780140449136 + + + Dostoyevsky, Fyodor, + + + Crime and punishment / + Fyodor Dostoyevsky ; translated with an introduction and notes by David McDuff. + + + London : + Penguin, + 2003. + + + McDuff, David, + + + https://covers.example.org/crime.jpeg + image/jpeg + + + + 00000nam a2200000 i 4500 + x2 + + Ampersand & <angle> "quotes" + + + diff --git a/tests/fixtures/marc/marc21-marc8.mrc b/tests/fixtures/marc/marc21-marc8.mrc new file mode 100644 index 000000000..f61449bc8 --- /dev/null +++ b/tests/fixtures/marc/marc21-marc8.mrc @@ -0,0 +1 @@ +00217nam 2200085 4500001000500000008004100005100003000046245002600076264002900102m8-1880101s1988 sp 000 0 spa d1 aGarcâia Mâarquez, Gabriel10aCien aänos de soledad 1bEdiciones Cáatedrac198800130nam 2200061 4500001000500000100002700005245003600032m8-21 a¢rsted, Hans Christian10aFranðcais et µsop à 2001 ¡âodâz00077nam 2200049 4500001000500000245002200005m8-310aTitle (NCyrillic00079nam 2200049 4500001000500000245002400005m8-410aHaèuser und Gèarten \ No newline at end of file diff --git a/tests/fixtures/marc/marc21-utf8.mrc b/tests/fixtures/marc/marc21-utf8.mrc new file mode 100644 index 000000000..6196967f8 --- /dev/null +++ b/tests/fixtures/marc/marc21-utf8.mrc @@ -0,0 +1 @@ +00930nam a2200289 i 4500001000900000003000600009008004100015020004000056020001500096024001800111041000800129050002300137082001600160100002700176245010700203250002000310264003200330300002400362490002900386520006200415650002400477653001300501700002400514700001800538700003100556856005300587ocm00001OCoLC200615s1980 it a 000 1 ita d a9788845292613 (brossura)qpaperback a88452926143 a40063813339310 aita 4aPQ4865.C6bN6 198004a853.9142231 aEco, Umberto,eauthor.14aIl nome della rosa :bromanzo /cUmberto Eco ; a cura di Lùcia Ferré ; illustrazioni di Ugo Disegni. aPrima edizione. 1aMilano :bBompiani,c[1980] a503 pagine ;c21 cm1 aTascabili Bompiani ;v12 aUn monaco indaga su una serie di delitti in un’abbazia. 0aMonasterivRomanzi. amedioevo1 aFerré, Lùcia4edt1 aDisegni, Ugo.1 aRossi, Marioejoint author423Cover imageuhttps://covers.example.org/rosa.jpg00421nam a2200133 i 4500001000900000008004100009020002200050100002100072245007400093260005200167300002500219700001700244830002600261ocm00002150101s2015 gw 000 0 ger d a978-3-16-148410-01 aMüller, Jürgen10aÖkologie der Straße /cJürgen Müller ; übersetzt von Zoë Øster aBerlin :bVerlag Grün ;bEdition Blau,cc2015. axii, 288 S. :c24 cm1 aØster, Zoë 0aStudien zur Stadt.v700135nam a2200061 4500001000900000008004100009245002300050ocm00003990101s1999 xx 000 0 eng d00aUntitled anthology \ No newline at end of file diff --git a/tests/fixtures/marc/unimarc-utf8.mrc b/tests/fixtures/marc/unimarc-utf8.mrc new file mode 100644 index 000000000..61e1a8571 --- /dev/null +++ b/tests/fixtures/marc/unimarc-utf8.mrc @@ -0,0 +1 @@ +00612nam 2200217 4500001001700000010001800017073001800035100003800053101000800091200005200099205001400151210002600165215001800191225001700209330004900226606001900275676001300294700002100307702002600328856004000354IT\ICCU\RAV\0001 a9788806219499 a8001234567894 a20200101d1947 y0itay50 ba0 aita1 aSe questo è un uomoetestimonianzafPrimo Levi aNuova ed. aTorinocEinaudid2014 a220 p.d20 cm1 aSuper ETv45 aIl racconto della deportazione ad Auschwitz. aOlocausto2FIR a940.5318 1aLevibPrimo4070 1aBelpolitibMarco43404 uhttps://covers.example.org/levi.png00249nam 2200109 4500001001000000010001500010101000800025200003000033214003000063700002300093702002300116FRBNF0002 a20703600240 afre1 aL'ÉtrangerfAlbert Camus 0aPariscGallimarddDL 1971 1aCamusbAlbert4070 1aWardbMatthew4730 \ No newline at end of file diff --git a/tests/helpers/marc-fixtures.php b/tests/helpers/marc-fixtures.php new file mode 100644 index 000000000..9b5879414 --- /dev/null +++ b/tests/helpers/marc-fixtures.php @@ -0,0 +1,260 @@ + [first-index] + * a file of MARC 21 (or UNIMARC) books whose titles and ISBNs + * carry , for the Playwright specs + */ + +const FT = "\x1E"; +const RT = "\x1D"; +const SF = "\x1F"; + +/** + * @param list|string}> $fields + * [tag, data] for control fields, [tag, ind1, ind2, subfields] for data fields + */ +function iso(string $leader, array $fields): string +{ + $dir = ''; + $data = ''; + foreach ($fields as $f) { + $tag = $f[0]; + $content = count($f) === 2 ? $f[1] : $f[1] . $f[2] . implode('', array_map(static fn ($s) => SF . $s[0] . $s[1], $f[3])); + $content .= FT; + $dir .= $tag . sprintf('%04d%05d', strlen($content), strlen($data)); + $data .= $content; + } + $dir .= FT; + $base = 24 + strlen($dir); + $len = $base + strlen($data) + 1; + return sprintf('%05d', $len) . substr($leader, 5, 7) . sprintf('%05d', $base) . substr($leader, 17, 7) . $dir . $data . RT; +} + +function isbn13(string $twelve): string +{ + $sum = 0; + for ($i = 0; $i < 12; $i++) { + $sum += (int) $twelve[$i] * ($i % 2 === 0 ? 1 : 3); + } + return $twelve . (string) ((10 - $sum % 10) % 10); +} + +function xmlRecord(string $leader, array $fields, string $indent = ' '): string +{ + $e = static fn (string $s): string => htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + $out = $indent . "\n" . $indent . " " . $e($leader) . "\n"; + foreach ($fields as $f) { + if (count($f) === 2) { + $out .= $indent . ' ' . $e($f[1]) . "\n"; + continue; + } + $out .= $indent . ' \n"; + foreach ($f[3] as $s) { + $out .= $indent . ' ' . $e($s[1]) . "\n"; + } + $out .= $indent . " \n"; + } + return $out . $indent . "\n"; +} + +/** A MARC 21 book of the E2E series. */ +function e2eMarc21(string $run, int $i, string $digits): array +{ + $isbn = isbn13('978' . str_pad($digits, 9, '0', STR_PAD_LEFT)); + $n = str_pad((string) $i, 2, '0', STR_PAD_LEFT); + $fields = [ + ['001', "MRC{$run}{$n}"], + ['008', '240101s2019 it 000 0 ita d'], + ['020', ' ', ' ', [['a', $isbn . ' (brossura)']]], + ['041', '0', ' ', [['a', 'ita']]], + ['082', '0', '4', [['a', '853.92'], ['2', '23']]], + ['100', '1', ' ', [['a', "Autore{$run} {$n}, Mario,"], ['e', 'author']]], + ['245', '1', '0', [['a', "Libro MARC {$run} {$n} :"], ['b', 'romanzo di prova /'], ['c', "Mario Autore{$run} {$n} ; traduzione di Anna Traduttrice{$run}"]]], + ['250', ' ', ' ', [['a', '2. ed.']]], + ['264', ' ', '1', [['a', 'Milano :'], ['b', "Editrice{$run}"], ['c', '2019.']]], + ['300', ' ', ' ', [['a', (120 + $i) . ' p. ;'], ['c', '21 cm']]], + ['490', '0', ' ', [['a', "Collana{$run}"], ['v', (string) $i]]], + ['520', ' ', ' ', [['a', "Descrizione del libro {$n} della serie {$run}."]]], + ['650', ' ', '4', [['a', "Soggetto{$run}"]]], + ['700', '1', ' ', [['a', "Traduttrice{$run}, Anna"]]], + ]; + return [$isbn, $fields]; +} + +$mode = $argv[1] ?? 'fixtures'; + +if ($mode === 'e2e') { + [$out, $run, $count, $kind] = [$argv[2], $argv[3], (int) $argv[4], $argv[5] ?? 'mrc']; + $first = (int) ($argv[6] ?? 1); + $seed = abs(crc32($run)) % 900000; + $bytes = $kind === 'xml' ? "\n\n" : ''; + for ($i = $first; $i < $first + $count; $i++) { + [, $fields] = e2eMarc21($run, $i, (string) ($seed * 100 + $i)); + if ($kind === 'xml') { + $bytes .= xmlRecord('00000nam a2200000 i 4500', $fields); + } elseif ($kind === 'unimarc') { + $isbn = isbn13('979' . str_pad((string) ($seed * 100 + $i), 9, '0', STR_PAD_LEFT)); + $n = str_pad((string) $i, 2, '0', STR_PAD_LEFT); + $bytes .= iso('00000nam 2200000 4500', [ + ['001', "UNI{$run}{$n}"], + ['010', ' ', ' ', [['a', $isbn]]], + ['101', '0', ' ', [['a', 'fre']]], + ['200', '1', ' ', [['a', "Livre UNIMARC {$run} {$n}"], ['e', 'roman'], ['f', 'Jean Écrivain']]], + ['210', ' ', ' ', [['a', 'Paris'], ['c', "Éditions{$run}"], ['d', '2021']]], + ['215', ' ', ' ', [['a', '200 p.'], ['d', '22 cm']]], + ['330', ' ', ' ', [['a', "Résumé {$n}."]]], + ['700', ' ', '1', [['a', "Écrivain{$run}"], ['b', 'Jean'], ['4', '070']]], + ['702', ' ', '1', [['a', "Traducteur{$run}"], ['b', 'Paul'], ['4', '730']]], + ]); + } else { + $bytes .= iso('00000nam a2200000 i 4500', $fields); + } + } + if ($kind === 'xml') { + $bytes .= "\n"; + } + file_put_contents($out, $bytes); + exit(0); +} + +$dir = dirname(__DIR__) . '/fixtures/marc'; + +// MARC 21, UTF-8 (leader/09 = a): three books, every mapped field. +$marc21 = [ + iso('00000nam a2200000 i 4500', [ + ['001', 'ocm00001'], + ['003', 'OCoLC'], + ['008', '200615s1980 it a 000 1 ita d'], + ['020', ' ', ' ', [['a', '9788845292613 (brossura)'], ['q', 'paperback']]], + ['020', ' ', ' ', [['a', '8845292614']]], + ['024', '3', ' ', [['a', '4006381333931']]], + ['041', '0', ' ', [['a', 'ita']]], + ['050', ' ', '4', [['a', 'PQ4865.C6'], ['b', 'N6 1980']]], + ['082', '0', '4', [['a', '853.914'], ['2', '23']]], + ['100', '1', ' ', [['a', 'Eco, Umberto,'], ['e', 'author.']]], + ['245', '1', '4', [['a', 'Il nome della rosa :'], ['b', 'romanzo /'], ['c', 'Umberto Eco ; a cura di Lùcia Ferré ; illustrazioni di Ugo Disegni.']]], + ['250', ' ', ' ', [['a', 'Prima edizione.']]], + ['264', ' ', '1', [['a', 'Milano :'], ['b', 'Bompiani,'], ['c', '[1980]']]], + ['300', ' ', ' ', [['a', '503 pagine ;'], ['c', '21 cm']]], + ['490', '1', ' ', [['a', 'Tascabili Bompiani ;'], ['v', '12']]], + ['520', ' ', ' ', [['a', 'Un monaco indaga su una serie di delitti in un’abbazia.']]], + ['650', ' ', '0', [['a', 'Monasteri'], ['v', 'Romanzi.']]], + ['653', ' ', ' ', [['a', 'medioevo']]], + ['700', '1', ' ', [['a', 'Ferré, Lùcia'], ['4', 'edt']]], + ['700', '1', ' ', [['a', 'Disegni, Ugo.']]], + ['700', '1', ' ', [['a', 'Rossi, Mario'], ['e', 'joint author']]], + ['856', '4', '2', [['3', 'Cover image'], ['u', 'https://covers.example.org/rosa.jpg']]], + ]), + iso('00000nam a2200000 i 4500', [ + ['001', 'ocm00002'], + ['008', '150101s2015 gw 000 0 ger d'], + ['020', ' ', ' ', [['a', '978-3-16-148410-0']]], + ['100', '1', ' ', [['a', 'Müller, Jürgen']]], + ['245', '1', '0', [['a', 'Ökologie der Straße /'], ['c', 'Jürgen Müller ; übersetzt von Zoë Øster']]], + ['260', ' ', ' ', [['a', 'Berlin :'], ['b', 'Verlag Grün ;'], ['b', 'Edition Blau,'], ['c', 'c2015.']]], + ['300', ' ', ' ', [['a', 'xii, 288 S. :'], ['c', '24 cm']]], + ['700', '1', ' ', [['a', 'Øster, Zoë']]], + ['830', ' ', '0', [['a', 'Studien zur Stadt.'], ['v', '7']]], + ]), + iso('00000nam a2200000 4500', [ + ['001', 'ocm00003'], + ['008', '990101s1999 xx 000 0 eng d'], + ['245', '0', '0', [['a', 'Untitled anthology']]], + ]), +]; +file_put_contents("$dir/marc21-utf8.mrc", implode('', $marc21)); + +// MARC 21, MARC-8 (leader/09 blank): ANSEL spacing letters and combining +// diacritics written before their base letter; the third record switches to +// Cyrillic, which the reader must report as an error and skip. +$marc8 = [ + iso('00000nam 2200000 4500', [ + ['001', 'm8-1'], + ['008', '880101s1988 sp 000 0 spa d'], + ['100', '1', ' ', [['a', "Garc\xE2ia M\xE2arquez, Gabriel"]]], + ['245', '1', '0', [['a', "Cien a\xE4nos de soledad"]]], + ['264', ' ', '1', [['b', "Ediciones C\xE1atedra"], ['c', '1988']]], + ]), + iso('00000nam 2200000 4500', [ + ['001', 'm8-2'], + ['100', '1', ' ', [['a', "\xA2rsted, Hans Christian"]]], + ['245', '1', '0', [['a', "Fran\xF0cais et \xB5sop \xC3 2001 \xA1\xE2od\xE2z"]]], + ]), + iso('00000nam 2200000 4500', [ + ['001', 'm8-3'], + ['245', '1', '0', [['a', "Title \x1B(NCyrillic"]]], + ]), + iso('00000nam 2200000 4500', [ + ['001', 'm8-4'], + ['245', '1', '0', [['a', "Ha\xE8user und G\xE8arten"]]], + ]), +]; +file_put_contents("$dir/marc21-marc8.mrc", implode('', $marc8)); + +// UNIMARC, UTF-8 (leader/09 undefined, blank). +$unimarc = [ + iso('00000nam 2200000 4500', [ + ['001', 'IT\\ICCU\\RAV\\0001'], + ['010', ' ', ' ', [['a', '9788806219499']]], + ['073', ' ', ' ', [['a', '8001234567894']]], + ['100', ' ', ' ', [['a', '20200101d1947 y0itay50 ba']]], + ['101', '0', ' ', [['a', 'ita']]], + ['200', '1', ' ', [['a', 'Se questo è un uomo'], ['e', 'testimonianza'], ['f', 'Primo Levi']]], + ['205', ' ', ' ', [['a', 'Nuova ed.']]], + ['210', ' ', ' ', [['a', 'Torino'], ['c', 'Einaudi'], ['d', '2014']]], + ['215', ' ', ' ', [['a', '220 p.'], ['d', '20 cm']]], + ['225', '1', ' ', [['a', 'Super ET'], ['v', '45']]], + ['330', ' ', ' ', [['a', 'Il racconto della deportazione ad Auschwitz.']]], + ['606', ' ', ' ', [['a', 'Olocausto'], ['2', 'FIR']]], + ['676', ' ', ' ', [['a', '940.5318']]], + ['700', ' ', '1', [['a', 'Levi'], ['b', 'Primo'], ['4', '070']]], + ['702', ' ', '1', [['a', 'Belpoliti'], ['b', 'Marco'], ['4', '340']]], + ['856', '4', ' ', [['u', 'https://covers.example.org/levi.png']]], + ]), + iso('00000nam 2200000 4500', [ + ['001', 'FRBNF0002'], + ['010', ' ', ' ', [['a', '2070360024']]], + ['101', '0', ' ', [['a', 'fre']]], + ['200', '1', ' ', [['a', "L'Étranger"], ['f', 'Albert Camus']]], + ['214', ' ', '0', [['a', 'Paris'], ['c', 'Gallimard'], ['d', 'DL 1971']]], + ['700', ' ', '1', [['a', 'Camus'], ['b', 'Albert'], ['4', '070']]], + ['702', ' ', '1', [['a', 'Ward'], ['b', 'Matthew'], ['4', '730']]], + ]), +]; +file_put_contents("$dir/unimarc-utf8.mrc", implode('', $unimarc)); + +// Malformed records between good ones: a directory that points past the +// record, a record cut short, a directory whose length is not a multiple of 12. +$good = $marc21[2]; +$bad1 = substr_replace($marc21[1], '9999', 24 + 3, 4); // first entry's length -> 9999 +$bad2 = '00042nam a22000 4500' . RT; +$bad3 = substr($marc21[0], 0, 24) . '24500100000' . FT . ' ' . SF . 'aBroken' . FT . RT; +file_put_contents("$dir/malformed.mrc", $good . $bad1 . "\n" . $bad2 . $bad3 . $marc21[0]); + +// MARCXML collection, with an SRU envelope around the second half. +$xml = "\n\n" + . xmlRecord('00000nam a2200000 i 4500', [ + ['001', 'x1'], + ['020', ' ', ' ', [['a', '9780140449136']]], + ['100', '1', ' ', [['a', 'Dostoyevsky, Fyodor,']]], + ['245', '1', '0', [['a', 'Crime and punishment /'], ['c', 'Fyodor Dostoyevsky ; translated with an introduction and notes by David McDuff.']]], + ['264', ' ', '1', [['a', 'London :'], ['b', 'Penguin,'], ['c', '2003.']]], + ['700', '1', ' ', [['a', 'McDuff, David,']]], + ['856', '4', '2', [['u', 'https://covers.example.org/crime.jpeg'], ['q', 'image/jpeg']]], + ]) + . xmlRecord('00000nam a2200000 i 4500', [ + ['001', 'x2'], + ['245', '0', '0', [['a', 'Ampersand & "quotes"']]], + ]) + . "\n"; +file_put_contents("$dir/marc21-collection.xml", $xml); + +echo "fixtures written to $dir\n"; diff --git a/tests/marc-library.unit.php b/tests/marc-library.unit.php new file mode 100644 index 000000000..03be0b49c --- /dev/null +++ b/tests/marc-library.unit.php @@ -0,0 +1,232 @@ + */ +$all = static fn (iterable $reader): array => array_values(iterator_to_array($reader, false)); +$records = static fn (array $items): array => array_values(array_filter($items, static fn ($r): bool => $r instanceof MarcRecord)); +$errors = static fn (array $items): array => array_values(array_filter($items, static fn ($r): bool => $r instanceof MarcReadError)); + +echo "ISO 2709 — MARC 21 UTF-8\n"; +$utf8 = $all(Iso2709Reader::fromPath("$fx/marc21-utf8.mrc")); +$check(count($utf8) === 3 && count($records($utf8)) === 3, '01 three records read'); +/** @var MarcRecord $rosa */ +$rosa = $utf8[0]; +$check($rosa->controlField('001') === 'ocm00001' && $rosa->controlField('003') === 'OCoLC', '02 control fields'); +$check($rosa->leader()[9] === 'a' && strlen($rosa->leader()) === 24, '03 leader kept, UTF-8 declared'); +$check($rosa->subfield('245', 'a') === 'Il nome della rosa :' && $rosa->first('245')?->ind2 === '4', '04 data field value and indicators'); +$check(count($rosa->fieldsByTag('700')) === 3 && $rosa->subfieldValues('020', 'a') === ['9788845292613 (brossura)', '8845292614'], '05 repeated fields and subfields in order'); +$check(($utf8[1])->subfield('245', 'a') === 'Ökologie der Straße /', '06 UTF-8 multibyte values intact'); + +echo "Round trips\n"; +foreach (['marc21-utf8.mrc', 'unimarc-utf8.mrc'] as $file) { + $original = (string) file_get_contents("$fx/$file"); + $read = $records($all(Iso2709Reader::fromPath("$fx/$file"))); + $written = implode('', array_map([Iso2709Writer::class, 'serialize'], $read)); + $check($written === $original, "07 {$file}: read → write reproduces the file byte for byte"); + $reread = $records($all(Iso2709Reader::fromString($written))); + $check(array_map(static fn ($r) => $r->toArray(), $reread) === array_map(static fn ($r) => $r->toArray(), $read), "08 {$file}: read → write → read is identical"); + $xmlWriter = new MarcXmlWriter($file === 'unimarc-utf8.mrc' ? MarcXmlWriter::NS_MARCXCHANGE : MarcXmlWriter::NS_MARC21); + $xmlWriter->startCollection(); + foreach ($read as $r) { + $xmlWriter->writeRecord($r); + } + $xmlWriter->endCollection(); + $fromXml = $records($all(MarcXmlReader::fromString($xmlWriter->output()))); + $strip = static fn (array $a): array => ['leader' => $a['leader'], 'fields' => $a['fields']]; + $check(array_map(static fn ($r) => $strip($r->toArray()), $fromXml) === array_map(static fn ($r) => $strip($r->toArray()), $read), "09 {$file}: ISO 2709 → MARCXML → read is identical"); +} +$handle = fopen('php://memory', 'w+b'); +$stream = new Iso2709Writer($handle); +foreach ($records($utf8) as $r) { + $stream->write($r); +} +rewind($handle); +$check(stream_get_contents($handle) === file_get_contents("$fx/marc21-utf8.mrc"), '10 the streaming writer writes the same bytes'); + +echo "MARC-8\n"; +$m8 = $all(Iso2709Reader::fromPath("$fx/marc21-marc8.mrc")); +$check(count($m8) === 4, '11 four records, the failing one included'); +$check($m8[0] instanceof MarcRecord && $m8[0]->subfield('100', 'a') === 'García Márquez, Gabriel' && $m8[0]->subfield('245', 'a') === 'Cien años de soledad', '12 combining diacritics move after their letter and compose (NFC)'); +$check($m8[0] instanceof MarcRecord && $m8[0]->leader()[9] === 'a', '13 a decoded record says UTF-8 in leader/09'); +$check($m8[1] instanceof MarcRecord && $m8[1]->subfield('100', 'a') === 'Ørsted, Hans Christian' && $m8[1]->subfield('245', 'a') === 'Français et æsop © 2001 Łódź', '14 ANSEL spacing letters, cedilla and acute'); +$check($m8[2] instanceof MarcReadError && $m8[2]->position === 3 && str_contains($m8[2]->message, 'not supported'), '15 a Cyrillic escape is a per-record error'); +$check($m8[3] instanceof MarcRecord && $m8[3]->subfield('245', 'a') === 'Haüser und Gärten', '16 the record after the error is read'); +$check(Marc8Decoder::decode("\x88The\x89 end") === 'The end', '17 non-sorting markers are dropped'); +try { + Marc8Decoder::decode("bad \xBF byte"); + $check(false, '18 an undefined ANSEL byte throws'); +} catch (MarcParseException) { + $check(true, '18 an undefined ANSEL byte throws'); +} +$forced = $records($all(Iso2709Reader::fromPath("$fx/marc21-utf8.mrc", Iso2709Reader::ENCODING_UTF8))); +$check(count($forced) === 3, '19 forced UTF-8 reads a UTF-8 file'); + +echo "Malformed records\n"; +$bad = $all(Iso2709Reader::fromPath("$fx/malformed.mrc")); +$check(count($bad) === 5 && count($errors($bad)) === 3 && count($records($bad)) === 2, '20 five records: three errors, two read'); +$check($bad[0] instanceof MarcRecord && $bad[4] instanceof MarcRecord && $bad[4]->subfield('245', 'a') === 'Il nome della rosa :', '21 the good records around the bad ones are intact'); +$check($bad[1] instanceof MarcReadError && str_contains($bad[1]->message, 'past the end') && $bad[1]->position === 2, '22 a directory pointing past the record is reported at its position'); +$check($bad[2] instanceof MarcReadError && $bad[3] instanceof MarcReadError && str_contains($bad[3]->message, 'multiple of 12'), '23 a short record and a broken directory are reported'); +$check($bad[1] instanceof MarcReadError && $bad[1]->excerpt !== '' && mb_check_encoding($bad[1]->excerpt, 'UTF-8'), '24 the error carries a printable excerpt'); + +echo "Resuming at an offset\n"; +$reader = Iso2709Reader::fromPath("$fx/marc21-utf8.mrc"); +$first = []; +foreach ($reader as $pos => $r) { + $first[] = $r; + if ($pos === 2) { + break; + } +} +$resumed = $all(Iso2709Reader::fromPath("$fx/marc21-utf8.mrc", Iso2709Reader::ENCODING_AUTO, $reader->offset(), 2)); +$check(count($resumed) === 1 && $resumed[0] instanceof MarcRecord && $resumed[0]->controlField('001') === 'ocm00003', '25 a reader resumed at offset() continues with the next record'); +$positions = array_keys(iterator_to_array(Iso2709Reader::fromPath("$fx/marc21-utf8.mrc", Iso2709Reader::ENCODING_AUTO, $reader->offset(), 2))); +$check($positions === [3], '26 and numbers it as record 3'); + +echo "MARCXML\n"; +$xml = $all(MarcXmlReader::fromPath("$fx/marc21-collection.xml")); +$check(count($xml) === 2 && $xml[1] instanceof MarcRecord && $xml[1]->subfield('245', 'a') === 'Ampersand & "quotes"', '27 a collection is streamed, entities decoded'); +$sru = '2' + . 'marcxml00000nam a2200000 a 4500Uno' + . '00000n 2200000 4500Due' + . ''; +$inSru = $records($all(MarcXmlReader::fromString($sru))); +$check(count($inSru) === 2 && $inSru[0]->subfield('245', 'a') === 'Uno' && $inSru[1]->subfield('245', 'a') === 'Due', '28 records inside an SRU envelope, envelope records skipped'); +$check($inSru[1]->format() === 'danMARC2' && MarcFormat::detectFlavour($inSru[1]) === MarcFormat::DANMARC2, '29 the MARCXchange format attribute is kept and recognised'); +$recordXml = '00000nam a2200000 a 4500' . str_repeat('x', 200) . ''; +$broken = $all(MarcXmlReader::fromString('' . str_repeat($recordXml, 300) . '= 290 && count($errors($broken)) === 1 && $last instanceof MarcReadError && str_contains($last->message, 'not well-formed'), '30 XML that breaks off yields the records before it and one error'); +$single = MarcXmlWriter::recordToXml($rosa); +$check(($records($all(MarcXmlReader::fromString($single)))[0] ?? null)?->toArray()['fields'] === $rosa->toArray()['fields'], '31 a standalone record document reads back identical'); + +echo "Format detection\n"; +$check(MarcFormat::containerOfFile("$fx/marc21-utf8.mrc") === MarcFormat::CONTAINER_ISO2709 && MarcFormat::containerOfFile("$fx/marc21-collection.xml") === MarcFormat::CONTAINER_XML, '32 container told from the first bytes'); +$check(MarcFormat::detectContainer("\xEF\xBB\xBF addDataField('001', '0', '0', [['a', '12345678'], ['b', '870970']]); +$check(MarcFormat::detectFlavour($dan) === MarcFormat::DANMARC2, '35 a 001 data field means danMARC2'); +$dan001 = Iso2709Reader::parse(substr(Iso2709Writer::serialize($dan), 0, -1)); +$check($dan001->first('001') !== null && !$dan001->first('001')->isControl(), '36 an ISO 2709 001 with subfields reads as a data field'); + +echo "MARC 21 mapping\n"; +$m = (new Marc21LibriMapper())->map($rosa); +$check($m['titolo'] === 'Il nome della rosa' && $m['sottotitolo'] === 'romanzo', '37 245 $a $b without ISBD punctuation'); +$check($m['autori'] === ['Eco, Umberto', 'Rossi, Mario'], '38 100 and a 700 joint author are the authors'); +$check($m['curatore'] === ['Ferré, Lùcia'] && $m['illustratore'] === ['Disegni, Ugo'], '39 700 roles from $4 (edt) and from the 245 $c statement (illustrazioni di)'); +$check($m['isbn13'] === '9788845292613' && $m['isbn10'] === '8845292614' && $m['ean'] === '4006381333931', '40 020 qualifier dropped; 024 EAN'); +$check($m['lingua'] === 'Italiano' && $m['classificazione_dewey'] === '853.914' && $m['lc_classification'] === 'PQ4865.C6 N6 1980', '41 041 language, 082 Dewey, 050 LC'); +$check($m['edizione'] !== '' && $m['luogo_pubblicazione'] === 'Milano' && $m['editori'] === ['Bompiani'] && $m['anno_pubblicazione'] === '1980', '42 250, 264 place, publisher, year in brackets'); +$check($m['numero_pagine'] === '503' && $m['dimensioni'] === '21 cm', '43 300 pages and dimensions'); +$check($m['collana'] === 'Tascabili Bompiani' && $m['numero_serie'] === '12', '44 490 series and number'); +$check(str_starts_with($m['descrizione'], 'Un monaco') && $m['parole_chiave'] === ['Monasteri', 'medioevo'], '45 520 summary, 650/653 keywords'); +$check($m['copertina_url'] === 'https://covers.example.org/rosa.jpg', '46 856 cover'); +$m2 = (new Marc21LibriMapper())->map($utf8[1]); +$check($m2['isbn13'] === '9783161484100' && $m2['editori'] === ['Verlag Grün', 'Edition Blau'] && $m2['anno_pubblicazione'] === '2015', '47 260 with two publishers, c2015, hyphenated ISBN'); +$check($m2['traduttore'] === ['Øster, Zoë'] && $m2['autori'] === ['Müller, Jürgen'], '48 a translator named by the statement (übersetzt von) is not an author'); +$check($m2['numero_pagine'] === '288' && $m2['collana'] === 'Studien zur Stadt' && $m2['numero_serie'] === '7' && $m2['lingua'] === 'Deutsch', '49 "288 S.", 830 series when no 490, 008 language'); +$m3 = (new Marc21LibriMapper())->map($utf8[2]); +$check($m3['titolo'] === 'Untitled anthology' && $m3['autori'] === [] && $m3['anno_pubblicazione'] === '1999', '50 a bare record: 008 year'); +$crime = (new Marc21LibriMapper())->map($xml[0]); +$check($crime['autori'] === ['Dostoyevsky, Fyodor'] && $crime['traduttore'] === ['McDuff, David'] && $crime['copertina_url'] === 'https://covers.example.org/crime.jpeg', '51 "translated with an introduction" → translator; 856 $q image/jpeg'); + +echo "UNIMARC mapping\n"; +$u = (new UnimarcLibriMapper())->map($uni[0]); +$check($u['titolo'] === 'Se questo è un uomo' && $u['sottotitolo'] === 'testimonianza' && $u['isbn13'] === '9788806219499' && $u['ean'] === '8001234567894', '52 200, 010, 073'); +$check($u['autori'] === ['Primo Levi'] && $u['curatore'] === ['Marco Belpoliti'], '53 700 author; 702 $4 340 editor'); +$check($u['editori'] === ['Einaudi'] && $u['luogo_pubblicazione'] === 'Torino' && $u['anno_pubblicazione'] === '2014' && $u['lingua'] === 'Italiano', '54 210 place/publisher/year, 101 language'); +$check($u['numero_pagine'] === '220' && $u['dimensioni'] === '20 cm' && $u['collana'] === 'Super ET' && $u['numero_serie'] === '45', '55 215, 225'); +$check($u['classificazione_dewey'] === '940.5318' && $u['parole_chiave'] === ['Olocausto'] && str_starts_with($u['descrizione'], 'Il racconto'), '56 676, 606, 330'); +$check($u['copertina_url'] === 'https://covers.example.org/levi.png', '57 856 cover'); +$u2 = (new UnimarcLibriMapper())->map($uni[1]); +$check($u2['isbn10'] === '2070360024' && $u2['editori'] === ['Gallimard'] && $u2['anno_pubblicazione'] === '1971' && $u2['traduttore'] === ['Matthew Ward'], '58 214 (UNIMARC 2014+), 702 $4 730 translator'); + +echo "danMARC2 mapping\n"; +$dbc = $records($all(MarcXmlReader::fromPath(__DIR__ . '/fixtures/sru/dbc-opensearch-9788702272451.xml'))); +$hp = null; +foreach ($dbc as $r) { + $d = (new Danmarc2LibriMapper())->map($r); + if ($d['titolo'] === 'Harry Potter og de vises sten') { + $hp = $d; + break; + } +} +$check($hp !== null && $hp['autori'] === ['Rowling, Joanne K.'] && $hp['traduttore'] === ['Hanna Lützen'], '59 danMARC2 through the existing reader: author and 720 translator'); + +echo "Book → MARC → book\n"; +$row = [ + 'id' => 42, 'titolo' => 'Viaggio al centro', 'sottotitolo' => 'della terra', 'isbn13' => '9788845292613', 'isbn10' => '', + 'ean' => '4006381333931', 'lingua' => 'Italiano', 'classificazione_dewey' => '843.8', 'lc_classification' => 'PQ2469', + 'edizione' => 'Terza edizione', 'luogo_pubblicazione' => 'Roma', 'anno_pubblicazione' => 2001, 'numero_pagine' => 310, + 'dimensioni' => '20 cm', 'collana' => 'Classici', 'numero_serie' => '9', 'descrizione' => '

      Un viaggio.

      ', + 'parole_chiave' => 'avventura, viaggi', 'copertina_url' => 'https://img.example.org/c.jpg', + 'created_at' => '2024-01-01 00:00:00', 'updated_at' => '2024-01-02 00:00:00', +]; +$authors = [['nome' => 'Jules Verne', 'ruolo' => 'principale'], ['nome' => 'Maria Traduce', 'ruolo' => 'traduttore']]; +$back = (new Marc21LibriMapper())->map($records($all(Iso2709Reader::fromString(Iso2709Writer::serialize(BookMarcBuilder::marc21($row, $authors, [['nome' => 'Mursia']], null, true)))))[0]); +$check($back['titolo'] === 'Viaggio al centro' && $back['autori'] === ['Jules Verne'] && $back['traduttore'] === ['Maria Traduce'] && $back['editori'] === ['Mursia'], '60 MARC 21 export maps back: title, author, translator, publisher'); +$check($back['isbn13'] === '9788845292613' && $back['ean'] === '4006381333931' && $back['lc_classification'] === 'PQ2469' && $back['copertina_url'] === 'https://img.example.org/c.jpg', '61 extended fields round trip: EAN, LC, cover'); +$check($back['anno_pubblicazione'] === '2001' && $back['numero_pagine'] === '310' && $back['collana'] === 'Classici' && $back['descrizione'] === 'Un viaggio.', '62 year, pages, series, plain summary'); +$backU = (new UnimarcLibriMapper())->map($records($all(Iso2709Reader::fromString(Iso2709Writer::serialize(BookMarcBuilder::unimarc($row, $authors, [['nome' => 'Mursia']], null, true)))))[0]); +$check($backU['titolo'] === 'Viaggio al centro' && $backU['autori'] === ['Jules Verne'] && $backU['traduttore'] === ['Maria Traduce'] && $backU['classificazione_dewey'] === '843.8' && $backU['copertina_url'] === 'https://img.example.org/c.jpg', '63 UNIMARC export maps back, with 676 and 856'); +$plain = BookMarcBuilder::marc21($row, $authors, [], null, false); +$check($plain->first('024') === null && $plain->first('856') === null && $plain->first('050') === null, '64 without $extended the record is the OAI one (no 024/050/856)'); + +echo "Import row\n"; +$importRow = MarcBookData::toImportRow($m); +$check($importRow['autori'] === 'Eco, Umberto;Rossi, Mario' && $importRow['editore'] === 'Bompiani' && $importRow['curatore'] === 'Ferré, Lùcia' && !array_key_exists('traduttore', $importRow), '65 names joined with ";", an absent role is not a column'); +$check($importRow['parole_chiave'] === 'Monasteri, medioevo' && $importRow['isbn13'] === '9788845292613', '66 keywords joined with ", "'); + +echo "Writer limits\n"; +$long = new MarcRecord(); +$long->addDataField('520', ' ', ' ', [['a', str_repeat('è', 6000)]]); +$serialized = Iso2709Writer::serialize($long); +$reparsed = Iso2709Reader::parse(substr($serialized, 0, -1)); +$value = (string) $reparsed->subfield('520', 'a'); +$check(mb_check_encoding($value, 'UTF-8') && strlen($value) <= 9999 && strlen($value) > 9000, '67 a field over 9999 bytes is cut on a character boundary'); + +echo "\n{$pass} PASS, {$fail} FAIL\n"; +exit($fail === 0 ? 0 : 1); From 28ee62b94856863d3e39aa576a6c012715cf0f8f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:22:37 +0100 Subject: [PATCH 031/398] Bulk MARC import and streamed MARC export /admin/books/import/marc reads ISO 2709 or MARCXML files (MARC 21, UNIMARC, danMARC2, told apart per record), stages them once, shows a preview of the first mapped records and imports in chunks through the CSV import's own row pipeline, so authors, publishers, copies, history and the error download behave as they do there. Duplicates are matched by ISBN-13, ISBN-10 or EAN among books not deleted and are skipped, completed or overwritten as chosen; covers come from 856. /admin/books/export/marc streams the catalogue or a selection as MARC 21, MARCXML or UNIMARC with the OAI server's serializer. import_logs.import_type gains 'marc' (migrate_0.9.0.sql). --- app/Controllers/MarcExportController.php | 212 +++++++++ app/Controllers/MarcImportController.php | 356 ++++++++++++++ app/Routes/web.php | 27 ++ app/Support/Marc/GeneratorStream.php | 126 +++++ app/Support/Marc/MarcBookImporter.php | 384 +++++++++++++++ app/Support/Marc/MarcCoverFetcher.php | 83 ++++ app/Views/admin/imports_history.php | 1 + app/Views/admin/marc_import.php | 443 ++++++++++++++++++ .../database/migrations/migrate_0.9.0.sql | 12 + installer/database/schema.sql | 2 +- tests/helpers/marc-e2e.js | 92 ++++ tests/helpers/marc-fixtures.php | 29 ++ tests/marc-bulk-import.spec.js | 171 +++++++ 13 files changed, 1937 insertions(+), 1 deletion(-) create mode 100644 app/Controllers/MarcExportController.php create mode 100644 app/Controllers/MarcImportController.php create mode 100644 app/Support/Marc/GeneratorStream.php create mode 100644 app/Support/Marc/MarcBookImporter.php create mode 100644 app/Support/Marc/MarcCoverFetcher.php create mode 100644 app/Views/admin/marc_import.php create mode 100644 tests/helpers/marc-e2e.js create mode 100644 tests/marc-bulk-import.spec.js diff --git a/app/Controllers/MarcExportController.php b/app/Controllers/MarcExportController.php new file mode 100644 index 000000000..d529bba32 --- /dev/null +++ b/app/Controllers/MarcExportController.php @@ -0,0 +1,212 @@ +getQueryParams(); + $format = (string) ($params['format'] ?? 'marc21'); + if (!in_array($format, self::FORMATS, true)) { + $format = 'marc21'; + } + $ids = null; + if (array_key_exists('ids', $params)) { + $raw = is_string($params['ids']) ? $params['ids'] : ''; + $ids = array_values(array_unique(array_filter(array_map('intval', explode(',', $raw)), static fn (int $id): bool => $id > 0))); + if (count($ids) > self::MAX_SELECTED) { + $_SESSION['error_message'] = sprintf( + __('Selezione troppo grande per l\'esportazione: %d libri (massimo 1000). Riduci la selezione e riprova.'), + count($ids) + ); + return $response->withHeader('Location', url('/admin/books'))->withStatus(302); + } + } + + $stamp = date('Y-m-d'); + [$contentType, $filename] = match ($format) { + 'marcxml' => ['application/marcxml+xml; charset=UTF-8', "catalogo_marcxml_{$stamp}.xml"], + 'unimarc' => ['application/marc', "catalogo_unimarc_{$stamp}.mrc"], + default => ['application/marc', "catalogo_marc21_{$stamp}.mrc"], + }; + + \App\Support\SecureLogger::info('[MARC export]', [ + 'format' => $format, + 'selected' => $ids === null ? null : count($ids), + 'user_id' => $_SESSION['user']['id'] ?? null, + ]); + + return $response + ->withBody(new GeneratorStream($this->generate($db, $format, $ids))) + ->withHeader('Content-Type', $contentType) + ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"') + ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + ->withHeader('X-Accel-Buffering', 'no'); + } + + /** + * @param list|null $ids + * @return \Generator + */ + public function generate(\mysqli $db, string $format, ?array $ids): \Generator + { + $xml = null; + if ($format === 'marcxml') { + $xml = new MarcXmlWriter(MarcXmlWriter::NS_MARC21); + $xml->startCollection(); + yield $xml->output(); + } + foreach ($this->books($db, $ids) as [$row, $authors, $publishers, $genre]) { + $record = $format === 'unimarc' + ? BookMarcBuilder::unimarc($row, $authors, $publishers, $genre, true) + : BookMarcBuilder::marc21($row, $authors, $publishers, $genre, true); + if ($xml !== null) { + $xml->writeRecord($record); + yield $xml->output(); + continue; + } + try { + yield Iso2709Writer::serialize($record); + } catch (\OverflowException $e) { + \App\Support\SecureLogger::warning('[MARC export] record too long for ISO 2709, skipped', ['book_id' => $row['id'] ?? null]); + } + } + if ($xml !== null) { + $xml->endCollection(); + yield $xml->output(); + } + } + + /** + * The books to export, page by page, with their authors (in credit + * order), publishers and genre. + * + * @param list|null $ids + * @return \Generator, 1: list>, 2: list>, 3: array|null}> + */ + private function books(\mysqli $db, ?array $ids): \Generator + { + if ($ids === []) { + return; + } + $hasJunction = \App\Support\SchemaInfo::hasLibriEditori($db); + $lastId = 0; + while (true) { + $sql = 'SELECT * FROM libri WHERE deleted_at IS NULL AND id > ?'; + $types = 'i'; + $params = [$lastId]; + if ($ids !== null) { + $sql .= ' AND id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')'; + $types .= str_repeat('i', count($ids)); + $params = array_merge($params, $ids); + } + $sql .= ' ORDER BY id LIMIT ' . self::PAGE; + $stmt = $db->prepare($sql); + if ($stmt === false) { + return; + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $stmt->close(); + if ($rows === []) { + return; + } + $pageIds = array_map(static fn (array $r): int => (int) $r['id'], $rows); + $authors = $this->grouped($db, + "SELECT la.libro_id, a.nome, la.ruolo, la.ordine_credito + FROM libri_autori la JOIN autori a ON a.id = la.autore_id + WHERE la.libro_id IN (%s) + ORDER BY la.libro_id, CASE la.ruolo WHEN 'principale' THEN 0 WHEN 'co-autore' THEN 1 ELSE 2 END, + la.ordine_credito IS NULL, la.ordine_credito, a.nome", + $pageIds); + $publishers = $hasJunction ? $this->grouped($db, + 'SELECT le.libro_id, e.id, e.nome FROM libri_editori le JOIN editori e ON e.id = le.editore_id + WHERE le.libro_id IN (%s) ORDER BY le.libro_id, le.ordine, e.nome', + $pageIds) : []; + $primary = $this->byId($db, 'SELECT id, nome FROM editori WHERE id IN (%s)', array_values(array_filter(array_map(static fn (array $r): int => (int) ($r['editore_id'] ?? 0), $rows)))); + $genres = $this->byId($db, 'SELECT id, nome FROM generi WHERE id IN (%s)', array_values(array_filter(array_map(static fn (array $r): int => (int) ($r['genere_id'] ?? 0), $rows)))); + foreach ($rows as $row) { + $id = (int) $row['id']; + $bookPublishers = $publishers[$id] ?? []; + $primaryId = (int) ($row['editore_id'] ?? 0); + if ($bookPublishers === [] && $primaryId > 0 && isset($primary[$primaryId])) { + $bookPublishers = [$primary[$primaryId]]; + } + $genreId = (int) ($row['genere_id'] ?? 0); + yield [$row, $authors[$id] ?? [], $bookPublishers, $genreId > 0 ? ($genres[$genreId] ?? null) : null]; + $lastId = $id; + } + if (count($rows) < self::PAGE) { + return; + } + } + } + + /** + * @param list $ids + * @return array>> + */ + private function grouped(\mysqli $db, string $sql, array $ids): array + { + $out = []; + foreach ($this->fetch($db, $sql, $ids) as $row) { + $out[(int) $row['libro_id']][] = $row; + } + return $out; + } + + /** + * @param list $ids + * @return array> + */ + private function byId(\mysqli $db, string $sql, array $ids): array + { + $out = []; + foreach ($this->fetch($db, $sql, array_values(array_unique($ids))) as $row) { + $out[(int) $row['id']] = $row; + } + return $out; + } + + /** + * @param list $ids + * @return list> + */ + private function fetch(\mysqli $db, string $sql, array $ids): array + { + if ($ids === []) { + return []; + } + $stmt = $db->prepare(sprintf($sql, implode(',', array_fill(0, count($ids), '?')))); + if ($stmt === false) { + return []; + } + $stmt->bind_param(str_repeat('i', count($ids)), ...$ids); + $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $stmt->close(); + return array_values($rows); + } +} diff --git a/app/Controllers/MarcImportController.php b/app/Controllers/MarcImportController.php new file mode 100644 index 000000000..55c737969 --- /dev/null +++ b/app/Controllers/MarcImportController.php @@ -0,0 +1,356 @@ +getBody()->write($content); + return $response->withHeader('Content-Type', 'text/html'); + } + + /** + * Stage the uploaded file and answer with the record count and a preview. + */ + public function upload(Request $request, Response $response, \mysqli $db): Response + { + @ini_set('display_errors', '0'); + @set_time_limit(300); + $data = (array) $request->getParsedBody(); + $file = $request->getUploadedFiles()['marc_file'] ?? null; + if (!$file instanceof \Psr\Http\Message\UploadedFileInterface || $file->getError() !== UPLOAD_ERR_OK) { + return $this->json($response, ['success' => false, 'error' => __('Nessun file caricato')], 400); + } + $name = (string) $file->getClientFilename(); + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if (!in_array($extension, self::EXTENSIONS, true)) { + return $this->json($response, ['success' => false, 'error' => __('Il file deve essere un file MARC (.mrc, .marc, .iso) o MARCXML (.xml)')], 400); + } + if ((int) $file->getSize() > self::MAX_FILE_BYTES) { + return $this->json($response, ['success' => false, 'error' => __('File troppo grande. Dimensione massima %d MB.', (int) (self::MAX_FILE_BYTES / 1048576))], 400); + } + $tmp = (string) $file->getStream()->getMetadata('uri'); + if ($tmp === '' || !is_file($tmp)) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile leggere il file caricato')], 400); + } + $container = MarcFormat::containerOfFile($tmp); + if ($container === null) { + return $this->json($response, ['success' => false, 'error' => __('Il file non contiene record MARC riconoscibili (ISO 2709 o MARCXML)')], 400); + } + + $flavour = (string) ($data['flavour'] ?? MarcFormat::AUTO); + if ($flavour !== MarcFormat::AUTO && !in_array($flavour, MarcFormat::FLAVOURS, true)) { + $flavour = MarcFormat::AUTO; + } + $encoding = (string) ($data['encoding'] ?? Iso2709Reader::ENCODING_AUTO); + if (!in_array($encoding, [Iso2709Reader::ENCODING_AUTO, Iso2709Reader::ENCODING_UTF8, Iso2709Reader::ENCODING_MARC8], true)) { + $encoding = Iso2709Reader::ENCODING_AUTO; + } + $mode = (string) ($data['duplicates'] ?? MarcBookImporter::MODE_SKIP); + if (!in_array($mode, MarcBookImporter::MODES, true)) { + $mode = MarcBookImporter::MODE_SKIP; + } + + $dir = __DIR__ . '/../../writable/uploads'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + $this->discardStaging(); + $staging = $dir . '/marc_import_' . preg_replace('/[^A-Za-z0-9]/', '', session_id()) . '_' . bin2hex(random_bytes(6)) . '.jsonl'; + $out = @fopen($staging, 'wb'); + if ($out === false) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile salvare il file caricato')], 500); + } + + $total = 0; + $unreadable = 0; + $preview = []; + $importer = new MarcBookImporter($db, $mode, false); + $csv = new CsvImportController(); + try { + $reader = $container === MarcFormat::CONTAINER_XML + ? MarcXmlReader::fromPath($tmp) + : Iso2709Reader::fromPath($tmp, $encoding); + foreach ($reader as $position => $item) { + if ($total >= self::MAX_RECORDS) { + break; + } + $total++; + $line = $this->stageLine((int) $position, $item, $flavour); + fwrite($out, json_encode($line, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) . "\n"); + if (isset($line['e'])) { + $unreadable++; + } + if (count($preview) < self::PREVIEW_SIZE) { + $preview[] = $this->previewRow($line, $importer, $csv); + } + } + } catch (\Throwable $e) { + fclose($out); + @unlink($staging); + \App\Support\SecureLogger::error('[MARC import] cannot read upload', ['error' => $e->getMessage()]); + return $this->json($response, ['success' => false, 'error' => __('Impossibile leggere il file caricato')], 400); + } + fclose($out); + if ($total === 0) { + @unlink($staging); + return $this->json($response, ['success' => false, 'error' => __('Il file non contiene record MARC riconoscibili (ISO 2709 o MARCXML)')], 400); + } + + $_SESSION[self::SESSION_KEY] = [ + 'file_path' => $staging, + 'original_filename' => $name, + 'total_rows' => $total, + 'mode' => $mode, + 'covers' => !empty($data['download_covers']), + 'offset' => 0, + 'processed' => 0, + 'counters' => [], + 'errors' => [], + ]; + + return $this->json($response, [ + 'success' => true, + 'total_rows' => $total, + 'unreadable' => $unreadable, + 'container' => $container, + 'chunk_size' => !empty($data['download_covers']) ? 2 : self::CHUNK_SIZE, + 'preview' => $preview, + ]); + } + + /** + * Import the next records of the staged file. A repeated request for a + * chunk already done (a retry after a lost response) does nothing. + */ + public function chunk(Request $request, Response $response, \mysqli $db): Response + { + @set_time_limit(600); + @ini_set('display_errors', '0'); + $payload = json_decode((string) $request->getBody(), true); + $state = $_SESSION[self::SESSION_KEY] ?? null; + if (!is_array($payload) || !is_array($state)) { + return $this->json($response, ['success' => false, 'error' => __('Sessione import scaduta o dati non validi')], 400); + } + $start = max(0, (int) ($payload['start'] ?? 0)); + $size = max(1, min((int) ($payload['size'] ?? self::CHUNK_SIZE), self::CHUNK_SIZE)); + $processed = (int) $state['processed']; + + if ($start === $processed && $processed < (int) $state['total_rows']) { + $handle = @fopen((string) $state['file_path'], 'rb'); + if ($handle === false) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile aprire il file caricato')], 500); + } + fseek($handle, (int) $state['offset']); + $importer = new MarcBookImporter($db, (string) $state['mode'], (bool) $state['covers']); + $counters = (array) $state['counters']; + for ($i = 0; $i < $size; $i++) { + $raw = fgets($handle); + if ($raw === false) { + break; + } + $processed++; + $line = json_decode($raw, true); + if (!is_array($line)) { + $state['errors'][] = ['line' => $processed, 'title' => '', 'message' => __('Record non leggibile'), 'type' => 'parse']; + continue; + } + $position = (int) ($line['p'] ?? $processed); + if (isset($line['e'])) { + $state['errors'][] = [ + 'line' => $position, + 'title' => (string) ($line['x'] ?? ''), + 'message' => (string) $line['e'], + 'type' => 'parse', + ]; + continue; + } + $bookData = is_array($line['d'] ?? null) ? $line['d'] : []; + $title = (string) ($bookData['titolo'] ?? ''); + try { + $warnings = []; + $result = $importer->import($bookData, $counters, $warnings); + if ($result['action'] === 'skipped') { + $state['errors'][] = [ + 'line' => $position, + 'title' => $title, + 'message' => sprintf(__('Già in catalogo (libro #%d): saltato'), (int) $result['id']), + 'type' => 'duplicate', + ]; + } + foreach ($warnings as $warning) { + $state['errors'][] = ['line' => $position, 'title' => $title, 'message' => $warning, 'type' => 'cover']; + } + } catch (\Throwable $e) { + $state['errors'][] = ['line' => $position, 'title' => $title, 'message' => $e->getMessage(), 'type' => 'validation']; + \App\Support\SecureLogger::warning('[MARC import] record failed', ['position' => $position, 'error' => $e->getMessage()]); + } + } + $offset = ftell($handle); + fclose($handle); + $state['offset'] = $offset === false ? (int) $state['offset'] : $offset; + $state['processed'] = $processed; + $state['counters'] = $counters; + $_SESSION[self::SESSION_KEY] = $state; + } + + $complete = $processed >= (int) $state['total_rows']; + $counters = (array) $state['counters']; + $errors = (array) $state['errors']; + $failed = count(array_filter($errors, static fn (array $e): bool => in_array($e['type'] ?? '', ['validation', 'parse'], true))); + $importId = null; + if ($complete) { + $importId = $this->persistHistory($db, $state, $failed); + $this->discardStaging(); + } + + return $this->json($response, [ + 'success' => true, + 'processed' => $processed, + 'total_rows' => (int) $state['total_rows'], + 'imported' => (int) ($counters['imported'] ?? 0), + 'updated' => (int) ($counters['updated'] ?? 0), + 'skipped' => (int) ($counters['skipped'] ?? 0), + 'covers' => (int) ($counters['covers'] ?? 0), + 'authors_created' => (int) ($counters['authors_created'] ?? 0), + 'publishers_created' => (int) ($counters['publishers_created'] ?? 0), + 'failed' => $failed, + 'error_details' => array_map( + static fn (array $e): string => sprintf('#%d (%s): %s', (int) ($e['line'] ?? 0), (string) ($e['title'] ?? ''), (string) ($e['message'] ?? '')), + array_slice(array_values(array_filter($errors, static fn (array $e): bool => ($e['type'] ?? '') !== 'duplicate')), 0, 50) + ), + 'complete' => $complete, + 'import_id' => $importId, + ]); + } + + /** + * @param MarcRecord|MarcReadError $item + * @return array + */ + private function stageLine(int $position, MarcRecord|MarcReadError $item, string $flavour): array + { + if ($item instanceof MarcReadError) { + return ['p' => $position, 'e' => $item->message, 'x' => $item->excerpt]; + } + $actual = $flavour === MarcFormat::AUTO ? MarcFormat::detectFlavour($item) : $flavour; + try { + $data = MarcFormat::mapperFor($actual)->map($item); + } catch (\Throwable $e) { + return ['p' => $position, 'e' => $e->getMessage(), 'x' => '']; + } + return ['p' => $position, 'f' => $actual, 'd' => $data]; + } + + /** + * @param array $line + * @return array + */ + private function previewRow(array $line, MarcBookImporter $importer, CsvImportController $csv): array + { + if (isset($line['e'])) { + return ['position' => $line['p'], 'error' => (string) $line['e']]; + } + $data = (array) $line['d']; + $existing = null; + try { + $existing = $importer->findExisting($csv->normalizeImportRow(\App\Support\Marc\MarcBookData::toImportRow($data))); + } catch (\Throwable) { + $existing = null; + } + return [ + 'position' => $line['p'], + 'flavour' => $line['f'] ?? '', + 'titolo' => (string) ($data['titolo'] ?? ''), + 'sottotitolo' => (string) ($data['sottotitolo'] ?? ''), + 'autori' => implode('; ', (array) ($data['autori'] ?? [])), + 'editore' => implode('; ', (array) ($data['editori'] ?? [])), + 'anno' => (string) ($data['anno_pubblicazione'] ?? ''), + 'isbn' => (string) (($data['isbn13'] ?? '') !== '' ? $data['isbn13'] : ($data['isbn10'] ?? '')), + 'copertina' => (string) ($data['copertina_url'] ?? '') !== '', + 'existing_id' => $existing, + ]; + } + + /** @param array $state */ + private function persistHistory(\mysqli $db, array $state, int $failed): ?string + { + try { + $userId = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null; + $logger = new \App\Support\ImportLogger($db, 'marc', (string) $state['original_filename'], $userId); + $counters = (array) $state['counters']; + $logger->setStats([ + 'imported' => (int) ($counters['imported'] ?? 0), + 'updated' => (int) ($counters['updated'] ?? 0), + 'failed' => $failed, + 'authors_created' => (int) ($counters['authors_created'] ?? 0), + 'publishers_created' => (int) ($counters['publishers_created'] ?? 0), + 'scraped' => (int) ($counters['covers'] ?? 0), + ]); + foreach ((array) $state['errors'] as $error) { + $logger->addError((int) ($error['line'] ?? 0), (string) ($error['title'] ?? ''), (string) ($error['message'] ?? ''), (string) ($error['type'] ?? 'validation')); + } + if (!$logger->complete((int) $state['total_rows'])) { + $logger->fail('Failed to persist import history', (int) $state['total_rows']); + return null; + } + return $logger->getImportId(); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('[MARC import] history not saved', ['error' => $e->getMessage()]); + return null; + } + } + + private function discardStaging(): void + { + $state = $_SESSION[self::SESSION_KEY] ?? null; + if (is_array($state) && !empty($state['file_path'])) { + $path = (string) $state['file_path']; + $dir = realpath(__DIR__ . '/../../writable/uploads'); + $real = realpath($path); + if ($dir !== false && $real !== false && str_starts_with($real, $dir . DIRECTORY_SEPARATOR) && str_starts_with(basename($real), 'marc_import_')) { + @unlink($real); + } + } + unset($_SESSION[self::SESSION_KEY]); + } + + /** @param array $payload */ + private function json(Response $response, array $payload, int $status = 200): Response + { + $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE)); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); + } +} diff --git a/app/Routes/web.php b/app/Routes/web.php index 8ae3f2406..d5ef528a3 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -1491,6 +1491,33 @@ // chunk loop is further bounded by the session-scoped import_id. // See issue #113 for the original rate-limit regression. + // MARC bulk import (ISO 2709 / MARCXML; MARC 21, UNIMARC, danMARC2) on + // the CSV import infrastructure: upload + preview, then chunks. No + // RateLimitMiddleware on /chunk for the same reason as the CSV chunk (#113). + $app->get('/admin/books/import/marc', function ($request, $response) { + $controller = new \App\Controllers\MarcImportController(); + return $controller->showImportPage($request, $response); + })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + + $app->post('/admin/books/import/marc/upload', function ($request, $response) use ($app) { + $controller = new \App\Controllers\MarcImportController(); + $db = $app->getContainer()->get('db'); + return $controller->upload($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/books/import/marc/chunk', function ($request, $response) use ($app) { + $controller = new \App\Controllers\MarcImportController(); + $db = $app->getContainer()->get('db'); + return $controller->chunk($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + // MARC bulk export: whole catalogue or ?ids= selection, streamed. + $app->get('/admin/books/export/marc', function ($request, $response) use ($app) { + $controller = new \App\Controllers\MarcExportController(); + $db = $app->getContainer()->get('db'); + return $controller->export($request, $response, $db); + })->add(new \App\Middleware\RateLimitMiddleware(15, 60))->add(new AdminAuthMiddleware()); + // LibraryThing Plugin Routes // Plugin Administration diff --git a/app/Support/Marc/GeneratorStream.php b/app/Support/Marc/GeneratorStream.php new file mode 100644 index 000000000..8b115e639 --- /dev/null +++ b/app/Support/Marc/GeneratorStream.php @@ -0,0 +1,126 @@ + $generator */ + public function __construct(private \Generator $generator) + { + } + + private function pull(int $length): void + { + while (!$this->finished && strlen($this->buffer) < $length) { + if ($this->started) { + $this->generator->next(); + } + $this->started = true; + if (!$this->generator->valid()) { + $this->finished = true; + break; + } + $this->buffer .= (string) $this->generator->current(); + } + } + + public function read(int $length): string + { + $this->pull($length); + $chunk = substr($this->buffer, 0, $length); + $this->buffer = (string) substr($this->buffer, strlen($chunk)); + $this->position += strlen($chunk); + return $chunk; + } + + public function eof(): bool + { + $this->pull(1); + return $this->finished && $this->buffer === ''; + } + + public function getContents(): string + { + $out = ''; + while (!$this->eof()) { + $out .= $this->read(65536); + } + return $out; + } + + public function __toString(): string + { + return $this->getContents(); + } + + public function close(): void + { + $this->finished = true; + $this->buffer = ''; + } + + public function detach() + { + $this->close(); + return null; + } + + public function getSize(): ?int + { + return null; + } + + public function tell(): int + { + return $this->position; + } + + public function isSeekable(): bool + { + return false; + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + throw new \RuntimeException('GeneratorStream is not seekable'); + } + + public function rewind(): void + { + throw new \RuntimeException('GeneratorStream is not seekable'); + } + + public function isWritable(): bool + { + return false; + } + + public function write(string $string): int + { + throw new \RuntimeException('GeneratorStream is not writable'); + } + + public function isReadable(): bool + { + return true; + } + + public function getMetadata(?string $key = null) + { + return $key === null ? [] : null; + } +} diff --git a/app/Support/Marc/MarcBookImporter.php b/app/Support/Marc/MarcBookImporter.php new file mode 100644 index 000000000..bb0fcbec0 --- /dev/null +++ b/app/Support/Marc/MarcBookImporter.php @@ -0,0 +1,384 @@ + ['isbn13', 'isbn10'], + 'ean' => ['ean'], + 'titolo' => ['titolo'], + 'sottotitolo' => ['sottotitolo'], + 'anno' => ['anno_pubblicazione'], + 'lingua' => ['lingua'], + 'edizione' => ['edizione'], + 'pagine' => ['numero_pagine'], + 'collana' => ['collana'], + 'dewey' => ['classificazione_dewey'], + 'keywords' => ['parole_chiave'], + 'description' => ['descrizione'], + 'publisher' => ['editore_id'], + 'contributors' => ['traduttore', 'illustratore', 'curatore'], + ]; + + /** CSV update family → the book data keys that carry it. */ + private const FAMILY_DATA = [ + 'isbn' => ['isbn13', 'isbn10'], + 'ean' => ['ean'], + 'titolo' => ['titolo'], + 'sottotitolo' => ['sottotitolo'], + 'anno' => ['anno_pubblicazione'], + 'lingua' => ['lingua'], + 'edizione' => ['edizione'], + 'pagine' => ['numero_pagine'], + 'collana' => ['collana'], + 'dewey' => ['classificazione_dewey'], + 'keywords' => ['parole_chiave'], + 'description' => ['descrizione'], + 'publisher' => ['editori'], + 'authors' => ['autori'], + 'contributors' => ['traduttore', 'illustratore', 'curatore', 'colorista'], + ]; + + /** Columns written here, not by the CSV pipeline. */ + private const EXTRA_COLUMNS = [ + 'luogo_pubblicazione' => 255, + 'dimensioni' => 50, + 'lc_classification' => 100, + 'issn' => 20, + ]; + + private CsvImportController $pipeline; + + /** @var array|null */ + private ?array $columns = null; + + public function __construct( + private \mysqli $db, + private string $mode = self::MODE_SKIP, + private bool $downloadCovers = true, + private ?MarcCoverFetcher $coverFetcher = null + ) { + if (!in_array($mode, self::MODES, true)) { + throw new \InvalidArgumentException('Unknown duplicate mode: ' . $mode); + } + $this->pipeline = new CsvImportController(); + $this->pipeline->setImportSource('marc'); + $this->coverFetcher ??= new MarcCoverFetcher(); + } + + /** + * Import one record's book data. $counters is advanced (imported, + * updated, skipped, authors_created, publishers_created, covers); a + * cover that cannot be fetched is reported in $warnings, not as a + * failure. Throws when the record cannot be written; nothing of it is + * then left in the catalogue. + * + * @param array> $data book data (MarcBookData shape) + * @param array $counters + * @param list $warnings + * @return array{action: string, id: int|null, title: string} + */ + public function import(array $data, array &$counters, array &$warnings = []): array + { + foreach (['imported', 'updated', 'skipped', 'authors_created', 'publishers_created', 'covers'] as $key) { + $counters[$key] = (int) ($counters[$key] ?? 0); + } + $parsed = $this->pipeline->normalizeImportRow(MarcBookData::toImportRow($data)); + $title = (string) ($parsed['titolo'] ?? ''); + $existingId = $this->findExisting($parsed); + + if ($existingId !== null && $this->mode === self::MODE_SKIP) { + $counters['skipped']++; + return ['action' => 'skipped', 'id' => $existingId, 'title' => $title]; + } + if ($existingId === null && $title === '') { + throw new \RuntimeException(__('Titolo mancante')); + } + + $existing = $existingId !== null ? $this->loadBook($existingId) : null; + if ($existingId !== null && $existing === null) { + throw new \RuntimeException('Unable to read the existing book'); + } + $families = []; + if ($existing !== null) { + $families = $this->families($data, $existing); + // A family writes several columns at once (ISBN-13 and ISBN-10, + // series and number, the legacy contributor columns): the ones + // the record leaves empty keep the book's value. + foreach (['isbn10', 'isbn13', 'numero_serie', 'traduttore', 'illustratore', 'curatore'] as $column) { + if (empty($parsed[$column]) && !empty($existing[$column])) { + $parsed[$column] = $existing[$column]; + } + } + } + + $result = $this->pipeline->importParsedRow( + $this->db, + $parsed, + $families, + $counters, + $existingId, + false + ); + $bookId = $result['id']; + + $this->writeExtraColumns($bookId, $data, $existing); + $this->writeCover($bookId, (string) ($data['copertina_url'] ?? ''), $existing, $counters, $warnings); + \App\Support\SearchIndexBuilder::rebuild($this->db, $bookId); + + return ['action' => $result['action'], 'id' => $bookId, 'title' => $title]; + } + + /** + * The book (not deleted) a record matches: by any form of its ISBN or its + * EAN; by exact title and first author when it has neither. + * + * @param array $parsed a row as normalizeImportRow() returns it + */ + public function findExisting(array $parsed): ?int + { + $thirteen = []; + $ten = []; + foreach (['isbn13', 'isbn10'] as $key) { + $isbn = (string) ($parsed[$key] ?? ''); + if ($isbn === '') { + continue; + } + $variants = \App\Support\IsbnFormatter::getAllVariants($isbn); + if (isset($variants['isbn13'])) { + $thirteen[] = $variants['isbn13']; + } + if (isset($variants['isbn10'])) { + $ten[] = $variants['isbn10']; + } + } + $ean = (string) ($parsed['ean'] ?? ''); + $codes13 = array_values(array_unique(array_filter(array_merge($thirteen, [$ean])))); + $ten = array_values(array_unique($ten)); + if ($codes13 !== [] || $ten !== []) { + $conditions = []; + $params = []; + if ($codes13 !== []) { + $in = implode(',', array_fill(0, count($codes13), '?')); + $conditions[] = "isbn13 IN ({$in})"; + $conditions[] = "ean IN ({$in})"; + $params = array_merge($params, $codes13, $codes13); + } + if ($ten !== []) { + $conditions[] = 'isbn10 IN (' . implode(',', array_fill(0, count($ten), '?')) . ')'; + $params = array_merge($params, $ten); + } + $stmt = $this->db->prepare('SELECT id FROM libri WHERE deleted_at IS NULL AND (' . implode(' OR ', $conditions) . ') ORDER BY id LIMIT 1'); + if ($stmt === false) { + throw new \RuntimeException('Unable to prepare the duplicate lookup'); + } + $stmt->bind_param(str_repeat('s', count($params)), ...$params); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return $row ? (int) $row['id'] : null; + } + + $title = trim((string) ($parsed['titolo'] ?? '')); + $authors = array_values(array_filter(array_map('trim', explode(';', (string) ($parsed['autori'] ?? ''))))); + if ($title === '' || $authors === []) { + return null; + } + $author = \App\Support\AuthorNormalizer::normalize($authors[0]); + $stmt = $this->db->prepare( + 'SELECT l.id FROM libri l + JOIN libri_autori la ON la.libro_id = l.id + JOIN autori a ON a.id = la.autore_id + WHERE l.deleted_at IS NULL AND l.titolo = ? AND a.nome = ? + ORDER BY ' . \App\Support\ExactSearch::exactFirstSql('l.titolo') . ', l.id LIMIT 1' + ); + if ($stmt === false) { + throw new \RuntimeException('Unable to prepare the duplicate lookup'); + } + $stmt->bind_param('sss', $title, $author, $title); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return $row ? (int) $row['id'] : null; + } + + /** + * Which CSV update families the record may write on an existing book. + * + * @param array> $data + * @param array $existing + * @return array + */ + private function families(array $data, array $existing): array + { + $carries = static function (string $family) use ($data): bool { + foreach (self::FAMILY_DATA[$family] ?? [] as $key) { + $value = $data[$key] ?? ''; + if (is_array($value) ? $value !== [] : trim((string) $value) !== '') { + return true; + } + } + return false; + }; + $empty = array_flip(\App\Support\BookDataMerger::getMissingFields($existing, array_keys($existing))); + $families = []; + foreach (array_keys(self::FAMILY_DATA) as $family) { + if (!$carries($family)) { + $families[$family] = false; + continue; + } + if ($this->mode === self::MODE_OVERWRITE) { + $families[$family] = true; + continue; + } + // fill: only a family the book has nothing in + if ($family === 'authors') { + $families[$family] = (int) ($existing['_principal_authors'] ?? 0) === 0; + continue; + } + $columns = self::FAMILY_COLUMNS[$family]; + $families[$family] = count(array_intersect_key(array_flip($columns), $empty)) === count($columns); + } + // Never from a MARC record: the genre, the format and the price. + foreach (['genre', 'formato', 'prezzo'] as $family) { + $families[$family] = false; + } + return $families; + } + + /** @return array|null */ + private function loadBook(int $id): ?array + { + $stmt = $this->db->prepare('SELECT * FROM libri WHERE id = ? AND deleted_at IS NULL LIMIT 1'); + if ($stmt === false) { + return null; + } + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc() ?: null; + $stmt->close(); + if ($row === null) { + return null; + } + $count = $this->db->prepare("SELECT COUNT(*) FROM libri_autori WHERE libro_id = ? AND ruolo = 'principale'"); + if ($count !== false) { + $count->bind_param('i', $id); + $count->execute(); + $row['_principal_authors'] = (int) ($count->get_result()->fetch_row()[0] ?? 0); + $count->close(); + } + return $row; + } + + /** + * @param array> $data + * @param array|null $existing + */ + private function writeExtraColumns(int $bookId, array $data, ?array $existing): void + { + $sets = []; + $params = []; + foreach (self::EXTRA_COLUMNS as $column => $length) { + $value = trim((string) ($data[$column] ?? '')); + if ($value === '' || !$this->hasColumn($column)) { + continue; + } + if ($existing !== null && $this->mode === self::MODE_FILL && trim((string) ($existing[$column] ?? '')) !== '') { + continue; + } + $sets[] = "{$column} = ?"; + $params[] = mb_substr($value, 0, $length); + } + if ($sets === []) { + return; + } + $params[] = $bookId; + $stmt = $this->db->prepare('UPDATE libri SET ' . implode(', ', $sets) . ' WHERE id = ? AND deleted_at IS NULL'); + if ($stmt === false) { + throw new \RuntimeException('Unable to prepare the MARC column update'); + } + $stmt->bind_param(str_repeat('s', count($params) - 1) . 'i', ...$params); + $stmt->execute(); + $stmt->close(); + } + + /** + * @param array|null $existing + * @param array $counters + * @param list $warnings + */ + private function writeCover(int $bookId, string $url, ?array $existing, array &$counters, array &$warnings): void + { + if (!$this->downloadCovers || $url === '') { + return; + } + if ($existing !== null && $this->mode === self::MODE_FILL && trim((string) ($existing['copertina_url'] ?? '')) !== '') { + return; + } + try { + $path = $this->coverFetcher?->fetch($url) ?? ''; + } catch (\Throwable $e) { + $warnings[] = __('Copertina non scaricata') . ': ' . $e->getMessage(); + return; + } + if ($path === '') { + return; + } + $stmt = $this->db->prepare('UPDATE libri SET copertina_url = ? WHERE id = ? AND deleted_at IS NULL'); + if ($stmt === false) { + return; + } + $stmt->bind_param('si', $path, $bookId); + $stmt->execute(); + $stmt->close(); + $counters['covers']++; + } + + private function hasColumn(string $column): bool + { + if ($this->columns === null) { + $this->columns = []; + $res = $this->db->query("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri'"); + if ($res instanceof \mysqli_result) { + while ($r = $res->fetch_row()) { + $this->columns[(string) $r[0]] = true; + } + $res->free(); + } + } + return isset($this->columns[$column]); + } +} diff --git a/app/Support/Marc/MarcCoverFetcher.php b/app/Support/Marc/MarcCoverFetcher.php new file mode 100644 index 000000000..94530b6d4 --- /dev/null +++ b/app/Support/Marc/MarcCoverFetcher.php @@ -0,0 +1,83 @@ +localCoverFile($url); + if ($local !== null) { + return $this->copyLocal($local); + } + $saved = (new \App\Controllers\CoverController())->downloadFromUrl($url); + return (string) $saved['file_url']; + } + + /** The file in this installation's covers folder a URL names, or null. */ + private function localCoverFile(string $url): ?string + { + $parts = parse_url($url); + $path = (string) ($parts['path'] ?? ''); + if ($path === '' || !isset($parts['host'])) { + return null; + } + $base = parse_url(function_exists('absoluteUrl') ? \absoluteUrl('/') : ''); + if (!is_array($base) || !isset($base['host']) + || strcasecmp((string) $base['host'], (string) $parts['host']) !== 0 + || (int) ($base['port'] ?? 0) !== (int) ($parts['port'] ?? 0) + ) { + return null; + } + $basePath = rtrim((string) ($base['path'] ?? ''), '/'); + if ($basePath !== '' && str_starts_with($path, $basePath . '/')) { + $path = substr($path, strlen($basePath)); + } + if (!str_starts_with($path, self::COVERS_PATH)) { + return null; + } + $name = basename(rawurldecode($path)); + if (preg_match('/^[A-Za-z0-9._-]+\.(jpe?g|png|webp|gif)$/i', $name) !== 1) { + return null; + } + $file = self::coversDir() . '/' . $name; + return is_file($file) ? $file : null; + } + + private function copyLocal(string $file): string + { + $info = @getimagesize($file); + if ($info === false) { + throw new \RuntimeException(__('File non valido o corrotto.')); + } + $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'png' ? 'png' : 'jpg'; + $name = uniqid('copertina_', true) . '.' . $extension; + if (!@copy($file, self::coversDir() . '/' . $name)) { + throw new \RuntimeException(__('Errore nel salvataggio dell\'immagine.')); + } + @chmod(self::coversDir() . '/' . $name, 0644); + return self::COVERS_PATH . $name; + } + + private static function coversDir(): string + { + return dirname(__DIR__, 3) . '/public/uploads/copertine'; + } +} diff --git a/app/Views/admin/imports_history.php b/app/Views/admin/imports_history.php index cdd553e90..38d04a5c5 100644 --- a/app/Views/admin/imports_history.php +++ b/app/Views/admin/imports_history.php @@ -180,6 +180,7 @@ $typeLabels = [ 'csv' => ['label' => 'CSV', 'color' => 'bg-blue-100 text-blue-800'], 'librarything' => ['label' => 'LibraryThing', 'color' => 'bg-purple-100 text-purple-800'], + 'marc' => ['label' => 'MARC', 'color' => 'bg-green-100 text-green-800'], ]; $type = $typeLabels[$import['import_type']] ?? ['label' => 'Unknown', 'color' => 'bg-gray-100 text-gray-800']; diff --git a/app/Views/admin/marc_import.php b/app/Views/admin/marc_import.php new file mode 100644 index 000000000..1cb4dfa1e --- /dev/null +++ b/app/Views/admin/marc_import.php @@ -0,0 +1,443 @@ + + +
      +
      + +

      + + + + + + + + + + + + +

      +
      + +
      + + +
      + +
      + +
      +

      + + +

      + +
      + +
      + + + +
      +
      + + +
      +
      + + +
      +
      + +
      +
      +
      + + +
      +
      +
      + [__("Salta"), __("Il libro già in catalogo resta com'è")], + 'fill' => [__("Completa i campi vuoti"), __("Solo i campi vuoti del libro vengono riempiti dal record")], + 'overwrite' => [__("Sovrascrivi"), __("I campi presenti nel record sostituiscono quelli del libro; gli altri restano")], + ] as $modeKey => [$modeLabel, $modeHelp]): ?> + + +
      +
      + +
      + +
      + + + +
      +
      +
      + + +
      +
      + +
      +
      +
      + + + + +
      + +
      +
      +

      + + +

      +
        +
      1. + 1 + +
      2. +
      3. + 2 + +
      4. +
      5. + 3 + +
      6. +
      +
      +
      +

      + + +

      +
        +
      • + + +
      • +
      • + + +
      • +
      • + + +
      • +
      +
      +
      +
      +

      +
      + + + + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + +async function loginAsAdmin(page) { + await page.goto(`${BASE}/admin/dashboard`); + const email = page.locator('input[name="email"]'); + if (await email.isVisible({ timeout: 3000 }).catch(() => false)) { + await email.fill(process.env.E2E_ADMIN_EMAIL); + await page.fill('input[name="password"]', process.env.E2E_ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/.*(?:dashboard|admin).*/, { timeout: 15000 }); + } +} + +/** A MARC file of the E2E series (see tests/helpers/marc-fixtures.php). */ +function marcFile(run, count, kind, first = 1) { + const ext = kind === 'xml' ? 'xml' : 'mrc'; + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pinakes-marc-')), `books-${run}-${kind}-${first}.${ext}`); + execFileSync('php', [path.join(ROOT, 'tests', 'helpers', 'marc-fixtures.php'), 'e2e', file, run, String(count), kind, String(first)]); + return file; +} + +/** Remove every book, author, publisher and series a run created. */ +function cleanupRun(run) { + const like = `'%${sqlEscape(run)}%'`; + const w = `l.titolo LIKE ${like}`; + dbQuery( + `DELETE lais FROM libri_autori_import_sources lais JOIN libri l ON l.id=lais.libro_id WHERE ${w};` + + `DELETE la FROM libri_autori la JOIN libri l ON l.id=la.libro_id WHERE ${w};` + + `DELETE le FROM libri_editori le JOIN libri l ON l.id=le.libro_id WHERE ${w};` + + `DELETE lc FROM libri_collane lc JOIN libri l ON l.id=lc.libro_id WHERE ${w};` + + `DELETE c FROM copie c JOIN libri l ON l.id=c.libro_id WHERE ${w};` + + `DELETE FROM libri WHERE titolo LIKE ${like};` + + `DELETE FROM autori WHERE nome LIKE ${like};` + + `DELETE FROM editori WHERE nome LIKE ${like};` + + `DELETE FROM collane WHERE nome LIKE ${like};` + + `DELETE FROM import_logs WHERE file_name LIKE ${like};` + ); +} + +/** Upload a file on the MARC import page and wait for the preview. */ +async function uploadForPreview(page, file, { duplicates = 'skip', covers = false, flavour = 'auto' } = {}) { + await page.goto(`${BASE}/admin/books/import/marc`); + await page.setInputFiles('#marc_file', file); + await page.selectOption('#marc_flavour', flavour); + await page.check(`#duplicates_${duplicates}`); + if (covers) await page.check('#download_covers'); else await page.uncheck('#download_covers'); + const uploaded = page.waitForResponse((r) => r.url().includes('/admin/books/import/marc/upload')); + await page.click('#previewBtn'); + const response = await uploaded; + return response.json(); +} + +/** Confirm the previewed import and wait for the last chunk. */ +async function confirmImport(page) { + let last = null; + const onResponse = async (r) => { + if (r.url().includes('/admin/books/import/marc/chunk')) { + try { const j = await r.json(); if (j.complete) last = j; } catch { /* asserted below */ } + } + }; + page.on('response', onResponse); + await page.click('#confirmImportBtn'); + await page.locator('#marc-result').waitFor({ state: 'visible', timeout: 120000 }); + page.off('response', onResponse); + return last; +} + +module.exports = { BASE, ROOT, dbQuery, sqlEscape, loginAsAdmin, marcFile, cleanupRun, uploadForPreview, confirmImport }; diff --git a/tests/helpers/marc-fixtures.php b/tests/helpers/marc-fixtures.php index 9b5879414..a0993cd04 100644 --- a/tests/helpers/marc-fixtures.php +++ b/tests/helpers/marc-fixtures.php @@ -98,6 +98,35 @@ function e2eMarc21(string $run, int $i, string $digits): array $bytes = $kind === 'xml' ? "\n\n" : ''; for ($i = $first; $i < $first + $count; $i++) { [, $fields] = e2eMarc21($run, $i, (string) ($seed * 100 + $i)); + if ($kind === 'malformed') { + // every third record has a directory that points past its end + $record = iso('00000nam a2200000 i 4500', $fields); + $bytes .= $i % 3 === 0 ? substr_replace($record, '9999', 24 + 3, 4) : $record; + continue; + } + if ($kind === 'marc8') { + // MARC-8: "Città" with a combining grave before the a; record 2 switches to Cyrillic + $n = str_pad((string) $i, 2, '0', STR_PAD_LEFT); + $title = $i === $first + 1 ? "Titolo \x1B(N{$run} {$n}" : "Citt\xE1a MARC8 {$run} {$n}"; + $bytes .= iso('00000nam 2200000 4500', [ + ['001', "M8{$run}{$n}"], + ['020', ' ', ' ', [['a', isbn13('978' . str_pad((string) ($seed * 100 + 50 + $i), 9, '0', STR_PAD_LEFT))]]], + ['100', '1', ' ', [['a', "M\xE8uller{$run}, J\xE8urgen"]]], + ['245', '1', '0', [['a', $title]]], + ['264', ' ', '1', [['b', "Edizioni{$run}"], ['c', '1999']]], + ]); + continue; + } + if ($kind === 'authors') { + $n = str_pad((string) $i, 2, '0', STR_PAD_LEFT); + $bytes .= iso('00000nam a2200000 i 4500', [ + ['020', ' ', ' ', [['a', isbn13('978' . str_pad((string) ($seed * 100 + 80 + $i), 9, '0', STR_PAD_LEFT))]]], + ['100', '1', ' ', [['a', "Häffner{$run}, Jürgen"]]], + ['245', '1', '0', [['a', "Libro esatto {$run} {$n}"]]], + ['264', ' ', '1', [['b', "Röss{$run}"], ['c', '2020']]], + ]); + continue; + } if ($kind === 'xml') { $bytes .= xmlRecord('00000nam a2200000 i 4500', $fields); } elseif ($kind === 'unimarc') { diff --git a/tests/marc-bulk-import.spec.js b/tests/marc-bulk-import.spec.js new file mode 100644 index 000000000..c4c5477b6 --- /dev/null +++ b/tests/marc-bulk-import.spec.js @@ -0,0 +1,171 @@ +// E2E: bulk MARC import (/admin/books/import/marc) through the real browser: +// upload, preview, confirm, chunked processing, duplicates, error report. +// Files are generated per run (tests/helpers/marc-fixtures.php), so titles and +// ISBNs never collide with an earlier run; afterAll removes what the run made. +const { test, expect } = require('@playwright/test'); +const { BASE, dbQuery, sqlEscape, loginAsAdmin, marcFile, cleanupRun, uploadForPreview, confirmImport } = require('./helpers/marc-e2e'); + +const RUN = 'MI' + Date.now().toString(36); +const SKIP = !process.env.E2E_ADMIN_EMAIL || !process.env.E2E_DB_NAME; + +test.describe.serial('MARC bulk import', () => { + test.skip(SKIP, 'E2E credentials not set'); + test.afterAll(() => cleanupRun(RUN)); + + test('a 50-record MARC 21 .mrc file: preview, confirm, books in the database and the catalogue', async ({ page }) => { + test.setTimeout(180000); + await loginAsAdmin(page); + const file = marcFile(RUN, 50, 'mrc'); + const upload = await uploadForPreview(page, file); + expect(upload.success).toBe(true); + expect(upload.total_rows).toBe(50); + + // The preview lists the first ten mapped records, all new + await expect(page.locator('#marc-preview')).toBeVisible(); + await expect(page.locator('#marc-preview-summary')).toContainText('50'); + await expect(page.locator('#marc-preview-rows tr[data-preview-row="record"]')).toHaveCount(10); + await expect(page.locator('#marc-preview-rows')).toContainText(`Libro MARC ${RUN} 01`); + await expect(page.locator('#marc-preview-rows')).toContainText(`Autore${RUN} 01, Mario`); + // Nothing is written before the librarian confirms + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE '%${RUN}%' AND deleted_at IS NULL`)).toBe('0'); + + const result = await confirmImport(page); + expect(result.imported).toBe(50); + expect(result.failed).toBe(0); + await expect(page.locator('#marc-result-summary')).toContainText('50'); + + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE 'Libro MARC ${RUN} %' AND deleted_at IS NULL`)).toBe('50'); + // Field by field on one book + const row = dbQuery(`SELECT CONCAT_WS('|', titolo, sottotitolo, edizione, luogo_pubblicazione, anno_pubblicazione, numero_pagine, dimensioni, collana, numero_serie, classificazione_dewey, lingua) FROM libri WHERE titolo = 'Libro MARC ${RUN} 07' AND deleted_at IS NULL`); + expect(row).toBe(`Libro MARC ${RUN} 07|romanzo di prova|2. ed.|Milano|2019|127|21 cm|Collana${RUN}|7|853.92|Italiano`); + expect(dbQuery(`SELECT a.nome FROM libri l JOIN libri_autori la ON la.libro_id=l.id AND la.ruolo='principale' JOIN autori a ON a.id=la.autore_id WHERE l.titolo='Libro MARC ${RUN} 07'`)).toBe(`Mario Autore${RUN} 07`); + // The 700 named in 245 $c as "traduzione di" is the translator, not an author + expect(dbQuery(`SELECT GROUP_CONCAT(la.ruolo) FROM libri l JOIN libri_autori la ON la.libro_id=l.id JOIN autori a ON a.id=la.autore_id WHERE l.titolo='Libro MARC ${RUN} 07' AND a.nome LIKE 'Anna Traduttrice%'`)).toBe('traduttore'); + expect(dbQuery(`SELECT e.nome FROM libri l JOIN editori e ON e.id=l.editore_id WHERE l.titolo='Libro MARC ${RUN} 07'`)).toBe(`Editrice${RUN}`); + // One copy per book, as the CSV import does + expect(dbQuery(`SELECT COUNT(*) FROM copie c JOIN libri l ON l.id=c.libro_id WHERE l.titolo='Libro MARC ${RUN} 07'`)).toBe('1'); + // The import is in the history as a MARC import + expect(dbQuery(`SELECT CONCAT(import_type,':',imported,':',status) FROM import_logs WHERE file_name LIKE '%${RUN}-mrc%' ORDER BY id DESC LIMIT 1`)).toBe('marc:50:completed'); + + // And in the catalogue: the admin book list and the public catalogue find it + await page.goto(`${BASE}/admin/books`); + await page.fill('#search_text', `Libro MARC ${RUN} 07`); + await expect(page.locator('#books-table, #table-view').first()).toContainText(`Libro MARC ${RUN} 07`, { timeout: 15000 }); + await page.goto(`${BASE}/catalogo?q=${encodeURIComponent(`Libro MARC ${RUN} 07`)}`); + await expect(page.locator('main, body').first()).toContainText(`Libro MARC ${RUN} 07`, { timeout: 15000 }); + }); + + test('a MARCXML collection imports the same way', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + const file = marcFile(RUN, 4, 'xml', 101); + const upload = await uploadForPreview(page, file); + expect(upload.container).toBe('xml'); + expect(upload.total_rows).toBe(4); + await expect(page.locator('#marc-preview-rows tr[data-preview-row="record"]')).toHaveCount(4); + const result = await confirmImport(page); + expect(result.imported).toBe(4); + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE 'Libro MARC ${RUN} 10_' AND deleted_at IS NULL`)).toBe('4'); + expect(dbQuery(`SELECT isbn13 IS NOT NULL FROM libri WHERE titolo = 'Libro MARC ${RUN} 102'`)).toBe('1'); + }); + + test('duplicates: skip leaves books alone, fill completes empty fields, overwrite replaces', async ({ page }) => { + test.setTimeout(180000); + await loginAsAdmin(page); + const file = marcFile(RUN, 3, 'mrc'); + const title1 = `Libro MARC ${RUN} 01`; + // The librarian edited book 01 after the first import, and book 02 lost its description. + dbQuery(`UPDATE libri SET titolo='${sqlEscape(title1)} (rivisto)', numero_pagine=999 WHERE titolo='${sqlEscape(title1)}' AND deleted_at IS NULL`); + dbQuery(`UPDATE libri SET descrizione=NULL, descrizione_plain=NULL, edizione=NULL WHERE titolo='Libro MARC ${RUN} 02' AND deleted_at IS NULL`); + + // skip: the preview flags all three as already in the catalogue; nothing changes + let upload = await uploadForPreview(page, file, { duplicates: 'skip' }); + expect(upload.preview.every((r) => r.existing_id > 0)).toBe(true); + await expect(page.locator('#marc-preview-rows')).toContainText('#'); + let result = await confirmImport(page); + expect([result.imported, result.updated, result.skipped]).toEqual([0, 0, 3]); + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE 'Libro MARC ${RUN} 01%' AND deleted_at IS NULL`)).toBe('1'); + expect(dbQuery(`SELECT IFNULL(edizione,'') FROM libri WHERE titolo='Libro MARC ${RUN} 02'`)).toBe(''); + + // fill: book 02 gets its description and edition back; book 01 keeps the librarian's edits + upload = await uploadForPreview(page, file, { duplicates: 'fill' }); + result = await confirmImport(page); + expect([result.imported, result.updated, result.skipped]).toEqual([0, 3, 0]); + expect(dbQuery(`SELECT CONCAT(edizione,'|',descrizione) FROM libri WHERE titolo='Libro MARC ${RUN} 02'`)).toBe(`2. ed.|Descrizione del libro 02 della serie ${RUN}.`); + expect(dbQuery(`SELECT CONCAT(titolo,'|',numero_pagine) FROM libri WHERE titolo LIKE '${sqlEscape(title1)}%'`)).toBe(`${title1} (rivisto)|999`); + + // overwrite: the record's fields replace the book's + upload = await uploadForPreview(page, file, { duplicates: 'overwrite' }); + result = await confirmImport(page); + expect(result.updated).toBe(3); + expect(dbQuery(`SELECT CONCAT(titolo,'|',numero_pagine) FROM libri WHERE titolo LIKE '${sqlEscape(title1)}%' AND deleted_at IS NULL`)).toBe(`${title1}|121`); + // still one book per ISBN, one copy each + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE '${sqlEscape(title1)}%' AND deleted_at IS NULL`)).toBe('1'); + expect(dbQuery(`SELECT COUNT(*) FROM copie c JOIN libri l ON l.id=c.libro_id WHERE l.titolo='${sqlEscape(title1)}'`)).toBe('1'); + }); + + test('a deleted book does not count as a duplicate: its ISBN was cleared', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + const title = `Libro MARC ${RUN} 03`; + // Delete book 03 from the admin UI path the app uses: soft delete clears the ISBN + const id = dbQuery(`SELECT id FROM libri WHERE titolo='${sqlEscape(title)}' AND deleted_at IS NULL`); + dbQuery(`UPDATE libri SET deleted_at=NOW(), isbn13=NULL, isbn10=NULL, ean=NULL WHERE id=${Number(id)}`); + const file = marcFile(RUN, 1, 'mrc', 3); + const upload = await uploadForPreview(page, file, { duplicates: 'skip' }); + expect(upload.preview[0].existing_id).toBeNull(); + const result = await confirmImport(page); + expect(result.imported).toBe(1); + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo='${sqlEscape(title)}' AND deleted_at IS NULL`)).toBe('1'); + }); + + test('malformed records do not stop the batch and the error report can be downloaded', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + const file = marcFile(RUN, 6, 'malformed', 201); + const upload = await uploadForPreview(page, file); + expect(upload.total_rows).toBe(6); + expect(upload.unreadable).toBe(2); + await expect(page.locator('#marc-preview-rows tr[data-preview-row="error"]')).toHaveCount(2); + const result = await confirmImport(page); + expect(result.imported).toBe(4); + expect(result.failed).toBe(2); + await expect(page.locator('#marc-result-errors')).toContainText('runs past the end'); + const link = page.locator('#marc-errors-link'); + await expect(link).toBeVisible(); + const [download] = await Promise.all([page.waitForEvent('download'), link.click()]); + const csv = require('fs').readFileSync(await download.path(), 'utf-8'); + expect(csv).toContain('runs past the end of the record'); + expect(csv.split('\n').filter((l) => l.includes('runs past the end')).length).toBe(2); + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE 'Libro MARC ${RUN} 20_' AND deleted_at IS NULL`)).toBe('4'); + }); + + test('MARC-8 records are decoded; one in an unsupported character set is reported', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + const file = marcFile(RUN, 3, 'marc8', 301); + const upload = await uploadForPreview(page, file); + expect(upload.unreadable).toBe(1); + await expect(page.locator('#marc-preview-rows')).toContainText(`Città MARC8 ${RUN} 301`); + const result = await confirmImport(page); + expect(result.imported).toBe(2); + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo = 'Città MARC8 ${RUN} 301' AND deleted_at IS NULL`)).toBe('1'); + expect(dbQuery(`SELECT a.nome FROM libri l JOIN libri_autori la ON la.libro_id=l.id JOIN autori a ON a.id=la.autore_id WHERE l.titolo='Città MARC8 ${RUN} 301'`)).toBe(`Jürgen Müller${RUN}`); + }); + + test('authors and publishers follow the exact-spelling rules of #501', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + // Both spellings already on file: the record's exact one must win + dbQuery(`INSERT INTO autori (nome, created_at) VALUES ('Jürgen Haffner${RUN}', NOW()), ('Jürgen Häffner${RUN}', NOW())`); + dbQuery(`INSERT INTO editori (nome, created_at) VALUES ('Ross${RUN}', NOW()), ('Röss${RUN}', NOW())`); + const file = marcFile(RUN, 1, 'authors', 401); + await uploadForPreview(page, file); + const result = await confirmImport(page); + expect(result.imported).toBe(1); + expect(dbQuery(`SELECT a.nome FROM libri l JOIN libri_autori la ON la.libro_id=l.id JOIN autori a ON a.id=la.autore_id WHERE l.titolo='Libro esatto ${RUN} 401' COLLATE utf8mb4_bin`)).toBe(`Jürgen Häffner${RUN}`); + expect(dbQuery(`SELECT e.nome FROM libri l JOIN editori e ON e.id=l.editore_id WHERE l.titolo='Libro esatto ${RUN} 401'`)).toBe(`Röss${RUN}`); + expect(result.authors_created).toBe(0); + expect(result.publishers_created).toBe(0); + }); +}); From ce9d62d73d173aa58407af53ac2edc95be35b839 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:26:23 +0100 Subject: [PATCH 032/398] Export the book list or a selection in MARC from the UI The Export menu offers MARC 21 (.mrc), MARCXML and UNIMARC for the whole catalogue, and the bulk bar gains "Esporta in MARC" for the selected books. The specs read each export back with the shared library and re-import it without duplicates. --- app/Controllers/LibriController.php | 8 ++ app/Views/libri/index.php | 50 +++++++++- tests/helpers/marc-inspect.php | 40 ++++++++ tests/marc-bulk-export.spec.js | 139 ++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 tests/helpers/marc-inspect.php create mode 100644 tests/marc-bulk-export.spec.js diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 5ca8e94d9..7deb40804 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -400,6 +400,14 @@ public function index(Request $request, Response $response, mysqli $db): Respons ob_start(); $articleHintState = \App\Support\PeriodicalArticlesHint::state($db); + // Copy cataloguing (SRU search) is served by the z39-server plugin. + $copyCatalogingAvailable = false; + try { + $pluginRow = $db->query("SELECT is_active FROM plugins WHERE name = 'z39-server' LIMIT 1"); + $copyCatalogingAvailable = $pluginRow instanceof \mysqli_result && (int) ($pluginRow->fetch_row()[0] ?? 0) === 1; + } catch (\Throwable) { + $copyCatalogingAvailable = false; + } $data = ['libri' => $libri, 'genreFilterName' => $genreFilterName, 'subgenreFilterName' => $subgenreFilterName]; // extract($data); require __DIR__ . '/../Views/libri/index.php'; diff --git a/app/Views/libri/index.php b/app/Views/libri/index.php index 1e59b6b5b..fdce81c12 100644 --- a/app/Views/libri/index.php +++ b/app/Views/libri/index.php @@ -51,9 +51,18 @@ - + + + + + + + + + + @@ -84,9 +93,18 @@ - + + + + + + + + + + @@ -102,6 +120,14 @@ + + + + + + + +
      @@ -361,6 +387,9 @@ class="form-input" /> + @@ -1445,6 +1474,23 @@ function updateBulkActionsBar() { window.location.href = `${window.BASE_PATH}/admin/books/export/csv?ids=${ids}`; }); + // Bulk MARC export of the selection: MARC 21, MARCXML or UNIMARC + document.getElementById('bulk-export-marc').addEventListener('click', async function() { + if (selectedBooks.size === 0) return; + const choice = await Swal.fire({ + title: __('Esporta in MARC'), + input: 'select', + inputOptions: { marc21: __('MARC 21 (.mrc)'), marcxml: __('MARCXML'), unimarc: __('UNIMARC (.mrc)') }, + inputValue: 'marc21', + showCancelButton: true, + confirmButtonText: __('Esporta'), + cancelButtonText: __('Annulla') + }); + if (!choice.isConfirmed || !choice.value) return; + const ids = Array.from(selectedBooks).join(','); + window.location.href = `${window.BASE_PATH}/admin/books/export/marc?format=${encodeURIComponent(choice.value)}&ids=${ids}`; + }); + // View toggle function setView(view) { currentView = view; diff --git a/tests/helpers/marc-inspect.php b/tests/helpers/marc-inspect.php new file mode 100644 index 000000000..1400d829d --- /dev/null +++ b/tests/helpers/marc-inspect.php @@ -0,0 +1,40 @@ + + * → {"container": "iso2709"|"xml", "errors": n, "records": [{flavour, titolo, isbn13, autori, cover, leader9, has856}]} + */ + +use App\Support\Marc\Iso2709Reader; +use App\Support\Marc\MarcFormat; +use App\Support\Marc\MarcRecord; +use App\Support\Marc\MarcXmlReader; + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; + +$file = $argv[1] ?? ''; +$container = MarcFormat::containerOfFile($file); +$reader = $container === MarcFormat::CONTAINER_XML ? MarcXmlReader::fromPath($file) : Iso2709Reader::fromPath($file); +$out = ['container' => $container, 'errors' => 0, 'records' => []]; +foreach ($reader as $item) { + if (!$item instanceof MarcRecord) { + $out['errors']++; + continue; + } + $flavour = MarcFormat::detectFlavour($item); + $data = MarcFormat::mapperFor($flavour)->map($item); + $out['records'][] = [ + 'flavour' => $flavour, + 'titolo' => $data['titolo'], + 'isbn13' => $data['isbn13'], + 'autori' => $data['autori'], + 'editori' => $data['editori'], + 'cover' => $data['copertina_url'], + 'leader9' => $item->leader()[9], + 'id' => $item->controlField('001'), + ]; +} +echo json_encode($out, JSON_UNESCAPED_UNICODE); diff --git a/tests/marc-bulk-export.spec.js b/tests/marc-bulk-export.spec.js new file mode 100644 index 000000000..0a2295276 --- /dev/null +++ b/tests/marc-bulk-export.spec.js @@ -0,0 +1,139 @@ +// E2E: bulk MARC export from the book list (selection like #77 and the whole +// catalogue) in MARC 21 .mrc, MARCXML and UNIMARC, read back with the shared +// MARC library, and re-imported from the UI without creating duplicates. +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { BASE, ROOT, dbQuery, sqlEscape, loginAsAdmin, marcFile, cleanupRun, uploadForPreview, confirmImport } = require('./helpers/marc-e2e'); + +const RUN = 'MX' + Date.now().toString(36); +const SKIP = !process.env.E2E_ADMIN_EMAIL || !process.env.E2E_DB_NAME; +const inspect = (file) => JSON.parse(execFileSync('php', [path.join(ROOT, 'tests', 'helpers', 'marc-inspect.php'), file], { encoding: 'utf-8' })); +const downloads = {}; +let coverFile = null; + +async function selectRunBooks(page) { + await page.goto(`${BASE}/admin/books?keywords=${encodeURIComponent(`Soggetto${RUN}`)}`); + await expect(page.locator('.row-select')).toHaveCount(3, { timeout: 15000 }); + await page.locator('#select-all').check(); + await expect(page.locator('#selected-count')).toHaveText('3'); +} + +async function exportSelection(page, format) { + await selectRunBooks(page); + await page.click('#bulk-export-marc'); + await page.locator('.swal2-select').selectOption(format); + const [download] = await Promise.all([page.waitForEvent('download'), page.click('.swal2-confirm')]); + const file = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'pinakes-export-')), download.suggestedFilename()); + await download.saveAs(file); + return { file, name: download.suggestedFilename() }; +} + +test.describe.serial('MARC bulk export', () => { + test.skip(SKIP, 'E2E credentials not set'); + test.afterAll(() => { + cleanupRun(RUN); + if (coverFile && fs.existsSync(coverFile)) fs.unlinkSync(coverFile); + }); + + test('setup: three books imported from MARC, one with a cover', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + await uploadForPreview(page, marcFile(RUN, 3, 'mrc')); + const result = await confirmImport(page); + expect(result.imported).toBe(3); + // A local cover on book 01, as the book form would have saved it + coverFile = path.join(ROOT, 'public', 'uploads', 'copertine', `marc-export-${RUN}.jpg`); + fs.mkdirSync(path.dirname(coverFile), { recursive: true }); + fs.copyFileSync(path.join(ROOT, 'tests', 'fixtures', 'archive-test-cover.jpg'), coverFile); + dbQuery(`UPDATE libri SET copertina_url='/uploads/copertine/marc-export-${RUN}.jpg' WHERE titolo='Libro MARC ${RUN} 01'`); + }); + + test('the selection exports as MARC 21 (.mrc): three UTF-8 records with ISBN and cover', async ({ page }) => { + await loginAsAdmin(page); + const { file, name } = await exportSelection(page, 'marc21'); + downloads.marc21 = file; + expect(name).toMatch(/marc21.*\.mrc$/); + const read = inspect(file); + expect(read.container).toBe('iso2709'); + expect(read.errors).toBe(0); + expect(read.records.map((r) => r.titolo).sort()).toEqual([1, 2, 3].map((i) => `Libro MARC ${RUN} 0${i}`)); + expect(read.records.every((r) => r.flavour === 'marc21' && r.leader9 === 'a' && /^97[89]\d{10}$/.test(r.isbn13))).toBe(true); + const one = read.records.find((r) => r.titolo === `Libro MARC ${RUN} 01`); + expect(one.cover).toMatch(new RegExp(`/uploads/copertine/marc-export-${RUN}\\.jpg$`)); + expect(one.autori).toEqual([`Mario Autore${RUN} 01`]); + }); + + test('the selection exports as a MARCXML collection', async ({ page }) => { + await loginAsAdmin(page); + const { file, name } = await exportSelection(page, 'marcxml'); + downloads.marcxml = file; + expect(name).toMatch(/\.xml$/); + const xml = fs.readFileSync(file, 'utf-8'); + expect(xml.startsWith('/g) || []).length).toBe(3); + const read = inspect(file); + expect(read.container).toBe('xml'); + expect(read.records.length).toBe(3); + }); + + test('the selection exports as UNIMARC (.mrc)', async ({ page }) => { + await loginAsAdmin(page); + const { file } = await exportSelection(page, 'unimarc'); + downloads.unimarc = file; + const read = inspect(file); + expect(read.records.length).toBe(3); + expect(read.records.every((r) => r.flavour === 'unimarc')).toBe(true); + expect(read.records.map((r) => r.titolo).sort()).toEqual([1, 2, 3].map((i) => `Libro MARC ${RUN} 0${i}`)); + expect(read.records.find((r) => r.titolo === `Libro MARC ${RUN} 01`).cover).toContain(`marc-export-${RUN}.jpg`); + }); + + test('each export re-imports with no duplicates', async ({ page }) => { + test.setTimeout(120000); + await loginAsAdmin(page); + for (const format of ['marc21', 'marcxml', 'unimarc']) { + const upload = await uploadForPreview(page, downloads[format]); + expect(upload.total_rows).toBe(3); + expect(upload.preview.every((r) => r.existing_id > 0)).toBe(true); + const result = await confirmImport(page); + expect([format, result.imported, result.skipped, result.failed]).toEqual([format, 0, 3, 0]); + } + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE 'Libro MARC ${RUN} %' AND deleted_at IS NULL`)).toBe('3'); + }); + + test('an export brings a catalogue back: books, authors and the cover from 856', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + const before = dbQuery(`SELECT CONCAT_WS('|', titolo, sottotitolo, anno_pubblicazione, numero_pagine, collana, classificazione_dewey, isbn13) FROM libri WHERE titolo='Libro MARC ${RUN} 01' AND deleted_at IS NULL`); + // The book is withdrawn (soft delete clears its identifiers), then the export is imported + dbQuery(`UPDATE libri SET deleted_at=NOW(), isbn13=NULL, isbn10=NULL, ean=NULL WHERE titolo='Libro MARC ${RUN} 01' AND deleted_at IS NULL`); + await uploadForPreview(page, downloads.marc21, { covers: true }); + const result = await confirmImport(page); + expect(result.imported).toBe(1); + expect(result.skipped).toBe(2); + expect(result.covers).toBe(1); + const after = dbQuery(`SELECT CONCAT_WS('|', titolo, sottotitolo, anno_pubblicazione, numero_pagine, collana, classificazione_dewey, isbn13) FROM libri WHERE titolo='Libro MARC ${RUN} 01' AND deleted_at IS NULL`); + expect(after).toBe(before); + const cover = dbQuery(`SELECT copertina_url FROM libri WHERE titolo='Libro MARC ${RUN} 01' AND deleted_at IS NULL`); + expect(cover).toMatch(/^\/uploads\/copertine\/copertina_.+\.jpg$/); + expect(fs.existsSync(path.join(ROOT, 'public', cover))).toBe(true); + fs.unlinkSync(path.join(ROOT, 'public', cover)); + }); + + test('the whole catalogue exports from the Export menu, without deleted books', async ({ page }) => { + test.setTimeout(120000); + await loginAsAdmin(page); + await page.goto(`${BASE}/admin/books`); + await page.click('.export-btn'); + const [download] = await Promise.all([page.waitForEvent('download'), page.click('a[data-marc-export="marc21"]')]); + const file = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'pinakes-export-')), download.suggestedFilename()); + await download.saveAs(file); + const read = inspect(file); + expect(read.errors).toBe(0); + expect(String(read.records.length)).toBe(dbQuery('SELECT COUNT(*) FROM libri WHERE deleted_at IS NULL')); + // the withdrawn copy of book 01 is not exported, its re-imported twin is: one record + expect(read.records.filter((r) => r.titolo === `Libro MARC ${RUN} 01`).length).toBe(1); + }); +}); From 9135f00eb7eb6bc72bc5cd05d05eaab32934573a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:32:02 +0100 Subject: [PATCH 033/398] Copy cataloguing: search other catalogues over SRU and import the chosen records SruClient::search() and searchServer() return every record of a CQL query (title, author, ISBN/ISSN or all fields, with each server's own index names), read with the shared MARC library. /admin/books/copy-cataloging searches the configured SRU servers, SBN, Library of Congress, BnF, DNB, K10plus and this installation's own SRU server, shows each record with its MARC detail and imports the selected ones through the MARC importer, with its duplicate modes and cover download. This installation's server is answered in-process, so the specs need no network. The UNIMARC records of the SRU server now carry the cover in 856 too, and the plugin settings gain an "other Pinakes installation" preset whose title and author indexes are kept on save. The plugin's views folder is no longer git-ignored. --- .gitignore | 2 + app/Views/admin/plugins.php | 20 +- .../plugins/z39-server/Z39ServerPlugin.php | 92 +++++ .../z39-server/classes/CopyCataloging.php | 322 ++++++++++++++++++ .../z39-server/classes/LocalSruClient.php | 57 ++++ .../plugins/z39-server/classes/SruClient.php | 160 +++++++++ .../classes/UNIMARCXMLFormatter.php | 10 + .../z39-server/views/copy-cataloging.php | 319 +++++++++++++++++ tests/marc-copy-cataloging.spec.js | 175 ++++++++++ tests/sru-search.unit.php | 99 ++++++ 10 files changed, 1254 insertions(+), 2 deletions(-) create mode 100644 storage/plugins/z39-server/classes/CopyCataloging.php create mode 100644 storage/plugins/z39-server/classes/LocalSruClient.php create mode 100644 storage/plugins/z39-server/views/copy-cataloging.php create mode 100644 tests/marc-copy-cataloging.spec.js create mode 100644 tests/sru-search.unit.php diff --git a/.gitignore b/.gitignore index 2a9be0c06..7aed0fa9f 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,8 @@ storage/plugins/z39-server/* !storage/plugins/z39-server/*.md !storage/plugins/z39-server/classes/ !storage/plugins/z39-server/classes/*.php +!storage/plugins/z39-server/views/ +!storage/plugins/z39-server/views/*.php !storage/plugins/api-book-scraper/ storage/plugins/api-book-scraper/* !storage/plugins/api-book-scraper/*.php diff --git a/app/Views/admin/plugins.php b/app/Views/admin/plugins.php index 24c8d5186..973c8040e 100644 --- a/app/Views/admin/plugins.php +++ b/app/Views/admin/plugins.php @@ -677,6 +677,9 @@ class="text-sm rounded-lg border-gray-300 focus:ring-indigo-500 focus:border-ind + + + @@ -1007,6 +1010,16 @@ function escapeHtml(str) { syntax: 'marcxml', indexes: { isbn: 'isbn' } }, + // Another Pinakes installation: its SRU server answers at /api/sru + // with MARCXML records that carry the cover in 856. Replace the host. + pinakes: { + name: '[Pinakes] ' + , + url: 'https://biblioteca.example.org/api/sru', + db: '', + version: '1.2', + syntax: 'marcxml', + indexes: { isbn: 'bath.isbn', issn: 'bath.issn', title: 'dc.title', author: 'dc.creator', any: 'cql.anywhere' } + }, // [DK] The Danish union catalogue through DBC OpenSearch: danMARC2 // records in MARCXchange. agency and profile are DBC's public test // pair; a Danish library puts its own in the URL. @@ -1119,6 +1132,9 @@ function addZ39ServerRow(server = null) { const row = document.createElement('div'); row.className = 'z39-server-row bg-gray-50 rounded-xl p-4 border border-gray-200 relative group'; + // Search indexes beyond ISBN (title, author, any: copy cataloguing) + // have no input of their own: they ride on the row and are saved back. + row.dataset.indexes = JSON.stringify(server.indexes && typeof server.indexes === 'object' ? server.indexes : {}); row.innerHTML = `
      @@ -1224,9 +1240,9 @@ function checkEmptyServers() { version: row.querySelector('[name="server_version[]"]')?.value.trim() || '1.1', syntax: row.querySelector('[name="server_syntax[]"]').value, quote_search_terms: row.querySelector('[name="server_quote_terms[]"]')?.checked || false, - indexes: { + indexes: Object.assign((() => { try { return JSON.parse(row.dataset.indexes || '{}') || {}; } catch (e) { return {}; } })(), { isbn: row.querySelector('[name="server_isbn_index[]"]').value || 'isbn' - }, + }), enabled: true }); }); diff --git a/storage/plugins/z39-server/Z39ServerPlugin.php b/storage/plugins/z39-server/Z39ServerPlugin.php index bbb86719f..d2a3e732e 100644 --- a/storage/plugins/z39-server/Z39ServerPlugin.php +++ b/storage/plugins/z39-server/Z39ServerPlugin.php @@ -730,6 +730,28 @@ public function registerRoutes($app): void return $plugin->applyAuthorityAction($request, $response, (int) $args['id']); })->add($csrfMiddleware)->add($adminMiddleware); + // ── Copy cataloguing: SRU search across catalogues + import ──────── + $app->get('/admin/books/copy-cataloging', function ( + ServerRequestInterface $request, + ResponseInterface $response + ) use ($plugin): ResponseInterface { + return $plugin->copyCatalogingPageAction($request, $response); + })->add($adminMiddleware); + + $app->post('/admin/books/copy-cataloging/search', function ( + ServerRequestInterface $request, + ResponseInterface $response + ) use ($plugin): ResponseInterface { + return $plugin->copyCatalogingSearchAction($request, $response); + })->add($csrfMiddleware)->add($adminMiddleware); + + $app->post('/admin/books/copy-cataloging/import', function ( + ServerRequestInterface $request, + ResponseInterface $response + ) use ($plugin): ResponseInterface { + return $plugin->copyCatalogingImportAction($request, $response); + })->add($csrfMiddleware)->add($adminMiddleware); + \App\Support\SecureLogger::debug('[Z39 Server Plugin] Routes registered', [ 'routes' => [ '/api/sru', '/api/sbn/search', @@ -737,10 +759,80 @@ public function registerRoutes($app): void '/admin/books/import-unimarc', '/admin/books/{id}/export.unimarc.xml', '/admin/books/soggettario-search', '/admin/authors/{id}/lookup-ccn', '/admin/authors/{id}/apply-authority', + '/admin/books/copy-cataloging', '/admin/books/copy-cataloging/search', + '/admin/books/copy-cataloging/import', ] ]); } + /** The copy cataloguing service, on the plugin's configured servers. */ + private function copyCataloging(): \Plugins\Z39Server\Classes\CopyCataloging + { + require_once __DIR__ . '/classes/CopyCataloging.php'; + $servers = json_decode($this->getSetting('servers', '[]'), true); + return new \Plugins\Z39Server\Classes\CopyCataloging( + $this->db, + $this->pluginId, + is_array($servers) ? array_values(array_filter($servers, 'is_array')) : [], + $this->isSettingEnabled('enable_sbn', true), + $this->isSettingEnabled('server_enabled', true) || $this->isSettingEnabled('enable_server'), + (int) $this->getSetting('sru_timeout', '15') + ); + } + + /** GET /admin/books/copy-cataloging */ + public function copyCatalogingPageAction(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $copyServers = $this->copyCataloging()->servers(); + $title = __('Copy cataloging'); + ob_start(); + include __DIR__ . '/views/copy-cataloging.php'; + $content = (string) ob_get_clean(); + ob_start(); + require dirname(__DIR__, 3) . '/app/Views/layout.php'; + $response->getBody()->write((string) ob_get_clean()); + return $response->withHeader('Content-Type', 'text/html'); + } + + /** POST /admin/books/copy-cataloging/search — JSON {servers, index, term, max} */ + public function copyCatalogingSearchAction(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + @ini_set('display_errors', '0'); + @set_time_limit(120); + $payload = json_decode((string) $request->getBody(), true); + $payload = is_array($payload) ? $payload : (array) $request->getParsedBody(); + $term = trim((string) ($payload['term'] ?? '')); + $index = (string) ($payload['index'] ?? 'any'); + $servers = array_values(array_filter(array_map('strval', (array) ($payload['servers'] ?? [])))); + if ($term === '' || $servers === []) { + return $this->jsonResponse($response, ['success' => false, 'error' => __('Indica cosa cercare e almeno un catalogo')], 400); + } + if (mb_strlen($term) > 200) { + $term = mb_substr($term, 0, 200); + } + $result = $this->copyCataloging()->search($servers, $index, $term, (int) ($payload['max'] ?? 10)); + return $this->jsonResponse($response, ['success' => true] + $result); + } + + /** POST /admin/books/copy-cataloging/import — JSON {ids, duplicates, covers} */ + public function copyCatalogingImportAction(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + @ini_set('display_errors', '0'); + @set_time_limit(300); + $payload = json_decode((string) $request->getBody(), true); + $payload = is_array($payload) ? $payload : (array) $request->getParsedBody(); + $ids = array_values(array_filter(array_map('strval', (array) ($payload['ids'] ?? [])))); + if ($ids === []) { + return $this->jsonResponse($response, ['success' => false, 'error' => __('Seleziona almeno un record da importare')], 400); + } + $result = $this->copyCataloging()->import( + array_slice($ids, 0, 100), + (string) ($payload['duplicates'] ?? 'skip'), + !empty($payload['covers']) + ); + return $this->jsonResponse($response, ['success' => true] + $result); + } + /** * Hook: Fetch book metadata from Z39.50/SRU servers and SBN (Italian catalog) * diff --git a/storage/plugins/z39-server/classes/CopyCataloging.php b/storage/plugins/z39-server/classes/CopyCataloging.php new file mode 100644 index 000000000..d3fbb82ba --- /dev/null +++ b/storage/plugins/z39-server/classes/CopyCataloging.php @@ -0,0 +1,322 @@ + [ + 'name' => 'Library of Congress', + 'url' => 'http://lx2.loc.gov:210/LCDB', + 'version' => '1.1', + 'syntax' => 'marcxml', + 'indexes' => ['isbn' => 'bath.isbn', 'issn' => 'bath.issn', 'title' => 'dc.title', 'author' => 'dc.creator', 'any' => 'cql.serverChoice'], + ], + 'bnf' => [ + 'name' => 'BnF - Bibliothèque nationale de France', + 'url' => 'https://catalogue.bnf.fr/api/SRU', + 'version' => '1.2', + 'syntax' => 'unimarcxchange', + 'quote_search_terms' => true, + 'indexes' => ['isbn' => 'bib.isbn', 'issn' => 'bib.issn', 'title' => 'bib.title', 'author' => 'bib.author', 'any' => 'bib.anywhere'], + ], + 'dnb' => [ + 'name' => 'DNB - Deutsche Nationalbibliothek', + 'url' => 'https://services.dnb.de/sru/dnb', + 'version' => '1.1', + 'syntax' => 'MARC21-xml', + 'indexes' => ['isbn' => 'num', 'issn' => 'num', 'title' => 'tit', 'author' => 'per', 'any' => 'woe'], + ], + 'k10plus' => [ + 'name' => 'K10plus (GBV)', + 'url' => 'http://sru.k10plus.de/opac-de-627', + 'version' => '1.1', + 'syntax' => 'marcxml', + 'indexes' => ['isbn' => 'pica.isb', 'issn' => 'pica.iss', 'title' => 'pica.tit', 'author' => 'pica.per', 'any' => 'pica.all'], + ], + ]; + + /** + * @param list> $configured the plugin's `servers` setting + */ + public function __construct( + private \mysqli $db, + private ?int $pluginId, + private array $configured, + private bool $sbnEnabled, + private bool $localServerEnabled, + private int $timeout = 15 + ) { + } + + /** + * Every server the page offers, keyed: cfg:N (configured), preset:KEY, + * sbn, local. + * + * @return array> + */ + public function servers(): array + { + $out = []; + $urls = []; + foreach (array_values($this->configured) as $i => $server) { + if (!is_array($server) || empty($server['url'])) { + continue; + } + $server['name'] = (string) ($server['name'] ?? $server['url']); + $server['kind'] = 'configured'; + $out['cfg:' . $i] = $server; + $urls[] = rtrim(strtolower((string) $server['url']), '/'); + } + if ($this->sbnEnabled) { + $out['sbn'] = ['name' => 'SBN - OPAC del Servizio Bibliotecario Nazionale', 'url' => 'https://opac.sbn.it', 'syntax' => 'sbn', 'kind' => 'preset', 'enabled' => true]; + } + foreach (self::PRESETS as $key => $preset) { + if (!in_array(rtrim(strtolower($preset['url']), '/'), $urls, true)) { + $out['preset:' . $key] = $preset + ['kind' => 'preset', 'enabled' => true]; + } + } + if ($this->localServerEnabled) { + $local = function_exists('absoluteUrl') ? \absoluteUrl('/api/sru') : '/api/sru'; + if (!in_array(rtrim(strtolower($local), '/'), $urls, true)) { + $out['local'] = [ + 'name' => __('Questa installazione (Pinakes)'), + 'url' => $local, + 'version' => '1.2', + 'syntax' => 'marcxml', + 'kind' => 'local', + 'enabled' => true, + 'indexes' => ['isbn' => 'bath.isbn', 'issn' => 'bath.issn', 'title' => 'dc.title', 'author' => 'dc.creator', 'any' => 'cql.anywhere'], + ]; + } + } + return $out; + } + + /** + * Search the chosen servers. Every result gets an id; its book data stays + * in the session until it is imported, so the import works on what the + * catalogue sent, not on what the browser sends back. + * + * @param list $serverKeys + * @return array{results: list>, servers: list>} + */ + public function search(array $serverKeys, string $index, string $term, int $max): array + { + $servers = $this->servers(); + $max = max(1, min(25, $max)); + $results = []; + $report = []; + $stored = []; + $importer = new MarcBookImporter($this->db, MarcBookImporter::MODE_SKIP, false); + $csv = new \App\Controllers\CsvImportController(); + foreach (array_values(array_unique($serverKeys)) as $key) { + if (!isset($servers[$key])) { + continue; + } + $server = $servers[$key]; + $answer = $key === 'sbn' + ? $this->searchSbn($index, $term, $max) + : $this->client($server)->searchServer($server, SruClient::cqlFor($server, $index, $term), $max); + $report[] = ['key' => $key, 'name' => (string) $server['name'], 'total' => $answer['total'], 'count' => count($answer['records']), 'error' => $answer['error']]; + foreach ($answer['records'] as $n => $record) { + if (count($stored) >= self::MAX_RESULTS) { + break 2; + } + $id = substr(hash('sha256', $key . '|' . $n . '|' . json_encode($record['data'])), 0, 16); + $data = (array) $record['data']; + $existing = null; + try { + $existing = $importer->findExisting($csv->normalizeImportRow(MarcBookData::toImportRow($data))); + } catch (\Throwable) { + $existing = null; + } + $stored[$id] = ['server' => (string) $server['name'], 'data' => $data]; + $results[] = [ + 'id' => $id, + 'server' => (string) $server['name'], + 'flavour' => (string) $record['flavour'], + 'titolo' => (string) ($data['titolo'] ?? ''), + 'sottotitolo' => (string) ($data['sottotitolo'] ?? ''), + 'autori' => implode('; ', (array) ($data['autori'] ?? [])), + 'editore' => implode('; ', (array) ($data['editori'] ?? [])), + 'anno' => (string) ($data['anno_pubblicazione'] ?? ''), + 'isbn' => (string) (($data['isbn13'] ?? '') !== '' ? $data['isbn13'] : ($data['isbn10'] ?? '')), + 'copertina' => (string) ($data['copertina_url'] ?? ''), + 'existing_id' => $existing, + 'detail' => $this->detail($data, $record['marc'] ?? null), + ]; + } + } + $_SESSION[self::SESSION_KEY] = $stored; + return ['results' => $results, 'servers' => $report]; + } + + /** + * Import the chosen results of the last search. + * + * @param list $ids + * @return array{imported: int, updated: int, skipped: int, failed: int, covers: int, items: list>, import_id: ?string} + */ + public function import(array $ids, string $mode, bool $covers): array + { + $stored = is_array($_SESSION[self::SESSION_KEY] ?? null) ? $_SESSION[self::SESSION_KEY] : []; + $importer = new MarcBookImporter($this->db, in_array($mode, MarcBookImporter::MODES, true) ? $mode : MarcBookImporter::MODE_SKIP, $covers); + $counters = []; + $items = []; + $errors = []; + $serverNames = []; + $position = 0; + foreach (array_values(array_unique($ids)) as $id) { + $position++; + if (!isset($stored[$id])) { + $items[] = ['id' => $id, 'action' => 'failed', 'message' => __('Risultato non più disponibile: ripeti la ricerca')]; + $errors[] = ['line' => $position, 'title' => '', 'message' => __('Risultato non più disponibile: ripeti la ricerca'), 'type' => 'validation']; + continue; + } + $serverNames[$stored[$id]['server']] = true; + $data = (array) $stored[$id]['data']; + $title = (string) ($data['titolo'] ?? ''); + try { + $warnings = []; + $result = $importer->import($data, $counters, $warnings); + $items[] = ['id' => $id, 'action' => $result['action'], 'book_id' => $result['id'], 'title' => $title, 'warnings' => $warnings]; + if ($result['action'] === 'skipped') { + $errors[] = ['line' => $position, 'title' => $title, 'message' => sprintf(__('Già in catalogo (libro #%d): saltato'), (int) $result['id']), 'type' => 'duplicate']; + } + foreach ($warnings as $warning) { + $errors[] = ['line' => $position, 'title' => $title, 'message' => $warning, 'type' => 'cover']; + } + } catch (\Throwable $e) { + $items[] = ['id' => $id, 'action' => 'failed', 'title' => $title, 'message' => $e->getMessage()]; + $errors[] = ['line' => $position, 'title' => $title, 'message' => $e->getMessage(), 'type' => 'validation']; + } + } + $failed = count(array_filter($items, static fn (array $i): bool => $i['action'] === 'failed')); + $importId = null; + try { + $userId = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null; + $logger = new \App\Support\ImportLogger($this->db, 'marc', mb_substr('Copy cataloging: ' . implode(', ', array_keys($serverNames)), 0, 255), $userId); + $logger->setStats([ + 'imported' => (int) ($counters['imported'] ?? 0), + 'updated' => (int) ($counters['updated'] ?? 0), + 'failed' => $failed, + 'authors_created' => (int) ($counters['authors_created'] ?? 0), + 'publishers_created' => (int) ($counters['publishers_created'] ?? 0), + 'scraped' => (int) ($counters['covers'] ?? 0), + ]); + foreach ($errors as $error) { + $logger->addError($error['line'], $error['title'], $error['message'], $error['type']); + } + if ($logger->complete(count($ids))) { + $importId = $logger->getImportId(); + } + } catch (\Throwable $e) { + \App\Support\SecureLogger::warning('[Copy cataloging] history not saved', ['error' => $e->getMessage()]); + } + return [ + 'imported' => (int) ($counters['imported'] ?? 0), + 'updated' => (int) ($counters['updated'] ?? 0), + 'skipped' => (int) ($counters['skipped'] ?? 0), + 'failed' => $failed, + 'covers' => (int) ($counters['covers'] ?? 0), + 'items' => $items, + 'import_id' => $importId, + ]; + } + + /** @param array $server */ + private function client(array $server): SruClient + { + $client = new LocalSruClient([$server], $this->db, $this->pluginId); + $client->setOptions(['timeout' => $this->timeout]); + return $client; + } + + /** + * @return array{total: int, records: list>, error: ?string} + */ + private function searchSbn(string $index, string $term, int $max): array + { + try { + $client = new SbnClient($this->timeout, true); + if ($index === 'isbn') { + $book = $client->searchByIsbn((string) preg_replace('/[^0-9Xx]/', '', $term)); + $books = $book !== null ? [$book] : []; + } elseif ($index === 'author') { + $books = $client->searchByAuthor($term, $max); + } else { + $books = $client->searchByTitle($term, $max); + } + $records = []; + foreach (array_slice($books, 0, $max) as $book) { + if (is_array($book)) { + $records[] = ['flavour' => 'sbn', 'marc' => null, 'data' => MarcBookData::fromScrape($book)]; + } + } + return ['total' => count($records), 'records' => $records, 'error' => null]; + } catch (\Throwable $e) { + return ['total' => 0, 'records' => [], 'error' => $e->getMessage()]; + } + } + + /** + * What the detail view shows: the MARC fields as the record has them, + * or the mapped book data for a record that was not MARC. + * + * @param array $data + * @param array|null $marc + * @return list + */ + private function detail(array $data, ?array $marc): array + { + $lines = []; + if (is_array($marc)) { + $lines[] = ['tag' => 'LDR', 'ind' => '', 'value' => (string) ($marc['leader'] ?? '')]; + foreach ((array) ($marc['fields'] ?? []) as $field) { + if (!is_array($field)) { + continue; + } + if (array_key_exists('data', $field)) { + $lines[] = ['tag' => (string) $field['tag'], 'ind' => '', 'value' => (string) $field['data']]; + continue; + } + $value = ''; + foreach ((array) ($field['subfields'] ?? []) as $sub) { + $value .= '$' . (string) ($sub[0] ?? '') . ' ' . (string) ($sub[1] ?? '') . ' '; + } + $lines[] = ['tag' => (string) $field['tag'], 'ind' => (string) ($field['ind1'] ?? ' ') . (string) ($field['ind2'] ?? ' '), 'value' => trim($value)]; + } + return $lines; + } + foreach ($data as $key => $value) { + $text = is_array($value) ? implode('; ', $value) : (string) $value; + if ($text !== '') { + $lines[] = ['tag' => (string) $key, 'ind' => '', 'value' => $text]; + } + } + return $lines; + } +} diff --git a/storage/plugins/z39-server/classes/LocalSruClient.php b/storage/plugins/z39-server/classes/LocalSruClient.php new file mode 100644 index 000000000..dcf0f197a --- /dev/null +++ b/storage/plugins/z39-server/classes/LocalSruClient.php @@ -0,0 +1,57 @@ +> $servers + */ + public function __construct(array $servers, private \mysqli $db, private ?int $pluginId) + { + parent::__construct($servers); + } + + public static function isLocalUrl(string $url): bool + { + $own = parse_url(function_exists('absoluteUrl') ? \absoluteUrl('/api/sru') : ''); + $target = parse_url($url); + if (!is_array($own) || !is_array($target) || !isset($own['host'], $target['host'])) { + return false; + } + return strcasecmp((string) $own['host'], (string) $target['host']) === 0 + && (int) ($own['port'] ?? 0) === (int) ($target['port'] ?? 0) + && rtrim((string) ($own['path'] ?? ''), '/') === rtrim((string) ($target['path'] ?? ''), '/'); + } + + protected function fetchUrl(string $url): ?string + { + if (!self::isLocalUrl($url)) { + return parent::fetchUrl($url); + } + $query = []; + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + require_once dirname(__DIR__) . '/endpoint.php'; + $request = (new \Slim\Psr7\Factory\ServerRequestFactory()) + ->createServerRequest('GET', '/api/sru', $_SERVER) + ->withQueryParams($query); + $response = \handleSRURequest($request, new \Slim\Psr7\Response(), $this->db, $this->pluginId); + $body = $response->getBody(); + $body->rewind(); + $xml = $body->getContents(); + if ($response->getStatusCode() >= 400 && $xml === '') { + throw new \RuntimeException('HTTP ' . $response->getStatusCode() . ' error from server'); + } + return $xml !== '' ? $xml : null; + } +} diff --git a/storage/plugins/z39-server/classes/SruClient.php b/storage/plugins/z39-server/classes/SruClient.php index c0e76c464..359d77b58 100644 --- a/storage/plugins/z39-server/classes/SruClient.php +++ b/storage/plugins/z39-server/classes/SruClient.php @@ -78,6 +78,166 @@ public function searchByIsbn(string $isbn): ?array return null; } + /** Search indexes the copy cataloguing page offers. */ + public const SEARCH_INDEXES = ['title', 'author', 'isbn', 'issn', 'any']; + + /** Default CQL index names (SRU context sets dc, bath, cql). */ + private const DEFAULT_CQL_INDEXES = [ + 'title' => 'dc.title', + 'author' => 'dc.creator', + 'isbn' => 'bath.isbn', + 'issn' => 'bath.issn', + 'any' => 'cql.serverChoice', + ]; + + /** + * The CQL query for one search on one server: the server's own index name + * when its configuration maps one (`indexes`), else the standard one. A + * term with spaces or a server that wants it (`quote_search_terms`) is + * quoted, backslash-escaping embedded quotes per the CQL specification; + * an ISBN or ISSN is reduced to its digits. + * + * @param array $server + */ + public static function cqlFor(array $server, string $index, string $term): string + { + $index = in_array($index, self::SEARCH_INDEXES, true) ? $index : 'any'; + $term = trim((string) preg_replace('/\s+/u', ' ', $term)); + if ($index === 'isbn' || $index === 'issn') { + $term = strtoupper((string) preg_replace('/[^0-9Xx]/', '', $term)); + } + $indexes = is_array($server['indexes'] ?? null) ? $server['indexes'] : []; + $cqlIndex = trim((string) ($indexes[$index] ?? '')); + if ($cqlIndex === '') { + $cqlIndex = self::DEFAULT_CQL_INDEXES[$index]; + } + $quote = !empty($server['quote_search_terms']) || preg_match('/[\s"()=<>\/]/u', $term) === 1; + $value = $quote ? '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $term) . '"' : $term; + return $cqlIndex . '=' . $value; + } + + /** + * Copy cataloguing: run one CQL query on every enabled server and return + * up to $max records from each, read with the shared MARC library. + * + * @return list>, error: ?string}> + */ + public function search(string $cql, int $max = 10): array + { + $out = []; + foreach ($this->servers as $server) { + if (empty($server['enabled']) || empty($server['url'])) { + continue; + } + $out[] = ['server' => (string) ($server['name'] ?? $server['url'])] + $this->searchServer($server, $cql, $max); + } + return $out; + } + + /** + * One CQL query on one server. Each record comes back as its book data + * (MarcBookData shape), the MARC flavour it was read as and, for a MARC + * record, the record itself (MarcRecord::toArray()) for the detail view. + * A failing server answers with `error` instead of throwing. + * + * @param array $server + * @return array{total: int, records: list>, error: ?string} + */ + public function searchServer(array $server, string $cql, int $max = 10): array + { + $max = max(1, min(50, $max)); + $url = (string) ($server['url'] ?? ''); + try { + if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) { + throw new \RuntimeException('Invalid server URL'); + } + $syntax = strtolower((string) ($server['syntax'] ?? 'marcxml')); + if ($syntax === 'dbc-opensearch') { + $params = ['action' => 'search', 'query' => $cql, 'outputType' => 'xml', 'objectFormat' => 'marcxchange', 'start' => 1, 'stepValue' => $max]; + } else { + $params = [ + 'operation' => 'searchRetrieve', + 'version' => (string) ($server['version'] ?? '1.1'), + 'query' => $cql, + 'recordSchema' => $syntax === 'danmarc2' ? 'marcxchange' : (string) ($server['syntax'] ?? 'marcxml'), + 'maximumRecords' => $max, + ]; + } + static $lastRequest = []; + $this->enforceRateLimit($url, $lastRequest); + $xml = $this->fetchUrl($url . (str_contains($url, '?') ? '&' : '?') . http_build_query($params)); + if ($xml === null) { + return ['total' => 0, 'records' => [], 'error' => null]; + } + return $this->readSearchResponse($xml, $syntax, $max); + } catch (\Throwable $e) { + \App\Support\SecureLogger::warning('[SruClient] search failed', ['server' => $server['name'] ?? $url, 'error' => $e->getMessage()]); + return ['total' => 0, 'records' => [], 'error' => $e->getMessage()]; + } + } + + /** + * @return array{total: int, records: list>, error: ?string} + */ + private function readSearchResponse(string $xml, string $syntax, int $max): array + { + $dom = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $loaded = $dom->loadXML($xml, LIBXML_NONET); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + if (!$loaded) { + throw new \RuntimeException('Invalid XML response'); + } + $xpath = new DOMXPath($dom); + $diagnostic = $xpath->query("//*[local-name()='diagnostic']/*[local-name()='message'] | /*[local-name()='searchResponse']/*[local-name()='error']")->item(0); + if ($diagnostic !== null) { + throw new \RuntimeException(trim((string) $diagnostic->nodeValue)); + } + $totalNode = $xpath->query("//*[local-name()='numberOfRecords'] | //*[local-name()='hitCount']")->item(0); + $records = []; + + if ($syntax === 'dc' || $syntax === 'oai_dc') { + foreach ($xpath->query("//*[local-name()='recordData']") ?: [] as $recordData) { + if (count($records) >= $max) { + break; + } + $single = new DOMDocument(); + $single->appendChild($single->importNode($recordData, true)); + $singleXpath = new DOMXPath($single); + $singleXpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/'); + $singleXpath->registerNamespace('oai_dc', 'http://www.openarchives.org/OAI/2.0/oai_dc/'); + $book = $this->parseDublinCore($singleXpath); + if ($book !== null) { + unset($book['_isbns']); + $records[] = ['flavour' => 'dc', 'marc' => null, 'data' => \App\Support\Marc\MarcBookData::fromScrape($book)]; + } + } + } else { + foreach (\App\Support\Marc\MarcXmlReader::fromString($xml) as $item) { + if (count($records) >= $max) { + break; + } + if (!$item instanceof \App\Support\Marc\MarcRecord) { + continue; + } + $flavour = \App\Support\Marc\MarcFormat::detectFlavour($item); + if (in_array($syntax, ['danmarc2', 'dbc-opensearch'], true)) { + $flavour = \App\Support\Marc\MarcFormat::DANMARC2; + } elseif (in_array($syntax, ['unimarc', 'unimarcxchange', 'unimarcxml'], true) && $flavour !== \App\Support\Marc\MarcFormat::DANMARC2) { + $flavour = \App\Support\Marc\MarcFormat::UNIMARC; + } + $records[] = [ + 'flavour' => $flavour, + 'marc' => $item->toArray(), + 'data' => \App\Support\Marc\MarcFormat::mapperFor($flavour)->map($item), + ]; + } + } + $total = $totalNode !== null ? (int) $totalNode->nodeValue : count($records); + return ['total' => max($total, count($records)), 'records' => $records, 'error' => null]; + } + /** * Query server with retry logic */ diff --git a/storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php b/storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php index 71421b874..e68e94222 100644 --- a/storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php +++ b/storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php @@ -257,6 +257,16 @@ public function format(array $record): \DOMElement $recordEl->appendChild($this->df('856', '4', ' ', [['u', (string) $record['public_url']]])); } + // 856 — the cover image, for copy cataloguing between Pinakes + // installations (and any client that reads 856 $u). Additive: the + // record URL above is unchanged. + if (!empty($record['copertina_url'])) { + $recordEl->appendChild($this->df('856', '4', ' ', [ + ['u', $this->absoluteCoverUrl((string) $record['copertina_url'])], + ['z', 'Cover image'], + ])); + } + // 801 — Originating source $recordEl->appendChild($this->df('801', ' ', '0', [ ['a', $country], diff --git a/storage/plugins/z39-server/views/copy-cataloging.php b/storage/plugins/z39-server/views/copy-cataloging.php new file mode 100644 index 000000000..0774b6f88 --- /dev/null +++ b/storage/plugins/z39-server/views/copy-cataloging.php @@ -0,0 +1,319 @@ +), the + * servers keyed as CopyCataloging::servers() returns them. + * + * @var array> $copyServers + */ +$jsonFlags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; +?> +
      +
      +

      + + + + + + + + +

      +
      +
      + + +
      +
      +

      + + +

      +
      + +
      +
      +
      +
      +
      + $server): ?> + + + +
      +
      +
      +
      + + +
      +
      + + "> +
      +
      +
      + +
      +
      +
      + + + + +
      +

      +
      + + diff --git a/tests/marc-copy-cataloging.spec.js b/tests/marc-copy-cataloging.spec.js new file mode 100644 index 000000000..c37944ffa --- /dev/null +++ b/tests/marc-copy-cataloging.spec.js @@ -0,0 +1,175 @@ +// E2E: interactive copy cataloguing (/admin/books/copy-cataloging). The SRU +// server searched is this installation's own (/api/sru, the "Questa +// installazione" entry), answered in-process: no external network, so the +// results are deterministic. The source books are made by a MARC import; to +// import them back they are withdrawn after the search, as a library that +// re-catalogues from a copy would have them gone. +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); +const { BASE, ROOT, dbQuery, sqlEscape, loginAsAdmin, marcFile, cleanupRun, uploadForPreview, confirmImport } = require('./helpers/marc-e2e'); + +const RUN = 'MC' + Date.now().toString(36); +const SKIP = !process.env.E2E_ADMIN_EMAIL || !process.env.E2E_DB_NAME; +let coverFile = null; +let createdCovers = []; +let isbn02 = ''; + +async function openPage(page) { + await page.goto(`${BASE}/admin/books/copy-cataloging`); + await expect(page.locator('#copySearchForm')).toBeVisible(); + // Only this installation: never the network + for (const box of await page.locator('input[name="servers[]"]').all()) { + if (await box.isChecked()) await box.uncheck(); + } + await page.locator('input[name="servers[]"][value="local"]').check(); +} + +async function search(page, index, term) { + await page.selectOption('#copy_index', index); + await page.fill('#copy_term', term); + const answered = page.waitForResponse((r) => r.url().includes('/admin/books/copy-cataloging/search')); + await page.click('#copySearchBtn'); + return (await answered).json(); +} + +async function importSelected(page, duplicates = 'skip') { + await page.check(`#copy_duplicates_${duplicates}`); + const answered = page.waitForResponse((r) => r.url().includes('/admin/books/copy-cataloging/import')); + await page.click('#copyImportBtn'); + const json = await (await answered).json(); + await expect(page.locator('#copy-import-result')).toBeVisible(); + return json; +} + +test.describe.serial('Copy cataloging', () => { + test.skip(SKIP, 'E2E credentials not set'); + test.afterAll(() => { + cleanupRun(RUN); + for (const f of [coverFile, ...createdCovers]) if (f && fs.existsSync(f)) fs.unlinkSync(f); + }); + + test('setup: three source books in the catalogue, one with a cover', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + await uploadForPreview(page, marcFile(RUN, 3, 'mrc')); + expect((await confirmImport(page)).imported).toBe(3); + coverFile = path.join(ROOT, 'public', 'uploads', 'copertine', `copy-cat-${RUN}.jpg`); + fs.copyFileSync(path.join(ROOT, 'tests', 'fixtures', 'archive-test-cover.jpg'), coverFile); + dbQuery(`UPDATE libri SET copertina_url='/uploads/copertine/copy-cat-${RUN}.jpg' WHERE titolo='Libro MARC ${RUN} 02'`); + isbn02 = dbQuery(`SELECT isbn13 FROM libri WHERE titolo='Libro MARC ${RUN} 02' AND deleted_at IS NULL`); + expect(isbn02).toMatch(/^97[89]\d{10}$/); + }); + + test('the page offers the configured servers, the national presets and this installation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${BASE}/admin/books/copy-cataloging`); + const names = await page.locator('input[name="servers[]"]').evaluateAll((els) => els.map((e) => e.dataset.serverName)); + expect(names.some((n) => n.includes('Library of Congress'))).toBe(true); + expect(names.some((n) => n.includes('BnF'))).toBe(true); + expect(names.some((n) => n.includes('DNB'))).toBe(true); + await expect(page.locator('input[name="servers[]"][value="local"]')).toHaveCount(1); + // Reachable from the book list's Import menu + await page.goto(`${BASE}/admin/books`); + await page.click('.import-btn'); + await page.click(`a[href$="/admin/books/copy-cataloging"]`); + await expect(page).toHaveURL(/\/admin\/books\/copy-cataloging$/); + }); + + test('search by title: several records, flagged as already in the catalogue, with the MARC detail', async ({ page }) => { + await loginAsAdmin(page); + await openPage(page); + const json = await search(page, 'title', `Libro MARC ${RUN}`); + expect(json.success).toBe(true); + expect(json.results.length).toBe(3); + await expect(page.locator('#copy-result-rows tr[data-result-row]')).toHaveCount(3); + await expect(page.locator('#copy-server-report')).toContainText('3'); + await expect(page.locator('#copy-result-rows')).toContainText('Già in catalogo'); + // The detail shows the record as the server sent it, cover in 856 included + const first = page.locator('#copy-result-rows tr[data-result-row]').filter({ hasText: `Libro MARC ${RUN} 02` }); + await first.locator('[data-detail-toggle]').click(); + const detail = page.locator('#copy-result-rows tr[data-detail-row]:not(.hidden)'); + await expect(detail).toContainText('245'); + await expect(detail).toContainText('856'); + await expect(detail).toContainText(`copy-cat-${RUN}.jpg`); + }); + + test('search by author and by ISBN', async ({ page }) => { + await loginAsAdmin(page); + await openPage(page); + let json = await search(page, 'author', `Autore${RUN} 01`); + expect(json.results.map((r) => r.titolo)).toEqual([`Libro MARC ${RUN} 01`]); + json = await search(page, 'isbn', isbn02); + expect(json.results.length).toBe(1); + expect(json.results[0].isbn).toBe(isbn02); + await expect(page.locator('#copy-result-rows tr[data-result-row]')).toHaveCount(1); + json = await search(page, 'any', `Collana${RUN}`); + expect(json.results.length).toBe(3); + }); + + test('multi-select import: two records become books, with their authors and the cover', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + await openPage(page); + await search(page, 'title', `Libro MARC ${RUN}`); + for (const n of ['02', '03']) { + await page.locator('#copy-result-rows tr[data-result-row]').filter({ hasText: `Libro MARC ${RUN} ${n}` }).locator('input[data-result-id]').check(); + } + await expect(page.locator('#copy-selected-count')).toHaveText('2'); + // The two source books leave the catalogue (withdrawn) before the import + dbQuery(`UPDATE libri SET deleted_at=NOW(), isbn13=NULL, isbn10=NULL, ean=NULL WHERE titolo IN ('Libro MARC ${RUN} 02','Libro MARC ${RUN} 03') AND deleted_at IS NULL`); + const json = await importSelected(page, 'skip'); + expect([json.imported, json.skipped, json.failed]).toEqual([2, 0, 0]); + expect(json.covers).toBe(1); + await expect(page.locator('#copy-import-items li[data-import-item="created"]')).toHaveCount(2); + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo IN ('Libro MARC ${RUN} 02','Libro MARC ${RUN} 03') AND deleted_at IS NULL`)).toBe('2'); + expect(dbQuery(`SELECT isbn13 FROM libri WHERE titolo='Libro MARC ${RUN} 02' AND deleted_at IS NULL`)).toBe(isbn02); + expect(dbQuery(`SELECT a.nome FROM libri l JOIN libri_autori la ON la.libro_id=l.id AND la.ruolo='principale' JOIN autori a ON a.id=la.autore_id WHERE l.titolo='Libro MARC ${RUN} 02' AND l.deleted_at IS NULL`)).toBe(`Mario Autore${RUN} 02`); + const cover = dbQuery(`SELECT copertina_url FROM libri WHERE titolo='Libro MARC ${RUN} 02' AND deleted_at IS NULL`); + expect(cover).toMatch(/^\/uploads\/copertine\/copertina_/); + createdCovers.push(path.join(ROOT, 'public', cover)); + expect(fs.existsSync(path.join(ROOT, 'public', cover))).toBe(true); + // The run is in the import history + expect(dbQuery(`SELECT CONCAT(import_type,':',imported) FROM import_logs WHERE file_name LIKE 'Copy cataloging:%' ORDER BY id DESC LIMIT 1`)).toBe('marc:2'); + // The link opens the new book + await page.locator('#copy-import-items a').first().click(); + await expect(page).toHaveURL(/\/admin\/books\/\d+$/); + }); + + test('importing again: skip leaves the copies alone, overwrite updates them', async ({ page }) => { + test.setTimeout(90000); + await loginAsAdmin(page); + await openPage(page); + await search(page, 'title', `Libro MARC ${RUN}`); + await page.locator('#copy-select-all').check(); + await expect(page.locator('#copy-selected-count')).toHaveText('3'); + let json = await importSelected(page, 'skip'); + expect([json.imported, json.skipped]).toEqual([0, 3]); + dbQuery(`UPDATE libri SET numero_pagine=1 WHERE titolo='Libro MARC ${RUN} 01' AND deleted_at IS NULL`); + json = await importSelected(page, 'overwrite'); + expect(json.updated).toBe(3); + expect(dbQuery(`SELECT numero_pagine FROM libri WHERE titolo='Libro MARC ${RUN} 01' AND deleted_at IS NULL`)).toBe('121'); + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE 'Libro MARC ${RUN} %' AND deleted_at IS NULL`)).toBe('3'); + }); + + test('a server that cannot be reached is reported without stopping the others', async ({ page }) => { + await loginAsAdmin(page); + await openPage(page); + // A configured server on a refused address (the SSRF guard blocks it) + const original = dbQuery("SELECT setting_value FROM plugin_settings ps JOIN plugins p ON p.id=ps.plugin_id WHERE p.name='z39-server' AND ps.setting_key='servers'"); + const broken = JSON.stringify([{ name: `Rotto${RUN}`, url: 'http://127.0.0.1:9/sru', version: '1.1', syntax: 'marcxml', enabled: true, indexes: { isbn: 'bath.isbn' } }]); + dbQuery(`UPDATE plugin_settings ps JOIN plugins p ON p.id=ps.plugin_id SET ps.setting_value='${sqlEscape(broken)}' WHERE p.name='z39-server' AND ps.setting_key='servers'`); + try { + await openPage(page); + await page.locator(`input[name="servers[]"][data-server-name="Rotto${RUN}"]`).check(); + const json = await search(page, 'title', `Libro MARC ${RUN}`); + const report = Object.fromEntries(json.servers.map((s) => [s.key, s])); + expect(report['cfg:0'].error).toBeTruthy(); + expect(report.local.count).toBe(3); + await expect(page.locator('#copy-server-report')).toContainText(`Rotto${RUN}`); + await expect(page.locator('#copy-result-rows tr[data-result-row]')).toHaveCount(3); + } finally { + dbQuery(`UPDATE plugin_settings ps JOIN plugins p ON p.id=ps.plugin_id SET ps.setting_value='${sqlEscape(original)}' WHERE p.name='z39-server' AND ps.setting_key='servers'`); + } + }); +}); diff --git a/tests/sru-search.unit.php b/tests/sru-search.unit.php new file mode 100644 index 000000000..641e9080a --- /dev/null +++ b/tests/sru-search.unit.php @@ -0,0 +1,99 @@ + */ + public array $urls = []; + public function __construct(array $servers, private string $xml) + { + parent::__construct($servers); + } + protected function fetchUrl(string $url): ?string + { + $this->urls[] = $url; + return $this->xml; + } +} + +$marcRecord = static fn (string $title, string $isbn, string $author): string => + '00000nam a2200000 a 4500' + . '' . $isbn . '' + . '' . $author . '' + . '' . $title . ''; +$sru = '1.142'; +foreach ([['Uno', '9788845292613', 'Eco, Umberto'], ['Due', '9780140449136', 'Dostoyevsky, Fyodor'], ['Tre', '9783161484100', 'Müller, Jürgen']] as [$t, $i, $a]) { + $sru .= 'marcxmlxml' . $marcRecord($t, $i, $a) . ''; +} +$sru .= ''; + +$server = ['name' => 'Fixture', 'url' => 'https://sru.example.org/sru', 'version' => '1.2', 'syntax' => 'marcxml', 'enabled' => true, 'indexes' => ['title' => 'bib.title']]; + +echo "CQL\n"; +$check(SruClient::cqlFor($server, 'title', 'il nome della rosa') === 'bib.title="il nome della rosa"', '01 the server\'s own index, a multi-word term quoted'); +$check(SruClient::cqlFor($server, 'author', 'Eco') === 'dc.creator=Eco', '02 the standard index when the server maps none'); +$check(SruClient::cqlFor(['indexes' => ['isbn' => 'alma.isbn']], 'isbn', '978-88-452-9261-3') === 'alma.isbn=9788845292613', '03 an ISBN is reduced to its digits'); +$check(SruClient::cqlFor([], 'any', 'say "hi"') === 'cql.serverChoice="say \\"hi\\""', '04 embedded quotes are escaped'); +$check(SruClient::cqlFor([], 'bogus', 'x') === 'cql.serverChoice=x', '05 an unknown index falls back to all fields'); + +echo "searchServer\n"; +$client = new SearchFixtureClient([$server], $sru); +$answer = $client->searchServer($server, 'bib.title=x', 3); +$check($answer['error'] === null && $answer['total'] === 42 && count($answer['records']) === 3, '06 three records of 42, no error'); +$check(array_map(static fn ($r) => $r['data']['titolo'], $answer['records']) === ['Uno', 'Due', 'Tre'], '07 in server order, mapped with the MARC 21 mapper'); +$check($answer['records'][1]['data']['isbn13'] === '9780140449136' && $answer['records'][2]['data']['autori'] === ['Müller, Jürgen'], '08 ISBN and authors read'); +$check($answer['records'][0]['flavour'] === 'marc21' && ($answer['records'][0]['marc']['leader'] ?? '') === '00000nam a2200000 a 4500', '09 the MARC record is kept for the detail view'); +parse_str((string) parse_url($client->urls[0], PHP_URL_QUERY), $q); +$check(($q['operation'] ?? '') === 'searchRetrieve' && ($q['maximumRecords'] ?? '') === '3' && ($q['query'] ?? '') === 'bib.title=x' && ($q['recordSchema'] ?? '') === 'marcxml', '10 the request asks for N records in the server\'s schema'); +$two = (new SearchFixtureClient([$server], $sru))->searchServer($server, 'q', 2); +$check(count($two['records']) === 2, '11 $max caps the records read'); + +echo "Other answers\n"; +$diag = '0info:srw/diagnostic/1/16Unsupported index'; +$bad = (new SearchFixtureClient([$server], $diag))->searchServer($server, 'q', 5); +$check($bad['error'] === 'Unsupported index' && $bad['records'] === [], '12 an SRU diagnostic is the server\'s error, not an exception'); +$broken = (new SearchFixtureClient([$server], 'searchServer($server, 'q', 5); +$check($broken['error'] !== null, '13 a response that is not XML is reported'); +$invalid = (new SearchFixtureClient([], $sru))->searchServer(['url' => 'ftp://x'], 'q', 5); +$check($invalid['error'] !== null, '14 a non-HTTP URL is refused'); +$dc = '2' + . 'Primo DCRossi, AnnaISBN 9788845292613' + . 'Secondo DC' + . ''; +$dcServer = ['url' => 'https://x.example.org/sru', 'syntax' => 'dc']; +$dcAnswer = (new SearchFixtureClient([$dcServer], $dc))->searchServer($dcServer, 'q', 5); +$check(array_map(static fn ($r) => $r['data']['titolo'], $dcAnswer['records']) === ['Primo DC', 'Secondo DC'] && $dcAnswer['records'][0]['data']['autori'] === ['Rossi, Anna'], '15 Dublin Core records, one by one'); +$dan = (string) file_get_contents(__DIR__ . '/fixtures/sru/dbc-opensearch-9788702272451.xml'); +$danServer = ['url' => 'https://opensearch.addi.dk/b3.5_5.2/?agency=100200&profile=test', 'syntax' => 'dbc-opensearch']; +$danClient = new SearchFixtureClient([$danServer], $dan); +$danAnswer = $danClient->searchServer($danServer, 'term.isbn=9788702272451', 10); +$check($danAnswer['records'] !== [] && $danAnswer['records'][0]['flavour'] === 'danmarc2' && str_contains($danClient->urls[0], 'stepValue=10') && str_contains($danClient->urls[0], 'agency=100200'), '16 DBC OpenSearch: danMARC2 records, stepValue, the agency kept'); + +echo "search() over every enabled server\n"; +$multi = new SearchFixtureClient([$server, array_merge($server, ['enabled' => false, 'name' => 'Off']), ['name' => 'Second', 'url' => 'https://b.example.org/sru', 'enabled' => true]], $sru); +$all = $multi->search('dc.title=x', 2); +$check(count($all) === 2 && $all[0]['server'] === 'Fixture' && $all[1]['server'] === 'Second' && count($all[1]['records']) === 2, '17 disabled servers are skipped, each server answers'); + +echo "\n{$pass} PASS, {$fail} FAIL\n"; +exit($fail === 0 ? 0 : 1); From ed051b6b41a69d426d76b782d859ce08d2b127a2 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:33:54 +0100 Subject: [PATCH 034/398] Translate the MARC import, export and copy cataloguing strings Every new string in the five locales (it_IT, en_US, de_DE, fr_FR, da_DK); ci-check-locales.py is green. --- locale/da_DK.json | 67 ++++++++++++++++++++++++++++++++++++++++++++++- locale/de_DE.json | 67 ++++++++++++++++++++++++++++++++++++++++++++++- locale/en_US.json | 67 ++++++++++++++++++++++++++++++++++++++++++++++- locale/fr_FR.json | 67 ++++++++++++++++++++++++++++++++++++++++++++++- locale/it_IT.json | 67 ++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 330 insertions(+), 5 deletions(-) diff --git a/locale/da_DK.json b/locale/da_DK.json index 628987012..650527e1d 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8497,5 +8497,70 @@ "Copia dichiarata persa in revisione inventariale": "Eksemplar erklæret tabt ved optælling", "Revisione inventariale aperta": "Optælling åbnet", "Revisione inventariale chiusa": "Optælling lukket", - "Revisione inventariale annullata": "Optælling annulleret" + "Revisione inventariale annullata": "Optælling annulleret", + "%d copertine scaricate": "%d omslag hentet", + "%d importati, %d aggiornati, %d saltati, %d errori": "%d importeret, %d opdateret, %d sprunget over, %d fejl", + "%d non leggibili": "%d ulæselige", + "%d record nel file": "%d poster i filen", + "%d record": "%d poster", + "Aggiornato": "Opdateret", + "Altra installazione Pinakes": "Anden Pinakes-installation", + "Anteprima dei record": "Forhåndsvisning af poster", + "Apri il libro": "Åbn bogen", + "Carica File MARC": "Upload MARC-fil", + "Carica il file esportato dal tuo catalogo (ISO 2709 o MARCXML)": "Upload filen eksporteret fra dit katalog (ISO 2709 eller MARCXML)", + "Carica un file MARC 21, UNIMARC o danMARC2 (ISO 2709 o MARCXML) per importare più libri contemporaneamente": "Upload en MARC 21-, UNIMARC- eller danMARC2-fil (ISO 2709 eller MARCXML) for at importere mange bøger på én gang", + "Cataloghi": "Kataloger", + "Cerca i record in altri cataloghi (SRU, SBN, altre installazioni Pinakes) e importa quelli che ti servono": "Søg i andre kataloger (SRU, SBN, andre Pinakes-installationer) og importér de poster, du har brug for", + "Cerca nei cataloghi": "Søg i katalogerne", + "Cerca per": "Søg efter", + "Codifica dei caratteri (ISO 2709)": "Tegnkodning (ISO 2709)", + "Completa i campi vuoti": "Udfyld tomme felter", + "Conferma importazione": "Bekræft import", + "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Bekræft: forfattere, forlag og eksemplarer oprettes som ved CSV-import", + "Controlla l'anteprima dei primi record": "Tjek forhåndsvisningen af de første poster", + "Copertina non scaricata": "Omslag ikke hentet", + "Copy cataloging (SRU)": "Kopikatalogisering (SRU)", + "Copy cataloging": "Kopikatalogisering", + "Dal leader del record (posizione 9)": "Fra postens leader (position 9)", + "es. Il nome della rosa": "f.eks. Rosens navn", + "Esporta in MARC": "Eksportér som MARC", + "File MARC (max 20MB)": "MARC-fil (maks. 20 MB)", + "File troppo grande. Dimensione massima %d MB.": "Filen er for stor. Maksimal størrelse %d MB.", + "Formato dei record": "Postformat", + "Formato: .mrc, .marc, .iso (ISO 2709) o .xml (MARCXML) • Max 20MB": "Format: .mrc, .marc, .iso (ISO 2709) eller .xml (MARCXML) • Maks. 20 MB", + "Già in catalogo (#%d)": "Allerede i kataloget (#%d)", + "Già in catalogo (libro #%d): saltato": "Allerede i kataloget (bog #%d): sprunget over", + "I campi presenti nel record sostituiscono quelli del libro; gli altri restano": "Postens felter erstatter bogens; de øvrige bevares", + "I record illeggibili vengono saltati e riportati negli errori, senza fermare l'import": "Ulæselige poster springes over og vises i fejlene, uden at importen stopper", + "Il file deve essere un file MARC (.mrc, .marc, .iso) o MARCXML (.xml)": "Filen skal være en MARC- (.mrc, .marc, .iso) eller MARCXML-fil (.xml)", + "Il file non contiene record MARC riconoscibili (ISO 2709 o MARCXML)": "Filen indeholder ingen genkendelige MARC-poster (ISO 2709 eller MARCXML)", + "Il libro già in catalogo resta com'è": "Bogen i kataloget forbliver, som den er", + "Import MARC": "MARC-import", + "Importa selezionati": "Importér valgte", + "Impossibile aprire il file caricato": "Den uploadede fil kan ikke åbnes", + "Indica cosa cercare e almeno un catalogo": "Angiv hvad der skal søges efter og mindst ét katalog", + "Le copertine indicate nei record vengono salvate in locale. Rallenta l'importazione.": "Omslagene, som posterne henviser til, gemmes lokalt. Det gør importen langsommere.", + "Le copie (campi 852/949/952) non vengono importate: ogni libro riceve una copia": "Beholdninger (felt 852/949/952) importeres ikke: hver bog får ét eksemplar", + "Libri già presenti (stesso ISBN-13, ISBN-10 o EAN)": "Bøger der allerede findes (samme ISBN-13, ISBN-10 eller EAN)", + "MARC (ISO 2709 / MARCXML)": "MARC (ISO 2709 / MARCXML)", + "MARC 21 (.mrc)": "MARC 21 (.mrc)", + "Non importato": "Ikke importeret", + "Per esportare in MARC usa il menu Export dell'elenco libri": "Brug Eksport-menuen i boglisten for at eksportere som MARC", + "Questa installazione (Pinakes)": "Denne installation (Pinakes)", + "Record non leggibile": "Ulæselig post", + "Riconoscimento automatico": "Registrér automatisk", + "Risposta non valida dal server": "Ugyldigt svar fra serveren", + "Risultato non più disponibile: ripeti la ricerca": "Resultatet er ikke længere tilgængeligt: søg igen", + "Salta": "Spring over", + "Saltato (già in catalogo)": "Sprunget over (allerede i kataloget)", + "Scarica le copertine (campo 856)": "Hent omslag (felt 856)", + "Seleziona almeno un record da importare": "Vælg mindst én post at importere", + "Solo i campi vuoti del libro vengono riempiti dal record": "Kun bogens tomme felter udfyldes fra posten", + "Sovrascrivi": "Overskriv", + "su %d": "af %d", + "Termine di ricerca": "Søgeord", + "Trascina qui il file MARC o %{browse}": "Træk MARC-filen hertil eller %{browse}", + "Tutti i campi": "Alle felter", + "UNIMARC (.mrc)": "UNIMARC (.mrc)" } diff --git a/locale/de_DE.json b/locale/de_DE.json index b45afda31..b31e1398f 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8497,5 +8497,70 @@ "Copia dichiarata persa in revisione inventariale": "Exemplar bei einer Bestandsrevision als verloren erklärt", "Revisione inventariale aperta": "Bestandsrevision eröffnet", "Revisione inventariale chiusa": "Bestandsrevision geschlossen", - "Revisione inventariale annullata": "Bestandsrevision abgebrochen" + "Revisione inventariale annullata": "Bestandsrevision abgebrochen", + "%d copertine scaricate": "%d Cover heruntergeladen", + "%d importati, %d aggiornati, %d saltati, %d errori": "%d importiert, %d aktualisiert, %d übersprungen, %d Fehler", + "%d non leggibili": "%d nicht lesbar", + "%d record nel file": "%d Datensätze in der Datei", + "%d record": "%d Datensätze", + "Aggiornato": "Aktualisiert", + "Altra installazione Pinakes": "Andere Pinakes-Installation", + "Anteprima dei record": "Vorschau der Datensätze", + "Apri il libro": "Buch öffnen", + "Carica File MARC": "MARC-Datei hochladen", + "Carica il file esportato dal tuo catalogo (ISO 2709 o MARCXML)": "Lade die aus deinem Katalog exportierte Datei hoch (ISO 2709 oder MARCXML)", + "Carica un file MARC 21, UNIMARC o danMARC2 (ISO 2709 o MARCXML) per importare più libri contemporaneamente": "Lade eine MARC-21-, UNIMARC- oder danMARC2-Datei (ISO 2709 oder MARCXML) hoch, um viele Bücher auf einmal zu importieren", + "Cataloghi": "Kataloge", + "Cerca i record in altri cataloghi (SRU, SBN, altre installazioni Pinakes) e importa quelli che ti servono": "Durchsuche andere Kataloge (SRU, SBN, andere Pinakes-Installationen) und importiere die Datensätze, die du brauchst", + "Cerca nei cataloghi": "In den Katalogen suchen", + "Cerca per": "Suchen nach", + "Codifica dei caratteri (ISO 2709)": "Zeichenkodierung (ISO 2709)", + "Completa i campi vuoti": "Leere Felder ergänzen", + "Conferma importazione": "Import bestätigen", + "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Bestätige: Autoren, Verlage und Exemplare werden wie beim CSV-Import angelegt", + "Controlla l'anteprima dei primi record": "Prüfe die Vorschau der ersten Datensätze", + "Copertina non scaricata": "Cover nicht heruntergeladen", + "Copy cataloging (SRU)": "Fremddatenübernahme (SRU)", + "Copy cataloging": "Fremddatenübernahme", + "Dal leader del record (posizione 9)": "Aus dem Leader des Datensatzes (Position 9)", + "es. Il nome della rosa": "z. B. Der Name der Rose", + "Esporta in MARC": "Als MARC exportieren", + "File MARC (max 20MB)": "MARC-Datei (max. 20 MB)", + "File troppo grande. Dimensione massima %d MB.": "Datei zu groß. Maximale Größe %d MB.", + "Formato dei record": "Datensatzformat", + "Formato: .mrc, .marc, .iso (ISO 2709) o .xml (MARCXML) • Max 20MB": "Format: .mrc, .marc, .iso (ISO 2709) oder .xml (MARCXML) • Max. 20 MB", + "Già in catalogo (#%d)": "Bereits im Katalog (#%d)", + "Già in catalogo (libro #%d): saltato": "Bereits im Katalog (Buch #%d): übersprungen", + "I campi presenti nel record sostituiscono quelli del libro; gli altri restano": "Die Felder des Datensatzes ersetzen die des Buches; die übrigen bleiben", + "I record illeggibili vengono saltati e riportati negli errori, senza fermare l'import": "Nicht lesbare Datensätze werden übersprungen und in den Fehlern aufgeführt, ohne den Import anzuhalten", + "Il file deve essere un file MARC (.mrc, .marc, .iso) o MARCXML (.xml)": "Die Datei muss eine MARC- (.mrc, .marc, .iso) oder MARCXML-Datei (.xml) sein", + "Il file non contiene record MARC riconoscibili (ISO 2709 o MARCXML)": "Die Datei enthält keine erkennbaren MARC-Datensätze (ISO 2709 oder MARCXML)", + "Il libro già in catalogo resta com'è": "Das Buch im Katalog bleibt, wie es ist", + "Import MARC": "MARC-Import", + "Importa selezionati": "Ausgewählte importieren", + "Impossibile aprire il file caricato": "Die hochgeladene Datei kann nicht geöffnet werden", + "Indica cosa cercare e almeno un catalogo": "Gib einen Suchbegriff ein und wähle mindestens einen Katalog", + "Le copertine indicate nei record vengono salvate in locale. Rallenta l'importazione.": "Die in den Datensätzen angegebenen Cover werden lokal gespeichert. Das verlangsamt den Import.", + "Le copie (campi 852/949/952) non vengono importate: ogni libro riceve una copia": "Exemplardaten (Felder 852/949/952) werden nicht importiert: jedes Buch erhält ein Exemplar", + "Libri già presenti (stesso ISBN-13, ISBN-10 o EAN)": "Bereits vorhandene Bücher (gleiche ISBN-13, ISBN-10 oder EAN)", + "MARC (ISO 2709 / MARCXML)": "MARC (ISO 2709 / MARCXML)", + "MARC 21 (.mrc)": "MARC 21 (.mrc)", + "Non importato": "Nicht importiert", + "Per esportare in MARC usa il menu Export dell'elenco libri": "Für den MARC-Export nutze das Export-Menü der Bücherliste", + "Questa installazione (Pinakes)": "Diese Installation (Pinakes)", + "Record non leggibile": "Nicht lesbarer Datensatz", + "Riconoscimento automatico": "Automatisch erkennen", + "Risposta non valida dal server": "Ungültige Antwort vom Server", + "Risultato non più disponibile: ripeti la ricerca": "Ergebnis nicht mehr verfügbar: suche erneut", + "Salta": "Überspringen", + "Saltato (già in catalogo)": "Übersprungen (bereits im Katalog)", + "Scarica le copertine (campo 856)": "Cover herunterladen (Feld 856)", + "Seleziona almeno un record da importare": "Wähle mindestens einen Datensatz zum Importieren", + "Solo i campi vuoti del libro vengono riempiti dal record": "Nur die leeren Felder des Buches werden aus dem Datensatz gefüllt", + "Sovrascrivi": "Überschreiben", + "su %d": "von %d", + "Termine di ricerca": "Suchbegriff", + "Trascina qui il file MARC o %{browse}": "MARC-Datei hierher ziehen oder %{browse}", + "Tutti i campi": "Alle Felder", + "UNIMARC (.mrc)": "UNIMARC (.mrc)" } diff --git a/locale/en_US.json b/locale/en_US.json index cb1d1c550..4bd7b1619 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8497,5 +8497,70 @@ "Copia dichiarata persa in revisione inventariale": "Copy declared lost in a stocktake", "Revisione inventariale aperta": "Stocktake opened", "Revisione inventariale chiusa": "Stocktake closed", - "Revisione inventariale annullata": "Stocktake cancelled" + "Revisione inventariale annullata": "Stocktake cancelled", + "%d copertine scaricate": "%d covers downloaded", + "%d importati, %d aggiornati, %d saltati, %d errori": "%d imported, %d updated, %d skipped, %d errors", + "%d non leggibili": "%d unreadable", + "%d record nel file": "%d records in the file", + "%d record": "%d records", + "Aggiornato": "Updated", + "Altra installazione Pinakes": "Another Pinakes installation", + "Anteprima dei record": "Record preview", + "Apri il libro": "Open the book", + "Carica File MARC": "Upload MARC file", + "Carica il file esportato dal tuo catalogo (ISO 2709 o MARCXML)": "Upload the file exported from your catalogue (ISO 2709 or MARCXML)", + "Carica un file MARC 21, UNIMARC o danMARC2 (ISO 2709 o MARCXML) per importare più libri contemporaneamente": "Upload a MARC 21, UNIMARC or danMARC2 file (ISO 2709 or MARCXML) to import many books at once", + "Cataloghi": "Catalogues", + "Cerca i record in altri cataloghi (SRU, SBN, altre installazioni Pinakes) e importa quelli che ti servono": "Search other catalogues (SRU, SBN, other Pinakes installations) and import the records you need", + "Cerca nei cataloghi": "Search the catalogues", + "Cerca per": "Search by", + "Codifica dei caratteri (ISO 2709)": "Character encoding (ISO 2709)", + "Completa i campi vuoti": "Fill in empty fields", + "Conferma importazione": "Confirm import", + "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Confirm: authors, publishers and copies are created as in the CSV import", + "Controlla l'anteprima dei primi record": "Check the preview of the first records", + "Copertina non scaricata": "Cover not downloaded", + "Copy cataloging (SRU)": "Copy cataloguing (SRU)", + "Copy cataloging": "Copy cataloguing", + "Dal leader del record (posizione 9)": "From the record leader (position 9)", + "es. Il nome della rosa": "e.g. The Name of the Rose", + "Esporta in MARC": "Export as MARC", + "File MARC (max 20MB)": "MARC file (max 20MB)", + "File troppo grande. Dimensione massima %d MB.": "File too large. Maximum size %d MB.", + "Formato dei record": "Record format", + "Formato: .mrc, .marc, .iso (ISO 2709) o .xml (MARCXML) • Max 20MB": "Format: .mrc, .marc, .iso (ISO 2709) or .xml (MARCXML) • Max 20MB", + "Già in catalogo (#%d)": "Already in the catalogue (#%d)", + "Già in catalogo (libro #%d): saltato": "Already in the catalogue (book #%d): skipped", + "I campi presenti nel record sostituiscono quelli del libro; gli altri restano": "The fields the record has replace the book's; the others stay", + "I record illeggibili vengono saltati e riportati negli errori, senza fermare l'import": "Unreadable records are skipped and listed in the errors, without stopping the import", + "Il file deve essere un file MARC (.mrc, .marc, .iso) o MARCXML (.xml)": "The file must be a MARC (.mrc, .marc, .iso) or MARCXML (.xml) file", + "Il file non contiene record MARC riconoscibili (ISO 2709 o MARCXML)": "The file contains no recognisable MARC records (ISO 2709 or MARCXML)", + "Il libro già in catalogo resta com'è": "The book already in the catalogue stays as it is", + "Import MARC": "MARC import", + "Importa selezionati": "Import selected", + "Impossibile aprire il file caricato": "Unable to open the uploaded file", + "Indica cosa cercare e almeno un catalogo": "Enter what to search for and choose at least one catalogue", + "Le copertine indicate nei record vengono salvate in locale. Rallenta l'importazione.": "The covers the records point to are saved locally. It slows the import down.", + "Le copie (campi 852/949/952) non vengono importate: ogni libro riceve una copia": "Holdings (fields 852/949/952) are not imported: each book gets one copy", + "Libri già presenti (stesso ISBN-13, ISBN-10 o EAN)": "Books already in the catalogue (same ISBN-13, ISBN-10 or EAN)", + "MARC (ISO 2709 / MARCXML)": "MARC (ISO 2709 / MARCXML)", + "MARC 21 (.mrc)": "MARC 21 (.mrc)", + "Non importato": "Not imported", + "Per esportare in MARC usa il menu Export dell'elenco libri": "To export as MARC, use the Export menu of the book list", + "Questa installazione (Pinakes)": "This installation (Pinakes)", + "Record non leggibile": "Unreadable record", + "Riconoscimento automatico": "Detect automatically", + "Risposta non valida dal server": "Invalid response from the server", + "Risultato non più disponibile: ripeti la ricerca": "Result no longer available: search again", + "Salta": "Skip", + "Saltato (già in catalogo)": "Skipped (already in the catalogue)", + "Scarica le copertine (campo 856)": "Download the covers (field 856)", + "Seleziona almeno un record da importare": "Select at least one record to import", + "Solo i campi vuoti del libro vengono riempiti dal record": "Only the book's empty fields are filled from the record", + "Sovrascrivi": "Overwrite", + "su %d": "of %d", + "Termine di ricerca": "Search term", + "Trascina qui il file MARC o %{browse}": "Drop the MARC file here or %{browse}", + "Tutti i campi": "All fields", + "UNIMARC (.mrc)": "UNIMARC (.mrc)" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 475fb1558..d858066ab 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8497,5 +8497,70 @@ "Copia dichiarata persa in revisione inventariale": "Exemplaire déclaré perdu lors d'un récolement", "Revisione inventariale aperta": "Récolement ouvert", "Revisione inventariale chiusa": "Récolement clôturé", - "Revisione inventariale annullata": "Récolement annulé" + "Revisione inventariale annullata": "Récolement annulé", + "%d copertine scaricate": "%d couvertures téléchargées", + "%d importati, %d aggiornati, %d saltati, %d errori": "%d importés, %d mis à jour, %d ignorés, %d erreurs", + "%d non leggibili": "%d illisibles", + "%d record nel file": "%d notices dans le fichier", + "%d record": "%d notices", + "Aggiornato": "Mis à jour", + "Altra installazione Pinakes": "Autre installation Pinakes", + "Anteprima dei record": "Aperçu des notices", + "Apri il libro": "Ouvrir le livre", + "Carica File MARC": "Charger un fichier MARC", + "Carica il file esportato dal tuo catalogo (ISO 2709 o MARCXML)": "Chargez le fichier exporté de votre catalogue (ISO 2709 ou MARCXML)", + "Carica un file MARC 21, UNIMARC o danMARC2 (ISO 2709 o MARCXML) per importare più libri contemporaneamente": "Chargez un fichier MARC 21, UNIMARC ou danMARC2 (ISO 2709 ou MARCXML) pour importer plusieurs livres à la fois", + "Cataloghi": "Catalogues", + "Cerca i record in altri cataloghi (SRU, SBN, altre installazioni Pinakes) e importa quelli che ti servono": "Recherchez dans d'autres catalogues (SRU, SBN, autres installations Pinakes) et importez les notices dont vous avez besoin", + "Cerca nei cataloghi": "Rechercher dans les catalogues", + "Cerca per": "Rechercher par", + "Codifica dei caratteri (ISO 2709)": "Encodage des caractères (ISO 2709)", + "Completa i campi vuoti": "Compléter les champs vides", + "Conferma importazione": "Confirmer l'importation", + "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Confirmez : auteurs, éditeurs et exemplaires sont créés comme dans l'import CSV", + "Controlla l'anteprima dei primi record": "Vérifiez l'aperçu des premières notices", + "Copertina non scaricata": "Couverture non téléchargée", + "Copy cataloging (SRU)": "Catalogage par dérivation (SRU)", + "Copy cataloging": "Catalogage par dérivation", + "Dal leader del record (posizione 9)": "D'après le guide de la notice (position 9)", + "es. Il nome della rosa": "ex. Le Nom de la rose", + "Esporta in MARC": "Exporter en MARC", + "File MARC (max 20MB)": "Fichier MARC (max 20 Mo)", + "File troppo grande. Dimensione massima %d MB.": "Fichier trop volumineux. Taille maximale %d Mo.", + "Formato dei record": "Format des notices", + "Formato: .mrc, .marc, .iso (ISO 2709) o .xml (MARCXML) • Max 20MB": "Format : .mrc, .marc, .iso (ISO 2709) ou .xml (MARCXML) • Max 20 Mo", + "Già in catalogo (#%d)": "Déjà au catalogue (#%d)", + "Già in catalogo (libro #%d): saltato": "Déjà au catalogue (livre #%d) : ignoré", + "I campi presenti nel record sostituiscono quelli del libro; gli altri restano": "Les champs présents dans la notice remplacent ceux du livre ; les autres restent", + "I record illeggibili vengono saltati e riportati negli errori, senza fermare l'import": "Les notices illisibles sont ignorées et signalées dans les erreurs, sans arrêter l'import", + "Il file deve essere un file MARC (.mrc, .marc, .iso) o MARCXML (.xml)": "Le fichier doit être un fichier MARC (.mrc, .marc, .iso) ou MARCXML (.xml)", + "Il file non contiene record MARC riconoscibili (ISO 2709 o MARCXML)": "Le fichier ne contient aucune notice MARC reconnaissable (ISO 2709 ou MARCXML)", + "Il libro già in catalogo resta com'è": "Le livre déjà au catalogue reste tel quel", + "Import MARC": "Import MARC", + "Importa selezionati": "Importer la sélection", + "Impossibile aprire il file caricato": "Impossible d'ouvrir le fichier chargé", + "Indica cosa cercare e almeno un catalogo": "Indiquez quoi rechercher et au moins un catalogue", + "Le copertine indicate nei record vengono salvate in locale. Rallenta l'importazione.": "Les couvertures indiquées dans les notices sont enregistrées localement. Cela ralentit l'import.", + "Le copie (campi 852/949/952) non vengono importate: ogni libro riceve una copia": "Les exemplaires (champs 852/949/952) ne sont pas importés : chaque livre reçoit un exemplaire", + "Libri già presenti (stesso ISBN-13, ISBN-10 o EAN)": "Livres déjà présents (même ISBN-13, ISBN-10 ou EAN)", + "MARC (ISO 2709 / MARCXML)": "MARC (ISO 2709 / MARCXML)", + "MARC 21 (.mrc)": "MARC 21 (.mrc)", + "Non importato": "Non importé", + "Per esportare in MARC usa il menu Export dell'elenco libri": "Pour exporter en MARC, utilisez le menu Export de la liste des livres", + "Questa installazione (Pinakes)": "Cette installation (Pinakes)", + "Record non leggibile": "Notice illisible", + "Riconoscimento automatico": "Détection automatique", + "Risposta non valida dal server": "Réponse non valide du serveur", + "Risultato non più disponibile: ripeti la ricerca": "Résultat plus disponible : relancez la recherche", + "Salta": "Ignorer", + "Saltato (già in catalogo)": "Ignoré (déjà au catalogue)", + "Scarica le copertine (campo 856)": "Télécharger les couvertures (champ 856)", + "Seleziona almeno un record da importare": "Sélectionnez au moins une notice à importer", + "Solo i campi vuoti del libro vengono riempiti dal record": "Seuls les champs vides du livre sont remplis à partir de la notice", + "Sovrascrivi": "Écraser", + "su %d": "sur %d", + "Termine di ricerca": "Terme de recherche", + "Trascina qui il file MARC o %{browse}": "Déposez le fichier MARC ici ou %{browse}", + "Tutti i campi": "Tous les champs", + "UNIMARC (.mrc)": "UNIMARC (.mrc)" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 4dee0d323..965e8b023 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8497,5 +8497,70 @@ "Copia dichiarata persa in revisione inventariale": "Copia dichiarata persa in revisione inventariale", "Revisione inventariale aperta": "Revisione inventariale aperta", "Revisione inventariale chiusa": "Revisione inventariale chiusa", - "Revisione inventariale annullata": "Revisione inventariale annullata" + "Revisione inventariale annullata": "Revisione inventariale annullata", + "%d copertine scaricate": "%d copertine scaricate", + "%d importati, %d aggiornati, %d saltati, %d errori": "%d importati, %d aggiornati, %d saltati, %d errori", + "%d non leggibili": "%d non leggibili", + "%d record nel file": "%d record nel file", + "%d record": "%d record", + "Aggiornato": "Aggiornato", + "Altra installazione Pinakes": "Altra installazione Pinakes", + "Anteprima dei record": "Anteprima dei record", + "Apri il libro": "Apri il libro", + "Carica File MARC": "Carica File MARC", + "Carica il file esportato dal tuo catalogo (ISO 2709 o MARCXML)": "Carica il file esportato dal tuo catalogo (ISO 2709 o MARCXML)", + "Carica un file MARC 21, UNIMARC o danMARC2 (ISO 2709 o MARCXML) per importare più libri contemporaneamente": "Carica un file MARC 21, UNIMARC o danMARC2 (ISO 2709 o MARCXML) per importare più libri contemporaneamente", + "Cataloghi": "Cataloghi", + "Cerca i record in altri cataloghi (SRU, SBN, altre installazioni Pinakes) e importa quelli che ti servono": "Cerca i record in altri cataloghi (SRU, SBN, altre installazioni Pinakes) e importa quelli che ti servono", + "Cerca nei cataloghi": "Cerca nei cataloghi", + "Cerca per": "Cerca per", + "Codifica dei caratteri (ISO 2709)": "Codifica dei caratteri (ISO 2709)", + "Completa i campi vuoti": "Completa i campi vuoti", + "Conferma importazione": "Conferma importazione", + "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Conferma: autori, editori e copie vengono creati come nell'import CSV", + "Controlla l'anteprima dei primi record": "Controlla l'anteprima dei primi record", + "Copertina non scaricata": "Copertina non scaricata", + "Copy cataloging (SRU)": "Copy cataloging (SRU)", + "Copy cataloging": "Copy cataloging", + "Dal leader del record (posizione 9)": "Dal leader del record (posizione 9)", + "es. Il nome della rosa": "es. Il nome della rosa", + "Esporta in MARC": "Esporta in MARC", + "File MARC (max 20MB)": "File MARC (max 20MB)", + "File troppo grande. Dimensione massima %d MB.": "File troppo grande. Dimensione massima %d MB.", + "Formato dei record": "Formato dei record", + "Formato: .mrc, .marc, .iso (ISO 2709) o .xml (MARCXML) • Max 20MB": "Formato: .mrc, .marc, .iso (ISO 2709) o .xml (MARCXML) • Max 20MB", + "Già in catalogo (#%d)": "Già in catalogo (#%d)", + "Già in catalogo (libro #%d): saltato": "Già in catalogo (libro #%d): saltato", + "I campi presenti nel record sostituiscono quelli del libro; gli altri restano": "I campi presenti nel record sostituiscono quelli del libro; gli altri restano", + "I record illeggibili vengono saltati e riportati negli errori, senza fermare l'import": "I record illeggibili vengono saltati e riportati negli errori, senza fermare l'import", + "Il file deve essere un file MARC (.mrc, .marc, .iso) o MARCXML (.xml)": "Il file deve essere un file MARC (.mrc, .marc, .iso) o MARCXML (.xml)", + "Il file non contiene record MARC riconoscibili (ISO 2709 o MARCXML)": "Il file non contiene record MARC riconoscibili (ISO 2709 o MARCXML)", + "Il libro già in catalogo resta com'è": "Il libro già in catalogo resta com'è", + "Import MARC": "Import MARC", + "Importa selezionati": "Importa selezionati", + "Impossibile aprire il file caricato": "Impossibile aprire il file caricato", + "Indica cosa cercare e almeno un catalogo": "Indica cosa cercare e almeno un catalogo", + "Le copertine indicate nei record vengono salvate in locale. Rallenta l'importazione.": "Le copertine indicate nei record vengono salvate in locale. Rallenta l'importazione.", + "Le copie (campi 852/949/952) non vengono importate: ogni libro riceve una copia": "Le copie (campi 852/949/952) non vengono importate: ogni libro riceve una copia", + "Libri già presenti (stesso ISBN-13, ISBN-10 o EAN)": "Libri già presenti (stesso ISBN-13, ISBN-10 o EAN)", + "MARC (ISO 2709 / MARCXML)": "MARC (ISO 2709 / MARCXML)", + "MARC 21 (.mrc)": "MARC 21 (.mrc)", + "Non importato": "Non importato", + "Per esportare in MARC usa il menu Export dell'elenco libri": "Per esportare in MARC usa il menu Export dell'elenco libri", + "Questa installazione (Pinakes)": "Questa installazione (Pinakes)", + "Record non leggibile": "Record non leggibile", + "Riconoscimento automatico": "Riconoscimento automatico", + "Risposta non valida dal server": "Risposta non valida dal server", + "Risultato non più disponibile: ripeti la ricerca": "Risultato non più disponibile: ripeti la ricerca", + "Salta": "Salta", + "Saltato (già in catalogo)": "Saltato (già in catalogo)", + "Scarica le copertine (campo 856)": "Scarica le copertine (campo 856)", + "Seleziona almeno un record da importare": "Seleziona almeno un record da importare", + "Solo i campi vuoti del libro vengono riempiti dal record": "Solo i campi vuoti del libro vengono riempiti dal record", + "Sovrascrivi": "Sovrascrivi", + "su %d": "su %d", + "Termine di ricerca": "Termine di ricerca", + "Trascina qui il file MARC o %{browse}": "Trascina qui il file MARC o %{browse}", + "Tutti i campi": "Tutti i campi", + "UNIMARC (.mrc)": "UNIMARC (.mrc)" } From c7c5da9dd190046915d5b29bd0e65a89d9b4452c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:36:54 +0100 Subject: [PATCH 035/398] Changelog and migration test for the MARC import The test runs the real MARC section of migrate_0.9.0.sql twice against a copy of the 0.8.9 import_logs table. --- CHANGELOG.md | 7 ++++ tests/migration-0.9.0-marc.unit.php | 58 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/migration-0.9.0-marc.unit.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ba36d4597..5c81bb77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,16 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Closing** either leaves the missing copies as they are or marks them `perso`. Marking them lost is an administrator action, checked on the server: Staff and custom roles can scan and close a session, but not declare copies lost. Copies on loan, being restored, in transit, in maintenance or already lost are never touched; holds on a copy declared lost move to another copy as with a manual change of state; book availability is recalculated through `DataIntegrity`, and each copy gets a "Copia dichiarata persa in revisione inventariale" entry in the book's activity log. A session can also be cancelled without changing any copy. - **Permission** `inventory.manage` for custom roles, and a new starting profile "Revisore inventario" (backend access + stocktakes). Staff and administrators have it already. +### MARC bulk import, export and copy cataloguing +- **Shared MARC library in core** (`app/Support/Marc/`). One neutral `MarcRecord`, streaming ISO 2709 and MARCXML readers and writers written in house (no new dependency), MARC-8 to UTF-8 for Basic and Extended Latin (ANSEL spacing letters and combining diacritics), format detection, and mappers from MARC 21, UNIMARC and danMARC2 to book data. A malformed record, or one in a MARC-8 character set that is not Latin, is reported on its own and the rest of the file goes on. The OAI-PMH server builds its MARCXML, UNIMARC and ISO 2709 records with it (byte for byte the same output, proved against golden files), and the z39-server plugin's role reader, danMARC2 reader and UNIMARC reader now live there. +- **Bulk MARC import** (Books → Import → MARC, `/admin/books/import/marc`). A `.mrc`/`.iso` (ISO 2709) or `.xml` (MARCXML) file is read once, a preview of the first ten mapped records says which are already in the catalogue, and the import runs in chunks with a progress bar through the CSV import's own row pipeline: authors and publishers are found with the exact-spelling rules of #501, one copy per book, the import history and its error download work as for CSV. A record matching a book by ISBN-13, ISBN-10 or EAN (deleted books have them cleared, so they never match) is skipped, completed in its empty fields, or overwritten with what the record carries. Covers come from 856. Holdings (852/949/952) are not turned into copies. +- **Bulk MARC export** (Books → Export, and "Esporta in MARC" on a selection). The catalogue or the selected books as MARC 21 `.mrc`, a MARCXML collection or UNIMARC `.mrc`, written while the download is sent, so a large catalogue never sits in memory. Deleted books are never exported. The records carry the EAN, the LC classification, the Dewey number and the cover, so an export re-imports without duplicates. +- **Copy cataloguing** (Books → Import → Copy cataloging, `/admin/books/copy-cataloging`, z39-server plugin). Search by title, author, ISBN/ISSN or all fields in the configured SRU servers, SBN, the Library of Congress, BnF, DNB, K10plus and this installation's own SRU server; look at each record's MARC fields; import the chosen ones with the same duplicate modes and cover download. `SruClient::search()` returns every record of a query. Another Pinakes installation is a preset in the plugin settings, and its SRU server's UNIMARC records now carry the cover in 856 (the MARCXML ones already did). + ### Migration - `migrate_0.9.0.sql`, section "Inventory stocktake": creates `inventario_sessioni`, `inventario_attese` and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes. +- `migrate_0.9.0.sql`, section "MARC bulk import / copy cataloguing": `import_logs.import_type` gains the value `marc`. Idempotent; existing rows are untouched. ## [0.8.9] diff --git a/tests/migration-0.9.0-marc.unit.php b/tests/migration-0.9.0-marc.unit.php new file mode 100644 index 000000000..16148a329 --- /dev/null +++ b/tests/migration-0.9.0-marc.unit.php @@ -0,0 +1,58 @@ +query($sql); + } + } +}; +$checks = 0; +$check = static function (bool $ok, string $label) use (&$checks): void { + if (!$ok) { + throw new RuntimeException('FAIL ' . $label); + } + $checks++; + echo "OK {$label}\n"; +}; +$type = static fn (): string => (string) $db->query("SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}' AND COLUMN_NAME = 'import_type'")->fetch_row()[0]; +try { + // The 0.8.9 shape of the column the migration touches. + $db->query("CREATE TABLE {$table} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, import_id VARCHAR(36) NOT NULL, import_type ENUM('csv','librarything') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import', file_name VARCHAR(255) DEFAULT NULL, KEY idx_import_type (import_type)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $db->query("INSERT INTO {$table} (import_id, import_type, file_name) VALUES ('a','csv','libri.csv'),('b','librarything','lt.tsv')"); + $check($type() === "enum('csv','librarything')", 'the pre-upgrade column has no marc value'); + $run(); + $check($type() === "enum('csv','librarything','marc')", 'the migration adds marc after the existing values'); + $col = $db->query("SELECT IS_NULLABLE, COLLATION_NAME, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}' AND COLUMN_NAME = 'import_type'")->fetch_assoc(); + $check($col['IS_NULLABLE'] === 'NO' && $col['COLLATION_NAME'] === 'utf8mb4_unicode_ci' && $col['COLUMN_COMMENT'] === 'Type of import', 'NOT NULL, collation and comment are kept'); + $check($db->query("SELECT GROUP_CONCAT(CONCAT(import_id,':',import_type) ORDER BY id) FROM {$table}")->fetch_row()[0] === 'a:csv,b:librarything', 'existing rows keep their type'); + $db->query("INSERT INTO {$table} (import_id, import_type, file_name) VALUES ('c','marc','books.mrc')"); + $check($db->query("SELECT import_type FROM {$table} WHERE import_id='c'")->fetch_row()[0] === 'marc', 'a MARC import can be logged'); + $run(); + $check($type() === "enum('csv','librarything','marc')" && (int) $db->query("SELECT COUNT(*) FROM {$table}")->fetch_row()[0] === 3, 'a second run is a no-op'); + $check(str_contains((string) file_get_contents(dirname(__DIR__) . '/installer/database/schema.sql'), "`import_type` enum('csv','librarything','marc')"), 'fresh install has the marc value'); + $version = json_decode((string) file_get_contents(dirname(__DIR__) . '/version.json'), true)['version']; + $check(version_compare('0.9.0', $version, '<='), 'the release includes the migration'); + echo "SUCCESS {$checks} migration checks\n"; +} finally { + $db->query("DROP TABLE IF EXISTS {$table}"); +} From 2f77cb93ac57342da6ce8c81de46796a21fbeacf Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:39:35 +0100 Subject: [PATCH 036/398] Drop two redundant array_values() calls PHPStan flags on the full tree --- app/Controllers/MarcExportController.php | 2 +- storage/plugins/z39-server/classes/CopyCataloging.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Controllers/MarcExportController.php b/app/Controllers/MarcExportController.php index d529bba32..8c0113539 100644 --- a/app/Controllers/MarcExportController.php +++ b/app/Controllers/MarcExportController.php @@ -207,6 +207,6 @@ private function fetch(\mysqli $db, string $sql, array $ids): array $stmt->execute(); $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); $stmt->close(); - return array_values($rows); + return $rows; } } diff --git a/storage/plugins/z39-server/classes/CopyCataloging.php b/storage/plugins/z39-server/classes/CopyCataloging.php index d3fbb82ba..58fbde54b 100644 --- a/storage/plugins/z39-server/classes/CopyCataloging.php +++ b/storage/plugins/z39-server/classes/CopyCataloging.php @@ -81,8 +81,8 @@ public function servers(): array { $out = []; $urls = []; - foreach (array_values($this->configured) as $i => $server) { - if (!is_array($server) || empty($server['url'])) { + foreach ($this->configured as $i => $server) { + if (empty($server['url'])) { continue; } $server['name'] = (string) ($server['name'] ?? $server['url']); From 300484a5769e821ad12e7f4d17c904160a52a55a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:42:05 +0100 Subject: [PATCH 037/398] Point the #237 compatibility test at the shared MARC builder The OAI server's MARC 21 and UNIMARC role mapping now lives in BookMarcBuilder; the test reads and invokes it there and still checks that the OAI plugin builds its UNIMARC through it. --- ...tributor-plugin-compatibility-237.unit.php | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/contributor-plugin-compatibility-237.unit.php b/tests/contributor-plugin-compatibility-237.unit.php index 03ccd0e29..549d444ba 100644 --- a/tests/contributor-plugin-compatibility-237.unit.php +++ b/tests/contributor-plugin-compatibility-237.unit.php @@ -114,29 +114,33 @@ 'SRU UNIMARC behavior exports translator as 702 with relator 730'); $oai = $read('storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php'); -$check(str_contains($oai, "? 'creator' : 'contributor'") && str_contains($oai, "? (\$index === \$primaryCreatorIndex ? '700' : '701') : '702'"), 'OAI Dublin Core and UNIMARC preserve creator/contributor semantics'); -$check(str_contains($oai, 'private function primaryCreatorIndex') - && str_contains($oai, "if (\$role === 'principale')") +// The OAI server's MARC 21 / UNIMARC records come from the shared builder +// (App\Support\Marc\BookMarcBuilder), which the bulk MARC export also uses. +$marcBuilder = $read('app/Support/Marc/BookMarcBuilder.php'); +$check(str_contains($oai, "? 'creator' : 'contributor'") && str_contains($marcBuilder, "? (\$index === \$primaryCreatorIndex ? '700' : '701') : '702'") && str_contains($oai, 'BookMarcBuilder::unimarc('), 'OAI Dublin Core and UNIMARC preserve creator/contributor semantics'); +$check(str_contains($marcBuilder, 'public static function primaryCreatorIndex') + && str_contains($marcBuilder, "if (\$role === 'principale')") + && !str_contains($marcBuilder, "\$authors[0]['nome']") && !str_contains($oai, "\$authors[0]['nome']"), 'OAI MARC/UNIMARC main responsibility is role-driven, never array-position-driven'); $check(substr_count($oai, "CASE la.ruolo WHEN 'principale' THEN 0 WHEN 'co-autore' THEN 1 ELSE 2 END") >= 1 && substr_count($oai, "CASE la.ruolo WHEN \\'principale\\' THEN 0 WHEN \\'co-autore\\' THEN 1 ELSE 2 END") >= 1, 'OAI batch and single-record fetches sort principal creators first'); -$check(str_contains($oai, "'traduttore' => '730'") && str_contains($oai, "'illustratore' => '440'") && str_contains($oai, "'colorista' => '410'"), 'OAI UNIMARC mappings use the standard relator codes'); +$check(str_contains($marcBuilder, "'traduttore' => '730'") && str_contains($marcBuilder, "'illustratore' => '440'") && str_contains($marcBuilder, "'colorista' => '410'"), 'OAI UNIMARC mappings use the standard relator codes'); require_once $root . '/storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php'; $oaiReflection = new ReflectionClass(\App\Plugins\OaiPmhServer\OaiPmhServerPlugin::class); $oaiInstance = $oaiReflection->newInstanceWithoutConstructor(); -$primaryCreatorIndex = $oaiReflection->getMethod('primaryCreatorIndex'); -$check($primaryCreatorIndex->invoke($oaiInstance, [ +$primaryCreatorIndex = new ReflectionMethod(\App\Support\Marc\BookMarcBuilder::class, 'primaryCreatorIndex'); +$check($primaryCreatorIndex->invoke(null, [ ['nome' => 'Translator', 'ruolo' => 'traduttore'], ['nome' => 'Coauthor', 'ruolo' => 'co-autore'], ['nome' => 'Principal', 'ruolo' => 'principale'], ]) === 2, 'OAI role selection finds a principal even when it is last'); -$check($primaryCreatorIndex->invoke($oaiInstance, [ +$check($primaryCreatorIndex->invoke(null, [ ['nome' => 'Illustrator', 'ruolo' => 'illustratore'], ['nome' => 'Coauthor', 'ruolo' => 'co-autore'], ]) === 1, 'OAI role selection falls back to a co-author when no principal exists'); -$check($primaryCreatorIndex->invoke($oaiInstance, [ +$check($primaryCreatorIndex->invoke(null, [ ['nome' => 'Translator', 'ruolo' => 'traduttore'], ]) === null, 'OAI contributors never become the main creator'); $writeMarc = $oaiReflection->getMethod('writeBookMarcXml'); From 4883fb7f24e1a448e84d56984ccfe0a0c6a482f7 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:44:38 +0100 Subject: [PATCH 038/398] Say so when a MARC file holds more records than one import takes The upload stops at 20000 records; the preview now tells the librarian instead of importing the first part silently. --- app/Controllers/MarcImportController.php | 3 +++ app/Views/admin/marc_import.php | 1 + locale/da_DK.json | 3 ++- locale/de_DE.json | 3 ++- locale/en_US.json | 3 ++- locale/fr_FR.json | 3 ++- locale/it_IT.json | 3 ++- 7 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/Controllers/MarcImportController.php b/app/Controllers/MarcImportController.php index 55c737969..02cd4cb21 100644 --- a/app/Controllers/MarcImportController.php +++ b/app/Controllers/MarcImportController.php @@ -96,6 +96,7 @@ public function upload(Request $request, Response $response, \mysqli $db): Respo $total = 0; $unreadable = 0; + $truncated = false; $preview = []; $importer = new MarcBookImporter($db, $mode, false); $csv = new CsvImportController(); @@ -105,6 +106,7 @@ public function upload(Request $request, Response $response, \mysqli $db): Respo : Iso2709Reader::fromPath($tmp, $encoding); foreach ($reader as $position => $item) { if ($total >= self::MAX_RECORDS) { + $truncated = true; break; } $total++; @@ -145,6 +147,7 @@ public function upload(Request $request, Response $response, \mysqli $db): Respo 'success' => true, 'total_rows' => $total, 'unreadable' => $unreadable, + 'truncated' => $truncated ? sprintf(__('Il file contiene più di %d record: vengono importati solo i primi %d'), self::MAX_RECORDS, self::MAX_RECORDS) : null, 'container' => $container, 'chunk_size' => !empty($data['download_covers']) ? 2 : self::CHUNK_SIZE, 'preview' => $preview, diff --git a/app/Views/admin/marc_import.php b/app/Views/admin/marc_import.php index 1cb4dfa1e..49a488664 100644 --- a/app/Views/admin/marc_import.php +++ b/app/Views/admin/marc_import.php @@ -367,6 +367,7 @@ function showError(message) { function renderPreview(data) { let summary = fmt(T.records, data.total_rows); if (data.unreadable > 0) summary += ' — ' + fmt(T.unreadable, data.unreadable); + if (data.truncated) summary += ' — ' + data.truncated; document.getElementById('marc-preview-summary').textContent = summary; const rows = (data.preview || []).map((r) => { if (r.error) { diff --git a/locale/da_DK.json b/locale/da_DK.json index 650527e1d..3039c9399 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8562,5 +8562,6 @@ "Termine di ricerca": "Søgeord", "Trascina qui il file MARC o %{browse}": "Træk MARC-filen hertil eller %{browse}", "Tutti i campi": "Alle felter", - "UNIMARC (.mrc)": "UNIMARC (.mrc)" + "UNIMARC (.mrc)": "UNIMARC (.mrc)", + "Il file contiene più di %d record: vengono importati solo i primi %d": "Filen indeholder mere end %d poster: kun de første %d importeres" } diff --git a/locale/de_DE.json b/locale/de_DE.json index b31e1398f..9e38d4c63 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8562,5 +8562,6 @@ "Termine di ricerca": "Suchbegriff", "Trascina qui il file MARC o %{browse}": "MARC-Datei hierher ziehen oder %{browse}", "Tutti i campi": "Alle Felder", - "UNIMARC (.mrc)": "UNIMARC (.mrc)" + "UNIMARC (.mrc)": "UNIMARC (.mrc)", + "Il file contiene più di %d record: vengono importati solo i primi %d": "Die Datei enthält mehr als %d Datensätze: nur die ersten %d werden importiert" } diff --git a/locale/en_US.json b/locale/en_US.json index 4bd7b1619..d9790a9e0 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8562,5 +8562,6 @@ "Termine di ricerca": "Search term", "Trascina qui il file MARC o %{browse}": "Drop the MARC file here or %{browse}", "Tutti i campi": "All fields", - "UNIMARC (.mrc)": "UNIMARC (.mrc)" + "UNIMARC (.mrc)": "UNIMARC (.mrc)", + "Il file contiene più di %d record: vengono importati solo i primi %d": "The file holds more than %d records: only the first %d are imported" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index d858066ab..171405973 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8562,5 +8562,6 @@ "Termine di ricerca": "Terme de recherche", "Trascina qui il file MARC o %{browse}": "Déposez le fichier MARC ici ou %{browse}", "Tutti i campi": "Tous les champs", - "UNIMARC (.mrc)": "UNIMARC (.mrc)" + "UNIMARC (.mrc)": "UNIMARC (.mrc)", + "Il file contiene più di %d record: vengono importati solo i primi %d": "Le fichier contient plus de %d notices : seules les %d premières sont importées" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 965e8b023..6075f912d 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8562,5 +8562,6 @@ "Termine di ricerca": "Termine di ricerca", "Trascina qui il file MARC o %{browse}": "Trascina qui il file MARC o %{browse}", "Tutti i campi": "Tutti i campi", - "UNIMARC (.mrc)": "UNIMARC (.mrc)" + "UNIMARC (.mrc)": "UNIMARC (.mrc)", + "Il file contiene più di %d record: vengono importati solo i primi %d": "Il file contiene più di %d record: vengono importati solo i primi %d" } From 5dd0b66dad7c16059768b8700c64f186661127df Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 04:25:15 +0100 Subject: [PATCH 039/398] MARC import: post-commit steps warn, local covers keep their type, cap listed duplicates Once importParsedRow() has committed the book, a failure of the extra MARC columns, the cover update or the search index rebuild is reported as a warning on the imported record instead of a failed record, so the summary no longer counts it twice while the book stays in the catalogue. A cover copied from this installation is stored with the extension of its image type (WebP and GIF kept as such, not renamed .jpg). Skipped duplicates are listed in the run report up to 100; beyond that they are only counted, so re-importing a whole export does not grow the session and the import log by one entry per record. --- app/Controllers/MarcImportController.php | 34 +++++- app/Support/Marc/MarcBookImporter.php | 38 ++++-- app/Support/Marc/MarcCoverFetcher.php | 10 +- locale/da_DK.json | 2 + locale/de_DE.json | 2 + locale/en_US.json | 2 + locale/fr_FR.json | 2 + locale/it_IT.json | 2 + tests/marc-import-robustness.unit.php | 148 +++++++++++++++++++++++ 9 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 tests/marc-import-robustness.unit.php diff --git a/app/Controllers/MarcImportController.php b/app/Controllers/MarcImportController.php index 02cd4cb21..ddf5eefc0 100644 --- a/app/Controllers/MarcImportController.php +++ b/app/Controllers/MarcImportController.php @@ -29,6 +29,13 @@ class MarcImportController private const MAX_FILE_BYTES = 20 * 1024 * 1024; private const MAX_RECORDS = 20000; private const SESSION_KEY = 'marc_import_data'; + /** + * Skipped duplicates listed by name in the run's history. The rest only + * count (counters['skipped']): re-importing a whole export of this + * catalogue must not grow the session, rewritten on every chunk, and the + * import log by one entry per record. + */ + public const MAX_LISTED_DUPLICATES = 100; private const EXTENSIONS = ['mrc', 'marc', 'iso', 'dat', 'xml', 'marcxml']; public function showImportPage(Request $request, Response $response): Response @@ -206,12 +213,7 @@ public function chunk(Request $request, Response $response, \mysqli $db): Respon $warnings = []; $result = $importer->import($bookData, $counters, $warnings); if ($result['action'] === 'skipped') { - $state['errors'][] = [ - 'line' => $position, - 'title' => $title, - 'message' => sprintf(__('Già in catalogo (libro #%d): saltato'), (int) $result['id']), - 'type' => 'duplicate', - ]; + self::listSkippedDuplicate($state, $position, $title, (int) $result['id']); } foreach ($warnings as $warning) { $state['errors'][] = ['line' => $position, 'title' => $title, 'message' => $warning, 'type' => 'cover']; @@ -259,6 +261,26 @@ public function chunk(Request $request, Response $response, \mysqli $db): Respon ]); } + /** + * Note a skipped duplicate in the run's report, up to MAX_LISTED_DUPLICATES. + * + * @param array $state + */ + public static function listSkippedDuplicate(array &$state, int $position, string $title, int $bookId): void + { + $listed = (int) ($state['duplicates_listed'] ?? 0); + if ($listed >= self::MAX_LISTED_DUPLICATES) { + return; + } + $state['duplicates_listed'] = $listed + 1; + $state['errors'][] = [ + 'line' => $position, + 'title' => $title, + 'message' => sprintf(__('Già in catalogo (libro #%d): saltato'), $bookId), + 'type' => 'duplicate', + ]; + } + /** * @param MarcRecord|MarcReadError $item * @return array diff --git a/app/Support/Marc/MarcBookImporter.php b/app/Support/Marc/MarcBookImporter.php index bb0fcbec0..afa102333 100644 --- a/app/Support/Marc/MarcBookImporter.php +++ b/app/Support/Marc/MarcBookImporter.php @@ -103,8 +103,9 @@ public function __construct( * Import one record's book data. $counters is advanced (imported, * updated, skipped, authors_created, publishers_created, covers); a * cover that cannot be fetched is reported in $warnings, not as a - * failure. Throws when the record cannot be written; nothing of it is - * then left in the catalogue. + * failure. Throws when the book itself cannot be written; nothing of it + * is then left in the catalogue. Once the book is committed, a failure + * of the extra MARC columns, the cover or the search index is a warning. * * @param array> $data book data (MarcBookData shape) * @param array $counters @@ -155,9 +156,23 @@ public function import(array $data, array &$counters, array &$warnings = []): ar ); $bookId = $result['id']; - $this->writeExtraColumns($bookId, $data, $existing); + // The book is committed and counted at this point: what follows only + // completes it, so a failure here is a warning on an imported record, + // never a failed record (that would count it twice and contradict the + // catalogue, where the book already is). + try { + $this->writeExtraColumns($bookId, $data, $existing); + } catch (\Throwable $e) { + $warnings[] = __('Campi MARC aggiuntivi non salvati') . ': ' . $e->getMessage(); + \App\Support\SecureLogger::warning('[MARC import] extra columns not written', ['libro_id' => $bookId, 'error' => $e->getMessage()]); + } $this->writeCover($bookId, (string) ($data['copertina_url'] ?? ''), $existing, $counters, $warnings); - \App\Support\SearchIndexBuilder::rebuild($this->db, $bookId); + try { + \App\Support\SearchIndexBuilder::rebuild($this->db, $bookId); + } catch (\Throwable $e) { + $warnings[] = __('Indice di ricerca non aggiornato') . ': ' . $e->getMessage(); + \App\Support\SecureLogger::warning('[MARC import] search index not rebuilt', ['libro_id' => $bookId, 'error' => $e->getMessage()]); + } return ['action' => $result['action'], 'id' => $bookId, 'title' => $title]; } @@ -357,13 +372,18 @@ private function writeCover(int $bookId, string $url, ?array $existing, array &$ if ($path === '') { return; } - $stmt = $this->db->prepare('UPDATE libri SET copertina_url = ? WHERE id = ? AND deleted_at IS NULL'); - if ($stmt === false) { + try { + $stmt = $this->db->prepare('UPDATE libri SET copertina_url = ? WHERE id = ? AND deleted_at IS NULL'); + if ($stmt === false) { + return; + } + $stmt->bind_param('si', $path, $bookId); + $stmt->execute(); + $stmt->close(); + } catch (\Throwable $e) { + $warnings[] = __('Copertina non scaricata') . ': ' . $e->getMessage(); return; } - $stmt->bind_param('si', $path, $bookId); - $stmt->execute(); - $stmt->close(); $counters['covers']++; } diff --git a/app/Support/Marc/MarcCoverFetcher.php b/app/Support/Marc/MarcCoverFetcher.php index 94530b6d4..0add4bd56 100644 --- a/app/Support/Marc/MarcCoverFetcher.php +++ b/app/Support/Marc/MarcCoverFetcher.php @@ -67,7 +67,15 @@ private function copyLocal(string $file): string if ($info === false) { throw new \RuntimeException(__('File non valido o corrotto.')); } - $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'png' ? 'png' : 'jpg'; + // The stored name follows the bytes, not the source name, so a server + // that types files by extension serves the right Content-Type. + $extension = match ($info[2]) { + IMAGETYPE_JPEG => 'jpg', + IMAGETYPE_PNG => 'png', + IMAGETYPE_WEBP => 'webp', + IMAGETYPE_GIF => 'gif', + default => throw new \RuntimeException(__('File non valido o corrotto.')), + }; $name = uniqid('copertina_', true) . '.' . $extension; if (!@copy($file, self::coversDir() . '/' . $name)) { throw new \RuntimeException(__('Errore nel salvataggio dell\'immagine.')); diff --git a/locale/da_DK.json b/locale/da_DK.json index 3039c9399..80df0bada 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8520,6 +8520,8 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Bekræft: forfattere, forlag og eksemplarer oprettes som ved CSV-import", "Controlla l'anteprima dei primi record": "Tjek forhåndsvisningen af de første poster", "Copertina non scaricata": "Omslag ikke hentet", + "Campi MARC aggiuntivi non salvati": "Yderligere MARC-felter ikke gemt", + "Indice di ricerca non aggiornato": "Søgeindeks ikke opdateret", "Copy cataloging (SRU)": "Kopikatalogisering (SRU)", "Copy cataloging": "Kopikatalogisering", "Dal leader del record (posizione 9)": "Fra postens leader (position 9)", diff --git a/locale/de_DE.json b/locale/de_DE.json index 9e38d4c63..af7469738 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8520,6 +8520,8 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Bestätige: Autoren, Verlage und Exemplare werden wie beim CSV-Import angelegt", "Controlla l'anteprima dei primi record": "Prüfe die Vorschau der ersten Datensätze", "Copertina non scaricata": "Cover nicht heruntergeladen", + "Campi MARC aggiuntivi non salvati": "Zusätzliche MARC-Felder nicht gespeichert", + "Indice di ricerca non aggiornato": "Suchindex nicht aktualisiert", "Copy cataloging (SRU)": "Fremddatenübernahme (SRU)", "Copy cataloging": "Fremddatenübernahme", "Dal leader del record (posizione 9)": "Aus dem Leader des Datensatzes (Position 9)", diff --git a/locale/en_US.json b/locale/en_US.json index d9790a9e0..49351f496 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8520,6 +8520,8 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Confirm: authors, publishers and copies are created as in the CSV import", "Controlla l'anteprima dei primi record": "Check the preview of the first records", "Copertina non scaricata": "Cover not downloaded", + "Campi MARC aggiuntivi non salvati": "Additional MARC fields not saved", + "Indice di ricerca non aggiornato": "Search index not updated", "Copy cataloging (SRU)": "Copy cataloguing (SRU)", "Copy cataloging": "Copy cataloguing", "Dal leader del record (posizione 9)": "From the record leader (position 9)", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 171405973..ff71bef48 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8520,6 +8520,8 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Confirmez : auteurs, éditeurs et exemplaires sont créés comme dans l'import CSV", "Controlla l'anteprima dei primi record": "Vérifiez l'aperçu des premières notices", "Copertina non scaricata": "Couverture non téléchargée", + "Campi MARC aggiuntivi non salvati": "Champs MARC supplémentaires non enregistrés", + "Indice di ricerca non aggiornato": "Index de recherche non mis à jour", "Copy cataloging (SRU)": "Catalogage par dérivation (SRU)", "Copy cataloging": "Catalogage par dérivation", "Dal leader del record (posizione 9)": "D'après le guide de la notice (position 9)", diff --git a/locale/it_IT.json b/locale/it_IT.json index 6075f912d..2501e6d0b 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8520,6 +8520,8 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Conferma: autori, editori e copie vengono creati come nell'import CSV", "Controlla l'anteprima dei primi record": "Controlla l'anteprima dei primi record", "Copertina non scaricata": "Copertina non scaricata", + "Campi MARC aggiuntivi non salvati": "Campi MARC aggiuntivi non salvati", + "Indice di ricerca non aggiornato": "Indice di ricerca non aggiornato", "Copy cataloging (SRU)": "Copy cataloging (SRU)", "Copy cataloging": "Copy cataloging", "Dal leader del record (posizione 9)": "Dal leader del record (posizione 9)", diff --git a/tests/marc-import-robustness.unit.php b/tests/marc-import-robustness.unit.php new file mode 100644 index 000000000..c2cde7b75 --- /dev/null +++ b/tests/marc-import-robustness.unit.php @@ -0,0 +1,148 @@ + []]; +for ($i = 1; $i <= MarcImportController::MAX_LISTED_DUPLICATES + 50; $i++) { + MarcImportController::listSkippedDuplicate($state, $i, 'Book ' . $i, $i); +} +$check(count($state['errors']) === MarcImportController::MAX_LISTED_DUPLICATES, 'duplicates beyond the cap are not listed one by one'); +$check(($state['errors'][0]['type'] ?? '') === 'duplicate' && ($state['errors'][0]['line'] ?? 0) === 1, 'the first duplicates keep their position and type'); + +// 2. Local cover copies take their extension from the image bytes. +$coversDir = $root . '/public/uploads/copertine'; +$copyLocal = new ReflectionMethod(MarcCoverFetcher::class, 'copyLocal'); +$created = []; +if (!function_exists('imagecreatetruecolor')) { + echo " SKIP cover extension checks: GD not available\n"; +} else { + if (!is_dir($coversDir)) { + mkdir($coversDir, 0775, true); + } + $img = imagecreatetruecolor(4, 4); + $writers = ['png' => 'imagepng', 'gif' => 'imagegif', 'jpg' => 'imagejpeg']; + if (function_exists('imagewebp')) { + $writers['webp'] = 'imagewebp'; + } + foreach ($writers as $ext => $writer) { + // Source named .jpg on purpose: the stored name must follow the bytes. + $source = $coversDir . '/zzmr_source_' . bin2hex(random_bytes(3)) . '.jpg'; + $writer($img, $source); + $created[] = $source; + try { + $stored = (string) $copyLocal->invoke(new MarcCoverFetcher(), $source); + $created[] = $root . '/public' . $stored; + $check(str_ends_with($stored, '.' . $ext), "a {$ext} image is stored as .{$ext}"); + } catch (Throwable $e) { + $check(false, "a {$ext} image is stored as .{$ext} (" . $e->getMessage() . ')'); + } + } + $bogus = $coversDir . '/zzmr_bogus_' . bin2hex(random_bytes(3)) . '.png'; + file_put_contents($bogus, 'not an image'); + $created[] = $bogus; + try { + $copyLocal->invoke(new MarcCoverFetcher(), $bogus); + $check(false, 'a file that is not an image is refused'); + } catch (RuntimeException) { + $check(true, 'a file that is not an image is refused'); + } +} +foreach ($created as $file) { + @unlink($file); +} + +// 3. Post-commit failures are warnings, not failed records. +$envFile = $root . '/.env'; +if (!is_file($envFile)) { + echo " SKIP database checks: no .env\n"; +} else { + $env = Dotenv\Dotenv::parse((string) file_get_contents($envFile)); + $db = new mysqli($env['DB_HOST'] ?? '127.0.0.1', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null)); + $db->set_charset('utf8mb4'); + $hasColumn = $db->query("SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND COLUMN_NAME = 'dimensioni'")->num_rows === 1; + if (!$hasColumn) { + if (getenv('CI_STRICT_TESTS') === '1') { + $check(false, 'libri.dimensioni exists'); + } else { + echo " SKIP database checks: libri.dimensioni missing\n"; + } + } else { + $run = strtoupper(bin2hex(random_bytes(3))); + $title = 'ZZMR' . $run . ' post-commit'; + $trigger = 'zzmr_probe_' . strtolower($run); + $db->query("CREATE TRIGGER {$trigger} BEFORE UPDATE ON libri FOR EACH ROW BEGIN IF NEW.dimensioni = 'ZZFAIL' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'probe failure'; END IF; END"); + try { + $counters = []; + $warnings = []; + $importer = new MarcBookImporter($db, MarcBookImporter::MODE_SKIP, false); + $threw = false; + $result = null; + try { + $result = $importer->import(['titolo' => $title, 'dimensioni' => 'ZZFAIL'], $counters, $warnings); + } catch (Throwable $e) { + $threw = true; + echo ' (threw: ' . $e->getMessage() . ")\n"; + } + $check(!$threw && ($result['action'] ?? '') === 'created', 'a failing extra-column write does not fail the record'); + $check((int) ($counters['imported'] ?? 0) === 1, 'the record is counted once, as imported'); + $check(count($warnings) === 1 && str_contains($warnings[0], 'probe failure'), 'the failure is reported as a warning'); + $stmt = $db->prepare('SELECT COUNT(*) FROM libri WHERE titolo = ? AND deleted_at IS NULL'); + $stmt->bind_param('s', $title); + $stmt->execute(); + $check((int) $stmt->get_result()->fetch_row()[0] === 1, 'the book is in the catalogue, matching the report'); + $stmt->close(); + } finally { + $db->query("DROP TRIGGER IF EXISTS {$trigger}"); + $like = 'ZZMR' . $run . '%'; + $stmt = $db->prepare('SELECT id FROM libri WHERE titolo LIKE ?'); + $stmt->bind_param('s', $like); + $stmt->execute(); + foreach ($stmt->get_result()->fetch_all() as [$bookId]) { + $bookId = (int) $bookId; + $db->query("DELETE FROM libri_autori WHERE libro_id = {$bookId}"); + $db->query("DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id = {$bookId}"); + $db->query("DELETE FROM copie WHERE libro_id = {$bookId}"); + $db->query("DELETE FROM libri WHERE id = {$bookId}"); + } + $stmt->close(); + } + } +} + +echo "\n{$pass} passed, {$fail} failed\n"; +exit($fail === 0 ? 0 : 1); From 55f4cdd2b6938d3a45cb3d30c1ce471bd3a8591e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 06:16:12 +0100 Subject: [PATCH 040/398] MARC and copy cataloguing: review fixes on encoding, languages, export and SRU queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISO 2709: forcing MARC-8 now reads records mislabelled leader/09 = a, and in auto mode a record labelled UTF-8 is checked and reported at its position when its bytes are not UTF-8. MarcXmlReader keeps its own libxml errors and drops those a consumer leaves between two records, so a well-formed file is no longer reported as broken. MarcLanguage::codeFor() also recognises every native name nameFor() stores (Русский, 中文, Suomi, Latina...), so the language survives an import and export. MARC export: only all-digit ids are accepted (12abc is not book 12), and a record over the ISO 2709 limit is written with its long texts shortened instead of being dropped from the file. MARC import: a chunk that reads nothing (staged file truncated) answers with an error, and the page stops when a chunk does not advance. Copy cataloguing: text terms are always quoted in CQL (dell'arte, and), only ISBN/ISSN digits go bare; servers after the 100-result cap are listed as not queried instead of vanishing from the report; https://host and https://host:443 are the same local endpoint, also for local covers. The E2E removes the import_logs rows its imports write. --- app/Controllers/MarcExportController.php | 56 +++++++++++- app/Controllers/MarcImportController.php | 7 ++ app/Support/Marc/Iso2709Reader.php | 26 +++--- app/Support/Marc/MarcCoverFetcher.php | 20 ++++- app/Support/Marc/MarcLanguage.php | 12 ++- app/Support/Marc/MarcXmlReader.php | 20 ++++- app/Views/admin/marc_import.php | 1 + locale/da_DK.json | 2 + locale/de_DE.json | 2 + locale/en_US.json | 2 + locale/fr_FR.json | 2 + locale/it_IT.json | 2 + .../z39-server/classes/CopyCataloging.php | 15 +++- .../z39-server/classes/LocalSruClient.php | 2 +- .../plugins/z39-server/classes/SruClient.php | 13 +-- tests/copy-cataloging-limits.unit.php | 89 +++++++++++++++++++ tests/marc-bulk-import.spec.js | 25 +++++- tests/marc-copy-cataloging.spec.js | 6 ++ tests/marc-library.unit.php | 72 +++++++++++++++ tests/sru-search.unit.php | 23 ++++- 20 files changed, 369 insertions(+), 28 deletions(-) create mode 100644 tests/copy-cataloging-limits.unit.php diff --git a/app/Controllers/MarcExportController.php b/app/Controllers/MarcExportController.php index 8c0113539..46523531b 100644 --- a/app/Controllers/MarcExportController.php +++ b/app/Controllers/MarcExportController.php @@ -34,7 +34,7 @@ public function export(Request $request, Response $response, \mysqli $db): Respo $ids = null; if (array_key_exists('ids', $params)) { $raw = is_string($params['ids']) ? $params['ids'] : ''; - $ids = array_values(array_unique(array_filter(array_map('intval', explode(',', $raw)), static fn (int $id): bool => $id > 0))); + $ids = self::parseIds($raw); if (count($ids) > self::MAX_SELECTED) { $_SESSION['error_message'] = sprintf( __('Selezione troppo grande per l\'esportazione: %d libri (massimo 1000). Riduci la selezione e riprova.'), @@ -88,8 +88,19 @@ public function generate(\mysqli $db, string $format, ?array $ids): \Generator } try { yield Iso2709Writer::serialize($record); - } catch (\OverflowException $e) { - \App\Support\SecureLogger::warning('[MARC export] record too long for ISO 2709, skipped', ['book_id' => $row['id'] ?? null]); + } catch (\OverflowException) { + // Over 99,999 bytes: the long free texts (description, notes) + // are shortened so the book is still in the file, not dropped. + $short = self::shortenTexts($row); + $record = $format === 'unimarc' + ? BookMarcBuilder::unimarc($short, $authors, $publishers, $genre, true) + : BookMarcBuilder::marc21($short, $authors, $publishers, $genre, true); + try { + yield Iso2709Writer::serialize($record); + \App\Support\SecureLogger::warning('[MARC export] record over the ISO 2709 limit, long texts shortened', ['book_id' => $row['id'] ?? null]); + } catch (\OverflowException) { + \App\Support\SecureLogger::error('[MARC export] record too long for ISO 2709 even shortened, skipped', ['book_id' => $row['id'] ?? null]); + } } } if ($xml !== null) { @@ -98,6 +109,45 @@ public function generate(\mysqli $db, string $format, ?array $ids): \Generator } } + /** + * Book ids from a comma-separated list: only tokens made of digits count + * ("12abc" is not book 12), positive, first-seen order, no duplicates. + * + * @return list + */ + public static function parseIds(string $raw): array + { + $ids = []; + foreach (explode(',', $raw) as $token) { + $token = trim($token); + if ($token === '' || !ctype_digit($token)) { + continue; + } + $id = (int) $token; + if ($id > 0) { + $ids[$id] = $id; + } + } + return array_values($ids); + } + + /** + * A copy of the book row with every text longer than 1000 characters + * cut there, for a record that would not fit ISO 2709 otherwise. + * + * @param array $row + * @return array + */ + public static function shortenTexts(array $row): array + { + foreach ($row as $key => $value) { + if (is_string($value) && mb_strlen($value) > 1000) { + $row[$key] = rtrim(mb_substr($value, 0, 1000)) . '…'; + } + } + return $row; + } + /** * The books to export, page by page, with their authors (in credit * order), publishers and genre. diff --git a/app/Controllers/MarcImportController.php b/app/Controllers/MarcImportController.php index ddf5eefc0..8edf02abb 100644 --- a/app/Controllers/MarcImportController.php +++ b/app/Controllers/MarcImportController.php @@ -225,6 +225,13 @@ public function chunk(Request $request, Response $response, \mysqli $db): Respon } $offset = ftell($handle); fclose($handle); + if ($processed === $start) { + // The staged file ended before total_rows (truncated or replaced): + // answering "success" with the same position would make the + // page ask for this chunk forever. + \App\Support\SecureLogger::warning('[MARC import] staging file ended early', ['processed' => $processed, 'total' => (int) $state['total_rows']]); + return $this->json($response, ['success' => false, 'error' => __('Il file caricato è incompleto: import interrotto.')], 500); + } $state['offset'] = $offset === false ? (int) $state['offset'] : $offset; $state['processed'] = $processed; $state['counters'] = $counters; diff --git a/app/Support/Marc/Iso2709Reader.php b/app/Support/Marc/Iso2709Reader.php index a186acef9..1ea87b1c5 100644 --- a/app/Support/Marc/Iso2709Reader.php +++ b/app/Support/Marc/Iso2709Reader.php @@ -138,17 +138,23 @@ public static function parse(string $raw, string $encoding = self::ENCODING_AUTO throw new MarcParseException('Directory length is not a multiple of 12'); } - $utf8 = match ($encoding) { - self::ENCODING_UTF8 => true, - self::ENCODING_MARC8 => false, + $validUtf8 = mb_check_encoding($raw, 'UTF-8'); + if ($encoding === self::ENCODING_MARC8) { + // The override exists for MARC-8 records mislabelled leader/09 = a. + $utf8 = false; + } elseif ($encoding === self::ENCODING_UTF8) { + if (!$validUtf8) { + throw new MarcParseException('Record declares UTF-8 but is not valid UTF-8'); + } + $utf8 = true; + } elseif ($leader[9] === 'a') { + if (!$validUtf8) { + throw new MarcParseException('Record declares UTF-8 (leader/09 = a) but is not valid UTF-8'); + } + $utf8 = true; + } else { // An escape (0x1B) only occurs in MARC-8, where it switches sets. - default => $leader[9] === 'a' || (!str_contains($raw, "\x1B") && mb_check_encoding($raw, 'UTF-8')), - }; - if ($encoding === self::ENCODING_UTF8 && !mb_check_encoding($raw, 'UTF-8')) { - throw new MarcParseException('Record declares UTF-8 but is not valid UTF-8'); - } - if (!$utf8 && $leader[9] === 'a') { - throw new MarcParseException('Record declares UTF-8 (leader/09 = a) but is not valid UTF-8'); + $utf8 = !str_contains($raw, "\x1B") && $validUtf8; } $decode = $utf8 ? static fn (string $s): string => $s diff --git a/app/Support/Marc/MarcCoverFetcher.php b/app/Support/Marc/MarcCoverFetcher.php index 0add4bd56..f6fd30638 100644 --- a/app/Support/Marc/MarcCoverFetcher.php +++ b/app/Support/Marc/MarcCoverFetcher.php @@ -42,7 +42,7 @@ private function localCoverFile(string $url): ?string $base = parse_url(function_exists('absoluteUrl') ? \absoluteUrl('/') : ''); if (!is_array($base) || !isset($base['host']) || strcasecmp((string) $base['host'], (string) $parts['host']) !== 0 - || (int) ($base['port'] ?? 0) !== (int) ($parts['port'] ?? 0) + || self::effectivePort($base) !== self::effectivePort($parts) ) { return null; } @@ -61,6 +61,24 @@ private function localCoverFile(string $url): ?string return is_file($file) ? $file : null; } + /** + * The port a parse_url() result points at: the explicit one, else the + * scheme's default, so https://host and https://host:443 are the same. + * + * @param array $parts + */ + public static function effectivePort(array $parts): int + { + if (isset($parts['port'])) { + return (int) $parts['port']; + } + return match (strtolower((string) ($parts['scheme'] ?? ''))) { + 'https' => 443, + 'http' => 80, + default => 0, + }; + } + private function copyLocal(string $file): string { $info = @getimagesize($file); diff --git a/app/Support/Marc/MarcLanguage.php b/app/Support/Marc/MarcLanguage.php index cbce5f847..f865fd07f 100644 --- a/app/Support/Marc/MarcLanguage.php +++ b/app/Support/Marc/MarcLanguage.php @@ -62,7 +62,17 @@ public static function codeFor(string $lang): ?string 'catalano' => 'cat', 'català' => 'cat', 'catalan' => 'cat', 'ca' => 'cat', 'cat' => 'cat', ]; $key = mb_strtolower(trim($lang)); - return $map[$key] ?? null; + if (isset($map[$key])) { + return $map[$key]; + } + // Every name nameFor() stores (Русский, 中文, Suomi, Latina...) maps + // back: NAMES lists the B form of a B/T pair first. + foreach (self::NAMES as $code => $name) { + if (mb_strtolower($name) === $key) { + return $code; + } + } + return null; } /** diff --git a/app/Support/Marc/MarcXmlReader.php b/app/Support/Marc/MarcXmlReader.php index e1047daf1..b31384b5f 100644 --- a/app/Support/Marc/MarcXmlReader.php +++ b/app/Support/Marc/MarcXmlReader.php @@ -49,25 +49,41 @@ public function getIterator(): \Generator $previous = libxml_use_internal_errors(true); $position = 0; try { + // libxml's error buffer is global: only this reader's errors may + // decide that the file is not well-formed. + libxml_clear_errors(); + /** @var list<\LibXMLError> $own */ + $own = []; $more = $this->reader->read(); while ($more) { if ($this->reader->nodeType === \XMLReader::ELEMENT && $this->reader->localName === 'record') { $node = @$this->reader->expand(); if ($node instanceof \DOMElement && self::isMarcRecord($node)) { $position++; + // Keep what the reader itself reported so far (it parses + // ahead), then let the consumer run between the yields. + $own = array_merge($own, libxml_get_errors()); try { yield $position => self::toRecord($node); } catch (MarcParseException $e) { yield $position => new MarcReadError($position, $e->getMessage(), MarcReadError::excerptOf((string) $node->textContent)); } + // The consumer ran between the yields (a mapper may + // parse XML of its own): drop what it left behind. + libxml_clear_errors(); $more = $this->reader->next(); continue; } } $more = $this->reader->read(); } - $error = libxml_get_last_error(); - if ($error !== false && $error->level >= LIBXML_ERR_ERROR) { + $error = null; + foreach (array_merge($own, libxml_get_errors()) as $candidate) { + if ($candidate->level >= LIBXML_ERR_ERROR) { + $error = $candidate; + } + } + if ($error !== null) { yield $position + 1 => new MarcReadError( $position + 1, sprintf('XML is not well-formed at line %d: %s', $error->line, trim($error->message)) diff --git a/app/Views/admin/marc_import.php b/app/Views/admin/marc_import.php index 49a488664..865e6dd3e 100644 --- a/app/Views/admin/marc_import.php +++ b/app/Views/admin/marc_import.php @@ -410,6 +410,7 @@ function renderPreview(data) { .then((r) => r.json()) .then((data) => { if (!data.success) { showError(data.error || T.invalid); return; } + if (!data.complete && data.processed <= current) { showError(T.invalid); return; } // no progress: never loop current = data.processed; if (data.complete) { finish(data); } else { next(); } }) diff --git a/locale/da_DK.json b/locale/da_DK.json index 80df0bada..a02592bcf 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8520,9 +8520,11 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Bekræft: forfattere, forlag og eksemplarer oprettes som ved CSV-import", "Controlla l'anteprima dei primi record": "Tjek forhåndsvisningen af de første poster", "Copertina non scaricata": "Omslag ikke hentet", + "Il file caricato è incompleto: import interrotto.": "Den uploadede fil er ufuldstændig: importen er stoppet.", "Campi MARC aggiuntivi non salvati": "Yderligere MARC-felter ikke gemt", "Indice di ricerca non aggiornato": "Søgeindeks ikke opdateret", "Copy cataloging (SRU)": "Kopikatalogisering (SRU)", + "Limite di %d risultati raggiunto: catalogo non interrogato.": "Grænsen på %d resultater er nået: kataloget blev ikke søgt.", "Copy cataloging": "Kopikatalogisering", "Dal leader del record (posizione 9)": "Fra postens leader (position 9)", "es. Il nome della rosa": "f.eks. Rosens navn", diff --git a/locale/de_DE.json b/locale/de_DE.json index af7469738..80ae3aa47 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8520,9 +8520,11 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Bestätige: Autoren, Verlage und Exemplare werden wie beim CSV-Import angelegt", "Controlla l'anteprima dei primi record": "Prüfe die Vorschau der ersten Datensätze", "Copertina non scaricata": "Cover nicht heruntergeladen", + "Il file caricato è incompleto: import interrotto.": "Die hochgeladene Datei ist unvollständig: Import abgebrochen.", "Campi MARC aggiuntivi non salvati": "Zusätzliche MARC-Felder nicht gespeichert", "Indice di ricerca non aggiornato": "Suchindex nicht aktualisiert", "Copy cataloging (SRU)": "Fremddatenübernahme (SRU)", + "Limite di %d risultati raggiunto: catalogo non interrogato.": "Grenze von %d Ergebnissen erreicht: Katalog nicht abgefragt.", "Copy cataloging": "Fremddatenübernahme", "Dal leader del record (posizione 9)": "Aus dem Leader des Datensatzes (Position 9)", "es. Il nome della rosa": "z. B. Der Name der Rose", diff --git a/locale/en_US.json b/locale/en_US.json index 49351f496..5a3eb3896 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8520,9 +8520,11 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Confirm: authors, publishers and copies are created as in the CSV import", "Controlla l'anteprima dei primi record": "Check the preview of the first records", "Copertina non scaricata": "Cover not downloaded", + "Il file caricato è incompleto: import interrotto.": "The uploaded file is incomplete: import stopped.", "Campi MARC aggiuntivi non salvati": "Additional MARC fields not saved", "Indice di ricerca non aggiornato": "Search index not updated", "Copy cataloging (SRU)": "Copy cataloguing (SRU)", + "Limite di %d risultati raggiunto: catalogo non interrogato.": "Limit of %d results reached: catalogue not queried.", "Copy cataloging": "Copy cataloguing", "Dal leader del record (posizione 9)": "From the record leader (position 9)", "es. Il nome della rosa": "e.g. The Name of the Rose", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index ff71bef48..9df92ebfe 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8520,9 +8520,11 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Confirmez : auteurs, éditeurs et exemplaires sont créés comme dans l'import CSV", "Controlla l'anteprima dei primi record": "Vérifiez l'aperçu des premières notices", "Copertina non scaricata": "Couverture non téléchargée", + "Il file caricato è incompleto: import interrotto.": "Le fichier envoyé est incomplet : import interrompu.", "Campi MARC aggiuntivi non salvati": "Champs MARC supplémentaires non enregistrés", "Indice di ricerca non aggiornato": "Index de recherche non mis à jour", "Copy cataloging (SRU)": "Catalogage par dérivation (SRU)", + "Limite di %d risultati raggiunto: catalogo non interrogato.": "Limite de %d résultats atteinte : catalogue non interrogé.", "Copy cataloging": "Catalogage par dérivation", "Dal leader del record (posizione 9)": "D'après le guide de la notice (position 9)", "es. Il nome della rosa": "ex. Le Nom de la rose", diff --git a/locale/it_IT.json b/locale/it_IT.json index 2501e6d0b..0f0399787 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8520,9 +8520,11 @@ "Conferma: autori, editori e copie vengono creati come nell'import CSV": "Conferma: autori, editori e copie vengono creati come nell'import CSV", "Controlla l'anteprima dei primi record": "Controlla l'anteprima dei primi record", "Copertina non scaricata": "Copertina non scaricata", + "Il file caricato è incompleto: import interrotto.": "Il file caricato è incompleto: import interrotto.", "Campi MARC aggiuntivi non salvati": "Campi MARC aggiuntivi non salvati", "Indice di ricerca non aggiornato": "Indice di ricerca non aggiornato", "Copy cataloging (SRU)": "Copy cataloging (SRU)", + "Limite di %d risultati raggiunto: catalogo non interrogato.": "Limite di %d risultati raggiunto: catalogo non interrogato.", "Copy cataloging": "Copy cataloging", "Dal leader del record (posizione 9)": "Dal leader del record (posizione 9)", "es. Il nome della rosa": "es. Il nome della rosa", diff --git a/storage/plugins/z39-server/classes/CopyCataloging.php b/storage/plugins/z39-server/classes/CopyCataloging.php index 58fbde54b..3d0870977 100644 --- a/storage/plugins/z39-server/classes/CopyCataloging.php +++ b/storage/plugins/z39-server/classes/CopyCataloging.php @@ -67,7 +67,9 @@ public function __construct( private array $configured, private bool $sbnEnabled, private bool $localServerEnabled, - private int $timeout = 15 + private int $timeout = 15, + /** @var (\Closure(array): SruClient)|null test seam: the client for one server */ + private ?\Closure $clientFactory = null ) { } @@ -137,13 +139,19 @@ public function search(array $serverKeys, string $index, string $term, int $max) continue; } $server = $servers[$key]; + if (count($stored) >= self::MAX_RESULTS) { + // The results are full: the server is listed as not queried, + // not silently dropped from the report. + $report[] = ['key' => $key, 'name' => (string) $server['name'], 'total' => 0, 'count' => 0, 'error' => sprintf(__('Limite di %d risultati raggiunto: catalogo non interrogato.'), self::MAX_RESULTS)]; + continue; + } $answer = $key === 'sbn' ? $this->searchSbn($index, $term, $max) : $this->client($server)->searchServer($server, SruClient::cqlFor($server, $index, $term), $max); $report[] = ['key' => $key, 'name' => (string) $server['name'], 'total' => $answer['total'], 'count' => count($answer['records']), 'error' => $answer['error']]; foreach ($answer['records'] as $n => $record) { if (count($stored) >= self::MAX_RESULTS) { - break 2; + break; } $id = substr(hash('sha256', $key . '|' . $n . '|' . json_encode($record['data'])), 0, 16); $data = (array) $record['data']; @@ -250,6 +258,9 @@ public function import(array $ids, string $mode, bool $covers): array /** @param array $server */ private function client(array $server): SruClient { + if ($this->clientFactory !== null) { + return ($this->clientFactory)($server); + } $client = new LocalSruClient([$server], $this->db, $this->pluginId); $client->setOptions(['timeout' => $this->timeout]); return $client; diff --git a/storage/plugins/z39-server/classes/LocalSruClient.php b/storage/plugins/z39-server/classes/LocalSruClient.php index dcf0f197a..fb2c4b0a9 100644 --- a/storage/plugins/z39-server/classes/LocalSruClient.php +++ b/storage/plugins/z39-server/classes/LocalSruClient.php @@ -30,7 +30,7 @@ public static function isLocalUrl(string $url): bool return false; } return strcasecmp((string) $own['host'], (string) $target['host']) === 0 - && (int) ($own['port'] ?? 0) === (int) ($target['port'] ?? 0) + && \App\Support\Marc\MarcCoverFetcher::effectivePort($own) === \App\Support\Marc\MarcCoverFetcher::effectivePort($target) && rtrim((string) ($own['path'] ?? ''), '/') === rtrim((string) ($target['path'] ?? ''), '/'); } diff --git a/storage/plugins/z39-server/classes/SruClient.php b/storage/plugins/z39-server/classes/SruClient.php index 359d77b58..1fc0729d5 100644 --- a/storage/plugins/z39-server/classes/SruClient.php +++ b/storage/plugins/z39-server/classes/SruClient.php @@ -92,10 +92,10 @@ public function searchByIsbn(string $isbn): ?array /** * The CQL query for one search on one server: the server's own index name - * when its configuration maps one (`indexes`), else the standard one. A - * term with spaces or a server that wants it (`quote_search_terms`) is - * quoted, backslash-escaping embedded quotes per the CQL specification; - * an ISBN or ISSN is reduced to its digits. + * when its configuration maps one (`indexes`), else the standard one. + * Text terms are always quoted, backslash-escaping embedded quotes per the + * CQL specification; an ISBN or ISSN is reduced to its digits and sent + * bare unless the server wants it quoted (`quote_search_terms`). * * @param array $server */ @@ -111,7 +111,10 @@ public static function cqlFor(array $server, string $index, string $term): strin if ($cqlIndex === '') { $cqlIndex = self::DEFAULT_CQL_INDEXES[$index]; } - $quote = !empty($server['quote_search_terms']) || preg_match('/[\s"()=<>\/]/u', $term) === 1; + // Words are always quoted: a bare term with an apostrophe (dell'arte) + // or equal to a CQL keyword (and, or, not) is not valid CQL. Only the + // digits of an ISBN or ISSN go bare, unless the server asks otherwise. + $quote = !in_array($index, ['isbn', 'issn'], true) || !empty($server['quote_search_terms']); $value = $quote ? '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $term) . '"' : $term; return $cqlIndex . '=' . $value; } diff --git a/tests/copy-cataloging-limits.unit.php b/tests/copy-cataloging-limits.unit.php new file mode 100644 index 000000000..48caca7f3 --- /dev/null +++ b/tests/copy-cataloging-limits.unit.php @@ -0,0 +1,89 @@ +set_charset('utf8mb4'); + $_SESSION = []; + + $queried = []; + $factory = static function (array $server) use (&$queried): SruClient { + return new class ([$server], $queried) extends SruClient { + /** @param list $log */ + public function __construct(array $servers, private array &$log) + { + parent::__construct($servers); + } + + public function searchServer(array $server, string $cql, int $max = 10): array + { + $this->log[] = (string) $server['name']; + $records = []; + for ($i = 1; $i <= $max; $i++) { + $records[] = ['data' => ['titolo' => "ZZCC {$server['name']} {$i}"], 'flavour' => 'marc21', 'marc' => null]; + } + return ['total' => 500, 'records' => $records, 'error' => null]; + } + }; + }; + $configured = []; + for ($n = 1; $n <= 6; $n++) { + $configured[] = ['name' => "Server {$n}", 'url' => "https://sru{$n}.example.org/sru", 'enabled' => true]; + } + $cc = new CopyCataloging($db, null, $configured, false, false, 15, $factory); + $keys = array_map(static fn (int $i): string => 'cfg:' . $i, range(0, 5)); + $answer = $cc->search($keys, 'title', 'x', 25); + + echo "Result cap\n"; + $check(count($answer['results']) === 100, '04 results stop at the cap of 100'); + $check(count($answer['servers']) === 6, '05 every chosen server is in the report, also those after the cap'); + $check($queried === ['Server 1', 'Server 2', 'Server 3', 'Server 4'], '06 servers after the cap are not queried'); + $notQueried = array_values(array_filter($answer['servers'], static fn (array $r): bool => $r['error'] !== null)); + $check(count($notQueried) === 2 && $notQueried[0]['name'] === 'Server 5' && str_contains((string) $notQueried[0]['error'], '100'), '07 they carry an explicit "limit reached" error'); +} + +echo "\n{$pass} passed, {$fail} failed\n"; +exit($fail === 0 ? 0 : 1); diff --git a/tests/marc-bulk-import.spec.js b/tests/marc-bulk-import.spec.js index c4c5477b6..3c48751fc 100644 --- a/tests/marc-bulk-import.spec.js +++ b/tests/marc-bulk-import.spec.js @@ -3,7 +3,9 @@ // Files are generated per run (tests/helpers/marc-fixtures.php), so titles and // ISBNs never collide with an earlier run; afterAll removes what the run made. const { test, expect } = require('@playwright/test'); -const { BASE, dbQuery, sqlEscape, loginAsAdmin, marcFile, cleanupRun, uploadForPreview, confirmImport } = require('./helpers/marc-e2e'); +const fs = require('fs'); +const path = require('path'); +const { BASE, ROOT, dbQuery, sqlEscape, loginAsAdmin, marcFile, cleanupRun, uploadForPreview, confirmImport } = require('./helpers/marc-e2e'); const RUN = 'MI' + Date.now().toString(36); const SKIP = !process.env.E2E_ADMIN_EMAIL || !process.env.E2E_DB_NAME; @@ -55,6 +57,27 @@ test.describe.serial('MARC bulk import', () => { await expect(page.locator('main, body').first()).toContainText(`Libro MARC ${RUN} 07`, { timeout: 15000 }); }); + test('a staged file that ends early stops the import with an error instead of looping', async ({ page }) => { + test.setTimeout(120000); + await loginAsAdmin(page); + const uploads = path.join(ROOT, 'writable', 'uploads'); + const staged = () => fs.readdirSync(uploads).filter((n) => n.startsWith('marc_import_') && n.endsWith('.jsonl')) + .map((n) => path.join(uploads, n)).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); + const upload = await uploadForPreview(page, marcFile(RUN + 'T', 20, 'mrc')); + expect(upload.total_rows).toBe(20); + // Cut the staged copy to its first three records, as a full disk or a + // cleanup run would leave it. + const file = staged()[0]; + fs.writeFileSync(file, fs.readFileSync(file, 'utf-8').split('\n').slice(0, 3).join('\n') + '\n'); + let chunks = 0; + page.on('request', (r) => { if (r.url().includes('/admin/books/import/marc/chunk')) chunks++; }); + await page.click('#confirmImportBtn'); + await expect(page.locator('.swal2-popup')).toBeVisible({ timeout: 30000 }); + await expect(page.locator('.swal2-popup')).toContainText('incompleto'); + expect(chunks, 'the page stops asking for chunks').toBeLessThanOrEqual(2); + expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE '%${RUN}T%' AND deleted_at IS NULL`)).toBe('3'); + }); + test('a MARCXML collection imports the same way', async ({ page }) => { test.setTimeout(90000); await loginAsAdmin(page); diff --git a/tests/marc-copy-cataloging.spec.js b/tests/marc-copy-cataloging.spec.js index c37944ffa..dd6bd5782 100644 --- a/tests/marc-copy-cataloging.spec.js +++ b/tests/marc-copy-cataloging.spec.js @@ -14,6 +14,8 @@ const SKIP = !process.env.E2E_ADMIN_EMAIL || !process.env.E2E_DB_NAME; let coverFile = null; let createdCovers = []; let isbn02 = ''; +/** import_logs rows written by this run's copy-cataloguing imports (named after the servers, not the run). */ +const importIds = []; async function openPage(page) { await page.goto(`${BASE}/admin/books/copy-cataloging`); @@ -38,6 +40,7 @@ async function importSelected(page, duplicates = 'skip') { const answered = page.waitForResponse((r) => r.url().includes('/admin/books/copy-cataloging/import')); await page.click('#copyImportBtn'); const json = await (await answered).json(); + if (json && json.import_id) importIds.push(String(json.import_id)); await expect(page.locator('#copy-import-result')).toBeVisible(); return json; } @@ -45,6 +48,9 @@ async function importSelected(page, duplicates = 'skip') { test.describe.serial('Copy cataloging', () => { test.skip(SKIP, 'E2E credentials not set'); test.afterAll(() => { + if (importIds.length) { + dbQuery(`DELETE FROM import_logs WHERE import_id IN (${importIds.map((id) => `'${sqlEscape(id)}'`).join(',')})`); + } cleanupRun(RUN); for (const f of [coverFile, ...createdCovers]) if (f && fs.existsSync(f)) fs.unlinkSync(f); }); diff --git a/tests/marc-library.unit.php b/tests/marc-library.unit.php index 03be0b49c..2a171d876 100644 --- a/tests/marc-library.unit.php +++ b/tests/marc-library.unit.php @@ -228,5 +228,77 @@ $value = (string) $reparsed->subfield('520', 'a'); $check(mb_check_encoding($value, 'UTF-8') && strlen($value) <= 9999 && strlen($value) > 9000, '67 a field over 9999 bytes is cut on a character boundary'); +echo "Encoding of ISO 2709 records\n"; +// A record labelled UTF-8 (leader/09 = a) whose bytes are MARC-8: the title +// "Caffe" with ANSEL 0xE2 (acute) before the last letter, as a MARC-8 catalogue writes it. +$mislabelled = new MarcRecord(); +$mislabelled->addDataField('245', '0', '0', [['a', 'CaffXe']]); +$raw = substr(Iso2709Writer::serialize($mislabelled), 0, -1); +$raw[9] = 'a'; +$raw = str_replace('CaffXe', "Caff\xE2e", $raw); +$threw = static function (callable $fn): bool { + try { + $fn(); + return false; + } catch (MarcParseException) { + return true; + } +}; +$check($threw(static fn () => Iso2709Reader::parse($raw)), '68 auto: leader/09 = a with invalid UTF-8 is reported, not passed through'); +$check($threw(static fn () => Iso2709Reader::parse($raw, Iso2709Reader::ENCODING_UTF8)), '69 forced UTF-8 refuses invalid UTF-8'); +$asMarc8 = null; +try { + $asMarc8 = Iso2709Reader::parse($raw, Iso2709Reader::ENCODING_MARC8); +} catch (MarcParseException) { +} +$check($asMarc8 !== null && $asMarc8->subfield('245', 'a') === 'Caffé', '70 forced MARC-8 reads a record mislabelled leader/09 = a'); +$good = new MarcRecord(); +$good->addDataField('245', '0', '0', [['a', 'Caffè']]); +$check(Iso2709Reader::parse(substr(Iso2709Writer::serialize($good), 0, -1))->subfield('245', 'a') === 'Caffè', '71 auto: valid UTF-8 labelled a is read as is'); + +echo "Export selection and oversized records\n"; +$check(\App\Controllers\MarcExportController::parseIds('12abc, 7,7, 0,-3, 9 ,x') === [7, 9], '72 export ids: only all-digit tokens, positive, unique ("12abc" is not book 12)'); +$huge = ['id' => 1, 'titolo' => 'T', 'descrizione' => str_repeat('parola ', 3000), + 'parole_chiave' => implode(', ', array_map(static fn (int $i): string => 'soggetto ' . str_repeat('x', 200) . " {$i}", range(1, 600)))]; +$check($threwOverflow = (static function () use ($huge): bool { + try { + Iso2709Writer::serialize(BookMarcBuilder::marc21($huge, [], [], null, true)); + return false; + } catch (\OverflowException) { + return true; + } +})(), '73 a book with huge free texts does not fit one ISO 2709 record'); +$fits = true; +try { + Iso2709Writer::serialize(BookMarcBuilder::marc21(\App\Controllers\MarcExportController::shortenTexts($huge), [], [], null, true)); +} catch (\OverflowException) { + $fits = false; +} +$check($fits, '74 with its long texts shortened the same book is exported instead of dropped'); + +echo "Language names round trip\n"; +$roundTrip = true; +foreach (['ita', 'eng', 'fre', 'ger', 'rus', 'chi', 'jpn', 'fin', 'gre', 'lat', 'ice', 'cze', 'hun', 'rum', 'tur', 'heb', 'kor', 'tha', 'grc', 'nor'] as $code) { + $back = \App\Support\Marc\MarcLanguage::codeFor(\App\Support\Marc\MarcLanguage::nameFor($code)); + if ($back !== $code) { + $roundTrip = false; + echo " {$code} -> " . var_export($back, true) . "\n"; + } +} +$check($roundTrip, '75 every stored language name maps back to its MARC code (import -> export keeps the language)'); +$check(\App\Support\Marc\MarcLanguage::marcCodeOf('Русский') === 'rus' && \App\Support\Marc\MarcLanguage::codeFor('Klingon') === null, '76 a native name is found, an unknown one stays unknown'); + +echo "MARCXML reader and the global libxml error buffer\n"; +$xmlDoc = '' + . str_repeat('00000nam a2200000 a 4500Uno', 2) + . ''; +$items = []; +foreach (MarcXmlReader::fromString($xmlDoc) as $item) { + $items[] = $item; + // A consumer that parses broken XML of its own between two records. + @(new DOMDocument())->loadXML(''); +} +$check(count($items) === 2 && !array_filter($items, static fn ($i): bool => $i instanceof MarcReadError), '77 a well-formed file stays error-free when the consumer leaves libxml errors behind'); + echo "\n{$pass} PASS, {$fail} FAIL\n"; exit($fail === 0 ? 0 : 1); diff --git a/tests/sru-search.unit.php b/tests/sru-search.unit.php index 641e9080a..f3e18ca33 100644 --- a/tests/sru-search.unit.php +++ b/tests/sru-search.unit.php @@ -14,6 +14,13 @@ $root = dirname(__DIR__); require $root . '/vendor/autoload.php'; require_once $root . '/storage/plugins/z39-server/classes/SruClient.php'; +require_once $root . '/storage/plugins/z39-server/classes/Exceptions/CQLException.php'; +require_once $root . '/storage/plugins/z39-server/classes/Exceptions/DatabaseException.php'; +require_once $root . '/storage/plugins/z39-server/classes/Exceptions/InvalidCQLSyntaxException.php'; +require_once $root . '/storage/plugins/z39-server/classes/Exceptions/SRUQueryException.php'; +require_once $root . '/storage/plugins/z39-server/classes/Exceptions/UnsupportedIndexException.php'; +require_once $root . '/storage/plugins/z39-server/classes/Exceptions/UnsupportedRelationException.php'; +require_once $root . '/storage/plugins/z39-server/classes/CQLParser.php'; $pass = 0; $fail = 0; @@ -52,10 +59,22 @@ protected function fetchUrl(string $url): ?string echo "CQL\n"; $check(SruClient::cqlFor($server, 'title', 'il nome della rosa') === 'bib.title="il nome della rosa"', '01 the server\'s own index, a multi-word term quoted'); -$check(SruClient::cqlFor($server, 'author', 'Eco') === 'dc.creator=Eco', '02 the standard index when the server maps none'); +$check(SruClient::cqlFor($server, 'author', 'Eco') === 'dc.creator="Eco"', '02 the standard index when the server maps none'); $check(SruClient::cqlFor(['indexes' => ['isbn' => 'alma.isbn']], 'isbn', '978-88-452-9261-3') === 'alma.isbn=9788845292613', '03 an ISBN is reduced to its digits'); $check(SruClient::cqlFor([], 'any', 'say "hi"') === 'cql.serverChoice="say \\"hi\\""', '04 embedded quotes are escaped'); -$check(SruClient::cqlFor([], 'bogus', 'x') === 'cql.serverChoice=x', '05 an unknown index falls back to all fields'); +$check(SruClient::cqlFor([], 'bogus', 'x') === 'cql.serverChoice="x"', '05 an unknown index falls back to all fields'); +$check(SruClient::cqlFor([], 'title', "dell'arte") === 'dc.title="dell\'arte"', '05b a word with an apostrophe is quoted'); +$check(SruClient::cqlFor([], 'title', 'and') === 'dc.title="and"', '05c a CQL keyword used as a word is quoted'); +$check(SruClient::cqlFor(['quote_search_terms' => true], 'isbn', '88-452-9261-3') === 'bath.isbn="8845292613"', '05d an ISBN is quoted only when the server asks'); +foreach (["dell'arte", 'and', 'not', 'Eco'] as $word) { + $parsed = true; + try { + (new \Z39Server\CQLParser())->parse(SruClient::cqlFor([], 'title', $word)); + } catch (Throwable) { + $parsed = false; + } + $check($parsed, "05e the local CQL parser accepts the query for '{$word}'"); +} echo "searchServer\n"; $client = new SearchFixtureClient([$server], $sru); From eb8581c9fa1ba2e38c2719dddf95bd5907d7b7ac Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:24:03 +0100 Subject: [PATCH 041/398] Schema for the visitor log, suppliers and RDA overrides (0.9.0) Adds the presenze and fornitori tables, libri.fornitore_id with an ON DELETE SET NULL foreign key and the three nullable RDA override columns, both in schema.sql and as the delimited minor-features section of migrate_0.9.0.sql. The migration runner test executes every migration-0.9.0- section test so stacked branches only add their own file. --- .../database/migrations/migrate_0.9.0.sql | 73 ++++++++++++ installer/database/schema.sql | 42 +++++++ tests/migration-0.9.0-minor-features.unit.php | 112 ++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 tests/migration-0.9.0-minor-features.unit.php diff --git a/installer/database/migrations/migrate_0.9.0.sql b/installer/database/migrations/migrate_0.9.0.sql index 8fd9c193b..a8822d5fc 100644 --- a/installer/database/migrations/migrate_0.9.0.sql +++ b/installer/database/migrations/migrate_0.9.0.sql @@ -69,3 +69,76 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- ── end MARC bulk import / copy cataloguing ── + +-- ── minor features: visitor log, suppliers, RDA overrides ── +-- presenze: one row per library visit (member card scan or guest). +-- fornitori + libri.fornitore_id: the supplier a book was acquired from. +-- libri.rda_*_type: optional RDA content/media/carrier overrides (NULL = derived from tipo_media). +CREATE TABLE IF NOT EXISTS `fornitori` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `referente` varchar(150) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `telefono` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sito_web` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `indirizzo` text COLLATE utf8mb4_unicode_ci, + `partita_iva` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `note` text COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_fornitori_nome` (`nome`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Suppliers books are acquired from'; + +CREATE TABLE IF NOT EXISTS `presenze` ( + `id` int NOT NULL AUTO_INCREMENT, + `utente_id` int DEFAULT NULL, + `sede_id` int DEFAULT NULL, + `tipo` enum('socio','ospite') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ospite', + `nome_ospite` varchar(150) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `motivo` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_presenze_created` (`created_at`), + KEY `idx_presenze_utente` (`utente_id`), + KEY `idx_presenze_sede` (`sede_id`), + CONSTRAINT `fk_presenze_utente` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_presenze_sede` FOREIGN KEY (`sede_id`) REFERENCES `sedi` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Visitor log: one row per library visit (member card scan or guest)'; + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND COLUMN_NAME = 'fornitore_id'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE `libri` ADD COLUMN `fornitore_id` int DEFAULT NULL COMMENT ''Supplier the copy was acquired from (fornitori)'' AFTER `editore_id`', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND INDEX_NAME = 'idx_libri_fornitore'); +SET @sql = IF(@idx_exists = 0, 'ALTER TABLE `libri` ADD KEY `idx_libri_fornitore` (`fornitore_id`)', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND CONSTRAINT_NAME = 'fk_libri_fornitore' AND CONSTRAINT_TYPE = 'FOREIGN KEY'); +SET @sql = IF(@fk_exists = 0, 'ALTER TABLE `libri` ADD CONSTRAINT `fk_libri_fornitore` FOREIGN KEY (`fornitore_id`) REFERENCES `fornitori` (`id`) ON DELETE SET NULL', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND COLUMN_NAME = 'rda_content_type'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE `libri` ADD COLUMN `rda_content_type` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''RDA content type override (LC code), NULL = derived from tipo_media'' AFTER `tipo_media`', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND COLUMN_NAME = 'rda_media_type'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE `libri` ADD COLUMN `rda_media_type` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''RDA media type override (LC code), NULL = derived from tipo_media'' AFTER `rda_content_type`', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND COLUMN_NAME = 'rda_carrier_type'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE `libri` ADD COLUMN `rda_carrier_type` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''RDA carrier type override (LC code), NULL = derived from tipo_media'' AFTER `rda_media_type`', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; +-- ── end minor features ── diff --git a/installer/database/schema.sql b/installer/database/schema.sql index 942017f48..f6ce4777a 100755 --- a/installer/database/schema.sql +++ b/installer/database/schema.sql @@ -211,6 +211,42 @@ CREATE TABLE `editori` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fornitori` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `referente` varchar(150) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `telefono` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sito_web` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `indirizzo` text COLLATE utf8mb4_unicode_ci, + `partita_iva` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `note` text COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_fornitori_nome` (`nome`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Suppliers books are acquired from'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `presenze` ( + `id` int NOT NULL AUTO_INCREMENT, + `utente_id` int DEFAULT NULL, + `sede_id` int DEFAULT NULL, + `tipo` enum('socio','ospite') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ospite', + `nome_ospite` varchar(150) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `motivo` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_presenze_created` (`created_at`), + KEY `idx_presenze_utente` (`utente_id`), + KEY `idx_presenze_sede` (`sede_id`), + CONSTRAINT `fk_presenze_utente` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_presenze_sede` FOREIGN KEY (`sede_id`) REFERENCES `sedi` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Visitor log: one row per library visit (member card scan or guest)'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `email_templates` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, @@ -420,6 +456,9 @@ CREATE TABLE `libri` ( `catalog_author_sort` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Derived surname/sort key for catalog author ordering', `formato` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT 'cartaceo', `tipo_media` enum('libro','disco','audiolibro','dvd','altro') NOT NULL DEFAULT 'libro', + `rda_content_type` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'RDA content type override (LC code), NULL = derived from tipo_media', + `rda_media_type` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'RDA media type override (LC code), NULL = derived from tipo_media', + `rda_carrier_type` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'RDA carrier type override (LC code), NULL = derived from tipo_media', `peso` float DEFAULT NULL, `dimensioni` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `physical_description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Physical description (LibraryThing)', @@ -430,6 +469,7 @@ CREATE TABLE `libri` ( `copie_totali` int DEFAULT '1', `copie_disponibili` int DEFAULT '1', `editore_id` int DEFAULT NULL, + `fornitore_id` int DEFAULT NULL COMMENT 'Supplier the copy was acquired from (fornitori)', `source` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Source/vendor (LibraryThing)', `from_where` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'From where acquired (LibraryThing)', `lending_patron` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Current lending patron (LibraryThing)', @@ -468,6 +508,7 @@ CREATE TABLE `libri` ( KEY `posizione_id` (`posizione_id`), KEY `idx_libri_titolo_sottotitolo` (`titolo`,`sottotitolo`), KEY `editore_id` (`editore_id`), + KEY `idx_libri_fornitore` (`fornitore_id`), KEY `idx_libri_stato` (`stato`), KEY `idx_libri_tipo_media_deleted_at` (`deleted_at`,`tipo_media`), KEY `fk_libri_mensola` (`mensola_id`), @@ -491,6 +532,7 @@ CREATE TABLE `libri` ( CONSTRAINT `fk_libri_mensola` FOREIGN KEY (`mensola_id`) REFERENCES `mensole` (`id`) ON DELETE SET NULL, CONSTRAINT `fk_libri_scaffale` FOREIGN KEY (`scaffale_id`) REFERENCES `scaffali` (`id`) ON DELETE SET NULL, CONSTRAINT `libri_ibfk_1` FOREIGN KEY (`editore_id`) REFERENCES `editori` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_libri_fornitore` FOREIGN KEY (`fornitore_id`) REFERENCES `fornitori` (`id`) ON DELETE SET NULL, CONSTRAINT `libri_ibfk_3` FOREIGN KEY (`genere_id`) REFERENCES `generi` (`id`) ON DELETE SET NULL, CONSTRAINT `fk_libri_sottogenere` FOREIGN KEY (`sottogenere_id`) REFERENCES `generi` (`id`) ON DELETE SET NULL, CONSTRAINT `libri_ibfk_5` FOREIGN KEY (`posizione_id`) REFERENCES `posizioni` (`id`) ON DELETE SET NULL, diff --git a/tests/migration-0.9.0-minor-features.unit.php b/tests/migration-0.9.0-minor-features.unit.php new file mode 100644 index 000000000..343cf58db --- /dev/null +++ b/tests/migration-0.9.0-minor-features.unit.php @@ -0,0 +1,112 @@ + $map[$m[1]], $source) ?? ''; +$run = static function () use ($db, $source): void { + foreach (explode(";\n", $source) as $sql) { + if (trim($sql) !== '') { + if ($db->query($sql) === false) { + throw new RuntimeException('FAIL statement errored: ' . $db->error . "\n" . $sql); + } + } + } +}; +$checks = 0; +$check = static function (bool $ok, string $label) use (&$checks): void { + if (!$ok) { throw new RuntimeException('FAIL ' . $label); } + $checks++; echo "OK {$label}\n"; +}; +$col = static function (string $table, string $column) use ($db): ?array { + $stmt = $db->prepare('SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'); + $stmt->bind_param('ss', $table, $column); + $stmt->execute(); + return $stmt->get_result()->fetch_assoc() ?: null; +}; +$tableExists = static fn (string $t): bool => (int) $db->query("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$t}'")->fetch_row()[0] === 1; +$colOrder = static fn (string $t): string => (string) $db->query("SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$t}'")->fetch_row()[0]; + +$L = $map['libri']; +$U = $map['utenti']; +$S = $map['sedi']; +$P = $map['presenze']; +$F = $map['fornitori']; +try { + // 0.8.9 shape of the columns the section touches (plus neighbours for AFTER). + $db->query("CREATE TABLE {$U} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(100) NOT NULL) ENGINE=InnoDB"); + $db->query("CREATE TABLE {$S} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(255) NOT NULL) ENGINE=InnoDB"); + $db->query("CREATE TABLE {$L} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, titolo VARCHAR(255) NOT NULL, tipo_media ENUM('libro','disco','audiolibro','dvd','altro') NOT NULL DEFAULT 'libro', peso FLOAT DEFAULT NULL, editore_id INT DEFAULT NULL, `source` VARCHAR(255) DEFAULT NULL, deleted_at DATETIME DEFAULT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $db->query("INSERT INTO {$U} (id, nome) VALUES (1, 'Mario')"); + $db->query("INSERT INTO {$L} (id, titolo, tipo_media, editore_id) VALUES (1, 'Uno', 'libro', NULL), (2, 'Due', 'dvd', 7)"); + $check(!$tableExists($P) && !$tableExists($F) && $col($L, 'fornitore_id') === null, 'the pre-upgrade schema has no visitor log, suppliers or supplier column'); + + $run(); + + $check($tableExists($P) && $tableExists($F), 'presenze and fornitori are created'); + $f = $col($L, 'fornitore_id'); + $check($f !== null && $f['DATA_TYPE'] === 'int' && $f['IS_NULLABLE'] === 'YES', 'libri.fornitore_id is a nullable INT'); + foreach (['rda_content_type', 'rda_media_type', 'rda_carrier_type'] as $c) { + $r = $col($L, $c); + $check($r !== null && $r['DATA_TYPE'] === 'varchar' && (int) $r['CHARACTER_MAXIMUM_LENGTH'] === 10 && $r['IS_NULLABLE'] === 'YES', "libri.{$c} is a nullable VARCHAR(10)"); + } + $check($colOrder($L) === 'id,titolo,tipo_media,rda_content_type,rda_media_type,rda_carrier_type,peso,editore_id,fornitore_id,source,deleted_at', 'new libri columns sit where schema.sql puts them'); + $fk = $db->query("SELECT DELETE_RULE, REFERENCED_TABLE_NAME FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = '{$map['fk_libri_fornitore']}'")->fetch_assoc(); + $check(is_array($fk) && $fk['DELETE_RULE'] === 'SET NULL' && $fk['REFERENCED_TABLE_NAME'] === $F, 'libri.fornitore_id references fornitori ON DELETE SET NULL'); + $check($db->query("SELECT GROUP_CONCAT(CONCAT(id,':',titolo,':',tipo_media,':',IFNULL(editore_id,'-'),':',IFNULL(fornitore_id,'-'),':',IFNULL(rda_carrier_type,'-')) ORDER BY id) FROM {$L}")->fetch_row()[0] === '1:Uno:libro:-:-:-,2:Due:dvd:7:-:-', 'existing books are preserved, with no supplier and no RDA override (zero-config)'); + + // Behaviour: the FK nulls the link, the visitor log keeps anonymous counts. + $db->query("INSERT INTO {$F} (id, nome) VALUES (5, 'Libreria Rossi')"); + $db->query("UPDATE {$L} SET fornitore_id = 5, rda_carrier_type = 'cr' WHERE id = 2"); + $db->query("INSERT INTO {$P} (utente_id, tipo, created_at) VALUES (1, 'socio', '2026-10-01 10:00:00')"); + $db->query("INSERT INTO {$P} (tipo, nome_ospite) VALUES ('ospite', NULL)"); + $check($db->query("SELECT tipo FROM {$P} WHERE utente_id IS NULL")->fetch_row()[0] === 'ospite', 'a guest visit needs no user'); + $db->query("DELETE FROM {$F} WHERE id = 5"); + $check($db->query("SELECT fornitore_id FROM {$L} WHERE id = 2")->fetch_row()[0] === null, 'deleting a supplier sets the book link to NULL'); + $db->query("DELETE FROM {$U} WHERE id = 1"); + $check((int) $db->query("SELECT COUNT(*) FROM {$P} WHERE tipo = 'socio' AND utente_id IS NULL")->fetch_row()[0] === 1, 'deleting a member keeps the visit, anonymised'); + $check((string) $db->query("SELECT rda_carrier_type FROM {$L} WHERE id = 2")->fetch_row()[0] === 'cr', 'an RDA override is stored'); + + $before = $colOrder($L) . '|' . $colOrder($P) . '|' . $colOrder($F); + $run(); + $check($colOrder($L) . '|' . $colOrder($P) . '|' . $colOrder($F) === $before, 'a second run changes no column'); + $check((int) $db->query("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = '{$L}' AND CONSTRAINT_TYPE = 'FOREIGN KEY'")->fetch_row()[0] === 1, 'a second run adds no duplicate foreign key'); + $check((int) $db->query("SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$L}' AND INDEX_NAME = 'idx_libri_fornitore'")->fetch_row()[0] === 1, 'a second run adds no duplicate index'); + $check((int) $db->query("SELECT COUNT(*) FROM {$P}")->fetch_row()[0] === 2 && (string) $db->query("SELECT rda_carrier_type FROM {$L} WHERE id = 2")->fetch_row()[0] === 'cr', 'a second run keeps the data written in between'); + + $schema = (string) file_get_contents(dirname(__DIR__) . '/installer/database/schema.sql'); + $check(str_contains($schema, 'CREATE TABLE `presenze`') && str_contains($schema, 'CREATE TABLE `fornitori`') + && str_contains($schema, '`fornitore_id` int DEFAULT NULL') && str_contains($schema, 'CONSTRAINT `fk_libri_fornitore`') + && str_contains($schema, '`rda_content_type` varchar(10)') && str_contains($schema, '`rda_media_type` varchar(10)') && str_contains($schema, '`rda_carrier_type` varchar(10)'), 'fresh install (schema.sql) has the same tables and columns'); + $version = json_decode((string) file_get_contents(dirname(__DIR__) . '/version.json'), true)['version']; + $check(version_compare('0.9.0', $version, '<='), 'the release includes the migration (0.9.0 <= version.json)'); + echo "ALL {$checks} PASS\n"; +} finally { + $db->query('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([$P, $L, $F, $S, $U] as $t) { + $db->query("DROP TABLE IF EXISTS {$t}"); + } + $db->query('SET FOREIGN_KEY_CHECKS = 1'); +} From c56110e2ff48ca2f72b50808895a278362b3b245 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:30:57 +0100 Subject: [PATCH 042/398] Suppliers and RDA content/media/carrier overrides in cataloguing Suppliers get an admin CRUD modelled on the publishers pages, a select in the book form, a filter in the book list and are unlinked (not deleted) from their books when removed. The book form shows the RDA types derived from the media type and lets a cataloguer override each one; BIBFRAME uses an override when it is set and keeps its current output otherwise. This commit also registers the routes, sidebar entries and translations of the other 0.9.0 minor features. --- app/Controllers/LibriApiController.php | 7 + app/Controllers/LibriController.php | 10 +- app/Controllers/SuppliersController.php | 101 ++++++++++ app/Models/SupplierRepository.php | 169 +++++++++++++++++ app/Routes/web.php | 50 +++++ app/Support/CataloguingExtras.php | 114 ++++++++++++ app/Support/Permissions.php | 4 +- app/Support/RdaTypes.php | 176 ++++++++++++++++++ app/Views/fornitori/form.php | 129 +++++++++++++ app/Views/fornitori/index.php | 160 ++++++++++++++++ app/Views/layout.php | 24 +++ app/Views/libri/index.php | 40 +++- app/Views/libri/partials/book_form.php | 74 ++++++++ app/Views/libri/scheda_libro.php | 27 +++ locale/da_DK.json | 111 ++++++++++- locale/de_DE.json | 109 ++++++++++- locale/en_US.json | 109 ++++++++++- locale/fr_FR.json | 109 ++++++++++- locale/it_IT.json | 109 ++++++++++- .../BibframeLinkedDataPlugin.php | 19 +- tests/helpers/minor-features-090.js | 114 ++++++++++++ tests/rda-types-090.spec.js | 114 ++++++++++++ tests/suppliers-090.spec.js | 139 ++++++++++++++ 23 files changed, 2002 insertions(+), 16 deletions(-) create mode 100644 app/Controllers/SuppliersController.php create mode 100644 app/Models/SupplierRepository.php create mode 100644 app/Support/CataloguingExtras.php create mode 100644 app/Support/RdaTypes.php create mode 100644 app/Views/fornitori/form.php create mode 100644 app/Views/fornitori/index.php create mode 100644 tests/helpers/minor-features-090.js create mode 100644 tests/rda-types-090.spec.js create mode 100644 tests/suppliers-090.spec.js diff --git a/app/Controllers/LibriApiController.php b/app/Controllers/LibriApiController.php index 27964ae12..e9d677dab 100644 --- a/app/Controllers/LibriApiController.php +++ b/app/Controllers/LibriApiController.php @@ -34,6 +34,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $anno_to = trim((string) ($q['anno_to'] ?? '')); $collana = trim((string) ($q['collana'] ?? '')); $tipo_media = trim((string) ($q['tipo_media'] ?? '')); + $fornitore_id = (int) ($q['fornitore_filter'] ?? 0); // Build WHERE clause with prepared statement parameters $where = 'WHERE l.deleted_at IS NULL '; @@ -159,6 +160,12 @@ public function list(Request $request, Response $response, mysqli $db): Response } } + if ($fornitore_id > 0 && $this->hasColumn($db, 'fornitore_id')) { + $where .= ' AND l.fornitore_id = ?'; + $params[] = $fornitore_id; + $types .= 'i'; + } + // Parse DataTables sorting parameters (with robust null checks to avoid notices) $order = $q['order'][0] ?? null; $orderColumn = isset($order['column']) ? (int) $order['column'] : 4; // Default to Info column (title) diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 7deb40804..5f257cf24 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -408,7 +408,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons } catch (\Throwable) { $copyCatalogingAvailable = false; } - $data = ['libri' => $libri, 'genreFilterName' => $genreFilterName, 'subgenreFilterName' => $subgenreFilterName]; + $data = ['libri' => $libri, 'genreFilterName' => $genreFilterName, 'subgenreFilterName' => $subgenreFilterName, 'fornitori' => (new \App\Models\SupplierRepository($db))->options()]; // extract($data); require __DIR__ . '/../Views/libri/index.php'; $content = ob_get_clean(); @@ -633,6 +633,7 @@ public function createForm(Request $request, Response $response, mysqli $db): Re $sottogeneri = $taxRepo->subgenres(); $libraryThingInstalled = \App\Support\LibraryThingInstaller::isInstalled($db); $articleHintState = \App\Support\PeriodicalArticlesHint::state($db); + $fornitori = (new \App\Models\SupplierRepository($db))->options(); ob_start(); // Variables are available in the template scope via require require __DIR__ . '/../Views/libri/crea_libro.php'; @@ -1403,6 +1404,9 @@ public function store(Request $request, Response $response, mysqli $db): Respons $this->saveLtVisibility($db, $id, $data['lt_visibility']); } + // Supplier and RDA overrides (0.9.0): only the keys the form posts. + \App\Support\CataloguingExtras::save($db, $id, $data); + // Plugin hook: After book save \App\Support\Hooks::do('book.save.after', [$id, $fields]); @@ -1498,6 +1502,7 @@ public function editForm(Request $request, Response $response, mysqli $db, int $ $sottogeneri = $taxRepo->subgenres(); $libraryThingInstalled = \App\Support\LibraryThingInstaller::isInstalled($db); $articleHintState = \App\Support\PeriodicalArticlesHint::state($db); + $fornitori = (new \App\Models\SupplierRepository($db))->options(); ob_start(); // Variables are available in the template scope via require require __DIR__ . '/../Views/libri/modifica_libro.php'; @@ -2034,6 +2039,9 @@ public function update(Request $request, Response $response, mysqli $db, int $id $this->saveLtVisibility($db, $id, $data['lt_visibility']); } + // Supplier and RDA overrides (0.9.0): only the keys the form posts. + \App\Support\CataloguingExtras::save($db, $id, $data); + // Plugin hook: After book save (update) \App\Support\Hooks::do('book.save.after', [$id, $fields]); diff --git a/app/Controllers/SuppliersController.php b/app/Controllers/SuppliersController.php new file mode 100644 index 000000000..ab6c2ff5b --- /dev/null +++ b/app/Controllers/SuppliersController.php @@ -0,0 +1,101 @@ +listWithCounts(); + return $this->render($response, 'index.php', ['fornitori' => $fornitori]); + } + + public function createForm(Request $request, Response $response): Response + { + return $this->render($response, 'form.php', ['fornitore' => null]); + } + + public function store(Request $request, Response $response, mysqli $db): Response + { + $data = SupplierRepository::normalize((array) $request->getParsedBody()); + if ($data['nome'] === null) { + $_SESSION['error_message'] = __('Il nome del fornitore è obbligatorio.'); + return $response->withHeader('Location', url('/admin/suppliers/create'))->withStatus(302); + } + try { + (new SupplierRepository($db))->create($data); + $_SESSION['success_message'] = __('Fornitore creato con successo.'); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('SuppliersController::store error', ['exception' => get_class($e), 'code' => $e->getCode()]); + $_SESSION['error_message'] = __('Impossibile salvare il fornitore. Riprova più tardi.'); + } + return $response->withHeader('Location', url('/admin/suppliers'))->withStatus(302); + } + + public function editForm(Request $request, Response $response, mysqli $db, int $id): Response + { + $fornitore = (new SupplierRepository($db))->getById($id); + if ($fornitore === null) { + return $response->withStatus(404); + } + return $this->render($response, 'form.php', ['fornitore' => $fornitore]); + } + + public function update(Request $request, Response $response, mysqli $db, int $id): Response + { + $repo = new SupplierRepository($db); + if ($repo->getById($id) === null) { + return $response->withStatus(404); + } + $data = SupplierRepository::normalize((array) $request->getParsedBody()); + if ($data['nome'] === null) { + $_SESSION['error_message'] = __('Il nome del fornitore è obbligatorio.'); + return $response->withHeader('Location', url('/admin/suppliers/edit/' . $id))->withStatus(302); + } + try { + $repo->update($id, $data); + $_SESSION['success_message'] = __('Fornitore aggiornato con successo.'); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('SuppliersController::update error', ['id' => $id, 'exception' => get_class($e), 'code' => $e->getCode()]); + $_SESSION['error_message'] = __('Impossibile salvare il fornitore. Riprova più tardi.'); + } + return $response->withHeader('Location', url('/admin/suppliers'))->withStatus(302); + } + + public function delete(Request $request, Response $response, mysqli $db, int $id): Response + { + try { + if ((new SupplierRepository($db))->delete($id)) { + $_SESSION['success_message'] = __('Fornitore eliminato. I libri collegati non hanno più un fornitore.'); + } else { + $_SESSION['error_message'] = __('Fornitore non trovato.'); + } + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('SuppliersController::delete error', ['id' => $id, 'exception' => get_class($e), 'code' => $e->getCode()]); + $_SESSION['error_message'] = __('Impossibile eliminare il fornitore. Riprova più tardi.'); + } + return $response->withHeader('Location', url('/admin/suppliers'))->withStatus(302); + } + + /** @param array $data */ + private function render(Response $response, string $view, array $data): Response + { + ob_start(); + require __DIR__ . '/../Views/fornitori/' . $view; + $content = ob_get_clean(); + ob_start(); + require __DIR__ . '/../Views/layout.php'; + $html = ob_get_clean(); + $response->getBody()->write((string) $html); + return $response; + } +} diff --git a/app/Models/SupplierRepository.php b/app/Models/SupplierRepository.php new file mode 100644 index 000000000..91c4e3bb5 --- /dev/null +++ b/app/Models/SupplierRepository.php @@ -0,0 +1,169 @@ +db->query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fornitori'"); + return $res instanceof \mysqli_result && $res->num_rows > 0; + } catch (\Throwable) { + return false; + } + } + + /** + * Every supplier with the number of (non-deleted) books linked to it. + * + * @return list> + */ + public function listWithCounts(): array + { + if (!$this->isAvailable()) { + return []; + } + $sql = 'SELECT f.*, COUNT(l.id) AS libri_count + FROM fornitori f + LEFT JOIN libri l ON l.fornitore_id = f.id AND l.deleted_at IS NULL + GROUP BY f.id + ORDER BY f.nome ASC, f.id ASC'; + $rows = []; + $res = $this->db->query($sql); + if ($res instanceof \mysqli_result) { + while ($row = $res->fetch_assoc()) { + $rows[] = $row; + } + } + return $rows; + } + + /** + * id => name, for the book form select and the book list filter. + * + * @return array + */ + public function options(): array + { + if (!$this->isAvailable()) { + return []; + } + $out = []; + $res = $this->db->query('SELECT id, nome FROM fornitori ORDER BY nome ASC, id ASC'); + if ($res instanceof \mysqli_result) { + while ($row = $res->fetch_assoc()) { + $out[(int) $row['id']] = (string) $row['nome']; + } + } + return $out; + } + + /** @return array|null */ + public function getById(int $id): ?array + { + $stmt = $this->db->prepare('SELECT * FROM fornitori WHERE id = ?'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return $row ?: null; + } + + /** @param array $data normalized by normalize() */ + public function create(array $data): int + { + $values = $this->orderedValues($data); + $columns = implode(', ', self::FIELDS); + $placeholders = implode(', ', array_fill(0, count(self::FIELDS), '?')); + $stmt = $this->db->prepare("INSERT INTO fornitori ({$columns}) VALUES ({$placeholders})"); + $stmt->bind_param(str_repeat('s', count($values)), ...$values); + $stmt->execute(); + $id = (int) $this->db->insert_id; + $stmt->close(); + return $id; + } + + /** @param array $data normalized by normalize() */ + public function update(int $id, array $data): bool + { + $values = $this->orderedValues($data); + $set = implode(', ', array_map(static fn (string $c): string => "{$c} = ?", self::FIELDS)); + $stmt = $this->db->prepare("UPDATE fornitori SET {$set} WHERE id = ?"); + $values[] = $id; + $stmt->bind_param(str_repeat('s', count(self::FIELDS)) . 'i', ...$values); + $ok = $stmt->execute(); + $stmt->close(); + return $ok; + } + + /** Deleting a supplier unlinks its books (FK ON DELETE SET NULL). */ + public function delete(int $id): bool + { + $stmt = $this->db->prepare('DELETE FROM fornitori WHERE id = ?'); + $stmt->bind_param('i', $id); + $ok = $stmt->execute() && $stmt->affected_rows > 0; + $stmt->close(); + return $ok; + } + + /** + * Trim every field, blank → NULL, cap the lengths to the column sizes and + * keep only http(s) websites. The name is required by the caller. + * + * @param array $input + * @return array + */ + public static function normalize(array $input): array + { + $limits = ['nome' => 255, 'referente' => 150, 'email' => 255, 'telefono' => 30, 'sito_web' => 255, 'partita_iva' => 20, 'indirizzo' => 2000, 'note' => 5000]; + $out = []; + foreach (self::FIELDS as $field) { + $raw = $input[$field] ?? null; + $value = is_scalar($raw) ? trim((string) $raw) : ''; + if ($value !== '') { + $value = mb_substr($value, 0, $limits[$field]); + } + $out[$field] = $value === '' ? null : $value; + } + if ($out['email'] !== null && filter_var($out['email'], FILTER_VALIDATE_EMAIL) === false) { + $out['email'] = null; + } + if ($out['sito_web'] !== null) { + $url = $out['sito_web']; + if (!preg_match('#^https?://#i', $url)) { + $url = 'https://' . ltrim($url, '/'); + } + $out['sito_web'] = filter_var($url, FILTER_VALIDATE_URL) !== false ? $url : null; + } + return $out; + } + + /** + * @param array $data + * @return list + */ + private function orderedValues(array $data): array + { + $values = []; + foreach (self::FIELDS as $field) { + $values[] = $data[$field] ?? null; + } + return $values; + } +} diff --git a/app/Routes/web.php b/app/Routes/web.php index d5ef528a3..65b12fff7 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -2613,6 +2613,54 @@ return $controller->delete($request, $response, $db, (int) $args['id']); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Suppliers (0.9.0): the publishers' CRUD shape, catalogue permission. + $app->get('/admin/suppliers', function ($request, $response) use ($app) { + return (new \App\Controllers\SuppliersController())->index($request, $response, $app->getContainer()->get('db')); + })->add(new AdminAuthMiddleware()); + $app->get('/admin/suppliers/create', function ($request, $response) { + return (new \App\Controllers\SuppliersController())->createForm($request, $response); + })->add(new AdminAuthMiddleware()); + $app->post('/admin/suppliers/create', function ($request, $response) use ($app) { + return (new \App\Controllers\SuppliersController())->store($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->get('/admin/suppliers/edit/{id:\d+}', function ($request, $response, $args) use ($app) { + return (new \App\Controllers\SuppliersController())->editForm($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new AdminAuthMiddleware()); + $app->post('/admin/suppliers/update/{id:\d+}', function ($request, $response, $args) use ($app) { + return (new \App\Controllers\SuppliersController())->update($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/suppliers/delete/{id:\d+}', function ($request, $response, $args) use ($app) { + return (new \App\Controllers\SuppliersController())->delete($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + // ISBD catalogue cards (0.9.0): one book, or the books selected in the list. + $app->get('/admin/books/{id:\d+}/catalogue-card', function ($request, $response, $args) use ($app) { + return (new \App\Controllers\CatalogueCardController())->single($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->get('/admin/books/catalogue-cards', function ($request, $response) use ($app) { + return (new \App\Controllers\CatalogueCardController())->bulk($request, $response, $app->getContainer()->get('db')); + })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + + // Visitor log kiosk (0.9.0). Front-desk surface: loans permission. + $app->get('/admin/attendance', function ($request, $response) use ($app) { + return (new \App\Controllers\AttendanceController())->index($request, $response, $app->getContainer()->get('db')); + })->add(new AdminAuthMiddleware()); + $app->get('/admin/attendance/export.csv', function ($request, $response) use ($app) { + return (new \App\Controllers\AttendanceController())->exportCsv($request, $response, $app->getContainer()->get('db')); + })->add(new AdminAuthMiddleware()); + $app->post('/admin/attendance/scan', function ($request, $response) use ($app) { + return (new \App\Controllers\AttendanceController())->scan($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(120, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/attendance/guest', function ($request, $response) use ($app) { + return (new \App\Controllers\AttendanceController())->guest($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(120, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/attendance/settings', function ($request, $response) use ($app) { + return (new \App\Controllers\AttendanceController())->saveSettings($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/attendance/purge', function ($request, $response) use ($app) { + return (new \App\Controllers\AttendanceController())->purge($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // API Libri by Author/Publisher $app->get('/api/libri/author/{id:\d+}', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\LibriApiController(); @@ -3751,6 +3799,8 @@ 'collocazione' => 'placement', 'recensioni' => 'reviews', 'statistiche' => 'statistics', + 'presenze' => 'attendance', + 'fornitori' => 'suppliers', ]; /** @var array $actionSegmentMap Italian action words → English */ diff --git a/app/Support/CataloguingExtras.php b/app/Support/CataloguingExtras.php new file mode 100644 index 000000000..f373e8a4d --- /dev/null +++ b/app/Support/CataloguingExtras.php @@ -0,0 +1,114 @@ + */ + private static array $columnCache = []; + + /** + * Persist the supplier and RDA overrides present in a form submission. + * + * @param array $data raw form data + */ + public static function save(mysqli $db, int $bookId, array $data): void + { + $set = []; + $types = ''; + $values = []; + + if (array_key_exists('fornitore_id', $data) && self::hasColumn($db, 'fornitore_id')) { + $supplierId = self::existingSupplierId($db, $data['fornitore_id']); + if ($supplierId === null) { + $set[] = 'fornitore_id = NULL'; + } else { + $set[] = 'fornitore_id = ?'; + $types .= 'i'; + $values[] = $supplierId; + } + } + + foreach (RdaTypes::COLUMNS as $kind => $column) { + if (!array_key_exists($column, $data) || !self::hasColumn($db, $column)) { + continue; + } + $code = RdaTypes::normalize($kind, $data[$column]); + if ($code === null) { + $set[] = "{$column} = NULL"; + } else { + $set[] = "{$column} = ?"; + $types .= 's'; + $values[] = $code; + } + } + + if ($set === []) { + return; + } + + $sql = 'UPDATE libri SET ' . implode(', ', $set) . ' WHERE id = ? AND deleted_at IS NULL'; + $types .= 'i'; + $values[] = $bookId; + $stmt = $db->prepare($sql); + if ($stmt === false) { + throw new \RuntimeException('Unable to prepare the cataloguing extras update'); + } + try { + $stmt->bind_param($types, ...$values); + $stmt->execute(); + } finally { + $stmt->close(); + } + } + + /** A submitted supplier id that exists, or NULL ("no supplier"). */ + private static function existingSupplierId(mysqli $db, mixed $raw): ?int + { + $id = filter_var($raw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($id === false) { + return null; + } + $stmt = $db->prepare('SELECT id FROM fornitori WHERE id = ?'); + if ($stmt === false) { + return null; + } + try { + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + } finally { + $stmt->close(); + } + return $row ? (int) $row['id'] : null; + } + + private static function hasColumn(mysqli $db, string $column): bool + { + if (!array_key_exists($column, self::$columnCache)) { + $stmt = $db->prepare("SELECT COUNT(*) AS c FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND COLUMN_NAME = ?"); + $exists = false; + if ($stmt !== false) { + $stmt->bind_param('s', $column); + $stmt->execute(); + $exists = (int) ($stmt->get_result()->fetch_assoc()['c'] ?? 0) > 0; + $stmt->close(); + } + self::$columnCache[$column] = $exists; + } + return self::$columnCache[$column]; + } +} diff --git a/app/Support/Permissions.php b/app/Support/Permissions.php index 2fecbc255..a846e766a 100644 --- a/app/Support/Permissions.php +++ b/app/Support/Permissions.php @@ -120,8 +120,8 @@ public static function permissionForPath(string $path): ?string return 'catalog.manage'; } $groups = [ - 'catalog.manage' => ['/admin/books', '/admin/authors', '/admin/publishers', '/admin/genres', '/admin/series', '/admin/placement', '/admin/copies', '/admin/import', '/admin/imports', '/admin/imports-history', '/api/books', '/api/authors', '/api/publishers', '/api/genres', '/api/series', '/api/copies', '/api/dewey', '/api/scrape', '/api/libri', '/api/autori', '/api/editori', '/api/collane', '/api/collocazione', '/api/cover', '/admin/periodicals', '/api/v1/staff/lookups', '/api/v1/staff/isbn', '/api/v1/staff/copies', '/api/v1/staff/books', '/api/v1/staff/authors', '/api/v1/staff/publishers', '/api/v1/staff/imports', '/api/v1/staff/periodicals', '/api/v1/staff/series', '/api/v1/staff/genres', '/api/v1/staff/works', '/api/v1/staff/covers', '/api/v1/staff/enrich', '/api/v1/staff/shelves', '/api/v1/staff/archives', '/api/search/editori', '/api/search/generi', '/api/search/preview', '/api/generi', '/admin/archives', '/admin/opere', '/admin/espressioni'], - 'loans.manage' => ['/admin/loans', '/admin/reservations', '/api/loans', '/api/reservations', '/api/prestiti', '/prestiti', '/api/v1/staff/loans', '/api/v1/staff/reservations'], + 'catalog.manage' => ['/admin/books', '/admin/authors', '/admin/publishers', '/admin/suppliers', '/admin/genres', '/admin/series', '/admin/placement', '/admin/copies', '/admin/import', '/admin/imports', '/admin/imports-history', '/api/books', '/api/authors', '/api/publishers', '/api/genres', '/api/series', '/api/copies', '/api/dewey', '/api/scrape', '/api/libri', '/api/autori', '/api/editori', '/api/collane', '/api/collocazione', '/api/cover', '/admin/periodicals', '/api/v1/staff/lookups', '/api/v1/staff/isbn', '/api/v1/staff/copies', '/api/v1/staff/books', '/api/v1/staff/authors', '/api/v1/staff/publishers', '/api/v1/staff/imports', '/api/v1/staff/periodicals', '/api/v1/staff/series', '/api/v1/staff/genres', '/api/v1/staff/works', '/api/v1/staff/covers', '/api/v1/staff/enrich', '/api/v1/staff/shelves', '/api/v1/staff/archives', '/api/search/editori', '/api/search/generi', '/api/search/preview', '/api/generi', '/admin/archives', '/admin/opere', '/admin/espressioni'], + 'loans.manage' => ['/admin/loans', '/admin/attendance', '/admin/reservations', '/api/loans', '/api/reservations', '/api/prestiti', '/prestiti', '/api/v1/staff/loans', '/api/v1/staff/reservations'], 'users.manage' => ['/admin/users', '/api/users', '/api/utenti', '/api/admin/pending-registrations', '/api/admin/pending-registrations-count', '/api/v1/staff/users', '/api/search/utenti'], 'digital.manage' => ['/admin/plugins/digital-library/upload'], 'settings.manage' => ['/admin/settings'], diff --git a/app/Support/RdaTypes.php b/app/Support/RdaTypes.php new file mode 100644 index 000000000..75a0098bd --- /dev/null +++ b/app/Support/RdaTypes.php @@ -0,0 +1,176 @@ + 'rda_content_type', + self::KIND_MEDIA => 'rda_media_type', + self::KIND_CARRIER => 'rda_carrier_type', + ]; + + /** id.loc.gov vocabulary base for each kind. */ + private const URI_BASE = [ + self::KIND_CONTENT => 'http://id.loc.gov/vocabulary/contentTypes/', + self::KIND_MEDIA => 'http://id.loc.gov/vocabulary/mediaTypes/', + self::KIND_CARRIER => 'http://id.loc.gov/vocabulary/carriers/', + ]; + + /** + * code => untranslated Italian label (pass through __() when rendering). + * + * @var array> + */ + private const VOCABULARY = [ + self::KIND_CONTENT => [ + 'txt' => 'Testo', + 'spw' => 'Parola parlata', + 'prm' => 'Musica eseguita', + 'ntm' => 'Musica notata', + 'snd' => 'Suoni', + 'sti' => 'Immagine fissa', + 'tdi' => 'Immagine in movimento bidimensionale', + 'cri' => 'Immagine cartografica', + 'cop' => 'Programma per computer', + 'xxx' => 'Altro', + ], + self::KIND_MEDIA => [ + 'n' => 'Senza mediazione', + 's' => 'Audio', + 'v' => 'Video', + 'c' => 'Computer', + 'h' => 'Microforma', + 'g' => 'Proiettato', + 'x' => 'Altro', + ], + self::KIND_CARRIER => [ + 'nc' => 'Volume', + 'no' => 'Scheda', + 'nb' => 'Foglio', + 'sd' => 'Disco sonoro', + 'ss' => 'Audiocassetta', + 'vd' => 'Videodisco', + 'cd' => 'Disco per computer', + 'cr' => 'Risorsa online', + 'zu' => 'Non specificato', + ], + ]; + + /** + * Derived triple for each tipo_media. + * + * @var array + */ + private const DERIVED = [ + 'libro' => ['content' => 'txt', 'media' => 'n', 'carrier' => 'nc'], + 'disco' => ['content' => 'prm', 'media' => 's', 'carrier' => 'sd'], + 'audiolibro' => ['content' => 'spw', 'media' => 's', 'carrier' => 'sd'], + 'dvd' => ['content' => 'tdi', 'media' => 'v', 'carrier' => 'vd'], + 'altro' => ['content' => 'xxx', 'media' => 'x', 'carrier' => 'zu'], + ]; + + /** @return list */ + public static function kinds(): array + { + return [self::KIND_CONTENT, self::KIND_MEDIA, self::KIND_CARRIER]; + } + + /** + * Code => untranslated label for one kind. + * + * @return array + */ + public static function options(string $kind): array + { + return self::VOCABULARY[$kind] ?? []; + } + + /** Untranslated label of a code, or the code itself when unknown. */ + public static function label(string $kind, string $code): string + { + return self::VOCABULARY[$kind][$code] ?? $code; + } + + /** + * The triple derived from tipo_media (unknown/empty values derive as a book, + * the same default the catalogue applies everywhere else). + * + * @return array{content: string, media: string, carrier: string} + */ + public static function derive(?string $tipoMedia): array + { + $key = MediaLabels::normalizeTipoMedia($tipoMedia) ?? 'libro'; + return self::DERIVED[$key] ?? self::DERIVED['libro']; + } + + /** + * A submitted override, validated against the vocabulary. Anything that + * is not a known code (including '' from the "derived" option) is NULL. + */ + public static function normalize(string $kind, mixed $code): ?string + { + if (!is_string($code)) { + return null; + } + $code = strtolower(trim($code)); + return isset(self::VOCABULARY[$kind][$code]) ? $code : null; + } + + /** + * Overrides explicitly stored on the record (kind => code), only the ones set. + * + * @param array $book + * @return array + */ + public static function overrides(array $book): array + { + $out = []; + foreach (self::COLUMNS as $kind => $column) { + $code = self::normalize($kind, $book[$column] ?? null); + if ($code !== null) { + $out[$kind] = $code; + } + } + return $out; + } + + /** + * Effective triple: override when set, derived value otherwise. + * + * @param array $book + * @return array{content: string, media: string, carrier: string} + */ + public static function effective(array $book): array + { + $derived = self::derive(isset($book['tipo_media']) ? (string) $book['tipo_media'] : null); + foreach (self::overrides($book) as $kind => $code) { + $derived[$kind] = $code; + } + return $derived; + } + + /** id.loc.gov URI of a code. */ + public static function uri(string $kind, string $code): string + { + return (self::URI_BASE[$kind] ?? '') . rawurlencode($code); + } +} diff --git a/app/Views/fornitori/form.php b/app/Views/fornitori/form.php new file mode 100644 index 000000000..b90deee0b --- /dev/null +++ b/app/Views/fornitori/form.php @@ -0,0 +1,129 @@ +|null } + */ +use App\Support\Csrf; +use App\Support\HtmlHelper; + +$csrf = Csrf::ensureToken(); +$fornitore = $data['fornitore']; +$isEdit = is_array($fornitore); +$action = $isEdit ? url('/admin/suppliers/update/' . (int) $fornitore['id']) : url('/admin/suppliers/create'); +$v = static fn (string $key): string => HtmlHelper::e((string) ($fornitore[$key] ?? '')); +?> +
      +
      + +

      + + + +

      + + + +
      + + + +
      +
      +

      + + +

      +
      +
      +
      +
      + + " /> +
      +
      + + +
      +
      + +
      +
      + + +
      +
      + + +
      +
      + +
      +
      + + +
      +
      + + +
      +
      + +
      + + +
      + +
      + + +
      +
      +
      + + +
      + + + + + +
      +
      +

      +
      diff --git a/app/Views/fornitori/index.php b/app/Views/fornitori/index.php new file mode 100644 index 000000000..d400074a6 --- /dev/null +++ b/app/Views/fornitori/index.php @@ -0,0 +1,160 @@ +> } + */ +use App\Support\Csrf; +use App\Support\HtmlHelper; + +$fornitori = $data['fornitori']; +$csrf = Csrf::ensureToken(); +?> +
      +
      + +

      +

      + + +
      + + + +
      + + + +
      +
      +
      +
      + + +
      +
      +
      + +
      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + +

      +
      + + + + +
      + +
      +
      +
      +
      + + +
      + + + + + + +
      + + +
      +
      +
      +
      +
      +
      +

      +
      + + diff --git a/app/Views/layout.php b/app/Views/layout.php index 698f4f481..40195e48b 100644 --- a/app/Views/layout.php +++ b/app/Views/layout.php @@ -267,6 +267,18 @@ class="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 group-hov
      + +
      + +
      +
      +
      +
      +
      +
      + @@ -390,6 +405,9 @@ class="form-input" /> + @@ -685,7 +703,8 @@ function renderRecentSearches() { anno_from: document.getElementById('anno_from').value, anno_to: document.getElementById('anno_to').value, collana: collanaFilter, - tipo_media: document.getElementById('tipo_media_filter').value + tipo_media: document.getElementById('tipo_media_filter').value, + fornitore_filter: document.getElementById('fornitore_filter')?.value || 0 }; }, dataSrc: function(json) { @@ -903,7 +922,7 @@ className: 'text-center align-middle', updateActiveFilters(); }); - ['search_text', 'search_isbn', 'stato_filter', 'tipo_media_filter', 'acq_from', 'acq_to', 'anno_from', 'anno_to'].forEach(id => { + ['search_text', 'search_isbn', 'stato_filter', 'tipo_media_filter', 'fornitore_filter', 'acq_from', 'acq_to', 'anno_from', 'anno_to'].forEach(id => { const el = document.getElementById(id); if (el) { el.addEventListener('input', reloadDebounced); @@ -939,6 +958,11 @@ function updateActiveFilters() { const tipoMedia = document.getElementById('tipo_media_filter').value; if (tipoMedia) filters.push({ key: 'tipo_media_filter', label: `${}: ${tipoMedia}`, icon: 'fa-compact-disc' }); + const fornitoreSel = document.getElementById('fornitore_filter'); + if (fornitoreSel && fornitoreSel.value) { + filters.push({ key: 'fornitore_filter', label: `${}: ${fornitoreSel.options[fornitoreSel.selectedIndex].text}`, icon: 'fa-truck' }); + } + const genere = document.getElementById('filter_genere').value; if (genere && document.getElementById('genere_id').value) { filters.push({ key: 'genere', label: `${}: ${genere}`, icon: 'fa-tags' }); @@ -967,6 +991,7 @@ function clearFilter(key) { else if (key === 'editore') { document.getElementById('filter_editore').value = ''; document.getElementById('editore_filter').value = ''; } else if (key === 'stato_filter') document.getElementById('stato_filter').value = ''; else if (key === 'tipo_media_filter') document.getElementById('tipo_media_filter').value = ''; + else if (key === 'fornitore_filter') document.getElementById('fornitore_filter').value = ''; else if (key === 'genere') { document.getElementById('filter_genere').value = ''; document.getElementById('genere_id').value = ''; genereUrlFilter = 0; sottogenereUrlFilter = 0; } table.ajax.reload(); updateActiveFilters(); @@ -974,7 +999,7 @@ function clearFilter(key) { // Clear all filters document.getElementById('clear-filters').addEventListener('click', function() { - ['search_text', 'search_isbn', 'stato_filter', 'tipo_media_filter', 'acq_from', 'acq_to', 'anno_from', 'anno_to', 'filter_autore', 'filter_editore', 'filter_genere', 'filter_posizione'].forEach(id => { + ['search_text', 'search_isbn', 'stato_filter', 'tipo_media_filter', 'fornitore_filter', 'acq_from', 'acq_to', 'anno_from', 'anno_to', 'filter_autore', 'filter_editore', 'filter_genere', 'filter_posizione'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); @@ -1467,6 +1492,13 @@ function updateBulkActionsBar() { } }); + // Bulk ISBD catalogue cards (PDF, one card per page) + document.getElementById('bulk-catalogue-cards').addEventListener('click', function() { + if (selectedBooks.size === 0) return; + const ids = Array.from(selectedBooks).join(','); + window.open(`${window.BASE_PATH}/admin/books/catalogue-cards?ids=${encodeURIComponent(ids)}`, '_blank', 'noopener'); + }); + // Bulk export document.getElementById('bulk-export').addEventListener('click', function() { if (selectedBooks.size === 0) return; @@ -1833,7 +1865,7 @@ function renderGrid() { .books-filter-grid > #clear-filters, .books-recent-searches { flex: 0 0 auto; } .books-recent-searches > button { width: auto; } - .books-bulk-buttons { grid-template-columns: repeat(4, auto); } + .books-bulk-buttons { grid-template-columns: repeat(3, auto); } } .books-scroll-hint { display: none; } diff --git a/app/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php index 832fe82ab..844a0ea00 100644 --- a/app/Views/libri/partials/book_form.php +++ b/app/Views/libri/partials/book_form.php @@ -413,6 +413,26 @@
      + name, loaded by the controller. + $supplierOptions = isset($fornitori) && is_array($fornitori) ? $fornitori : []; + $currentSupplier = (int) ($book['fornitore_id'] ?? 0); + ?> + @@ -465,6 +485,60 @@ + $rdaCode) { + $rdaDerivedByMedia[$mediaKey][$rdaKind] = __(\App\Support\RdaTypes::label($rdaKind, $rdaCode)) . ' (' . $rdaCode . ')'; + } + } + $rdaKindLabels = [ + \App\Support\RdaTypes::KIND_CONTENT => __('Tipo di contenuto RDA'), + \App\Support\RdaTypes::KIND_MEDIA => __('Tipo di mediazione RDA'), + \App\Support\RdaTypes::KIND_CARRIER => __('Tipo di supporto RDA'), + ]; + ?> +
      +

      +

      +
      + $rdaColumn): $rdaCurrent = \App\Support\RdaTypes::normalize($rdaKind, $book[$rdaColumn] ?? null); ?> +
      + + +
      + +
      +
      + + + + + + + @@ -580,6 +585,28 @@ class="text-gray-700 hover:text-gray-900 hover:underline transition-colors"> getById((int) $libro['fornitore_id']) + : null; + ?> + + + + $rdaCode) { + $rdaParts[] = __(\App\Support\RdaTypes::label($rdaKind, $rdaCode)); + } + ?> +
      +
      +
      +
      + 'bf:Content', '@id' => 'http://id.loc.gov/vocabulary/contentTypes/txt']; + // RDA content/media/carrier: a cataloguer's per-record override wins + // (libri.rda_*_type, 0.9.0); without one the historical output stays + // exactly as it was (text / unmediated / volume). + $rdaOverrides = \App\Support\RdaTypes::overrides($book); + $work['bf:content'] = [ + '@type' => 'bf:Content', + '@id' => isset($rdaOverrides['content']) + ? \App\Support\RdaTypes::uri('content', $rdaOverrides['content']) + : 'http://id.loc.gov/vocabulary/contentTypes/txt', + ]; // No owl:sameAs on the Work: a VIAF id identifies a PERSON, so linking // the Work to its author's VIAF cluster would assert that the book IS @@ -517,8 +526,12 @@ private function buildGraph(array $book): array '@type' => 'bf:Title', 'bf:mainTitle' => $instanceMainTitleVal, ], - 'bf:carrier' => ['@id' => 'http://id.loc.gov/vocabulary/carriers/nc'], - 'bf:media' => ['@id' => 'http://id.loc.gov/vocabulary/mediaTypes/n'], + 'bf:carrier' => ['@id' => isset($rdaOverrides['carrier']) + ? \App\Support\RdaTypes::uri('carrier', $rdaOverrides['carrier']) + : 'http://id.loc.gov/vocabulary/carriers/nc'], + 'bf:media' => ['@id' => isset($rdaOverrides['media']) + ? \App\Support\RdaTypes::uri('media', $rdaOverrides['media']) + : 'http://id.loc.gov/vocabulary/mediaTypes/n'], ]; // Identifiers diff --git a/tests/helpers/minor-features-090.js b/tests/helpers/minor-features-090.js new file mode 100644 index 000000000..6f8b06922 --- /dev/null +++ b/tests/helpers/minor-features-090.js @@ -0,0 +1,114 @@ +// Shared helpers for the 0.9.0 minor-feature specs (visitor log, catalogue +// cards, suppliers, RDA overrides, statistics). DB access is only for setup +// and verification; every action under test goes through the UI. +const { execFileSync } = require('child_process'); +const zlib = require('zlib'); + +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_NAME = process.env.E2E_DB_NAME || ''; + +const configured = Boolean(ADMIN_EMAIL && ADMIN_PASS && DB_USER && DB_NAME); + +function mysqlArgs(extra) { + const args = []; + if (DB_SOCKET) args.push('-S', DB_SOCKET); + args.push('-u', DB_USER); + if (DB_PASS !== '') args.push(`-p${DB_PASS}`); + args.push(DB_NAME, ...extra); + return args; +} + +/** Run SQL, return the tab-separated output without headers. */ +function dbQuery(sql) { + return execFileSync('mysql', mysqlArgs(['-N', '-B', '-e', sql]), { encoding: 'utf-8', timeout: 15000 }).trim(); +} + +/** SQL string literal (single quotes doubled, backslashes escaped). */ +function sqlString(value) { + return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; +} + +async function loginAsAdmin(page) { + await page.goto(`${BASE}/admin/dashboard`); + if (/\/admin\//.test(page.url()) && (await page.locator('input[name="password"]').count()) === 0) return; + for (const slug of ['accedi', 'login', 'anmelden', 'connexion', 'log-ind']) { + const resp = await page.goto(`${BASE}/${slug}`).catch(() => null); + if (resp && resp.status() === 200 && (await page.locator('input[name="email"]').count()) > 0) break; + } + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await Promise.all([ + page.waitForURL(/\/admin\//, { timeout: 45000, waitUntil: 'domcontentloaded' }), + page.locator('button[type="submit"]').first().click(), + ]); +} + +/** + * Submit #bookForm, confirm the SweetAlert dialog when one appears and wait + * for the redirect away from the form (book page or list). + */ +async function submitBookForm(page) { + await page.locator('#bookForm button[type="submit"]').click(); + const swalConfirm = page.locator('.swal2-confirm'); + if (await swalConfirm.isVisible({ timeout: 5000 }).catch(() => false)) { + await swalConfirm.click(); + } + await page.waitForURL(/\/admin\/books(?:\/\d+)?(?:\?.*)?$/, { timeout: 30000 }); + await page.waitForLoadState('domcontentloaded'); +} + +/** Insert a minimal catalogue record (setup only) and return its id. */ +function seedBook(fields) { + const cols = Object.keys(fields); + const vals = cols.map((c) => (fields[c] === null ? 'NULL' : sqlString(fields[c]))); + dbQuery(`INSERT INTO libri (${cols.join(', ')}) VALUES (${vals.join(', ')})`); + return parseInt(dbQuery(`SELECT MAX(id) FROM libri WHERE titolo = ${sqlString(fields.titolo)}`), 10); +} + +function deleteBooks(ids) { + const list = ids.filter((id) => id > 0); + if (list.length === 0) return; + const inList = list.join(','); + try { dbQuery(`DELETE FROM libri_autori WHERE libro_id IN (${inList})`); } catch (_) { /* cleanup */ } + try { dbQuery(`DELETE FROM copie WHERE libro_id IN (${inList})`); } catch (_) { /* cleanup */ } + try { dbQuery(`DELETE FROM libri WHERE id IN (${inList})`); } catch (_) { /* cleanup */ } +} + +/** + * Text drawn in a TCPDF document. Content streams are zlib-compressed and + * TCPDF writes Unicode text as UTF-16BE, so every stream is inflated and the + * NUL high bytes of ASCII characters are dropped. + */ +function pdfText(buffer) { + const bin = buffer.toString('latin1'); + let out = ''; + const re = /stream\r?\n/g; + let m; + while ((m = re.exec(bin)) !== null) { + const start = m.index + m[0].length; + const end = bin.indexOf('endstream', start); + if (end < 0) break; + const chunk = Buffer.from(bin.slice(start, end), 'latin1'); + try { + out += zlib.inflateSync(chunk).toString('latin1'); + } catch (_) { + // Not a deflated stream (fonts, images): ignore. + } + re.lastIndex = end; + } + return out.replace(/\x00/g, ''); +} + +function pdfPageCount(buffer) { + return (buffer.toString('latin1').match(/\/Type\s*\/Page[^s]/g) || []).length; +} + +module.exports = { + BASE, ADMIN_EMAIL, ADMIN_PASS, configured, + dbQuery, sqlString, loginAsAdmin, submitBookForm, seedBook, deleteBooks, pdfText, pdfPageCount, +}; diff --git a/tests/rda-types-090.spec.js b/tests/rda-types-090.spec.js new file mode 100644 index 000000000..da3ad95a4 --- /dev/null +++ b/tests/rda-types-090.spec.js @@ -0,0 +1,114 @@ +// @ts-check +// RDA content / media / carrier in cataloguing (0.9.0): derived from the +// media type, optional per-record override in the book form, override used by +// the BIBFRAME output (historical output kept when there is no override). +const { test, expect } = require('@playwright/test'); +const h = require('./helpers/minor-features-090'); + +test.skip(!h.configured, 'E2E credentials not configured'); + +const STAMP = Date.now().toString(36); +const TITLE = `E2E RDA ${STAMP}`; + +async function bibframe(page, id) { + const resp = await page.request.get(`${h.BASE}/api/bibframe/book/${id}`, { headers: { Accept: 'application/ld+json' } }); + expect(resp.status()).toBe(200); + return resp.text(); +} + +test.describe.serial('RDA content/media/carrier types', () => { + let context; + let page; + let bookId = 0; + + test.beforeAll(async ({ browser }) => { + const active = h.dbQuery("SELECT is_active FROM plugins WHERE name = 'bibframe-linked-data'"); + test.skip(active !== '1', 'bibframe-linked-data plugin is not active on this install'); + context = await browser.newContext(); + page = await context.newPage(); + await h.loginAsAdmin(page); + }); + + test.afterAll(async () => { + h.deleteBooks([bookId]); + await context?.close(); + }); + + test('1. The book form shows the RDA types derived from the media type', async () => { + await page.goto(`${h.BASE}/admin/books/create`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 15000 }); + const derived = (id) => page.locator(`#${id} option[data-rda-derived]`); + await expect(derived('rda_content_type')).toHaveText('Automatico: Testo (txt)'); + await expect(derived('rda_media_type')).toHaveText('Automatico: Senza mediazione (n)'); + await expect(derived('rda_carrier_type')).toHaveText('Automatico: Volume (nc)'); + await page.selectOption('#tipo_media', 'dvd'); + await expect(derived('rda_content_type')).toHaveText('Automatico: Immagine in movimento bidimensionale (tdi)'); + await expect(derived('rda_carrier_type')).toHaveText('Automatico: Videodisco (vd)'); + for (const id of ['rda_content_type', 'rda_media_type', 'rda_carrier_type']) { + await expect(page.locator(`#${id}`)).toHaveValue(''); + } + }); + + test('2. Without an override nothing is stored and the derived value is shown', async () => { + test.setTimeout(90000); + await page.goto(`${h.BASE}/admin/books/create`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 15000 }); + await page.fill('#titolo', TITLE); + await page.selectOption('#tipo_media', 'audiolibro'); + await h.submitBookForm(page); + await expect.poll(() => h.dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo = ${h.sqlString(TITLE)} AND deleted_at IS NULL`), { timeout: 30000 }).toBe('1'); + bookId = parseInt(h.dbQuery(`SELECT id FROM libri WHERE titolo = ${h.sqlString(TITLE)} AND deleted_at IS NULL`), 10); + expect(h.dbQuery(`SELECT CONCAT_WS('|', tipo_media, IFNULL(rda_content_type,'NULL'), IFNULL(rda_media_type,'NULL'), IFNULL(rda_carrier_type,'NULL')) FROM libri WHERE id = ${bookId}`)) + .toBe('audiolibro|NULL|NULL|NULL'); + await page.goto(`${h.BASE}/admin/books/${bookId}`); + await expect(page.locator('#book-rda-types')).toHaveAttribute('data-rda', 'spw/s/sd'); + await expect(page.locator('#book-rda-types')).toHaveText('Parola parlata · Audio · Disco sonoro'); + }); + + test('3. BIBFRAME keeps its current output when no override is set', async () => { + const body = await bibframe(page, bookId); + expect(body).toContain('http://id.loc.gov/vocabulary/contentTypes/txt'); + expect(body).toContain('http://id.loc.gov/vocabulary/carriers/nc'); + expect(body).toContain('http://id.loc.gov/vocabulary/mediaTypes/n'); + }); + + test('4. An override saved in the form is stored, reloaded and shown', async () => { + test.setTimeout(90000); + await page.goto(`${h.BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 15000 }); + await page.selectOption('#rda_media_type', 'c'); + await page.selectOption('#rda_carrier_type', 'cr'); + await h.submitBookForm(page); + await expect.poll(() => h.dbQuery(`SELECT CONCAT_WS('|', IFNULL(rda_content_type,'NULL'), IFNULL(rda_media_type,'NULL'), IFNULL(rda_carrier_type,'NULL')) FROM libri WHERE id = ${bookId}`), { timeout: 30000 }) + .toBe('NULL|c|cr'); + await page.goto(`${h.BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#rda_media_type')).toHaveValue('c'); + await expect(page.locator('#rda_carrier_type')).toHaveValue('cr'); + await expect(page.locator('#rda_content_type')).toHaveValue(''); + await page.goto(`${h.BASE}/admin/books/${bookId}`); + await expect(page.locator('#book-rda-types')).toHaveAttribute('data-rda', 'spw/c/cr'); + }); + + test('5. BIBFRAME uses the override, and only the overridden types', async () => { + const body = await bibframe(page, bookId); + expect(body).toContain('http://id.loc.gov/vocabulary/carriers/cr'); + expect(body).toContain('http://id.loc.gov/vocabulary/mediaTypes/c'); + expect(body).not.toContain('http://id.loc.gov/vocabulary/carriers/nc'); + // Content was not overridden: the historical value stays. + expect(body).toContain('http://id.loc.gov/vocabulary/contentTypes/txt'); + }); + + test('6. Choosing "Automatico" again clears the override', async () => { + test.setTimeout(90000); + await page.goto(`${h.BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 15000 }); + await page.selectOption('#rda_media_type', ''); + await page.selectOption('#rda_carrier_type', ''); + await h.submitBookForm(page); + await expect.poll(() => h.dbQuery(`SELECT CONCAT_WS('|', IFNULL(rda_media_type,'NULL'), IFNULL(rda_carrier_type,'NULL')) FROM libri WHERE id = ${bookId}`), { timeout: 30000 }) + .toBe('NULL|NULL'); + const body = await bibframe(page, bookId); + expect(body).toContain('http://id.loc.gov/vocabulary/carriers/nc'); + expect(body).not.toContain('http://id.loc.gov/vocabulary/carriers/cr'); + }); +}); diff --git a/tests/suppliers-090.spec.js b/tests/suppliers-090.spec.js new file mode 100644 index 000000000..2fb2d5b97 --- /dev/null +++ b/tests/suppliers-090.spec.js @@ -0,0 +1,139 @@ +// @ts-check +// Suppliers (0.9.0): CRUD from the admin pages, assignment in the book form, +// the book-list filter and "delete supplier → books keep existing, link NULL". +const { test, expect } = require('@playwright/test'); +const h = require('./helpers/minor-features-090'); + +test.skip(!h.configured, 'E2E credentials not configured'); + +const STAMP = Date.now().toString(36); +const SUPPLIER = `E2E Fornitore ${STAMP}`; +const SUPPLIER_RENAMED = `E2E Fornitore Rinominato ${STAMP}`; +const OTHER_SUPPLIER = `E2E Altro Fornitore ${STAMP}`; +const BOOK_WITH = `E2E Libro fornito ${STAMP}`; +const BOOK_WITHOUT = `E2E Libro senza fornitore ${STAMP}`; + +test.describe.serial('Suppliers (fornitori)', () => { + let context; + let page; + let supplierId = 0; + let otherSupplierId = 0; + let bookId = 0; + let otherBookId = 0; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + await h.loginAsAdmin(page); + }); + + test.afterAll(async () => { + h.deleteBooks([bookId, otherBookId]); + try { h.dbQuery(`DELETE FROM fornitori WHERE nome LIKE 'E2E %${STAMP}'`); } catch (_) { /* cleanup */ } + await context?.close(); + }); + + test('1. A librarian creates a supplier from the Suppliers page', async () => { + await page.goto(`${h.BASE}/admin/suppliers`); + await page.getByRole('link', { name: /Nuovo Fornitore/ }).first().click(); + await expect(page.locator('#form-fornitore')).toBeVisible(); + await page.fill('#nome', SUPPLIER); + await page.fill('#partita_iva', 'IT01234567890'); + await page.fill('#email', 'ordini@example.org'); + await page.fill('#sito_web', 'www.example.org'); + await Promise.all([page.waitForURL(/\/admin\/suppliers$/), page.click('#form-fornitore button[type="submit"]')]); + + const row = h.dbQuery(`SELECT id, partita_iva, email, sito_web FROM fornitori WHERE nome = ${h.sqlString(SUPPLIER)}`); + const [id, piva, email, site] = row.split('\t'); + supplierId = parseInt(id, 10); + expect(supplierId).toBeGreaterThan(0); + expect([piva, email, site]).toEqual(['IT01234567890', 'ordini@example.org', 'https://www.example.org']); + await expect(page.locator('[data-supplier-row]', { hasText: SUPPLIER })).toBeVisible(); + // A second supplier, to prove the filter is selective. + await page.goto(`${h.BASE}/admin/suppliers/create`); + await page.fill('#nome', OTHER_SUPPLIER); + await Promise.all([page.waitForURL(/\/admin\/suppliers$/), page.click('#form-fornitore button[type="submit"]')]); + otherSupplierId = parseInt(h.dbQuery(`SELECT id FROM fornitori WHERE nome = ${h.sqlString(OTHER_SUPPLIER)}`), 10); + expect(otherSupplierId).toBeGreaterThan(0); + }); + + test('2. A blank name is refused and the supplier can be edited', async () => { + await page.goto(`${h.BASE}/admin/suppliers/edit/${supplierId}`); + await expect(page.locator('#nome')).toHaveValue(SUPPLIER); + // Spaces pass the browser "required" check; the server must still refuse. + await page.fill('#nome', ' '); + await Promise.all([page.waitForURL(new RegExp(`/admin/suppliers/edit/${supplierId}$`)), page.click('#form-fornitore button[type="submit"]')]); + await expect(page.getByText('Il nome del fornitore è obbligatorio.')).toBeVisible(); + expect(h.dbQuery(`SELECT nome FROM fornitori WHERE id = ${supplierId}`)).toBe(SUPPLIER); + + await page.fill('#nome', SUPPLIER_RENAMED); + await page.fill('#referente', 'Giulia Bianchi'); + await Promise.all([page.waitForURL(/\/admin\/suppliers$/), page.click('#form-fornitore button[type="submit"]')]); + expect(h.dbQuery(`SELECT CONCAT(nome, '|', referente) FROM fornitori WHERE id = ${supplierId}`)).toBe(`${SUPPLIER_RENAMED}|Giulia Bianchi`); + await expect(page.locator('[data-supplier-row]', { hasText: SUPPLIER_RENAMED })).toContainText('Giulia Bianchi'); + }); + + test('3. The book form assigns the supplier and the book page shows it', async () => { + test.setTimeout(90000); + await page.goto(`${h.BASE}/admin/books/create`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 15000 }); + await page.fill('#titolo', BOOK_WITH); + await page.selectOption('#fornitore_id', String(supplierId)); + await h.submitBookForm(page); + await expect.poll(() => h.dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo = ${h.sqlString(BOOK_WITH)} AND deleted_at IS NULL`), { timeout: 30000 }).toBe('1'); + bookId = parseInt(h.dbQuery(`SELECT id FROM libri WHERE titolo = ${h.sqlString(BOOK_WITH)} AND deleted_at IS NULL`), 10); + expect(h.dbQuery(`SELECT fornitore_id FROM libri WHERE id = ${bookId}`)).toBe(String(supplierId)); + + // A second book without a supplier. + await page.goto(`${h.BASE}/admin/books/create`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 15000 }); + await page.fill('#titolo', BOOK_WITHOUT); + await h.submitBookForm(page); + await expect.poll(() => h.dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo = ${h.sqlString(BOOK_WITHOUT)} AND deleted_at IS NULL`), { timeout: 30000 }).toBe('1'); + otherBookId = parseInt(h.dbQuery(`SELECT id FROM libri WHERE titolo = ${h.sqlString(BOOK_WITHOUT)} AND deleted_at IS NULL`), 10); + expect(h.dbQuery(`SELECT IFNULL(fornitore_id, 'NULL') FROM libri WHERE id = ${otherBookId}`)).toBe('NULL'); + + await page.goto(`${h.BASE}/admin/books/${bookId}`); + await expect(page.locator('#book-supplier')).toHaveText(SUPPLIER_RENAMED); + await page.goto(`${h.BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#fornitore_id')).toHaveValue(String(supplierId)); + }); + + test('4. The book list filters by supplier', async () => { + await page.goto(`${h.BASE}/admin/books`); + await page.click('#toggle-advanced'); + await page.selectOption('#fornitore_filter', String(supplierId)); + await page.fill('#search_text', `E2E Libro`); + const rows = page.locator('#libri-table tbody tr'); + await expect(rows.filter({ hasText: BOOK_WITH })).toHaveCount(1, { timeout: 15000 }); + await expect(rows.filter({ hasText: BOOK_WITHOUT })).toHaveCount(0); + await expect(page.locator('#active-filters')).toContainText(SUPPLIER_RENAMED); + + await page.selectOption('#fornitore_filter', String(otherSupplierId)); + await expect(rows.filter({ hasText: BOOK_WITH })).toHaveCount(0, { timeout: 15000 }); + + // The "books of this supplier" shortcut on the suppliers list lands on the same filter. + await page.goto(`${h.BASE}/admin/suppliers`); + await expect(page.locator('[data-supplier-row]', { hasText: SUPPLIER_RENAMED }).locator('[data-supplier-count]')).toHaveText('1'); + await page.locator('[data-supplier-row]', { hasText: SUPPLIER_RENAMED }).getByRole('link', { name: 'Libri di questo fornitore' }).click(); + await expect(page.locator('#fornitore_filter')).toHaveValue(String(supplierId)); + await expect(page.locator('#libri-table tbody tr').filter({ hasText: BOOK_WITH })).toHaveCount(1, { timeout: 15000 }); + }); + + test('5. Deleting a supplier keeps its books and clears their supplier', async () => { + await page.goto(`${h.BASE}/admin/suppliers`); + const row = page.locator('[data-supplier-row]', { hasText: SUPPLIER_RENAMED }); + await row.locator('.supplier-delete-form button[type="submit"]').click(); + await expect(page.locator('.swal2-popup')).toContainText('1 libri collegati'); + await Promise.all([page.waitForURL(/\/admin\/suppliers$/), page.locator('.swal2-confirm').click()]); + + expect(h.dbQuery(`SELECT COUNT(*) FROM fornitori WHERE id = ${supplierId}`)).toBe('0'); + expect(h.dbQuery(`SELECT CONCAT(IFNULL(fornitore_id, 'NULL'), '|', IF(deleted_at IS NULL, 'live', 'gone')) FROM libri WHERE id = ${bookId}`)).toBe('NULL|live'); + await expect(page.locator('[data-supplier-row]', { hasText: SUPPLIER_RENAMED })).toHaveCount(0); + + await page.goto(`${h.BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#fornitore_id')).toHaveValue(''); + await page.goto(`${h.BASE}/admin/books/${bookId}`); + await expect(page.locator('#book-supplier')).toHaveCount(0); + }); +}); From f47898739662a9473efd882e1908512011eacc80 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:46:04 +0100 Subject: [PATCH 043/398] ISBD catalogue cards as 125 x 75 mm PDF One card per page with the call number, the main entry heading, the ISBD description, the standard number and the subject tracings. Printable from the book page and from a selection in the book list; soft-deleted books are never printed. The unit test covers the ISBD assembly and the other pure helpers of the 0.9.0 minor features. --- app/Controllers/CatalogueCardController.php | 86 +++++++ app/Support/CatalogueCard.php | 256 ++++++++++++++++++++ tests/catalogue-cards-090.spec.js | 140 +++++++++++ tests/helpers/minor-features-090.js | 56 +++-- tests/minor-features-090.unit.php | 89 +++++++ 5 files changed, 608 insertions(+), 19 deletions(-) create mode 100644 app/Controllers/CatalogueCardController.php create mode 100644 app/Support/CatalogueCard.php create mode 100644 tests/catalogue-cards-090.spec.js create mode 100644 tests/minor-features-090.unit.php diff --git a/app/Controllers/CatalogueCardController.php b/app/Controllers/CatalogueCardController.php new file mode 100644 index 000000000..421f198a9 --- /dev/null +++ b/app/Controllers/CatalogueCardController.php @@ -0,0 +1,86 @@ +getById($id); + if ($book === null) { + $_SESSION['error_message'] = __('Libro non trovato.'); + return $response->withHeader('Location', url('/admin/books'))->withStatus(302); + } + return $this->pdf($response, [$book], 'scheda_' . $id . '.pdf'); + } + + public function bulk(Request $request, Response $response, mysqli $db): Response + { + $ids = self::parseIds((string) ($request->getQueryParams()['ids'] ?? '')); + $repo = new BookRepository($db); + $books = []; + foreach ($ids as $id) { + $book = $repo->getById($id); + if ($book !== null) { + $books[] = $book; + } + } + if ($books === []) { + $_SESSION['error_message'] = __('Seleziona almeno un libro per stampare le schede catalografiche.'); + return $response->withHeader('Location', url('/admin/books'))->withStatus(302); + } + return $this->pdf($response, $books, 'schede_catalografiche.pdf'); + } + + /** + * "3,1,3,x,2" → [3, 1, 2]: positive ints, first occurrence order, capped. + * + * @return list + */ + public static function parseIds(string $raw): array + { + $ids = []; + foreach (explode(',', $raw) as $part) { + $id = filter_var(trim($part), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($id !== false && !in_array($id, $ids, true)) { + $ids[] = $id; + if (count($ids) >= self::MAX_BULK) { + break; + } + } + } + return $ids; + } + + /** @param list> $books */ + private function pdf(Response $response, array $books, string $filename): Response + { + $name = (string) \App\Support\ConfigStore::get('app.name', 'Biblioteca'); + try { + $content = CatalogueCard::render($books, $name); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('Catalogue card PDF failed', ['exception' => get_class($e), 'message' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile generare le schede catalografiche.'); + return $response->withHeader('Location', url('/admin/books'))->withStatus(302); + } + $response->getBody()->write($content); + return $response + ->withHeader('Content-Type', 'application/pdf') + ->withHeader('Content-Disposition', 'inline; filename="' . $filename . '"'); + } +} diff --git a/app/Support/CatalogueCard.php b/app/Support/CatalogueCard.php new file mode 100644 index 000000000..b544fc199 --- /dev/null +++ b/app/Support/CatalogueCard.php @@ -0,0 +1,256 @@ + $book row from BookRepository::getById() + * @return array{call:string, heading:string, body:string, isbn:string, tracings:string, inventory:string} + */ + public static function lines(array $book): array + { + $authors = self::names($book['autori'] ?? []); + $title = self::clean($book['titolo'] ?? ''); + $subtitle = self::clean($book['sottotitolo'] ?? ''); + + $heading = ''; + $principal = is_array($book['autori'] ?? null) ? ($book['autori'][0] ?? null) : null; + if (is_array($principal)) { + $heading = AuthorName::citation($principal); + } + + // Area 1: title and statement of responsibility + $area1 = $title; + if ($subtitle !== '') { + $area1 .= ' : ' . $subtitle; + } + $responsibility = []; + if ($authors !== []) { + $responsibility[] = implode(', ', $authors); + } + $roles = [ + 'traduttori' => __('traduzione di'), + 'illustratori' => __('illustrazioni di'), + 'curatori' => __('a cura di'), + ]; + foreach ($roles as $key => $phrase) { + $names = self::names($book[$key] ?? []); + if ($names !== []) { + $responsibility[] = $phrase . ' ' . implode(', ', $names); + } + } + if ($responsibility !== []) { + $area1 .= ' / ' . implode(' ; ', $responsibility); + } + $areas = [$area1]; + + // Area 2: edition + $edition = self::clean($book['edizione'] ?? ''); + if ($edition !== '') { + $areas[] = $edition; + } + + // Area 4: publication + $place = self::clean($book['luogo_pubblicazione'] ?? ''); + $publishers = []; + foreach ((array) ($book['editori'] ?? []) as $pub) { + $name = is_array($pub) ? self::clean($pub['nome'] ?? '') : ''; + if ($name !== '') { + $publishers[] = $name; + } + } + if ($publishers === [] && self::clean($book['editore_nome'] ?? '') !== '') { + $publishers[] = self::clean($book['editore_nome']); + } + $year = self::clean($book['anno_pubblicazione'] ?? ''); + $publication = ''; + if ($place !== '') { + $publication = $place; + } + if ($publishers !== []) { + $publication .= ($publication !== '' ? ' : ' : '') . implode(' : ', $publishers); + } + if ($year !== '') { + $publication .= ($publication !== '' ? ', ' : '') . $year; + } + if ($publication !== '') { + $areas[] = $publication; + } + + // Area 5: physical description + $pages = (int) ($book['numero_pagine'] ?? 0); + $physical = $pages > 0 ? $pages . ' p.' : ''; + $dimensions = self::clean($book['dimensioni'] ?? ''); + if ($dimensions !== '') { + $physical .= ($physical !== '' ? ' ; ' : '') . $dimensions; + } + if ($physical !== '') { + $areas[] = $physical; + } + + // Area 6: series + $series = self::clean($book['collana'] ?? ''); + if ($series !== '') { + $number = self::clean($book['numero_serie'] ?? ''); + $areas[] = '(' . $series . ($number !== '' ? ' ; ' . $number : '') . ')'; + } + + // ". – " between areas; an area already ending with a full stop + // (an abbreviation such as "ed.") does not get a second one. + $body = ''; + foreach ($areas as $i => $area) { + if ($i > 0) { + $body .= str_ends_with($body, '.') ? ' – ' : self::AREA; + } + $body .= $area; + } + $body = rtrim($body, '.') . '.'; + + $isbn = self::clean($book['isbn13'] ?? '') ?: self::clean($book['isbn10'] ?? ''); + $isbnLine = $isbn !== '' ? 'ISBN ' . $isbn : ''; + $ean = self::clean($book['ean'] ?? ''); + if ($ean !== '' && $ean !== $isbn) { + $isbnLine .= ($isbnLine !== '' ? self::AREA : '') . 'EAN ' . $ean; + } + + // Tracings: subjects (genre, sub-genre, keywords) then the title added entry + $subjects = []; + foreach (['genere_nome', 'sottogenere_nome'] as $key) { + $value = self::clean($book[$key] ?? ''); + if ($value !== '') { + $subjects[mb_strtolower($value)] ??= $value; + } + } + foreach (explode(',', (string) ($book['parole_chiave'] ?? '')) as $keyword) { + $keyword = self::clean($keyword); + if ($keyword !== '') { + $subjects[mb_strtolower($keyword)] ??= $keyword; + } + } + $tracings = []; + $n = 1; + foreach ($subjects as $subject) { + $tracings[] = $n++ . '. ' . rtrim($subject, '.') . '.'; + } + if ($heading !== '' && $title !== '') { + $tracings[] = 'I. ' . __('Titolo') . '.'; + } + + $call = self::clean($book['collocazione'] ?? ''); + $dewey = self::clean($book['classificazione_dewey'] ?? ''); + if ($call === '') { + $call = $dewey; + } elseif ($dewey !== '') { + $call .= "\n" . $dewey; + } + + return [ + 'call' => $call, + 'heading' => $heading !== '' ? $heading : $title, + 'body' => $body, + 'isbn' => $isbnLine, + 'tracings' => implode(' ', $tracings), + 'inventory' => self::clean($book['numero_inventario'] ?? ''), + ]; + } + + /** + * One card per page, in the order given. + * + * @param list> $books rows from BookRepository::getById() + */ + public static function render(array $books, string $libraryName = ''): string + { + $pdf = new TCPDF('L', 'mm', [self::WIDTH_MM, self::HEIGHT_MM], true, 'UTF-8', false); + $pdf->SetCreator($libraryName !== '' ? $libraryName : 'Pinakes'); + $pdf->SetAuthor($libraryName !== '' ? $libraryName : 'Pinakes'); + $pdf->SetTitle(__('Schede catalografiche')); + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(false); + $pdf->SetMargins(8, 7, 8); + $pdf->SetAutoPageBreak(true, 6); + + foreach ($books as $book) { + $card = self::lines($book); + $pdf->AddPage(); + + // Call number, top left + $pdf->SetFont('dejavusans', 'B', 8); + $pdf->MultiCell(30, 0, $card['call'], 0, 'L', false, 0, 8, 7); + + // Main entry heading + $pdf->SetFont('dejavusans', 'B', 9); + $pdf->MultiCell(0, 0, $card['heading'], 0, 'L', false, 1, 40, 7); + $pdf->Ln(1.5); + + // Body, indented as on a traditional card + $pdf->SetFont('dejavusans', '', 8); + $pdf->SetX(44); + $pdf->MultiCell(0, 0, $card['body'], 0, 'L', false, 1, 44); + if ($card['isbn'] !== '') { + $pdf->Ln(1); + $pdf->MultiCell(0, 0, $card['isbn'], 0, 'L', false, 1, 44); + } + if ($card['tracings'] !== '') { + $pdf->Ln(2); + $pdf->MultiCell(0, 0, $card['tracings'], 0, 'L', false, 1, 44); + } + if ($card['inventory'] !== '') { + $pdf->SetFont('dejavusans', '', 7); + $pdf->MultiCell(40, 0, __('Inv.') . ' ' . $card['inventory'], 0, 'L', false, 1, 8, self::HEIGHT_MM - 12); + } + } + + return $pdf->Output('', 'S'); + } + + /** + * @param mixed $authors list of author rows (nome, pseudonimo) + * @return list + */ + private static function names(mixed $authors): array + { + $out = []; + foreach ((array) $authors as $author) { + if (is_array($author)) { + $name = AuthorName::display($author); + if ($name !== '') { + $out[] = $name; + } + } + } + return $out; + } + + private static function clean(mixed $value): string + { + if (!is_scalar($value)) { + return ''; + } + return trim(preg_replace('/\s+/u', ' ', HtmlHelper::decode((string) $value)) ?? ''); + } +} diff --git a/tests/catalogue-cards-090.spec.js b/tests/catalogue-cards-090.spec.js new file mode 100644 index 000000000..fa7bfa2f1 --- /dev/null +++ b/tests/catalogue-cards-090.spec.js @@ -0,0 +1,140 @@ +// @ts-check +// ISBD catalogue cards (0.9.0): 125 × 75 mm PDF from the book page and from a +// selection in the book list, with the ISBD content; soft-deleted books are +// never printed. +const { test, expect } = require('@playwright/test'); +const h = require('./helpers/minor-features-090'); + +test.skip(!h.configured, 'E2E credentials not configured'); + +const STAMP = Date.now().toString(36); +const TOKEN = `cardz${STAMP}`; +const AUTHOR = `Ottavia Schedari${STAMP}`; +const TITLE_A = `Schedario A ${TOKEN}`; +const TITLE_B = `Schedario B ${TOKEN}`; +const TITLE_DELETED = `Schedario cancellato ${TOKEN}`; + +/** 125 × 75 mm landscape = 354.33 × 212.60 pt. */ +function expectCardSize(buffer) { + const m = buffer.toString('latin1').match(/\/MediaBox\s*\[\s*0(?:\.0+)?\s+0(?:\.0+)?\s+([\d.]+)\s+([\d.]+)\s*\]/); + expect(m, 'the PDF declares a MediaBox').not.toBeNull(); + expect(Math.abs(parseFloat(m[1]) - 354.33)).toBeLessThan(0.1); + expect(Math.abs(parseFloat(m[2]) - 212.6)).toBeLessThan(0.1); +} + +test.describe.serial('Catalogue cards (schede catalografiche)', () => { + let context; + let page; + let authorId = 0; + let publisherId = 0; + let bookA = 0; + let bookB = 0; + let deletedBook = 0; + + test.beforeAll(async ({ browser }) => { + // Setup: an author, a publisher and three records (one soft-deleted). + h.dbQuery(`INSERT INTO autori (nome) VALUES (${h.sqlString(AUTHOR)})`); + authorId = parseInt(h.dbQuery(`SELECT MAX(id) FROM autori WHERE nome = ${h.sqlString(AUTHOR)}`), 10); + h.dbQuery(`INSERT INTO editori (nome) VALUES (${h.sqlString('Editrice Schede ' + STAMP)})`); + publisherId = parseInt(h.dbQuery(`SELECT MAX(id) FROM editori WHERE nome = ${h.sqlString('Editrice Schede ' + STAMP)}`), 10); + bookA = h.seedBook({ + titolo: TITLE_A, sottotitolo: 'una prova', editore_id: String(publisherId), luogo_pubblicazione: 'Bologna', + anno_pubblicazione: '2021', numero_pagine: '312', dimensioni: '22 cm', isbn13: `97888${String(Date.now()).slice(-8)}`, + parole_chiave: 'Biblioteconomia, Cataloghi', classificazione_dewey: '025.3', search_index: TITLE_A.toLowerCase(), + }); + bookB = h.seedBook({ titolo: TITLE_B, anno_pubblicazione: '1999', search_index: TITLE_B.toLowerCase() }); + deletedBook = h.seedBook({ titolo: TITLE_DELETED, search_index: TITLE_DELETED.toLowerCase() }); + h.dbQuery(`UPDATE libri SET deleted_at = NOW() WHERE id = ${deletedBook}`); + h.dbQuery(`INSERT INTO libri_autori (libro_id, autore_id, ruolo, ordine_credito) VALUES (${bookA}, ${authorId}, 'principale', 1)`); + + context = await browser.newContext({ acceptDownloads: true }); + page = await context.newPage(); + await h.loginAsAdmin(page); + }); + + test.afterAll(async () => { + h.deleteBooks([bookA, bookB, deletedBook]); + try { h.dbQuery(`DELETE FROM autori WHERE id = ${authorId}`); } catch (_) { /* cleanup */ } + try { h.dbQuery(`DELETE FROM editori WHERE id = ${publisherId}`); } catch (_) { /* cleanup */ } + await context?.close(); + }); + + test('1. The book page offers a catalogue card that is a 125 × 75 mm PDF', async () => { + await page.goto(`${h.BASE}/admin/books/${bookA}`); + const link = page.locator('#catalogue-card-link'); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('href', new RegExp(`/admin/books/${bookA}/catalogue-card$`)); + const [popup] = await Promise.all([page.waitForEvent('popup'), link.click()]); + await popup.close(); + const resp = await page.request.get(`${h.BASE}/admin/books/${bookA}/catalogue-card`); + expect(resp.status()).toBe(200); + expect(resp.headers()['content-type']).toContain('application/pdf'); + const pdf = await resp.body(); + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-'); + expect(h.pdfPageCount(pdf)).toBe(1); + expectCardSize(pdf); + }); + + test('2. The card carries the ISBD description', async () => { + const pdf = await (await page.request.get(`${h.BASE}/admin/books/${bookA}/catalogue-card`)).body(); + const text = h.pdfText(pdf); + // Main entry: surname first. + expect(text).toContain(`Schedari${STAMP}, Ottavia`); + // Area 1 and the publication area. ISBD punctuation is non-ASCII and + // the PDF wraps long lines, so check the individual elements. + for (const part of [TITLE_A, 'una prova', 'Bologna', `Editrice Schede ${STAMP}`, '2021', '312 p.', '22 cm', 'ISBN 97888', 'Biblioteconomia', 'Cataloghi', '025.3']) { + expect(text, `card contains "${part}"`).toContain(part); + } + }); + + test('3. Selecting books in the list prints one card per book', async () => { + await page.goto(`${h.BASE}/admin/books`); + await page.fill('#search_text', TOKEN); + const rows = page.locator('#libri-table tbody tr'); + await expect(rows.filter({ hasText: TITLE_A })).toHaveCount(1, { timeout: 15000 }); + await expect(rows.filter({ hasText: TITLE_B })).toHaveCount(1); + await expect(rows.filter({ hasText: TITLE_DELETED })).toHaveCount(0); + await rows.filter({ hasText: TITLE_A }).locator('.row-select').check(); + await rows.filter({ hasText: TITLE_B }).locator('.row-select').check(); + await expect(page.locator('#bulk-actions-bar')).toBeVisible(); + + // The PDF opens in a new tab (headless Chromium turns it into a + // download), so read the URL from the request the click issues. + const [request] = await Promise.all([ + context.waitForEvent('request', (r) => r.url().includes('/admin/books/catalogue-cards')), + page.click('#bulk-catalogue-cards'), + ]); + const url = request.url(); + for (const p of context.pages()) { if (p !== page) await p.close().catch(() => {}); } + expect(url).toMatch(/\/admin\/books\/catalogue-cards\?ids=/); + const ids = decodeURIComponent(url.split('ids=')[1]).split(',').map(Number).sort(); + expect(ids).toEqual([bookA, bookB].sort()); + + const resp = await page.request.get(url); + expect(resp.headers()['content-type']).toContain('application/pdf'); + const pdf = await resp.body(); + expect(h.pdfPageCount(pdf)).toBe(2); + expectCardSize(pdf); + const text = h.pdfText(pdf); + expect(text).toContain(TITLE_A); + expect(text).toContain(TITLE_B); + }); + + test('4. A soft-deleted book is never printed', async () => { + const pdf = await (await page.request.get(`${h.BASE}/admin/books/catalogue-cards?ids=${deletedBook},${bookB}`)).body(); + expect(h.pdfPageCount(pdf)).toBe(1); + const text = h.pdfText(pdf); + expect(text).toContain(TITLE_B); + expect(text).not.toContain(TITLE_DELETED); + + await page.goto(`${h.BASE}/admin/books/${deletedBook}/catalogue-card`); + await expect(page).toHaveURL(/\/admin\/books$/); + await expect(page.getByText('Libro non trovato.')).toBeVisible(); + }); + + test('5. A selection with nothing printable goes back to the list with a message', async () => { + await page.goto(`${h.BASE}/admin/books/catalogue-cards?ids=${deletedBook},abc`); + await expect(page).toHaveURL(/\/admin\/books$/); + await expect(page.getByText('Seleziona almeno un libro per stampare le schede catalografiche.')).toBeVisible(); + }); +}); diff --git a/tests/helpers/minor-features-090.js b/tests/helpers/minor-features-090.js index 6f8b06922..0ab26f80d 100644 --- a/tests/helpers/minor-features-090.js +++ b/tests/helpers/minor-features-090.js @@ -42,10 +42,13 @@ async function loginAsAdmin(page) { } await page.fill('input[name="email"]', ADMIN_EMAIL); await page.fill('input[name="password"]', ADMIN_PASS); - await Promise.all([ - page.waitForURL(/\/admin\//, { timeout: 45000, waitUntil: 'domcontentloaded' }), - page.locator('button[type="submit"]').first().click(), - ]); + await page.locator('button[type="submit"]').first().click(); + // The redirect target varies (dashboard, intended page): wait for the + // password field to go away rather than for one specific navigation. + await page.locator('input[name="password"]').waitFor({ state: 'detached', timeout: 45000 }); + if (!/\/admin\//.test(page.url())) { + await page.goto(`${BASE}/admin/dashboard`); + } } /** @@ -80,28 +83,43 @@ function deleteBooks(ids) { } /** - * Text drawn in a TCPDF document. Content streams are zlib-compressed and - * TCPDF writes Unicode text as UTF-16BE, so every stream is inflated and the - * NUL high bytes of ASCII characters are dropped. + * Text drawn in a TCPDF document. TCPDF writes Unicode text as UTF-16BE + * strings inside `[(...)] TJ` operators, in content streams that may or may + * not be deflated. Every text string is decoded and the pieces are joined + * with a space, so a line wrapped at a space reads as the original phrase. */ function pdfText(buffer) { const bin = buffer.toString('latin1'); - let out = ''; - const re = /stream\r?\n/g; - let m; - while ((m = re.exec(bin)) !== null) { - const start = m.index + m[0].length; + const streams = []; + let pos = 0; + for (;;) { + const open = bin.indexOf('stream', pos); + if (open < 0) break; + if (bin.slice(open - 3, open) === 'end') { pos = open + 6; continue; } + let start = open + 6; + if (bin[start] === '\r') start++; + if (bin[start] === '\n') start++; const end = bin.indexOf('endstream', start); if (end < 0) break; - const chunk = Buffer.from(bin.slice(start, end), 'latin1'); - try { - out += zlib.inflateSync(chunk).toString('latin1'); - } catch (_) { - // Not a deflated stream (fonts, images): ignore. + const raw = Buffer.from(bin.slice(start, end), 'latin1'); + let body; + try { body = zlib.inflateSync(raw).toString('latin1'); } catch (_) { body = raw.toString('latin1'); } + streams.push(body); + pos = end + 9; + } + const pieces = []; + for (const body of streams) { + const re = /\[\((.*?)\)\]\s*TJ/gs; + let m; + while ((m = re.exec(body)) !== null) { + const unescaped = m[1].replace(/\\([()\\])/g, '$1').replace(/\\r/g, '\r'); + const bytes = Buffer.from(unescaped, 'latin1'); + let text = ''; + for (let i = 0; i + 1 < bytes.length; i += 2) text += String.fromCharCode((bytes[i] << 8) | bytes[i + 1]); + pieces.push(text); } - re.lastIndex = end; } - return out.replace(/\x00/g, ''); + return pieces.join(' ').replace(/\s+/g, ' '); } function pdfPageCount(buffer) { diff --git a/tests/minor-features-090.unit.php b/tests/minor-features-090.unit.php new file mode 100644 index 000000000..3f047b2c6 --- /dev/null +++ b/tests/minor-features-090.unit.php @@ -0,0 +1,89 @@ + 'txt', 'media' => 'n', 'carrier' => 'nc'], 'a book derives text / unmediated / volume'); +checkMinor(RdaTypes::derive('dvd') === ['content' => 'tdi', 'media' => 'v', 'carrier' => 'vd'], 'a DVD derives moving image / video / videodisc'); +checkMinor(RdaTypes::derive('audiolibro')['content'] === 'spw' && RdaTypes::derive('disco')['content'] === 'prm', 'audiobook is spoken word, a record is performed music'); +checkMinor(RdaTypes::derive(null) === RdaTypes::derive('libro') && RdaTypes::derive('') === RdaTypes::derive('libro'), 'an empty media type derives as a book'); +checkMinor(RdaTypes::normalize('carrier', ' CR ') === 'cr', 'an override is trimmed and lower-cased'); +checkMinor(RdaTypes::normalize('carrier', 'nope') === null && RdaTypes::normalize('carrier', '') === null && RdaTypes::normalize('media', ['n']) === null, 'unknown, blank or non-string overrides are NULL'); +checkMinor(RdaTypes::normalize('content', 'nc') === null, 'a code from another vocabulary is rejected'); +$book = ['tipo_media' => 'libro', 'rda_carrier_type' => 'cr', 'rda_media_type' => 'c', 'rda_content_type' => null]; +checkMinor(RdaTypes::overrides($book) === ['media' => 'c', 'carrier' => 'cr'], 'overrides() lists only the stored overrides'); +checkMinor(RdaTypes::effective($book) === ['content' => 'txt', 'media' => 'c', 'carrier' => 'cr'], 'effective() mixes overrides over the derived triple'); +checkMinor(RdaTypes::overrides(['tipo_media' => 'dvd']) === [], 'a record without overrides has none (BIBFRAME keeps its historical output)'); +checkMinor(RdaTypes::uri('carrier', 'cr') === 'http://id.loc.gov/vocabulary/carriers/cr' && RdaTypes::uri('content', 'txt') === 'http://id.loc.gov/vocabulary/contentTypes/txt', 'codes map to id.loc.gov URIs'); +foreach (RdaTypes::kinds() as $kind) { + foreach (array_keys(MediaLabelsKeys()) as $media) { + checkMinor(isset(RdaTypes::options($kind)[RdaTypes::derive($media)[$kind]]), "derived {$kind} of {$media} is a code of its vocabulary"); + } +} +function MediaLabelsKeys(): array { return \App\Support\MediaLabels::allTypes(); } + +// ── ISBD catalogue card ── +$card = CatalogueCard::lines([ + 'titolo' => 'Il sistema periodico', 'sottotitolo' => 'racconti', + 'autori' => [['nome' => 'Primo Levi', 'pseudonimo' => null]], + 'traduttori' => [['nome' => 'Ann Goldstein', 'pseudonimo' => null]], + 'edizione' => '3. ed.', 'luogo_pubblicazione' => 'Torino', 'editori' => [['id' => 1, 'nome' => 'Einaudi']], + 'anno_pubblicazione' => 1975, 'numero_pagine' => 245, 'dimensioni' => '21 cm', + 'collana' => 'Nuovi coralli', 'numero_serie' => '16', 'isbn13' => '9788806172268', + 'genere_nome' => 'Narrativa', 'parole_chiave' => 'Chimica, Memorie, chimica', + 'collocazione' => 'A.1.3', 'classificazione_dewey' => '853.914', 'numero_inventario' => 'INV-77', +]); +checkMinor($card['heading'] === 'Levi, Primo', 'the main entry is the principal author, surname first'); +checkMinor(str_starts_with($card['body'], 'Il sistema periodico : racconti / Primo Levi ; traduzione di Ann Goldstein'), 'area 1: title : subtitle / responsibility ; translator'); +checkMinor(str_contains($card['body'], '. – 3. ed. – Torino : Einaudi, 1975. – 245 p. ; 21 cm. – (Nuovi coralli ; 16).'), 'areas 2, 4, 5 and 6 follow with ISBD punctuation'); +checkMinor($card['isbn'] === 'ISBN 9788806172268', 'the standard number line'); +checkMinor($card['tracings'] === '1. Narrativa. 2. Chimica. 3. Memorie. I. Titolo.', 'tracings: genre and keywords once each (case-insensitive), then the title entry'); +checkMinor($card['call'] === "A.1.3\n853.914" && $card['inventory'] === 'INV-77', 'call number carries shelf mark and Dewey, inventory number kept'); +$anon = CatalogueCard::lines(['titolo' => 'Anonimo & basta', 'autori' => [], 'editori' => [], 'editore_nome' => 'Sellerio']); +checkMinor($anon['heading'] === 'Anonimo & basta' && $anon['body'] === 'Anonimo & basta. – Sellerio.', 'without an author the title is the heading; HTML entities are decoded'); +checkMinor($anon['tracings'] === '', 'no subjects and no author: no tracings'); +$pdf = CatalogueCard::render([['titolo' => 'Uno', 'autori' => []], ['titolo' => 'Due', 'autori' => []]], 'Biblioteca'); +checkMinor(str_starts_with($pdf, '%PDF-'), 'render() produces a PDF'); +checkMinor(preg_match_all('#/Type\s*/Page[^s]#', $pdf) === 2, 'one card per book (two pages)'); +checkMinor(preg_match('#/MediaBox\s*\[\s*0(?:\.0+)?\s+0(?:\.0+)?\s+(354\.\d+)\s+(212\.\d+)\s*\]#', $pdf, $m) === 1, 'the page is 125 × 75 mm landscape (354.33 × 212.60 pt)'); + +// ── Bulk ids ── +checkMinor(CatalogueCardController::parseIds('3, 1,3,x,-2,0,2') === [3, 1, 2], 'bulk ids: positive integers, first-seen order, no duplicates'); +checkMinor(count(CatalogueCardController::parseIds(implode(',', range(1, 500)))) === CatalogueCardController::MAX_BULK, 'bulk print is capped'); + +// ── Suppliers ── +$s = SupplierRepository::normalize(['nome' => ' Libreria Rossi ', 'email' => 'not-an-email', 'sito_web' => 'www.rossi.it', 'telefono' => '', 'note' => ['x']]); +checkMinor($s['nome'] === 'Libreria Rossi' && $s['telefono'] === null && $s['note'] === null, 'supplier fields are trimmed, blanks and non-scalars become NULL'); +checkMinor($s['email'] === null && $s['sito_web'] === 'https://www.rossi.it', 'invalid email dropped, scheme-less website gets https'); +checkMinor(SupplierRepository::normalize(['sito_web' => 'javascript:alert(1)'])['sito_web'] === null, 'a non-http website is rejected'); +checkMinor(array_keys($s) === SupplierRepository::FIELDS, 'normalize() returns exactly the writable fields'); + +// ── Visitor log helpers ── +checkMinor(AttendanceRepository::shortName('Mario', 'rossi') === 'Mario R.' && AttendanceRepository::shortName('Cher', '') === 'Cher', 'kiosk names are shortened to the initial'); +checkMinor(AttendanceRepository::normalizeRetention('30') === 30 && AttendanceRepository::normalizeRetention('0') === 0, 'retention accepts days and 0 (keep forever)'); +checkMinor(AttendanceRepository::normalizeRetention('-5') === 0 && AttendanceRepository::normalizeRetention('999999') === AttendanceRepository::MAX_RETENTION_DAYS, 'retention is clamped'); +checkMinor(AttendanceRepository::normalizeRetention('abc') === AttendanceRepository::DEFAULT_RETENTION_DAYS, 'non-numeric retention falls back to the default'); +checkMinor(AttendanceRepository::clampDays(0) === 1 && AttendanceRepository::clampDays(10000) === 366, 'statistics period is clamped'); + +// ── Statistics extension points ── +checkMinor(StatisticsSections::tableSection(null) === null && StatisticsSections::tableSection(['x' => 1]) === null, 'no provider data hides the section'); +checkMinor(StatisticsSections::tableSection(['rows' => [['label' => 'A', 'value' => 3], ['label' => 'B'], 'junk', ['label' => 'C', 'value' => [1]]], 'note' => 'n']) + === ['rows' => [['label' => 'A', 'value' => 3]], 'note' => 'n'], 'malformed rows are dropped, the note is kept'); + +echo "ALL {$count} PASS\n"; From c3a9b66ff478b4282247c3c6443c20990fba3011 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:50:50 +0100 Subject: [PATCH 044/398] Visitor log kiosk with day and hour-slot statistics /admin/attendance records a visit from a member card scan or a short guest form, shows visits per day and per hour slot with a CSV export, and keeps the data only for the configured number of days (admin setting, default 365, 0 = forever). The scheduled maintenance purges older visits and the admin can apply the retention immediately. --- app/Controllers/AttendanceController.php | 165 ++++++++++++ app/Models/AttendanceRepository.php | 303 ++++++++++++++++++++++ app/Support/MaintenanceService.php | 17 +- app/Views/attendance/index.php | 312 +++++++++++++++++++++++ cron/full-maintenance.php | 1 + tests/attendance-090.spec.js | 132 ++++++++++ 6 files changed, 928 insertions(+), 2 deletions(-) create mode 100644 app/Controllers/AttendanceController.php create mode 100644 app/Models/AttendanceRepository.php create mode 100644 app/Views/attendance/index.php create mode 100644 tests/attendance-090.spec.js diff --git a/app/Controllers/AttendanceController.php b/app/Controllers/AttendanceController.php new file mode 100644 index 000000000..6cc92351b --- /dev/null +++ b/app/Controllers/AttendanceController.php @@ -0,0 +1,165 @@ +period($request); + $data = [ + 'days' => $days, + 'periods' => self::PERIODS, + 'daily' => $repo->dailyCounts($days), + 'hourly' => $repo->hourlyCounts($days), + 'totals' => $repo->totals($days), + 'today' => $repo->totals(1), + 'recent' => $repo->recent(10), + 'branches' => $repo->branches(), + 'retentionDays' => AttendanceRepository::retentionDays(new SettingsRepository($db)), + 'isAdmin' => ($_SESSION['user']['tipo_utente'] ?? '') === 'admin', + ]; + ob_start(); + require __DIR__ . '/../Views/attendance/index.php'; + $content = ob_get_clean(); + ob_start(); + require __DIR__ . '/../Views/layout.php'; + $html = ob_get_clean(); + $response->getBody()->write((string) $html); + return $response; + } + + public function scan(Request $request, Response $response, mysqli $db): Response + { + $body = (array) $request->getParsedBody(); + $repo = new AttendanceRepository($db); + $card = is_string($body['codice_tessera'] ?? null) ? $body['codice_tessera'] : ''; + $member = $repo->findMemberByCard($card); + if ($member === null) { + $_SESSION['error_message'] = __('Tessera non riconosciuta. Controlla il codice o registra la visita come ospite.'); + return $this->back($response); + } + try { + $repo->recordMember($member['id'], $this->rememberBranch($repo, $body)); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('Attendance scan failed', ['exception' => get_class($e), 'code' => $e->getCode()]); + $_SESSION['error_message'] = __('Impossibile registrare la presenza. Riprova.'); + return $this->back($response); + } + $name = AttendanceRepository::shortName($member['nome'], $member['cognome']); + $_SESSION['success_message'] = $member['stato'] === 'attivo' + ? sprintf(__('Benvenuto/a, %s: presenza registrata.'), $name) + : sprintf(__('Presenza registrata per %s, ma la tessera non è attiva.'), $name); + return $this->back($response); + } + + public function guest(Request $request, Response $response, mysqli $db): Response + { + $body = (array) $request->getParsedBody(); + $repo = new AttendanceRepository($db); + $name = is_string($body['nome_ospite'] ?? null) ? $body['nome_ospite'] : null; + $reason = is_string($body['motivo'] ?? null) ? $body['motivo'] : null; + try { + $repo->recordGuest($name, $reason, $this->rememberBranch($repo, $body)); + $_SESSION['success_message'] = __('Presenza dell\'ospite registrata.'); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('Attendance guest failed', ['exception' => get_class($e), 'code' => $e->getCode()]); + $_SESSION['error_message'] = __('Impossibile registrare la presenza. Riprova.'); + } + return $this->back($response); + } + + /** Retention setting. AdminAuthMiddleware admits staff: this one is admin-only. */ + public function saveSettings(Request $request, Response $response, mysqli $db): Response + { + if (!$this->isAdmin()) { + $_SESSION['error_message'] = __('Operazione riservata agli amministratori.'); + return $this->back($response); + } + $body = (array) $request->getParsedBody(); + $days = AttendanceRepository::normalizeRetention($body['retention_days'] ?? null); + (new SettingsRepository($db))->set(AttendanceRepository::SETTINGS_CATEGORY, AttendanceRepository::SETTING_RETENTION, (string) $days); + $_SESSION['success_message'] = $days === 0 + ? __('Conservazione aggiornata: le presenze non verranno cancellate automaticamente.') + : sprintf(__('Conservazione aggiornata: le presenze più vecchie di %d giorni verranno cancellate.'), $days); + return $this->back($response); + } + + /** Apply the retention now instead of waiting for the next maintenance run. */ + public function purge(Request $request, Response $response, mysqli $db): Response + { + if (!$this->isAdmin()) { + $_SESSION['error_message'] = __('Operazione riservata agli amministratori.'); + return $this->back($response); + } + $days = AttendanceRepository::retentionDays(new SettingsRepository($db)); + $deleted = (new AttendanceRepository($db))->purge($days); + $_SESSION['success_message'] = sprintf(__('Presenze cancellate: %d.'), $deleted); + return $this->back($response); + } + + /** Daily counts for the selected period as CSV. */ + public function exportCsv(Request $request, Response $response, mysqli $db): Response + { + $days = $this->period($request); + $rows = (new AttendanceRepository($db))->dailyCounts($days); + $out = fopen('php://temp', 'r+'); + if ($out === false) { + return $response->withStatus(500); + } + fwrite($out, "\xEF\xBB\xBF"); + fputcsv($out, [__('Data'), __('Soci'), __('Ospiti'), __('Totale')], ';', '"', ''); + foreach ($rows as $row) { + fputcsv($out, [$row['giorno'], $row['soci'], $row['ospiti'], $row['totale']], ';', '"', ''); + } + rewind($out); + $csv = (string) stream_get_contents($out); + fclose($out); + $response->getBody()->write($csv); + return $response + ->withHeader('Content-Type', 'text/csv; charset=UTF-8') + ->withHeader('Content-Disposition', 'attachment; filename="presenze_' . $days . 'g.csv"'); + } + + private function period(Request $request): int + { + $days = (int) ($request->getQueryParams()['days'] ?? 30); + return in_array($days, self::PERIODS, true) ? $days : 30; + } + + /** + * The kiosk keeps the branch chosen for the previous visit selected. + * + * @param array $body + */ + private function rememberBranch(AttendanceRepository $repo, array $body): ?int + { + $sedeId = $repo->validBranchId($body['sede_id'] ?? null); + $_SESSION['attendance_sede_id'] = $sedeId; + return $sedeId; + } + + private function isAdmin(): bool + { + return ($_SESSION['user']['tipo_utente'] ?? '') === 'admin'; + } + + private function back(Response $response): Response + { + return $response->withHeader('Location', url('/admin/attendance'))->withStatus(302); + } +} diff --git a/app/Models/AttendanceRepository.php b/app/Models/AttendanceRepository.php new file mode 100644 index 000000000..74f8ad25d --- /dev/null +++ b/app/Models/AttendanceRepository.php @@ -0,0 +1,303 @@ +db->query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'presenze'"); + return $res instanceof \mysqli_result && $res->num_rows > 0; + } catch (\Throwable) { + return false; + } + } + + /** + * Find the member owning a card code (exact match, surrounding blanks ignored). + * + * @return array{id:int, nome:string, cognome:string, stato:string}|null + */ + public function findMemberByCard(string $card): ?array + { + $card = trim($card); + if ($card === '' || mb_strlen($card) > 20) { + return null; + } + $stmt = $this->db->prepare('SELECT id, nome, cognome, stato FROM utenti WHERE codice_tessera = ? LIMIT 1'); + $stmt->bind_param('s', $card); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!$row) { + return null; + } + return [ + 'id' => (int) $row['id'], + 'nome' => (string) $row['nome'], + 'cognome' => (string) $row['cognome'], + 'stato' => (string) ($row['stato'] ?? 'attivo'), + ]; + } + + public function recordMember(int $userId, ?int $sedeId): int + { + return $this->insert('socio', $userId, $sedeId, null, null); + } + + public function recordGuest(?string $name, ?string $reason, ?int $sedeId): int + { + return $this->insert('ospite', null, $sedeId, self::clip($name, 150), self::clip($reason, 255)); + } + + /** + * Active branches (sedi) for the kiosk selector; empty on single-site installs. + * + * @return array + */ + public function branches(): array + { + $out = []; + try { + $res = $this->db->query('SELECT id, nome FROM sedi WHERE is_active = 1 ORDER BY nome ASC'); + if ($res instanceof \mysqli_result) { + while ($row = $res->fetch_assoc()) { + $out[(int) $row['id']] = (string) $row['nome']; + } + } + } catch (\Throwable) { + return []; + } + return $out; + } + + /** A submitted branch id that exists and is active, or NULL. */ + public function validBranchId(mixed $raw): ?int + { + $id = filter_var($raw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($id === false) { + return null; + } + return array_key_exists($id, $this->branches()) ? $id : null; + } + + /** + * Visits per day over the last $days days (today included), oldest first, + * with zero rows for days without visits. + * + * @return list + */ + public function dailyCounts(int $days): array + { + [$from, $to] = self::window($days); + $stmt = $this->db->prepare( + "SELECT DATE(created_at) AS giorno, + SUM(tipo = 'socio') AS soci, + SUM(tipo = 'ospite') AS ospiti, + COUNT(*) AS totale + FROM presenze + WHERE created_at >= ? AND created_at < ? + GROUP BY DATE(created_at)" + ); + $stmt->bind_param('ss', $from, $to); + $stmt->execute(); + $res = $stmt->get_result(); + $byDay = []; + while ($row = $res->fetch_assoc()) { + $byDay[(string) $row['giorno']] = $row; + } + $stmt->close(); + + $out = []; + $cursor = new \DateTimeImmutable(substr($from, 0, 10)); + for ($i = 0; $i < self::clampDays($days); $i++) { + $day = $cursor->modify('+' . $i . ' day')->format('Y-m-d'); + $row = $byDay[$day] ?? null; + $out[] = [ + 'giorno' => $day, + 'soci' => (int) ($row['soci'] ?? 0), + 'ospiti' => (int) ($row['ospiti'] ?? 0), + 'totale' => (int) ($row['totale'] ?? 0), + ]; + } + return $out; + } + + /** + * Visits per hour slot (0–23) over the last $days days. + * + * @return array + */ + public function hourlyCounts(int $days): array + { + [$from, $to] = self::window($days); + $stmt = $this->db->prepare( + 'SELECT HOUR(created_at) AS ora, COUNT(*) AS totale + FROM presenze + WHERE created_at >= ? AND created_at < ? + GROUP BY HOUR(created_at)' + ); + $stmt->bind_param('ss', $from, $to); + $stmt->execute(); + $res = $stmt->get_result(); + $out = array_fill(0, 24, 0); + while ($row = $res->fetch_assoc()) { + $out[(int) $row['ora']] = (int) $row['totale']; + } + $stmt->close(); + return $out; + } + + /** + * @return array{soci:int, ospiti:int, totale:int} + */ + public function totals(int $days): array + { + $totals = ['soci' => 0, 'ospiti' => 0, 'totale' => 0]; + foreach ($this->dailyCounts($days) as $row) { + $totals['soci'] += $row['soci']; + $totals['ospiti'] += $row['ospiti']; + $totals['totale'] += $row['totale']; + } + return $totals; + } + + /** + * Latest visits for the kiosk feed. Member names are shortened to + * "Name S." so the screen facing the desk shows as little as possible. + * + * @return list + */ + public function recent(int $limit = 10): array + { + $limit = max(1, min(50, $limit)); + $res = $this->db->query( + 'SELECT p.created_at, p.tipo, p.nome_ospite, u.nome, u.cognome + FROM presenze p + LEFT JOIN utenti u ON u.id = p.utente_id + ORDER BY p.created_at DESC, p.id DESC + LIMIT ' . $limit + ); + $out = []; + if ($res instanceof \mysqli_result) { + while ($row = $res->fetch_assoc()) { + $label = $row['tipo'] === 'socio' + ? self::shortName((string) ($row['nome'] ?? ''), (string) ($row['cognome'] ?? '')) + : trim((string) ($row['nome_ospite'] ?? '')); + $out[] = ['created_at' => (string) $row['created_at'], 'tipo' => (string) $row['tipo'], 'etichetta' => $label]; + } + } + return $out; + } + + /** + * Delete visits older than the retention window. 0 days = keep forever. + * The cut-off is midnight (application timezone) $retentionDays days ago, + * so a visit is kept for at least $retentionDays whole days. + */ + public function purge(int $retentionDays): int + { + if ($retentionDays <= 0) { + return 0; + } + $cutoff = (new \DateTimeImmutable(DateHelper::today())) + ->modify('-' . min($retentionDays, self::MAX_RETENTION_DAYS) . ' day') + ->format('Y-m-d 00:00:00'); + $stmt = $this->db->prepare('DELETE FROM presenze WHERE created_at < ?'); + $stmt->bind_param('s', $cutoff); + $stmt->execute(); + $deleted = (int) $stmt->affected_rows; + $stmt->close(); + return max(0, $deleted); + } + + /** Configured retention in days (0 = keep forever). */ + public static function retentionDays(SettingsRepository $settings): int + { + $raw = $settings->get(self::SETTINGS_CATEGORY, self::SETTING_RETENTION, null); + if ($raw === null || trim($raw) === '' || !is_numeric($raw)) { + return self::DEFAULT_RETENTION_DAYS; + } + return self::normalizeRetention($raw); + } + + /** Clamp a submitted retention to 0..MAX_RETENTION_DAYS (non-numeric → default). */ + public static function normalizeRetention(mixed $raw): int + { + $days = filter_var($raw, FILTER_VALIDATE_INT); + if ($days === false) { + return self::DEFAULT_RETENTION_DAYS; + } + return max(0, min(self::MAX_RETENTION_DAYS, $days)); + } + + /** "Mario Rossi" → "Mario R." */ + public static function shortName(string $first, string $last): string + { + $first = trim($first); + $last = trim($last); + if ($last === '') { + return $first; + } + return trim($first . ' ' . mb_strtoupper(mb_substr($last, 0, 1)) . '.'); + } + + public static function clampDays(int $days): int + { + return max(1, min(366, $days)); + } + + /** + * [from, to) bounds of the last $days days in the application timezone. + * + * @return array{0:string, 1:string} + */ + public static function window(int $days): array + { + $today = new \DateTimeImmutable(DateHelper::today()); + $from = $today->modify('-' . (self::clampDays($days) - 1) . ' day')->format('Y-m-d 00:00:00'); + $to = $today->modify('+1 day')->format('Y-m-d 00:00:00'); + return [$from, $to]; + } + + private function insert(string $type, ?int $userId, ?int $sedeId, ?string $guestName, ?string $reason): int + { + $now = DateHelper::now(); + $stmt = $this->db->prepare('INSERT INTO presenze (utente_id, sede_id, tipo, nome_ospite, motivo, created_at) VALUES (?, ?, ?, ?, ?, ?)'); + $stmt->bind_param('iissss', $userId, $sedeId, $type, $guestName, $reason, $now); + $stmt->execute(); + $id = (int) $this->db->insert_id; + $stmt->close(); + return $id; + } + + private static function clip(?string $value, int $max): ?string + { + $value = trim((string) $value); + return $value === '' ? null : mb_substr($value, 0, $max); + } +} diff --git a/app/Support/MaintenanceService.php b/app/Support/MaintenanceService.php index f58160d02..6a305a92f 100644 --- a/app/Support/MaintenanceService.php +++ b/app/Support/MaintenanceService.php @@ -107,7 +107,7 @@ public function runIfNeeded(int $cooldownMinutes = 60): array * overdue loan updates, expired pickups, notifications, and ICS calendar generation. * Each task is wrapped in try-catch to prevent failures from blocking others. * - * @return array{skipped: true, reason: 'in_progress'}|array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, errors: array} Results for each maintenance task, or the skip marker when another connection holds the maintenance lock + * @return array{skipped: true, reason: 'in_progress'}|array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, errors: array} Results for each maintenance task, or the skip marker when another connection holds the maintenance lock */ public function runAll(): array { @@ -147,7 +147,7 @@ public function runAll(): array } } - /** @return array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, errors: array} */ + /** @return array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, errors: array} */ private function runAllLocked(): array { $results = [ @@ -321,6 +321,19 @@ private function runAllLocked(): array SecureLogger::error(__('MaintenanceService errore pulizia sessioni scadute'), ['error' => $e->getMessage()]); } + // Visitor log retention (GDPR, 0.9.0): visits older than the + // configured number of days are deleted. 0 days = keep forever; with + // no setting the default retention applies. + try { + $attendance = new \App\Models\AttendanceRepository($this->db); + $results['attendance_purged'] = $attendance->isAvailable() + ? $attendance->purge(\App\Models\AttendanceRepository::retentionDays(new \App\Models\SettingsRepository($this->db))) + : 0; + } catch (\Throwable $e) { + $results['errors'][] = 'purgeAttendance: ' . $e->getMessage(); + SecureLogger::error(__('MaintenanceService errore pulizia presenze'), ['error' => $e->getMessage()]); + } + // Generic post-maintenance hook, fired as the TRUE last step so // listeners (e.g. Book Club poll auto-close + reminders) only run // once the whole maintenance pass — ICS generation included — is diff --git a/app/Views/attendance/index.php b/app/Views/attendance/index.php new file mode 100644 index 000000000..d15edfad0 --- /dev/null +++ b/app/Views/attendance/index.php @@ -0,0 +1,312 @@ +, daily:list, hourly:array, + * totals:array, today:array, recent:list, branches:array, + * retentionDays:int, isAdmin:bool + * } + */ +use App\Support\Csrf; +use App\Support\HtmlHelper; + +$csrf = Csrf::ensureToken(); +$days = (int) $data['days']; +$branches = $data['branches']; +$selectedBranch = (int) ($_SESSION['attendance_sede_id'] ?? 0); +$hourly = $data['hourly']; +$maxHour = max(1, ...array_values($hourly)); +$branchSelect = static function (string $id) use ($branches, $selectedBranch): void { + if ($branches === []) { + return; + } + ?> +
      + + +
      + +
      +

      +

      + +
      + + + +
      + + + +
      +
      +
      +

      + + +

      +
      +
      +
      + +
      + + +
      + + +
      +
      +
      + +
      +
      +

      + + +

      +
      +
      +
      + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      +
      + + +
      +
      +

      +

      +
      +
      +

      +

      +
      +
      +

      +

      +
      +
      +

      +

      +
      +
      + + +
      + : + + > + + + +
      + +
      + +
      +
      +

      + + +

      +
      +
      +
      + + + + + + + + + $count): if ($count === 0) { continue; } ?> + + + + + + + + + +
      +
      +
      + +
      +
      +
      +
      +
      + + +
      +
      +

      + + +

      +
      +
      +
      + + + + + + + + + + + + + + + + + + + + + + +
      +
      +
      +
      +
      + +
      + +
      +
      +

      + + +

      +
      +
      +
        + +
      • + + +
      • + + + + + + + + + +
      • + +
      +
      +
      + + +
      +
      +

      + + +

      +
      +
      +

      + 0): ?> + + + + + +

      + +
      + +
      + + +

      +
      + +
      +
      + + +
      + +
      +
      +
      +

      + diff --git a/cron/full-maintenance.php b/cron/full-maintenance.php index 480cfe2bf..51e744c73 100644 --- a/cron/full-maintenance.php +++ b/cron/full-maintenance.php @@ -153,6 +153,7 @@ function logMessage(string $message): void { logMessage("- Expired reservations (prenotato -> scaduto): " . $results['expired_reservations']); logMessage("- Expired pickups (da_ritirare -> scaduto): " . $results['expired_pickups']); logMessage("- Overdue loans updated (in_corso -> in_ritardo): " . $results['overdue_loans_updated']); + logMessage("- Visitor log rows purged (retention): " . ($results['attendance_purged'] ?? 0)); // Notification results (counts returned at top-level by MaintenanceService::runAll) logMessage("Notifications sent:"); diff --git a/tests/attendance-090.spec.js b/tests/attendance-090.spec.js new file mode 100644 index 000000000..545a6d5aa --- /dev/null +++ b/tests/attendance-090.spec.js @@ -0,0 +1,132 @@ +// @ts-check +// Visitor log (0.9.0): member card scan, guest form, statistics by day and +// hour slot, CSV export, GDPR retention setting and purge (manual and cron). +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const path = require('path'); +const h = require('./helpers/minor-features-090'); + +test.skip(!h.configured, 'E2E credentials not configured'); + +const STAMP = Date.now().toString(36); +const CARD = `E2EP${STAMP}`.slice(0, 20); +const REASON = `E2E studio ${STAMP}`; + +test.describe.serial('Visitor log (presenze)', () => { + let context; + let page; + let userId = 0; + let previousRetention = null; + + const visits = (where) => parseInt(h.dbQuery(`SELECT COUNT(*) FROM presenze WHERE ${where}`), 10); + + test.beforeAll(async ({ browser }) => { + h.dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente) VALUES (${h.sqlString(CARD)}, 'Mario', 'Rossini', ${h.sqlString(`presenze-${STAMP}@example.test`)}, 'x', 'attivo', 'standard')`); + userId = parseInt(h.dbQuery(`SELECT id FROM utenti WHERE codice_tessera = ${h.sqlString(CARD)}`), 10); + const prev = h.dbQuery("SELECT setting_value FROM system_settings WHERE category = 'attendance' AND setting_key = 'retention_days'"); + previousRetention = prev === '' ? null : prev; + context = await browser.newContext(); + page = await context.newPage(); + await h.loginAsAdmin(page); + }); + + test.afterAll(async () => { + try { h.dbQuery(`DELETE FROM presenze WHERE utente_id = ${userId} OR motivo LIKE 'E2E %${STAMP}'`); } catch (_) { /* cleanup */ } + try { h.dbQuery(`DELETE FROM utenti WHERE id = ${userId}`); } catch (_) { /* cleanup */ } + try { + h.dbQuery(previousRetention === null + ? "DELETE FROM system_settings WHERE category = 'attendance' AND setting_key = 'retention_days'" + : `UPDATE system_settings SET setting_value = ${h.sqlString(previousRetention)} WHERE category = 'attendance' AND setting_key = 'retention_days'`); + } catch (_) { /* cleanup */ } + await context?.close(); + }); + + test('1. Scanning a member card records the visit and greets the member', async () => { + await page.goto(`${h.BASE}/admin/attendance`); + await expect(page.locator('#codice_tessera')).toBeFocused(); + // A card reader types the code and sends Enter. + await page.locator('#codice_tessera').fill(CARD); + await Promise.all([page.waitForURL(/\/admin\/attendance$/), page.locator('#codice_tessera').press('Enter')]); + await expect(page.getByText('Benvenuto/a, Mario R.: presenza registrata.')).toBeVisible(); + expect(visits(`utente_id = ${userId} AND tipo = 'socio'`)).toBe(1); + // The kiosk feed shows the shortened name only. + await expect(page.locator('#attendance-recent')).toContainText('Mario R.'); + await expect(page.locator('#attendance-recent')).not.toContainText('Rossini'); + }); + + test('2. An unknown card is refused and nothing is recorded', async () => { + const before = visits('1=1'); + await page.fill('#codice_tessera', `NOPE${STAMP}`.slice(0, 20)); + await Promise.all([page.waitForURL(/\/admin\/attendance$/), page.click('#attendance-scan-form button[type="submit"]')]); + await expect(page.getByText('Tessera non riconosciuta. Controlla il codice o registra la visita come ospite.')).toBeVisible(); + expect(visits('1=1')).toBe(before); + }); + + test('3. A guest is recorded with the optional reason', async () => { + await page.fill('#motivo', REASON); + await Promise.all([page.waitForURL(/\/admin\/attendance$/), page.click('#attendance-guest-form button[type="submit"]')]); + await expect(page.getByText("Presenza dell'ospite registrata.")).toBeVisible(); + expect(h.dbQuery(`SELECT CONCAT(tipo, '|', IFNULL(utente_id, 'NULL'), '|', IFNULL(nome_ospite, 'NULL')) FROM presenze WHERE motivo = ${h.sqlString(REASON)}`)).toBe('ospite|NULL|NULL'); + }); + + test('4. Statistics count the day and the hour slot, and export as CSV', async () => { + // Expected values come from the database (Europe/Rome via the app), never the runner clock. + const day = h.dbQuery(`SELECT DATE(created_at) FROM presenze WHERE utente_id = ${userId} LIMIT 1`); + const hour = parseInt(h.dbQuery(`SELECT HOUR(created_at) FROM presenze WHERE utente_id = ${userId} LIMIT 1`), 10); + const dayTotal = h.dbQuery(`SELECT CONCAT(SUM(tipo='socio'), '|', SUM(tipo='ospite'), '|', COUNT(*)) FROM presenze WHERE DATE(created_at) = '${day}'`); + const hourTotal = h.dbQuery(`SELECT COUNT(*) FROM presenze WHERE DATE(created_at) >= '${day}' - INTERVAL 29 DAY AND HOUR(created_at) = ${hour}`); + + await page.goto(`${h.BASE}/admin/attendance`); + const row = page.locator(`#attendance-daily tr[data-day="${day}"]`); + const [soci, ospiti, totale] = dayTotal.split('|'); + await expect(row.locator('[data-col="soci"]')).toHaveText(soci); + await expect(row.locator('[data-col="ospiti"]')).toHaveText(ospiti); + await expect(row.locator('[data-col="totale"]')).toHaveText(totale); + await expect(page.locator(`#attendance-hourly tr[data-hour="${hour}"] [data-hour-count]`)).toHaveText(hourTotal); + await expect(page.locator('#attendance-today')).toHaveText(totale); + + await page.getByRole('link', { name: '7 giorni' }).click(); + await expect(page).toHaveURL(/days=7/); + await expect(page.locator(`#attendance-daily tr[data-day="${day}"] [data-col="totale"]`)).toHaveText(totale); + + const csv = await page.request.get(`${h.BASE}/admin/attendance/export.csv?days=7`); + expect(csv.headers()['content-type']).toContain('text/csv'); + const lines = (await csv.text()).replace(/^/, '').trim().split(/\r?\n/); + expect(lines[0]).toBe('Data;Soci;Ospiti;Totale'); + expect(lines).toHaveLength(8); + expect(lines).toContain(`${day};${soci};${ospiti};${totale}`); + }); + + test('5. The admin sets the retention and the GDPR note follows it', async () => { + await page.goto(`${h.BASE}/admin/attendance`); + await page.fill('#retention_days', '30'); + await Promise.all([page.waitForURL(/\/admin\/attendance$/), page.click('#attendance-retention-form button[type="submit"]')]); + await expect(page.getByText('Conservazione aggiornata: le presenze più vecchie di 30 giorni verranno cancellate.')).toBeVisible(); + expect(h.dbQuery("SELECT setting_value FROM system_settings WHERE category = 'attendance' AND setting_key = 'retention_days'")).toBe('30'); + await expect(page.locator('#retention_days')).toHaveValue('30'); + await expect(page.locator('#attendance-gdpr-note')).toContainText('conservate per 30 giorni'); + }); + + test('6. "Apply now" purges only the visits older than the retention', async () => { + h.dbQuery(`INSERT INTO presenze (tipo, motivo, created_at) VALUES ('ospite', 'E2E old ${STAMP}', NOW() - INTERVAL 400 DAY), ('ospite', 'E2E recent ${STAMP}', NOW() - INTERVAL 10 DAY)`); + await page.goto(`${h.BASE}/admin/attendance`); + await Promise.all([page.waitForURL(/\/admin\/attendance$/), page.click('#attendance-purge-form button[type="submit"]')]); + await expect(page.getByText(/Presenze cancellate: [1-9]\d*\./)).toBeVisible(); + expect(visits(`motivo = 'E2E old ${STAMP}'`)).toBe(0); + expect(visits(`motivo = 'E2E recent ${STAMP}'`)).toBe(1); + expect(visits(`utente_id = ${userId}`)).toBe(1); + }); + + test('7. The scheduled maintenance applies the same retention', async () => { + test.setTimeout(180000); + await page.goto(`${h.BASE}/admin/attendance`); + await page.fill('#retention_days', '5'); + await Promise.all([page.waitForURL(/\/admin\/attendance$/), page.click('#attendance-retention-form button[type="submit"]')]); + expect(visits(`motivo = 'E2E recent ${STAMP}'`)).toBe(1); + + const out = execFileSync('php', [path.join(__dirname, '..', 'cron', 'full-maintenance.php')], { encoding: 'utf-8', timeout: 150000 }); + expect(out).toMatch(/Visitor log rows purged \(retention\): [1-9]\d*/); + expect(visits(`motivo = 'E2E recent ${STAMP}'`)).toBe(0); + expect(visits(`utente_id = ${userId}`)).toBe(1); + }); +}); From 6e5a43a13be4806c68510ac76910795cd47991ba Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:53:04 +0100 Subject: [PATCH 045/398] Statistics: loans by material, visitors and extension points /admin/statistics shows the loans of the last 12 months by media type and the visits of the last 30 days. Loans by patron type, fines and stocktake outcomes are extension points in StatisticsSections (one function and one filter hook each) that stay hidden until a feature returns data. --- CHANGELOG.md | 9 ++ app/Controllers/Admin/StatsController.php | 6 + app/Support/StatisticsSections.php | 182 ++++++++++++++++++++++ app/Views/admin/stats.php | 91 +++++++++++ tests/statistics-090.spec.js | 97 ++++++++++++ 5 files changed, 385 insertions(+) create mode 100644 app/Support/StatisticsSections.php create mode 100644 tests/statistics-090.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c81bb77c..b7e566acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,19 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Bulk MARC export** (Books → Export, and "Esporta in MARC" on a selection). The catalogue or the selected books as MARC 21 `.mrc`, a MARCXML collection or UNIMARC `.mrc`, written while the download is sent, so a large catalogue never sits in memory. Deleted books are never exported. The records carry the EAN, the LC classification, the Dewey number and the cover, so an export re-imports without duplicates. - **Copy cataloguing** (Books → Import → Copy cataloging, `/admin/books/copy-cataloging`, z39-server plugin). Search by title, author, ISBN/ISSN or all fields in the configured SRU servers, SBN, the Library of Congress, BnF, DNB, K10plus and this installation's own SRU server; look at each record's MARC fields; import the chosen ones with the same duplicate modes and cover download. `SruClient::search()` returns every record of a query. Another Pinakes installation is a preset in the plugin settings, and its SRU server's UNIMARC records now carry the cover in 856 (the MARCXML ones already did). +### Minor features (visitor log, catalogue cards, suppliers, RDA types, statistics) +- **Visitor log** (`/admin/attendance`, sidebar "Presenze"). A kiosk page records each visit: a member scans the library card (or the desk types the code), a guest is recorded with an optional name and reason, and an optional branch when the installation has more than one. The page shows today's visits, visits per day and per hour slot over 7/30/90/365 days, the latest visits with members' names shortened to "Name S.", and a CSV export of the daily counts. New table `presenze`. +- **GDPR retention for the visitor log.** Visits are kept for 365 days by default; an administrator can change the number of days (0 keeps them forever) and apply it immediately. The scheduled maintenance (`cron/full-maintenance.php`) deletes older visits on every run. Deleting a member keeps the visit, anonymised. +- **ISBD catalogue cards.** "Scheda catalografica" on the book page and "Schede catalografiche" on a selection in the book list produce a PDF of 125 × 75 mm cards, one per book: call number, main entry heading (principal author, surname first), ISBD description, standard number and subject tracings. Deleted books are never printed. +- **Suppliers** (`/admin/suppliers`, sidebar "Fornitori"). New table `fornitori` with name, contact, VAT number, website, address and notes; a supplier select in the book form ("Dettagli Acquisizione"), the supplier on the book page and a "Fornitore" filter in the book list. Deleting a supplier keeps its books and clears their supplier (`libri.fornitore_id`, ON DELETE SET NULL). +- **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME plugin uses an override when it is set and keeps its current output otherwise. +- **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. Loans by patron type, fines and stocktake outcomes have their place on the page and appear as soon as those features provide data (`App\Support\StatisticsSections`). + ### Migration - `migrate_0.9.0.sql`, section "Inventory stocktake": creates `inventario_sessioni`, `inventario_attese` and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes. - `migrate_0.9.0.sql`, section "MARC bulk import / copy cataloguing": `import_logs.import_type` gains the value `marc`. Idempotent; existing rows are untouched. +- `migrate_0.9.0.sql`, section "minor features": creates `presenze` (visitor log) and `fornitori` (suppliers), adds `libri.fornitore_id` (ON DELETE SET NULL) and the nullable `libri.rda_content_type`, `rda_media_type`, `rda_carrier_type` overrides. Additive and idempotent. ## [0.8.9] diff --git a/app/Controllers/Admin/StatsController.php b/app/Controllers/Admin/StatsController.php index bdfd5bd06..0145cdcea 100644 --- a/app/Controllers/Admin/StatsController.php +++ b/app/Controllers/Admin/StatsController.php @@ -109,6 +109,12 @@ public function index(Request $request, Response $response, \mysqli $db): Respon $statsResult = $db->query($statsQuery); $stats = $statsResult->fetch_assoc(); + // 0.9.0 sections: visitors (attendance log), loans by material and the + // extension points other branches fill (see StatisticsSections). + $visitors = \App\Support\StatisticsSections::visitors($db, 30); + $loansByMedia = \App\Support\StatisticsSections::loansByMediaType($db); + $extraSections = \App\Support\StatisticsSections::extensions($db); + $title = 'Statistiche Prestiti'; ob_start(); diff --git a/app/Support/StatisticsSections.php b/app/Support/StatisticsSections.php new file mode 100644 index 000000000..de455538a --- /dev/null +++ b/app/Support/StatisticsSections.php @@ -0,0 +1,182 @@ +, note?: string} + * + * which the view renders as a two-column table (label, value). Labels are + * already translated by the provider. + */ +final class StatisticsSections +{ + /** Loan states that mean the item actually left the library. */ + public const CIRCULATED_STATES = ['in_corso', 'in_ritardo', 'restituito', 'perso', 'danneggiato']; + + /** + * Visitors from the attendance log over the last $days days. + * + * @return array{days:int, soci:int, ospiti:int, totale:int, daily:list}|null + */ + public static function visitors(mysqli $db, int $days = 30): ?array + { + try { + $repo = new AttendanceRepository($db); + if (!$repo->isAvailable()) { + return null; + } + $daily = $repo->dailyCounts($days); + } catch (\Throwable $e) { + SecureLogger::warning('Statistics visitors section unavailable', ['error' => $e->getMessage()]); + return null; + } + $totals = ['soci' => 0, 'ospiti' => 0, 'totale' => 0]; + foreach ($daily as $row) { + $totals['soci'] += $row['soci']; + $totals['ospiti'] += $row['ospiti']; + $totals['totale'] += $row['totale']; + } + return ['days' => AttendanceRepository::clampDays($days)] + $totals + ['daily' => $daily]; + } + + /** + * Loans in the last 12 months grouped by material (libri.tipo_media), + * every media type listed even when it has no loans. + * + * @return list + */ + public static function loansByMediaType(mysqli $db): array + { + $states = "'" . implode("','", self::CIRCULATED_STATES) . "'"; + $sql = "SELECT COALESCE(NULLIF(l.tipo_media, ''), 'libro') AS tipo_media, COUNT(p.id) AS totale + FROM prestiti p + JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL + WHERE p.stato IN ({$states}) + AND p.data_prestito >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) + GROUP BY COALESCE(NULLIF(l.tipo_media, ''), 'libro')"; + $counts = []; + $res = $db->query($sql); + if ($res instanceof \mysqli_result) { + while ($row = $res->fetch_assoc()) { + $counts[(string) $row['tipo_media']] = (int) $row['totale']; + } + } + $out = []; + foreach (MediaLabels::allTypes() as $key => $meta) { + $out[] = [ + 'tipo_media' => $key, + 'label' => __($meta['label']), + 'icon' => $meta['icon'], + 'totale' => $counts[$key] ?? 0, + ]; + } + return $out; + } + + /** + * EXTENSION POINT — loans by patron type (tipologie_lettore). + * Filled by the patron-types branch; NULL hides the section. + * + * @return array{rows: list, note?: string}|null + */ + public static function loansByPatronType(mysqli $db): ?array + { + return self::tableSection(Hooks::apply('statistics.section.patron_types', null, [$db])); + } + + /** + * EXTENSION POINT — fines (amount charged / paid / outstanding). + * Filled by the fines branch; NULL hides the section. + * + * @return array{rows: list, note?: string}|null + */ + public static function fines(mysqli $db): ?array + { + return self::tableSection(Hooks::apply('statistics.section.fines', null, [$db])); + } + + /** + * EXTENSION POINT — stocktake outcomes (found / missing / misplaced …). + * Filled by the stocktake branch; NULL hides the section. + * + * @return array{rows: list, note?: string}|null + */ + public static function stocktakeOutcomes(mysqli $db): ?array + { + return self::tableSection(Hooks::apply('statistics.section.stocktake', null, [$db])); + } + + /** + * Validate a provider result against the generic table shape; anything + * else (NULL included) hides the section. + * + * @return array{rows: list, note?: string}|null + */ + public static function tableSection(mixed $data): ?array + { + if (!is_array($data) || !isset($data['rows']) || !is_array($data['rows'])) { + return null; + } + $rows = []; + foreach ($data['rows'] as $row) { + if (!is_array($row) || !isset($row['label'], $row['value']) || !is_scalar($row['label'])) { + continue; + } + $value = $row['value']; + if (!is_int($value) && !is_float($value) && !is_string($value)) { + continue; + } + $rows[] = ['label' => (string) $row['label'], 'value' => $value]; + } + $out = ['rows' => $rows]; + if (isset($data['note']) && is_string($data['note']) && $data['note'] !== '') { + $out['note'] = $data['note']; + } + return $out; + } + + /** + * The extension sections in display order, keyed by a stable id; only the + * ones whose provider returned data. + * + * @return array, note?: string}}> + */ + public static function extensions(mysqli $db): array + { + $providers = [ + 'patron-types' => [__('Prestiti per tipologia di lettore'), 'fa-id-badge', [self::class, 'loansByPatronType']], + 'fines' => [__('Multe'), 'fa-coins', [self::class, 'fines']], + 'stocktake' => [__('Esiti delle revisioni inventariali'), 'fa-clipboard-check', [self::class, 'stocktakeOutcomes']], + ]; + $out = []; + foreach ($providers as $id => [$title, $icon, $callable]) { + try { + $data = $callable($db); + } catch (\Throwable $e) { + SecureLogger::warning('Statistics extension section failed', ['section' => $id, 'error' => $e->getMessage()]); + $data = null; + } + if (is_array($data)) { + $out[$id] = ['title' => $title, 'icon' => $icon, 'data' => $data]; + } + } + return $out; + } +} diff --git a/app/Views/admin/stats.php b/app/Views/admin/stats.php index c3e69a28b..441dda70a 100644 --- a/app/Views/admin/stats.php +++ b/app/Views/admin/stats.php @@ -146,6 +146,97 @@ + +
      +
      +
      +

      + + +

      +
      +
      +
      + + + + + + + + + + + + + + + +
      +
      +
      +
      + + +
      +
      +

      + + +

      +
      +
      +
      +
      +

      +

      +
      +
      +

      +

      +
      +
      +

      +

      +
      +
      + + + +
      +
      + +
      + + $section): ?> +
      +
      +

      + + +

      +
      +
      + + + + + + + + + +
      + +

      + +
      +
      + +
      diff --git a/tests/statistics-090.spec.js b/tests/statistics-090.spec.js new file mode 100644 index 000000000..b1f710e86 --- /dev/null +++ b/tests/statistics-090.spec.js @@ -0,0 +1,97 @@ +// @ts-check +// /admin/statistics in 0.9.0: loans by material, visitors from the visitor +// log, and the extension sections (patron types, fines, stocktake) hidden +// until another feature provides them. +const { test, expect } = require('@playwright/test'); +const h = require('./helpers/minor-features-090'); + +test.skip(!h.configured, 'E2E credentials not configured'); + +const STAMP = Date.now().toString(36); +const CARD = `E2ES${STAMP}`.slice(0, 20); +const DVD_TITLE = `E2E Statistiche DVD ${STAMP}`; + +test.describe.serial('Statistics 0.9.0 sections', () => { + let context; + let page; + let userId = 0; + let dvdId = 0; + + const mediaCount = (media) => parseInt(h.dbQuery( + `SELECT COUNT(*) FROM prestiti p JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL + WHERE COALESCE(NULLIF(l.tipo_media, ''), 'libro') = '${media}' + AND p.stato IN ('in_corso','in_ritardo','restituito','perso','danneggiato') + AND p.data_prestito >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)`), 10); + const shownCount = (media) => page.locator(`#stats-loans-by-media tr[data-media="${media}"] [data-count]`); + + test.beforeAll(async ({ browser }) => { + h.dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente) VALUES (${h.sqlString(CARD)}, 'Lia', 'Statistica', ${h.sqlString(`stats-${STAMP}@example.test`)}, 'x', 'attivo', 'standard')`); + userId = parseInt(h.dbQuery(`SELECT id FROM utenti WHERE codice_tessera = ${h.sqlString(CARD)}`), 10); + dvdId = h.seedBook({ titolo: DVD_TITLE, tipo_media: 'dvd' }); + context = await browser.newContext(); + page = await context.newPage(); + await h.loginAsAdmin(page); + }); + + test.afterAll(async () => { + try { h.dbQuery(`DELETE FROM prestiti WHERE libro_id = ${dvdId}`); } catch (_) { /* cleanup */ } + try { h.dbQuery(`DELETE FROM presenze WHERE utente_id = ${userId}`); } catch (_) { /* cleanup */ } + h.deleteBooks([dvdId]); + try { h.dbQuery(`DELETE FROM utenti WHERE id = ${userId}`); } catch (_) { /* cleanup */ } + await context?.close(); + }); + + test('1. Loans by material list every media type with the database counts', async () => { + await page.goto(`${h.BASE}/admin/statistics`); + await expect(page.locator('#stats-loans-by-media')).toBeVisible(); + for (const media of ['libro', 'disco', 'audiolibro', 'dvd', 'altro']) { + await expect(shownCount(media)).toHaveText(String(mediaCount(media))); + } + // The existing sections are still there. + await expect(page.locator('#loansPerMonthChart')).toBeAttached(); + await expect(page.getByText('Top 10 Libri Più Prestati')).toBeVisible(); + }); + + test('2. A returned DVD loan is counted under DVD', async () => { + const before = mediaCount('dvd'); + h.dbQuery(`INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, data_restituzione, stato, attivo, origine) + VALUES (${dvdId}, ${userId}, CURDATE() - INTERVAL 20 DAY, CURDATE() - INTERVAL 5 DAY, CURDATE() - INTERVAL 6 DAY, 'restituito', 0, 'diretto')`); + expect(mediaCount('dvd')).toBe(before + 1); + await page.goto(`${h.BASE}/admin/statistics`); + await expect(shownCount('dvd')).toHaveText(String(before + 1)); + }); + + test('3. Loans of a deleted record are not counted', async () => { + const before = mediaCount('dvd'); + h.dbQuery(`UPDATE libri SET deleted_at = NOW() WHERE id = ${dvdId}`); + await page.goto(`${h.BASE}/admin/statistics`); + await expect(shownCount('dvd')).toHaveText(String(before - 1)); + h.dbQuery(`UPDATE libri SET deleted_at = NULL WHERE id = ${dvdId}`); + }); + + test('4. A visit recorded at the kiosk shows up among the visitors', async () => { + await page.goto(`${h.BASE}/admin/statistics`); + const read = async (key) => parseInt((await page.locator(`#stats-visitors [data-visitors="${key}"]`).innerText()).replace(/\D/g, ''), 10); + const totale = await read('totale'); + const soci = await read('soci'); + + await page.goto(`${h.BASE}/admin/attendance`); + await page.fill('#codice_tessera', CARD); + await Promise.all([page.waitForURL(/\/admin\/attendance$/), page.press('#codice_tessera', 'Enter')]); + await expect(page.getByText('Benvenuto/a, Lia S.: presenza registrata.')).toBeVisible(); + + await page.goto(`${h.BASE}/admin/statistics`); + expect(await read('totale')).toBe(totale + 1); + expect(await read('soci')).toBe(soci + 1); + await page.locator('#stats-visitors').getByRole('link', { name: 'Dettaglio per giorno e fascia oraria' }).click(); + await expect(page).toHaveURL(/\/admin\/attendance$/); + }); + + test('5. Patron-type, fines and stocktake sections stay hidden until a feature provides them', async () => { + await page.goto(`${h.BASE}/admin/statistics`); + await expect(page.locator('#stats-loans-by-media')).toBeVisible(); + for (const id of ['patron-types', 'fines', 'stocktake']) { + await expect(page.locator(`#stats-${id}`)).toHaveCount(0); + } + }); +}); From 7f1664e04c714b4d6f9232e6026536611c2e8c61 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 02:06:20 +0100 Subject: [PATCH 046/398] Keep the supplier name out of the SweetAlert title SweetAlert2 renders title as HTML, so a supplier named with markup ran in the delete confirmation (stored DOM XSS). The name now goes into the plain-text body and the title is a fixed label. The suppliers spec creates a supplier named with an onerror image and checks that the dialog shows it literally and that nothing executes. --- app/Views/fornitori/index.php | 7 +++++-- tests/suppliers-090.spec.js | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/Views/fornitori/index.php b/app/Views/fornitori/index.php index d400074a6..b30dadb88 100644 --- a/app/Views/fornitori/index.php +++ b/app/Views/fornitori/index.php @@ -146,9 +146,12 @@ class="form-input" /> const text = count > 0 ? .replace('%d', String(count)) : ; + // The supplier name is user data: it only ever goes into `text`, + // which SweetAlert2 renders as plain text (its `title` is HTML). + const question = .replace('%s', form.dataset.name || ''); const result = await window.SwalApp.confirm({ - title: .replace('%s', form.dataset.name || ''), - text: text, + title: , + text: question + ' ' + text, confirmText: }); if (result.isConfirmed) { diff --git a/tests/suppliers-090.spec.js b/tests/suppliers-090.spec.js index 2fb2d5b97..cb87cbeb6 100644 --- a/tests/suppliers-090.spec.js +++ b/tests/suppliers-090.spec.js @@ -136,4 +136,25 @@ test.describe.serial('Suppliers (fornitori)', () => { await page.goto(`${h.BASE}/admin/books/${bookId}`); await expect(page.locator('#book-supplier')).toHaveCount(0); }); + + test('6. A supplier name with markup is shown literally, never executed', async () => { + const XSS = `E2E ${STAMP}`; + await page.goto(`${h.BASE}/admin/suppliers/create`); + await page.fill('#nome', XSS); + await Promise.all([page.waitForURL(/\/admin\/suppliers$/), page.click('#form-fornitore button[type="submit"]')]); + expect(h.dbQuery(`SELECT COUNT(*) FROM fornitori WHERE nome = ${h.sqlString(XSS)}`)).toBe('1'); + const row = page.locator('[data-supplier-row]', { hasText: XSS }); + await expect(row).toHaveCount(1); + await row.locator('.supplier-delete-form button[type="submit"]').click(); + const popup = page.locator('.swal2-popup'); + await expect(popup).toBeVisible(); + await expect(popup).toContainText(XSS); + await expect(popup.locator('img[src="x"]')).toHaveCount(0); + expect(await page.evaluate(() => window.__xss)).toBeUndefined(); + await page.locator('.swal2-cancel').click(); + // The book form select and the list filter show it as text too. + await page.goto(`${h.BASE}/admin/books`); + await expect(page.locator('#fornitore_filter option', { hasText: XSS })).toHaveCount(1); + expect(await page.evaluate(() => window.__xss)).toBeUndefined(); + }); }); From bc51426605039219bc568953034d2e288631ab39 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 03:04:59 +0100 Subject: [PATCH 047/398] Statistics: outcomes of the last closed stocktake StatisticsSections::stocktakeOutcomes() now reads the stocktake tables: expected, found, misplaced, on loan, missing and unknown codes of the most recently closed session, through StocktakeService::summary(). A filter on statistics.section.stocktake still takes precedence, and the section stays hidden without the tables or a closed session. --- CHANGELOG.md | 2 +- app/Support/StatisticsSections.php | 40 ++++++++++++++++++++++-- locale/da_DK.json | 3 +- locale/de_DE.json | 3 +- locale/en_US.json | 3 +- locale/fr_FR.json | 3 +- locale/it_IT.json | 3 +- tests/statistics-090.spec.js | 50 +++++++++++++++++++++++++++--- 8 files changed, 94 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e566acc..6d4a84055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **ISBD catalogue cards.** "Scheda catalografica" on the book page and "Schede catalografiche" on a selection in the book list produce a PDF of 125 × 75 mm cards, one per book: call number, main entry heading (principal author, surname first), ISBD description, standard number and subject tracings. Deleted books are never printed. - **Suppliers** (`/admin/suppliers`, sidebar "Fornitori"). New table `fornitori` with name, contact, VAT number, website, address and notes; a supplier select in the book form ("Dettagli Acquisizione"), the supplier on the book page and a "Fornitore" filter in the book list. Deleting a supplier keeps its books and clears their supplier (`libri.fornitore_id`, ON DELETE SET NULL). - **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME plugin uses an override when it is set and keeps its current output otherwise. -- **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. Loans by patron type, fines and stocktake outcomes have their place on the page and appear as soon as those features provide data (`App\Support\StatisticsSections`). +- **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. The outcomes of the last closed stocktake (expected, found, misplaced, on loan, missing, unknown codes) appear once a session has been closed. Loans by patron type and fines have their place on the page and appear as soon as those features provide data (`App\Support\StatisticsSections`). ### Migration diff --git a/app/Support/StatisticsSections.php b/app/Support/StatisticsSections.php index de455538a..7030c0628 100644 --- a/app/Support/StatisticsSections.php +++ b/app/Support/StatisticsSections.php @@ -113,14 +113,48 @@ public static function fines(mysqli $db): ?array } /** - * EXTENSION POINT — stocktake outcomes (found / missing / misplaced …). - * Filled by the stocktake branch; NULL hides the section. + * Stocktake outcomes (found / misplaced / on loan / missing) of the last + * closed session. A filter on statistics.section.stocktake still wins; + * NULL (no stocktake table, no closed session) hides the section. * * @return array{rows: list, note?: string}|null */ public static function stocktakeOutcomes(mysqli $db): ?array { - return self::tableSection(Hooks::apply('statistics.section.stocktake', null, [$db])); + $hooked = self::tableSection(Hooks::apply('statistics.section.stocktake', null, [$db])); + if ($hooked !== null) { + return $hooked; + } + // Built-in provider: the outcomes of the most recently closed + // stocktake session. No stocktake table or no closed session hides + // the section. + $exists = $db->query("SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'inventario_sessioni' LIMIT 1"); + if (!$exists instanceof \mysqli_result || $exists->num_rows === 0) { + return null; + } + $res = $db->query("SELECT id, nome, chiusa_il, (SELECT COUNT(*) FROM inventario_sessioni WHERE stato = 'chiusa') AS chiuse + FROM inventario_sessioni WHERE stato = 'chiusa' ORDER BY chiusa_il DESC, id DESC LIMIT 1"); + $last = $res instanceof \mysqli_result ? $res->fetch_assoc() : null; + if (!is_array($last)) { + return null; + } + $summary = (new \App\Services\StocktakeService($db))->summary((int) $last['id']); + return [ + 'rows' => [ + ['label' => __('Copie attese'), 'value' => $summary['attese']], + ['label' => __('Trovata'), 'value' => $summary['trovate']], + ['label' => __('Fuori posto'), 'value' => $summary['fuori_posto']], + ['label' => __('In prestito'), 'value' => $summary['in_prestito']], + ['label' => __('Mancanti'), 'value' => $summary['mancanti']], + ['label' => __('Codici sconosciuti'), 'value' => $summary['sconosciute']], + ], + 'note' => sprintf( + __('Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.'), + (string) $last['nome'], + format_date((string) $last['chiusa_il'], true, '/'), + (int) $last['chiuse'] + ), + ]; } /** diff --git a/locale/da_DK.json b/locale/da_DK.json index ba0578d2d..6c21b9f81 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8674,5 +8674,6 @@ "Disco sonoro": "Lydplade", "Audiocassetta": "Lydkassette", "Videodisco": "Videodisk", - "Disco per computer": "Computerdisk" + "Disco per computer": "Computerdisk", + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Senest afsluttede lageroptælling: »%s«, %s. Afsluttede lageroptællinger i alt: %d." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 7e4ea9e8b..428852220 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8674,5 +8674,6 @@ "Disco sonoro": "Audiodisk", "Audiocassetta": "Audiokassette", "Videodisco": "Videodisk", - "Disco per computer": "Computerdisk" + "Disco per computer": "Computerdisk", + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Zuletzt abgeschlossene Bestandsrevision: „%s“, %s. Abgeschlossene Bestandsrevisionen insgesamt: %d." } diff --git a/locale/en_US.json b/locale/en_US.json index 317f697e7..0738630d9 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8674,5 +8674,6 @@ "Disco sonoro": "Audio disc", "Audiocassetta": "Audiocassette", "Videodisco": "Videodisc", - "Disco per computer": "Computer disc" + "Disco per computer": "Computer disc", + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Last closed stocktake: “%s”, %s. Closed stocktakes in total: %d." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index dfdef275c..83b85b4db 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8674,5 +8674,6 @@ "Disco sonoro": "Disque audio", "Audiocassetta": "Cassette audio", "Videodisco": "Vidéodisque", - "Disco per computer": "Disque informatique" + "Disco per computer": "Disque informatique", + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Dernier récolement clôturé : « %s », %s. Récolements clôturés au total : %d." } diff --git a/locale/it_IT.json b/locale/it_IT.json index ba8b80aff..e7fee115c 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8674,5 +8674,6 @@ "Disco sonoro": "Disco sonoro", "Audiocassetta": "Audiocassetta", "Videodisco": "Videodisco", - "Disco per computer": "Disco per computer" + "Disco per computer": "Disco per computer", + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d." } diff --git a/tests/statistics-090.spec.js b/tests/statistics-090.spec.js index b1f710e86..4d8d263ca 100644 --- a/tests/statistics-090.spec.js +++ b/tests/statistics-090.spec.js @@ -1,7 +1,7 @@ // @ts-check // /admin/statistics in 0.9.0: loans by material, visitors from the visitor -// log, and the extension sections (patron types, fines, stocktake) hidden -// until another feature provides them. +// log, the outcomes of the last closed stocktake, and the extension +// sections (patron types, fines) hidden until another feature provides them. const { test, expect } = require('@playwright/test'); const h = require('./helpers/minor-features-090'); @@ -87,11 +87,53 @@ test.describe.serial('Statistics 0.9.0 sections', () => { await expect(page).toHaveURL(/\/admin\/attendance$/); }); - test('5. Patron-type, fines and stocktake sections stay hidden until a feature provides them', async () => { + test('5. Patron-type and fines sections stay hidden until a feature provides them', async () => { await page.goto(`${h.BASE}/admin/statistics`); await expect(page.locator('#stats-loans-by-media')).toBeVisible(); - for (const id of ['patron-types', 'fines', 'stocktake']) { + for (const id of ['patron-types', 'fines']) { await expect(page.locator(`#stats-${id}`)).toHaveCount(0); } }); + + test('6. The last closed stocktake shows its outcomes', async () => { + const name = `E2E Revisione statistiche ${STAMP}`; + const bookId = h.seedBook({ titolo: `E2E Statistiche revisione ${STAMP}` }); + const copies = ['A', 'B', 'C'].map((suffix) => { + const code = `E2ESR${STAMP}${suffix}`.slice(0, 40); + h.dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (${bookId}, ${h.sqlString(code)}, 'disponibile')`); + return { code, id: parseInt(h.dbQuery(`SELECT id FROM copie WHERE numero_inventario = ${h.sqlString(code)}`), 10) }; + }); + let sessionId = 0; + try { + // Setup: a session closed now, three expected copies, one found, one + // misplaced, one never scanned, plus one unknown code. + h.dbQuery(`INSERT INTO inventario_sessioni (nome, stato, totale_attese, aperta_il, chiusa_il) VALUES (${h.sqlString(name)}, 'chiusa', 3, NOW() - INTERVAL 1 HOUR, NOW())`); + sessionId = parseInt(h.dbQuery(`SELECT MAX(id) FROM inventario_sessioni WHERE nome = ${h.sqlString(name)}`), 10); + h.dbQuery(`INSERT INTO inventario_attese (sessione_id, copia_id, stato_all_apertura) VALUES ${copies.map((c) => `(${sessionId}, ${c.id}, 'disponibile')`).join(', ')}`); + h.dbQuery(`INSERT INTO inventario_scansioni (sessione_id, copia_id, codice_letto, esito) VALUES + (${sessionId}, ${copies[0].id}, ${h.sqlString(copies[0].code)}, 'trovata'), + (${sessionId}, ${copies[1].id}, ${h.sqlString(copies[1].code)}, 'fuori_posto'), + (${sessionId}, NULL, ${h.sqlString(`E2EUNKNOWN${STAMP}`)}, 'sconosciuta')`); + const closed = parseInt(h.dbQuery(`SELECT COUNT(*) FROM inventario_sessioni WHERE stato = 'chiusa'`), 10); + + await page.goto(`${h.BASE}/admin/statistics`); + const section = page.locator('#stats-stocktake'); + await expect(section).toBeVisible(); + await expect(section.getByRole('heading', { name: 'Esiti delle revisioni inventariali' })).toBeVisible(); + const value = (label) => section.locator('tr', { has: page.locator('td', { hasText: new RegExp(`^${label}$`) }) }).locator('td').nth(1); + await expect(value('Copie attese')).toHaveText('3'); + await expect(value('Trovata')).toHaveText('1'); + await expect(value('Fuori posto')).toHaveText('1'); + await expect(value('In prestito')).toHaveText('0'); + await expect(value('Mancanti')).toHaveText('1'); + await expect(value('Codici sconosciuti')).toHaveText('1'); + await expect(section).toContainText(`Ultima revisione chiusa: «${name}»`); + await expect(section).toContainText(`Revisioni chiuse in totale: ${closed}.`); + } finally { + if (sessionId > 0) { + try { h.dbQuery(`DELETE FROM inventario_sessioni WHERE id = ${sessionId}`); } catch (_) { /* cleanup */ } + } + h.deleteBooks([bookId]); + } + }); }); From c11f4497c6a20ef9de95507b7c171be8ec7806a9 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 09:40:08 +0100 Subject: [PATCH 048/398] E2E: reach the staged MARC file through sudo when the web server owns it writable/uploads belongs to the PHP user (www-data in CI), so the truncated-staging test could not list or rewrite the staged file as the runner. tests/helpers/server-files.js does it directly when possible and through passwordless sudo otherwise. --- tests/helpers/server-files.js | 74 ++++++++++++++++++++++++++++++++++ tests/marc-bulk-import.spec.js | 13 +++--- 2 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 tests/helpers/server-files.js diff --git a/tests/helpers/server-files.js b/tests/helpers/server-files.js new file mode 100644 index 000000000..8fff6596d --- /dev/null +++ b/tests/helpers/server-files.js @@ -0,0 +1,74 @@ +// Files the web server writes (storage/patron_photos, writable/uploads) keep +// production permissions: 0750 directories and 0640 files owned by the PHP +// user. Locally that user is the one running Playwright; in CI it is +// www-data, so the runner reaches them through passwordless sudo instead. +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +function denied(err) { + return err && (err.code === 'EACCES' || err.code === 'EPERM'); +} + +function sudo(args, input) { + return execFileSync('sudo', ['-n', ...args], { input, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] }); +} + +/** Whether a server-written file exists, also when the runner cannot traverse its directory. */ +function serverFileExists(file) { + try { + fs.statSync(file); + return true; + } catch (err) { + if (err.code === 'ENOENT' && fs.existsSync(path.dirname(file))) return false; + if (!denied(err) && err.code !== 'ENOENT') throw err; + try { sudo(['test', '-e', file]); return true; } catch (_) { return false; } + } +} + +/** The bytes of a server-written file. */ +function readServerFile(file) { + try { + return fs.readFileSync(file); + } catch (err) { + if (!denied(err)) throw err; + return sudo(['cat', file]); + } +} + +/** Names in a server-written directory ([] when it does not exist). */ +function listServerDir(dir) { + try { + return fs.readdirSync(dir); + } catch (err) { + if (err.code === 'ENOENT') return []; + if (!denied(err)) throw err; + return sudo(['ls', '-1A', dir]).toString('utf-8').split('\n').filter(Boolean); + } +} + +/** Write a file into a server-owned directory and set its mtime. */ +function writeServerFile(file, content, mtime) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + if (mtime) fs.utimesSync(file, mtime, mtime); + } catch (err) { + if (!denied(err)) throw err; + sudo(['tee', file], content); + if (mtime) sudo(['touch', '-d', mtime.toISOString(), file]); + } +} + +/** Remove a server-written file (no error when it is already gone). */ +function removeServerFile(file) { + try { + fs.unlinkSync(file); + } catch (err) { + if (err.code === 'ENOENT') return; + if (!denied(err)) throw err; + try { sudo(['rm', '-f', file]); } catch (_) { /* best effort */ } + } +} + +module.exports = { serverFileExists, readServerFile, listServerDir, writeServerFile, removeServerFile }; diff --git a/tests/marc-bulk-import.spec.js b/tests/marc-bulk-import.spec.js index 3c48751fc..3d23a3098 100644 --- a/tests/marc-bulk-import.spec.js +++ b/tests/marc-bulk-import.spec.js @@ -3,7 +3,7 @@ // Files are generated per run (tests/helpers/marc-fixtures.php), so titles and // ISBNs never collide with an earlier run; afterAll removes what the run made. const { test, expect } = require('@playwright/test'); -const fs = require('fs'); +const { listServerDir, readServerFile, writeServerFile } = require('./helpers/server-files'); const path = require('path'); const { BASE, ROOT, dbQuery, sqlEscape, loginAsAdmin, marcFile, cleanupRun, uploadForPreview, confirmImport } = require('./helpers/marc-e2e'); @@ -60,15 +60,18 @@ test.describe.serial('MARC bulk import', () => { test('a staged file that ends early stops the import with an error instead of looping', async ({ page }) => { test.setTimeout(120000); await loginAsAdmin(page); + // writable/uploads belongs to the PHP user (www-data in CI). const uploads = path.join(ROOT, 'writable', 'uploads'); - const staged = () => fs.readdirSync(uploads).filter((n) => n.startsWith('marc_import_') && n.endsWith('.jsonl')) - .map((n) => path.join(uploads, n)).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); + const staged = () => listServerDir(uploads).filter((n) => n.startsWith('marc_import_') && n.endsWith('.jsonl')); + const before = new Set(staged()); const upload = await uploadForPreview(page, marcFile(RUN + 'T', 20, 'mrc')); expect(upload.total_rows).toBe(20); + const added = staged().filter((n) => !before.has(n)); + expect(added.length, 'the upload stages one file').toBe(1); // Cut the staged copy to its first three records, as a full disk or a // cleanup run would leave it. - const file = staged()[0]; - fs.writeFileSync(file, fs.readFileSync(file, 'utf-8').split('\n').slice(0, 3).join('\n') + '\n'); + const file = path.join(uploads, added[0]); + writeServerFile(file, readServerFile(file).toString('utf-8').split('\n').slice(0, 3).join('\n') + '\n'); let chunks = 0; page.on('request', (r) => { if (r.url().includes('/admin/books/import/marc/chunk')) chunks++; }); await page.click('#confirmImportBtn'); From e2baed8b23b8740d20bb95cdf42706db6cd23359 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 04:54:37 +0100 Subject: [PATCH 049/398] Catalogue cards: one page per card whatever the text, warn above the bulk limit A card no longer spills onto a second page: auto page break is off and each block shrinks down to 5.5 pt, then the tracings are shortened with an ellipsis, so the bulk PDF stays one card per page with the inventory number on it. Printing more books than CatalogueCardController::MAX_BULK now stops with a warning instead of silently printing only the first 200. The "select all" box of the book list follows the rows on the current page, so it can be ticked again after paging. --- app/Support/CatalogueCard.php | 59 +++++++++++++++++++++++++------ app/Views/libri/index.php | 20 ++++++++++- locale/da_DK.json | 2 ++ locale/de_DE.json | 2 ++ locale/en_US.json | 2 ++ locale/fr_FR.json | 2 ++ locale/it_IT.json | 2 ++ tests/catalogue-cards-090.spec.js | 47 ++++++++++++++++++++++++ tests/minor-features-090.unit.php | 4 +++ 9 files changed, 129 insertions(+), 11 deletions(-) diff --git a/app/Support/CatalogueCard.php b/app/Support/CatalogueCard.php index b544fc199..a3747e60b 100644 --- a/app/Support/CatalogueCard.php +++ b/app/Support/CatalogueCard.php @@ -192,32 +192,32 @@ public static function render(array $books, string $libraryName = ''): string $pdf->setPrintHeader(false); $pdf->setPrintFooter(false); $pdf->SetMargins(8, 7, 8); - $pdf->SetAutoPageBreak(true, 6); + // One card, one page: nothing may flow onto a second page. Text that + // does not fit the card shrinks, then the tracings are shortened. + $pdf->SetAutoPageBreak(false); foreach ($books as $book) { $card = self::lines($book); $pdf->AddPage(); + $bottom = self::HEIGHT_MM - ($card['inventory'] !== '' ? 13.0 : 6.0); // Call number, top left $pdf->SetFont('dejavusans', 'B', 8); $pdf->MultiCell(30, 0, $card['call'], 0, 'L', false, 0, 8, 7); // Main entry heading - $pdf->SetFont('dejavusans', 'B', 9); - $pdf->MultiCell(0, 0, $card['heading'], 0, 'L', false, 1, 40, 7); + self::fitBlock($pdf, $card['heading'], 40, 7, $bottom, 9.0, 'B'); $pdf->Ln(1.5); // Body, indented as on a traditional card - $pdf->SetFont('dejavusans', '', 8); - $pdf->SetX(44); - $pdf->MultiCell(0, 0, $card['body'], 0, 'L', false, 1, 44); - if ($card['isbn'] !== '') { + self::fitBlock($pdf, $card['body'], 44, $pdf->GetY(), $bottom, 8.0); + if ($card['isbn'] !== '' && $pdf->GetY() + 4 < $bottom) { $pdf->Ln(1); - $pdf->MultiCell(0, 0, $card['isbn'], 0, 'L', false, 1, 44); + self::fitBlock($pdf, $card['isbn'], 44, $pdf->GetY(), $bottom, 8.0); } - if ($card['tracings'] !== '') { + if ($card['tracings'] !== '' && $pdf->GetY() + 5 < $bottom) { $pdf->Ln(2); - $pdf->MultiCell(0, 0, $card['tracings'], 0, 'L', false, 1, 44); + self::fitBlock($pdf, $card['tracings'], 44, $pdf->GetY(), $bottom, 8.0); } if ($card['inventory'] !== '') { $pdf->SetFont('dejavusans', '', 7); @@ -228,6 +228,45 @@ public static function render(array $books, string $libraryName = ''): string return $pdf->Output('', 'S'); } + /** + * Write $text from ($x, $y) to the right margin without passing $bottom: + * the font shrinks down to 5.5 pt, then trailing words give way to "…". + */ + private static function fitBlock(TCPDF $pdf, string $text, float $x, float $y, float $bottom, float $size, string $style = ''): void + { + if ($text === '') { + return; + } + $width = self::WIDTH_MM - 8.0 - $x; + $room = $bottom - $y; + if ($room <= 0) { + return; + } + $fits = static function (string $candidate, float $pt) use ($pdf, $style, $width, $room): bool { + $pdf->SetFont('dejavusans', $style, $pt); + return $pdf->getStringHeight($width, $candidate) <= $room; + }; + for ($pt = $size; $pt > 5.5 && !$fits($text, $pt); $pt -= 0.5) { + } + $pt = max(5.5, $pt); + if (!$fits($text, $pt)) { + $words = preg_split('/\s+/u', $text) ?: []; + while (count($words) > 1) { + array_pop($words); + $candidate = rtrim(implode(' ', $words), " ,;.") . ' …'; + if ($fits($candidate, $pt)) { + $text = $candidate; + break; + } + } + if (count($words) <= 1) { + $text = '…'; + } + } + $pdf->SetFont('dejavusans', $style, $pt); + $pdf->MultiCell($width, 0, $text, 0, 'L', false, 1, $x, $y); + } + /** * @param mixed $authors list of author rows (nome, pseudonimo) * @return list diff --git a/app/Views/libri/index.php b/app/Views/libri/index.php index 0292dec1b..53c0182b5 100644 --- a/app/Views/libri/index.php +++ b/app/Views/libri/index.php @@ -894,6 +894,7 @@ className: 'text-center align-middle', document.querySelectorAll('.row-select').forEach(cb => { cb.addEventListener('change', handleRowSelect); }); + syncSelectAll(); updateBulkActionsBar(); } }); @@ -1092,7 +1093,14 @@ function handleRowSelect(e) { if (e.target.checked) selectedBooks.add(id); else selectedBooks.delete(id); updateBulkActionsBar(); - document.getElementById('select-all').checked = selectedBooks.size > 0 && selectedBooks.size === document.querySelectorAll('.row-select').length; + syncSelectAll(); + } + + // "Select all" reflects the rows on screen: the selection survives paging, + // so on a new page the box starts unticked unless every row there is selected. + function syncSelectAll() { + const boxes = Array.from(document.querySelectorAll('.row-select')); + document.getElementById('select-all').checked = boxes.length > 0 && boxes.every(cb => cb.checked); } document.getElementById('select-all').addEventListener('change', function() { @@ -1495,6 +1503,16 @@ function updateBulkActionsBar() { // Bulk ISBD catalogue cards (PDF, one card per page) document.getElementById('bulk-catalogue-cards').addEventListener('click', function() { if (selectedBooks.size === 0) return; + const maxCards = ; + if (selectedBooks.size > maxCards) { + // The server prints at most maxCards cards: say so instead of silently dropping the rest. + Swal.fire({ + icon: 'warning', + title: , + text: .replace('%d', maxCards) + }); + return; + } const ids = Array.from(selectedBooks).join(','); window.open(`${window.BASE_PATH}/admin/books/catalogue-cards?ids=${encodeURIComponent(ids)}`, '_blank', 'noopener'); }); diff --git a/locale/da_DK.json b/locale/da_DK.json index 6c21b9f81..1649626ca 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8641,6 +8641,8 @@ "Salva Fornitore": "Gem leverandør", "Scheda catalografica": "Katalogkort", "Schede catalografiche": "Katalogkort", + "Troppi libri selezionati": "For mange bøger valgt", + "Si possono stampare al massimo %d schede alla volta: selezionane di meno.": "Der kan højst udskrives %d kort ad gangen: vælg færre bøger.", "Seleziona almeno un libro per stampare le schede catalografiche.": "Vælg mindst én bog for at udskrive katalogkort.", "Soci": "Medlemmer", "Socio": "Medlem", diff --git a/locale/de_DE.json b/locale/de_DE.json index 428852220..96d916c46 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8641,6 +8641,8 @@ "Salva Fornitore": "Lieferant speichern", "Scheda catalografica": "Katalogkarte", "Schede catalografiche": "Katalogkarten", + "Troppi libri selezionati": "Zu viele Bücher ausgewählt", + "Si possono stampare al massimo %d schede alla volta: selezionane di meno.": "Es können höchstens %d Karten auf einmal gedruckt werden: wähle weniger Bücher aus.", "Seleziona almeno un libro per stampare le schede catalografiche.": "Wählen Sie mindestens ein Buch aus, um Katalogkarten zu drucken.", "Soci": "Mitglieder", "Socio": "Mitglied", diff --git a/locale/en_US.json b/locale/en_US.json index 0738630d9..bde650fce 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8641,6 +8641,8 @@ "Salva Fornitore": "Save Supplier", "Scheda catalografica": "Catalogue card", "Schede catalografiche": "Catalogue cards", + "Troppi libri selezionati": "Too many books selected", + "Si possono stampare al massimo %d schede alla volta: selezionane di meno.": "At most %d cards can be printed at a time: select fewer books.", "Seleziona almeno un libro per stampare le schede catalografiche.": "Select at least one book to print catalogue cards.", "Soci": "Members", "Socio": "Member", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 83b85b4db..3a25d2f3c 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8641,6 +8641,8 @@ "Salva Fornitore": "Enregistrer le fournisseur", "Scheda catalografica": "Fiche de catalogue", "Schede catalografiche": "Fiches de catalogue", + "Troppi libri selezionati": "Trop de livres sélectionnés", + "Si possono stampare al massimo %d schede alla volta: selezionane di meno.": "Au maximum %d fiches peuvent être imprimées à la fois : sélectionnez moins de livres.", "Seleziona almeno un libro per stampare le schede catalografiche.": "Sélectionnez au moins un livre pour imprimer les fiches de catalogue.", "Soci": "Membres", "Socio": "Membre", diff --git a/locale/it_IT.json b/locale/it_IT.json index e7fee115c..f2f96f45a 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8641,6 +8641,8 @@ "Salva Fornitore": "Salva Fornitore", "Scheda catalografica": "Scheda catalografica", "Schede catalografiche": "Schede catalografiche", + "Troppi libri selezionati": "Troppi libri selezionati", + "Si possono stampare al massimo %d schede alla volta: selezionane di meno.": "Si possono stampare al massimo %d schede alla volta: selezionane di meno.", "Seleziona almeno un libro per stampare le schede catalografiche.": "Seleziona almeno un libro per stampare le schede catalografiche.", "Soci": "Soci", "Socio": "Socio", diff --git a/tests/catalogue-cards-090.spec.js b/tests/catalogue-cards-090.spec.js index fa7bfa2f1..bac6ca49b 100644 --- a/tests/catalogue-cards-090.spec.js +++ b/tests/catalogue-cards-090.spec.js @@ -137,4 +137,51 @@ test.describe.serial('Catalogue cards (schede catalografiche)', () => { await expect(page).toHaveURL(/\/admin\/books$/); await expect(page.getByText('Seleziona almeno un libro per stampare le schede catalografiche.')).toBeVisible(); }); + test('6. Selecting more books than one print allows warns instead of silently dropping some', async () => { + const BULK = `cardbulk${STAMP}`; + // 205 plain records, more than CatalogueCardController::MAX_BULK (200). + h.dbQuery(`INSERT INTO libri (titolo, search_index, copie_totali, copie_disponibili) + SELECT CONCAT('Scheda massiva ', ${h.sqlString(BULK)}, ' ', n), CONCAT('scheda massiva ', ${h.sqlString(BULK)}, ' ', n), 1, 1 + FROM (SELECT a.d + 10 * b.d + 100 * c.d AS n FROM + (SELECT 0 d UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a, + (SELECT 0 d UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b, + (SELECT 0 d UNION SELECT 1 UNION SELECT 2) c) t WHERE n < 205`); + const bulkIds = h.dbQuery(`SELECT id FROM libri WHERE titolo LIKE ${h.sqlString('Scheda massiva ' + BULK + ' %')}`).split('\n').filter(Boolean).map(Number); + try { + expect(bulkIds.length).toBe(205); + await page.goto(`${h.BASE}/admin/books`); + await page.locator('.dt-length select, .dataTables_length select').first().selectOption('100'); + await page.fill('#search_text', BULK); + // The search box also reloads the table on "change", i.e. when it loses + // focus: leave it now and let that reload land before paging. + const settled = page.waitForResponse((r) => r.url().includes('/api/libri') && r.url().includes(BULK)); + await page.locator('#search_text').press('Tab'); + await settled; + const rows = page.locator('#libri-table tbody tr .row-select'); + // Tick "select all" on each of the three result pages, as a librarian would. + for (let pageNo = 1; pageNo <= 3; pageNo++) { + await expect(rows.first()).toBeVisible({ timeout: 15000 }); + await expect(page.locator('#libri-table tbody tr').filter({ hasNotText: BULK })).toHaveCount(0, { timeout: 15000 }); + if (pageNo === 3) await expect(rows).toHaveCount(5, { timeout: 15000 }); + else await expect(rows).toHaveCount(100, { timeout: 15000 }); + await page.locator('#select-all').check(); + // The bulk actions bar is fixed over the bottom pager once books are + // selected, so the click goes straight to the pager's "next" button. + if (pageNo < 3) { + await page.getByRole('navigation', { name: 'pagination' }).getByRole('link', { name: 'Next' }).dispatchEvent('click'); + await expect(page.locator('.dt-info, .dataTables_info').first()).toContainText(`${pageNo * 100 + 1}`, { timeout: 15000 }); + } + } + await expect(page.locator('#selected-count')).toHaveText('205'); + let printed = false; + page.on('request', (r) => { if (r.url().includes('/admin/books/catalogue-cards')) printed = true; }); + await page.click('#bulk-catalogue-cards'); + await expect(page.locator('.swal2-popup')).toBeVisible(); + await expect(page.locator('.swal2-html-container')).toContainText('200'); + await page.locator('.swal2-confirm').click(); + expect(printed, 'no PDF request is sent for an oversized selection').toBe(false); + } finally { + h.deleteBooks(bulkIds); + } + }); }); diff --git a/tests/minor-features-090.unit.php b/tests/minor-features-090.unit.php index 3f047b2c6..92afd594a 100644 --- a/tests/minor-features-090.unit.php +++ b/tests/minor-features-090.unit.php @@ -62,6 +62,10 @@ function MediaLabelsKeys(): array { return \App\Support\MediaLabels::allTypes(); checkMinor(str_starts_with($pdf, '%PDF-'), 'render() produces a PDF'); checkMinor(preg_match_all('#/Type\s*/Page[^s]#', $pdf) === 2, 'one card per book (two pages)'); checkMinor(preg_match('#/MediaBox\s*\[\s*0(?:\.0+)?\s+0(?:\.0+)?\s+(354\.\d+)\s+(212\.\d+)\s*\]#', $pdf, $m) === 1, 'the page is 125 × 75 mm landscape (354.33 × 212.60 pt)'); +$keywords = implode(', ', array_map(static fn (int $n): string => 'Soggetto lungo numero ' . $n, range(1, 40))); +$crowded = ['titolo' => str_repeat('Titolo molto lungo ', 12), 'sottotitolo' => str_repeat('sottotitolo esteso ', 10), 'autori' => [], 'editore_nome' => 'Sellerio', 'parole_chiave' => $keywords, 'numero_inventario' => 'INV-999', 'isbn13' => '9788804668619']; +$pdf = CatalogueCard::render([$crowded, ['titolo' => 'Due', 'autori' => []]], 'Biblioteca'); +checkMinor(preg_match_all('#/Type\s*/Page[^s]#', $pdf) === 2, 'a card with 40 keywords and a long title still takes exactly one page'); // ── Bulk ids ── checkMinor(CatalogueCardController::parseIds('3, 1,3,x,-2,0,2') === [3, 1, 2], 'bulk ids: positive integers, first-seen order, no duplicates'); From 424008f4c33eb1a4c2325b5f6be14511e54b0150 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 11:27:20 +0100 Subject: [PATCH 050/398] Visitor log: a negative retention falls back to 365 days, never to keep forever --- app/Models/AttendanceRepository.php | 4 +++- tests/minor-features-090.unit.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/Models/AttendanceRepository.php b/app/Models/AttendanceRepository.php index 74f8ad25d..3693857e2 100644 --- a/app/Models/AttendanceRepository.php +++ b/app/Models/AttendanceRepository.php @@ -249,7 +249,9 @@ public static function retentionDays(SettingsRepository $settings): int public static function normalizeRetention(mixed $raw): int { $days = filter_var($raw, FILTER_VALIDATE_INT); - if ($days === false) { + // Not a number, or negative: the prudent default, never 0 (= keep + // forever), so a typo cannot switch the GDPR purge off. + if ($days === false || $days < 0) { return self::DEFAULT_RETENTION_DAYS; } return max(0, min(self::MAX_RETENTION_DAYS, $days)); diff --git a/tests/minor-features-090.unit.php b/tests/minor-features-090.unit.php index 92afd594a..9026693b7 100644 --- a/tests/minor-features-090.unit.php +++ b/tests/minor-features-090.unit.php @@ -81,7 +81,7 @@ function MediaLabelsKeys(): array { return \App\Support\MediaLabels::allTypes(); // ── Visitor log helpers ── checkMinor(AttendanceRepository::shortName('Mario', 'rossi') === 'Mario R.' && AttendanceRepository::shortName('Cher', '') === 'Cher', 'kiosk names are shortened to the initial'); checkMinor(AttendanceRepository::normalizeRetention('30') === 30 && AttendanceRepository::normalizeRetention('0') === 0, 'retention accepts days and 0 (keep forever)'); -checkMinor(AttendanceRepository::normalizeRetention('-5') === 0 && AttendanceRepository::normalizeRetention('999999') === AttendanceRepository::MAX_RETENTION_DAYS, 'retention is clamped'); +checkMinor(AttendanceRepository::normalizeRetention('-5') === AttendanceRepository::DEFAULT_RETENTION_DAYS && AttendanceRepository::normalizeRetention('999999') === AttendanceRepository::MAX_RETENTION_DAYS, 'retention is clamped; a negative value falls back to the default, never to "keep forever"'); checkMinor(AttendanceRepository::normalizeRetention('abc') === AttendanceRepository::DEFAULT_RETENTION_DAYS, 'non-numeric retention falls back to the default'); checkMinor(AttendanceRepository::clampDays(0) === 1 && AttendanceRepository::clampDays(10000) === 366, 'statistics period is clamped'); From 62f6cabf11e4c8fe1153bd5b34a0598f75ea25df Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 11:27:22 +0100 Subject: [PATCH 051/398] Minor features views: plain htmlspecialchars; supplier link resets the saved page and shows its filter; ISBN import refreshes the RDA labels --- app/Views/admin/stats.php | 10 +++++----- app/Views/attendance/index.php | 5 ++--- app/Views/fornitori/form.php | 3 +-- app/Views/fornitori/index.php | 11 +++++------ app/Views/libri/index.php | 12 ++++++++++-- app/Views/libri/partials/book_form.php | 4 +++- app/Views/libri/scheda_libro.php | 2 +- 7 files changed, 27 insertions(+), 20 deletions(-) diff --git a/app/Views/admin/stats.php b/app/Views/admin/stats.php index 441dda70a..2134f3528 100644 --- a/app/Views/admin/stats.php +++ b/app/Views/admin/stats.php @@ -167,7 +167,7 @@ - + @@ -216,7 +216,7 @@

      - +

      @@ -224,14 +224,14 @@ - - + + -

      +

      diff --git a/app/Views/attendance/index.php b/app/Views/attendance/index.php index d15edfad0..c50666566 100644 --- a/app/Views/attendance/index.php +++ b/app/Views/attendance/index.php @@ -9,7 +9,6 @@ * } */ use App\Support\Csrf; -use App\Support\HtmlHelper; $csrf = Csrf::ensureToken(); $days = (int) $data['days']; @@ -27,7 +26,7 @@
      @@ -255,7 +254,7 @@ - + diff --git a/app/Views/fornitori/form.php b/app/Views/fornitori/form.php index b90deee0b..6ef100234 100644 --- a/app/Views/fornitori/form.php +++ b/app/Views/fornitori/form.php @@ -5,13 +5,12 @@ * @var array $data { fornitore: array|null } */ use App\Support\Csrf; -use App\Support\HtmlHelper; $csrf = Csrf::ensureToken(); $fornitore = $data['fornitore']; $isEdit = is_array($fornitore); $action = $isEdit ? url('/admin/suppliers/update/' . (int) $fornitore['id']) : url('/admin/suppliers/create'); -$v = static fn (string $key): string => HtmlHelper::e((string) ($fornitore[$key] ?? '')); +$v = static fn (string $key): string => htmlspecialchars((string) ((string) ($fornitore[$key] ?? '')), ENT_QUOTES, 'UTF-8'); ?>
      diff --git a/app/Views/fornitori/index.php b/app/Views/fornitori/index.php index b30dadb88..d39e6e461 100644 --- a/app/Views/fornitori/index.php +++ b/app/Views/fornitori/index.php @@ -3,7 +3,6 @@ * @var array $data { fornitori: list> } */ use App\Support\Csrf; -use App\Support\HtmlHelper; $fornitori = $data['fornitori']; $csrf = Csrf::ensureToken(); @@ -86,16 +85,16 @@ class="form-input" /> - + -
      +
      - + -
      -
      +
      +
      diff --git a/app/Views/libri/index.php b/app/Views/libri/index.php index 53c0182b5..3c30c25c1 100644 --- a/app/Views/libri/index.php +++ b/app/Views/libri/index.php @@ -337,7 +337,7 @@ class="form-input" />
      @@ -467,6 +467,8 @@ class="form-input" /> let genereUrlFilter = initialGenere; let sottogenereUrlFilter = initialSottogenere; const initialKeywords = urlParams.get('keywords') || ''; + // "Libri di questo fornitore" opens the list with ?fornitore=N preselected. + const initialFornitore = ; const initialCollana = urlParams.get('collana') || ''; let collanaFilter = initialCollana; @@ -662,7 +664,7 @@ function renderRecentSearches() { stateLoadCallback: function(settings) { // When URL filters are active, reset saved pagination to page 1 // to avoid "Showing 2501 to 44 of 44" when saved state had a high page offset - const hasUrlFilter = initialKeywords || initialCollana || initialGenere || initialSottogenere; + const hasUrlFilter = initialKeywords || initialCollana || initialGenere || initialSottogenere || initialFornitore > 0; let raw = null; try { raw = localStorage.getItem('DataTables_libri-table_' + window.location.pathname); @@ -931,6 +933,12 @@ className: 'text-center align-middle', } }); + // A supplier preselected from the URL sits in the closed "more filters" + // panel: show it as an active filter chip from the start. + if (initialFornitore > 0) { + updateActiveFilters(); + } + // Active filters display function updateActiveFilters() { const container = document.getElementById('active-filters'); diff --git a/app/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php index 844a0ea00..2e0eae229 100644 --- a/app/Views/libri/partials/book_form.php +++ b/app/Views/libri/partials/book_form.php @@ -424,7 +424,7 @@

      @@ -3958,6 +3958,8 @@ function initializeIsbnImport() { const tipoMediaSelect = document.getElementById('tipo_media'); if (tipoMediaSelect) { tipoMediaSelect.value = data.tipo_media; + // Listeners (the RDA "Automatico" labels) follow the new material. + tipoMediaSelect.dispatchEvent(new Event('change', { bubbles: true })); } } } catch (err) { diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php index e8c74dff9..4b0a64664 100644 --- a/app/Views/libri/scheda_libro.php +++ b/app/Views/libri/scheda_libro.php @@ -592,7 +592,7 @@ class="text-gray-700 hover:text-gray-900 hover:underline transition-colors">

      -
      +
      Date: Wed, 28 Oct 2026 11:27:29 +0100 Subject: [PATCH 052/398] BIBFRAME carries the effective RDA types the book page shows Without an override the content, media and carrier types now follow the material (an audiobook is spw/s/sd), as on the book page; a book keeps txt/n/nc exactly as before. --- .../BibframeLinkedDataPlugin.php | 20 +++++++------------ tests/rda-types-090.spec.js | 17 ++++++++-------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php b/storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php index d0531ba8e..f1836c321 100644 --- a/storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php +++ b/storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php @@ -445,15 +445,13 @@ private function buildGraph(array $book): array ]; } - // RDA content/media/carrier: a cataloguer's per-record override wins - // (libri.rda_*_type, 0.9.0); without one the historical output stays - // exactly as it was (text / unmediated / volume). - $rdaOverrides = \App\Support\RdaTypes::overrides($book); + // RDA content/media/carrier: the same effective types the book page + // shows (libri.tipo_media, then the cataloguer's per-record override, + // 0.9.0). A book keeps the historical text / unmediated / volume. + $rdaEffective = \App\Support\RdaTypes::effective($book); $work['bf:content'] = [ '@type' => 'bf:Content', - '@id' => isset($rdaOverrides['content']) - ? \App\Support\RdaTypes::uri('content', $rdaOverrides['content']) - : 'http://id.loc.gov/vocabulary/contentTypes/txt', + '@id' => \App\Support\RdaTypes::uri('content', $rdaEffective['content']), ]; // No owl:sameAs on the Work: a VIAF id identifies a PERSON, so linking @@ -526,12 +524,8 @@ private function buildGraph(array $book): array '@type' => 'bf:Title', 'bf:mainTitle' => $instanceMainTitleVal, ], - 'bf:carrier' => ['@id' => isset($rdaOverrides['carrier']) - ? \App\Support\RdaTypes::uri('carrier', $rdaOverrides['carrier']) - : 'http://id.loc.gov/vocabulary/carriers/nc'], - 'bf:media' => ['@id' => isset($rdaOverrides['media']) - ? \App\Support\RdaTypes::uri('media', $rdaOverrides['media']) - : 'http://id.loc.gov/vocabulary/mediaTypes/n'], + 'bf:carrier' => ['@id' => \App\Support\RdaTypes::uri('carrier', $rdaEffective['carrier'])], + 'bf:media' => ['@id' => \App\Support\RdaTypes::uri('media', $rdaEffective['media'])], ]; // Identifiers diff --git a/tests/rda-types-090.spec.js b/tests/rda-types-090.spec.js index da3ad95a4..981aae6bc 100644 --- a/tests/rda-types-090.spec.js +++ b/tests/rda-types-090.spec.js @@ -65,11 +65,12 @@ test.describe.serial('RDA content/media/carrier types', () => { await expect(page.locator('#book-rda-types')).toHaveText('Parola parlata · Audio · Disco sonoro'); }); - test('3. BIBFRAME keeps its current output when no override is set', async () => { + test('3. Without an override BIBFRAME carries the types the book page shows (audiobook: spw/s/sd)', async () => { const body = await bibframe(page, bookId); - expect(body).toContain('http://id.loc.gov/vocabulary/contentTypes/txt'); - expect(body).toContain('http://id.loc.gov/vocabulary/carriers/nc'); - expect(body).toContain('http://id.loc.gov/vocabulary/mediaTypes/n'); + expect(body).toContain('http://id.loc.gov/vocabulary/contentTypes/spw'); + expect(body).toContain('http://id.loc.gov/vocabulary/carriers/sd'); + expect(body).toContain('http://id.loc.gov/vocabulary/mediaTypes/s'); + expect(body).not.toContain('http://id.loc.gov/vocabulary/contentTypes/txt'); }); test('4. An override saved in the form is stored, reloaded and shown', async () => { @@ -93,9 +94,9 @@ test.describe.serial('RDA content/media/carrier types', () => { const body = await bibframe(page, bookId); expect(body).toContain('http://id.loc.gov/vocabulary/carriers/cr'); expect(body).toContain('http://id.loc.gov/vocabulary/mediaTypes/c'); - expect(body).not.toContain('http://id.loc.gov/vocabulary/carriers/nc'); - // Content was not overridden: the historical value stays. - expect(body).toContain('http://id.loc.gov/vocabulary/contentTypes/txt'); + expect(body).not.toContain('http://id.loc.gov/vocabulary/carriers/sd'); + // Content was not overridden: the value derived from the material stays. + expect(body).toContain('http://id.loc.gov/vocabulary/contentTypes/spw'); }); test('6. Choosing "Automatico" again clears the override', async () => { @@ -108,7 +109,7 @@ test.describe.serial('RDA content/media/carrier types', () => { await expect.poll(() => h.dbQuery(`SELECT CONCAT_WS('|', IFNULL(rda_media_type,'NULL'), IFNULL(rda_carrier_type,'NULL')) FROM libri WHERE id = ${bookId}`), { timeout: 30000 }) .toBe('NULL|NULL'); const body = await bibframe(page, bookId); - expect(body).toContain('http://id.loc.gov/vocabulary/carriers/nc'); + expect(body).toContain('http://id.loc.gov/vocabulary/carriers/sd'); expect(body).not.toContain('http://id.loc.gov/vocabulary/carriers/cr'); }); }); From cff6728b8a21df18afae4579ea08f4de9ed06cfc Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 11:27:29 +0100 Subject: [PATCH 053/398] Catalogue cards E2E: the oversized-selection check watches every tab --- tests/catalogue-cards-090.spec.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/catalogue-cards-090.spec.js b/tests/catalogue-cards-090.spec.js index bac6ca49b..22e361265 100644 --- a/tests/catalogue-cards-090.spec.js +++ b/tests/catalogue-cards-090.spec.js @@ -173,13 +173,21 @@ test.describe.serial('Catalogue cards (schede catalografiche)', () => { } } await expect(page.locator('#selected-count')).toHaveText('205'); + // The PDF opens in a new tab: watch every page of the context, and give + // a refused print the time a real one would need to start. let printed = false; - page.on('request', (r) => { if (r.url().includes('/admin/books/catalogue-cards')) printed = true; }); + const onRequest = (r) => { if (r.url().includes('/admin/books/catalogue-cards')) printed = true; }; + page.context().on('request', onRequest); + let opened = false; + page.context().on('page', () => { opened = true; }); await page.click('#bulk-catalogue-cards'); await expect(page.locator('.swal2-popup')).toBeVisible(); await expect(page.locator('.swal2-html-container')).toContainText('200'); await page.locator('.swal2-confirm').click(); + await page.context().waitForEvent('request', { predicate: (r) => r.url().includes('/admin/books/catalogue-cards'), timeout: 3000 }).catch(() => null); + page.context().off('request', onRequest); expect(printed, 'no PDF request is sent for an oversized selection').toBe(false); + expect(opened, 'no PDF tab is opened for an oversized selection').toBe(false); } finally { h.deleteBooks(bulkIds); } From 791ee11c03d614327ee051707693b98e988a7deb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:19:53 +0100 Subject: [PATCH 054/398] Let import_logs record user imports (0.9.0 migration section) --- .../database/migrations/migrate_0.9.0.sql | 12 +++++ installer/database/schema.sql | 2 +- tests/migration-0.9.0-patron-import.unit.php | 53 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/migration-0.9.0-patron-import.unit.php diff --git a/installer/database/migrations/migrate_0.9.0.sql b/installer/database/migrations/migrate_0.9.0.sql index a8822d5fc..7c1e26313 100644 --- a/installer/database/migrations/migrate_0.9.0.sql +++ b/installer/database/migrations/migrate_0.9.0.sql @@ -142,3 +142,15 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- ── end minor features ── + +-- ── patron-cards-import ── +-- The user CSV import writes its run into import_logs like the book imports do, +-- so the import_type enum gains a users value. The new type is computed from the +-- current COLUMN_TYPE instead of being spelled out, which keeps any value another +-- section of this file (or a future release) has already added to the enum. +SET @pci_type = (SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'import_logs' AND COLUMN_NAME = 'import_type'); +SET @pci_sql = IF(@pci_type IS NOT NULL AND LOCATE('''users''', @pci_type) = 0, CONCAT('ALTER TABLE import_logs MODIFY COLUMN import_type ', LEFT(@pci_type, CHAR_LENGTH(@pci_type) - 1), ',''users'') NOT NULL COMMENT ''Type of import'''), 'SELECT 1'); +PREPARE pci_stmt FROM @pci_sql; +EXECUTE pci_stmt; +DEALLOCATE PREPARE pci_stmt; +-- ── end patron-cards-import ── diff --git a/installer/database/schema.sql b/installer/database/schema.sql index f6ce4777a..68f94640e 100755 --- a/installer/database/schema.sql +++ b/installer/database/schema.sql @@ -336,7 +336,7 @@ CREATE TABLE `home_content` ( CREATE TABLE `import_logs` ( `id` int NOT NULL AUTO_INCREMENT, `import_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Unique identifier for this import session', - `import_type` enum('csv','librarything','marc') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import', + `import_type` enum('csv','librarything','marc','users') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import', `file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Original filename uploaded', `user_id` int DEFAULT NULL COMMENT 'User who initiated the import', `total_rows` int NOT NULL DEFAULT '0' COMMENT 'Total rows in CSV', diff --git a/tests/migration-0.9.0-patron-import.unit.php b/tests/migration-0.9.0-patron-import.unit.php new file mode 100644 index 000000000..50f7db6af --- /dev/null +++ b/tests/migration-0.9.0-patron-import.unit.php @@ -0,0 +1,53 @@ +query($sql); } } +}; +$checks = 0; +$check = static function (bool $ok, string $label) use (&$checks): void { + if (!$ok) { throw new RuntimeException('FAIL ' . $label); } + $checks++; echo "OK {$label}\n"; +}; +$type = static function () use ($db, $table): array { + $r = $db->query("SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}' AND COLUMN_NAME = 'import_type'"); + return $r->fetch_assoc() ?? []; +}; +try { + // The 0.8.9 shape of the column. + $db->query("CREATE TABLE {$table} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, import_id VARCHAR(36) NOT NULL, import_type ENUM('csv','librarything') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import', file_name VARCHAR(255) NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $db->query("INSERT INTO {$table} (import_id, import_type, file_name) VALUES ('a','csv','books.csv'),('b','librarything','lt.tsv')"); + $check(!str_contains($type()['COLUMN_TYPE'] ?? '', "'users'"), 'the pre-upgrade enum has no users value'); + $run(); + $after = $type(); + $check(($after['COLUMN_TYPE'] ?? '') === "enum('csv','librarything','users')", 'the migration appends users and keeps the existing values in order'); + $check(($after['IS_NULLABLE'] ?? '') === 'NO' && ($after['COLUMN_COMMENT'] ?? '') === 'Type of import', 'NOT NULL and the comment survive the change'); + $db->query("INSERT INTO {$table} (import_id, import_type, file_name) VALUES ('c','users','patrons.csv')"); + $check($db->query("SELECT GROUP_CONCAT(CONCAT(import_id,':',import_type) ORDER BY id) FROM {$table}")->fetch_row()[0] === 'a:csv,b:librarything,c:users', 'existing rows are preserved and a users row can be stored'); + $run(); + $check(($type()['COLUMN_TYPE'] ?? '') === "enum('csv','librarything','users')", 'a second run is a no-op'); + // A stacked section that already added another value must not lose it. + $db->query("DELETE FROM {$table} WHERE import_type = 'users'"); + $db->query("ALTER TABLE {$table} MODIFY COLUMN import_type ENUM('csv','librarything','marc') NOT NULL COMMENT 'Type of import'"); + $run(); + $check(($type()['COLUMN_TYPE'] ?? '') === "enum('csv','librarything','marc','users')", 'a value added by another section is kept'); + $check(str_contains((string) file_get_contents(dirname(__DIR__) . '/installer/database/schema.sql'), "`import_type` enum('csv','librarything','users')"), 'fresh install includes the users value'); + $version = json_decode((string) file_get_contents(dirname(__DIR__) . '/version.json'), true)['version']; + $check(version_compare('0.9.0', $version, '<='), 'release includes the migration'); + echo "SUCCESS {$checks} migration checks\n"; +} finally { + $db->query("DROP TABLE IF EXISTS {$table}"); +} From 2094050ad5c2226c523f9b275fa4cb4eecf76758 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:23:05 +0100 Subject: [PATCH 055/398] Print library cards, take the patron photo, import users from CSV Library cards: a TCPDF PDF in CR80 size or on A4 sheets of 10 (9 for the portrait layout), with logo, name, card code as Code128 or QR, photo and expiry, and an optional back text mirrored for duplex printing. Settings live in a new "Tessere" tab (/admin/settings/cards). A card prints from the reader's page; the users list gets a selection column to print many. Patron photo: the user form takes it from the webcam (getUserMedia, canvas) or from a file and sends both to one endpoint that re-encodes a bounded, EXIF-free JPEG into storage/patron_photos, outside the web root, served only through /admin/users/{id}/photo. Deleting the photo, or the account, deletes the file. Permissions-Policy keeps camera=(self), now built in ContentSecurityPolicy; nothing else is loosened. User import: /admin/users/import uploads a CSV, previews it, maps the columns (headers recognised in five languages, custom registration fields included), then processes it in chunks. Duplicates by email or card code are skipped, never merged; passwords are never imported and an optional invitation uses PasswordSetupToken. Each run lands in the import history as type "users". The patron-type column goes through PatronTypeImport, which does nothing until the tipologie_lettore schema exists. --- app/Controllers/PatronCardsController.php | 256 ++++++++++++ app/Controllers/SettingsController.php | 16 + app/Controllers/UserImportController.php | 465 ++++++++++++++++++++++ app/Controllers/UsersController.php | 3 + app/Controllers/UtentiApiController.php | 13 +- app/Routes/web.php | 55 +++ app/Support/ContentSecurityPolicy.php | 18 + app/Support/PatronCardPdf.php | 424 ++++++++++++++++++++ app/Support/PatronCardSettings.php | 111 ++++++ app/Support/PatronPhoto.php | 189 +++++++++ app/Support/PatronTypeImport.php | 108 +++++ app/Support/Updater.php | 1 + app/Support/UserImport.php | 333 ++++++++++++++++ app/Views/admin/imports_history.php | 1 + app/Views/partials/settings-sections.php | 1 + app/Views/settings/cards-tab.php | 146 +++++++ app/Views/settings/index.php | 3 + app/Views/utenti/dettagli_utente.php | 48 +++ app/Views/utenti/import.php | 353 ++++++++++++++++ app/Views/utenti/index.php | 105 ++++- app/Views/utenti/modifica_utente.php | 158 ++++++++ public/index.php | 8 +- 22 files changed, 2800 insertions(+), 15 deletions(-) create mode 100644 app/Controllers/PatronCardsController.php create mode 100644 app/Controllers/UserImportController.php create mode 100644 app/Support/PatronCardPdf.php create mode 100644 app/Support/PatronCardSettings.php create mode 100644 app/Support/PatronPhoto.php create mode 100644 app/Support/PatronTypeImport.php create mode 100644 app/Support/UserImport.php create mode 100644 app/Views/settings/cards-tab.php create mode 100644 app/Views/utenti/import.php diff --git a/app/Controllers/PatronCardsController.php b/app/Controllers/PatronCardsController.php new file mode 100644 index 000000000..3653c342f --- /dev/null +++ b/app/Controllers/PatronCardsController.php @@ -0,0 +1,256 @@ +withStatus(403); + } + $rows = $this->loadUsers($db, [$id]); + if ($rows === []) { + return $response->withStatus(404); + } + if (!$this->canRead($rows[0])) { + return $response->withStatus(403); + } + $code = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $rows[0]['codice_tessera']) ?: (string) $id; + return $this->pdfResponse($response, $db, $rows, $request->getQueryParams(), 'tessera_' . $code . '.pdf'); + } + + public function bulkPdf(Request $request, Response $response, mysqli $db): Response + { + if (!\App\Support\Permissions::can('users.manage')) { + return $response->withStatus(403); + } + $query = $request->getQueryParams(); + $ids = []; + foreach (explode(',', is_string($query['ids'] ?? null) ? $query['ids'] : '') as $part) { + $n = (int) trim($part); + if ($n > 0) { + $ids[$n] = $n; + } + } + if ($ids === []) { + return $response->withStatus(400); + } + if (count($ids) > self::MAX_BULK) { + $ids = array_slice($ids, 0, self::MAX_BULK, true); + } + $rows = array_values(array_filter($this->loadUsers($db, array_values($ids)), fn (array $row): bool => $this->canRead($row))); + if ($rows === []) { + return $response->withStatus(404); + } + return $this->pdfResponse($response, $db, $rows, $query, 'tessere.pdf'); + } + + /** Stream the stored photo (never cached by shared caches). */ + public function photo(Request $request, Response $response, mysqli $db, int $id): Response + { + if (!\App\Support\Permissions::can('users.manage')) { + return $response->withStatus(403); + } + $rows = $this->loadUsers($db, [$id]); + if ($rows === [] || !$this->canRead($rows[0])) { + return $response->withStatus($rows === [] ? 404 : 403); + } + $path = PatronPhoto::absolutePath($rows[0]['foto_profilo_url'] ?? null); + if ($path === null) { + return $response->withStatus(404); + } + $bytes = (string) file_get_contents($path); + $response->getBody()->write($bytes); + return $response + ->withHeader('Content-Type', 'image/jpeg') + ->withHeader('Content-Length', (string) strlen($bytes)) + ->withHeader('Cache-Control', 'private, no-store') + ->withHeader('X-Content-Type-Options', 'nosniff'); + } + + /** Save a photo sent as multipart field `photo` (webcam frame or picked file). JSON reply. */ + public function uploadPhoto(Request $request, Response $response, mysqli $db, int $id): Response + { + $guard = $this->editGuard($db, $id); + if ($guard !== null) { + return $this->json($response, ['success' => false, 'error' => $guard[1]], $guard[0]); + } + $files = $request->getUploadedFiles(); + $file = $files['photo'] ?? null; + if (!$file instanceof \Psr\Http\Message\UploadedFileInterface || $file->getError() !== UPLOAD_ERR_OK) { + $tooLarge = $file instanceof \Psr\Http\Message\UploadedFileInterface + && in_array($file->getError(), [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true); + return $this->json($response, ['success' => false, 'error' => $tooLarge ? __('La foto supera la dimensione massima consentita.') : __('Nessuna foto ricevuta.')], 400); + } + $tmp = $file->getStream()->getMetadata('uri'); + if (!is_string($tmp) || !is_file($tmp)) { + return $this->json($response, ['success' => false, 'error' => __('Nessuna foto ricevuta.')], 400); + } + $error = PatronPhoto::store($db, $id, $tmp); + if ($error !== null) { + $message = match ($error) { + 'too_large' => __('La foto supera la dimensione massima consentita.'), + 'invalid_image' => __('Il file non è un\'immagine JPEG, PNG o WebP valida.'), + default => __('Impossibile salvare la foto. Riprova più tardi.'), + }; + return $this->json($response, ['success' => false, 'error' => $message], $error === 'storage' || $error === 'db' ? 500 : 422); + } + \App\Support\SecureLogger::info('Patron photo updated', ['user_id' => $id, 'by' => $_SESSION['user']['id'] ?? null]); + return $this->json($response, [ + 'success' => true, + 'url' => url('/admin/users/' . $id . '/photo') . '?v=' . substr(sha1((string) PatronPhoto::storedFor($db, $id)), 0, 10), + ]); + } + + /** Remove the photo (the reader's right to erasure). JSON for fetch(), redirect for a plain form. */ + public function deletePhoto(Request $request, Response $response, mysqli $db, int $id): Response + { + $wantsJson = str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json'); + $guard = $this->editGuard($db, $id); + if ($guard !== null) { + return $wantsJson + ? $this->json($response, ['success' => false, 'error' => $guard[1]], $guard[0]) + : $response->withStatus($guard[0]); + } + $ok = PatronPhoto::delete($db, $id); + if ($ok) { + \App\Support\SecureLogger::info('Patron photo deleted', ['user_id' => $id, 'by' => $_SESSION['user']['id'] ?? null]); + } + if ($wantsJson) { + return $this->json($response, $ok ? ['success' => true] : ['success' => false, 'error' => __('Impossibile eliminare la foto. Riprova più tardi.')], $ok ? 200 : 500); + } + $_SESSION[$ok ? 'success_message' : 'error_message'] = $ok ? __('Foto eliminata.') : __('Impossibile eliminare la foto. Riprova più tardi.'); + return $response->withHeader('Location', url('/admin/users/details/' . $id))->withStatus(302); + } + + /** + * @param list> $rows + * @param array $query + */ + private function pdfResponse(Response $response, mysqli $db, array $rows, array $query, string $filename): Response + { + $repository = new SettingsRepository($db); + $settings = PatronCardSettings::resolve($repository); + $format = is_string($query['format'] ?? null) && in_array($query['format'], PatronCardSettings::FORMATS, true) + ? $query['format'] + : $settings['format']; + + $cards = []; + foreach ($rows as $row) { + $expiry = (string) ($row['data_scadenza_tessera'] ?? ''); + $cards[] = [ + 'name' => trim((string) ($row['nome'] ?? '') . ' ' . (string) ($row['cognome'] ?? '')), + 'code' => (string) ($row['codice_tessera'] ?? ''), + 'expiry' => $expiry !== '' && function_exists('format_date') ? format_date($expiry, false, '/') : $expiry, + 'photo' => $settings['show_photo'] ? PatronPhoto::absolutePath($row['foto_profilo_url'] ?? null) : null, + ]; + } + + $appName = (string) ConfigStore::get('app.name', 'Biblioteca'); + $generator = new PatronCardPdf($settings, $appName, $this->localLogo()); + $pdf = $generator->render($cards, $format); + + $response->getBody()->write($pdf); + return $response + ->withHeader('Content-Type', 'application/pdf') + ->withHeader('Content-Disposition', 'inline; filename="' . $filename . '"') + ->withHeader('Cache-Control', 'private, no-store'); + } + + /** The configured logo as a local file (remote logos are not fetched while rendering). */ + private function localLogo(): ?string + { + $logo = Branding::logo(); + if ($logo === '' || preg_match('#^https?://#i', $logo) === 1) { + return null; + } + $path = (string) (parse_url($logo, PHP_URL_PATH) ?? ''); + $public = realpath(dirname(__DIR__, 2) . '/public'); + if ($path === '' || $public === false) { + return null; + } + $file = realpath($public . '/' . ltrim($path, '/')); + return $file !== false && str_starts_with($file, $public . DIRECTORY_SEPARATOR) ? $file : null; + } + + /** + * @param list $ids + * @return list> + */ + private function loadUsers(mysqli $db, array $ids): array + { + $ids = array_values(array_filter(array_map('intval', $ids), static fn (int $n): bool => $n > 0)); + if ($ids === []) { + return []; + } + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare("SELECT id, nome, cognome, codice_tessera, data_scadenza_tessera, foto_profilo_url, tipo_utente, ruolo_id, accesso_digitale + FROM utenti WHERE id IN ({$placeholders}) + ORDER BY cognome, nome, id"); + if ($stmt === false) { + return []; + } + $stmt->bind_param(str_repeat('i', count($ids)), ...$ids); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = $row; + } + $stmt->close(); + return $rows; + } + + /** @param array $row */ + private function canRead(array $row): bool + { + return in_array($_SESSION['user']['tipo_utente'] ?? '', ['admin', 'staff'], true) + || \App\Support\UserRoleAssignment::canEdit($row); + } + + /** @return array{0:int,1:string}|null status and message when refused */ + private function editGuard(mysqli $db, int $id): ?array + { + if (!\App\Support\Permissions::can('users.manage')) { + return [403, __('Accesso negato')]; + } + $rows = $this->loadUsers($db, [$id]); + if ($rows === []) { + return [404, __('Utente non trovato')]; + } + if (!\App\Support\UserRoleAssignment::canEdit($rows[0])) { + return [403, __('Accesso negato')]; + } + return null; + } + + /** @param array $payload */ + private function json(Response $response, array $payload, int $status = 200): Response + { + $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); + } +} diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index e72cc04e9..d563edd88 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -68,6 +68,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons $contactSettings = $this->resolveContactSettings($repository); $privacySettings = $this->resolvePrivacySettings($repository); $labelSettings = $this->resolveLabelSettings($repository); + $cardSettings = \App\Support\PatronCardSettings::resolve($repository); $eventSettings = $this->resolveEventSettings($repository); // Catalogue header (Settings → CMS): edit the configured site language. // Historical translations stay stored for later language changes. @@ -104,6 +105,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons 'contactSettings', 'privacySettings', 'labelSettings', + 'cardSettings', 'eventSettings', 'catalogHeaderLocales', 'catalogHeaderTexts', @@ -1301,6 +1303,20 @@ public function updateLabels(Request $request, Response $response, mysqli $db): return $this->redirect($response, '/admin/settings?tab=labels'); } + /** Save the library card settings (Settings → Tessere). */ + public function updateCards(Request $request, Response $response, mysqli $db): Response + { + $data = (array) $request->getParsedBody(); + // CSRF validated by CsrfMiddleware + + $repository = new SettingsRepository($db); + $repository->ensureTables(); + \App\Support\PatronCardSettings::save($repository, \App\Support\PatronCardSettings::sanitize($data)); + + $_SESSION['success_message'] = __('Impostazioni tessere aggiornate correttamente.'); + return $this->redirect($response, '/admin/settings?tab=cards'); + } + public function toggleApi(Request $request, Response $response, mysqli $db): Response { // Exposing or withdrawing the public API changes the application's diff --git a/app/Controllers/UserImportController.php b/app/Controllers/UserImportController.php new file mode 100644 index 000000000..bedfcf467 --- /dev/null +++ b/app/Controllers/UserImportController.php @@ -0,0 +1,465 @@ +withStatus(403); + } + $patronTypes = (new PatronTypeImport($db))->available(); + ob_start(); + require __DIR__ . '/../Views/utenti/import.php'; + $content = ob_get_clean(); + ob_start(); + require __DIR__ . '/../Views/layout.php'; + $html = ob_get_clean(); + $response->getBody()->write((string) $html); + return $response; + } + + /** A sample file with every recognised column. */ + public function example(Request $request, Response $response, mysqli $db): Response + { + if (!\App\Support\Permissions::can('users.manage')) { + return $response->withStatus(403); + } + $writer = Csv::writerToString(';', "'"); + $writer->insertOne(['nome', 'cognome', 'email', 'telefono', 'indirizzo', 'data_nascita', 'codice_tessera', 'data_scadenza_tessera', 'note']); + $writer->insertOne(['Maria', 'Rossi', 'maria.rossi@example.org', '+39 333 1234567', 'Via Roma 1, Milano', '15/04/1990', 'T0001', '31/12/2027', '']); + $writer->insertOne(['Luca', 'Bianchi', 'luca.bianchi@example.org', '', '', '1985-09-02', '', '', 'Socio sostenitore']); + $response->getBody()->write("\xEF\xBB\xBF" . $writer->toString()); + return $response + ->withHeader('Content-Type', 'text/csv; charset=UTF-8') + ->withHeader('Content-Disposition', 'attachment; filename="esempio_import_utenti.csv"') + ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + } + + public function upload(Request $request, Response $response, mysqli $db): Response + { + @ini_set('display_errors', '0'); + if (!\App\Support\Permissions::can('users.manage')) { + return $this->json($response, ['success' => false, 'error' => __('Accesso negato')], 403); + } + $this->discardSessionFile(); + + $file = $request->getUploadedFiles()['csv_file'] ?? null; + if (!$file instanceof \Psr\Http\Message\UploadedFileInterface || $file->getError() !== UPLOAD_ERR_OK) { + return $this->json($response, ['success' => false, 'error' => __('Nessun file caricato')], 400); + } + $name = (string) $file->getClientFilename(); + if (!preg_match('/\.(csv|txt|tsv)$/i', $name)) { + return $this->json($response, ['success' => false, 'error' => __('Il file deve avere estensione .csv')], 400); + } + if ((int) $file->getSize() > UserImport::MAX_BYTES) { + return $this->json($response, ['success' => false, 'error' => __('Il file supera la dimensione massima di 5 MB.')], 400); + } + $tmp = $file->getStream()->getMetadata('uri'); + if (!is_string($tmp) || !is_file($tmp)) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile leggere il file caricato')], 400); + } + + $handle = fopen($tmp, 'r'); + if ($handle === false) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile leggere il file caricato')], 400); + } + $sample = []; + for ($i = 0; $i < 5 && !feof($handle); $i++) { + $sample[] = fgets($handle); + } + fclose($handle); + $delimiter = UserImport::detectDelimiter($sample); + if ($delimiter === null) { + return $this->json($response, ['success' => false, 'error' => __('File CSV non valido: usa ";", "," o TAB come separatore.')], 400); + } + + $dir = dirname(__DIR__, 2) . '/writable/uploads'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + $saved = $dir . '/user_import_' . bin2hex(random_bytes(12)) . '.csv'; + if (!@copy($tmp, $saved)) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile salvare il file CSV caricato')], 500); + } + + try { + $reader = Csv::readerFromPath($saved, $delimiter); + $headers = array_map(static fn ($h): string => trim((string) $h), (array) $reader->nth(0)); + $total = max(0, count($reader) - 1); + $preview = []; + foreach ((new Statement())->offset(1)->limit(UserImport::PREVIEW_ROWS)->process($reader) as $row) { + $preview[] = array_map(static fn ($v): string => mb_substr(trim((string) $v), 0, 120), array_values((array) $row)); + } + } catch (\Throwable $e) { + @unlink($saved); + return $this->json($response, ['success' => false, 'error' => __('File CSV vuoto o formato non valido')], 400); + } + if ($headers === [] || $total === 0) { + @unlink($saved); + return $this->json($response, ['success' => false, 'error' => __('File CSV vuoto o formato non valido')], 400); + } + + $fields = $this->targetFields($db); + $_SESSION[self::SESSION_KEY] = [ + 'file' => $saved, + 'name' => mb_substr($name, 0, 255), + 'delimiter' => $delimiter, + 'total' => $total, + 'columns' => count($headers), + 'started' => false, + ]; + + return $this->json($response, [ + 'success' => true, + 'file_name' => $name, + 'total_rows' => $total, + 'headers' => $headers, + 'preview' => $preview, + 'fields' => $fields, + 'mapping' => UserImport::guessMapping($headers, $fields), + ]); + } + + public function start(Request $request, Response $response, mysqli $db): Response + { + @ini_set('display_errors', '0'); + if (!\App\Support\Permissions::can('users.manage')) { + return $this->json($response, ['success' => false, 'error' => __('Accesso negato')], 403); + } + $state = $_SESSION[self::SESSION_KEY] ?? null; + $body = json_decode((string) $request->getBody(), true); + if (!is_array($state) || !is_array($body) || !is_file((string) ($state['file'] ?? ''))) { + return $this->json($response, ['success' => false, 'error' => __('Sessione import scaduta o dati non validi')], 400); + } + $check = UserImport::validateMapping($body['mapping'] ?? null, $this->targetFields($db), (int) $state['columns']); + if ($check['error'] !== null) { + return $this->json($response, ['success' => false, 'error' => $check['error']], 422); + } + $state['mapping'] = $check['mapping']; + $state['send_invites'] = !empty($body['send_invites']); + $state['started'] = true; + $state['created'] = 0; + $state['duplicates'] = 0; + $state['failed'] = 0; + $state['invited'] = 0; + $state['invite_failed'] = 0; + $state['errors'] = []; + $state['seen_emails'] = []; + $state['seen_codes'] = []; + $state['next'] = 0; + $_SESSION[self::SESSION_KEY] = $state; + + return $this->json($response, [ + 'success' => true, + 'total_rows' => (int) $state['total'], + 'chunk_size' => UserImport::CHUNK_SIZE, + ]); + } + + public function chunk(Request $request, Response $response, mysqli $db): Response + { + @set_time_limit(300); + @ini_set('display_errors', '0'); + if (!\App\Support\Permissions::can('users.manage')) { + return $this->json($response, ['success' => false, 'error' => __('Accesso negato')], 403); + } + $state = $_SESSION[self::SESSION_KEY] ?? null; + $body = json_decode((string) $request->getBody(), true); + if (!is_array($state) || empty($state['started']) || !is_array($body)) { + return $this->json($response, ['success' => false, 'error' => __('Sessione import scaduta o dati non validi')], 400); + } + $start = max(0, (int) ($body['start'] ?? 0)); + // Chunks are processed in order exactly once: a replayed request (a + // retry after a timeout) must not insert the same rows again. + if ($start !== (int) $state['next']) { + return $this->json($response, ['success' => false, 'error' => __('Blocco di righe non valido.')], 409); + } + $total = (int) $state['total']; + + try { + $reader = Csv::readerFromPath((string) $state['file'], (string) $state['delimiter']); + } catch (\Throwable) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile aprire il file CSV')], 500); + } + + $customDefinitions = RegistrationFields::definitions($db); + $locales = array_map('strval', array_keys(\App\Support\I18n::getAvailableLocales())); + $visible = [ + 'sesso' => RegistrationFields::isVisible('sesso'), + 'cod_fiscale' => RegistrationFields::isVisible('cod_fiscale'), + ]; + $installLocale = \App\Support\I18n::normalizeLocaleCode(\App\Support\I18n::getInstallationLocale()); + if (!in_array($installLocale, $locales, true)) { + $installLocale = 'it_IT'; + } + $patronTypes = new PatronTypeImport($db); + $notifier = $state['send_invites'] ? new NotificationService($db) : null; + + $line = $start + 2; // header is line 1 + $records = (new Statement())->offset(1 + $start)->limit(UserImport::CHUNK_SIZE)->process($reader); + $processed = 0; + foreach ($records as $row) { + $processed++; + $row = array_values((array) $row); + $label = ''; + try { + $parsed = UserImport::normalizeRow($row, $state['mapping'], $customDefinitions, $locales, $visible); + $record = $parsed['record']; + $label = trim((string) ($record['nome'] ?? '') . ' ' . (string) ($record['cognome'] ?? '')) ?: (string) ($record['email'] ?? ''); + if ($parsed['error'] !== null) { + $this->rowError($state, $line, $this->rowLabel($row, $state['mapping']), $parsed['error'], 'validation'); + continue; + } + $email = (string) $record['email']; + $code = (string) ($record['codice_tessera'] ?? ''); + if (isset($state['seen_emails'][$email]) || $this->exists($db, 'email', $email)) { + $this->duplicate($state, $line, $label, sprintf(__('Email già registrata: %s'), $email)); + continue; + } + if ($code !== '' && (isset($state['seen_codes'][mb_strtolower($code)]) || $this->exists($db, 'codice_tessera', $code))) { + $this->duplicate($state, $line, $label, sprintf(__('Codice tessera già assegnato: %s'), $code)); + continue; + } + if ($code === '') { + $record['codice_tessera'] = $this->generateCardCode($db); + } + $record['locale'] ??= $installLocale; + + $userId = $this->insertUser($db, $record); + $state['seen_emails'][$email] = true; + $state['seen_codes'][mb_strtolower((string) $record['codice_tessera'])] = true; + $state['created']++; + + if ($parsed['custom'] !== []) { + RegistrationFields::saveValues($db, $userId, $parsed['custom']); + } + if ($patronTypes->assign($userId, $parsed['tipologia']) === 'unknown') { + $state['errors'][] = ['line' => $line, 'title' => $label, 'message' => sprintf(__('Tipologia lettore sconosciuta: %s (assegnata quella predefinita)'), (string) $parsed['tipologia']), 'type' => 'warning']; + } + if ($notifier !== null) { + $token = PasswordSetupToken::issue($db, $userId); + if ($token !== null && $notifier->sendUserPasswordSetup($userId, $token)) { + $state['invited']++; + } else { + $state['invite_failed']++; + $state['errors'][] = ['line' => $line, 'title' => $label, 'message' => __('Utente importato, ma l\'invito non è stato inviato.'), 'type' => 'email']; + } + } + } catch (\Throwable $e) { + $field = $e instanceof \mysqli_sql_exception ? \App\Support\UniqueViolation::fieldFor($e) : 'other'; + if ($field === 'email' || $field === 'codice_tessera' || $field === 'cod_fiscale') { + $this->duplicate($state, $line, $label, match ($field) { + 'email' => __('Email già registrata'), + 'codice_tessera' => __('Codice tessera già assegnato'), + default => __('Codice fiscale già registrato'), + }); + } else { + SecureLogger::error('[UserImport] row failed', ['line' => $line, 'error' => $e->getMessage()]); + $this->rowError($state, $line, $label, __('Errore del database'), 'database'); + } + } finally { + $line++; + } + } + + $state['next'] = $start + $processed; + $done = $processed === 0 || $state['next'] >= $total; + $payload = [ + 'success' => true, + 'processed' => (int) $state['next'], + 'total_rows' => $total, + 'created' => (int) $state['created'], + 'duplicates' => (int) $state['duplicates'], + 'failed' => (int) $state['failed'], + 'invited' => (int) $state['invited'], + 'done' => $done, + ]; + + if ($done) { + $payload['import_id'] = $this->finish($db, $state); + $payload['invite_failed'] = (int) $state['invite_failed']; + $payload['errors'] = array_slice($state['errors'], 0, 50); + $this->discardSessionFile(); + unset($_SESSION[self::SESSION_KEY]); + SecureLogger::info('Users imported from CSV', [ + 'created' => $state['created'], + 'duplicates' => $state['duplicates'], + 'failed' => $state['failed'], + 'by' => $_SESSION['user']['id'] ?? null, + ]); + } else { + $_SESSION[self::SESSION_KEY] = $state; + } + return $this->json($response, $payload); + } + + /** Write the run into import_logs; returns its import_id ('' when logging failed). */ + private function finish(mysqli $db, array $state): string + { + try { + $logger = new ImportLogger($db, 'users', (string) $state['name'], isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null); + $logger->setStats(['imported' => (int) $state['created'], 'failed' => (int) $state['failed']]); + foreach ($state['errors'] as $error) { + $logger->addError((int) $error['line'], (string) $error['title'], (string) $error['message'], (string) $error['type']); + } + $logger->complete((int) $state['total']); + return $logger->getImportId(); + } catch (\Throwable $e) { + SecureLogger::error('[UserImport] could not write the import log: ' . $e->getMessage()); + return ''; + } + } + + /** @param array $record */ + private function insertUser(mysqli $db, array $record): int + { + // Never imported: the password is a random secret nobody knows, the + // reader sets a real one through the setup invitation. + $password = password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT); + $stato = 'attivo'; + $tipo = 'standard'; + $verified = 1; + $stmt = $db->prepare('INSERT INTO utenti ( + nome, cognome, email, telefono, password, indirizzo, cod_fiscale, data_nascita, sesso, + codice_tessera, data_scadenza_tessera, stato, tipo_utente, note_utente, locale, email_verificata + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + $stmt->bind_param( + str_repeat('s', 15) . 'i', + $record['nome'], + $record['cognome'], + $record['email'], + $record['telefono'], + $password, + $record['indirizzo'], + $record['cod_fiscale'], + $record['data_nascita'], + $record['sesso'], + $record['codice_tessera'], + $record['data_scadenza_tessera'], + $stato, + $tipo, + $record['note_utente'], + $record['locale'], + $verified + ); + try { + $stmt->execute(); + return (int) $stmt->insert_id; + } finally { + $stmt->close(); + } + } + + private function exists(mysqli $db, string $column, string $value): bool + { + $sql = $column === 'email' + ? 'SELECT 1 FROM utenti WHERE LOWER(email) = LOWER(?) LIMIT 1' + : 'SELECT 1 FROM utenti WHERE codice_tessera = ? LIMIT 1'; + $stmt = $db->prepare($sql); + if ($stmt === false) { + return false; + } + $stmt->bind_param('s', $value); + $stmt->execute(); + $found = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $found; + } + + /** Same shape as the codes UsersController generates for new readers. */ + private function generateCardCode(mysqli $db): string + { + do { + $code = 'T' . strtoupper(substr(bin2hex(random_bytes(6)), 0, 10)); + } while ($this->exists($db, 'codice_tessera', $code)); + return $code; + } + + private function rowError(array &$state, int $line, string $label, string $message, string $type): void + { + $state['failed']++; + $state['errors'][] = ['line' => $line, 'title' => $label, 'message' => $message, 'type' => $type]; + } + + private function duplicate(array &$state, int $line, string $label, string $message): void + { + $state['duplicates']++; + $state['errors'][] = ['line' => $line, 'title' => $label, 'message' => $message, 'type' => 'duplicate']; + } + + /** + * A readable name for a row that failed validation (nome/cognome/email cells). + * + * @param list $row + * @param array $mapping + */ + private function rowLabel(array $row, array $mapping): string + { + $parts = []; + foreach (['nome', 'cognome', 'email'] as $field) { + $index = array_search($field, $mapping, true); + if ($index !== false && trim((string) ($row[$index] ?? '')) !== '') { + $parts[] = trim((string) $row[$index]); + } + } + return mb_substr(implode(' ', $parts), 0, 200); + } + + /** @return array */ + private function targetFields(mysqli $db): array + { + return UserImport::targetFields(RegistrationFields::definitions($db), (new PatronTypeImport($db))->available()); + } + + private function discardSessionFile(): void + { + $file = $_SESSION[self::SESSION_KEY]['file'] ?? null; + if (is_string($file) && str_contains($file, '/writable/uploads/user_import_') && is_file($file)) { + @unlink($file); + } + } + + /** @param array $payload */ + private function json(Response $response, array $payload, int $status = 200): Response + { + $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); + } +} diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index 01202f669..5e44ae548 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -707,6 +707,9 @@ public function delete(Request $request, Response $response, mysqli $db, int $id return $response->withHeader('Location', url('/admin/users?error=db_error'))->withStatus(302); } + // The photo is personal data: it goes with the account. + \App\Support\PatronPhoto::deleteFile($userToDelete['foto_profilo_url'] ?? null); + // Audit logging for user deletion \App\Support\SecureLogger::info('User deleted', [ 'deleted_user_id' => $id, diff --git a/app/Controllers/UtentiApiController.php b/app/Controllers/UtentiApiController.php index 79c425f76..c3a7d8324 100644 --- a/app/Controllers/UtentiApiController.php +++ b/app/Controllers/UtentiApiController.php @@ -92,12 +92,13 @@ public function list(Request $request, Response $response, mysqli $db): Response // Map column index to database column // 0: nome/cognome, 1: email, 2: telefono, 3: tipo_utente, 4: stato + // Column 0 of the on-screen table is the selection checkbox (not orderable). $columnMap = [ - 0 => 'u.cognome', // Nome (sorts by cognome first) - 1 => 'u.email', // Email - 2 => 'u.telefono', // Telefono - 3 => 'u.tipo_utente', // Ruolo - 4 => 'u.stato', // Stato + 1 => 'u.cognome', // Nome (sorts by cognome first) + 2 => 'u.email', // Email + 3 => 'u.telefono', // Telefono + 4 => 'u.tipo_utente', // Ruolo + 5 => 'u.stato', // Stato ]; // Parse order parameter from DataTables @@ -109,7 +110,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $orderColumn = $columnMap[$colIdx]; $orderDir = $dir; // Only add secondary sort for nome column - $orderExtra = ($colIdx === 0) ? ', u.nome ' . $dir : ''; + $orderExtra = ($colIdx === 1) ? ', u.nome ' . $dir : ''; } } diff --git a/app/Routes/web.php b/app/Routes/web.php index 65b12fff7..631e2c1b2 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -718,6 +718,17 @@ return $controller->updateLabels($request, $response, $db); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Library cards (patron-cards-import): the tab lives in /admin/settings like + // the labels one; the GET form of the address opens that tab. + $app->get('/admin/settings/cards', function ($request, $response) { + return $response->withHeader('Location', url('/admin/settings?tab=cards'))->withStatus(302); + })->add(new AdminAuthMiddleware()); + $app->post('/admin/settings/cards', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + $controller = new SettingsController(); + return $controller->updateCards($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/settings/sharing', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); $controller = new SettingsController(); @@ -1271,6 +1282,50 @@ return $controller->activateDirectly($request, $response, $db, $args); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Library cards, patron photo and user CSV import (patron-cards-import). + $app->get('/admin/users/{id:\d+}/card-pdf', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->cardPdf($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->get('/admin/users/cards-pdf', function ($request, $response) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->bulkPdf($request, $response, $app->getContainer()->get('db')); + })->add(new \App\Middleware\RateLimitMiddleware(15, 60))->add(new AdminAuthMiddleware()); + $app->get('/admin/users/{id:\d+}/photo', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->photo($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new AdminAuthMiddleware()); + $app->post('/admin/users/{id:\d+}/photo', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->uploadPhoto($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/users/{id:\d+}/photo/delete', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->deletePhoto($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->get('/admin/users/import', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->page($request, $response, $app->getContainer()->get('db')); + })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->get('/admin/users/import/example', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->example($request, $response, $app->getContainer()->get('db')); + })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/users/import/upload', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->upload($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/users/import/start', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->start($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // No RateLimitMiddleware on /chunk: same rationale as the book import (#113), + // one sequential request per chunk, bound to the session's import. + $app->post('/admin/users/import/chunk', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->chunk($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // CSV Export route for users $app->get('/admin/users/export/csv', function ($request, $response) use ($app) { $controller = new \App\Controllers\UsersController(); diff --git a/app/Support/ContentSecurityPolicy.php b/app/Support/ContentSecurityPolicy.php index 50d0b48f4..385a1a40b 100644 --- a/app/Support/ContentSecurityPolicy.php +++ b/app/Support/ContentSecurityPolicy.php @@ -28,6 +28,24 @@ public static function createNonce(): string return implode('-', str_split(bin2hex(random_bytes(16)), 8)); } + /** + * The Permissions-Policy header sent with every response. + * + * camera=(self): two same-origin features call getUserMedia — the copy + * barcode scanner (zxing-wasm) on the loan/return pages and the patron + * photo taken from the webcam in the user form. Denying it (camera=()) + * blocks both; no other origin (no embedded frame) gets the camera. + * geolocation and microphone stay denied: nothing in the app uses them. + * The CSP needs nothing for the photo: the live MediaStream is not a URL + * that media-src governs, the captured frame is drawn on a canvas and sent + * to the same origin (connect-src 'self'), and the stored photo is an + * ordinary same-origin image (img-src 'self'). + */ + public static function permissionsPolicy(): string + { + return 'geolocation=(), microphone=(), camera=(self)'; + } + public static function header(string $nonce, bool $upgradeInsecureRequests = false): string { if (!preg_match(self::NONCE_PATTERN, $nonce)) { diff --git a/app/Support/PatronCardPdf.php b/app/Support/PatronCardPdf.php new file mode 100644 index 000000000..5b923076a --- /dev/null +++ b/app/Support/PatronCardPdf.php @@ -0,0 +1,424 @@ +settings = $settings; + $this->title = $settings['title'] !== '' ? $settings['title'] : $appName; + $this->logoFile = ($settings['show_logo'] && $logoFile !== null && self::isUsableImage($logoFile)) ? $logoFile : null; + } + + /** + * Card size (mm) for the configured layout, as [width, height]. + * + * @return array{0: float, 1: float} + */ + public function cardSize(): array + { + return $this->settings['layout'] === 'portrait' + ? [self::CARD_H, self::CARD_W] + : [self::CARD_W, self::CARD_H]; + } + + /** + * Slots of one A4 sheet as [columns, rows, left, top] for the layout. + * + * @return array{0:int, 1:int, 2:float, 3:float} + */ + public function sheetGrid(): array + { + [$w, $h] = $this->cardSize(); + $cols = (int) floor(self::A4_W / $w); + $rows = (int) floor((self::A4_H - 20) / $h); + $cols = max(1, min($cols, 3)); + $rows = max(1, $rows); + $left = (self::A4_W - $cols * $w) / 2; + $top = (self::A4_H - $rows * $h) / 2; + return [$cols, $rows, $left, $top]; + } + + /** + * @param list $cards + * @param string $format 'single' or 'sheet' + * @return string the PDF bytes + */ + public function render(array $cards, string $format): string + { + [$w, $h] = $this->cardSize(); + $portrait = $h > $w; + $hasBack = $this->settings['back_text'] !== ''; + + if ($format === 'sheet') { + $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false); + } else { + $pdf = new TCPDF($portrait ? 'P' : 'L', 'mm', [$w, $h], true, 'UTF-8', false); + } + $pdf->SetCreator($this->title); + $pdf->SetAuthor($this->title); + $pdf->SetTitle(__('Tessere della biblioteca')); + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(false); + $pdf->SetMargins(0, 0, 0); + $pdf->SetAutoPageBreak(false, 0); + $pdf->setCellPaddings(0, 0, 0, 0); + + if ($format === 'sheet') { + [$cols, $rows, $left, $top] = $this->sheetGrid(); + $perSheet = $cols * $rows; + foreach (array_chunk($cards, $perSheet) as $sheet) { + $pdf->AddPage(); + foreach ($sheet as $i => $card) { + $col = $i % $cols; + $row = intdiv($i, $cols); + $x = $left + $col * $w; + $y = $top + $row * $h; + $this->cutGuide($pdf, $x, $y, $w, $h); + $this->front($pdf, $x, $y, $w, $h, $card); + } + if ($hasBack) { + $pdf->AddPage(); + foreach ($sheet as $i => $card) { + // Mirror the columns: on a long-edge duplex print the + // back of column c lands behind column (cols-1-c). + $col = $cols - 1 - ($i % $cols); + $row = intdiv($i, $cols); + $x = $left + $col * $w; + $y = $top + $row * $h; + $this->cutGuide($pdf, $x, $y, $w, $h); + $this->back($pdf, $x, $y, $w, $h); + } + } + } + } else { + foreach ($cards as $card) { + $pdf->AddPage(); + $this->front($pdf, 0, 0, $w, $h, $card); + if ($hasBack) { + $pdf->AddPage(); + $this->back($pdf, 0, 0, $w, $h); + } + } + } + + if ($cards === []) { + $pdf->AddPage(); + } + + return $pdf->Output('', 'S'); + } + + /** @param array{name:string, code:string, expiry:string, photo:?string} $card */ + private function front(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + switch ($this->settings['layout']) { + case 'minimal': + $this->frontMinimal($pdf, $x, $y, $w, $h, $card); + break; + case 'portrait': + $this->frontPortrait($pdf, $x, $y, $w, $h, $card); + break; + default: + $this->frontClassic($pdf, $x, $y, $w, $h, $card); + } + } + + /** + * Header band with logo and title; photo on the left; name, code and expiry + * on the right; Code128 across the bottom of the text column, or a QR code + * at its right. + * + * @param array{name:string, code:string, expiry:string, photo:?string} $card + */ + private function frontClassic(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + $band = 11.0; + $this->band($pdf, $x, $y, $w, $band); + + $pad = 3.0; + $top = $y + $band + 3; + $textX = $x + $pad; + if ($this->settings['show_photo']) { + $this->photo($pdf, $x + $pad, $top, 22, 28, $card); + $textX = $x + $pad + 22 + 3; + } + $textW = $x + $w - $pad - $textX; + $qr = $this->settings['code_type'] === 'qr'; + $qrSize = 20.0; + if ($qr) { + $textW -= $qrSize + 2; + } + + $this->fitText($pdf, $textX, $top, $textW, 6, $card['name'], 'B', 11); + $this->codeLine($pdf, $textX, $top + 7, $textW, $card['code'], 9); + if ($this->settings['show_expiry'] && $card['expiry'] !== '') { + $this->fitText($pdf, $textX, $top + 12, $textW, 4, __('Scadenza') . ': ' . $card['expiry'], '', 7, [80, 80, 80]); + } + + if ($qr) { + $this->qr($pdf, $x + $w - $pad - $qrSize, $top, $qrSize, $card['code']); + } else { + $this->barcode($pdf, $textX, $y + $h - $pad - 11, $textW, 11, $card['code']); + } + } + + /** + * No band and no photo: logo and title small at the top, the name large, + * the code as a wide barcode (or a QR on the right). + * + * @param array{name:string, code:string, expiry:string, photo:?string} $card + */ + private function frontMinimal(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + $pad = 4.0; + $titleX = $x + $pad; + if ($this->logoFile !== null) { + $logoW = $this->logoWidth(6, $w * 0.35); + $this->image($pdf, $this->logoFile, $x + $pad, $y + $pad, $logoW, 6, true); + $titleX = $x + $pad + $logoW + 2; + } + $this->fitText($pdf, $titleX, $y + $pad + 0.5, $x + $w - $pad - $titleX, 5, $this->title, 'B', 8, [70, 70, 70]); + + $qr = $this->settings['code_type'] === 'qr'; + $qrSize = 22.0; + $textW = $w - 2 * $pad - ($qr ? $qrSize + 3 : 0); + $this->fitText($pdf, $x + $pad, $y + 15, $textW, 8, $card['name'], 'B', 14); + if ($this->settings['show_expiry'] && $card['expiry'] !== '') { + $this->fitText($pdf, $x + $pad, $y + 24, $textW, 4, __('Scadenza') . ': ' . $card['expiry'], '', 7, [80, 80, 80]); + } + if ($qr) { + $this->qr($pdf, $x + $w - $pad - $qrSize, $y + 14, $qrSize, $card['code']); + $this->codeLine($pdf, $x + $pad, $y + 30, $textW, $card['code'], 10); + } else { + $this->barcode($pdf, $x + $pad, $y + 30, $w - 2 * $pad, 13, $card['code']); + $this->codeLine($pdf, $x + $pad, $y + 44, $w - 2 * $pad, $card['code'], 9, 'C'); + } + } + + /** + * Vertical card: band on top, photo centred, then name, code and expiry. + * + * @param array{name:string, code:string, expiry:string, photo:?string} $card + */ + private function frontPortrait(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + $band = 12.0; + $this->band($pdf, $x, $y, $w, $band); + $pad = 3.0; + $cursor = $y + $band + 3; + if ($this->settings['show_photo']) { + $this->photo($pdf, $x + ($w - 26) / 2, $cursor, 26, 32, $card); + $cursor += 34; + } else { + $cursor += 4; + } + $this->fitText($pdf, $x + $pad, $cursor, $w - 2 * $pad, 6, $card['name'], 'B', 10, [0, 0, 0], 'C'); + $cursor += 7; + if ($this->settings['code_type'] === 'qr') { + $this->qr($pdf, $x + ($w - 17) / 2, $cursor, 17, $card['code']); + $cursor += 18; + } else { + $this->barcode($pdf, $x + $pad + 2, $cursor, $w - 2 * $pad - 4, 10, $card['code']); + $cursor += 11; + } + $this->codeLine($pdf, $x + $pad, $cursor, $w - 2 * $pad, $card['code'], 8, 'C'); + if ($this->settings['show_expiry'] && $card['expiry'] !== '') { + $this->fitText($pdf, $x + $pad, $y + $h - $pad - 4, $w - 2 * $pad, 4, __('Scadenza') . ': ' . $card['expiry'], '', 7, [80, 80, 80], 'C'); + } + } + + private function back(TCPDF $pdf, float $x, float $y, float $w, float $h): void + { + $pad = 4.0; + $this->fitText($pdf, $x + $pad, $y + $pad, $w - 2 * $pad, 5, $this->title, 'B', 8); + $areaY = $y + $pad + 6; + $areaH = $h - 2 * $pad - 6; + $areaW = $w - 2 * $pad; + $text = $this->settings['back_text']; + $size = 7.0; + $pdf->SetFont(self::FONT, '', $size); + while ($size > 4.5 && $pdf->getStringHeight($areaW, $text) > $areaH) { + $size -= 0.25; + $pdf->SetFont(self::FONT, '', $size); + } + $pdf->SetTextColor(40, 40, 40); + $pdf->MultiCell($areaW, $areaH, $text, 0, 'L', false, 1, $x + $pad, $areaY, true, 0, false, true, $areaH, 'T', true); + $pdf->SetTextColor(0, 0, 0); + } + + private function band(TCPDF $pdf, float $x, float $y, float $w, float $hBand): void + { + $pdf->SetFillColor(31, 41, 55); + $pdf->Rect($x, $y, $w, $hBand, 'F'); + $titleX = $x + 3; + if ($this->logoFile !== null) { + $logoW = $this->logoWidth($hBand - 3, $w * 0.35); + $this->image($pdf, $this->logoFile, $x + 3, $y + 1.5, $logoW, $hBand - 3, true); + $titleX = $x + 3 + $logoW + 2; + } + $this->fitText($pdf, $titleX, $y + ($hBand - 5) / 2, $x + $w - 3 - $titleX, 5, $this->title, 'B', 9, [255, 255, 255]); + } + + /** @param array{name:string, code:string, expiry:string, photo:?string} $card */ + private function photo(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + $file = $card['photo']; + if ($file !== null && self::isUsableImage($file)) { + // Fit the photo in the box, centred, keeping its proportions. + $this->image($pdf, $file, $x, $y, $w, $h, true); + $pdf->SetDrawColor(200, 200, 200); + $pdf->Rect($x, $y, $w, $h, 'D'); + return; + } + $pdf->SetFillColor(229, 231, 235); + $pdf->Rect($x, $y, $w, $h, 'F'); + $initials = ''; + foreach (preg_split('/\s+/u', trim($card['name'])) ?: [] as $part) { + if ($part !== '' && mb_strlen($initials) < 2) { + $initials .= mb_strtoupper(mb_substr($part, 0, 1)); + } + } + $pdf->SetFont(self::FONT, 'B', 16); + $pdf->SetTextColor(120, 120, 120); + $pdf->MultiCell($w, $h, $initials, 0, 'C', false, 1, $x, $y, true, 0, false, true, $h, 'M'); + $pdf->SetTextColor(0, 0, 0); + } + + private function barcode(TCPDF $pdf, float $x, float $y, float $w, float $h, string $code): void + { + if ($code === '' || preg_match('/^[\x20-\x7E]+$/', $code) !== 1) { + return; // Code128 encodes printable ASCII only; the text line still shows the code. + } + $pdf->write1DBarcode($code, 'C128', $x, $y, $w, $h, 0.4, [ + 'stretch' => true, + 'fitwidth' => false, + 'border' => false, + 'text' => false, + 'fgcolor' => [0, 0, 0], + 'bgcolor' => false, + ], 'N'); + } + + private function qr(TCPDF $pdf, float $x, float $y, float $size, string $code): void + { + if ($code === '') { + return; + } + $pdf->write2DBarcode($code, 'QRCODE,M', $x, $y, $size, $size, [ + 'border' => false, + 'padding' => 0, + 'fgcolor' => [0, 0, 0], + 'bgcolor' => false, + ], 'N'); + } + + /** The card code as text: Helvetica when it is plain ASCII, the Unicode font otherwise. */ + private function codeLine(TCPDF $pdf, float $x, float $y, float $w, string $code, float $size, string $align = 'L'): void + { + $font = preg_match('/^[\x20-\x7E]*$/', $code) === 1 ? self::CODE_FONT : self::FONT; + $pdf->SetFont($font, 'B', $size); + while ($size > 5 && $pdf->GetStringWidth($code) > $w) { + $size -= 0.5; + $pdf->SetFont($font, 'B', $size); + } + $pdf->SetTextColor(0, 0, 0); + $pdf->SetXY($x, $y); + $pdf->Cell($w, 4, $code, 0, 0, $align, false, '', 0, false, 'T', 'T'); + } + + /** @param array{0:int,1:int,2:int} $color */ + private function fitText(TCPDF $pdf, float $x, float $y, float $w, float $h, string $text, string $style, float $size, array $color = [0, 0, 0], string $align = 'L'): void + { + if ($text === '' || $w <= 1) { + return; + } + $pdf->SetFont(self::FONT, $style, $size); + while ($size > 5 && $pdf->GetStringWidth($text) > $w) { + $size -= 0.5; + $pdf->SetFont(self::FONT, $style, $size); + } + $pdf->SetTextColor($color[0], $color[1], $color[2]); + $pdf->SetXY($x, $y); + // Cell with stretch 0 and no fitting beyond the font loop: a name + // longer than the minimum font allows is clipped, never overprinted. + $pdf->Cell($w, $h, $text, 0, 0, $align, false, '', 1, false, 'T', 'T'); + $pdf->SetTextColor(0, 0, 0); + } + + /** Width (mm) the logo takes at height $h, from its pixel proportions, capped at $max. */ + private function logoWidth(float $h, float $max): float + { + $info = $this->logoFile !== null ? @getimagesize($this->logoFile) : false; + if ($info === false || $info[1] <= 0) { + return $h; + } + return min($max, $h * ($info[0] / $info[1])); + } + + private function cutGuide(TCPDF $pdf, float $x, float $y, float $w, float $h): void + { + $pdf->SetLineWidth(0.1); + $pdf->SetDrawColor(190, 190, 190); + $pdf->Rect($x, $y, $w, $h, 'D'); + } + + private function image(TCPDF $pdf, string $file, float $x, float $y, float $w, float $h, bool $fit = false): void + { + try { + $pdf->Image($file, $x, $y, $w, $h, '', '', '', true, 300, '', false, false, 0, $fit ? 'CM' : false); + } catch (\Throwable $e) { + SecureLogger::warning('[PatronCardPdf] image skipped: ' . $e->getMessage()); + } + } + + /** TCPDF stops the request on an unreadable image, so only proven JPEG/PNG files reach it. */ + private static function isUsableImage(string $file): bool + { + if (!is_file($file) || !is_readable($file)) { + return false; + } + $info = @getimagesize($file); + return $info !== false && in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true); + } +} diff --git a/app/Support/PatronCardSettings.php b/app/Support/PatronCardSettings.php new file mode 100644 index 000000000..19e7cae55 --- /dev/null +++ b/app/Support/PatronCardSettings.php @@ -0,0 +1,111 @@ + 'classic', + 'code_type' => 'code128', + 'format' => 'single', + 'title' => '', + 'back_text' => '', + 'show_photo' => true, + 'show_logo' => true, + 'show_expiry' => true, + ]; + } + + /** + * @return array{layout:string, code_type:string, format:string, title:string, back_text:string, show_photo:bool, show_logo:bool, show_expiry:bool} + */ + public static function resolve(SettingsRepository $repository): array + { + $d = self::defaults(); + $raw = [ + 'card_layout' => (string) $repository->get(self::CATEGORY, 'layout', $d['layout']), + 'card_code_type' => (string) $repository->get(self::CATEGORY, 'code_type', $d['code_type']), + 'card_format' => (string) $repository->get(self::CATEGORY, 'format', $d['format']), + 'card_title' => (string) $repository->get(self::CATEGORY, 'title', $d['title']), + 'card_back_text' => (string) $repository->get(self::CATEGORY, 'back_text', $d['back_text']), + ]; + foreach (['show_photo', 'show_logo', 'show_expiry'] as $flag) { + if ($repository->get(self::CATEGORY, $flag, $d[$flag] ? '1' : '0') === '1') { + $raw['card_' . $flag] = '1'; + } + } + return self::sanitize($raw); + } + + /** + * Normalise a submitted settings form (or stored values) into the settings + * array. Unknown enum values fall back to the default; text is trimmed, + * stripped of tags and length-capped; an absent checkbox means "off". + * + * @param array $post + * @return array{layout:string, code_type:string, format:string, title:string, back_text:string, show_photo:bool, show_logo:bool, show_expiry:bool} + */ + public static function sanitize(array $post): array + { + $d = self::defaults(); + $pick = static function (mixed $value, array $allowed, string $fallback): string { + $value = is_scalar($value) ? strtolower(trim((string) $value)) : ''; + return in_array($value, $allowed, true) ? $value : $fallback; + }; + $text = static function (mixed $value, int $max, bool $multiline): string { + $value = is_scalar($value) ? (string) $value : ''; + $value = strip_tags(str_replace("\r\n", "\n", $value)); + $value = $multiline + ? (string) preg_replace("/\n{3,}/", "\n\n", $value) + : (string) preg_replace('/\s+/u', ' ', $value); + $value = trim($value); + return mb_substr($value, 0, $max, 'UTF-8'); + }; + return [ + 'layout' => $pick($post['card_layout'] ?? null, self::LAYOUTS, $d['layout']), + 'code_type' => $pick($post['card_code_type'] ?? null, self::CODE_TYPES, $d['code_type']), + 'format' => $pick($post['card_format'] ?? null, self::FORMATS, $d['format']), + 'title' => $text($post['card_title'] ?? '', self::TITLE_MAX, false), + 'back_text' => $text($post['card_back_text'] ?? '', self::BACK_TEXT_MAX, true), + 'show_photo' => !empty($post['card_show_photo']), + 'show_logo' => !empty($post['card_show_logo']), + 'show_expiry' => !empty($post['card_show_expiry']), + ]; + } + + /** + * @param array{layout:string, code_type:string, format:string, title:string, back_text:string, show_photo:bool, show_logo:bool, show_expiry:bool} $settings + */ + public static function save(SettingsRepository $repository, array $settings): void + { + foreach (['layout', 'code_type', 'format', 'title', 'back_text'] as $key) { + $repository->set(self::CATEGORY, $key, $settings[$key]); + } + foreach (['show_photo', 'show_logo', 'show_expiry'] as $flag) { + $repository->set(self::CATEGORY, $flag, $settings[$flag] ? '1' : '0'); + } + } +} diff --git a/app/Support/PatronPhoto.php b/app/Support/PatronPhoto.php new file mode 100644 index 000000000..184115ebc --- /dev/null +++ b/app/Support/PatronPhoto.php @@ -0,0 +1,189 @@ +-.jpg`); a random part in the name means a new + * photo never reuses a cached URL of the old one. + */ +final class PatronPhoto +{ + public const DIR = 'patron_photos'; + public const MAX_BYTES = 5 * 1024 * 1024; + public const MAX_EDGE = 600; + private const NAME_PATTERN = '/^patron_photos\/(\d+)-[a-f0-9]{16}\.jpg$/D'; + + /** Absolute directory holding the photos (created on first use). */ + public static function directory(): string + { + return dirname(__DIR__, 2) . '/storage/' . self::DIR; + } + + /** + * Absolute path of a stored photo, or null when the stored value is not + * one this class wrote (or the file is gone). The pattern check is what + * keeps a tampered column from pointing the reader at another file. + */ + public static function absolutePath(?string $stored): ?string + { + $stored = (string) $stored; + if ($stored === '' || preg_match(self::NAME_PATTERN, $stored) !== 1) { + return null; + } + $path = dirname(__DIR__, 2) . '/storage/' . $stored; + return is_file($path) ? $path : null; + } + + /** Stored value for a user, or null when none. */ + public static function storedFor(mysqli $db, int $userId): ?string + { + $stmt = $db->prepare('SELECT foto_profilo_url FROM utenti WHERE id = ? LIMIT 1'); + if ($stmt === false) { + return null; + } + $stmt->bind_param('i', $userId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + $value = is_array($row) ? ($row['foto_profilo_url'] ?? null) : null; + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * Decode $sourcePath, re-encode it as a bounded JPEG and make it the + * user's photo. Returns null on success or an error code: + * 'too_large', 'invalid_image', 'storage', 'db'. + */ + public static function store(mysqli $db, int $userId, string $sourcePath): ?string + { + if ($userId <= 0 || !is_file($sourcePath)) { + return 'invalid_image'; + } + $size = filesize($sourcePath); + if ($size === false || $size === 0) { + return 'invalid_image'; + } + if ($size > self::MAX_BYTES) { + return 'too_large'; + } + $jpeg = self::normalise($sourcePath); + if ($jpeg === null) { + return 'invalid_image'; + } + + $dir = self::directory(); + if (!is_dir($dir) && !@mkdir($dir, 0750, true) && !is_dir($dir)) { + SecureLogger::error('[PatronPhoto] cannot create the photo directory'); + return 'storage'; + } + $name = self::DIR . '/' . $userId . '-' . bin2hex(random_bytes(8)) . '.jpg'; + $target = dirname(__DIR__, 2) . '/storage/' . $name; + if (@file_put_contents($target, $jpeg, LOCK_EX) === false) { + SecureLogger::error('[PatronPhoto] cannot write the photo file', ['user_id' => $userId]); + return 'storage'; + } + @chmod($target, 0640); + + $previous = self::storedFor($db, $userId); + try { + $stmt = $db->prepare('UPDATE utenti SET foto_profilo_url = ? WHERE id = ?'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + $stmt->bind_param('si', $name, $userId); + $stmt->execute(); + $stmt->close(); + } catch (\Throwable $e) { + @unlink($target); + SecureLogger::error('[PatronPhoto] cannot record the photo', ['user_id' => $userId, 'error' => $e->getMessage()]); + return 'db'; + } + if ($previous !== null && $previous !== $name) { + self::deleteFile($previous); + } + return null; + } + + /** Remove the user's photo: the file and the reference. True when none is left. */ + public static function delete(mysqli $db, int $userId): bool + { + $previous = self::storedFor($db, $userId); + try { + $stmt = $db->prepare('UPDATE utenti SET foto_profilo_url = NULL WHERE id = ?'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + $stmt->bind_param('i', $userId); + $stmt->execute(); + $stmt->close(); + } catch (\Throwable $e) { + SecureLogger::error('[PatronPhoto] cannot clear the photo', ['user_id' => $userId, 'error' => $e->getMessage()]); + return false; + } + if ($previous !== null) { + self::deleteFile($previous); + } + return true; + } + + /** Delete the file behind a stored value (used when the account itself is deleted). */ + public static function deleteFile(?string $stored): void + { + $path = self::absolutePath($stored); + if ($path !== null) { + @unlink($path); + } + } + + /** Decode any GD-readable JPEG/PNG/WebP and re-encode it as a bounded JPEG. */ + private static function normalise(string $sourcePath): ?string + { + if (!function_exists('imagecreatefromstring')) { + return null; + } + $info = @getimagesize($sourcePath); + if ($info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { + return null; + } + [$width, $height] = [(int) $info[0], (int) $info[1]]; + if ($width < 16 || $height < 16 || $width > 8000 || $height > 8000) { + return null; + } + $bytes = @file_get_contents($sourcePath); + if ($bytes === false) { + return null; + } + $src = @imagecreatefromstring($bytes); + if ($src === false) { + return null; + } + $scale = min(1.0, self::MAX_EDGE / max($width, $height)); + $newW = max(1, (int) round($width * $scale)); + $newH = max(1, (int) round($height * $scale)); + $dst = imagecreatetruecolor($newW, $newH); + // A transparent PNG becomes white, not black. + imagefill($dst, 0, 0, (int) imagecolorallocate($dst, 255, 255, 255)); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $width, $height); + ob_start(); + imagejpeg($dst, null, 85); + $out = (string) ob_get_clean(); + return $out !== '' ? $out : null; + } +} diff --git a/app/Support/PatronTypeImport.php b/app/Support/PatronTypeImport.php new file mode 100644 index 000000000..4fd2bf236 --- /dev/null +++ b/app/Support/PatronTypeImport.php @@ -0,0 +1,108 @@ +|null code (lowercase) => id */ + private ?array $codes = null; + private ?bool $available = null; + + public function __construct( + private readonly mysqli $db, + private readonly string $typesTable = 'tipologie_lettore', + private readonly string $usersTable = 'utenti' + ) { + } + + /** True when the patron types schema exists (checked once per instance). */ + public function available(): bool + { + if ($this->available !== null) { + return $this->available; + } + $this->available = $this->columnExists($this->typesTable, 'id') + && $this->columnExists($this->typesTable, 'codice') + && $this->columnExists($this->usersTable, 'tipologia_id'); + return $this->available; + } + + /** + * Assign the patron type with code $code to the user. + * + * @return 'skipped'|'assigned'|'unknown' skipped = nothing to do (no code, + * or no patron types schema); unknown = the code matches no type, + * so the reader keeps the default type + */ + public function assign(int $userId, ?string $code): string + { + $code = trim((string) $code); + if ($code === '' || $userId <= 0 || !$this->available()) { + return 'skipped'; + } + $typeId = $this->idFor($code); + if ($typeId === null) { + return 'unknown'; + } + $stmt = $this->db->prepare("UPDATE `{$this->usersTable}` SET tipologia_id = ? WHERE id = ?"); + if ($stmt === false) { + return 'unknown'; + } + $stmt->bind_param('ii', $typeId, $userId); + $stmt->execute(); + $stmt->close(); + return 'assigned'; + } + + private function idFor(string $code): ?int + { + if ($this->codes === null) { + $this->codes = []; + $result = $this->db->query("SELECT id, codice FROM `{$this->typesTable}`"); + if ($result instanceof \mysqli_result) { + while ($row = $result->fetch_assoc()) { + $this->codes[mb_strtolower((string) $row['codice'], 'UTF-8')] = (int) $row['id']; + } + $result->free(); + } + } + return $this->codes[mb_strtolower($code, 'UTF-8')] ?? null; + } + + private function columnExists(string $table, string $column): bool + { + try { + $stmt = $this->db->prepare( + 'SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1' + ); + if ($stmt === false) { + return false; + } + $stmt->bind_param('ss', $table, $column); + $stmt->execute(); + $exists = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $exists; + } catch (\Throwable) { + return false; + } + } +} diff --git a/app/Support/Updater.php b/app/Support/Updater.php index 0ac058a17..fd0ceeb9d 100644 --- a/app/Support/Updater.php +++ b/app/Support/Updater.php @@ -111,6 +111,7 @@ class Updater 'storage/cache', 'storage/logs', 'storage/calendar', + 'storage/patron_photos', 'storage/tmp', 'storage/sessions', 'public/uploads', diff --git a/app/Support/UserImport.php b/app/Support/UserImport.php new file mode 100644 index 000000000..7f35095ef --- /dev/null +++ b/app/Support/UserImport.php @@ -0,0 +1,333 @@ + [max length or 0, required]. */ + private const FIELDS = [ + 'nome' => [100, true], + 'cognome' => [100, false], + 'email' => [255, true], + 'telefono' => [20, false], + 'indirizzo' => [500, false], + 'data_nascita' => [0, false], + 'sesso' => [0, false], + 'cod_fiscale' => [16, false], + 'codice_tessera' => [20, false], + 'data_scadenza_tessera' => [0, false], + 'note_utente' => [2000, false], + 'locale' => [0, false], + 'tipologia' => [40, false], + ]; + + /** Header spellings recognised for each field (normalised with normalizeHeader()). */ + private const ALIASES = [ + 'nome' => ['nome', 'name', 'firstname', 'first', 'givenname', 'vorname', 'prenom', 'fornavn'], + 'cognome' => ['cognome', 'surname', 'lastname', 'last', 'familyname', 'nachname', 'nom', 'nomdefamille', 'efternavn'], + 'email' => ['email', 'mail', 'emailaddress', 'indirizzoemail', 'posta', 'courriel', 'adressemail'], + 'telefono' => ['telefono', 'phone', 'tel', 'telephone', 'telefon', 'cellulare', 'mobile', 'telefonnummer'], + 'indirizzo' => ['indirizzo', 'address', 'adresse', 'indirizzocompleto'], + 'data_nascita' => ['datanascita', 'datadinascita', 'birthdate', 'dateofbirth', 'birthday', 'geburtsdatum', 'datedenaissance', 'fodselsdato'], + 'sesso' => ['sesso', 'gender', 'sex', 'geschlecht', 'sexe', 'kon'], + 'cod_fiscale' => ['codfiscale', 'codicefiscale', 'cf', 'taxcode', 'fiscalcode', 'steuernummer', 'codefiscal'], + 'codice_tessera' => ['codicetessera', 'tessera', 'card', 'cardnumber', 'cardcode', 'librarycard', 'barcode', 'ausweisnummer', 'ausweis', 'numerodecarte', 'kortnummer', 'lanerkort'], + 'data_scadenza_tessera' => ['datascadenzatessera', 'scadenzatessera', 'scadenza', 'expiry', 'expirydate', 'cardexpiry', 'expires', 'ablaufdatum', 'gultigbis', 'datedexpiration', 'udlobsdato'], + 'note_utente' => ['noteutente', 'note', 'notes', 'notizen', 'noter', 'noteinterne'], + 'locale' => ['locale', 'lingua', 'language', 'sprache', 'langue', 'sprog'], + 'tipologia' => ['tipologia', 'tipologialettore', 'patrontype', 'membertype', 'readertype', 'lesertyp', 'typedelecteur', 'lanertype'], + ]; + + /** + * Target fields shown in the mapping step, in order, as key => label. + * Custom registration fields follow as `custom:`; `tipologia` is offered + * only when the patron types exist (PatronTypeImport::available()). + * + * @param list $customDefinitions + * @return array + */ + public static function targetFields(array $customDefinitions, bool $withPatronType): array + { + $fields = [ + 'nome' => __('Nome'), + 'cognome' => __('Cognome'), + 'email' => __('Email'), + 'telefono' => __('Telefono'), + 'indirizzo' => __('Indirizzo'), + 'data_nascita' => __('Data di nascita'), + 'sesso' => __('Sesso'), + 'cod_fiscale' => __('Codice Fiscale'), + 'codice_tessera' => __('Codice tessera'), + 'data_scadenza_tessera' => __('Scadenza tessera'), + 'note_utente' => __('Note interne'), + 'locale' => __('Lingua dell\'interfaccia'), + ]; + if ($withPatronType) { + $fields['tipologia'] = __('Tipologia lettore (codice)'); + } + foreach ($customDefinitions as $def) { + $fields['custom:' . $def['id']] = $def['etichetta']; + } + return $fields; + } + + /** Lowercase, accents removed, only letters and digits. */ + public static function normalizeHeader(string $header): string + { + $header = preg_replace('/^\xEF\xBB\xBF/', '', $header) ?? $header; + $header = mb_strtolower(trim($header), 'UTF-8'); + $header = strtr($header, ['ø' => 'o', 'æ' => 'ae', 'å' => 'a', 'ß' => 'ss']); + $ascii = function_exists('iconv') ? @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $header) : false; + if (is_string($ascii) && $ascii !== '') { + $header = strtolower($ascii); + } + return (string) preg_replace('/[^a-z0-9]/', '', $header); + } + + /** + * Suggested mapping: column index => field key ('' = ignore). Each field is + * proposed for one column at most (the first that matches). + * + * @param list $headers + * @param array $targetFields from targetFields() + * @return array + */ + public static function guessMapping(array $headers, array $targetFields): array + { + $byAlias = []; + foreach (self::ALIASES as $field => $aliases) { + if (!isset($targetFields[$field])) { + continue; + } + foreach ($aliases as $alias) { + $byAlias[$alias] ??= $field; + } + // The label in the current language too (an export from this + // installation carries localized headers). + $byAlias[self::normalizeHeader($targetFields[$field])] ??= $field; + } + foreach ($targetFields as $key => $label) { + if (str_starts_with($key, 'custom:')) { + $byAlias[self::normalizeHeader($label)] ??= $key; + } + } + $mapping = []; + $taken = []; + foreach ($headers as $i => $header) { + $norm = self::normalizeHeader((string) $header); + $field = $byAlias[$norm] ?? ''; + if ($field !== '' && !isset($taken[$field])) { + $mapping[$i] = $field; + $taken[$field] = true; + } else { + $mapping[$i] = ''; + } + } + return $mapping; + } + + /** + * Validate a submitted mapping. Returns the clean mapping, or an error + * message when a field is mapped twice or nome/email are missing. + * + * @param mixed $mapping column index => field key, as sent by the browser + * @param array $targetFields + * @return array{mapping: array, error: ?string} + */ + public static function validateMapping(mixed $mapping, array $targetFields, int $columnCount): array + { + if (!is_array($mapping)) { + return ['mapping' => [], 'error' => __('Mappatura delle colonne non valida.')]; + } + $clean = []; + $used = []; + foreach ($mapping as $index => $field) { + $index = (int) $index; + $field = is_string($field) ? $field : ''; + if ($index < 0 || $index >= $columnCount || $field === '') { + continue; + } + if (!isset($targetFields[$field])) { + return ['mapping' => [], 'error' => __('Mappatura delle colonne non valida.')]; + } + if (isset($used[$field])) { + return ['mapping' => [], 'error' => sprintf(__('Il campo "%s" è associato a più di una colonna.'), $targetFields[$field])]; + } + $used[$field] = true; + $clean[$index] = $field; + } + foreach (['nome', 'email'] as $required) { + if (!isset($used[$required])) { + return ['mapping' => [], 'error' => sprintf(__('Associa una colonna al campo obbligatorio "%s".'), $targetFields[$required])]; + } + } + ksort($clean); + return ['mapping' => $clean, 'error' => null]; + } + + /** + * Turn one CSV row into an account record. + * + * @param list $row raw cells + * @param array $mapping column index => field key + * @param list $customDefinitions + * @param list $locales valid interface locale codes + * @param array{sesso:bool, cod_fiscale:bool} $visible whether the optional profile fields are collected + * @return array{record: array, custom: array, tipologia: ?string, error: ?string} + */ + public static function normalizeRow(array $row, array $mapping, array $customDefinitions, array $locales, array $visible = ['sesso' => true, 'cod_fiscale' => true]): array + { + $fail = static fn (string $message): array => ['record' => [], 'custom' => [], 'tipologia' => null, 'error' => $message]; + $raw = []; + $customRaw = []; + foreach ($mapping as $index => $field) { + $value = self::cell($row[$index] ?? null); + if (str_starts_with($field, 'custom:')) { + $customRaw[(int) substr($field, 7)] = $value; + } else { + $raw[$field] = $value; + } + } + + $record = []; + foreach (self::FIELDS as $field => [$max]) { + $value = $raw[$field] ?? ''; + if ($max > 0 && mb_strlen($value, 'UTF-8') > $max) { + return $fail(sprintf(__('Il valore di "%s" è troppo lungo (massimo %d caratteri).'), $field, $max)); + } + $record[$field] = $value === '' ? null : $value; + } + + if ($record['nome'] === null) { + return $fail(__('Nome mancante.')); + } + if ($record['email'] === null) { + return $fail(__('Email mancante.')); + } + $email = mb_strtolower((string) $record['email'], 'UTF-8'); + if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + return $fail(sprintf(__('Email non valida: %s'), (string) $record['email'])); + } + $record['email'] = $email; + $record['cognome'] ??= ''; + + foreach (['data_nascita', 'data_scadenza_tessera'] as $dateField) { + if ($record[$dateField] !== null) { + $date = self::parseDate($record[$dateField]); + if ($date === null) { + return $fail(sprintf(__('Data non valida: %s'), $record[$dateField])); + } + $record[$dateField] = $date; + } + } + + $record['sesso'] = $visible['sesso'] ? self::normalizeGender($record['sesso']) : null; + $record['cod_fiscale'] = $visible['cod_fiscale'] && $record['cod_fiscale'] !== null ? strtoupper($record['cod_fiscale']) : null; + + if ($record['locale'] !== null) { + $code = str_replace('-', '_', $record['locale']); + $match = null; + foreach ($locales as $locale) { + if (strcasecmp($locale, $code) === 0 || strcasecmp(substr($locale, 0, 2), $code) === 0) { + $match = $locale; + break; + } + } + $record['locale'] = $match; // an unknown language falls back to the installation one + } + + $tipologia = $record['tipologia']; + unset($record['tipologia']); + + $custom = []; + if ($customDefinitions !== [] && $customRaw !== []) { + $mapped = array_values(array_filter($customDefinitions, static fn (array $d): bool => array_key_exists($d['id'], $customRaw))); + $result = RegistrationFields::validate($mapped, ['custom_field' => $customRaw], false); + if ($result['error'] !== null) { + return $fail(sprintf(__('Valore non valido per il campo "%s".'), $result['error'])); + } + $custom = array_filter($result['values'], static fn (string $v): bool => $v !== ''); + } + + return ['record' => $record, 'custom' => $custom, 'tipologia' => $tipologia, 'error' => null]; + } + + /** Y-m-d from the spellings a spreadsheet produces, or null. */ + public static function parseDate(string $value): ?string + { + $value = trim($value); + $formats = ['Y-m-d', 'd/m/Y', 'd.m.Y', 'd-m-Y', 'Y/m/d', 'd/m/y', 'd.m.y']; + foreach ($formats as $format) { + $date = \DateTimeImmutable::createFromFormat('!' . $format, $value); + if ($date !== false && $date->format($format) === $value) { + $year = (int) $date->format('Y'); + if ($year >= 1900 && $year <= 2200) { + return $date->format('Y-m-d'); + } + } + } + return null; + } + + public static function normalizeGender(?string $value): ?string + { + if ($value === null) { + return null; + } + $v = self::normalizeHeader($value); + return match (true) { + in_array($v, ['m', 'maschio', 'male', 'man', 'uomo', 'mannlich', 'homme', 'masculin', 'mand'], true) => 'M', + in_array($v, ['f', 'femmina', 'female', 'woman', 'donna', 'w', 'weiblich', 'femme', 'feminin', 'kvinde', 'k'], true) => 'F', + in_array($v, ['altro', 'other', 'x', 'divers', 'autre', 'andet', 'nonbinary', 'nb'], true) => 'Altro', + default => null, + }; + } + + /** + * Delimiter of a CSV sample: the most frequent of ";", "," and TAB, or null + * when none occurs. + * + * @param list $lines + */ + public static function detectDelimiter(array $lines): ?string + { + $candidates = [';' => 0, ',' => 0, "\t" => 0]; + foreach ($lines as $line) { + if (!is_string($line)) { + continue; + } + foreach (array_keys($candidates) as $delimiter) { + $candidates[$delimiter] += substr_count($line, $delimiter); + } + } + arsort($candidates); + $best = (string) array_key_first($candidates); + return $candidates[$best] > 0 ? $best : null; + } + + /** A CSV cell as trimmed text, with the export's formula-escape apostrophe undone. */ + private static function cell(mixed $value): string + { + $value = trim(is_scalar($value) ? (string) $value : ''); + if (strlen($value) > 1 && $value[0] === "'" && in_array($value[1], ['=', '+', '-', '@'], true)) { + $value = substr($value, 1); + } + return strip_tags($value); + } +} diff --git a/app/Views/admin/imports_history.php b/app/Views/admin/imports_history.php index 38d04a5c5..4570627ce 100644 --- a/app/Views/admin/imports_history.php +++ b/app/Views/admin/imports_history.php @@ -181,6 +181,7 @@ 'csv' => ['label' => 'CSV', 'color' => 'bg-blue-100 text-blue-800'], 'librarything' => ['label' => 'LibraryThing', 'color' => 'bg-purple-100 text-purple-800'], 'marc' => ['label' => 'MARC', 'color' => 'bg-green-100 text-green-800'], + 'users' => ['label' => __('Utenti'), 'color' => 'bg-amber-100 text-amber-800'], ]; $type = $typeLabels[$import['import_type']] ?? ['label' => 'Unknown', 'color' => 'bg-gray-100 text-gray-800']; diff --git a/app/Views/partials/settings-sections.php b/app/Views/partials/settings-sections.php index 6b308a6db..de3f3a7d8 100644 --- a/app/Views/partials/settings-sections.php +++ b/app/Views/partials/settings-sections.php @@ -10,6 +10,7 @@ 'privacy' => ['group' => __('Sito e sistema'), 'label' => __('Privacy'), 'description' => __('Gestisci informativa, cookie e preferenze di consenso.'), 'icon' => 'fa-shield-alt'], 'messages' => ['group' => __('Comunicazione'), 'label' => __('Messaggi'), 'description' => __('Consulta le richieste ricevute dal modulo di contatto.'), 'icon' => 'fa-inbox'], 'labels' => ['group' => __('Biblioteca'), 'label' => __('Etichette'), 'description' => __('Imposta formato e contenuto delle etichette da stampare.'), 'icon' => 'fa-barcode'], + 'cards' => ['group' => __('Biblioteca'), 'label' => __('Tessere'), 'description' => __('Imposta layout, codice e retro delle tessere della biblioteca da stampare.'), 'icon' => 'fa-id-card'], 'sharing' => ['group' => __('Sito e sistema'), 'label' => __('Condivisione'), 'description' => __('Personalizza le anteprime e i contenuti condivisi dal sito.'), 'icon' => 'fa-share-alt'], 'advanced' => ['group' => __('Sito e sistema'), 'label' => __('Avanzate'), 'description' => __('Gestisci integrazioni e configurazioni avanzate del sistema.'), 'icon' => 'fa-cogs'], 'loans' => ['group' => __('Biblioteca'), 'label' => __('Prestiti'), 'description' => __('Configura le regole e le automazioni della circolazione.'), 'icon' => 'fa-book-open'], diff --git a/app/Views/settings/cards-tab.php b/app/Views/settings/cards-tab.php new file mode 100644 index 000000000..7feb8f784 --- /dev/null +++ b/app/Views/settings/cards-tab.php @@ -0,0 +1,146 @@ + 'classic', 'name' => __('Classica'), 'desc' => __('Fascia con logo e nome della biblioteca, foto a sinistra, nome, codice e scadenza a destra.')], + ['key' => 'minimal', 'name' => __('Essenziale'), 'desc' => __('Senza foto: nome grande e codice a barre su tutta la larghezza.')], + ['key' => 'portrait', 'name' => __('Verticale'), 'desc' => __('Tessera in verticale con la foto al centro (9 per foglio A4).')], +]; +$cardCodeTypes = [ + ['key' => 'code128', 'name' => __('Codice a barre (Code128)'), 'desc' => __('Leggibile da ogni lettore di codici a barre del banco prestiti.')], + ['key' => 'qr', 'name' => __('QR code'), 'desc' => __('Leggibile anche con la fotocamera dello smartphone.')], +]; +$cardFormats = [ + ['key' => 'single', 'name' => __('Tessera singola (CR80)'), 'desc' => __('Una tessera per pagina, 85,6×54mm: per stampanti di tessere.')], + ['key' => 'sheet', 'name' => __('Foglio A4 da 10'), 'desc' => __('Dieci tessere per foglio A4 con i bordi di taglio.')], +]; +?> + +
      +
      + + +
      +
      +

      + + +

      +

      + + +

      +
      + +
      +
      +

      + +

      +
      + + + + +
      +
      + +
      + +
      + + + + +
      +
      + +
      + +
      + + + + +
      +
      + +
      + +
      + __('Foto dell\'utente'), + 'show_logo' => __('Logo della biblioteca'), + 'show_expiry' => __('Data di scadenza'), + ] as $settingKey => $settingLabel): ?> + + +
      + +
      + +
      + +

      + +
      + +
      +
      + +
      +

      +

      +
      +
      +
      +
      +
      + +
      + +
      +
      +
      diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index 5ace6cf99..c231e8395 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -892,6 +892,9 @@ class="mt-1 w-4 h-4 aspect-square shrink-0 text-gray-900 focus:ring-gray-500"> + + +
      diff --git a/app/Views/utenti/dettagli_utente.php b/app/Views/utenti/dettagli_utente.php index ad7e6d70d..76b5fee2a 100644 --- a/app/Views/utenti/dettagli_utente.php +++ b/app/Views/utenti/dettagli_utente.php @@ -21,6 +21,9 @@ $creatoIl = $utente['created_at'] ?? ''; $aggiornatoIl = $utente['updated_at'] ?? ''; $note = $utente['note_utente'] ?? ''; +$photoRaw = (string) ($utente['foto_profilo_url'] ?? ''); +$photoStored = \App\Support\PatronPhoto::absolutePath($photoRaw) !== null ? $photoRaw : null; +$photoUrl = $photoStored !== null ? url('/admin/users/' . $id . '/photo') . '?v=' . substr(sha1($photoStored), 0, 10) : null; $statusLabels = [ 'attivo' => __('Attivo'), @@ -122,6 +125,10 @@ ?>
      + + + + @@ -149,6 +156,47 @@ unset($workspaceHeader); ?> +
      +

      +
      +
      +

      diff --git a/app/Views/utenti/import.php b/app/Views/utenti/import.php new file mode 100644 index 000000000..e87d0b9f3 --- /dev/null +++ b/app/Views/utenti/import.php @@ -0,0 +1,353 @@ + __('— Ignora colonna —'), + 'uploading' => __('Caricamento del file...'), + 'rows' => __('%d righe da importare'), + 'processing' => __('Importazione in corso...'), + 'progress' => __('%d di %d righe elaborate'), + 'invalidResponse' => __('Risposta non valida dal server'), + 'networkError' => __('Errore di rete. Riprova.'), + 'line' => __('Riga'), + 'name' => __('Utente'), + 'type' => __('Tipo'), + 'message' => __('Messaggio'), + 'types' => [ + 'duplicate' => __('Duplicato'), + 'validation' => __('Riga non valida'), + 'database' => __('Errore del database'), + 'email' => __('Email'), + 'warning' => __('Avviso'), + ], +]; +?> +
      +
      +

      + + + +

      + + + + +
      +
      + + +
      +

      + + +

      +
      + + + +
      +
      +
      + + ;', ',') ?> +
      +
      + +
      +
      +
      + + + + + + + + +
      +

      + + +

      +

      + + + + +
      +
      + + +
      +
      +

      + + +

      +
        +
      • +
      • +
      • +
      • +
      • + +
      • + +
      • +
      +

      + + . +

      +
      +
      +
      +

      +
      + + diff --git a/app/Views/utenti/index.php b/app/Views/utenti/index.php index 62443edff..660cdbbc1 100644 --- a/app/Views/utenti/index.php +++ b/app/Views/utenti/index.php @@ -22,11 +22,15 @@

      -
      + + 1): + $ledgerFrom = ($ledgerPage - 1) * $ledgerPerPage + 1; + $ledgerTo = min($ledgerTotal, $ledgerPage * $ledgerPerPage); + $ledgerPageUrl = static fn (int $page): string => url('/admin/users/details/' . $id) . '?ledger_page=' . $page . '#movimenti'; + ?> + +
      +

      - +
      @@ -157,6 +179,7 @@ +

      @@ -173,6 +196,7 @@ +

      diff --git a/locale/da_DK.json b/locale/da_DK.json index e5efd9760..e49cd6a6a 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8975,7 +8975,7 @@ "L'importo supera il saldo a debito del lettore.": "Beløbet overstiger lånerens skyldige saldo.", "La descrizione dell'addebito è obbligatoria.": "En beskrivelse af gebyret er påkrævet.", "La motivazione del condono è obbligatoria.": "En begrundelse for eftergivelsen er påkrævet.", - "La multa cresce ogni giorno di ritardo e viene addebitata alla restituzione.": "Gebyret stiger for hver dag for sent og opkræves ved aflevering.", + "L'importo può crescere fino alla restituzione, secondo le regole della biblioteca, e viene addebitato in quel momento.": "Beløbet kan stige indtil afleveringen efter bibliotekets regler og opkræves på det tidspunkt.", "Metodo di pagamento": "Betalingsmåde", "Metodo di pagamento non valido.": "Ugyldig betalingsmåde.", "Metodo di pagamento:": "Betalingsmåde:", @@ -9028,5 +9028,6 @@ "Multa giornaliera 0 = nessuna multa per questa tipologia; soglia di blocco 0 = nessun blocco per debito. Un campo vuoto eredita l'impostazione della scheda Prestiti.": "Dagsgebyr 0 = ingen gebyrer for denne type; spærregrænse 0 = ingen spærring for gæld. Et tomt felt arver indstillingen fra fanen Lån.", "Saldo da incassare": "Udestående saldo", "Lettori con saldo a debito": "Lånere med gæld", - "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Seneste 12 måneder; den udestående saldo er dagens. Måned for måned står i gebyrrapporten længere nede." + "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Seneste 12 måneder; den udestående saldo er dagens. Måned for måned står i gebyrrapporten længere nede.", + "Movimenti %1$d–%2$d di %3$d": "Posteringer %1$d–%2$d af %3$d" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 338d5ca16..3c5e0b7bd 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8975,7 +8975,7 @@ "L'importo supera il saldo a debito del lettore.": "Der Betrag übersteigt den offenen Betrag des Lesers.", "La descrizione dell'addebito è obbligatoria.": "Eine Beschreibung der Gebühr ist erforderlich.", "La motivazione del condono è obbligatoria.": "Ein Grund für den Erlass ist erforderlich.", - "La multa cresce ogni giorno di ritardo e viene addebitata alla restituzione.": "Die Gebühr steigt mit jedem Tag Verspätung und wird bei der Rückgabe berechnet.", + "L'importo può crescere fino alla restituzione, secondo le regole della biblioteca, e viene addebitato in quel momento.": "Der Betrag kann bis zur Rückgabe nach den Regeln der Bibliothek steigen und wird dann berechnet.", "Metodo di pagamento": "Zahlungsart", "Metodo di pagamento non valido.": "Ungültige Zahlungsart.", "Metodo di pagamento:": "Zahlungsart:", @@ -9028,5 +9028,6 @@ "Multa giornaliera 0 = nessuna multa per questa tipologia; soglia di blocco 0 = nessun blocco per debito. Un campo vuoto eredita l'impostazione della scheda Prestiti.": "Tagesgebühr 0 = keine Gebühren für diesen Typ; Sperrschwelle 0 = keine Sperre wegen Schulden. Ein leeres Feld übernimmt die Einstellung des Tabs Ausleihen.", "Saldo da incassare": "Offener Saldo", "Lettori con saldo a debito": "Leser mit offenem Saldo", - "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Letzte 12 Monate; der offene Saldo ist der heutige. Die Aufstellung Monat für Monat steht im Gebührenbericht weiter unten." + "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Letzte 12 Monate; der offene Saldo ist der heutige. Die Aufstellung Monat für Monat steht im Gebührenbericht weiter unten.", + "Movimenti %1$d–%2$d di %3$d": "Buchungen %1$d–%2$d von %3$d" } diff --git a/locale/en_US.json b/locale/en_US.json index 98365ca13..538fd8ab7 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8975,7 +8975,7 @@ "L'importo supera il saldo a debito del lettore.": "The amount exceeds what the reader owes.", "La descrizione dell'addebito è obbligatoria.": "A description of the charge is required.", "La motivazione del condono è obbligatoria.": "A reason for the waiver is required.", - "La multa cresce ogni giorno di ritardo e viene addebitata alla restituzione.": "The fine grows with every day late and is charged when you return the book.", + "L'importo può crescere fino alla restituzione, secondo le regole della biblioteca, e viene addebitato in quel momento.": "The amount can grow until the book is returned, following the library's rules, and is charged at that point.", "Metodo di pagamento": "Payment method", "Metodo di pagamento non valido.": "Invalid payment method.", "Metodo di pagamento:": "Payment method:", @@ -9028,5 +9028,6 @@ "Multa giornaliera 0 = nessuna multa per questa tipologia; soglia di blocco 0 = nessun blocco per debito. Un campo vuoto eredita l'impostazione della scheda Prestiti.": "Daily fine 0 = no fines for this type; block threshold 0 = no debt block. An empty field inherits the setting of the Loans tab.", "Saldo da incassare": "Outstanding balance", "Lettori con saldo a debito": "Patrons with a debt", - "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Last 12 months; the outstanding balance is today's. The month-by-month detail is in the fines report below." + "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Last 12 months; the outstanding balance is today's. The month-by-month detail is in the fines report below.", + "Movimenti %1$d–%2$d di %3$d": "Movements %1$d–%2$d of %3$d" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index acb26c666..ec7a1791b 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8975,7 +8975,7 @@ "L'importo supera il saldo a debito del lettore.": "Le montant dépasse le solde dû par le lecteur.", "La descrizione dell'addebito è obbligatoria.": "La description du frais est obligatoire.", "La motivazione del condono è obbligatoria.": "Le motif de la remise est obligatoire.", - "La multa cresce ogni giorno di ritardo e viene addebitata alla restituzione.": "La pénalité augmente chaque jour de retard et est facturée au retour.", + "L'importo può crescere fino alla restituzione, secondo le regole della biblioteca, e viene addebitato in quel momento.": "Le montant peut augmenter jusqu'au retour, selon les règles de la bibliothèque, et il est facturé à ce moment-là.", "Metodo di pagamento": "Moyen de paiement", "Metodo di pagamento non valido.": "Moyen de paiement non valide.", "Metodo di pagamento:": "Moyen de paiement :", @@ -9028,5 +9028,6 @@ "Multa giornaliera 0 = nessuna multa per questa tipologia; soglia di blocco 0 = nessun blocco per debito. Un campo vuoto eredita l'impostazione della scheda Prestiti.": "Pénalité journalière 0 = aucune pénalité pour ce type ; seuil de blocage 0 = aucun blocage pour dette. Un champ vide hérite du réglage de l'onglet Emprunts.", "Saldo da incassare": "Solde à encaisser", "Lettori con saldo a debito": "Lecteurs avec un solde débiteur", - "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "12 derniers mois ; le solde à encaisser est celui d'aujourd'hui. Le détail mois par mois se trouve dans le rapport des pénalités plus bas." + "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "12 derniers mois ; le solde à encaisser est celui d'aujourd'hui. Le détail mois par mois se trouve dans le rapport des pénalités plus bas.", + "Movimenti %1$d–%2$d di %3$d": "Mouvements %1$d–%2$d sur %3$d" } diff --git a/locale/it_IT.json b/locale/it_IT.json index c6b6353dd..86f3c5de7 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8975,7 +8975,7 @@ "L'importo supera il saldo a debito del lettore.": "L'importo supera il saldo a debito del lettore.", "La descrizione dell'addebito è obbligatoria.": "La descrizione dell'addebito è obbligatoria.", "La motivazione del condono è obbligatoria.": "La motivazione del condono è obbligatoria.", - "La multa cresce ogni giorno di ritardo e viene addebitata alla restituzione.": "La multa cresce ogni giorno di ritardo e viene addebitata alla restituzione.", + "L'importo può crescere fino alla restituzione, secondo le regole della biblioteca, e viene addebitato in quel momento.": "L'importo può crescere fino alla restituzione, secondo le regole della biblioteca, e viene addebitato in quel momento.", "Metodo di pagamento": "Metodo di pagamento", "Metodo di pagamento non valido.": "Metodo di pagamento non valido.", "Metodo di pagamento:": "Metodo di pagamento:", @@ -9028,5 +9028,6 @@ "Multa giornaliera 0 = nessuna multa per questa tipologia; soglia di blocco 0 = nessun blocco per debito. Un campo vuoto eredita l'impostazione della scheda Prestiti.": "Multa giornaliera 0 = nessuna multa per questa tipologia; soglia di blocco 0 = nessun blocco per debito. Un campo vuoto eredita l'impostazione della scheda Prestiti.", "Saldo da incassare": "Saldo da incassare", "Lettori con saldo a debito": "Lettori con saldo a debito", - "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso." + "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.", + "Movimenti %1$d–%2$d di %3$d": "Movimenti %1$d–%2$d di %3$d" } diff --git a/tests/fines-ledger.spec.js b/tests/fines-ledger.spec.js index 698df06cf..ef796563b 100644 --- a/tests/fines-ledger.spec.js +++ b/tests/fines-ledger.spec.js @@ -6,7 +6,7 @@ * create the overdue loans (due dates in the application's timezone) and to * verify what was written. * - * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/fines-ledger.spec.js --config=tests/playwright.config.js --workers=1 + * Run: /tmp/run-e2e.sh tests/fines-ledger.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); const { execFileSync } = require('child_process'); @@ -433,4 +433,47 @@ test.describe.serial('Fines and patron ledger', () => { expect(dbQuery(`SELECT stato FROM prestiti WHERE id=${loanId}`)).toBe('restituito'); expect(Number(dbQuery(`SELECT COUNT(*) FROM movimenti_lettore WHERE prestito_id=${loanId}`))).toBe(0); }); + + test('15. a desk form sent twice records one payment (double click, lost reply)', async () => { + dbQuery(`INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES (${state.patronId}, 'addebito_manuale', 3.00, 'ZZ doppio invio', NOW())`); + const before = balanceCents(); + await page.goto(`${BASE}/admin/users/details/${state.patronId}`); + await page.fill('#ledger_pay_amount', '1,00'); + const form = page.locator('#ledger-payment-form'); + const fields = await form.evaluate((f) => Object.fromEntries(new FormData(/** @type {HTMLFormElement} */ (f)).entries())); + expect(String(fields.request_key)).toMatch(/^[a-f0-9]{32}$/); + const action = await form.getAttribute('action'); + // The same submission twice, as a browser retry would send it. + for (let i = 0; i < 2; i++) { + const r = await page.request.post(new URL(String(action), BASE).toString(), { form: /** @type {Record} */ (fields), maxRedirects: 0 }); + expect(r.status()).toBe(302); + } + expect(balanceCents()).toBe(before - 100); + expect(Number(dbQuery(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${state.patronId} AND chiave_idempotenza='form:${fields.request_key}'`))).toBe(1); + // A fresh form is a new payment. + await page.goto(`${BASE}/admin/users/details/${state.patronId}`); + await page.fill('#ledger_pay_amount', '1,00'); + await page.locator('#ledger-payment-form button[type="submit"]').click(); + await expect(page.locator('[data-ledger-flash="paid"]')).toBeVisible(); + expect(balanceCents()).toBe(before - 200); + }); + + test('16. a long ledger pages through older movements on the reader record', async () => { + const existing = Number(dbQuery(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${state.patronId}`)); + const rows = []; + for (let i = 0; i < 205; i++) rows.push(`(${state.patronId}, 'addebito_manuale', 0.01, 'ZZ pagina ${i}', DATE_SUB(NOW(), INTERVAL ${400 + i} DAY))`); + dbQuery(`INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES ${rows.join(',')}`); + const total = existing + 205; + await page.goto(`${BASE}/admin/users/details/${state.patronId}`); + const pager = page.locator('[data-ledger-pager]'); + await expect(pager).toContainText(`1–200 di ${total}`); + await expect(page.locator('#ledger-movements tbody tr')).toHaveCount(200); + await expect(page.locator('#ledger-movements')).not.toContainText('ZZ pagina 204'); + await page.locator('[data-ledger-next]').click(); + await expect(page).toHaveURL(/ledger_page=2/); + await expect(page.locator('#ledger-movements')).toContainText('ZZ pagina 204'); + await expect(page.locator('#ledger-movements tbody tr')).toHaveCount(total - 200); + await page.locator('[data-ledger-prev]').click(); + await expect(page.locator('#ledger-movements tbody tr')).toHaveCount(200); + }); }); From 254a7c38ade5ec8ca48744377ed86f7d715257e0 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 08:22:56 +0100 Subject: [PATCH 108/398] Readers refused for debt see the debt message on every channel UserActionsController keeps the debt_block code instead of folding it into not_eligible. The book page shows its message; the app keeps the not_eligible code for existing builds, with the debt message. --- app/Controllers/UserActionsController.php | 20 +++++++++++------- app/Views/frontend/book-detail.php | 3 ++- .../src/Controllers/ActionsController.php | 4 ++++ tests/debt-block.spec.js | 21 ++++++++++++++++++- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index f3f18500a..3634d1ca4 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -712,8 +712,9 @@ public function loan(Request $request, Response $response, mysqli $db): Response // User eligibility gate (M7): stato/tessera are only verified at login, // so a user suspended (or with an expired card) mid-session could // otherwise keep submitting loan requests. - if (\App\Support\LoanEligibility::checkUser($db, $utenteId) !== null) { - return $this->back($response, ['loan_error' => 'not_eligible']); + if (($eligibilityError = \App\Support\LoanEligibility::checkUser($db, $utenteId)) !== null) { + // debt_block keeps its own message; any other refusal is not_eligible. + return $this->back($response, ['loan_error' => $eligibilityError === 'debt_block' ? 'debt_block' : 'not_eligible']); } // "Today" via DateHelper (M9): PHP process tz is often UTC while the @@ -804,9 +805,10 @@ public function loan(Request $request, Response $response, mysqli $db): Response $userLockStmt->execute(); $userLockStmt->get_result(); $userLockStmt->close(); - if (\App\Support\LoanEligibility::checkUser($db, $utenteId) !== null) { + if (($eligibilityError = \App\Support\LoanEligibility::checkUser($db, $utenteId)) !== null) { $db->rollback(); - return $this->back($response, ['loan_error' => 'not_eligible']); + // debt_block keeps its own message; any other refusal is not_eligible. + return $this->back($response, ['loan_error' => $eligibilityError === 'debt_block' ? 'debt_block' : 'not_eligible']); } // #384 coherence: the reservation-vs-loan decision is the SAME @@ -981,8 +983,9 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo // User eligibility gate (M7): stato/tessera are only verified at login, // so a user suspended (or with an expired card) mid-session could // otherwise keep queueing reservations. - if (\App\Support\LoanEligibility::checkUser($db, $utenteId) !== null) { - return $this->back($response, ['reserve_error' => 'not_eligible']); + if (($eligibilityError = \App\Support\LoanEligibility::checkUser($db, $utenteId)) !== null) { + // debt_block keeps its own message; any other refusal is not_eligible. + return $this->back($response, ['reserve_error' => $eligibilityError === 'debt_block' ? 'debt_block' : 'not_eligible']); } // Calculate date range for availability check. A start day the library @@ -1046,9 +1049,10 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo $userLockStmt->execute(); $userLockStmt->get_result(); $userLockStmt->close(); - if (\App\Support\LoanEligibility::checkUser($db, $utenteId) !== null) { + if (($eligibilityError = \App\Support\LoanEligibility::checkUser($db, $utenteId)) !== null) { $db->rollback(); - return $this->back($response, ['reserve_error' => 'not_eligible']); + // debt_block keeps its own message; any other refusal is not_eligible. + return $this->back($response, ['reserve_error' => $eligibilityError === 'debt_block' ? 'debt_block' : 'not_eligible']); } // Circulation rules of the patron type, under the user lock. diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index 4fc0cbf37..887718e00 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -710,7 +710,7 @@ class="book-cover-large img-fluid pk-book__img - + @@ -736,6 +736,7 @@ class="book-cover-large img-fluid pk-book__img \App\Support\LoanEligibility::errorMessage('not_loanable'), 'reservations_disabled' => \App\Support\LoanEligibility::errorMessage('reservations_disabled'), 'reservation_limit_reached' => \App\Support\LoanEligibility::errorMessage('reservation_limit_reached'), + 'debt_block' => \App\Support\LoanEligibility::errorMessage('debt_block'), ]; // Defense-in-depth: the values are __() translations from // the i18n catalog (app-controlled), and unknown keys fall diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index b64a61e18..51f855766 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -1301,6 +1301,10 @@ private function mapActionError(ResponseInterface $response, string $code, array // Guardia di idoneità M7 (utente sospeso o tessera scaduta): // condizione permanente lato utente, mai un errore server. return ResponseEnvelope::error($response, 'not_eligible', __('Non sei idoneo al prestito: account sospeso o tessera scaduta.'), 403); + case 'debt_block': + // Same code as before for existing app builds; the message + // says the balance is the reason. + return ResponseEnvelope::error($response, 'not_eligible', \App\Support\LoanEligibility::errorMessage('debt_block'), 403); case 'book_not_found': return ResponseEnvelope::error($response, 'not_found', __('Libro non trovato.'), 404); case 'past_date': diff --git a/tests/debt-block.spec.js b/tests/debt-block.spec.js index 94e687406..11ac4b638 100644 --- a/tests/debt-block.spec.js +++ b/tests/debt-block.spec.js @@ -5,7 +5,7 @@ * gets the dedicated message. The DB is used only to seed the debt and the * loans (dates in the application timezone) and to verify the outcome. * - * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/debt-block.spec.js --config=tests/playwright.config.js --workers=1 + * Run: /tmp/run-e2e.sh tests/debt-block.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); const { execFileSync } = require('child_process'); @@ -183,6 +183,25 @@ test.describe.serial('Debt block', () => { expect(Number(dbQuery(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${state.patronId} AND libro_id=${state.bookFree}`))).toBe(0); }); + test('2b. the app asking for the same book gets the debt message (code not_eligible kept)', async ({ request, browser }) => { + const F = require('./helpers/mobile-api-fixtures'); + const helper = await browser.newPage(); + let gate = null; + try { + gate = await F.ensureMobileApiReady(helper); + const token = await F.login(request, PATRON_EMAIL, PATRON_PASS, `zz-debt-${RUN}`); + const r = await F.api(request, 'POST', '/reservations', { token, body: { book_id: state.bookFree } }); + expect(r.status, r.text.slice(0, 300)).toBe(403); + expect(r.json.error.code).toBe('not_eligible'); + expect(r.json.error.message).toContain(DEBT_MESSAGE); + } finally { + F.restoreGate(gate); + dbQuery(`DELETE FROM mobile_app_tokens WHERE user_id=${state.patronId}`); + await helper.close(); + } + expect(Number(dbQuery(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${state.patronId} AND libro_id=${state.bookFree}`))).toBe(0); + }); + test('3. a loan created by the librarian is refused with the same message', async () => { await page.goto(`${BASE}/admin/loans/create`); await fillAutocomplete(page, '#utente_search', '#utente_suggest', SURNAME, '/api/search/utenti'); From 60ae16ffdf04aad5d76ef6b831f640a33a327f04 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 08:23:01 +0100 Subject: [PATCH 109/398] Ledger tests connect over TCP in CI; spec headers name the standard runner --- tests/fines-closed-days.spec.js | 2 +- tests/fines-patron-types.spec.js | 2 +- tests/patron-ledger.unit.php | 10 ++++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/fines-closed-days.spec.js b/tests/fines-closed-days.spec.js index 69069b4e1..3ab01f1ad 100644 --- a/tests/fines-closed-days.spec.js +++ b/tests/fines-closed-days.spec.js @@ -11,7 +11,7 @@ * Every loan is due 10 days ago and returned today: with no closure it is * 10 days late, so at 1.00 a day the fine in cents is 100 × open late days. * - * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/fines-closed-days.spec.js --config=tests/playwright.config.js --workers=1 + * Run: /tmp/run-e2e.sh tests/fines-closed-days.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); const fs = require('fs'); diff --git a/tests/fines-patron-types.spec.js b/tests/fines-patron-types.spec.js index 44ed6cd4a..8ff44119b 100644 --- a/tests/fines-patron-types.spec.js +++ b/tests/fines-patron-types.spec.js @@ -8,7 +8,7 @@ * desk. The database is used only to create the overdue loans (due dates in * the application's timezone), to seed a debt, and to verify what was written. * - * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/fines-patron-types.spec.js --config=tests/playwright.config.js --workers=1 + * Run: /tmp/run-e2e.sh tests/fines-patron-types.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); const fs = require('fs'); diff --git a/tests/patron-ledger.unit.php b/tests/patron-ledger.unit.php index 4f51ff85a..57d068ef4 100644 --- a/tests/patron-ledger.unit.php +++ b/tests/patron-ledger.unit.php @@ -42,9 +42,15 @@ [$key, $value] = explode('=', $line, 2); $env[trim($key)] = trim(trim($value), "\"'"); } -$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? '/opt/homebrew/var/mysql/mysql.sock'); +// Socket when one is configured and present (local dev), TCP otherwise (CI). +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? ''); try { - $db = new mysqli(null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ''), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket); + $user = getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''); + $pass = getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')); + $name = getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''); + $db = $socket !== '' && file_exists($socket) + ? new mysqli(null, $user, $pass, $name, 0, $socket) + : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), $user, $pass, $name, (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); $db->set_charset('utf8mb4'); DateHelper::synchronizeDatabaseSession($db); } catch (Throwable $e) { From 56e84b39fae73be64a06fe9e6925f97695831f2f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 08:23:24 +0100 Subject: [PATCH 110/398] Changelog: ledger forms recorded once, ledger pages, waived fines --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f7b57b7..73634d5fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre ### Fines, patron ledger and debt block (plan §7) - **Late fines.** Settings → Loans → "Multe e blocco per debito": daily fine, grace days, maximum fine per loan. These are the global layer of `CirculationPolicy`: a patron type (Settings → Circulation) can set its own daily fine, grace days, cap per loan and debt threshold, and a cell of the rules matrix its own fine, cap and grace for one material; an empty field inherits, 0 switches fines (or the block) off for that type or material. Late days are the days the library is open (closures calendar: weekly closed days, closures, yearly closures, branch closures for the copy's branch); with no closure configured every day counts. The grace period is a threshold (SLiMS rule): up to the grace days nothing is charged, beyond it every late day counts from the first. With the daily fine empty or 0 (the default) nothing changes. The fine is written at return, in the same transaction as the close, on every return path (return screen, quick return, NCIP check-in), and only once per return (idempotency key `late:`). Fines accruing on open overdue loans are shown on the loan, the reader record, the reader's own page and the app, never written in advance. -- **Patron ledger.** New append-only table `movimenti_lettore` (charges, payments, waivers, reversals); the balance is the sum of the rows. The reader record has a "Movimenti" section: balance, accruing fines, list, register a payment (cash, card, transfer, other) with a PDF receipt, manual charge (description required), waiver (reason required). The return screen shows the fine before the confirmation and can waive it on the spot, with a reason. A reader with ledger rows is suspended instead of deleted. +- **Patron ledger.** New append-only table `movimenti_lettore` (charges, payments, waivers, reversals); the balance is the sum of the rows. The reader record has a "Movimenti" section: balance, accruing fines, list, register a payment (cash, card, transfer, other) with a PDF receipt, manual charge (description required), waiver (reason required). Each desk form is recorded once even when it is sent twice, and a long ledger is shown 200 rows per page. The return screen shows the fine before the confirmation and can waive it on the spot, with a reason; a waived fine no longer appears in the return email, the fine email or on the loan page. A reader with ledger rows is suspended instead of deleted. - **Debt block.** With a threshold set (the patron type's, else the global one), a reader who owes more than it gets `debt_block` ("Saldo a debito oltre la soglia consentita…") on loan requests, admin loans, approvals, renewals and the reservation queue, on the website, the Mobile API and NCIP. - **Reports and email.** Statistics: charged, collected, waived and reversed per month with CSV export. Return email: new placeholder `{{multa}}` (empty when nothing was charged). New optional email "Multa per ritardo addebitata" (`fine_charged`), off by default. - **Mobile API 1.9.0** (requires 0.9.0): `GET /me/movements` with balance, movements and accruing fines; `debt_block` arrives as `not_eligible` with `meta.reason`. From c0d124148f8e08af3c74ac3020a338c7d1d9101b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 10:04:09 +0100 Subject: [PATCH 111/398] Movements partial: translated attribute text through htmlspecialchars (attribute-escaping guard) --- app/Views/utenti/_movimenti.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Views/utenti/_movimenti.php b/app/Views/utenti/_movimenti.php index 7a66b1caa..602b75218 100644 --- a/app/Views/utenti/_movimenti.php +++ b/app/Views/utenti/_movimenti.php @@ -121,7 +121,7 @@ - + @@ -137,7 +137,7 @@ $ledgerTo = min($ledgerTotal, $ledgerPage * $ledgerPerPage); $ledgerPageUrl = static fn (int $page): string => url('/admin/users/details/' . $id) . '?ledger_page=' . $page . '#movimenti'; ?> -
      +

      @@ -452,6 +453,14 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr

      + +
      +
      + +
      +
      +
      +
      @@ -463,6 +472,7 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr inputmode="decimal" id="daily_fine" name="daily_fine" + pattern="[0-9]{1,8}([.,][0-9]{1,2})?" placeholder="0" value="" @@ -482,6 +492,7 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr min="0" max="365" value="" @@ -502,6 +513,7 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr inputmode="decimal" id="fine_max_per_loan" name="fine_max_per_loan" + pattern="[0-9]{1,8}([.,][0-9]{1,2})?" placeholder="0" value="" @@ -522,6 +534,7 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr inputmode="decimal" id="debt_block_threshold" name="debt_block_threshold" + pattern="[0-9]{1,8}([.,][0-9]{1,2})?" placeholder="0" value="" @@ -542,6 +555,7 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr value="1" aria-labelledby="fine_charged_email_label" aria-describedby="fine_charged_email_desc" diff --git a/locale/da_DK.json b/locale/da_DK.json index e49cd6a6a..9bef6cd9b 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -9029,5 +9029,6 @@ "Saldo da incassare": "Udestående saldo", "Lettori con saldo a debito": "Lånere med gæld", "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Seneste 12 måneder; den udestående saldo er dagens. Måned for måned står i gebyrrapporten længere nede.", - "Movimenti %1$d–%2$d di %3$d": "Posteringer %1$d–%2$d af %3$d" + "Movimenti %1$d–%2$d di %3$d": "Posteringer %1$d–%2$d af %3$d", + "Solo gli amministratori possono modificare multe e soglia di blocco.": "Kun administratorer kan ændre gebyrer og spærregrænsen." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 3c5e0b7bd..f7053adf6 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -9029,5 +9029,6 @@ "Saldo da incassare": "Offener Saldo", "Lettori con saldo a debito": "Leser mit offenem Saldo", "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Letzte 12 Monate; der offene Saldo ist der heutige. Die Aufstellung Monat für Monat steht im Gebührenbericht weiter unten.", - "Movimenti %1$d–%2$d di %3$d": "Buchungen %1$d–%2$d von %3$d" + "Movimenti %1$d–%2$d di %3$d": "Buchungen %1$d–%2$d von %3$d", + "Solo gli amministratori possono modificare multe e soglia di blocco.": "Nur Administratoren können Gebühren und die Sperrschwelle ändern." } diff --git a/locale/en_US.json b/locale/en_US.json index 538fd8ab7..f5704098d 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -9029,5 +9029,6 @@ "Saldo da incassare": "Outstanding balance", "Lettori con saldo a debito": "Patrons with a debt", "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Last 12 months; the outstanding balance is today's. The month-by-month detail is in the fines report below.", - "Movimenti %1$d–%2$d di %3$d": "Movements %1$d–%2$d of %3$d" + "Movimenti %1$d–%2$d di %3$d": "Movements %1$d–%2$d of %3$d", + "Solo gli amministratori possono modificare multe e soglia di blocco.": "Only administrators can change fines and the debt threshold." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index ec7a1791b..a9ecdd577 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -9029,5 +9029,6 @@ "Saldo da incassare": "Solde à encaisser", "Lettori con saldo a debito": "Lecteurs avec un solde débiteur", "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "12 derniers mois ; le solde à encaisser est celui d'aujourd'hui. Le détail mois par mois se trouve dans le rapport des pénalités plus bas.", - "Movimenti %1$d–%2$d di %3$d": "Mouvements %1$d–%2$d sur %3$d" + "Movimenti %1$d–%2$d di %3$d": "Mouvements %1$d–%2$d sur %3$d", + "Solo gli amministratori possono modificare multe e soglia di blocco.": "Seuls les administrateurs peuvent modifier les pénalités et le seuil de blocage." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 86f3c5de7..ff2a85ab9 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -9029,5 +9029,6 @@ "Saldo da incassare": "Saldo da incassare", "Lettori con saldo a debito": "Lettori con saldo a debito", "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.": "Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso.", - "Movimenti %1$d–%2$d di %3$d": "Movimenti %1$d–%2$d di %3$d" + "Movimenti %1$d–%2$d di %3$d": "Movimenti %1$d–%2$d di %3$d", + "Solo gli amministratori possono modificare multe e soglia di blocco.": "Solo gli amministratori possono modificare multe e soglia di blocco." } diff --git a/tests/fines-ledger.spec.js b/tests/fines-ledger.spec.js index ef796563b..52924f8a2 100644 --- a/tests/fines-ledger.spec.js +++ b/tests/fines-ledger.spec.js @@ -30,7 +30,7 @@ const RUN = Date.now().toString(36); const PATRON_EMAIL = `zz-fines-${RUN}@test.local`; const PATRON_PASS = 'Test1234!'; const TITLE_PREFIX = `ZZ_FINES_${RUN}`; -const SETTING_KEYS = ['daily_fine', 'fine_grace_days', 'fine_max_per_loan', 'debt_block_threshold', 'fine_charged_email']; +const SETTING_KEYS = ['daily_fine', 'fine_grace_days', 'fine_max_per_loan', 'debt_block_threshold', 'fine_charged_email', 'max_renewals']; const EMAIL_KEYS = ['type', 'driver_mode', 'smtp_host', 'smtp_port', 'smtp_security', 'smtp_username', 'smtp_password', 'from_email', 'from_name']; function dbQuery(sql) { @@ -476,4 +476,38 @@ test.describe.serial('Fines and patron ledger', () => { await page.locator('[data-ledger-prev]').click(); await expect(page.locator('#ledger-movements tbody tr')).toHaveCount(200); }); + + test('17. staff saving the loans tab cannot change fines or the debt threshold', async ({ browser }) => { + const email = `zz-fines-staff-${RUN}@test.local`; + dbQuery(`DELETE FROM utenti WHERE email='${email}'`); + dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, privacy_accettata) VALUES ('${`ZZS${RUN}`.slice(0, 20)}', 'Zz', 'Staff', '${email}', '${esc(phpHash(PATRON_PASS))}', 'attivo', 1, 'staff', 1)`); + const before = { daily: readSetting('loans', 'daily_fine'), threshold: readSetting('loans', 'debt_block_threshold') }; + const staff = await browser.newPage(); + try { + await staff.goto(`${BASE}/accedi`); + await staff.fill('input[name="email"]', email); + await staff.fill('input[name="password"]', PATRON_PASS); + await staff.locator('button[type="submit"]').click(); + await staff.waitForURL((u) => !u.toString().includes('/accedi'), { timeout: 30000 }); + await staff.goto(`${BASE}/admin/settings?tab=loans`); + await expect(staff.locator('[data-testid="fines-admin-only"]')).toBeVisible(); + await expect(staff.locator('#daily_fine')).toBeDisabled(); + await expect(staff.locator('#debt_block_threshold')).toBeDisabled(); + // A hand-built POST with fine values is ignored for those keys only. + const form = staff.locator('form[action$="/admin/settings/loans"]'); + const fields = await form.evaluate((f) => Object.fromEntries(new FormData(/** @type {HTMLFormElement} */ (f)).entries())); + const r = await staff.request.post(`${BASE}/admin/settings/loans`, { form: { ...fields, daily_fine: '9,99', debt_block_threshold: '1', max_renewals: '7' }, maxRedirects: 0 }); + expect(r.status()).toBe(302); + expect(readSetting('loans', 'daily_fine')).toBe(before.daily); + expect(readSetting('loans', 'debt_block_threshold')).toBe(before.threshold); + expect(readSetting('loans', 'max_renewals')).toBe('7'); + } finally { + await staff.close(); + dbQuery(`DELETE FROM utenti WHERE email='${email}'`); + } + // The administrator sees the fields enabled. + await page.goto(`${BASE}/admin/settings?tab=loans`); + await expect(page.locator('#daily_fine')).toBeEnabled(); + await expect(page.locator('[data-testid="fines-admin-only"]')).toHaveCount(0); + }); }); From 61690152ec087ac755715f3f0a3e0f1003ef0fcf Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 12:04:05 +0100 Subject: [PATCH 113/398] Patron photo: rotate after the downscale, one full-size image in memory at a time --- app/Support/PatronPhoto.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/Support/PatronPhoto.php b/app/Support/PatronPhoto.php index 4625682c6..216c3f9f9 100644 --- a/app/Support/PatronPhoto.php +++ b/app/Support/PatronPhoto.php @@ -180,6 +180,9 @@ private static function normalise(string $sourcePath): ?string } // Phone photos carry their rotation in EXIF Orientation, which the // re-encode drops: turn the pixels so the card shows them upright. + // Read now, applied after the downscale, so GD never holds two + // full-resolution copies at once. + $angle = 0; if ($info[2] === IMAGETYPE_JPEG && function_exists('exif_read_data')) { $exif = @exif_read_data($sourcePath); $angle = match (is_array($exif) ? (int) ($exif['Orientation'] ?? 1) : 1) { @@ -188,13 +191,6 @@ private static function normalise(string $sourcePath): ?string 8 => 90, default => 0, }; - if ($angle !== 0) { - $rotated = imagerotate($src, $angle, 0); - if ($rotated !== false) { - $src = $rotated; - [$width, $height] = [imagesx($src), imagesy($src)]; - } - } } $scale = min(1.0, self::MAX_EDGE / max($width, $height)); $newW = max(1, (int) round($width * $scale)); @@ -203,6 +199,14 @@ private static function normalise(string $sourcePath): ?string // A transparent PNG becomes white, not black. imagefill($dst, 0, 0, (int) imagecolorallocate($dst, 255, 255, 255)); imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $width, $height); + unset($src); // free the full-size image before the rotation + if ($angle !== 0) { + // A quarter turn keeps the long edge within MAX_EDGE. + $rotated = imagerotate($dst, $angle, 0); + if ($rotated !== false) { + $dst = $rotated; + } + } ob_start(); imagejpeg($dst, null, 85); $out = (string) ob_get_clean(); From ea96416f49ec1b324da5fd6c31196d9231fc85db Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:09:01 +0000 Subject: [PATCH 114/398] Closures calendar: /admin/calendar follows the settings permission LibraryCalendarController::guard() admits Permissions::mayConfigure(), so a custom role with settings.manage may use the page, but the prefix was missing from the Permissions map and AdminAuthMiddleware refused that role before the controller ran. /admin/calendar/rotate-link keeps its own admin check inside the route, so the map changes nothing for it. --- app/Support/Permissions.php | 5 ++- tests/calendar-permission.unit.php | 54 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/calendar-permission.unit.php diff --git a/app/Support/Permissions.php b/app/Support/Permissions.php index a846e766a..a073b63d0 100644 --- a/app/Support/Permissions.php +++ b/app/Support/Permissions.php @@ -124,7 +124,10 @@ public static function permissionForPath(string $path): ?string 'loans.manage' => ['/admin/loans', '/admin/attendance', '/admin/reservations', '/api/loans', '/api/reservations', '/api/prestiti', '/prestiti', '/api/v1/staff/loans', '/api/v1/staff/reservations'], 'users.manage' => ['/admin/users', '/api/users', '/api/utenti', '/api/admin/pending-registrations', '/api/admin/pending-registrations-count', '/api/v1/staff/users', '/api/search/utenti'], 'digital.manage' => ['/admin/plugins/digital-library/upload'], - 'settings.manage' => ['/admin/settings'], + // The closures calendar is a settings page: LibraryCalendarController + // admits Permissions::mayConfigure(). /admin/calendar/rotate-link is + // admin-only inside its route, whatever the map says. + 'settings.manage' => ['/admin/settings', '/admin/calendar'], 'inventory.manage' => ['/admin/inventory'], ]; foreach ($groups as $permission => $prefixes) { diff --git a/tests/calendar-permission.unit.php b/tests/calendar-permission.unit.php new file mode 100644 index 000000000..88671f0f4 --- /dev/null +++ b/tests/calendar-permission.unit.php @@ -0,0 +1,54 @@ + 'standard', 'stato' => 'attivo', 'permissions' => ['backend.access', 'settings.manage']]; +$check(P::allowsPath('/admin/calendar', $configurator), 'a custom role with settings.manage opens the calendar page'); +$check(P::allowsPath('/admin/calendar/import-ics', $configurator, 'POST'), 'and reaches the ICS import'); +foreach (['/admin/books', '/admin/loans', '/admin/users', '/admin/inventory'] as $path) { + $check(!P::allowsPath($path, $configurator), "settings.manage does not open {$path}"); +} + +$desk = ['tipo_utente' => 'standard', 'stato' => 'attivo', 'permissions' => ['backend.access', 'loans.manage']]; +$check(!P::allowsPath('/admin/calendar', $desk), 'the loan desk permission alone does not open the calendar'); +$check(!P::allowsPath('/admin/calendar/closures', $desk, 'POST'), 'nor its writes'); +$check(P::allowsPath('/admin/calendar', ['tipo_utente' => 'staff', 'stato' => 'attivo']), 'Staff passes the middleware (the controller guard decides)'); +$check(P::allowsPath('/admin/calendar', ['tipo_utente' => 'admin', 'stato' => 'attivo']), 'administrators reach the calendar'); + +// The route closure of /admin/calendar/rotate-link refuses every non-admin +// itself: the map admits the configurator to the middleware only. +$routes = (string) file_get_contents(dirname(__DIR__) . '/app/Routes/web.php'); +$pos = strpos($routes, "'/admin/calendar/rotate-link'"); +$check($pos !== false && str_contains(substr($routes, $pos, 400), "!== 'admin'"), 'rotate-link checks tipo_utente admin inside its route'); + +echo "SUCCESS {$checks} calendar permission checks\n"; From a0e690d3eb0cd3478d596bb3ec08fcb11a462233 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:10:20 +0000 Subject: [PATCH 115/398] User import: convert a Windows-1252 CSV to UTF-8 on upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CSV saved by Excel on Windows is in the system code page, not UTF-8. The preview's json_encode() then returned false, the page received an empty 200 body and showed only "Risposta non valida dal server". The upload now reads the file once, drops a BOM, and converts the bytes from Windows-1252 when they are not valid UTF-8 (UserImport::toUtf8, a pure function with its own unit cases) before writing the staged copy, so every later step reads clean text; the preview carries a notice that the page shows above the table. Belt and braces: the JSON helper uses JSON_INVALID_UTF8_SUBSTITUTE so a response can never be empty, and the row labels of the error report are scrubbed to valid UTF-8. The E2E uploads a file with "Caffè" as a single 0xE8 byte and checks the preview, the notice and the stored UTF-8 bytes. --- CHANGELOG.md | 2 +- app/Controllers/UserImportController.php | 33 +++++++++++++++--------- app/Support/UserImport.php | 24 +++++++++++++++++ app/Views/utenti/import.php | 5 ++++ 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 + tests/patron-cards-import.unit.php | 8 ++++++ tests/user-import.spec.js | 30 +++++++++++++++++++++ 11 files changed, 94 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3608e235c..791fe4ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets. - **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card. - **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. -- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. +- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. ### Migration diff --git a/app/Controllers/UserImportController.php b/app/Controllers/UserImportController.php index 6c59acb53..2cc2444ad 100644 --- a/app/Controllers/UserImportController.php +++ b/app/Controllers/UserImportController.php @@ -102,16 +102,15 @@ public function upload(Request $request, Response $response, mysqli $db): Respon return $this->json($response, ['success' => false, 'error' => __('Impossibile leggere il file caricato')], 400); } - $handle = fopen($tmp, 'r'); - if ($handle === false) { + $bytes = @file_get_contents($tmp); + if ($bytes === false) { return $this->json($response, ['success' => false, 'error' => __('Impossibile leggere il file caricato')], 400); } - $sample = []; - for ($i = 0; $i < 5 && !feof($handle); $i++) { - $sample[] = fgets($handle); - } - fclose($handle); - $delimiter = UserImport::detectDelimiter($sample); + // The staged copy is always UTF-8 without BOM: a Windows-1252 export + // is converted here, once, so every later step reads clean text. + ['text' => $text, 'converted' => $converted] = UserImport::toUtf8($bytes); + unset($bytes); + $delimiter = UserImport::detectDelimiter(array_slice(explode("\n", $text, 6), 0, 5)); if ($delimiter === null) { return $this->json($response, ['success' => false, 'error' => __('File CSV non valido: usa ";", "," o TAB come separatore.')], 400); } @@ -121,9 +120,10 @@ public function upload(Request $request, Response $response, mysqli $db): Respon @mkdir($dir, 0755, true); } $saved = $dir . '/user_import_' . bin2hex(random_bytes(12)) . '.csv'; - if (!@copy($tmp, $saved)) { + if (@file_put_contents($saved, $text) === false) { return $this->json($response, ['success' => false, 'error' => __('Impossibile salvare il file CSV caricato')], 500); } + unset($text); try { $reader = Csv::readerFromPath($saved, $delimiter); @@ -160,6 +160,7 @@ public function upload(Request $request, Response $response, mysqli $db): Respon 'preview' => $preview, 'fields' => $fields, 'mapping' => UserImport::guessMapping($headers, $fields), + 'notice' => $converted ? __('Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell\'anteprima.') : null, ]); } @@ -249,7 +250,7 @@ public function chunk(Request $request, Response $response, mysqli $db): Respons try { $parsed = UserImport::normalizeRow($row, $state['mapping'], $customDefinitions, $locales, $visible); $record = $parsed['record']; - $label = trim((string) ($record['nome'] ?? '') . ' ' . (string) ($record['cognome'] ?? '')) ?: (string) ($record['email'] ?? ''); + $label = $this->text(trim((string) ($record['nome'] ?? '') . ' ' . (string) ($record['cognome'] ?? '')) ?: (string) ($record['email'] ?? '')); if ($parsed['error'] !== null) { $this->rowError($state, $line, $this->rowLabel($row, $state['mapping']), $parsed['error'], 'validation'); continue; @@ -437,7 +438,13 @@ private function rowLabel(array $row, array $mapping): string $parts[] = trim((string) $row[$index]); } } - return mb_substr(implode(' ', $parts), 0, 200); + return $this->text(implode(' ', $parts)); + } + + /** A label for the error report: valid UTF-8 (whatever the cells held) and at most 200 characters. */ + private function text(string $value): string + { + return mb_substr(mb_scrub($value, 'UTF-8'), 0, 200); } /** @@ -517,7 +524,9 @@ private function discardSessionFile(): void /** @param array $payload */ private function json(Response $response, array $payload, int $status = 200): Response { - $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + // A stray invalid byte becomes U+FFFD rather than an empty body the page + // cannot parse. + $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); return $response->withHeader('Content-Type', 'application/json')->withStatus($status); } } diff --git a/app/Support/UserImport.php b/app/Support/UserImport.php index 3ef47ed58..d2a2142af 100644 --- a/app/Support/UserImport.php +++ b/app/Support/UserImport.php @@ -305,6 +305,30 @@ public static function normalizeGender(?string $value): ?string }; } + /** + * The uploaded bytes as UTF-8. A BOM is dropped. A file that is not valid + * UTF-8 — a spreadsheet saved as "CSV" on Windows writes the system code + * page — is read as Windows-1252, the superset of ISO-8859-1 such exports + * fall under; "Caffè" then keeps its accent instead of breaking the JSON + * preview. Whatever comes back is valid UTF-8. + * + * @return array{text: string, converted: bool} + */ + public static function toUtf8(string $bytes): array + { + if (str_starts_with($bytes, "\xEF\xBB\xBF")) { + $bytes = substr($bytes, 3); + } + if (mb_check_encoding($bytes, 'UTF-8')) { + return ['text' => $bytes, 'converted' => false]; + } + $text = mb_convert_encoding($bytes, 'UTF-8', 'Windows-1252'); + if (!is_string($text) || !mb_check_encoding($text, 'UTF-8')) { + $text = mb_scrub($bytes, 'UTF-8'); + } + return ['text' => $text, 'converted' => true]; + } + /** * Delimiter of a CSV sample: the most frequent of ";", "," and TAB, or null * when none occurs. diff --git a/app/Views/utenti/import.php b/app/Views/utenti/import.php index 6d5ead1f0..93bee5c39 100644 --- a/app/Views/utenti/import.php +++ b/app/Views/utenti/import.php @@ -99,6 +99,9 @@

      —

      +
      @@ -251,6 +254,8 @@ function renderMapping(data) { state.fields = data.fields; $('user-import-file-name').textContent = data.file_name; $('user-import-row-count').textContent = fmt(T.rows, data.total_rows); + $('user-import-notice-text').textContent = data.notice || ''; + $('user-import-notice').classList.toggle('hidden', !data.notice); const thead = document.querySelector('#user-import-preview thead'); const tbody = document.querySelector('#user-import-preview tbody'); let head = ''; diff --git a/locale/da_DK.json b/locale/da_DK.json index bd1faf8f0..5c9d7f887 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8758,6 +8758,7 @@ "— Ignora colonna —": "— Ignorér kolonne —", "Caricamento del file...": "Uploader filen...", "%d righe da importare": "%d rækker at importere", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Filen var ikke UTF-8 og er blevet konverteret fra Windows-1252: tjek de accentuerede bogstaver i forhåndsvisningen.", "%d di %d righe elaborate": "%d af %d rækker behandlet", "Errore di rete. Riprova.": "Netværksfejl. Prøv igen.", "Duplicato": "Dublet", diff --git a/locale/de_DE.json b/locale/de_DE.json index 44508285b..8cbb14872 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8758,6 +8758,7 @@ "— Ignora colonna —": "— Spalte ignorieren —", "Caricamento del file...": "Datei wird hochgeladen...", "%d righe da importare": "%d Zeilen zu importieren", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Die Datei war nicht UTF-8 und wurde aus Windows-1252 umgewandelt: prüfen Sie die Umlaute und Akzente in der Vorschau.", "%d di %d righe elaborate": "%d von %d Zeilen verarbeitet", "Errore di rete. Riprova.": "Netzwerkfehler. Bitte erneut versuchen.", "Duplicato": "Duplikat", diff --git a/locale/en_US.json b/locale/en_US.json index 98ac688ea..70a0b00bc 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8758,6 +8758,7 @@ "— Ignora colonna —": "— Ignore column —", "Caricamento del file...": "Uploading the file...", "%d righe da importare": "%d rows to import", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "The file was not UTF-8 and has been converted from Windows-1252: check the accented letters in the preview.", "%d di %d righe elaborate": "%d of %d rows processed", "Errore di rete. Riprova.": "Network error. Please try again.", "Duplicato": "Duplicate", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index ab2d7d973..a00972380 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8758,6 +8758,7 @@ "— Ignora colonna —": "— Ignorer la colonne —", "Caricamento del file...": "Téléversement du fichier...", "%d righe da importare": "%d lignes à importer", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Le fichier n'était pas en UTF-8 et a été converti depuis Windows-1252 : vérifiez les lettres accentuées dans l'aperçu.", "%d di %d righe elaborate": "%d lignes traitées sur %d", "Errore di rete. Riprova.": "Erreur réseau. Réessayez.", "Duplicato": "Doublon", diff --git a/locale/it_IT.json b/locale/it_IT.json index ae93af43b..0bf521483 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8758,6 +8758,7 @@ "— Ignora colonna —": "— Ignora colonna —", "Caricamento del file...": "Caricamento del file...", "%d righe da importare": "%d righe da importare", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.", "%d di %d righe elaborate": "%d di %d righe elaborate", "Errore di rete. Riprova.": "Errore di rete. Riprova.", "Duplicato": "Duplicato", diff --git a/tests/patron-cards-import.unit.php b/tests/patron-cards-import.unit.php index fc683c318..ba54bd877 100644 --- a/tests/patron-cards-import.unit.php +++ b/tests/patron-cards-import.unit.php @@ -69,6 +69,14 @@ $check(UserImport::parseDate('2026-12-31') === '2026-12-31' && UserImport::parseDate('31.12.2026') === '2026-12-31' && UserImport::parseDate('12/31/2026') === null, 'date spellings: ISO, dotted, day-first only'); $check(UserImport::detectDelimiter(["a;b;c\n", "1;2;3\n"]) === ';' && UserImport::detectDelimiter(["a,b\n"]) === ',' && UserImport::detectDelimiter(["abc\n"]) === null, 'delimiter detection'); +// ── UserImport: file encoding ─────────────────────────────────────────── +$u = UserImport::toUtf8("\xEF\xBB\xBFNome;Cognome\nCaff\xC3\xA8;Rossi\n"); +$check($u['converted'] === false && $u['text'] === "Nome;Cognome\nCaff\xC3\xA8;Rossi\n", 'a UTF-8 file loses its BOM and is otherwise untouched'); +$u = UserImport::toUtf8("Nome;Cognome\nCaff\xE8;M\xFCller \x80\n"); +$check($u['converted'] === true && $u['text'] === "Nome;Cognome\nCaffè;Müller €\n", 'a Windows-1252 file (Excel on Windows) is converted: è, ü and the euro sign survive'); +$check(mb_check_encoding(UserImport::toUtf8("ok\xFF\xFE\xC0")['text'], 'UTF-8'), 'whatever the bytes, the result is valid UTF-8'); +$check(UserImport::toUtf8('plain ascii')['converted'] === false, 'ASCII is UTF-8 already: nothing to report'); + // ── Card settings ─────────────────────────────────────────────────────── $d = PatronCardSettings::sanitize([]); $check($d['layout'] === 'classic' && $d['code_type'] === 'code128' && $d['format'] === 'single', 'an empty form falls back to the default layout, code and format'); diff --git a/tests/user-import.spec.js b/tests/user-import.spec.js index 20c9f4109..bdfc1bb05 100644 --- a/tests/user-import.spec.js +++ b/tests/user-import.spec.js @@ -298,6 +298,36 @@ test.describe.serial('User import from CSV (phase 7)', () => { expect(serverFileExists(path.join(uploads, added[0])), 'cancelling the preview deletes the copy').toBe(false); }); + test('a CSV saved as Windows-1252 is converted on upload: the preview shows "Caffè" and the reader is created with that spelling', async () => { + test.setTimeout(120_000); + const file = path.join(os.tmpdir(), `utenti-1252-${RUN}.csv`); + const email = `caffe.${RUN}@${DOMAIN}`; + // "Caffè" as Excel on Windows writes it: è is the single byte 0xE8, not UTF-8. + fs.writeFileSync(file, Buffer.concat([ + Buffer.from('Nome;Cognome;E-mail\n'), + Buffer.from([0x43, 0x61, 0x66, 0x66, 0xe8]), + Buffer.from(`;Latino;${email}\n`), + ])); + try { + await page.goto(`${BASE}/admin/users/import`); + await page.locator('#user_import_file').setInputFiles(file); + await page.locator('#user-import-upload-btn').click(); + await expect(page.locator('#user-import-step-mapping')).toBeVisible(); + await expect(page.locator('#user-import-notice')).toBeVisible(); + await expect(page.locator('#user-import-notice')).toContainText('Windows-1252'); + await expect(page.locator('#user-import-preview tbody tr').first()).toContainText('Caffè'); + await page.locator('#user-import-start').click(); + await expect(page.locator('#user-import-summary')).toBeVisible({ timeout: 60_000 }); + await expect(page.locator('[data-import-count="created"]')).toHaveText('1'); + await expect(page.locator('[data-import-count="failed"]')).toHaveText('0'); + // UTF-8 bytes of "Caffè" in the database, whatever the mysql client's own charset. + expect(dbQuery(`SELECT HEX(nome) FROM utenti WHERE email = ${sqlStr(email)}`)).toBe('43616666C3A8'); + } finally { + dbQuery(`DELETE FROM import_logs WHERE file_name = ${sqlStr(path.basename(file))}`); + try { fs.unlinkSync(file); } catch { /* gone */ } + } + }); + test('a failure after the account is created is a warning on a created row, not a failed row', async () => { test.setTimeout(120_000); const trigger = `zz_ui_probe_${RUN.replace(/[^a-z0-9]/gi, '')}`; From 7717aafba74029f1075acc8948a86857866752c4 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:11:05 +0000 Subject: [PATCH 116/398] User import: transliterate accented headers without relying on iconv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeHeader() (and normalizeGender() through it) left every accented letter but ø æ å ß to iconv's ASCII//TRANSLIT, whose output depends on LC_CTYPE: under the "C" locale glibc gives "?" and musl drops the letter, so "Prénom" became "prnom" and "Männlich" was not recognised at all. The hand-written table now covers the accented letters the five languages' aliases use, lower and upper case, and iconv runs only when something non-ASCII is left. The unit suite sets LC_CTYPE to "C" around French and German headers, gender words and a whole French header row. --- CHANGELOG.md | 2 +- app/Support/UserImport.php | 39 ++++++++++++++++++++++++++---- tests/patron-cards-import.unit.php | 10 ++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 791fe4ff7..3679e3113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets. - **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card. - **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. -- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. +- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. ### Migration diff --git a/app/Support/UserImport.php b/app/Support/UserImport.php index d2a2142af..c65739c03 100644 --- a/app/Support/UserImport.php +++ b/app/Support/UserImport.php @@ -93,15 +93,44 @@ public static function targetFields(array $customDefinitions, bool $withPatronTy return $fields; } + /** + * Accented letters of the five languages' headers and gender words, to + * their ASCII base. iconv's ASCII//TRANSLIT depends on LC_CTYPE (under + * the "C" locale glibc gives "?", musl gives nothing) and would turn + * "Prénom" into "prnom"; this table is applied first, iconv only mops up. + */ + private const TRANSLIT = [ + 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ä' => 'a', 'ã' => 'a', 'å' => 'a', 'æ' => 'ae', + 'ç' => 'c', + 'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', + 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', + 'ñ' => 'n', + 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'ö' => 'o', 'õ' => 'o', 'ø' => 'o', 'œ' => 'oe', + 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', + 'ý' => 'y', 'ÿ' => 'y', + 'ß' => 'ss', + 'À' => 'a', 'Á' => 'a', 'Â' => 'a', 'Ä' => 'a', 'Ã' => 'a', 'Å' => 'a', 'Æ' => 'ae', + 'Ç' => 'c', + 'È' => 'e', 'É' => 'e', 'Ê' => 'e', 'Ë' => 'e', + 'Ì' => 'i', 'Í' => 'i', 'Î' => 'i', 'Ï' => 'i', + 'Ñ' => 'n', + 'Ò' => 'o', 'Ó' => 'o', 'Ô' => 'o', 'Ö' => 'o', 'Õ' => 'o', 'Ø' => 'o', 'Œ' => 'oe', + 'Ù' => 'u', 'Ú' => 'u', 'Û' => 'u', 'Ü' => 'u', + 'Ý' => 'y', 'Ÿ' => 'y', + ]; + /** Lowercase, accents removed, only letters and digits. */ public static function normalizeHeader(string $header): string { $header = preg_replace('/^\xEF\xBB\xBF/', '', $header) ?? $header; - $header = mb_strtolower(trim($header), 'UTF-8'); - $header = strtr($header, ['ø' => 'o', 'æ' => 'ae', 'å' => 'a', 'ß' => 'ss']); - $ascii = function_exists('iconv') ? @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $header) : false; - if (is_string($ascii) && $ascii !== '') { - $header = strtolower($ascii); + $header = strtr(mb_strtolower(trim($header), 'UTF-8'), self::TRANSLIT); + if (!preg_match('/^[\x00-\x7F]*$/', $header)) { + // Letters outside the table (a Polish ł, a Czech č): iconv's + // locale-dependent best effort, or nothing — never a wrong match. + $ascii = function_exists('iconv') ? @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $header) : false; + if (is_string($ascii) && $ascii !== '') { + $header = strtolower($ascii); + } } return (string) preg_replace('/[^a-z0-9]/', '', $header); } diff --git a/tests/patron-cards-import.unit.php b/tests/patron-cards-import.unit.php index ba54bd877..192525a83 100644 --- a/tests/patron-cards-import.unit.php +++ b/tests/patron-cards-import.unit.php @@ -29,6 +29,16 @@ $check(!in_array('password', array_keys($fields), true), 'there is no password field to map'); $check(UserImport::normalizeHeader("\xEF\xBB\xBFE-Mail ") === 'email', 'a BOM, case and punctuation are ignored in headers'); $check(UserImport::normalizeHeader('Fødselsdato') === 'fodselsdato', 'Danish letters are transliterated'); +// Under the "C" locale iconv's ASCII//TRANSLIT yields "?" (glibc) or nothing +// (musl) for an accented letter; the headers must not depend on it. +$savedLocale = setlocale(LC_CTYPE, '0'); +setlocale(LC_CTYPE, 'C'); +$check(UserImport::normalizeHeader('Prénom') === 'prenom' && UserImport::normalizeHeader('Téléphone') === 'telephone', 'French accents are removed whatever the locale'); +$check(UserImport::normalizeHeader('Gültig bis') === 'gultigbis' && UserImport::normalizeHeader('GEBURTSDATUM ') === 'geburtsdatum', 'German umlauts and upper case are normalised whatever the locale'); +$check(UserImport::normalizeHeader('Nº de carte') === 'ndecarte' && UserImport::normalizeHeader('Straße') === 'strasse' && UserImport::normalizeHeader('Cœur') === 'coeur', 'º falls through to the fallback, ß and œ expand'); +$check(UserImport::normalizeGender('Männlich') === 'M' && UserImport::normalizeGender('Féminin') === 'F' && UserImport::normalizeGender('Autre') === 'Altro', 'gender words with accents are recognised whatever the locale'); +$check(UserImport::guessMapping(['Prénom', 'Nom', 'Courriel', 'Téléphone', 'Date de naissance', 'Numéro de carte', "Date d'expiration"], $fields) === [0 => 'nome', 1 => 'cognome', 2 => 'email', 3 => 'telefono', 4 => 'data_nascita', 5 => 'codice_tessera', 6 => 'data_scadenza_tessera'], 'French headers are recognised under the "C" locale'); +setlocale(LC_CTYPE, $savedLocale === false ? 'C' : $savedLocale); $guess = UserImport::guessMapping(['Nome', 'Cognome', 'E-mail', 'Codice tessera', 'Password', 'Telegram', 'Nome'], $fields); $check($guess === [0 => 'nome', 1 => 'cognome', 2 => 'email', 3 => 'codice_tessera', 4 => '', 5 => 'custom:7', 6 => ''], 'Italian headers, a custom field and a repeated column map as expected; password is ignored'); From b301febd7640d6ab83d7a9d7beb5878016e9e514 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:11:13 +0000 Subject: [PATCH 117/398] Supplier form refuses an invalid e-mail or website normalize() dropped an e-mail that is not an address or a website that is not an http(s) URL to NULL, and the page then said "Fornitore creato con successo": the typo was lost without a word. The controller now checks both (SupplierRepository::invalidFields, the same rules normalize() applies) and redirects back with a message, as it already does for a blank name. normalize() keeps dropping invalid values as a safety net for callers that do not validate first. The publisher form does not carry typed values across a refused submission, so this form does not either; only the message is shown. --- CHANGELOG.md | 2 +- app/Controllers/SuppliersController.php | 38 ++++++++++++++++---- app/Models/SupplierRepository.php | 46 +++++++++++++++++++++---- locale/da_DK.json | 4 ++- locale/de_DE.json | 4 ++- locale/en_US.json | 4 ++- locale/fr_FR.json | 4 ++- locale/it_IT.json | 4 ++- tests/minor-features-090.unit.php | 3 ++ tests/suppliers-090.spec.js | 11 +++++- 10 files changed, 101 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4a84055..99c3ad55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Visitor log** (`/admin/attendance`, sidebar "Presenze"). A kiosk page records each visit: a member scans the library card (or the desk types the code), a guest is recorded with an optional name and reason, and an optional branch when the installation has more than one. The page shows today's visits, visits per day and per hour slot over 7/30/90/365 days, the latest visits with members' names shortened to "Name S.", and a CSV export of the daily counts. New table `presenze`. - **GDPR retention for the visitor log.** Visits are kept for 365 days by default; an administrator can change the number of days (0 keeps them forever) and apply it immediately. The scheduled maintenance (`cron/full-maintenance.php`) deletes older visits on every run. Deleting a member keeps the visit, anonymised. - **ISBD catalogue cards.** "Scheda catalografica" on the book page and "Schede catalografiche" on a selection in the book list produce a PDF of 125 × 75 mm cards, one per book: call number, main entry heading (principal author, surname first), ISBD description, standard number and subject tracings. Deleted books are never printed. -- **Suppliers** (`/admin/suppliers`, sidebar "Fornitori"). New table `fornitori` with name, contact, VAT number, website, address and notes; a supplier select in the book form ("Dettagli Acquisizione"), the supplier on the book page and a "Fornitore" filter in the book list. Deleting a supplier keeps its books and clears their supplier (`libri.fornitore_id`, ON DELETE SET NULL). +- **Suppliers** (`/admin/suppliers`, sidebar "Fornitori"). New table `fornitori` with name, contact, VAT number, website, address and notes; a supplier select in the book form ("Dettagli Acquisizione"), the supplier on the book page and a "Fornitore" filter in the book list. Deleting a supplier keeps its books and clears their supplier (`libri.fornitore_id`, ON DELETE SET NULL). An e-mail or website that is not one is refused by the supplier form with a message, never saved as empty. - **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME plugin uses an override when it is set and keeps its current output otherwise. - **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. The outcomes of the last closed stocktake (expected, found, misplaced, on loan, missing, unknown codes) appear once a session has been closed. Loans by patron type and fines have their place on the page and appear as soon as those features provide data (`App\Support\StatisticsSections`). diff --git a/app/Controllers/SuppliersController.php b/app/Controllers/SuppliersController.php index ab6c2ff5b..afb04c5df 100644 --- a/app/Controllers/SuppliersController.php +++ b/app/Controllers/SuppliersController.php @@ -26,9 +26,11 @@ public function createForm(Request $request, Response $response): Response public function store(Request $request, Response $response, mysqli $db): Response { - $data = SupplierRepository::normalize((array) $request->getParsedBody()); - if ($data['nome'] === null) { - $_SESSION['error_message'] = __('Il nome del fornitore è obbligatorio.'); + $input = (array) $request->getParsedBody(); + $data = SupplierRepository::normalize($input); + $error = $this->validationError($input, $data); + if ($error !== null) { + $_SESSION['error_message'] = $error; return $response->withHeader('Location', url('/admin/suppliers/create'))->withStatus(302); } try { @@ -56,9 +58,11 @@ public function update(Request $request, Response $response, mysqli $db, int $id if ($repo->getById($id) === null) { return $response->withStatus(404); } - $data = SupplierRepository::normalize((array) $request->getParsedBody()); - if ($data['nome'] === null) { - $_SESSION['error_message'] = __('Il nome del fornitore è obbligatorio.'); + $input = (array) $request->getParsedBody(); + $data = SupplierRepository::normalize($input); + $error = $this->validationError($input, $data); + if ($error !== null) { + $_SESSION['error_message'] = $error; return $response->withHeader('Location', url('/admin/suppliers/edit/' . $id))->withStatus(302); } try { @@ -86,6 +90,28 @@ public function delete(Request $request, Response $response, mysqli $db, int $id return $response->withHeader('Location', url('/admin/suppliers'))->withStatus(302); } + /** + * The message that refuses a submission, or NULL when it can be saved: + * the name is required, and an e-mail or website that is not one is + * refused rather than silently dropped by normalize(). + * + * @param array $input raw form data + * @param array $data the same data, normalized + */ + private function validationError(array $input, array $data): ?string + { + $messages = []; + if ($data['nome'] === null) { + $messages[] = __('Il nome del fornitore è obbligatorio.'); + } + foreach (SupplierRepository::invalidFields($input) as $field) { + $messages[] = $field === 'email' + ? __('L\'email del fornitore non è un indirizzo valido.') + : __('Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.'); + } + return $messages === [] ? null : implode(' ', $messages); + } + /** @param array $data */ private function render(Response $response, string $view, array $data): Response { diff --git a/app/Models/SupplierRepository.php b/app/Models/SupplierRepository.php index 91c4e3bb5..d719fdeaf 100644 --- a/app/Models/SupplierRepository.php +++ b/app/Models/SupplierRepository.php @@ -141,19 +141,53 @@ public static function normalize(array $input): array } $out[$field] = $value === '' ? null : $value; } - if ($out['email'] !== null && filter_var($out['email'], FILTER_VALIDATE_EMAIL) === false) { + if ($out['email'] !== null && !self::isValidEmail($out['email'])) { $out['email'] = null; } if ($out['sito_web'] !== null) { - $url = $out['sito_web']; - if (!preg_match('#^https?://#i', $url)) { - $url = 'https://' . ltrim($url, '/'); - } - $out['sito_web'] = filter_var($url, FILTER_VALIDATE_URL) !== false ? $url : null; + $out['sito_web'] = self::websiteUrl($out['sito_web']); } return $out; } + /** + * Fields of a submission that carry a value normalize() would have to + * drop: 'email' when it is not an e-mail address, 'sito_web' when it is + * not an http(s) URL once the scheme is added. The controller refuses the + * form on these instead of saving NULL with a success message. + * + * @param array $input + * @return list + */ + public static function invalidFields(array $input): array + { + $invalid = []; + $email = is_scalar($input['email'] ?? null) ? trim((string) $input['email']) : ''; + if ($email !== '' && !self::isValidEmail($email)) { + $invalid[] = 'email'; + } + $site = is_scalar($input['sito_web'] ?? null) ? trim((string) $input['sito_web']) : ''; + if ($site !== '' && self::websiteUrl($site) === null) { + $invalid[] = 'sito_web'; + } + return $invalid; + } + + private static function isValidEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; + } + + /** The website as an http(s) URL (a missing scheme becomes https), or NULL when it is not a URL. */ + private static function websiteUrl(string $raw): ?string + { + $url = $raw; + if (!preg_match('#^https?://#i', $url)) { + $url = 'https://' . ltrim($url, '/'); + } + return filter_var($url, FILTER_VALIDATE_URL) !== false ? $url : null; + } + /** * @param array $data * @return list diff --git a/locale/da_DK.json b/locale/da_DK.json index 1649626ca..4a7bde30b 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8677,5 +8677,7 @@ "Audiocassetta": "Lydkassette", "Videodisco": "Videodisk", "Disco per computer": "Computerdisk", - "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Senest afsluttede lageroptælling: »%s«, %s. Afsluttede lageroptællinger i alt: %d." + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Senest afsluttede lageroptælling: »%s«, %s. Afsluttede lageroptællinger i alt: %d.", + "L'email del fornitore non è un indirizzo valido.": "Leverandørens e-mail er ikke en gyldig adresse.", + "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "Leverandørens websted er ikke en gyldig adresse: angiv en http(s)-URL, for eksempel https://www.eksempel.dk." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 96d916c46..e9342ea6b 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8677,5 +8677,7 @@ "Audiocassetta": "Audiokassette", "Videodisco": "Videodisk", "Disco per computer": "Computerdisk", - "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Zuletzt abgeschlossene Bestandsrevision: „%s“, %s. Abgeschlossene Bestandsrevisionen insgesamt: %d." + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Zuletzt abgeschlossene Bestandsrevision: „%s“, %s. Abgeschlossene Bestandsrevisionen insgesamt: %d.", + "L'email del fornitore non è un indirizzo valido.": "Die E-Mail-Adresse des Lieferanten ist ungültig.", + "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "Die Website des Lieferanten ist keine gültige Adresse: geben Sie eine http(s)-URL an, zum Beispiel https://www.beispiel.de." } diff --git a/locale/en_US.json b/locale/en_US.json index bde650fce..53eac2ceb 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8677,5 +8677,7 @@ "Audiocassetta": "Audiocassette", "Videodisco": "Videodisc", "Disco per computer": "Computer disc", - "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Last closed stocktake: “%s”, %s. Closed stocktakes in total: %d." + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Last closed stocktake: “%s”, %s. Closed stocktakes in total: %d.", + "L'email del fornitore non è un indirizzo valido.": "The supplier e-mail is not a valid address.", + "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "The supplier website is not a valid address: enter an http(s) URL, for example https://www.example.org." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 3a25d2f3c..4eff13bfd 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8677,5 +8677,7 @@ "Audiocassetta": "Cassette audio", "Videodisco": "Vidéodisque", "Disco per computer": "Disque informatique", - "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Dernier récolement clôturé : « %s », %s. Récolements clôturés au total : %d." + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Dernier récolement clôturé : « %s », %s. Récolements clôturés au total : %d.", + "L'email del fornitore non è un indirizzo valido.": "L'e-mail du fournisseur n'est pas une adresse valide.", + "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "Le site web du fournisseur n'est pas une adresse valide : indiquez une URL http(s), par exemple https://www.exemple.fr." } diff --git a/locale/it_IT.json b/locale/it_IT.json index f2f96f45a..7388b0ed4 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8677,5 +8677,7 @@ "Audiocassetta": "Audiocassetta", "Videodisco": "Videodisco", "Disco per computer": "Disco per computer", - "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d." + "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.": "Ultima revisione chiusa: «%s», %s. Revisioni chiuse in totale: %d.", + "L'email del fornitore non è un indirizzo valido.": "L'email del fornitore non è un indirizzo valido.", + "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it." } diff --git a/tests/minor-features-090.unit.php b/tests/minor-features-090.unit.php index 9026693b7..da9467b58 100644 --- a/tests/minor-features-090.unit.php +++ b/tests/minor-features-090.unit.php @@ -77,6 +77,9 @@ function MediaLabelsKeys(): array { return \App\Support\MediaLabels::allTypes(); checkMinor($s['email'] === null && $s['sito_web'] === 'https://www.rossi.it', 'invalid email dropped, scheme-less website gets https'); checkMinor(SupplierRepository::normalize(['sito_web' => 'javascript:alert(1)'])['sito_web'] === null, 'a non-http website is rejected'); checkMinor(array_keys($s) === SupplierRepository::FIELDS, 'normalize() returns exactly the writable fields'); +checkMinor(SupplierRepository::invalidFields(['email' => 'not-an-email', 'sito_web' => 'not a url']) === ['email', 'sito_web'], 'the form is refused on an e-mail or website that is not one'); +checkMinor(SupplierRepository::invalidFields(['email' => ' ordini@example.org ', 'sito_web' => 'www.rossi.it']) === [] && SupplierRepository::invalidFields(['email' => '', 'sito_web' => null]) === [], 'a valid or blank e-mail and website pass'); +checkMinor(SupplierRepository::invalidFields(['sito_web' => 'javascript:alert(1)']) === ['sito_web'], 'a non-http website is refused, not silently dropped'); // ── Visitor log helpers ── checkMinor(AttendanceRepository::shortName('Mario', 'rossi') === 'Mario R.' && AttendanceRepository::shortName('Cher', '') === 'Cher', 'kiosk names are shortened to the initial'); diff --git a/tests/suppliers-090.spec.js b/tests/suppliers-090.spec.js index cb87cbeb6..e60f91d06 100644 --- a/tests/suppliers-090.spec.js +++ b/tests/suppliers-090.spec.js @@ -57,7 +57,7 @@ test.describe.serial('Suppliers (fornitori)', () => { expect(otherSupplierId).toBeGreaterThan(0); }); - test('2. A blank name is refused and the supplier can be edited', async () => { + test('2. A blank name or an invalid website is refused and the supplier can be edited', async () => { await page.goto(`${h.BASE}/admin/suppliers/edit/${supplierId}`); await expect(page.locator('#nome')).toHaveValue(SUPPLIER); // Spaces pass the browser "required" check; the server must still refuse. @@ -66,6 +66,15 @@ test.describe.serial('Suppliers (fornitori)', () => { await expect(page.getByText('Il nome del fornitore è obbligatorio.')).toBeVisible(); expect(h.dbQuery(`SELECT nome FROM fornitori WHERE id = ${supplierId}`)).toBe(SUPPLIER); + // A website that is not a URL is refused with a message, nothing is saved + // (it used to be silently stored as NULL under a success message). + await page.fill('#nome', SUPPLIER); + await page.fill('#referente', 'Non salvato'); + await page.fill('#sito_web', 'not a url'); + await Promise.all([page.waitForURL(new RegExp(`/admin/suppliers/edit/${supplierId}$`)), page.click('#form-fornitore button[type="submit"]')]); + await expect(page.getByText('Il sito web del fornitore non è un indirizzo valido')).toBeVisible(); + expect(h.dbQuery(`SELECT CONCAT(IFNULL(referente, 'NULL'), '|', IFNULL(sito_web, 'NULL')) FROM fornitori WHERE id = ${supplierId}`)).toBe('NULL|https://www.example.org'); + await page.fill('#nome', SUPPLIER_RENAMED); await page.fill('#referente', 'Giulia Bianchi'); await Promise.all([page.waitForURL(/\/admin\/suppliers$/), page.click('#form-fornitore button[type="submit"]')]); From 9294094f8dfefb29a221f7c75f7579150493b991 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:11:37 +0000 Subject: [PATCH 118/398] Say what BIBFRAME emits for non-book records The plugin emits RdaTypes::effective() for every record, so a DVD, disc or audiobook changes its content/media/carrier URIs on upgrade even without an override; the E2E spec asserts exactly that. The CHANGELOG bullet ("keeps its current output otherwise"), the unit-test label ("BIBFRAME keeps its historical output"), the spec header and the plugin comment said otherwise. They now describe the behaviour: books keep txt/n/nc, the other media types emit their real RDA types, an override wins. The unit test also checks the derived triple a DVD gets. --- CHANGELOG.md | 2 +- .../bibframe-linked-data/BibframeLinkedDataPlugin.php | 6 ++++-- tests/minor-features-090.unit.php | 3 ++- tests/rda-types-090.spec.js | 5 +++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c3ad55a..4dd0b1a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **GDPR retention for the visitor log.** Visits are kept for 365 days by default; an administrator can change the number of days (0 keeps them forever) and apply it immediately. The scheduled maintenance (`cron/full-maintenance.php`) deletes older visits on every run. Deleting a member keeps the visit, anonymised. - **ISBD catalogue cards.** "Scheda catalografica" on the book page and "Schede catalografiche" on a selection in the book list produce a PDF of 125 × 75 mm cards, one per book: call number, main entry heading (principal author, surname first), ISBD description, standard number and subject tracings. Deleted books are never printed. - **Suppliers** (`/admin/suppliers`, sidebar "Fornitori"). New table `fornitori` with name, contact, VAT number, website, address and notes; a supplier select in the book form ("Dettagli Acquisizione"), the supplier on the book page and a "Fornitore" filter in the book list. Deleting a supplier keeps its books and clears their supplier (`libri.fornitore_id`, ON DELETE SET NULL). An e-mail or website that is not one is refused by the supplier form with a message, never saved as empty. -- **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME plugin uses an override when it is set and keeps its current output otherwise. +- **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME output carries the same effective types: books keep txt/n/nc as before; DVD, disc and audiobook records now emit their real RDA types (tdi/v/vd, prm/s/sd, spw/s/sd) instead of a book's; an override wins over the derived value. - **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. The outcomes of the last closed stocktake (expected, found, misplaced, on loan, missing, unknown codes) appear once a session has been closed. Loans by patron type and fines have their place on the page and appear as soon as those features provide data (`App\Support\StatisticsSections`). ### Migration diff --git a/storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php b/storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php index f1836c321..1f907a632 100644 --- a/storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php +++ b/storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php @@ -446,8 +446,10 @@ private function buildGraph(array $book): array } // RDA content/media/carrier: the same effective types the book page - // shows (libri.tipo_media, then the cataloguer's per-record override, - // 0.9.0). A book keeps the historical text / unmediated / volume. + // shows (derived from libri.tipo_media, the cataloguer's per-record + // override when set; 0.9.0). A book keeps the historical text / + // unmediated / volume; a DVD, disc or audiobook now carries its own + // types instead of a book's. $rdaEffective = \App\Support\RdaTypes::effective($book); $work['bf:content'] = [ '@type' => 'bf:Content', diff --git a/tests/minor-features-090.unit.php b/tests/minor-features-090.unit.php index da9467b58..8581a4c6d 100644 --- a/tests/minor-features-090.unit.php +++ b/tests/minor-features-090.unit.php @@ -29,7 +29,8 @@ function checkMinor(bool $ok, string $label): void { global $count; if (!$ok) { $book = ['tipo_media' => 'libro', 'rda_carrier_type' => 'cr', 'rda_media_type' => 'c', 'rda_content_type' => null]; checkMinor(RdaTypes::overrides($book) === ['media' => 'c', 'carrier' => 'cr'], 'overrides() lists only the stored overrides'); checkMinor(RdaTypes::effective($book) === ['content' => 'txt', 'media' => 'c', 'carrier' => 'cr'], 'effective() mixes overrides over the derived triple'); -checkMinor(RdaTypes::overrides(['tipo_media' => 'dvd']) === [], 'a record without overrides has none (BIBFRAME keeps its historical output)'); +checkMinor(RdaTypes::overrides(['tipo_media' => 'dvd']) === [], 'a record without overrides has none'); +checkMinor(RdaTypes::effective(['tipo_media' => 'dvd']) === ['content' => 'tdi', 'media' => 'v', 'carrier' => 'vd'] && RdaTypes::effective(['tipo_media' => 'libro']) === ['content' => 'txt', 'media' => 'n', 'carrier' => 'nc'], 'without an override BIBFRAME emits the derived triple: a book keeps txt/n/nc, a DVD gets its real types'); checkMinor(RdaTypes::uri('carrier', 'cr') === 'http://id.loc.gov/vocabulary/carriers/cr' && RdaTypes::uri('content', 'txt') === 'http://id.loc.gov/vocabulary/contentTypes/txt', 'codes map to id.loc.gov URIs'); foreach (RdaTypes::kinds() as $kind) { foreach (array_keys(MediaLabelsKeys()) as $media) { diff --git a/tests/rda-types-090.spec.js b/tests/rda-types-090.spec.js index 981aae6bc..3b3b6978e 100644 --- a/tests/rda-types-090.spec.js +++ b/tests/rda-types-090.spec.js @@ -1,7 +1,8 @@ // @ts-check // RDA content / media / carrier in cataloguing (0.9.0): derived from the -// media type, optional per-record override in the book form, override used by -// the BIBFRAME output (historical output kept when there is no override). +// media type, optional per-record override in the book form, and the BIBFRAME +// output carrying the effective triple (derived types for every record, the +// override when one is set: a book keeps txt/n/nc, an audiobook gets spw/s/sd). const { test, expect } = require('@playwright/test'); const h = require('./helpers/minor-features-090'); From 41a3fd0f863841d6f845c6f5ce3be6b3839b5775 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:11:38 +0000 Subject: [PATCH 119/398] Users: erase the patron photo when a deleted account is only suspended MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An account with a loan history is not deleted but marked "sospeso" with an "[ELIMINATO IL …]" note. That branch left the photo file and foto_profilo_url in place, while the hard DELETE removed the file: the photo is personal data and the librarian asked for the account to go. The soft-delete branch now calls PatronPhoto::delete(), which clears the column and unlinks the file; its failures are logged and never thrown, so a stuck file cannot abort the request. The photo E2E gives the reader a photo and one returned loan, deletes the account through the real endpoint and checks: stato "sospeso", the loan kept, the reference and the file gone, the photo route answering 404. --- CHANGELOG.md | 2 +- app/Controllers/UsersController.php | 6 ++++++ tests/patron-photo.spec.js | 31 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3679e3113..3648e0fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre ### Library cards, patron photo and user import - **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets. - **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card. -- **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. +- **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo, also when the account has a loan history and is therefore only suspended. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. - **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. ### Migration diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index 5e44ae548..daa36468e 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -678,6 +678,12 @@ public function delete(Request $request, Response $response, mysqli $db, int $id $updateStmt->execute(); $updateStmt->close(); + // The photo is personal data and goes with the account even when + // the record itself is kept for the loan history. delete() clears + // the reference and unlinks the file; its failures are logged, never + // thrown, so the request completes either way. + \App\Support\PatronPhoto::delete($db, $id); + // Audit logging \App\Support\SecureLogger::info('User marked as deleted (has loan history)', [ 'user_id' => $id, diff --git a/tests/patron-photo.spec.js b/tests/patron-photo.spec.js index 2a328aa67..2418d8947 100644 --- a/tests/patron-photo.spec.js +++ b/tests/patron-photo.spec.js @@ -80,6 +80,7 @@ function writePng(file, w = 64, h = 80) { test.describe.serial('Patron photo (phase 7)', () => { let userId = 0; + let bookId = 0; const code = `ZPH${RUN}`; const files = []; @@ -92,7 +93,9 @@ test.describe.serial('Patron photo (phase 7)', () => { test.afterAll(() => { const stored = userId ? storedPhoto(userId) : ''; if (stored) files.push(photoFile(stored)); + if (userId) dbQuery(`DELETE FROM prestiti WHERE utente_id = ${userId}`); if (userId) dbQuery(`DELETE FROM utenti WHERE id = ${userId}`); + if (bookId) dbQuery(`DELETE FROM libri WHERE id = ${bookId}`); for (const f of files) removeServerFile(f); }); @@ -230,4 +233,32 @@ test.describe.serial('Patron photo (phase 7)', () => { await page.waitForURL((u) => !u.toString().includes('/edit/'), { timeout: 15000 }); expect(storedPhoto(userId)).toBe(''); }); + + test('deleting a reader with loan history keeps the account as suspended but still erases the photo', async ({ page }) => { + await login(page); + await page.goto(`${BASE}/admin/users/edit/${userId}`); + const png = path.join(os.tmpdir(), `zph-loan-${RUN}.png`); + writePng(png); + await page.locator('#photo-file').setInputFiles(png); + await expect(page.locator('#photo-status')).toHaveText('Foto salvata.', { timeout: 15000 }); + fs.unlinkSync(png); + const stored = storedPhoto(userId); + expect(stored).not.toBe(''); + files.push(photoFile(stored)); + // A returned loan: the account cannot be deleted, only marked "sospeso". + const title = `Photo loan ${RUN}`; + dbQuery(`INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES (${sqlStr(title)}, 1, 1)`); + bookId = Number(dbQuery(`SELECT id FROM libri WHERE titolo = ${sqlStr(title)} ORDER BY id DESC LIMIT 1`)); + dbQuery(`INSERT INTO prestiti (utente_id, libro_id, data_prestito, data_scadenza, stato, attivo) VALUES (${userId}, ${bookId}, CURDATE(), CURDATE(), 'restituito', 0)`); + + const csrf = await page.locator('meta[name="csrf-token"]').getAttribute('content'); + const res = await page.request.post(`${BASE}/admin/users/delete/${userId}`, { form: { csrf_token: String(csrf) }, maxRedirects: 0 }); + expect(res.status()).toBe(302); + expect(dbQuery(`SELECT stato FROM utenti WHERE id = ${userId}`)).toBe('sospeso'); + expect(dbQuery(`SELECT COUNT(*) FROM prestiti WHERE utente_id = ${userId}`)).toBe('1'); + expect(storedPhoto(userId)).toBe(''); + expect(serverFileExists(photoFile(stored))).toBe(false); + const gone = await page.request.get(`${BASE}/admin/users/${userId}/photo`); + expect(gone.status()).toBe(404); + }); }); From 0befb617bbdb66ea730a78dd800586f2282097a5 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:11:38 +0000 Subject: [PATCH 120/398] Stocktake: copies held for pickup or damaged are never marked lost A copy set aside on the pickup shelf for a reader (copie.stato 'prenotato', or an active loan row 'prenotato'/'da_ritirare') is not on its shelf, yet the session counted it as missing and a close with "mark as lost" set it to 'perso' and moved the reader's hold to another copy. Only 'in_corso'/'in_ritardo' loans and the 'prestato' state counted as on loan. Such a copy now belongs to the on-loan bucket, in the one SQL predicate shared by summary(), unscannedExpected() and reportRows() and in isOnLoan(), so found + misplaced + on loan + missing still equals the expected copies and mark-lost skips it. The bucket label becomes "In prestito o in attesa di ritiro" (five locales; the old "In prestito" key stays, other pages use it) and the scan feedback says the same. 'danneggiato' joins AWAY_STATES: every other place treats a damaged copy as out of circulation, and a close must not overwrite it with 'perso'. The DB-backed snapshot test adds a 'prenotato' copy with a 'da_ritirare' hold (counted on loan, hold untouched after the close) and a copy that gets damaged during the session (reported missing, not marked lost). --- CHANGELOG.md | 6 +-- app/Controllers/InventoryController.php | 2 +- app/Services/StocktakeService.php | 52 +++++++++++++++++++------ app/Views/inventario/show.php | 2 +- locale/da_DK.json | 5 ++- locale/de_DE.json | 5 ++- locale/en_US.json | 5 ++- locale/fr_FR.json | 5 ++- locale/it_IT.json | 5 ++- tests/inventory-stocktake.spec.js | 2 +- tests/stocktake-snapshot.unit.php | 43 +++++++++++++------- 11 files changed, 91 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba36d4597..0776a5dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,9 @@ Full version-by-version history for Pinakes. The README shows only the last thre ### Inventory stocktake - **Stocktake sessions** (Admin → Revisione inventariale, `/admin/inventory`). A session is opened with a name, an optional branch and a filter (bookcase, shelf level, genre with its subgenres, media type, collocation range). Opening it freezes the list of expected copies in `inventario_attese`, books removed from the catalogue excluded, so moving a book, adding or deleting a copy while the stocktake runs does not change what the session looks for. -- **Scanning** works with keyboard-wedge barcode readers (the code field keeps the focus, Enter records), with the self-hosted camera scanner already used at the loan desk, and with an uploaded `.txt` (one code per line) or `.csv` (first column) list. Each read is classified as found, misplaced (found on a bookcase other than the expected one, as declared by "Scaffale in cui stai lavorando"), on loan, unknown code, out of scope or repeated, with its own colour and sound. Several operators can scan the same session at once: the counters refresh every few seconds and "Solo le mie scansioni" narrows the list to one's own reads. -- **Live report**: expected copies always add up as found + misplaced + on loan + missing; unknown, out-of-scope and repeated reads are counted on the side. PDF and CSV reports are available while the session is open and after it ends. -- **Closing** either leaves the missing copies as they are or marks them `perso`. Marking them lost is an administrator action, checked on the server: Staff and custom roles can scan and close a session, but not declare copies lost. Copies on loan, being restored, in transit, in maintenance or already lost are never touched; holds on a copy declared lost move to another copy as with a manual change of state; book availability is recalculated through `DataIntegrity`, and each copy gets a "Copia dichiarata persa in revisione inventariale" entry in the book's activity log. A session can also be cancelled without changing any copy. +- **Scanning** works with keyboard-wedge barcode readers (the code field keeps the focus, Enter records), with the self-hosted camera scanner already used at the loan desk, and with an uploaded `.txt` (one code per line) or `.csv` (first column) list. Each read is classified as found, misplaced (found on a bookcase other than the expected one, as declared by "Scaffale in cui stai lavorando"), on loan or awaiting pickup, unknown code, out of scope or repeated, with its own colour and sound. Several operators can scan the same session at once: the counters refresh every few seconds and "Solo le mie scansioni" narrows the list to one's own reads. +- **Live report**: expected copies always add up as found + misplaced + on loan (lent, or set aside on the pickup shelf for a reader) + missing; unknown, out-of-scope and repeated reads are counted on the side. PDF and CSV reports are available while the session is open and after it ends. +- **Closing** either leaves the missing copies as they are or marks them `perso`. Marking them lost is an administrator action, checked on the server: Staff and custom roles can scan and close a session, but not declare copies lost. Copies on loan or waiting to be picked up (by their state or by an active loan row), damaged, being restored, in transit, in maintenance or already lost are never touched; holds on a copy declared lost move to another copy as with a manual change of state; book availability is recalculated through `DataIntegrity`, and each copy gets a "Copia dichiarata persa in revisione inventariale" entry in the book's activity log. A session can also be cancelled without changing any copy. - **Permission** `inventory.manage` for custom roles, and a new starting profile "Revisore inventario" (backend access + stocktakes). Staff and administrators have it already. ### Migration diff --git a/app/Controllers/InventoryController.php b/app/Controllers/InventoryController.php index f33040922..413c8a720 100644 --- a/app/Controllers/InventoryController.php +++ b/app/Controllers/InventoryController.php @@ -62,7 +62,7 @@ public static function outcomeLabels(): array return [ 'trovata' => __('Trovata'), 'fuori_posto' => __('Fuori posto'), - 'in_prestito' => __('In prestito'), + 'in_prestito' => __('In prestito o in attesa di ritiro'), 'mancante' => __('Mancante'), 'sconosciuta' => __('Codice sconosciuto'), 'fuori_ambito' => __('Fuori ambito'), diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index 443bb38be..33c83381f 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -18,8 +18,9 @@ * stocktake runs cannot change what the session was looking for. * * The expected copies always end in exactly one bucket of the report: - * found, misplaced, on loan or missing. Unknown codes, copies outside the - * filter and repeated scans are counted on the side. + * found, misplaced, on loan (lent, or waiting on the pickup shelf) or missing. + * Unknown codes, copies outside the filter and repeated scans are counted on + * the side. */ final class StocktakeService { @@ -31,9 +32,19 @@ final class StocktakeService /** * Copy states that already explain why a copy is not on the shelf. A missing * copy in one of these states is reported, but closing with "mark as lost" - * leaves it alone: it is lent, at the restorer's, in transit, or already lost. + * leaves it alone: it is lent or waiting on the pickup shelf, damaged, at + * the restorer's, in transit, in maintenance, or already lost. */ - private const AWAY_STATES = ['prestato', 'perso', 'in_restauro', 'in_trasferimento', 'manutenzione']; + private const AWAY_STATES = ['prestato', 'prenotato', 'perso', 'danneggiato', 'in_restauro', 'in_trasferimento', 'manutenzione']; + + /** + * The "on loan" bucket of the report: a copy lent to a reader or set aside + * on the pickup shelf for one, by its own state or by an active loan row. + * Such a copy is where the loan desk put it, so it is never declared lost + * (a copy marked lost would also have its hold moved to another copy). + */ + private const ON_LOAN_COPY_STATES = ['prestato', 'prenotato']; + private const ON_LOAN_LOAN_STATES = ['in_corso', 'in_ritardo', 'prenotato', 'da_ritirare']; public function __construct(private mysqli $db) { @@ -45,7 +56,7 @@ public function __construct(private mysqli $db) * @param bool $copyExists the code matches a copy of a book still in the catalogue * @param bool $alreadyScanned this session already recorded a scan of that copy * @param bool $expected the copy is in the session's opening snapshot - * @param bool $onLoan the copy is out on an active loan right now + * @param bool $onLoan the copy is out on an active loan or waiting on the pickup shelf right now * @param int|null $expectedShelf shelf recorded in the snapshot (null: the book had none) * @param int|null $scanShelf shelf the operator says they are working on (null: not given) */ @@ -435,7 +446,7 @@ private function record(int $sessionId, string $code, ?int $operatorId, ?int $sc $alreadyScanned = $stmt->get_result()->fetch_row() !== null; $stmt->close(); - $onLoan = ($copy['stato'] ?? '') === 'prestato' || $this->isOnLoan($copyId); + $onLoan = in_array((string) ($copy['stato'] ?? ''), self::ON_LOAN_COPY_STATES, true) || $this->isOnLoan($copyId); } $expectedShelf = is_array($expected) && $expected['scaffale_atteso'] !== null ? (int) $expected['scaffale_atteso'] : null; @@ -530,9 +541,26 @@ private function insertScan(int $sessionId, ?int $copyId, string $code, string $ } } + /** @param list $values */ + private static function sqlList(array $values): string + { + return "'" . implode("','", $values) . "'"; + } + + /** + * SQL predicate: the expected copy `a` (joined as `c`) is in the "on loan" + * bucket. NULL when the copy no longer exists. + */ + private static function onLoanSql(): string + { + return '(c.stato IN (' . self::sqlList(self::ON_LOAN_COPY_STATES) . ') OR EXISTS (' + . 'SELECT 1 FROM prestiti p WHERE p.copia_id = a.copia_id AND p.attivo = 1 AND p.stato IN (' . self::sqlList(self::ON_LOAN_LOAN_STATES) . ')))'; + } + + /** Lent, or set aside on the pickup shelf: see ON_LOAN_LOAN_STATES. */ private function isOnLoan(int $copyId): bool { - $stmt = $this->db->prepare("SELECT 1 FROM prestiti WHERE copia_id = ? AND attivo = 1 AND stato IN ('in_corso','in_ritardo') LIMIT 1"); + $stmt = $this->db->prepare('SELECT 1 FROM prestiti WHERE copia_id = ? AND attivo = 1 AND stato IN (' . self::sqlList(self::ON_LOAN_LOAN_STATES) . ') LIMIT 1'); if ($stmt === false) { return false; } @@ -593,7 +621,7 @@ public function summary(int $sessionId, ?int $operatorId = null): array // (c.stato NULL) is not on loan and stays among the missing ones. $stmt = $this->db->prepare( 'SELECT COUNT(*) AS n, COALESCE(SUM(t.in_prestito), 0) AS lent FROM (' - . "SELECT COALESCE(c.stato = 'prestato' OR EXISTS (SELECT 1 FROM prestiti p WHERE p.copia_id = a.copia_id AND p.attivo = 1 AND p.stato IN ('in_corso','in_ritardo')), 0) AS in_prestito " + . 'SELECT COALESCE(' . self::onLoanSql() . ', 0) AS in_prestito ' . 'FROM inventario_attese a ' . 'LEFT JOIN inventario_scansioni s ON s.sessione_id = a.sessione_id AND s.copia_id = a.copia_id ' . 'LEFT JOIN copie c ON c.id = a.copia_id ' @@ -633,7 +661,7 @@ private function unscannedExpected(int $sessionId): array $stmt = $this->db->prepare( 'SELECT a.copia_id, a.scaffale_atteso, a.stato_all_apertura, c.numero_inventario, c.stato AS stato_attuale, c.libro_id, ' . 'l.titolo, l.collocazione, ' - . "(c.stato = 'prestato' OR EXISTS (SELECT 1 FROM prestiti p WHERE p.copia_id = a.copia_id AND p.attivo = 1 AND p.stato IN ('in_corso','in_ritardo'))) AS in_prestito " + . self::onLoanSql() . ' AS in_prestito ' . 'FROM inventario_attese a ' . 'LEFT JOIN inventario_scansioni s ON s.sessione_id = a.sessione_id AND s.copia_id = a.copia_id ' . 'LEFT JOIN copie c ON c.id = a.copia_id ' @@ -725,8 +753,8 @@ public function scans(int $sessionId, ?int $operatorId = null, int $limit = 200) /** * Close the session. With $markLost every missing copy that is not already - * explained by its state (lent, lost, at the restorer's, in transit, in - * maintenance) becomes 'perso', one book per transaction, with the same + * explained by its state (lent or waiting for pickup, damaged, lost, at the + * restorer's, in transit, in maintenance) becomes 'perso', one book per transaction, with the same * hold reassignment and availability recalculation as a manual change of * a copy's state, and an ActivityLog entry per copy. * @@ -745,7 +773,7 @@ public function close(int $sessionId, bool $markLost, ?int $operatorId): array $byBook = []; foreach ($this->unscannedExpected($sessionId) as $row) { if ((int) $row['in_prestito'] === 1 || $row['libro_id'] === null || $row['titolo'] === null) { - continue; // lent, or the copy/book left the catalogue meanwhile + continue; // lent or held for pickup, or the copy/book left the catalogue meanwhile } if (in_array((string) $row['stato_attuale'], self::AWAY_STATES, true)) { continue; diff --git a/app/Views/inventario/show.php b/app/Views/inventario/show.php index 92639fea7..ad9d2ae25 100644 --- a/app/Views/inventario/show.php +++ b/app/Views/inventario/show.php @@ -380,7 +380,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te 'unknown' => __('Il codice non corrisponde a nessuna copia a catalogo.'), 'duplicate' => __('Questa copia è già stata registrata in questa revisione.'), 'outOfScope' => __('La copia esiste ma non rientra nel filtro della revisione.'), - 'onLoan' => __('La copia risulta in prestito: verifica il prestito.'), + 'onLoan' => __('La copia risulta in prestito o in attesa di ritiro: verifica il prestito.'), 'closed' => __('La revisione è stata chiusa da un altro operatore.'), ], ], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE) ?>; diff --git a/locale/da_DK.json b/locale/da_DK.json index 628987012..7e3f7a439 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8489,7 +8489,6 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "Koden svarer ikke til noget eksemplar i kataloget.", "Questa copia è già stata registrata in questa revisione.": "Dette eksemplar er allerede registreret i denne optælling.", "La copia esiste ma non rientra nel filtro della revisione.": "Eksemplaret findes, men er uden for optællingens filter.", - "La copia risulta in prestito: verifica il prestito.": "Eksemplaret står som udlånt: tjek udlånet.", "La revisione è stata chiusa da un altro operatore.": "Optællingen er lukket af en anden medarbejder.", "Revisore inventario": "Optællingsmedarbejder", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Startprofil til at deltage i lageroptællinger: åbne en session og scanne eksemplarer uden at ændre kataloget eller erklære eksemplarer tabt.", @@ -8497,5 +8496,7 @@ "Copia dichiarata persa in revisione inventariale": "Eksemplar erklæret tabt ved optælling", "Revisione inventariale aperta": "Optælling åbnet", "Revisione inventariale chiusa": "Optælling lukket", - "Revisione inventariale annullata": "Optælling annulleret" + "Revisione inventariale annullata": "Optælling annulleret", + "In prestito o in attesa di ritiro": "Udlånt eller klar til afhentning", + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Eksemplaret står som udlånt eller klar til afhentning: tjek udlånet." } diff --git a/locale/de_DE.json b/locale/de_DE.json index b45afda31..479204042 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8489,7 +8489,6 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "Der Code entspricht keinem Exemplar im Katalog.", "Questa copia è già stata registrata in questa revisione.": "Dieses Exemplar wurde in dieser Revision bereits erfasst.", "La copia esiste ma non rientra nel filtro della revisione.": "Das Exemplar existiert, liegt aber außerhalb des Filters der Revision.", - "La copia risulta in prestito: verifica il prestito.": "Das Exemplar ist als ausgeliehen erfasst: Prüfe die Ausleihe.", "La revisione è stata chiusa da un altro operatore.": "Die Revision wurde von einem anderen Mitarbeiter geschlossen.", "Revisore inventario": "Bestandsprüfer", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Ausgangsprofil für die Teilnahme an Bestandsrevisionen: eine Sitzung eröffnen und Exemplare scannen, ohne den Katalog zu ändern oder Exemplare als verloren zu erklären.", @@ -8497,5 +8496,7 @@ "Copia dichiarata persa in revisione inventariale": "Exemplar bei einer Bestandsrevision als verloren erklärt", "Revisione inventariale aperta": "Bestandsrevision eröffnet", "Revisione inventariale chiusa": "Bestandsrevision geschlossen", - "Revisione inventariale annullata": "Bestandsrevision abgebrochen" + "Revisione inventariale annullata": "Bestandsrevision abgebrochen", + "In prestito o in attesa di ritiro": "Ausgeliehen oder zur Abholung bereit", + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Das Exemplar ist als ausgeliehen oder zur Abholung bereit erfasst: Prüfe die Ausleihe." } diff --git a/locale/en_US.json b/locale/en_US.json index cb1d1c550..a156a7bff 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8489,7 +8489,6 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "The code does not match any copy in the catalogue.", "Questa copia è già stata registrata in questa revisione.": "This copy was already recorded in this stocktake.", "La copia esiste ma non rientra nel filtro della revisione.": "The copy exists but is outside the stocktake's filter.", - "La copia risulta in prestito: verifica il prestito.": "The copy is recorded as on loan: check the loan.", "La revisione è stata chiusa da un altro operatore.": "The stocktake was closed by another operator.", "Revisore inventario": "Stocktake reviewer", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Starting profile for taking part in stocktakes: open a session and scan copies, without changing the catalogue or declaring copies lost.", @@ -8497,5 +8496,7 @@ "Copia dichiarata persa in revisione inventariale": "Copy declared lost in a stocktake", "Revisione inventariale aperta": "Stocktake opened", "Revisione inventariale chiusa": "Stocktake closed", - "Revisione inventariale annullata": "Stocktake cancelled" + "Revisione inventariale annullata": "Stocktake cancelled", + "In prestito o in attesa di ritiro": "On loan or awaiting pickup", + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "The copy is recorded as on loan or awaiting pickup: check the loan." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 475fb1558..29fd227af 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8489,7 +8489,6 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "Le code ne correspond à aucun exemplaire du catalogue.", "Questa copia è già stata registrata in questa revisione.": "Cet exemplaire a déjà été enregistré dans ce récolement.", "La copia esiste ma non rientra nel filtro della revisione.": "L'exemplaire existe mais n'entre pas dans le filtre du récolement.", - "La copia risulta in prestito: verifica il prestito.": "L'exemplaire est enregistré comme prêté : vérifiez le prêt.", "La revisione è stata chiusa da un altro operatore.": "Le récolement a été clôturé par un autre opérateur.", "Revisore inventario": "Agent de récolement", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Profil de départ pour participer aux récolements : ouvrir une session et scanner les exemplaires, sans modifier le catalogue ni déclarer des exemplaires perdus.", @@ -8497,5 +8496,7 @@ "Copia dichiarata persa in revisione inventariale": "Exemplaire déclaré perdu lors d'un récolement", "Revisione inventariale aperta": "Récolement ouvert", "Revisione inventariale chiusa": "Récolement clôturé", - "Revisione inventariale annullata": "Récolement annulé" + "Revisione inventariale annullata": "Récolement annulé", + "In prestito o in attesa di ritiro": "Emprunté ou en attente de retrait", + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "L'exemplaire est enregistré comme prêté ou en attente de retrait : vérifiez le prêt." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 4dee0d323..8cee395e2 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8489,7 +8489,6 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "Il codice non corrisponde a nessuna copia a catalogo.", "Questa copia è già stata registrata in questa revisione.": "Questa copia è già stata registrata in questa revisione.", "La copia esiste ma non rientra nel filtro della revisione.": "La copia esiste ma non rientra nel filtro della revisione.", - "La copia risulta in prestito: verifica il prestito.": "La copia risulta in prestito: verifica il prestito.", "La revisione è stata chiusa da un altro operatore.": "La revisione è stata chiusa da un altro operatore.", "Revisore inventario": "Revisore inventario", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.", @@ -8497,5 +8496,7 @@ "Copia dichiarata persa in revisione inventariale": "Copia dichiarata persa in revisione inventariale", "Revisione inventariale aperta": "Revisione inventariale aperta", "Revisione inventariale chiusa": "Revisione inventariale chiusa", - "Revisione inventariale annullata": "Revisione inventariale annullata" + "Revisione inventariale annullata": "Revisione inventariale annullata", + "In prestito o in attesa di ritiro": "In prestito o in attesa di ritiro", + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "La copia risulta in prestito o in attesa di ritiro: verifica il prestito." } diff --git a/tests/inventory-stocktake.spec.js b/tests/inventory-stocktake.spec.js index 143165e19..bd18bb7bb 100644 --- a/tests/inventory-stocktake.spec.js +++ b/tests/inventory-stocktake.spec.js @@ -325,7 +325,7 @@ test('7. the PDF and CSV reports download with the expected content', async ({ p expect(byCode[code('A4')].slice(0, 3)).toEqual(['Mancante', code('A4'), `${TAG} B1`]); expect(byCode[code('A2')][0]).toBe('Fuori posto'); expect(byCode[code('A2')][4]).toBe(`[${TAG}X] Stocktake X`); - expect(byCode[code('L1')][0]).toBe('In prestito'); + expect(byCode[code('L1')][0]).toBe('In prestito o in attesa di ritiro'); expect(byCode[`${TAG}-NOPE`][0]).toBe('Codice sconosciuto'); expect(byCode[code('Y1')][0]).toBe('Fuori ambito'); expect(rows.filter((r) => r[0] === 'Trovata').map((r) => r[1]).sort()).toEqual([code('A1'), code('A3')]); diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index 56bd4a05a..e5f5d89d4 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -5,7 +5,8 @@ * Inventory stocktake (plan §8) against the real database: the snapshot taken * at opening is independent of later catalogue edits, every scan lands in the * right bucket, the buckets add up, and closing with "mark as lost" changes - * only the missing copies, recalculates availability and writes the audit log. + * only the missing copies (never one lent, waiting for pickup or damaged), + * recalculates availability and writes the audit log. * * Touches only rows it creates (titles/codes prefixed ZZST) and removes them. * Run: php tests/stocktake-snapshot.unit.php @@ -49,6 +50,7 @@ $books = []; $sessionIds = []; $triggers = []; +$borrowerId = 0; try { foreach (['X', 'Y'] as $tag) { $db->query("INSERT INTO scaffali (codice, nome, lettera) VALUES ('{$p}{$tag}', 'Stocktake {$tag}', '{$tag}')"); @@ -71,21 +73,30 @@ $c6 = $addCopy($b1, 'C6'); $c3 = $addCopy($b2, 'C3', 'prestato'); $c4 = $addCopy($b3, 'C4'); - $db->query("UPDATE libri SET copie_totali = 3, copie_disponibili = 3 WHERE id = {$b1}"); + // C7 is on the pickup shelf for a reader: 'prenotato' with a 'da_ritirare' loan. C8 gets damaged during the stocktake. + $c7 = $addCopy($b2, 'C7', 'prenotato'); + $c8 = $addCopy($b1, 'C8'); + $db->query("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata, locale) VALUES ('{$p}', 'Reader', '{$p}', '{$p}@stocktake.test.local', 'x', 'standard', 'attivo', 1, 1, 'it_IT')"); + $borrowerId = (int) $db->insert_id; + $db->query("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, pickup_deadline, stato, origine, attivo) VALUES ({$b2}, {$c7}, {$borrowerId}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 14 DAY), DATE_ADD(CURDATE(), INTERVAL 3 DAY), 'da_ritirare', 'prenotazione', 1)"); + $holdId = (int) $db->insert_id; + $db->query("UPDATE libri SET copie_totali = 4, copie_disponibili = 4 WHERE id = {$b1}"); + $db->query("UPDATE libri SET copie_totali = 2, copie_disponibili = 0 WHERE id = {$b2}"); $service = new StocktakeService($db); $sessionId = $service->open('ZZST ' . $run, null, ['scaffale_id' => (string) $shelves['X']], null); $sessionIds[] = $sessionId; - $check((int) $one("SELECT totale_attese FROM inventario_sessioni WHERE id = {$sessionId}") === 4, 'the snapshot holds the four copies on shelf X'); - $check($one("SELECT GROUP_CONCAT(copia_id ORDER BY copia_id) FROM inventario_attese WHERE sessione_id = {$sessionId}") === implode(',', [$c1, $c2, $c6, $c3]), 'exactly the shelf-X copies are expected'); - $check($one("SELECT stato_all_apertura FROM inventario_attese WHERE sessione_id = {$sessionId} AND copia_id = {$c3}") === 'prestato', 'the snapshot records each copy state at opening'); + $check((int) $one("SELECT totale_attese FROM inventario_sessioni WHERE id = {$sessionId}") === 6, 'the snapshot holds the six copies on shelf X'); + $check($one("SELECT GROUP_CONCAT(copia_id ORDER BY copia_id) FROM inventario_attese WHERE sessione_id = {$sessionId}") === implode(',', [$c1, $c2, $c6, $c3, $c7, $c8]), 'exactly the shelf-X copies are expected'); + $check($one("SELECT stato_all_apertura FROM inventario_attese WHERE sessione_id = {$sessionId} AND copia_id = {$c3}") === 'prestato' && $one("SELECT stato_all_apertura FROM inventario_attese WHERE sessione_id = {$sessionId} AND copia_id = {$c7}") === 'prenotato', 'the snapshot records each copy state at opening'); // Catalogue edits after opening must not change what the session expects. $db->query("UPDATE libri SET scaffale_id = {$shelves['Y']} WHERE id = {$b1}"); $c5 = $addCopy($b2, 'C5'); $db->query("DELETE FROM copie WHERE id = {$c2}"); $db->query("UPDATE libri SET deleted_at = NOW() WHERE id = {$b3}"); - $check((int) $one("SELECT COUNT(*) FROM inventario_attese WHERE sessione_id = {$sessionId}") === 4, 'moving a book, adding and deleting copies leaves the snapshot intact'); + $db->query("UPDATE copie SET stato = 'danneggiato' WHERE id = {$c8}"); + $check((int) $one("SELECT COUNT(*) FROM inventario_attese WHERE sessione_id = {$sessionId}") === 6, 'moving a book, adding and deleting copies leaves the snapshot intact'); $check((int) $one("SELECT scaffale_atteso FROM inventario_attese WHERE sessione_id = {$sessionId} AND copia_id = {$c1}") === $shelves['X'], 'the expected shelf is the one at opening, not the current one'); $x = $shelves['X']; @@ -98,12 +109,12 @@ $check((int) $one("SELECT COUNT(*) FROM inventario_scansioni WHERE sessione_id = {$sessionId} AND copia_id = {$c1}") === 1, 'the UNIQUE (sessione_id, copia_id) row stays single: duplicates are stored without a copy id'); $summary = $service->summary($sessionId); - $check($summary['attese'] === 4 && $summary['trovate'] === 1 && $summary['fuori_posto'] === 0 && $summary['in_prestito'] === 1 && $summary['mancanti'] === 2, 'found 1, on loan 1, missing 2 (C2 deleted, C6 unscanned)'); + $check($summary['attese'] === 6 && $summary['trovate'] === 1 && $summary['fuori_posto'] === 0 && $summary['in_prestito'] === 2 && $summary['mancanti'] === 3, 'found 1, on loan 2 (C3 lent, C7 waiting for pickup), missing 3 (C2 deleted, C6 unscanned, C8 damaged)'); $check($summary['trovate'] + $summary['fuori_posto'] + $summary['in_prestito'] + $summary['mancanti'] === $summary['attese'], 'the four buckets add up to the expected copies'); $check($summary['sconosciute'] === 2 && $summary['fuori_ambito'] === 1 && $summary['duplicate'] === 1 && $summary['scansioni'] === 6, 'side counters: 2 unknown, 1 out of scope, 1 duplicate, 6 scans'); $categories = array_count_values(array_column($service->reportRows($sessionId), 'categoria')); - $check(($categories['mancante'] ?? 0) === 2 && ($categories['trovata'] ?? 0) === 1 && ($categories['in_prestito'] ?? 0) === 1, 'the report rows list the same buckets'); + $check(($categories['mancante'] ?? 0) === 3 && ($categories['trovata'] ?? 0) === 1 && ($categories['in_prestito'] ?? 0) === 2, 'the report rows list the same buckets'); // Probe: record the session state at the instant a copy turns 'perso'. // close() must claim the session before it marks anything, so scans that @@ -115,10 +126,12 @@ $result = $service->close($sessionId, true, null); $db->query("DROP TRIGGER IF EXISTS {$probe}"); $check($one('SELECT @zzst_state_at_lost') === 'chiusa', 'the session is already closed when the first copy is marked lost'); - $check($result === ['marked' => 1, 'failed' => 0], 'closing with "mark lost" marks only C6 (C2 no longer exists, C3 is lent)'); + $check($result === ['marked' => 1, 'failed' => 0], 'closing with "mark lost" marks only C6 (C2 no longer exists, C3 is lent, C7 waits for pickup, C8 is damaged)'); $check($one("SELECT stato FROM copie WHERE id = {$c6}") === 'perso', 'C6 is now lost'); $check($one("SELECT stato FROM copie WHERE id = {$c1}") === 'disponibile' && $one("SELECT stato FROM copie WHERE id = {$c3}") === 'prestato', 'found and lent copies are untouched'); - $check((int) $one("SELECT copie_disponibili FROM libri WHERE id = {$b1}") === 1, 'book availability is recalculated (1 of the 2 remaining copies)'); + $check($one("SELECT stato FROM copie WHERE id = {$c7}") === 'prenotato' && $one("SELECT CONCAT(stato, ':', attivo, ':', copia_id) FROM prestiti WHERE id = {$holdId}") === "da_ritirare:1:{$c7}", 'the copy on the pickup shelf keeps its state and its hold is not reassigned'); + $check($one("SELECT stato FROM copie WHERE id = {$c8}") === 'danneggiato', 'a damaged copy is not overwritten to lost'); + $check((int) $one("SELECT copie_disponibili FROM libri WHERE id = {$b1}") === 1, 'book availability is recalculated (1 of the 3 remaining copies)'); $check((int) $one("SELECT COUNT(*) FROM log_modifiche WHERE tabella = 'libri' AND record_id = {$b1} AND JSON_UNQUOTE(JSON_EXTRACT(dati_nuovi, '$._activity.event')) = 'copy.inventory_lost'") === 1, 'an ActivityLog entry records the copy declared lost'); $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$sessionId}") === 'chiusa', 'the session is closed'); try { @@ -131,9 +144,9 @@ // Upload path + cancel: nothing is modified. $second = $service->open('ZZST2 ' . $run, null, ['collocazione_da' => "{$p}.B2", 'collocazione_a' => "{$p}.B2"], null); $sessionIds[] = $second; - $check((int) $one("SELECT totale_attese FROM inventario_sessioni WHERE id = {$second}") === 2, 'a collocation range opens on B2 alone (C3, C5)'); - $import = $service->importCodes($second, StocktakeService::parseCodeList("codice\n{$p}-C5\n{$p}-C5\n{$p}-C1\n"), null, null); - $check($import['total'] === 3 && $import['outcomes']['trovata'] === 1 && $import['outcomes']['duplicata'] === 1 && $import['outcomes']['fuori_ambito'] === 1, 'an uploaded list is classified line by line'); + $check((int) $one("SELECT totale_attese FROM inventario_sessioni WHERE id = {$second}") === 3, 'a collocation range opens on B2 alone (C3, C5, C7)'); + $import = $service->importCodes($second, StocktakeService::parseCodeList("codice\n{$p}-C5\n{$p}-C5\n{$p}-C1\n{$p}-C7\n"), null, null); + $check($import['total'] === 4 && $import['outcomes']['trovata'] === 1 && $import['outcomes']['duplicata'] === 1 && $import['outcomes']['fuori_ambito'] === 1 && $import['outcomes']['in_prestito'] === 1, 'an uploaded list is classified line by line; a copy waiting for pickup is reported with the lent ones'); $service->cancel($second, null); $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$second}") === 'annullata' && $one("SELECT stato FROM copie WHERE id = {$c5}") === 'disponibile', 'cancelling leaves every copy as it was'); @@ -195,10 +208,14 @@ } foreach ($books as $bid) { $db->query("DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id = {$bid}"); + $db->query("DELETE FROM prestiti WHERE libro_id = {$bid}"); $db->query("DELETE FROM copie WHERE libro_id = {$bid}"); $db->query("DELETE FROM libri WHERE id = {$bid}"); } foreach ($shelves as $sid) { $db->query("DELETE FROM scaffali WHERE id = {$sid}"); } + if ($borrowerId > 0) { + $db->query("DELETE FROM utenti WHERE id = {$borrowerId}"); + } } From 1036fb109e54a0830cfb7cee3e477f5b9bee0a5e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:11:52 +0000 Subject: [PATCH 121/398] RdaTypes::codes() for the MARC 336/337/338 builder effective() already returns the {content, media, carrier} triple; codes() names it for the exporters so BookMarcBuilder (#505) has a stable entry point for 336/337/338 that does not depend on the override/derived split. The class docblock records that mapping. No MARC code here: the builder change is on #505. --- app/Support/RdaTypes.php | 18 +++++++++++++++++- tests/minor-features-090.unit.php | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/Support/RdaTypes.php b/app/Support/RdaTypes.php index 75a0098bd..a0b6ff809 100644 --- a/app/Support/RdaTypes.php +++ b/app/Support/RdaTypes.php @@ -13,7 +13,10 @@ * columns; NULL means "use the derived value". * * Codes are the Library of Congress RDA vocabulary codes, so they map 1:1 to - * the id.loc.gov URIs the BIBFRAME plugin emits. + * the id.loc.gov URIs the BIBFRAME plugin emits and to the MARC 21 fields + * 336 (content), 337 (media) and 338 (carrier): codes() is the triple + * BookMarcBuilder (MARC export, #505) writes in $b of those fields, with + * "rdacontent" / "rdamedia" / "rdacarrier" in $2. */ final class RdaTypes { @@ -168,6 +171,19 @@ public static function effective(array $book): array return $derived; } + /** + * The effective codes under the names the exporters use: the same triple + * as effective(), kept as a stable entry point for BookMarcBuilder + * (336/337/338) and the BIBFRAME plugin. + * + * @param array $book + * @return array{content: string, media: string, carrier: string} + */ + public static function codes(array $book): array + { + return self::effective($book); + } + /** id.loc.gov URI of a code. */ public static function uri(string $kind, string $code): string { diff --git a/tests/minor-features-090.unit.php b/tests/minor-features-090.unit.php index 8581a4c6d..13cfe4991 100644 --- a/tests/minor-features-090.unit.php +++ b/tests/minor-features-090.unit.php @@ -31,6 +31,7 @@ function checkMinor(bool $ok, string $label): void { global $count; if (!$ok) { checkMinor(RdaTypes::effective($book) === ['content' => 'txt', 'media' => 'c', 'carrier' => 'cr'], 'effective() mixes overrides over the derived triple'); checkMinor(RdaTypes::overrides(['tipo_media' => 'dvd']) === [], 'a record without overrides has none'); checkMinor(RdaTypes::effective(['tipo_media' => 'dvd']) === ['content' => 'tdi', 'media' => 'v', 'carrier' => 'vd'] && RdaTypes::effective(['tipo_media' => 'libro']) === ['content' => 'txt', 'media' => 'n', 'carrier' => 'nc'], 'without an override BIBFRAME emits the derived triple: a book keeps txt/n/nc, a DVD gets its real types'); +checkMinor(RdaTypes::codes($book) === RdaTypes::effective($book) && array_keys(RdaTypes::codes(['tipo_media' => 'dvd'])) === ['content', 'media', 'carrier'], 'codes() is the effective triple under the content/media/carrier keys the MARC 336/337/338 builder reads'); checkMinor(RdaTypes::uri('carrier', 'cr') === 'http://id.loc.gov/vocabulary/carriers/cr' && RdaTypes::uri('content', 'txt') === 'http://id.loc.gov/vocabulary/contentTypes/txt', 'codes map to id.loc.gov URIs'); foreach (RdaTypes::kinds() as $kind) { foreach (array_keys(MediaLabelsKeys()) as $media) { From 2ba1f624692b2cdb81d0253e68175aab9f41e396 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:11:57 +0000 Subject: [PATCH 122/398] Keep the quotes around a normalised author name, not inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthorNormalizer::normalize() swapped "Häffner, Rolf" typed in quotes into `Rolf" "Häffner`: the quote characters became part of the name, and the quote-aware lookup (ExactSearch::quotedValue) no longer saw a whole-value quoted string. The LibraryThing importer normalises the primary and secondary authors and the contributor columns before the lookup, and the CSV importer does the same for the translator, illustrator and editor columns, so a quoted inverted name was stored with the quotes in it. normalize() now recognises a name quoted as a whole, canonicalises the name inside and returns it re-wrapped (`"Rolf Häffner"`), so every caller keeps the "quotes mean this exact spelling" contract and AuthorRepository::create()/findByCanonicalName() still strip them. toSearchForm() and getSearchVariants() work on the name inside the quotes, so match() compares a quoted candidate on the name itself. The CSV title + first author duplicate lookup compares without the quotes. The core #501 suite pins the normaliser cases; the dedup suite drives parseLibraryThingRow with a quoted inverted author and translator and checks the stored author has no quote characters and is reused. --- app/Controllers/CsvImportController.php | 5 ++++- app/Support/AuthorNormalizer.php | 20 +++++++++++++++++--- tests/exact-search-core-501.unit.php | 14 +++++++++++++- tests/exact-search-dedup-501.unit.php | 23 ++++++++++++++++++++++- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 049e25c19..bde71e37c 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -1698,8 +1698,11 @@ private function findExistingBook(\mysqli $db, array $data): ?int $firstAuthor = $authorsArray[0]; if ($firstAuthor !== '') { - // Normalize author name to match DB format (e.g. "Levi, Primo" → "Primo Levi") + // Normalize author name to match DB format (e.g. "Levi, Primo" → "Primo Levi"). + // A name in quotes keeps them through normalize() (#501); they + // are never part of a stored name, so compare without them. $normalizedAuthor = \App\Support\AuthorNormalizer::normalize($firstAuthor); + $normalizedAuthor = \App\Support\ExactSearch::quotedValue($normalizedAuthor) ?? $normalizedAuthor; $stmt = $db->prepare(" SELECT DISTINCT l.id FROM libri l diff --git a/app/Support/AuthorNormalizer.php b/app/Support/AuthorNormalizer.php index 8ec86d8ba..22d3dd3b0 100644 --- a/app/Support/AuthorNormalizer.php +++ b/app/Support/AuthorNormalizer.php @@ -24,11 +24,24 @@ class AuthorNormalizer * - "Marx, Karl Heinrich" → "Karl Heinrich Marx" * - "De Filippo, Eduardo" → "Eduardo De Filippo" * + * A name typed wholly in quotes (`"Häffner, Rolf"`) means "this exact + * spelling" (#501): the quotes are kept, around the canonical form + * (`"Rolf Häffner"`), so the save and lookup paths still see a whole-value + * quoted string (ExactSearch::quotedValue()) and the person is the one + * written that way. Without this the quotes ended up inside the swapped + * name (`Rolf" "Häffner`) and were stored as part of it. + * * @param string $name The author name in any format * @return string Normalized name in "Name Surname" format */ public static function normalize(string $name): string { + $quoted = ExactSearch::quotedValue($name); + if ($quoted !== null) { + $inner = self::normalize($quoted); + return $inner === '' ? '' : '"' . $inner . '"'; + } + $name = trim($name); if ($name === '') { @@ -78,8 +91,9 @@ public static function normalize(string $name): string */ public static function toSearchForm(string $name): string { - // First normalize the name - $normalized = self::normalize($name); + // First normalize the name. Quotes are a lookup instruction, never + // part of the name: a quoted candidate compares on the name inside. + $normalized = self::normalize(ExactSearch::quotedValue($name) ?? $name); if ($normalized === '') { return ''; @@ -108,7 +122,7 @@ public static function toSearchForm(string $name): string */ public static function getSearchVariants(string $name): array { - $normalized = self::normalize($name); + $normalized = self::normalize(ExactSearch::quotedValue($name) ?? $name); if ($normalized === '') { return []; diff --git a/tests/exact-search-core-501.unit.php b/tests/exact-search-core-501.unit.php index fb3220ae0..7f4077431 100644 --- a/tests/exact-search-core-501.unit.php +++ b/tests/exact-search-core-501.unit.php @@ -10,6 +10,7 @@ require __DIR__ . '/helpers/exact-search-501.php'; +use App\Support\AuthorNormalizer as N; use App\Support\ExactSearch as E; $ok = [Exact501::class, 'check']; @@ -48,6 +49,17 @@ $same(null, E::quotedValue('Haffner'), 'an unquoted name is not a quoted value'); $same(null, E::quotedValue('"'), 'a lone quote is not a name'); $ok(E::filterCondition(['a.nome'], '"') === null, 'a lone quote adds no filter'); +// AuthorNormalizer: a quoted name is canonicalised INSIDE the quotes, so the +// save and lookup paths still see a whole-value quoted string and the quote +// characters never end up in the stored name (`Rolf" "Häffner`). +$same('"Rolf Häffner"', N::normalize('"Häffner, Rolf"'), 'a quoted inverted name is swapped with the quotes kept around the whole name'); +$same('"Rolf Häffner"', N::normalize('"Rolf Häffner"'), 'a quoted direct name is unchanged'); +$same('"Rolf Häffner"', N::normalize('„Häffner, Rolf“'), 'typographic quotes come back as straight ones around the canonical name'); +$same('Rolf Häffner', N::normalize('Häffner, Rolf'), 'an unquoted inverted name is still swapped'); +$same('"Pippo" Inzaghi', N::normalize('"Pippo" Inzaghi'), 'a quoted part inside a name is not a quoted name and is kept as typed'); +$same('Rolf Häffner', E::quotedValue(N::normalize('"Häffner, Rolf"')), 'the normalised quoted name still reads as one quoted value when saved'); +$same('rolf haffner', N::toSearchForm('"Häffner, Rolf"'), 'the search form of a quoted name is the name inside the quotes'); +$ok(N::match('"Häffner, Rolf"', 'Rolf Häffner'), 'match() compares a quoted candidate on the name inside the quotes'); $cond = E::filterCondition(['a.nome', 'a.pseudonimo'], '"x"'); $ok($cond !== null && count($cond['params']) === 2 && $cond['types'] === 'ss', 'one bound pattern per searched column'); @@ -104,4 +116,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(47); +Exact501::finish(55); diff --git a/tests/exact-search-dedup-501.unit.php b/tests/exact-search-dedup-501.unit.php index 60abeed3f..1cb3d10b3 100644 --- a/tests/exact-search-dedup-501.unit.php +++ b/tests/exact-search-dedup-501.unit.php @@ -110,7 +110,28 @@ $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"); } +// A LibraryThing row normalises its names before the lookup. A quoted +// inverted name must come out canonical WITH the quotes around the whole name +// (`"Given Höffner"`, never `Given" "Höffner`), so the exact lookup still +// applies and the stored author has no quote characters. +$lt = (new \ReflectionClass(\App\Controllers\LibraryThingImportController::class))->newInstanceWithoutConstructor(); +$ltRow = (new \ReflectionMethod($lt, 'parseLibraryThingRow'))->invoke($lt, [ + 'Title' => 'Quoted names', + 'Primary Author' => "\"Höffner, {$given}\"", + 'Secondary Author' => "\"Hüffner, {$given}\"", + 'Secondary Author Roles' => 'Translator', +]); +$same("\"{$given} Höffner\"", $ltRow['autori'], 'LibraryThing import: a quoted inverted primary author is canonicalised inside the quotes'); +$same("\"{$given} Hüffner\"", $ltRow['traduttore'] ?? null, 'LibraryThing import: a quoted inverted translator is canonicalised inside the quotes too'); +$ltAuthor = new \ReflectionMethod($lt, 'getOrCreateAuthor'); +$ltFirst = $ltAuthor->invoke($lt, $db, $ltRow['autori']); +$ltName = $nameOf('autori', (int) $ltFirst['id']); +$ok($ltFirst['created'] === true && $ltName === "{$given} Höffner" && !str_contains($ltName, '"'), 'LibraryThing import: the quoted author is stored without quote characters'); +$same(['id' => $ltFirst['id'], 'created' => false], $ltAuthor->invoke($lt, $db, $ltRow['autori']), 'LibraryThing import: the same quoted author is reused, not duplicated'); +$ltTranslator = \App\Support\ContributorSync::resolveNameIds($db, (string) $ltRow['traduttore']); +$ok($ltTranslator['created'] === 1 && $nameOf('autori', $ltTranslator['ids'][0]) === "{$given} Hüffner", 'LibraryThing import: a quoted translator reaches ContributorSync and is stored without quotes'); + $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); +Exact501::finish(46); From 070058f025116c24a81b13fbabcc454df0adcee6 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:11:57 +0000 Subject: [PATCH 123/398] Series suggestions: every word must be at least two characters The two-character minimum that guards the series autocomplete against single-character enumeration (SEC2-1, SEC1-5) was measured on the joined parseFilter() term, while the condition is built per word by ExactSearch::filterCondition(): `"a" b` passed the check and probed one-character patterns. The minimum now applies to every token, so a request with any word shorter than two characters returns no suggestions, as a short unquoted term already did. Lone quotes still yield no condition. --- app/Controllers/CollaneController.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/Controllers/CollaneController.php b/app/Controllers/CollaneController.php index ba91c16df..a6e60bc8d 100644 --- a/app/Controllers/CollaneController.php +++ b/app/Controllers/CollaneController.php @@ -484,9 +484,11 @@ public function searchApi(Request $request, Response $response, mysqli $db): Res // wildcards so callers can't broaden the match with `%`/`_` or // probe single-char prefixes for enumeration. // Each word keeps its own rule (`Mont "Häffner"`): the condition - // comes from filterCondition(); parseFilter() only measures the input. - ['term' => $term] = \App\Support\ExactSearch::parseFilter($q); - if (mb_strlen($term) >= 2) { + // comes from filterCondition(), one per word, so the minimum applies + // to every word: `"a" b` must not probe single characters either. + $words = array_column(\App\Support\ExactSearch::tokenize($q), 'term'); + $longEnough = $words !== [] && min(array_map('mb_strlen', $words)) >= 2; + if ($longEnough) { $hasCollane = $this->hasCollaneTable($db); $sqlParts = []; From d538b5f3565f09bd999bf57be658003b6b4ef996 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:12:00 +0000 Subject: [PATCH 124/398] Card settings: French name of the portrait layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Verticale" was still the Italian word in fr_FR (de Hochformat, en Portrait, da Stående). The layout is now "Portrait", and its description says "au format portrait" to match, alongside "Classique" and "Épurée". --- locale/fr_FR.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locale/fr_FR.json b/locale/fr_FR.json index a00972380..9d15465cb 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8733,8 +8733,8 @@ "Fascia con logo e nome della biblioteca, foto a sinistra, nome, codice e scadenza a destra.": "Bandeau avec le logo et le nom de la bibliothèque, photo à gauche, nom, code et échéance à droite.", "Essenziale": "Épurée", "Senza foto: nome grande e codice a barre su tutta la larghezza.": "Sans photo : nom en grand et code-barres sur toute la largeur.", - "Verticale": "Verticale", - "Tessera in verticale con la foto al centro (9 per foglio A4).": "Carte verticale avec la photo au centre (9 par feuille A4).", + "Verticale": "Portrait", + "Tessera in verticale con la foto al centro (9 per foglio A4).": "Carte au format portrait avec la photo au centre (9 par feuille A4).", "Codice a barre (Code128)": "Code-barres (Code128)", "Leggibile da ogni lettore di codici a barre del banco prestiti.": "Lisible par tout lecteur de codes-barres de la banque de prêt.", "QR code": "Code QR", From eeaff33226d59181a4affe5b39595e8ad60a280c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:12:15 +0000 Subject: [PATCH 125/398] Stocktake: leave copies off the shelf out of the opening snapshot The snapshot took every copy matching the filter, whatever its state. A copy already lost, damaged, at the restorer's, in transit or in maintenance was then "missing" for the whole session: nobody expects it on the shelf, and the close skips it anyway, so the count could never be settled. Those states are now left out of inventario_attese at opening. Lent copies and copies waiting for pickup stay in, they have the on-loan bucket. The snapshot test adds a 'perso' and an 'in_restauro' copy at opening and checks they are neither expected nor touched; the E2E fixtures open on 'disponibile' copies, so their counts do not move. --- CHANGELOG.md | 2 +- app/Services/StocktakeService.php | 14 ++++++++++++-- tests/stocktake-snapshot.unit.php | 9 ++++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0776a5dcd..fa28bf45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre ### Inventory stocktake -- **Stocktake sessions** (Admin → Revisione inventariale, `/admin/inventory`). A session is opened with a name, an optional branch and a filter (bookcase, shelf level, genre with its subgenres, media type, collocation range). Opening it freezes the list of expected copies in `inventario_attese`, books removed from the catalogue excluded, so moving a book, adding or deleting a copy while the stocktake runs does not change what the session looks for. +- **Stocktake sessions** (Admin → Revisione inventariale, `/admin/inventory`). A session is opened with a name, an optional branch and a filter (bookcase, shelf level, genre with its subgenres, media type, collocation range). Opening it freezes the list of expected copies in `inventario_attese`, books removed from the catalogue excluded, so moving a book, adding or deleting a copy while the stocktake runs does not change what the session looks for. Copies that are not on any shelf when the session opens (lost, damaged, being restored, in transit, in maintenance) are left out of the expected set, so they are not reported missing for the whole session; lent copies and copies waiting for pickup stay in, with a bucket of their own. - **Scanning** works with keyboard-wedge barcode readers (the code field keeps the focus, Enter records), with the self-hosted camera scanner already used at the loan desk, and with an uploaded `.txt` (one code per line) or `.csv` (first column) list. Each read is classified as found, misplaced (found on a bookcase other than the expected one, as declared by "Scaffale in cui stai lavorando"), on loan or awaiting pickup, unknown code, out of scope or repeated, with its own colour and sound. Several operators can scan the same session at once: the counters refresh every few seconds and "Solo le mie scansioni" narrows the list to one's own reads. - **Live report**: expected copies always add up as found + misplaced + on loan (lent, or set aside on the pickup shelf for a reader) + missing; unknown, out-of-scope and repeated reads are counted on the side. PDF and CSV reports are available while the session is open and after it ends. - **Closing** either leaves the missing copies as they are or marks them `perso`. Marking them lost is an administrator action, checked on the server: Staff and custom roles can scan and close a session, but not declare copies lost. Copies on loan or waiting to be picked up (by their state or by an active loan row), damaged, being restored, in transit, in maintenance or already lost are never touched; holds on a copy declared lost move to another copy as with a manual change of state; book availability is recalculated through `DataIntegrity`, and each copy gets a "Copia dichiarata persa in revisione inventariale" entry in the book's activity log. A session can also be cancelled without changing any copy. diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index 33c83381f..608a2ded5 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -46,6 +46,14 @@ final class StocktakeService private const ON_LOAN_COPY_STATES = ['prestato', 'prenotato']; private const ON_LOAN_LOAN_STATES = ['in_corso', 'in_ritardo', 'prenotato', 'da_ritirare']; + /** + * Copies nobody expects on a shelf when the session opens: they are left + * out of the snapshot, otherwise they would be "missing" for the whole + * session with nothing the close could do about them. Lent copies and + * copies waiting for pickup stay in: they have a bucket of their own. + */ + private const OFF_SHELF_STATES = ['perso', 'danneggiato', 'in_restauro', 'in_trasferimento', 'manutenzione']; + public function __construct(private mysqli $db) { } @@ -166,7 +174,9 @@ public static function parseCodeList(string $text): array } /** - * Open a session and freeze the copies it expects to find. + * Open a session and freeze the copies it expects to find: those matching + * the filter, of books still in the catalogue, that are not already out of + * circulation (OFF_SHELF_STATES). * * @param array $filter */ @@ -199,7 +209,7 @@ public function open(string $name, ?int $sedeId, array $filter, ?int $operatorId $sql = 'INSERT INTO inventario_attese (sessione_id, copia_id, scaffale_atteso, stato_all_apertura) ' . 'SELECT ?, c.id, l.scaffale_id, c.stato FROM copie c ' . 'JOIN libri l ON l.id = c.libro_id AND l.deleted_at IS NULL ' - . 'WHERE 1 = 1' . $where; + . 'WHERE c.stato NOT IN (' . self::sqlList(self::OFF_SHELF_STATES) . ')' . $where; $stmt = $this->db->prepare($sql); if ($stmt === false) { throw new \RuntimeException($this->db->error); diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index e5f5d89d4..d3b8a6b61 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -76,18 +76,21 @@ // C7 is on the pickup shelf for a reader: 'prenotato' with a 'da_ritirare' loan. C8 gets damaged during the stocktake. $c7 = $addCopy($b2, 'C7', 'prenotato'); $c8 = $addCopy($b1, 'C8'); + // C9 and C10 are already out of circulation when the session opens: nobody expects them on the shelf. + $c9 = $addCopy($b1, 'C9', 'perso'); + $c10 = $addCopy($b1, 'C10', 'in_restauro'); $db->query("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata, locale) VALUES ('{$p}', 'Reader', '{$p}', '{$p}@stocktake.test.local', 'x', 'standard', 'attivo', 1, 1, 'it_IT')"); $borrowerId = (int) $db->insert_id; $db->query("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, pickup_deadline, stato, origine, attivo) VALUES ({$b2}, {$c7}, {$borrowerId}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 14 DAY), DATE_ADD(CURDATE(), INTERVAL 3 DAY), 'da_ritirare', 'prenotazione', 1)"); $holdId = (int) $db->insert_id; - $db->query("UPDATE libri SET copie_totali = 4, copie_disponibili = 4 WHERE id = {$b1}"); + $db->query("UPDATE libri SET copie_totali = 6, copie_disponibili = 4 WHERE id = {$b1}"); $db->query("UPDATE libri SET copie_totali = 2, copie_disponibili = 0 WHERE id = {$b2}"); $service = new StocktakeService($db); $sessionId = $service->open('ZZST ' . $run, null, ['scaffale_id' => (string) $shelves['X']], null); $sessionIds[] = $sessionId; $check((int) $one("SELECT totale_attese FROM inventario_sessioni WHERE id = {$sessionId}") === 6, 'the snapshot holds the six copies on shelf X'); - $check($one("SELECT GROUP_CONCAT(copia_id ORDER BY copia_id) FROM inventario_attese WHERE sessione_id = {$sessionId}") === implode(',', [$c1, $c2, $c6, $c3, $c7, $c8]), 'exactly the shelf-X copies are expected'); + $check($one("SELECT GROUP_CONCAT(copia_id ORDER BY copia_id) FROM inventario_attese WHERE sessione_id = {$sessionId}") === implode(',', [$c1, $c2, $c6, $c3, $c7, $c8]), 'exactly the shelf-X copies are expected: a copy already lost or at the restorer\'s at opening is not'); $check($one("SELECT stato_all_apertura FROM inventario_attese WHERE sessione_id = {$sessionId} AND copia_id = {$c3}") === 'prestato' && $one("SELECT stato_all_apertura FROM inventario_attese WHERE sessione_id = {$sessionId} AND copia_id = {$c7}") === 'prenotato', 'the snapshot records each copy state at opening'); // Catalogue edits after opening must not change what the session expects. @@ -130,7 +133,7 @@ $check($one("SELECT stato FROM copie WHERE id = {$c6}") === 'perso', 'C6 is now lost'); $check($one("SELECT stato FROM copie WHERE id = {$c1}") === 'disponibile' && $one("SELECT stato FROM copie WHERE id = {$c3}") === 'prestato', 'found and lent copies are untouched'); $check($one("SELECT stato FROM copie WHERE id = {$c7}") === 'prenotato' && $one("SELECT CONCAT(stato, ':', attivo, ':', copia_id) FROM prestiti WHERE id = {$holdId}") === "da_ritirare:1:{$c7}", 'the copy on the pickup shelf keeps its state and its hold is not reassigned'); - $check($one("SELECT stato FROM copie WHERE id = {$c8}") === 'danneggiato', 'a damaged copy is not overwritten to lost'); + $check($one("SELECT stato FROM copie WHERE id = {$c8}") === 'danneggiato' && $one("SELECT stato FROM copie WHERE id = {$c9}") === 'perso' && $one("SELECT stato FROM copie WHERE id = {$c10}") === 'in_restauro', 'a damaged copy is not overwritten to lost; copies out of circulation at opening are untouched'); $check((int) $one("SELECT copie_disponibili FROM libri WHERE id = {$b1}") === 1, 'book availability is recalculated (1 of the 3 remaining copies)'); $check((int) $one("SELECT COUNT(*) FROM log_modifiche WHERE tabella = 'libri' AND record_id = {$b1} AND JSON_UNQUOTE(JSON_EXTRACT(dati_nuovi, '$._activity.event')) = 'copy.inventory_lost'") === 1, 'an ActivityLog entry records the copy declared lost'); $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$sessionId}") === 'chiusa', 'the session is closed'); From 898367eb26f0b73f2847fb1cfd3531bf2ab1085a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:12:16 +0000 Subject: [PATCH 126/398] SRU client: a surrogate diagnostic skips one record, not the search readSearchResponse() treated any in the response as fatal. The BnF answers a per-record diagnostic inside ("erreur de traitement") for a hit it cannot render, so a title search with 1495 hits came back with 0 records and an error. Only a diagnostic directly under the root's ends the search now; a record whose recordData holds one is skipped (the MARC reader never saw it as a record) and the others are returned. The root our own endpoint writes on 401/429/503 is read as the server's error too. LocalSruClient passes the plugin's API key in the in-process request when the endpoint requires one, so the local server no longer answers 401 to its own copy cataloguing page; an error answer with a body is returned so its message is reported. --- .../z39-server/classes/LocalSruClient.php | 10 ++++ .../plugins/z39-server/classes/SruClient.php | 59 +++++++++++++++---- tests/sru-search.unit.php | 15 +++++ 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/storage/plugins/z39-server/classes/LocalSruClient.php b/storage/plugins/z39-server/classes/LocalSruClient.php index fb2c4b0a9..bd4a12c4b 100644 --- a/storage/plugins/z39-server/classes/LocalSruClient.php +++ b/storage/plugins/z39-server/classes/LocalSruClient.php @@ -42,6 +42,13 @@ protected function fetchUrl(string $url): ?string $query = []; parse_str((string) parse_url($url, PHP_URL_QUERY), $query); require_once dirname(__DIR__) . '/endpoint.php'; + // The endpoint checks the plugin's API key when the setting asks for + // one: this request comes from the plugin itself, so it carries the + // key as a client would, instead of being answered with a 401. + $settings = \getPluginSettings($this->db, $this->pluginId); + if (in_array($settings['require_api_key'] ?? '', ['1', 'true'], true) && trim((string) ($settings['api_key'] ?? '')) !== '') { + $query['api_key'] = (string) $settings['api_key']; + } $request = (new \Slim\Psr7\Factory\ServerRequestFactory()) ->createServerRequest('GET', '/api/sru', $_SERVER) ->withQueryParams($query); @@ -49,6 +56,9 @@ protected function fetchUrl(string $url): ?string $body = $response->getBody(); $body->rewind(); $xml = $body->getContents(); + // An error answer with a body (, as the endpoint + // writes on 401, 429 and 503) is returned so its message is read and + // reported as the server's error; one without a body is the status. if ($response->getStatusCode() >= 400 && $xml === '') { throw new \RuntimeException('HTTP ' . $response->getStatusCode() . ' error from server'); } diff --git a/storage/plugins/z39-server/classes/SruClient.php b/storage/plugins/z39-server/classes/SruClient.php index 1fc0729d5..c462421f3 100644 --- a/storage/plugins/z39-server/classes/SruClient.php +++ b/storage/plugins/z39-server/classes/SruClient.php @@ -90,6 +90,9 @@ public function searchByIsbn(string $isbn): ?array 'any' => 'cql.serverChoice', ]; + /** CQL relations a server configuration may ask for on an index. */ + private const CQL_RELATIONS = ['=', '==', 'all', 'any', 'adj', 'exact']; + /** * The CQL query for one search on one server: the server's own index name * when its configuration maps one (`indexes`), else the standard one. @@ -97,6 +100,11 @@ public function searchByIsbn(string $isbn): ?array * CQL specification; an ISBN or ISSN is reduced to its digits and sent * bare unless the server wants it quoted (`quote_search_terms`). * + * An index is mapped as a name (`'any' => 'woe'`, searched with `=`) or + * with the relation the server needs (`'any' => ['index' => 'woe', + * 'relation' => 'all']`): the DNB's `woe="Kafka Prozess"` finds nothing, + * `woe all "Kafka Prozess"` finds every record with both words. + * * @param array $server */ public static function cqlFor(array $server, string $index, string $term): string @@ -106,8 +114,7 @@ public static function cqlFor(array $server, string $index, string $term): strin if ($index === 'isbn' || $index === 'issn') { $term = strtoupper((string) preg_replace('/[^0-9Xx]/', '', $term)); } - $indexes = is_array($server['indexes'] ?? null) ? $server['indexes'] : []; - $cqlIndex = trim((string) ($indexes[$index] ?? '')); + [$cqlIndex, $relation] = self::indexSpec($server, $index); if ($cqlIndex === '') { $cqlIndex = self::DEFAULT_CQL_INDEXES[$index]; } @@ -116,7 +123,28 @@ public static function cqlFor(array $server, string $index, string $term): strin // digits of an ISBN or ISSN go bare, unless the server asks otherwise. $quote = !in_array($index, ['isbn', 'issn'], true) || !empty($server['quote_search_terms']); $value = $quote ? '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $term) . '"' : $term; - return $cqlIndex . '=' . $value; + return $cqlIndex . ($relation === '=' ? '=' : ' ' . $relation . ' ') . $value; + } + + /** + * The CQL index name and relation a server configuration maps one of + * SEARCH_INDEXES to: '' and '=' when it maps none. + * + * @param array $server + * @return array{0: string, 1: string} + */ + public static function indexSpec(array $server, string $index): array + { + $indexes = is_array($server['indexes'] ?? null) ? $server['indexes'] : []; + $spec = $indexes[$index] ?? ''; + if (is_array($spec)) { + $relation = strtolower(trim((string) ($spec['relation'] ?? '='))); + return [ + trim((string) ($spec['index'] ?? '')), + in_array($relation, self::CQL_RELATIONS, true) ? $relation : '=', + ]; + } + return [trim((string) $spec), '=']; } /** @@ -193,7 +221,17 @@ private function readSearchResponse(string $xml, string $syntax, int $max): arra throw new \RuntimeException('Invalid XML response'); } $xpath = new DOMXPath($dom); - $diagnostic = $xpath->query("//*[local-name()='diagnostic']/*[local-name()='message'] | /*[local-name()='searchResponse']/*[local-name()='error']")->item(0); + // Only a diagnostic of the response itself ends the search: the + // element directly under the root, or the + // our own endpoint and some servers answer with. A diagnostic inside + // a record's (a surrogate: the BnF answers "erreur de + // traitement" for one hit of many) only means that record cannot be + // read; the MARC reader below skips it, the others are returned. + $diagnostic = $xpath->query( + "/*/*[local-name()='diagnostics']/*[local-name()='diagnostic']/*[local-name()='message']" + . " | /*[local-name()='error']/*[local-name()='message']" + . " | /*[local-name()='searchResponse']/*[local-name()='error']" + )->item(0); if ($diagnostic !== null) { throw new \RuntimeException(trim((string) $diagnostic->nodeValue)); } @@ -379,12 +417,11 @@ private function queryServer(array $server, string $index, string $term): ?array $recordSchema = strtolower($server['syntax'] ?? 'marcxml'); // Build CQL query with custom index mapping - $cqlIndex = $index; - if (isset($server['indexes'][$index])) { - $cqlIndex = $server['indexes'][$index]; - } elseif ($index === 'isbn') { - $cqlIndex = 'isbn'; + [$cqlIndex, $relation] = self::indexSpec($server, $index); + if ($cqlIndex === '') { + $cqlIndex = $index; } + $relation = $relation === '=' ? '=' : ' ' . $relation . ' '; // Build query parameters // quote_search_terms: some servers (e.g. BNF) require the term wrapped in CQL quotes. @@ -395,7 +432,7 @@ private function queryServer(array $server, string $index, string $term): ?array $params = [ 'operation' => 'searchRetrieve', 'version' => $version, - 'query' => $cqlIndex . '=' . $quotedTerm, + 'query' => $cqlIndex . $relation . $quotedTerm, 'recordSchema' => $recordSchema, 'maximumRecords' => 1 ]; @@ -410,7 +447,7 @@ private function queryServer(array $server, string $index, string $term): ?array if ($recordSchema === 'dbc-opensearch') { $params = [ 'action' => 'search', - 'query' => ($cqlIndex === 'isbn' ? 'term.isbn' : $cqlIndex) . '=' . $quotedTerm, + 'query' => ($cqlIndex === 'isbn' ? 'term.isbn' : $cqlIndex) . $relation . $quotedTerm, 'outputType' => 'xml', 'objectFormat' => 'marcxchange', 'start' => 1, diff --git a/tests/sru-search.unit.php b/tests/sru-search.unit.php index f3e18ca33..aaaa30f50 100644 --- a/tests/sru-search.unit.php +++ b/tests/sru-search.unit.php @@ -66,6 +66,9 @@ protected function fetchUrl(string $url): ?string $check(SruClient::cqlFor([], 'title', "dell'arte") === 'dc.title="dell\'arte"', '05b a word with an apostrophe is quoted'); $check(SruClient::cqlFor([], 'title', 'and') === 'dc.title="and"', '05c a CQL keyword used as a word is quoted'); $check(SruClient::cqlFor(['quote_search_terms' => true], 'isbn', '88-452-9261-3') === 'bath.isbn="8845292613"', '05d an ISBN is quoted only when the server asks'); +$check(SruClient::cqlFor(['indexes' => ['any' => ['index' => 'woe', 'relation' => 'all']]], 'any', 'Kafka Prozess') === 'woe all "Kafka Prozess"', '05f an index mapped with a relation (DNB woe all)'); +$check(SruClient::cqlFor(['indexes' => ['any' => ['index' => 'woe', 'relation' => 'drop table']]], 'any', 'x') === 'woe="x"', '05g an unknown relation falls back to ='); +$check(SruClient::cqlFor(['indexes' => ['title' => ['index' => 'tit']]], 'title', 'x') === 'tit="x"', '05h the array form without a relation is ='); foreach (["dell'arte", 'and', 'not', 'Eco'] as $word) { $parsed = true; try { @@ -92,6 +95,18 @@ protected function fetchUrl(string $url): ?string $diag = '0info:srw/diagnostic/1/16Unsupported index'; $bad = (new SearchFixtureClient([$server], $diag))->searchServer($server, 'q', 5); $check($bad['error'] === 'Unsupported index' && $bad['records'] === [], '12 an SRU diagnostic is the server\'s error, not an exception'); +// The BnF answers a surrogate diagnostic inside recordData for a hit it +// cannot render ("erreur de traitement"): the other records are returned. +$surrogate = '1.21495' + . 'marcxchangexmlinfo:srw/diagnostic/1/131-20;erreur de traitement1' + . 'marcxchangexml' . $marcRecord('Le Petit Prince', '9782070612758', 'Saint-Exupéry, Antoine de') . '2' + . 'marcxchangexml' . $marcRecord('Il piccolo principe', '9788845292613', 'Saint-Exupéry, Antoine de') . '3' + . ''; +$partial = (new SearchFixtureClient([$server], $surrogate))->searchServer($server, 'q', 5); +$check($partial['error'] === null && $partial['total'] === 1495 && array_map(static fn ($r) => $r['data']['titolo'], $partial['records']) === ['Le Petit Prince', 'Il piccolo principe'], '12b a surrogate diagnostic inside recordData skips that record, the others are returned'); +$ownError = 'Invalid or missing API key'; +$denied = (new SearchFixtureClient([$server], $ownError))->searchServer($server, 'q', 5); +$check($denied['error'] === 'Invalid or missing API key' && $denied['records'] === [], '12c the our own endpoint answers with is the server\'s error'); $broken = (new SearchFixtureClient([$server], 'searchServer($server, 'q', 5); $check($broken['error'] !== null, '13 a response that is not XML is reported'); $invalid = (new SearchFixtureClient([], $sru))->searchServer(['url' => 'ftp://x'], 'q', 5); From e962ece4e2004e1d7a134e2e9c94b6783d19dc86 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:12:16 +0000 Subject: [PATCH 127/398] Copy cataloguing presets: BnF fuzzy ISBN, DNB any-index with "all" BnF: bib.isbn matches only the ISBN form the record carries (2070612759 found nothing, 9782070612758 did); bib.fuzzyIsbn, listed in the BnF explain, matches either. Checked live. DNB: woe="Kafka Prozess" returns 0, woe all "Kafka Prozess" 1029 (checked live). A preset may now map an index with the relation it needs ('any' => ['index' => 'woe', 'relation' => 'all']); SruClient::cqlFor() builds `index all "term"` for it and keeps `=` for every other mapping, including the plain string form every configured server uses. --- storage/plugins/z39-server/classes/CopyCataloging.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/storage/plugins/z39-server/classes/CopyCataloging.php b/storage/plugins/z39-server/classes/CopyCataloging.php index 3d0870977..6cc336a8d 100644 --- a/storage/plugins/z39-server/classes/CopyCataloging.php +++ b/storage/plugins/z39-server/classes/CopyCataloging.php @@ -40,14 +40,19 @@ final class CopyCataloging 'version' => '1.2', 'syntax' => 'unimarcxchange', 'quote_search_terms' => true, - 'indexes' => ['isbn' => 'bib.isbn', 'issn' => 'bib.issn', 'title' => 'bib.title', 'author' => 'bib.author', 'any' => 'bib.anywhere'], + // bib.fuzzyIsbn matches either form of the ISBN (bib.isbn only + // the one the record carries: "2070612759" finds nothing while + // "9782070612758" does). + 'indexes' => ['isbn' => 'bib.fuzzyIsbn', 'issn' => 'bib.issn', 'title' => 'bib.title', 'author' => 'bib.author', 'any' => 'bib.anywhere'], ], 'dnb' => [ 'name' => 'DNB - Deutsche Nationalbibliothek', 'url' => 'https://services.dnb.de/sru/dnb', 'version' => '1.1', 'syntax' => 'MARC21-xml', - 'indexes' => ['isbn' => 'num', 'issn' => 'num', 'title' => 'tit', 'author' => 'per', 'any' => 'woe'], + // woe="Kafka Prozess" finds nothing (an exact phrase in a word + // index); woe all "Kafka Prozess" finds every record with both. + 'indexes' => ['isbn' => 'num', 'issn' => 'num', 'title' => 'tit', 'author' => 'per', 'any' => ['index' => 'woe', 'relation' => 'all']], ], 'k10plus' => [ 'name' => 'K10plus (GBV)', From ca0ee0090e39f5c7f835650f9d84a2f3f8253f9f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:12:32 +0000 Subject: [PATCH 128/398] Calendar review: ICS events meet the form's limits, a long yearly row never closes every day, UTC ICS times read in the app timezone, update checks the id The ICS import stored every parsed event as it came, so a yearly event longer than a year became a closure of every day; each event now passes the same span check as the form (a year for yearly, ten years otherwise, end not before start) and the import message counts the discarded ones. A yearly row whose span is a year or more, however it got stored, is applied once as the interval it names instead of closing every day. A DATE-TIME ending in Z is converted to app.timezone before its day is taken (23:00Z on Apr 10 is Apr 11 in Rome). Updating a closure that no longer exists reports "Chiusura non trovata." instead of a success. --- .../Admin/LibraryCalendarController.php | 58 +++++++++++-- app/Support/LibraryCalendar.php | 87 +++++++++++++++---- 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 + tests/closures-calendar-admin.spec.js | 39 +++++++++ tests/library-calendar.unit.php | 42 +++++++++ 9 files changed, 204 insertions(+), 27 deletions(-) diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php index c043507ab..211f4ec59 100644 --- a/app/Controllers/Admin/LibraryCalendarController.php +++ b/app/Controllers/Admin/LibraryCalendarController.php @@ -112,6 +112,10 @@ public function update(Request $request, Response $response, \mysqli $db, int $i if ($guard = $this->guard($response)) { return $guard; } + if (!$this->closureExists($db, $id)) { + $_SESSION['error_message'] = __('Chiusura non trovata.'); + return $this->back($response); + } $row = $this->validatedClosure($db, (array) $request->getParsedBody()); if (is_string($row)) { $_SESSION['error_message'] = $row; @@ -207,7 +211,14 @@ public function importIcs(Request $request, Response $response, \mysqli $db): Re $sede = $this->branchFrom($db, $data['sede_id'] ?? null); $added = 0; $skipped = 0; + $rejected = 0; foreach (LibraryCalendar::parseIcs($ics) as $event) { + // The same limits as the form: a yearly event longer than a year + // would otherwise be stored as a closure of every day. + if ($this->spanError($event['start'], $event['end'], $event['yearly']) !== null) { + $rejected++; + continue; + } $row = [ 'start' => $event['start'], 'end' => $event['end'], @@ -223,7 +234,9 @@ public function importIcs(Request $request, Response $response, \mysqli $db): Re $added++; } $this->calendarChanged($db); - $_SESSION['success_message'] = __('File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.', $added, $skipped); + $_SESSION['success_message'] = $rejected > 0 + ? __('File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).', $added, $skipped, $rejected) + : __('File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.', $added, $skipped); return $this->back($response); } @@ -280,15 +293,9 @@ private function validatedClosure(\mysqli $db, array $data): array|string if (!DateHelper::isISODateFormat($start) || !DateHelper::isISODateFormat($end)) { return __('Data non valida: usa il formato AAAA-MM-GG.'); } - if ($end < $start) { - return __('La data di fine non può precedere la data di inizio'); - } - $span = (int) (new \DateTimeImmutable($start))->diff(new \DateTimeImmutable($end))->days; $yearly = !empty($data['ricorrente_annuale']); - if ($span > ($yearly ? 364 : 3660)) { - return $yearly - ? __('Una chiusura annuale non può durare più di un anno.') - : __('Una chiusura non può durare più di dieci anni.'); + if (($error = $this->spanError($start, $end, $yearly)) !== null) { + return $error; } $description = is_string($data['descrizione'] ?? null) ? trim($data['descrizione']) : ''; $description = mb_substr(preg_replace('/\s+/u', ' ', $description) ?? '', 0, 255); @@ -300,6 +307,39 @@ private function validatedClosure(\mysqli $db, array $data): array|string return ['start' => $start, 'end' => $end, 'description' => $description, 'yearly' => $yearly, 'sede' => $sede]; } + /** + * The limits every closure meets, from the form or from a file: end not + * before start, a yearly closure within a year, a one-off within ten. + * Null when the span is fine, otherwise the message for the operator. + */ + private function spanError(string $start, string $end, bool $yearly): ?string + { + if ($end < $start) { + return __('La data di fine non può precedere la data di inizio'); + } + $span = LibraryCalendar::spanDays($start, $end); + if ($span < 0) { + return __('Data non valida: usa il formato AAAA-MM-GG.'); + } + if ($yearly && $span > LibraryCalendar::MAX_YEARLY_SPAN_DAYS) { + return __('Una chiusura annuale non può durare più di un anno.'); + } + if (!$yearly && $span > LibraryCalendar::MAX_ONCE_SPAN_DAYS) { + return __('Una chiusura non può durare più di dieci anni.'); + } + return null; + } + + private function closureExists(\mysqli $db, int $id): bool + { + $stmt = $db->prepare('SELECT 1 FROM calendario_chiusure WHERE id = ? LIMIT 1'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $found = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $found; + } + private function branchFrom(\mysqli $db, mixed $raw): ?int { if (!is_scalar($raw) || !ctype_digit((string) $raw) || (int) $raw <= 0) { diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index ae1b306d9..222ee22bb 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -30,6 +30,12 @@ final class LibraryCalendar /** Upper bound on day-by-day scans, so an all-closed setup cannot loop forever. */ private const MAX_SCAN_DAYS = 1100; + /** Longest yearly closure (days between start and end): a year less a day. */ + public const MAX_YEARLY_SPAN_DAYS = 364; + + /** Longest one-off closure: ten years. */ + public const MAX_ONCE_SPAN_DAYS = 3660; + /** @var array */ private array $closedWeekdays = []; @@ -67,10 +73,16 @@ public function __construct(array $closedWeekdays = [], array $closures = []) [$start, $end] = [$end, $start]; } $sede = $row['sede_id'] ?? $row['sede'] ?? null; + // A yearly closure repeats its month-day span, which only makes + // sense for a span shorter than a year. A longer row (the form + // refuses one; an old import may have stored one) is applied once, + // as the interval it names, instead of closing every day. + $yearly = (bool) ($row['ricorrente_annuale'] ?? $row['yearly'] ?? false) + && self::spanDays($start, $end) <= self::MAX_YEARLY_SPAN_DAYS; $this->closures[] = [ 'start' => $start, 'end' => $end, - 'yearly' => (bool) ($row['ricorrente_annuale'] ?? $row['yearly'] ?? false), + 'yearly' => $yearly, 'sede' => ($sede === null || $sede === '' || (int) $sede <= 0) ? null : (int) $sede, ]; } @@ -392,12 +404,16 @@ public static function italianHolidays(int $year): array * Closed intervals from an iCalendar file: every VEVENT with a DTSTART. * All-day events have an exclusive DTEND (RFC 5545), so the last closed * day is the day before it; RRULE:FREQ=YEARLY makes the row recurring. - * Other recurrences are imported as their first occurrence only. + * Other recurrences are imported as their first occurrence only. A UTC + * DATE-TIME (trailing Z) is read in $timezone (app.timezone by default) + * before its day is taken. The spans are not validated here: the import + * applies the same limits as the form. * * @return list */ - public static function parseIcs(string $ics, int $maxEvents = 1000): array + public static function parseIcs(string $ics, int $maxEvents = 1000, ?string $timezone = null): array { + $timezone ??= self::appTimezone(); // Unfold continuation lines (RFC 5545 §3.1). $ics = str_replace(["\r\n", "\r"], "\n", $ics); $ics = preg_replace("/\n[ \t]/", '', $ics) ?? $ics; @@ -445,7 +461,7 @@ public static function parseIcs(string $ics, int $maxEvents = 1000): array $value = substr($line, $colon + 1); $name = explode(';', $nameAndParams)[0]; if ($name === 'DTSTART' || $name === 'DTEND') { - $date = self::icsDate($value); + $date = self::icsDate($value, $timezone); if ($date === null) { continue; } @@ -484,35 +500,70 @@ private static function weekday(string $ymd): int return $date === false ? -1 : (int) $date->format('w'); } + /** Days between two Y-m-d dates (0 for the same day); -1 when either is not a date. */ + public static function spanDays(string $start, string $end): int + { + $from = \DateTimeImmutable::createFromFormat('!Y-m-d', $start, new \DateTimeZone('UTC')); + $to = \DateTimeImmutable::createFromFormat('!Y-m-d', $end, new \DateTimeZone('UTC')); + if ($from === false || $to === false) { + return -1; + } + $days = $from->diff($to)->days; + return $days === false ? -1 : (int) $days; + } + /** * A yearly closure repeats its month-day span every year. A span that - * crosses New Year (12-24 → 01-06) wraps; a span longer than a year - * closes every day. + * crosses New Year (12-24 → 01-06) wraps. The constructor keeps only + * spans shorter than a year recurring, so $ymd is closed only when its + * month-day falls inside the span: no span closes every day. */ private static function recurringCovers(string $start, string $end, string $ymd, string $monthDay): bool { - if ((int) substr($end, 0, 4) - (int) substr($start, 0, 4) >= 2) { - return true; - } $from = substr($start, 5); $to = substr($end, 5); - $crossesYear = substr($end, 0, 4) !== substr($start, 0, 4); - if ($crossesYear) { - if ($to >= $from) { - return true; // more than a full year - } - return $monthDay >= $from || $monthDay <= $to; + if ($to < $from) { + return $monthDay >= $from || $monthDay <= $to; // wraps over New Year } return $monthDay >= $from && $monthDay <= $to; } - private static function icsDate(string $value): ?string + /** + * The day of an iCalendar DATE or DATE-TIME. A floating DATE-TIME and a + * DATE are taken as written; a UTC DATE-TIME (trailing Z) is converted + * to $timezone first, so 20260410T230000Z in Europe/Rome is 2026-04-11. + */ + private static function icsDate(string $value, string $timezone): ?string { - if (preg_match('/^(\d{4})(\d{2})(\d{2})/', trim($value), $m) !== 1) { + $value = trim($value); + if (preg_match('/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z?))?/i', $value, $m) !== 1) { return null; } $date = "{$m[1]}-{$m[2]}-{$m[3]}"; - return DateHelper::isISODateFormat($date) ? $date : null; + if (!DateHelper::isISODateFormat($date)) { + return null; + } + if (($m[7] ?? '') === '') { + return $date; + } + try { + $utc = new \DateTimeImmutable(sprintf('%sT%s:%s:%s', $date, $m[4], $m[5], $m[6] !== '' ? $m[6] : '00'), new \DateTimeZone('UTC')); + return $utc->setTimezone(new \DateTimeZone($timezone))->format('Y-m-d'); + } catch (\Throwable $e) { + return $date; + } + } + + /** app.timezone, with the same fallback as DateHelper::today(). */ + private static function appTimezone(): string + { + try { + $tz = (string) ConfigStore::get('app.timezone', 'Europe/Rome'); + new \DateTimeZone($tz); + return $tz; + } catch (\Throwable $e) { + return 'Europe/Rome'; + } } /** diff --git a/locale/da_DK.json b/locale/da_DK.json index afd0939f0..27c603aff 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8916,6 +8916,7 @@ "Festività nazionali italiane": "Italienske nationale helligdage", "File ICS": "ICS-fil", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "ICS-fil importeret: %1$d lukninger tilføjet, %2$d fandtes allerede.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "ICS-fil importeret: %1$d lukninger tilføjet, %2$d fandtes allerede, %3$d afvist, fordi de er længere end grænsen (et år hvis årlige, ellers ti år).", "Gestisci il calendario chiusure": "Administrér lukkekalenderen", "Giorni di chiusura e festività": "Lukkedage og helligdage", "Giorni di chiusura settimanale salvati.": "Ugentlige lukkedage er gemt.", diff --git a/locale/de_DE.json b/locale/de_DE.json index f4911614f..c04270293 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8916,6 +8916,7 @@ "Festività nazionali italiane": "Italienische gesetzliche Feiertage", "File ICS": "ICS-Datei", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "ICS-Datei importiert: %1$d Schließungen hinzugefügt, %2$d bereits vorhanden.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "ICS-Datei importiert: %1$d Schließungen hinzugefügt, %2$d bereits vorhanden, %3$d verworfen, weil länger als das Limit (ein Jahr bei jährlichen, sonst zehn Jahre).", "Gestisci il calendario chiusure": "Schließtage-Kalender verwalten", "Giorni di chiusura e festività": "Schließtage und Feiertage", "Giorni di chiusura settimanale salvati.": "Wöchentliche Schließtage gespeichert.", diff --git a/locale/en_US.json b/locale/en_US.json index fb9974685..59f593fb5 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8916,6 +8916,7 @@ "Festività nazionali italiane": "Italian national holidays", "File ICS": "ICS file", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "ICS file imported: %1$d closures added, %2$d already present.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "ICS file imported: %1$d closures added, %2$d already present, %3$d discarded as longer than the limit (one year if yearly, ten years otherwise).", "Gestisci il calendario chiusure": "Manage the closures calendar", "Giorni di chiusura e festività": "Closed days and holidays", "Giorni di chiusura settimanale salvati.": "Weekly closed days saved.", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index b9ec4bd45..a128829d9 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8916,6 +8916,7 @@ "Festività nazionali italiane": "Jours fériés nationaux italiens", "File ICS": "Fichier ICS", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "Fichier ICS importé : %1$d fermetures ajoutées, %2$d déjà présentes.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "Fichier ICS importé : %1$d fermetures ajoutées, %2$d déjà présentes, %3$d écartées car plus longues que la limite (un an si annuelles, dix ans sinon).", "Gestisci il calendario chiusure": "Gérer le calendrier des fermetures", "Giorni di chiusura e festività": "Jours de fermeture et fériés", "Giorni di chiusura settimanale salvati.": "Jours de fermeture hebdomadaire enregistrés.", diff --git a/locale/it_IT.json b/locale/it_IT.json index b6b130e5a..4fb40824b 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8916,6 +8916,7 @@ "Festività nazionali italiane": "Festività nazionali italiane", "File ICS": "File ICS", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).", "Gestisci il calendario chiusure": "Gestisci il calendario chiusure", "Giorni di chiusura e festività": "Giorni di chiusura e festività", "Giorni di chiusura settimanale salvati.": "Giorni di chiusura settimanale salvati.", diff --git a/tests/closures-calendar-admin.spec.js b/tests/closures-calendar-admin.spec.js index 13a7cdf4c..aab642e16 100644 --- a/tests/closures-calendar-admin.spec.js +++ b/tests/closures-calendar-admin.spec.js @@ -160,6 +160,24 @@ test.describe('Closures calendar — admin page', () => { expect(rowsFor(`${TAG} inverted`)).toBe('0'); }); + test('CRUD 7: updating a closure that no longer exists reports "non trovata" and stores nothing', async () => { + const missingId = parseInt(db('SELECT COALESCE(MAX(id), 0) + 100000 FROM calendario_chiusure'), 10); + const before = db('SELECT COUNT(*) FROM calendario_chiusure'); + await page.goto(`${BASE}/admin/calendar`); + await page.fill('#closure_start', `${farYear}-06-20`); + await page.fill('#closure_description', `${TAG} ghost`); + // Same form, same CSRF token, the update route of an id that does not exist. + await page.evaluate((id) => { + const form = document.querySelector('#save-closure').closest('form'); + form.action = form.action.replace(/\/admin\/calendar\/closures.*$/, `/admin/calendar/closures/${id}/update`); + }, missingId); + await submitAndLand(page, '#save-closure'); + await expect(page.locator('body')).toContainText(/Chiusura non trovata/); + await expect(page.locator('body')).not.toContainText(/Chiusura aggiornata/); + expect(db('SELECT COUNT(*) FROM calendario_chiusure')).toBe(before); + expect(rowsFor(`${TAG} ghost`)).toBe('0'); + }); + test('CRUD 6: a closure for one branch is stored with that branch', async () => { await addClosure(page, { start: `${farYear}-07-01`, description: `${TAG} branch`, branch: String(sedeId) }); expect(db(`SELECT sede_id FROM calendario_chiusure WHERE descrizione='${TAG} branch'`)).toBe(String(sedeId)); @@ -330,6 +348,27 @@ test.describe('Closures calendar — admin page', () => { expect(db(`SELECT data_inizio FROM calendario_chiusure WHERE descrizione='${TAG} caffé'`)).toBe(`${farYear}-06-13`); }); + test('ICS 8: a yearly event longer than a year is discarded with a reason; the other events and the open days stay', async () => { + // A ten-year RRULE:FREQ=YEARLY event, stored as a yearly row, would have + // closed every day of every year: the import applies the form's limits. + const decadeIcs = [ + 'BEGIN:VCALENDAR', 'VERSION:2.0', + 'BEGIN:VEVENT', 'UID:decade@e2e', `DTSTART;VALUE=DATE:${farYear}0101`, `DTEND;VALUE=DATE:${farYear + 10}0101`, 'RRULE:FREQ=YEARLY', `SUMMARY:${TAG} decennio`, 'END:VEVENT', + 'BEGIN:VEVENT', 'UID:fine@e2e', `DTSTART;VALUE=DATE:${farYear}0921`, `SUMMARY:${TAG} ics valido`, 'END:VEVENT', + 'END:VCALENDAR', '', + ].join('\r\n'); + await importIcs(decadeIcs); + await expect(page.locator('body')).toContainText(/1 chiusure aggiunte, 0 già presenti, 1 scartate perché più lunghe del limite/); + expect(rowsFor(`${TAG} decennio`)).toBe('0'); + expect(rowsFor(`${TAG} ics valido`)).toBe('1'); + // Weekdays of the next years are still open: nothing closed every day. + await page.goto(`${BASE}/admin/calendar?month=${farYear + 2}-09`); + await expect(page.locator(`[data-calendar-day="${farYear + 2}-09-21"]`)).toHaveAttribute('data-closed', '0'); + await expect(page.locator(`[data-calendar-day="${farYear + 2}-09-22"]`)).toHaveAttribute('data-closed', '0'); + await page.goto(`${BASE}/admin/calendar?month=${farYear}-09`); + await expect(page.locator(`[data-calendar-day="${farYear}-09-21"]`)).toHaveAttribute('data-closed', '1'); + }); + test('ICS 5: a file that is not a calendar is refused', async () => { const before = db('SELECT COUNT(*) FROM calendario_chiusure'); await importIcs('this is not a calendar'); diff --git a/tests/library-calendar.unit.php b/tests/library-calendar.unit.php index 88872a6b7..ce7f6d852 100644 --- a/tests/library-calendar.unit.php +++ b/tests/library-calendar.unit.php @@ -79,6 +79,27 @@ $eq($leap->isOpen('2028-02-29'), false, 'a yearly Feb 29 closes Feb 29 in leap years'); $eq($leap->isOpen('2027-03-01'), true, 'and does not close Mar 1 in other years'); +// A yearly span of a year or more cannot repeat: it is applied once, as the +// interval stored, and never closes a day outside it. +$eq(LibraryCalendar::spanDays('2026-01-01', '2026-01-01'), 0, 'spanDays: same day is 0'); +$eq(LibraryCalendar::spanDays('2026-01-01', '2026-12-31'), 364, 'spanDays: a year less a day is 364'); +$eq(LibraryCalendar::spanDays('2026-01-01', 'nope'), -1, 'spanDays: not a date is -1'); +$eq(LibraryCalendar::MAX_YEARLY_SPAN_DAYS, 364, 'the yearly limit is a year less a day'); +$decade = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2035-12-31', 'yearly' => true]]); +$eq($decade->isOpen('2030-06-15'), false, 'a ten-year yearly row closes a day inside its interval'); +$eq($decade->isOpen('2040-06-15'), true, 'and leaves an arbitrary later date open (it does not close every day)'); +$eq($decade->isOpen('2025-06-15'), true, 'nor an earlier one'); +$twoYears = new LibraryCalendar([], [['start' => '2020-03-01', 'end' => '2022-02-28', 'yearly' => true]]); +$eq($twoYears->isOpen('2021-07-01'), false, 'a two-year yearly row closes its own days'); +$eq($twoYears->isOpen('2026-07-01'), true, 'and not the same day years later'); +$fullYear = new LibraryCalendar([], [['start' => '2026-06-01', 'end' => '2027-05-31', 'yearly' => true]]); +$eq($fullYear->isOpen('2030-12-25'), false, 'a 364-day yearly span (Jun 1 - May 31) still recurs and wraps'); +$eq($fullYear->isOpen('2030-05-31'), false, 'its last month-day is closed'); +$eq($fullYear->hasRules(), true, 'it is a rule'); +$oneDayMore = new LibraryCalendar([], [['start' => '2026-06-01', 'end' => '2027-06-01', 'yearly' => true]]); +$eq($oneDayMore->isOpen('2030-12-25'), true, 'a 365-day yearly span does not recur'); +$eq($oneDayMore->isOpen('2026-12-25'), false, 'but closes its own interval'); + // ── Branch-specific vs all branches ─────────────────────────────────────── $branches = new LibraryCalendar([], [ ['start' => '2026-11-10', 'end' => '2026-11-10', 'sede_id' => 2], @@ -144,6 +165,27 @@ $eq($parsed[1]['yearly'] && $parsed[1]['start'] === '2026-08-15' && $parsed[1]['end'] === '2026-08-15', true, 'ICS: RRULE FREQ=YEARLY recurs, no DTEND = one day'); $eq([$parsed[2]['start'], $parsed[2]['end']], ['2026-11-02', '2026-11-02'], 'ICS: a timed event closes its day'); $eq(LibraryCalendar::parseIcs('not a calendar'), [], 'ICS: garbage yields nothing'); + +// A UTC DATE-TIME is read in the app timezone before its day is taken; a +// floating DATE-TIME and a DATE are taken as written. +$utcIcs = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260410T230000Z\nDTEND:20260411T000000Z\nSUMMARY:utc\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20260410T230000\nSUMMARY:floating\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20260410\nSUMMARY:date\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20260411T020000Z\nSUMMARY:early\nEND:VEVENT\nEND:VCALENDAR\n"; +$rome = LibraryCalendar::parseIcs($utcIcs, 1000, 'Europe/Rome'); +$eq([$rome[0]['start'], $rome[0]['end']], ['2026-04-11', '2026-04-11'], 'ICS: 20260410T230000Z is Apr 11 in Europe/Rome'); +$eq([$rome[1]['start'], $rome[2]['start']], ['2026-04-10', '2026-04-10'], 'ICS: a floating DATE-TIME and a DATE keep their day'); +$eq($rome[3]['start'], '2026-04-11', 'ICS: 20260411T020000Z stays Apr 11 in Europe/Rome'); +$newYork = LibraryCalendar::parseIcs($utcIcs, 1000, 'America/New_York'); +$eq($newYork[0]['start'], '2026-04-10', 'ICS: 20260410T230000Z is still Apr 10 in New York'); +$eq($newYork[3]['start'], '2026-04-10', 'ICS: 20260411T020000Z is Apr 10 in New York'); +$eq(LibraryCalendar::parseIcs($utcIcs, 1000, 'UTC')[0]['start'], '2026-04-10', 'ICS: in UTC the day is as written'); + +// A ten-year yearly event is parsed as such; the import refuses it with the +// form's limits (E2E: closures-calendar-admin.spec.js, ICS 8). +$tenYears = LibraryCalendar::parseIcs("BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20260101\nDTEND;VALUE=DATE:20360101\nRRULE:FREQ=YEARLY\nSUMMARY:decade\nEND:VEVENT\nEND:VCALENDAR\n"); +$eq([$tenYears[0]['start'], $tenYears[0]['end'], $tenYears[0]['yearly']], ['2026-01-01', '2035-12-31', true], 'ICS: a ten-year yearly event is parsed as stored'); +$eq(LibraryCalendar::spanDays($tenYears[0]['start'], $tenYears[0]['end']) > LibraryCalendar::MAX_YEARLY_SPAN_DAYS, true, 'ICS: and exceeds the yearly limit the import applies'); $eq(count(LibraryCalendar::parseIcs(str_repeat("BEGIN:VEVENT\nDTSTART;VALUE=DATE:20260101\nEND:VEVENT\n", 50), 10)), 10, 'ICS: the event cap holds'); echo ($failures === 0 ? 'SUCCESS' : 'FAILURE') . " {$checks} checks, {$failures} failed\n"; From df03ae228acf8c640f08b7390f93332e0079b045 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:12:32 +0000 Subject: [PATCH 129/398] LibraryCalendar::branchOfCopy() asks the database once per copy The due-date and pickup funnels, the renewal cap and the NCIP renewal all look up the copy's branch, and a request may do so several times for the same copy. The answer now lives on the per-connection calendar that forDb() holds, so reset() discards it with the calendar. --- app/Support/LibraryCalendar.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index 222ee22bb..9fe2e1445 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -45,6 +45,9 @@ final class LibraryCalendar /** @var array per-connection cache, keyed by spl_object_id */ private static array $instances = []; + /** @var array branchOfCopy() memo: copy id => sede_id (null when none) */ + private array $copyBranches = []; + /** * @param list|array $closedWeekdays 0 = Sunday … 6 = Saturday * @param list $closures @@ -609,12 +612,25 @@ private static function loadClosures(\mysqli $db): array } } - /** The branch (copie.sede_id) of a copy, null when unknown or none. */ + /** + * The branch (copie.sede_id) of a copy, null when unknown or none. One + * query per copy and connection: the answer is kept on the forDb() + * calendar, which reset() discards with it. + */ public static function branchOfCopy(\mysqli $db, ?int $copiaId): ?int { if ($copiaId === null || $copiaId <= 0) { return null; } + $calendar = self::forDb($db); + if (array_key_exists($copiaId, $calendar->copyBranches)) { + return $calendar->copyBranches[$copiaId]; + } + return $calendar->copyBranches[$copiaId] = self::queryBranchOfCopy($db, $copiaId); + } + + private static function queryBranchOfCopy(\mysqli $db, int $copiaId): ?int + { try { $stmt = $db->prepare('SELECT sede_id FROM copie WHERE id = ?'); if ($stmt === false) { From f8d069be96b311cbaf1ab6d6c2f7f1236767377a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:05:28 +0000 Subject: [PATCH 130/398] Allowlist the 28 historic gitleaks findings that are examples The "Scan complete Git history with Gitleaks" step passes on pull_request and push events, which only scan the changed range, but fails on every scheduled and manual run: the full-history scan reports 28 findings in commits from 2025, all documentation placeholders (api-book-scraper guide and README, the curl example on the advanced settings tab), array literals and French translation strings matched by the generic rule, and the RFC 8291 Appendix A web-push test vectors. None is a credential. Record their fingerprints in .gitleaksignore so the weekly scan on main is green again and a real leak would be visible. (cherry picked from commit a844ffbe7bf254b2d1b63d8988b0ec7b4bda1b65) --- .gitleaksignore | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 000000000..e22b9b419 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,39 @@ +# Gitleaks fingerprints (commit:path:rule:line) for findings in the full-history +# scan that are documentation examples or published test vectors, not secrets. +# The pull_request and push runs only scan the changed range; the scheduled and +# manual runs scan every commit and were failing on these 28 entries. +# +# api-book-scraper docs and views: placeholder keys in curl examples +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:storage/plugins/api-book-scraper/README.md:stripe-access-token:43 +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:storage/plugins/api-book-scraper/SERVER_IMPLEMENTATION_GUIDE.md:stripe-access-token:89 +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:storage/plugins/api-book-scraper/SERVER_IMPLEMENTATION_GUIDE.md:stripe-access-token:113 +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:storage/plugins/api-book-scraper/SERVER_IMPLEMENTATION_GUIDE.md:curl-auth-header:1034 +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:storage/plugins/api-book-scraper/SERVER_IMPLEMENTATION_GUIDE.md:curl-auth-header:1039 +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:storage/plugins/api-book-scraper/SERVER_IMPLEMENTATION_GUIDE.md:curl-auth-header:1043 +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:app/Views/admin/plugins.php:stripe-access-token:426 +84f14515c448bbadf6ee55cec463ba858a32faa4:app/Views/admin/plugins.php:stripe-access-token:474 +c9453c46fe2225531790a2edc90c25b1cb667f05:app/Views/admin/plugins.php:stripe-access-token:474 +# Settings page and admin docs: "your-api-key-here" style curl examples +16ba645f1f990600f69d3842ce3bcd319fbe15f0:app/Views/settings/advanced-tab.php:curl-auth-header:607 +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:app/Views/settings/advanced-tab.php:curl-auth-header:684 +e851035fbe920538a6bf8fc5980312891e4eb878:admin/configurazione.md:curl-auth-header:546 +# z39-server plugin: array literals matched by the generic rule +412253a18455260a5a793b04df36acc23712e7d2:storage/plugins/z39-server/Z39ServerPlugin.php:generic-api-key:122 +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:storage/plugins/z39-server/Z39ServerPlugin.php:generic-api-key:126 +b868a6b5d2180172b101171ac9eb98ae3e65ce5f:storage/plugins/z39-server/Z39ServerPlugin.php:generic-api-key:169 +# French translation strings matched by the generic rule +ad01c97eb68aefad0c1067adfd8377d8f882fa89:locale/fr_FR.json:generic-api-key:2861 +3511b34a1e45f1f59a02cd2ebedf36f7dfe32f3e:locale/fr_FR.json:generic-api-key:2867 +8cfb76efc76183f5fdffb702d682238b2b5dcaac:locale/fr_FR.json:generic-api-key:2871 +320d3a3df0475fbe34d765e50b243499bd5b1414:locale/fr_FR.json:generic-api-key:2874 +9d70038db598cccc7ab2f2f5941e1ff3a66a75cb:locale/fr_FR.json:generic-api-key:4439 +# Update scripts: option names matched by the generic rule +03a8f3e1f75140af13b065d9b52b1d41ebf63e96:scripts/manual-update.php:generic-api-key:443 +5e4ea3f42c694595559f9122932a0eb9fec023e7:scripts/manual-upgrade.php:generic-api-key:569 +f0b1f8b5d473119235892e5dcec8860937eb8f67:test-updater/manual-update.php:generic-api-key:443 +# Tests: RFC 8291 Appendix A web-push vectors and fixture strings +abbe641a9221c29f1ed009bde2873835b53c20f7:tests/mobile-webpush-encryption.unit.php:private-key:25 +abbe641a9221c29f1ed009bde2873835b53c20f7:tests/mobile-webpush-encryption.unit.php:generic-api-key:34 +ac4fcabc00953f3fd5f5c9d59fe5fb5da7f744cb:tests/schema-integrity.spec.js:generic-api-key:91 +5eb52517f88d1ea3fadfffc1f92af21dbd9a1da2:tests/schema-integrity.spec.js:generic-api-key:117 +8cfea8b8d47a8df9efef4d203ca96d8697203908:tests/security-hardening.spec.js:generic-api-key:37 From 5c4b701fa45b870db45663e021ced9d580367c84 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:12:46 +0000 Subject: [PATCH 131/398] Stocktake page: a newer refresh cancels the one in flight refresh() is started by the 5-second poll, by every recorded scan and by the "only my scans" toggle. The poll checked `busy` but not whether a refresh was already running, and the toggle started another one without cancelling, so a slower, older answer could land after a newer one and put stale counters and rows on the page. Each refresh now carries an AbortController; starting one aborts the previous, and an answer whose body was already read when it was superseded is dropped. AbortError is ignored in the catch: the newer request is the one that counts. --- app/Views/inventario/show.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/Views/inventario/show.php b/app/Views/inventario/show.php index ad9d2ae25..554233835 100644 --- a/app/Views/inventario/show.php +++ b/app/Views/inventario/show.php @@ -393,6 +393,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te var body = document.getElementById('inventory-scans-body'); var busy = false; var queue = []; + var refreshing = null; // AbortController of the refresh in flight, if any var tones = { trovata: [880, 0.12], fuori_posto: [520, 0.25], in_prestito: [660, 0.25], fuori_ambito: [440, 0.3], duplicata: [330, 0.2], sconosciuta: [220, 0.45] }; var feedbackClasses = { trovata: 'bg-green-50 border-green-200 text-green-800', @@ -465,9 +466,16 @@ function renderScans(scans) { } function refresh() { - return fetch(config.stateUrl + (config.mine ? '?mine=1' : ''), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + // One refresh at a time: a poll, a scan and the "only mine" toggle each + // start one, and an older answer must not overwrite a newer state. + if (refreshing) { refreshing.abort(); } + var controller = new AbortController(); + refreshing = controller; + return fetch(config.stateUrl + (config.mine ? '?mine=1' : ''), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin', signal: controller.signal }) .then(function (r) { return r.json(); }) .then(function (data) { + if (refreshing !== controller) { return; } // superseded after its body was read + refreshing = null; if (!data || !data.success) { return; } setCounters(data.summary); renderScans(data.scans || []); @@ -476,7 +484,11 @@ function refresh() { window.location.assign(config.pageUrl); } }) - .catch(function () {}); + .catch(function (e) { + if (refreshing === controller) { refreshing = null; } + // An aborted refresh was replaced by a newer one; a failed one is retried by the next poll. + if (e && e.name === 'AbortError') { return; } + }); } function send(code) { From e589fa30a2f0bdae74a88167de7199b58e2238c0 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:12:56 +0000 Subject: [PATCH 132/398] Availability counter excludes the consultation-only copies DataIntegrity recalculated libri.copie_disponibili from every copy on the shelf, consultation copies included, while CapacityService, the allocators and the reservation endpoint count lendable copies only. A title with a consultation copy on the shelf and its lendable copy out showed "Richiedi prestito" and passed the catalogue "available" filter although no request could succeed. Both recalculation paths (global and per book) now apply CirculationPolicy::lendableCopySql() to the counter and to the derived libri.stato; copie_totali keeps counting the physical copies in circulation, consultation ones included, since it is the published holdings number. Creation defaults (BookRepository, CSV and LibraryThing imports) are left alone: new copies are never consultation copies. The copy toggle already recalculates the title, and upgrades run the Updater's post-migration recalculation, so nothing more is needed for existing installations. E2E: a mixed title offers "Prenota quando disponibile" while the lendable copy is out. Unit: the recalculation reports 1/2, 0/2 and the consultation-only title as not lendable. --- CHANGELOG.md | 2 +- app/Support/DataIntegrity.php | 20 +++++++++-- tests/circulation-consultation.spec.js | 20 +++++++++-- .../circulation-queue-material-rules.unit.php | 33 ++++++++++++++++++- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78659a8bf..3fdbb9ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **One source for the circulation rules.** Loan duration, request window, renewals and the active-loan limit were read straight from the loans settings in about twenty places (staff desk, reader requests on the website and in the app, approval, queue promotion, renewals, NCIP). They now all go through `App\Services\CirculationPolicy`, which returns immutable `UserPolicy`/`LoanPolicy` objects, and every due date is computed by `CirculationPolicy::dueDateFor()`. With only the global settings the numbers are the same as before (a unit test compares every old read site over a grid of stored values); a CI step rejects any new direct read of those settings. - **Patron types** (Settings → Circulation). Every reader belongs to a patron type (child, student, teacher…) that decides the circulation rules; the role (`tipo_utente`) keeps deciding only the permissions. A type can set the total active-loan limit, loan duration, longest request, renewals and days per renewal, whether reservations are allowed and how many, and the card duration; an empty field inherits the global loans setting. Types are created, edited, ordered and deleted from the new tab, one of them is the default for patrons without a type. The user forms have a "Tipologia lettore" select, the reader page shows the effective rules, the user list filters by type and the CSV export has the column. Existing users are not migrated: everyone starts on the default "Standard" type, which inherits everything. - **Rules matrix patron type × material** (`libri.tipo_media`). A cell can make a material consultation only, cap the loans of that material on top of the total limit, and set its duration, renewals and days per renewal; the matrix shows the inherited value of every empty cell in grey. The book page's renew button and its confirmation say the real renewal length of the loan instead of a fixed "+14 giorni". Per field, the first value wins among rule (type, material) → rule (type, every material) → rule (every type, material) → patron type → global settings. -- **Consultation-only copies.** A single copy can be kept for in-house consultation from the book page (`copie.solo_consultazione`): it is never allocated to a loan nor counted as lendable capacity, and a title whose every copy is kept for consultation is consultation only. The book page shows "Solo consultazione" instead of the request button. +- **Consultation-only copies.** A single copy can be kept for in-house consultation from the book page (`copie.solo_consultazione`): it is never allocated to a loan nor counted as lendable capacity, and a title whose every copy is kept for consultation is consultation only. The book page shows "Solo consultazione" instead of the request button. The availability counter `libri.copie_disponibili` (book page button and badge, catalogue "available" filter, OPAC and mobile availability) is recalculated without the consultation copies, so a title whose only copy on the shelf is a consultation copy offers "Prenota quando disponibile", not "Richiedi prestito"; `copie_totali` keeps counting every physical copy in circulation. Existing installations get the counters recalculated by the upgrade (the Updater re-derives availability after the migrations) or from Maintenance → "Ricalcola disponibilità". - **The same rules on every channel.** Staff desk, reader requests, approval, queue promotion, renewals, the Mobile API and NCIP all refuse with the same codes: `media_limit_reached`, `not_loanable`, `reservations_disabled`, `reservation_limit_reached` (Mobile API `CirculationOutcome`, NCIP *Item Does Not Circulate* / *Maximum Check Outs Exceeded*). `GET /api/v1/catalog/books/{id}/availability` gains `loanable` and `reservations_enabled`. - **Reservation limits per patron type.** A type without reservations does not see "reserve when available" on the book page and a request that would join the waitlist is refused (`reservations_disabled`); `max_prenotazioni` counts the active queue reservations plus the loan requests still awaiting approval (`reservation_limit_reached`). Website, app, NCIP RequestItem and the admin reservation form apply them; the admin form can override them with an explicit "Ignora i limiti di prenotazione" tick (`override_limits: true` from the staff app). - **Card duration of the patron type.** When the type sets a card duration, the card expiry is computed on creation and edit ("Calcola la scadenza dalla durata tessera", on by default when creating), on self-registration and on registration approval; the date can still be set by hand. The reader page has a **"Rinnova tessera"** button: expiry = today + the type's duration (one year when the type sets none), an expired account becomes active again, and the renewal is in the activity log. diff --git a/app/Support/DataIntegrity.php b/app/Support/DataIntegrity.php index d8c5dec3e..094b206b0 100644 --- a/app/Support/DataIntegrity.php +++ b/app/Support/DataIntegrity.php @@ -26,6 +26,9 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): // stored dates are app-tz, so CURDATE() (DB session tz) drifts by a day in the window // around local midnight. Validated Y-m-d string → safe to interpolate. $today = \App\Support\DateHelper::today(); + // Consultation-only copies are never lendable capacity: the public + // counter excludes them like CapacityService and the allocators do. + $lendable = (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c'); try { if (!$insideTransaction) { @@ -74,6 +77,10 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): // - Include copie 'disponibile' e 'prenotato' (fisicamente in biblioteca) // - Esclude quelle con prestiti attivi già iniziati (in_corso, in_ritardo, da_ritirare, prenotato con data <= oggi) // - Sottrae le prenotazioni attive che coprono la data odierna (slot-level) + // - Esclude le copie di sola consultazione (copie.solo_consultazione): non sono + // capacità prestabile (CapacityService, allocatori), quindi non contano come + // disponibili e non rendono 'disponibile' lo stato del libro. copie_totali + // resta il numero di copie fisiche in circolazione, consultazione compresa. // Note: 'da_ritirare' conta come slot occupato anche se la copia fisica è ancora in biblioteca $stmt = $this->db->prepare(" UPDATE libri l @@ -91,6 +98,7 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): ) WHERE c.libro_id = l.id AND c.stato IN ('disponibile', 'prenotato') + AND {$lendable} AND p.id IS NULL ) - ( SELECT COUNT(*) @@ -124,6 +132,7 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): ) WHERE c.libro_id = l.id AND c.stato IN ('disponibile', 'prenotato') + AND {$lendable} AND p.id IS NULL ) - ( SELECT COUNT(*) @@ -139,7 +148,7 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = l.id AND c.stato = 'prestato') > 0 THEN 'prestato' WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = l.id AND c.stato = 'prenotato') > 0 THEN 'prenotato' -- Copia in biblioteca ma assorbita da una prenotazione attiva → riservata (A1) - WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = l.id AND c.stato IN ('disponibile', 'prenotato')) > 0 THEN 'prenotato' + WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = l.id AND c.stato IN ('disponibile', 'prenotato') AND {$lendable}) > 0 THEN 'prenotato' -- Il libro HA copie ma nessuna è circolante (tutte in manutenzione/ -- in_restauro/perso/danneggiato/in_trasferimento) e nessuna è -- prestata/prenotata → non è disponibile. Senza questo ramo lo stato @@ -306,6 +315,7 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction // App-timezone "today" (see recalculateAllBookAvailability) — interpolated in place of // the DB CURDATE() so the availability comparisons don't drift around local midnight. $today = \App\Support\DateHelper::today(); + $lendable = (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c'); try { if (!$insideTransaction) { $this->db->begin_transaction(); @@ -363,6 +373,10 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction // - Include copie 'disponibile' e 'prenotato' (fisicamente in biblioteca) // - Esclude quelle con prestiti attivi già iniziati (in_corso, in_ritardo, da_ritirare, prenotato con data <= oggi) // - Sottrae le prenotazioni attive che coprono la data odierna (slot-level) + // - Esclude le copie di sola consultazione (copie.solo_consultazione): non sono + // capacità prestabile (CapacityService, allocatori), quindi non contano come + // disponibili e non rendono 'disponibile' lo stato del libro. copie_totali + // resta il numero di copie fisiche in circolazione, consultazione compresa. // Note: 'da_ritirare' conta come slot occupato anche se la copia fisica è ancora in biblioteca $stmt = $this->db->prepare(" UPDATE libri l @@ -380,6 +394,7 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction ) WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND {$lendable} AND p.id IS NULL ) - ( SELECT COUNT(*) @@ -413,6 +428,7 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction ) WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND {$lendable} AND p.id IS NULL ) - ( SELECT COUNT(*) @@ -430,7 +446,7 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction -- Copia fisicamente in biblioteca ma assorbita da una prenotazione attiva: -- è riservata, non 'disponibile' né stantia (A1). Evita di lasciare il libro -- bloccato su uno stato precedente (es. 'prestato') dopo la restituzione. - WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato')) > 0 THEN 'prenotato' + WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') AND {$lendable}) > 0 THEN 'prenotato' -- Come nel ricalcolo globale: copie presenti ma tutte fuori -- circolazione e nessuna prestata/prenotata → 'non_disponibile'. WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = ?) > 0 THEN 'non_disponibile' diff --git a/tests/circulation-consultation.spec.js b/tests/circulation-consultation.spec.js index 5e8b573a8..e17eb0d7e 100644 --- a/tests/circulation-consultation.spec.js +++ b/tests/circulation-consultation.spec.js @@ -104,7 +104,7 @@ test.describe.serial('Consultation-only material (matrix rule)', () => { test.describe.serial('Consultation-only copy (single copy flag)', () => { /** @type {import('@playwright/test').Page} */ let admin; /** @type {import('@playwright/test').Page} */ let reader; - let ctxA; let ctxR; let patron; let title; + let ctxA; let ctxR; let patron; let viewer; let title; const errors = []; test.beforeAll(async ({ browser }) => { @@ -113,12 +113,13 @@ test.describe.serial('Consultation-only copy (single copy flag)', () => { for (const p of [admin, reader]) p.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); await F.loginAsAdmin(admin); patron = F.createPatron(`CSC${RUN}`); + viewer = F.createPatron(`CSW${RUN}`); title = F.createTitle(`${RUN} TWO`, 'libro', 2); }); test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); test.afterAll(async () => { F.deleteTitles([title?.id]); - F.deletePatrons([patron?.id]); + F.deletePatrons([patron?.id, viewer?.id]); await ctxA?.close(); await ctxR?.close(); }); @@ -147,6 +148,21 @@ test.describe.serial('Consultation-only copy (single copy flag)', () => { expect(F.dbInt(`SELECT copia_id FROM prestiti WHERE utente_id=${patron.id} AND attivo=1 ORDER BY id DESC LIMIT 1`)).toBe(title.copyIds[1]); }); + test('with the lendable copy out, the consultation copy does not count: the reader is offered a reservation, not a loan', async () => { + // libri.copie_disponibili is the counter behind the book page button and + // the catalogue "available" filter: the consultation copy on the shelf + // must not make the title look lendable while the other copy is out. + expect(F.dbInt(`SELECT copie_disponibili FROM libri WHERE id=${title.id}`)).toBe(0); + expect(F.dbInt(`SELECT copie_totali FROM libri WHERE id=${title.id}`)).toBe(2); + await F.loginAs(reader, viewer.email, viewer.password); + await reader.goto(`${F.BASE}/libro/${title.id}`); + await expect(reader.locator('#consultation-only')).toHaveCount(0); + const button = reader.locator('#btn-request-loan'); + await expect(button).toBeVisible(); + await expect(button).toContainText(/Prenota quando disponibile|Reserve when available/i); + await expect(button).not.toContainText(/Richiedi prestito|Request loan/i); + }); + test('a copy held by a loan cannot be reserved for consultation', async () => { await toggleCopy(title.copyIds[1]); expect(F.dbInt(`SELECT solo_consultazione FROM copie WHERE id=${title.copyIds[1]}`)).toBe(0); diff --git a/tests/circulation-queue-material-rules.unit.php b/tests/circulation-queue-material-rules.unit.php index dc2f6f62d..dd633686e 100644 --- a/tests/circulation-queue-material-rules.unit.php +++ b/tests/circulation-queue-material-rules.unit.php @@ -8,7 +8,9 @@ * the patron type): the next patron in the queue is promoted, and the * refused reservation stays active without holding capacity; * - PatronTypeAssignment::fromSubmission() refuses a deactivated patron - * type, unless it is the one the user already has. + * type, unless it is the one the user already has; + * - DataIntegrity recalculates libri.copie_disponibili without the + * consultation-only copies (copie_totali keeps counting them). * * Runs against the REAL database of the checkout and only touches rows it * creates (titles ZZ_CQM_%, emails zzcqm+%@test.local, types zzcqm-%). @@ -19,6 +21,7 @@ use App\Controllers\ReservationManager; use App\Services\CapacityService; use App\Services\CirculationPolicy; +use App\Support\DataIntegrity; use App\Support\DateHelper; use App\Support\PatronTypeAssignment; @@ -250,6 +253,34 @@ $check(PatronTypeAssignment::fromSubmission($db, ['tipologia_id' => '999999999'], $student) === $student, 'an unknown type keeps the current one'); $check(PatronTypeAssignment::fromSubmission($db, ['tipologia_id' => ''], $student) === null, 'an empty value selects the default type'); +/* ------------------------------------------------------------------------ + * 5. libri.copie_disponibili excludes the consultation-only copies. + * ---------------------------------------------------------------------- */ +echo "Availability counter without the consultation copies\n"; +[$mixed, $lendableCopy] = $makeBook('mixed', 'libro'); +$consultationInv = substr('ZZCQM-MIXED-CONS-' . strtoupper($prefix), 0, 50); +$stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato, solo_consultazione) VALUES (?, ?, 'disponibile', 1)"); +$stmt->bind_param('is', $mixed, $consultationInv); +$stmt->execute(); +$stmt->close(); +$counters = static function (int $bookId) use ($db): string { + $row = $db->query("SELECT copie_disponibili, copie_totali, stato FROM libri WHERE id = {$bookId}")->fetch_assoc(); + return $row['copie_disponibili'] . '/' . $row['copie_totali'] . '/' . $row['stato']; +}; +$integrity = new DataIntegrity($db); +$check($integrity->recalculateBookAvailability($mixed) && $counters($mixed) === '1/2/disponibile', 'one lendable + one consultation copy: 1 available of 2 physical copies'); +$borrower = $makeUser('borrower', null); +$mkLoan($mixed, $lendableCopy, $borrower); +$check($integrity->recalculateBookAvailability($mixed) && $counters($mixed) === '0/2/prestato', 'with the lendable copy out the consultation copy does not count: 0 available, title lent'); +$db->query("UPDATE copie SET solo_consultazione = 0 WHERE numero_inventario = '{$consultationInv}'"); +$check($integrity->recalculateBookAvailability($mixed) && $counters($mixed) === '1/2/disponibile', 'back in circulation, the copy counts again'); +$db->query("UPDATE copie SET solo_consultazione = 1 WHERE libro_id = {$mixed}"); +$db->query("DELETE FROM prestiti WHERE libro_id = {$mixed}"); +$db->query("UPDATE copie SET stato = 'disponibile' WHERE libro_id = {$mixed}"); +$check($integrity->recalculateBookAvailability($mixed) && $counters($mixed) === '0/2/non_disponibile', 'every copy kept for consultation: nothing available, title not lendable'); +$all = $integrity->recalculateAllBookAvailability(); +$check($all['errors'] === [] && $counters($mixed) === '0/2/non_disponibile', 'the global recalculation applies the same rule'); + $cleanup(); $db->close(); From 0a8bff7555742157d4fa9bb4869631d74ad05eeb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:13:07 +0000 Subject: [PATCH 133/398] Stocktake form: say the collocation range is alphabetical The "Collocazione da/a" bounds are compared as strings, so A.10 sorts before A.2. The query stays as it is (collocations are free text, with no numeric shape to rely on); the form now says so under the two inputs, in the five locales, so a librarian does not open a session on a range that misses part of the shelf. --- app/Views/inventario/index.php | 5 +++-- locale/da_DK.json | 3 ++- locale/de_DE.json | 3 ++- locale/en_US.json | 3 ++- locale/fr_FR.json | 3 ++- locale/it_IT.json | 3 ++- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/Views/inventario/index.php b/app/Views/inventario/index.php index 2ec65d5ce..7acdb9463 100644 --- a/app/Views/inventario/index.php +++ b/app/Views/inventario/index.php @@ -142,11 +142,12 @@
      - +
      - + +

      diff --git a/locale/da_DK.json b/locale/da_DK.json index 7e3f7a439..072a4cade 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8498,5 +8498,6 @@ "Revisione inventariale chiusa": "Optælling lukket", "Revisione inventariale annullata": "Optælling annulleret", "In prestito o in attesa di ritiro": "Udlånt eller klar til afhentning", - "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Eksemplaret står som udlånt eller klar til afhentning: tjek udlånet." + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Eksemplaret står som udlånt eller klar til afhentning: tjek udlånet.", + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallet sammenlignes alfabetisk, ikke numerisk: A.10 kommer før A.2." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 479204042..24b14f4ac 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8498,5 +8498,6 @@ "Revisione inventariale chiusa": "Bestandsrevision geschlossen", "Revisione inventariale annullata": "Bestandsrevision abgebrochen", "In prestito o in attesa di ritiro": "Ausgeliehen oder zur Abholung bereit", - "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Das Exemplar ist als ausgeliehen oder zur Abholung bereit erfasst: Prüfe die Ausleihe." + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Das Exemplar ist als ausgeliehen oder zur Abholung bereit erfasst: Prüfe die Ausleihe.", + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Der Bereich wird alphabetisch verglichen, nicht numerisch: A.10 steht vor A.2." } diff --git a/locale/en_US.json b/locale/en_US.json index a156a7bff..00060f6a8 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8498,5 +8498,6 @@ "Revisione inventariale chiusa": "Stocktake closed", "Revisione inventariale annullata": "Stocktake cancelled", "In prestito o in attesa di ritiro": "On loan or awaiting pickup", - "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "The copy is recorded as on loan or awaiting pickup: check the loan." + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "The copy is recorded as on loan or awaiting pickup: check the loan.", + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "The range is compared alphabetically, not numerically: A.10 comes before A.2." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 29fd227af..5d0f97807 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8498,5 +8498,6 @@ "Revisione inventariale chiusa": "Récolement clôturé", "Revisione inventariale annullata": "Récolement annulé", "In prestito o in attesa di ritiro": "Emprunté ou en attente de retrait", - "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "L'exemplaire est enregistré comme prêté ou en attente de retrait : vérifiez le prêt." + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "L'exemplaire est enregistré comme prêté ou en attente de retrait : vérifiez le prêt.", + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "L'intervalle est comparé par ordre alphabétique, pas numérique : A.10 vient avant A.2." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 8cee395e2..8344d268b 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8498,5 +8498,6 @@ "Revisione inventariale chiusa": "Revisione inventariale chiusa", "Revisione inventariale annullata": "Revisione inventariale annullata", "In prestito o in attesa di ritiro": "In prestito o in attesa di ritiro", - "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "La copia risulta in prestito o in attesa di ritiro: verifica il prestito." + "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.", + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2." } From 28c5446c836fd7c58b1d49d7219150d132ecdac5 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:14:49 +0000 Subject: [PATCH 134/398] User import: mb_convert_encoding() of a string is a string PHPStan: the is_string() guard in UserImport::toUtf8() can only be true for a string argument. The mb_check_encoding() fallback stays. --- app/Support/UserImport.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Support/UserImport.php b/app/Support/UserImport.php index c65739c03..cdd7a4081 100644 --- a/app/Support/UserImport.php +++ b/app/Support/UserImport.php @@ -352,7 +352,7 @@ public static function toUtf8(string $bytes): array return ['text' => $bytes, 'converted' => false]; } $text = mb_convert_encoding($bytes, 'UTF-8', 'Windows-1252'); - if (!is_string($text) || !mb_check_encoding($text, 'UTF-8')) { + if (!mb_check_encoding($text, 'UTF-8')) { $text = mb_scrub($bytes, 'UTF-8'); } return ['text' => $text, 'converted' => true]; From c1e057ef52b8f0b46753159f7a8c8fbbb59bf07c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:15:12 +0000 Subject: [PATCH 135/398] Ledger desk actions need the loans permission The ledger routes sit under /admin/users/{id}/ledger/, so the prefix map granted them to users.manage while PatronLedgerController demanded loans.manage: a custom role managing readers got a 403 from forms it was shown, and a loan-desk role could not reach routes it was entitled to. An exact-match rule in Permissions::permissionForPath() now sends every ledger path (payment, charge, waive, receipt) to loans.manage, placed with the other exact rules above the prefix groups. The movements partial renders the three forms, their buttons and the receipt links only with that capability, so a users-only role reads the ledger and nothing more. tests/ledger-permission.unit.php mirrors the stocktake permission test; the fines E2E spec gains a custom-role case (users.manage: read-only and a refused POST; with loans.manage: the payment form works). --- CHANGELOG.md | 2 +- app/Support/Permissions.php | 5 +++ app/Views/utenti/_movimenti.php | 10 ++++-- tests/fines-ledger.spec.js | 56 +++++++++++++++++++++++++++++ tests/ledger-permission.unit.php | 60 ++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 tests/ledger-permission.unit.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c64165db..3b847eb6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre ### Fines, patron ledger and debt block (plan §7) - **Late fines.** Settings → Loans → "Multe e blocco per debito": daily fine, grace days, maximum fine per loan. These are the global layer of `CirculationPolicy`: a patron type (Settings → Circulation) can set its own daily fine, grace days, cap per loan and debt threshold, and a cell of the rules matrix its own fine, cap and grace for one material; an empty field inherits, 0 switches fines (or the block) off for that type or material. Late days are the days the library is open (closures calendar: weekly closed days, closures, yearly closures, branch closures for the copy's branch); with no closure configured every day counts. The grace period is a threshold (SLiMS rule): up to the grace days nothing is charged, beyond it every late day counts from the first. With the daily fine empty or 0 (the default) nothing changes. The fine is written at return, in the same transaction as the close, on every return path (return screen, quick return, NCIP check-in), and only once per return (idempotency key `late:`). Fines accruing on open overdue loans are shown on the loan, the reader record, the reader's own page and the app, never written in advance. -- **Patron ledger.** New append-only table `movimenti_lettore` (charges, payments, waivers, reversals); the balance is the sum of the rows. The reader record has a "Movimenti" section: balance, accruing fines, list, register a payment (cash, card, transfer, other) with a PDF receipt, manual charge (description required), waiver (reason required). Each desk form is recorded once even when it is sent twice, and a long ledger is shown 200 rows per page. The return screen shows the fine before the confirmation and can waive it on the spot, with a reason; a waived fine no longer appears in the return email, the fine email or on the loan page. A reader with ledger rows is suspended instead of deleted. +- **Patron ledger.** New append-only table `movimenti_lettore` (charges, payments, waivers, reversals); the balance is the sum of the rows. The reader record has a "Movimenti" section: balance, accruing fines, list, register a payment (cash, card, transfer, other) with a PDF receipt, manual charge (description required), waiver (reason required). Each desk form is recorded once even when it is sent twice, and a long ledger is shown 200 rows per page. Recording a payment, a charge or a waiver and downloading a receipt are loan-desk actions: they need the loans permission (`loans.manage`, as Staff has), so a custom role that only manages readers sees the ledger read-only. The return screen shows the fine before the confirmation and can waive it on the spot, with a reason; a waived fine no longer appears in the return email, the fine email or on the loan page. A reader with ledger rows is suspended instead of deleted. - **Debt block.** With a threshold set (the patron type's, else the global one), a reader who owes more than it gets `debt_block` ("Saldo a debito oltre la soglia consentita…") on loan requests, admin loans, approvals, renewals and the reservation queue, on the website, the Mobile API and NCIP. - **Reports and email.** Statistics: charged, collected, waived and reversed per month with CSV export. Return email: new placeholder `{{multa}}` (empty when nothing was charged). New optional email "Multa per ritardo addebitata" (`fine_charged`), off by default. - **Mobile API 1.9.0** (requires 0.9.0): `GET /me/movements` with balance, movements and accruing fines; `debt_block` arrives as `not_eligible` with `meta.reason`. diff --git a/app/Support/Permissions.php b/app/Support/Permissions.php index a846e766a..58dc079a4 100644 --- a/app/Support/Permissions.php +++ b/app/Support/Permissions.php @@ -119,6 +119,11 @@ public static function permissionForPath(string $path): ?string || preg_match('#^/admin/frbr-lrm/opere/\d+/espressioni$#', $path) === 1) { return 'catalog.manage'; } + // The reader ledger (payment, manual charge, waiver, receipt) is a loan-desk + // action that happens to live under the reader record's URL: it follows the + // loan desk, not the users module, so a users-only role reads the ledger but + // cannot move money. + if (preg_match('#^/admin/users/\d+/ledger/#', $path) === 1) { return 'loans.manage'; } $groups = [ 'catalog.manage' => ['/admin/books', '/admin/authors', '/admin/publishers', '/admin/suppliers', '/admin/genres', '/admin/series', '/admin/placement', '/admin/copies', '/admin/import', '/admin/imports', '/admin/imports-history', '/api/books', '/api/authors', '/api/publishers', '/api/genres', '/api/series', '/api/copies', '/api/dewey', '/api/scrape', '/api/libri', '/api/autori', '/api/editori', '/api/collane', '/api/collocazione', '/api/cover', '/admin/periodicals', '/api/v1/staff/lookups', '/api/v1/staff/isbn', '/api/v1/staff/copies', '/api/v1/staff/books', '/api/v1/staff/authors', '/api/v1/staff/publishers', '/api/v1/staff/imports', '/api/v1/staff/periodicals', '/api/v1/staff/series', '/api/v1/staff/genres', '/api/v1/staff/works', '/api/v1/staff/covers', '/api/v1/staff/enrich', '/api/v1/staff/shelves', '/api/v1/staff/archives', '/api/search/editori', '/api/search/generi', '/api/search/preview', '/api/generi', '/admin/archives', '/admin/opere', '/admin/espressioni'], 'loans.manage' => ['/admin/loans', '/admin/attendance', '/admin/reservations', '/api/loans', '/api/reservations', '/api/prestiti', '/prestiti', '/api/v1/staff/loans', '/api/v1/staff/reservations'], diff --git a/app/Views/utenti/_movimenti.php b/app/Views/utenti/_movimenti.php index 602b75218..98638d264 100644 --- a/app/Views/utenti/_movimenti.php +++ b/app/Views/utenti/_movimenti.php @@ -9,7 +9,7 @@ * @var list> $ledgerMovements * @var list $ledgerAccrued * @var \App\Services\FineParameters $ledgerParams - * @var bool $ledgerCanManage + * @var bool $ledgerCanManage loans.manage: the desk forms and the receipts; without it the ledger is read-only * @var int $ledgerPage * @var int $ledgerPages * @var int $ledgerPerPage @@ -26,6 +26,10 @@ $ledgerError = is_scalar($_GET['ledger_error'] ?? null) ? (string) $_GET['ledger_error'] : ''; $ledgerMovementId = (int) (is_scalar($_GET['movement'] ?? null) ? $_GET['movement'] : 0); $ledgerCsrf = App\Support\Csrf::ensureToken(); +// The ledger routes (/admin/users/{id}/ledger/*) need loans.manage +// (Permissions::permissionForPath): the forms, the buttons and the receipt +// links are rendered only for whoever may reach them. +$ledgerCanManage = ($ledgerCanManage ?? false) && App\Support\Permissions::can('loans.manage'); ?>
      @@ -44,7 +48,7 @@
      - 0): ?> + 0 && $ledgerCanManage): ?> @@ -120,7 +124,7 @@
      - + ['nome' => __('Tutte le tipologie')]] + $cTypes as $cTypeId => $cType): ?> - + $cMediaLabel): $cKey = (int) $cTypeId . '|' . $cM; $cRule = $cRules[$cKey] ?? []; @@ -158,15 +157,15 @@ ?> - + @@ -367,3 +477,11 @@ class="btn-icon btn-secondary" data-action="edit-closure" + diff --git a/cron/full-maintenance.php b/cron/full-maintenance.php index 1a4ec00ff..72bdbd379 100644 --- a/cron/full-maintenance.php +++ b/cron/full-maintenance.php @@ -154,6 +154,7 @@ function logMessage(string $message): void { logMessage("- Expired pickups (da_ritirare -> scaduto): " . $results['expired_pickups']); logMessage("- Overdue loans updated (in_corso -> in_ritardo): " . $results['overdue_loans_updated']); logMessage("- Visitor log rows purged (retention): " . ($results['attendance_purged'] ?? 0)); + logMessage("- Holiday rows added (movable holidays, two years ahead): " . ($results['holiday_rows_added'] ?? 0)); // Notification results (counts returned at top-level by MaintenanceService::runAll) logMessage("Notifications sent:"); diff --git a/locale/da_DK.json b/locale/da_DK.json index 2ddb5ca5c..4e8cbfe19 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8892,7 +8892,6 @@ "La scadenza verrà estesa di %d giorni": "Fristen forlænges med %d dage", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Vil du fornye lånet? Udløbsdatoen udvides med %d dage.", "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Undtagelser pr. lånerkategori og materialetype: udlånbarhed, maksimalt antal lån af det materiale (ud over den samlede grænse), periode og fornyelser. Grå værdier er arvede; skriv kun en værdi, hvor der er brug for en undtagelse.", - "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Tilføjer de faste helligdage som årlige lukninger samt påskedag og 2. påskedag for det valgte år. Lukninger, der allerede findes, oprettes ikke igen.", "Aggiungi chiusura": "Tilføj lukning", "Aggiungi una chiusura o importa le festività nazionali.": "Tilføj en lukning eller importér de nationale helligdage.", "Almeno un giorno della settimana deve restare aperto.": "Mindst én ugedag skal være åben.", @@ -8913,8 +8912,6 @@ "Festa del Lavoro": "Arbejdernes kampdag", "Festa della Liberazione": "Befrielsesdagen", "Festa della Repubblica": "Republikkens dag", - "Festività nazionali importate: %d nuove chiusure.": "Nationale helligdage importeret: %d nye lukninger.", - "Festività nazionali italiane": "Italienske nationale helligdage", "File ICS": "ICS-fil", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "ICS-fil importeret: %1$d lukninger tilføjet, %2$d fandtes allerede.", "Gestisci il calendario chiusure": "Administrér lukkekalenderen", @@ -8926,7 +8923,6 @@ "Immacolata Concezione": "Marias undfangelse", "Importa ICS": "Importér ICS", "Importa da file ICS": "Importér fra en ICS-fil", - "Importa festività": "Importér helligdage", "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "Biblioteket er lukket denne dag: vælg en anden startdag.", "Lunedì dell'Angelo (Pasquetta)": "2. påskedag", "Mese precedente": "Forrige måned", @@ -9047,5 +9043,32 @@ "Fornitore non assegnato": "Leverandør ikke tildelt", "In prestito o in attesa di ritiro": "Udlånt eller klar til afhentning", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Eksemplaret står som udlånt eller klar til afhentning: tjek udlånet.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallet sammenlignes alfabetisk, ikke numerisk: A.10 kommer før A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallet sammenlignes alfabetisk, ikke numerisk: A.10 kommer før A.2.", + "Aggiorna anteprima": "Opdater forhåndsvisning", + "Anno di anteprima": "Forhåndsvisningsår", + "Applica festività selezionate": "Anvend valgte helligdage", + "Chiusure annuali": "Årlige lukninger", + "Chiusure annuali salvate: %d modificate.": "Årlige lukninger gemt: %d ændret.", + "Dalla selezione delle festività": "Fra valget af helligdage", + "Esegui prima l'aggiornamento del database: manca la colonna delle festività.": "Kør først databaseopdateringen: kolonnen til helligdage mangler.", + "Festività": "Helligdag", + "Festività applicate: %1$d aggiunte, %2$d rimosse, %3$d già presenti riconosciute.": "Helligdage anvendt: %1$d tilføjet, %2$d fjernet, %3$d allerede eksisterende genkendt.", + "Festività applicate: %1$d aggiunte, %2$d rimosse.": "Helligdage anvendt: %1$d tilføjet, %2$d fjernet.", + "Festività nazionali": "Helligdage", + "Festività non applicate: riprova.": "Helligdage ikke anvendt: prøv igen.", + "Le chiusure che si ripetono ogni anno, aggiunte a mano o da file ICS. Togli la spunta per sospenderne una senza eliminarla.": "Lukninger, der gentages hvert år, tilføjet manuelt eller fra en ICS-fil. Fjern fluebenet for at sætte en på pause uden at slette den.", + "Le date sono calcolate dalle regole ufficiali note; la biblioteca sceglie quali applicare e può sempre aggiungere chiusure a mano.": "Datoerne beregnes ud fra de kendte officielle regler; biblioteket vælger, hvilke der skal anvendes, og kan altid tilføje lukninger manuelt.", + "Le festività si modificano dalla selezione delle festività nazionali.": "Helligdage ændres via valget af helligdage.", + "Nessuna chiusura annuale aggiunta a mano.": "Ingen årlig lukning tilføjet manuelt.", + "Paese non valido.": "Ugyldigt land.", + "Scegli il paese (e la regione, dove serve) e spunta le festività in cui la biblioteca è chiusa. Le festività fisse diventano chiusure annuali, quelle mobili sono ricalcolate ogni anno. Togliere la spunta rimuove solo quella festività; le chiusure aggiunte a mano o da file ICS non vengono toccate.": "Vælg landet (og regionen, hvor det er nødvendigt) og sæt flueben ved de helligdage, hvor biblioteket er lukket. Faste helligdage bliver til årlige lukninger, bevægelige genberegnes hvert år. Fjernes et flueben, fjernes kun den helligdag; lukninger tilføjet manuelt eller fra en ICS-fil berøres ikke.", + "Seleziona tutte": "Vælg alle", + "Solo festività nazionali": "Kun nationale helligdage", + "Sospesa": "Sat på pause", + "facoltativa": "valgfri", + "mobile": "bevægelig", + "non prevista nel %d": "ikke i %d", + "ogni anno": "hvert år", + "regionale": "regional", + "MaintenanceService errore festività": "MaintenanceService fejl ved helligdage" } diff --git a/locale/de_DE.json b/locale/de_DE.json index d5c977198..e8c64eebd 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8892,7 +8892,6 @@ "La scadenza verrà estesa di %d giorni": "Die Frist wird um %d Tage verlängert", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Ausleihe verlängern? Das Fälligkeitsdatum wird um %d Tage verlängert.", "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Ausnahmen pro Leserkategorie und Medienart: Ausleihbarkeit, maximale Ausleihen dieses Mediums (zusätzlich zur Gesamtgrenze), Dauer und Verlängerungen. Graue Werte sind geerbt; trage nur dort einen Wert ein, wo eine Ausnahme nötig ist.", - "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Fügt die festen Feiertage als jährliche Schließungen hinzu, dazu Ostern und Ostermontag für das gewählte Jahr. Bereits vorhandene Schließungen werden nicht doppelt angelegt.", "Aggiungi chiusura": "Schließung hinzufügen", "Aggiungi una chiusura o importa le festività nazionali.": "Füge eine Schließung hinzu oder importiere die gesetzlichen Feiertage.", "Almeno un giorno della settimana deve restare aperto.": "Mindestens ein Wochentag muss geöffnet bleiben.", @@ -8913,8 +8912,6 @@ "Festa del Lavoro": "Tag der Arbeit", "Festa della Liberazione": "Tag der Befreiung", "Festa della Repubblica": "Tag der Republik", - "Festività nazionali importate: %d nuove chiusure.": "Gesetzliche Feiertage importiert: %d neue Schließungen.", - "Festività nazionali italiane": "Italienische gesetzliche Feiertage", "File ICS": "ICS-Datei", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "ICS-Datei importiert: %1$d Schließungen hinzugefügt, %2$d bereits vorhanden.", "Gestisci il calendario chiusure": "Schließtage-Kalender verwalten", @@ -8926,7 +8923,6 @@ "Immacolata Concezione": "Mariä Empfängnis", "Importa ICS": "ICS importieren", "Importa da file ICS": "Aus ICS-Datei importieren", - "Importa festività": "Feiertage importieren", "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "Die Bibliothek ist an diesem Tag geschlossen: wähle einen anderen Starttag.", "Lunedì dell'Angelo (Pasquetta)": "Ostermontag", "Mese precedente": "Vorheriger Monat", @@ -9047,5 +9043,32 @@ "Fornitore non assegnato": "Lieferant nicht zugewiesen", "In prestito o in attesa di ritiro": "Ausgeliehen oder zur Abholung bereit", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Das Exemplar ist als ausgeliehen oder zur Abholung bereit erfasst: Prüfe die Ausleihe.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Der Bereich wird alphabetisch verglichen, nicht numerisch: A.10 steht vor A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Der Bereich wird alphabetisch verglichen, nicht numerisch: A.10 steht vor A.2.", + "Aggiorna anteprima": "Vorschau aktualisieren", + "Anno di anteprima": "Vorschaujahr", + "Applica festività selezionate": "Ausgewählte Feiertage übernehmen", + "Chiusure annuali": "Jährliche Schließungen", + "Chiusure annuali salvate: %d modificate.": "Jährliche Schließungen gespeichert: %d geändert.", + "Dalla selezione delle festività": "Aus der Feiertagsauswahl", + "Esegui prima l'aggiornamento del database: manca la colonna delle festività.": "Führen Sie zuerst die Datenbankaktualisierung aus: Die Feiertagsspalte fehlt.", + "Festività": "Feiertag", + "Festività applicate: %1$d aggiunte, %2$d rimosse, %3$d già presenti riconosciute.": "Feiertage übernommen: %1$d hinzugefügt, %2$d entfernt, %3$d bereits vorhandene erkannt.", + "Festività applicate: %1$d aggiunte, %2$d rimosse.": "Feiertage übernommen: %1$d hinzugefügt, %2$d entfernt.", + "Festività nazionali": "Gesetzliche Feiertage", + "Festività non applicate: riprova.": "Feiertage nicht übernommen: bitte erneut versuchen.", + "Le chiusure che si ripetono ogni anno, aggiunte a mano o da file ICS. Togli la spunta per sospenderne una senza eliminarla.": "Jährlich wiederkehrende Schließungen, von Hand oder aus einer ICS-Datei hinzugefügt. Entfernen Sie das Häkchen, um eine auszusetzen, ohne sie zu löschen.", + "Le date sono calcolate dalle regole ufficiali note; la biblioteca sceglie quali applicare e può sempre aggiungere chiusure a mano.": "Die Daten werden aus den bekannten amtlichen Regeln berechnet; die Bibliothek wählt, welche sie übernimmt, und kann jederzeit Schließungen von Hand hinzufügen.", + "Le festività si modificano dalla selezione delle festività nazionali.": "Feiertage werden über die Auswahl der gesetzlichen Feiertage geändert.", + "Nessuna chiusura annuale aggiunta a mano.": "Keine jährliche Schließung von Hand hinzugefügt.", + "Paese non valido.": "Ungültiges Land.", + "Scegli il paese (e la regione, dove serve) e spunta le festività in cui la biblioteca è chiusa. Le festività fisse diventano chiusure annuali, quelle mobili sono ricalcolate ogni anno. Togliere la spunta rimuove solo quella festività; le chiusure aggiunte a mano o da file ICS non vengono toccate.": "Wählen Sie das Land (und, wo nötig, die Region) und haken Sie die Feiertage an, an denen die Bibliothek geschlossen ist. Feste Feiertage werden zu jährlichen Schließungen, bewegliche werden jedes Jahr neu berechnet. Ein entferntes Häkchen entfernt nur diesen Feiertag; von Hand oder aus einer ICS-Datei hinzugefügte Schließungen bleiben unberührt.", + "Seleziona tutte": "Alle auswählen", + "Solo festività nazionali": "Nur bundesweite Feiertage", + "Sospesa": "Ausgesetzt", + "facoltativa": "optional", + "mobile": "beweglich", + "non prevista nel %d": "%d nicht vorgesehen", + "ogni anno": "jedes Jahr", + "regionale": "regional", + "MaintenanceService errore festività": "MaintenanceService Fehler bei den Feiertagen" } diff --git a/locale/en_US.json b/locale/en_US.json index 599cba2e0..428e5a801 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8892,7 +8892,6 @@ "La scadenza verrà estesa di %d giorni": "The due date will be extended by %d days", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renew loan? The due date will be extended by %d days.", "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Exceptions per patron type and material type: lendability, maximum loans of that material (on top of the total limit), duration and renewals. Grey values are inherited; enter a value only where an exception is needed.", - "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Adds the fixed holidays as yearly closures, plus Easter and Easter Monday for the chosen year. Closures already present are not duplicated.", "Aggiungi chiusura": "Add closure", "Aggiungi una chiusura o importa le festività nazionali.": "Add a closure or import the national holidays.", "Almeno un giorno della settimana deve restare aperto.": "At least one day of the week must stay open.", @@ -8913,8 +8912,6 @@ "Festa del Lavoro": "Labour Day", "Festa della Liberazione": "Liberation Day", "Festa della Repubblica": "Republic Day", - "Festività nazionali importate: %d nuove chiusure.": "National holidays imported: %d new closures.", - "Festività nazionali italiane": "Italian national holidays", "File ICS": "ICS file", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "ICS file imported: %1$d closures added, %2$d already present.", "Gestisci il calendario chiusure": "Manage the closures calendar", @@ -8926,7 +8923,6 @@ "Immacolata Concezione": "Immaculate Conception", "Importa ICS": "Import ICS", "Importa da file ICS": "Import from an ICS file", - "Importa festività": "Import holidays", "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "The library is closed on this day: choose another start day.", "Lunedì dell'Angelo (Pasquetta)": "Easter Monday", "Mese precedente": "Previous month", @@ -9047,5 +9043,32 @@ "Fornitore non assegnato": "Supplier not assigned", "In prestito o in attesa di ritiro": "On loan or awaiting pickup", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "The copy is recorded as on loan or awaiting pickup: check the loan.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "The range is compared alphabetically, not numerically: A.10 comes before A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "The range is compared alphabetically, not numerically: A.10 comes before A.2.", + "Aggiorna anteprima": "Update preview", + "Anno di anteprima": "Preview year", + "Applica festività selezionate": "Apply selected holidays", + "Chiusure annuali": "Yearly closures", + "Chiusure annuali salvate: %d modificate.": "Yearly closures saved: %d changed.", + "Dalla selezione delle festività": "From the holiday selection", + "Esegui prima l'aggiornamento del database: manca la colonna delle festività.": "Run the database update first: the holiday column is missing.", + "Festività": "Holiday", + "Festività applicate: %1$d aggiunte, %2$d rimosse, %3$d già presenti riconosciute.": "Holidays applied: %1$d added, %2$d removed, %3$d already present recognised.", + "Festività applicate: %1$d aggiunte, %2$d rimosse.": "Holidays applied: %1$d added, %2$d removed.", + "Festività nazionali": "Public holidays", + "Festività non applicate: riprova.": "Holidays not applied: please try again.", + "Le chiusure che si ripetono ogni anno, aggiunte a mano o da file ICS. Togli la spunta per sospenderne una senza eliminarla.": "Closures that repeat every year, added by hand or from an ICS file. Untick one to suspend it without deleting it.", + "Le date sono calcolate dalle regole ufficiali note; la biblioteca sceglie quali applicare e può sempre aggiungere chiusure a mano.": "Dates are computed from the known official rules; the library chooses which to apply and can always add closures by hand.", + "Le festività si modificano dalla selezione delle festività nazionali.": "Holidays are changed from the public holiday selection.", + "Nessuna chiusura annuale aggiunta a mano.": "No yearly closure added by hand.", + "Paese non valido.": "Invalid country.", + "Scegli il paese (e la regione, dove serve) e spunta le festività in cui la biblioteca è chiusa. Le festività fisse diventano chiusure annuali, quelle mobili sono ricalcolate ogni anno. Togliere la spunta rimuove solo quella festività; le chiusure aggiunte a mano o da file ICS non vengono toccate.": "Choose the country (and the region, where needed) and tick the holidays on which the library is closed. Fixed holidays become yearly closures, movable ones are recomputed every year. Unticking removes only that holiday; closures added by hand or from an ICS file are not touched.", + "Seleziona tutte": "Select all", + "Solo festività nazionali": "National holidays only", + "Sospesa": "Suspended", + "facoltativa": "optional", + "mobile": "movable", + "non prevista nel %d": "not held in %d", + "ogni anno": "every year", + "regionale": "regional", + "MaintenanceService errore festività": "MaintenanceService holiday rows error" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index b1db74581..361ea83e6 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8892,7 +8892,6 @@ "La scadenza verrà estesa di %d giorni": "La date d'échéance sera prolongée de %d jours", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renouveler l'emprunt ? La date d'échéance sera prolongée de %d jours.", "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Exceptions par catégorie de lecteur et type de support : possibilité de prêt, prêts maximum de ce support (en plus de la limite totale), durée et renouvellements. Les valeurs en gris sont héritées ; saisissez une valeur seulement là où une exception est nécessaire.", - "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Ajoute les jours fériés fixes comme fermetures annuelles, ainsi que Pâques et le lundi de Pâques pour l'année choisie. Les fermetures déjà présentes ne sont pas dupliquées.", "Aggiungi chiusura": "Ajouter une fermeture", "Aggiungi una chiusura o importa le festività nazionali.": "Ajoutez une fermeture ou importez les jours fériés nationaux.", "Almeno un giorno della settimana deve restare aperto.": "Au moins un jour de la semaine doit rester ouvert.", @@ -8913,8 +8912,6 @@ "Festa del Lavoro": "Fête du Travail", "Festa della Liberazione": "Fête de la Libération", "Festa della Repubblica": "Fête de la République", - "Festività nazionali importate: %d nuove chiusure.": "Jours fériés nationaux importés : %d nouvelles fermetures.", - "Festività nazionali italiane": "Jours fériés nationaux italiens", "File ICS": "Fichier ICS", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "Fichier ICS importé : %1$d fermetures ajoutées, %2$d déjà présentes.", "Gestisci il calendario chiusure": "Gérer le calendrier des fermetures", @@ -8926,7 +8923,6 @@ "Immacolata Concezione": "Immaculée Conception", "Importa ICS": "Importer l'ICS", "Importa da file ICS": "Importer depuis un fichier ICS", - "Importa festività": "Importer les jours fériés", "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "La bibliothèque est fermée ce jour-là : choisissez un autre jour de début.", "Lunedì dell'Angelo (Pasquetta)": "Lundi de Pâques", "Mese precedente": "Mois précédent", @@ -9047,5 +9043,32 @@ "Fornitore non assegnato": "Fournisseur non attribué", "In prestito o in attesa di ritiro": "Emprunté ou en attente de retrait", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "L'exemplaire est enregistré comme prêté ou en attente de retrait : vérifiez le prêt.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "L'intervalle est comparé par ordre alphabétique, pas numérique : A.10 vient avant A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "L'intervalle est comparé par ordre alphabétique, pas numérique : A.10 vient avant A.2.", + "Aggiorna anteprima": "Mettre à jour l'aperçu", + "Anno di anteprima": "Année d'aperçu", + "Applica festività selezionate": "Appliquer les jours fériés sélectionnés", + "Chiusure annuali": "Fermetures annuelles", + "Chiusure annuali salvate: %d modificate.": "Fermetures annuelles enregistrées : %d modifiées.", + "Dalla selezione delle festività": "Issu de la sélection des jours fériés", + "Esegui prima l'aggiornamento del database: manca la colonna delle festività.": "Exécutez d'abord la mise à jour de la base de données : la colonne des jours fériés est absente.", + "Festività": "Jour férié", + "Festività applicate: %1$d aggiunte, %2$d rimosse, %3$d già presenti riconosciute.": "Jours fériés appliqués : %1$d ajoutés, %2$d supprimés, %3$d déjà présents reconnus.", + "Festività applicate: %1$d aggiunte, %2$d rimosse.": "Jours fériés appliqués : %1$d ajoutés, %2$d supprimés.", + "Festività nazionali": "Jours fériés", + "Festività non applicate: riprova.": "Jours fériés non appliqués : veuillez réessayer.", + "Le chiusure che si ripetono ogni anno, aggiunte a mano o da file ICS. Togli la spunta per sospenderne una senza eliminarla.": "Les fermetures qui se répètent chaque année, ajoutées à la main ou depuis un fichier ICS. Décochez-en une pour la suspendre sans la supprimer.", + "Le date sono calcolate dalle regole ufficiali note; la biblioteca sceglie quali applicare e può sempre aggiungere chiusure a mano.": "Les dates sont calculées à partir des règles officielles connues ; la bibliothèque choisit celles à appliquer et peut toujours ajouter des fermetures à la main.", + "Le festività si modificano dalla selezione delle festività nazionali.": "Les jours fériés se modifient depuis la sélection des jours fériés.", + "Nessuna chiusura annuale aggiunta a mano.": "Aucune fermeture annuelle ajoutée à la main.", + "Paese non valido.": "Pays non valide.", + "Scegli il paese (e la regione, dove serve) e spunta le festività in cui la biblioteca è chiusa. Le festività fisse diventano chiusure annuali, quelle mobili sono ricalcolate ogni anno. Togliere la spunta rimuove solo quella festività; le chiusure aggiunte a mano o da file ICS non vengono toccate.": "Choisissez le pays (et la région, si nécessaire) et cochez les jours fériés où la bibliothèque est fermée. Les jours fériés fixes deviennent des fermetures annuelles, les jours mobiles sont recalculés chaque année. Décocher ne supprime que ce jour férié ; les fermetures ajoutées à la main ou depuis un fichier ICS ne sont pas modifiées.", + "Seleziona tutte": "Tout sélectionner", + "Solo festività nazionali": "Jours fériés nationaux uniquement", + "Sospesa": "Suspendue", + "facoltativa": "facultatif", + "mobile": "mobile", + "non prevista nel %d": "non prévu en %d", + "ogni anno": "chaque année", + "regionale": "régional", + "MaintenanceService errore festività": "MaintenanceService erreur jours fériés" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 53f18f70b..b4d53a9b7 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8892,7 +8892,6 @@ "La scadenza verrà estesa di %d giorni": "La scadenza verrà estesa di %d giorni", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.", "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.", - "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.", "Aggiungi chiusura": "Aggiungi chiusura", "Aggiungi una chiusura o importa le festività nazionali.": "Aggiungi una chiusura o importa le festività nazionali.", "Almeno un giorno della settimana deve restare aperto.": "Almeno un giorno della settimana deve restare aperto.", @@ -8913,8 +8912,6 @@ "Festa del Lavoro": "Festa del Lavoro", "Festa della Liberazione": "Festa della Liberazione", "Festa della Repubblica": "Festa della Repubblica", - "Festività nazionali importate: %d nuove chiusure.": "Festività nazionali importate: %d nuove chiusure.", - "Festività nazionali italiane": "Festività nazionali italiane", "File ICS": "File ICS", "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.", "Gestisci il calendario chiusure": "Gestisci il calendario chiusure", @@ -8926,7 +8923,6 @@ "Immacolata Concezione": "Immacolata Concezione", "Importa ICS": "Importa ICS", "Importa da file ICS": "Importa da file ICS", - "Importa festività": "Importa festività", "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.", "Lunedì dell'Angelo (Pasquetta)": "Lunedì dell'Angelo (Pasquetta)", "Mese precedente": "Mese precedente", @@ -9047,5 +9043,32 @@ "Fornitore non assegnato": "Fornitore non assegnato", "In prestito o in attesa di ritiro": "In prestito o in attesa di ritiro", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.", + "Aggiorna anteprima": "Aggiorna anteprima", + "Anno di anteprima": "Anno di anteprima", + "Applica festività selezionate": "Applica festività selezionate", + "Chiusure annuali": "Chiusure annuali", + "Chiusure annuali salvate: %d modificate.": "Chiusure annuali salvate: %d modificate.", + "Dalla selezione delle festività": "Dalla selezione delle festività", + "Esegui prima l'aggiornamento del database: manca la colonna delle festività.": "Esegui prima l'aggiornamento del database: manca la colonna delle festività.", + "Festività": "Festività", + "Festività applicate: %1$d aggiunte, %2$d rimosse, %3$d già presenti riconosciute.": "Festività applicate: %1$d aggiunte, %2$d rimosse, %3$d già presenti riconosciute.", + "Festività applicate: %1$d aggiunte, %2$d rimosse.": "Festività applicate: %1$d aggiunte, %2$d rimosse.", + "Festività nazionali": "Festività nazionali", + "Festività non applicate: riprova.": "Festività non applicate: riprova.", + "Le chiusure che si ripetono ogni anno, aggiunte a mano o da file ICS. Togli la spunta per sospenderne una senza eliminarla.": "Le chiusure che si ripetono ogni anno, aggiunte a mano o da file ICS. Togli la spunta per sospenderne una senza eliminarla.", + "Le date sono calcolate dalle regole ufficiali note; la biblioteca sceglie quali applicare e può sempre aggiungere chiusure a mano.": "Le date sono calcolate dalle regole ufficiali note; la biblioteca sceglie quali applicare e può sempre aggiungere chiusure a mano.", + "Le festività si modificano dalla selezione delle festività nazionali.": "Le festività si modificano dalla selezione delle festività nazionali.", + "Nessuna chiusura annuale aggiunta a mano.": "Nessuna chiusura annuale aggiunta a mano.", + "Paese non valido.": "Paese non valido.", + "Scegli il paese (e la regione, dove serve) e spunta le festività in cui la biblioteca è chiusa. Le festività fisse diventano chiusure annuali, quelle mobili sono ricalcolate ogni anno. Togliere la spunta rimuove solo quella festività; le chiusure aggiunte a mano o da file ICS non vengono toccate.": "Scegli il paese (e la regione, dove serve) e spunta le festività in cui la biblioteca è chiusa. Le festività fisse diventano chiusure annuali, quelle mobili sono ricalcolate ogni anno. Togliere la spunta rimuove solo quella festività; le chiusure aggiunte a mano o da file ICS non vengono toccate.", + "Seleziona tutte": "Seleziona tutte", + "Solo festività nazionali": "Solo festività nazionali", + "Sospesa": "Sospesa", + "facoltativa": "facoltativa", + "mobile": "mobile", + "non prevista nel %d": "non prevista nel %d", + "ogni anno": "ogni anno", + "regionale": "regionale", + "MaintenanceService errore festività": "MaintenanceService errore festività" } diff --git a/tests/calendar-permission.unit.php b/tests/calendar-permission.unit.php index 88671f0f4..a11093b2f 100644 --- a/tests/calendar-permission.unit.php +++ b/tests/calendar-permission.unit.php @@ -26,7 +26,7 @@ echo "OK {$label}\n"; }; -foreach (['/admin/calendar', '/admin/calendar/weekdays', '/admin/calendar/closures', '/admin/calendar/closures/12/update', '/admin/calendar/closures/12/delete', '/admin/calendar/preset', '/admin/calendar/import-ics'] as $path) { +foreach (['/admin/calendar', '/admin/calendar/weekdays', '/admin/calendar/closures', '/admin/calendar/closures/12/update', '/admin/calendar/closures/12/delete', '/admin/calendar/preset', '/admin/calendar/yearly', '/admin/calendar/import-ics'] as $path) { $check(P::permissionForPath($path) === 'settings.manage', "{$path} needs settings.manage"); } $check(P::permissionForPath('/admin/calendarx') === null, 'a similarly named path does not inherit it'); From bdb20dc81f3cd3f533a33b1dcc3d512479d6c5b7 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:29:21 +0100 Subject: [PATCH 196/398] Tests: holiday presets from the librarian's side, and the changelog holidays-preset.spec.js picks countries and regions, previews dates, applies a subset, reapplies, unticks, checks a due date on a ticked and on an unticked holiday, switches country next to manual and ICS closures, suspends a yearly closure and adopts former Italian rows. The closures-calendar-admin preset tests use the new card. The spec joins the circulation group of the 0.9.0 E2E job. --- .github/workflows/ci-e2e.yml | 1 + CHANGELOG.md | 9 +- tests/closures-calendar-admin.spec.js | 42 ++-- tests/holidays-preset.spec.js | 327 ++++++++++++++++++++++++++ 4 files changed, 364 insertions(+), 15 deletions(-) create mode 100644 tests/holidays-preset.spec.js diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 2632a6f0f..a25a58a60 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -423,6 +423,7 @@ jobs: tests/fines-closed-days.spec.js tests/fines-ledger.spec.js tests/fines-patron-types.spec.js + tests/holidays-preset.spec.js tests/stack-090-integration.spec.js tests/stack-090-review-fixes.spec.js tests/stack-090-zero-config.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 615fe5267..9e7ab436e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,13 +49,19 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Administrators only.** Patron types and the rules matrix (Settings → Circulation, the type pages and every form behind them) are reserved to administrators and to custom roles with the settings permission; a staff account is sent back to the dashboard and nothing changes. For staff the Circulation section is absent from the settings navigation and `/admin/settings?tab=circulation` opens the default section without the circulation panel. ### Closures calendar -- **The library's closed days.** A new admin page, Admin → Calendario chiusure (`/admin/calendar`, also linked from Settings → Loans), holds the weekly closed days (`loans.closed_weekdays`) and the closures: single days or periods, once or every year, for every branch or for one. The Italian national holidays come with one click (fixed holidays as yearly closures, Easter and Easter Monday computed for the chosen year), any other calendar can be imported from an ICS file, and a monthly preview shows which days are open. +- **The library's closed days.** A new admin page, Admin → Calendario chiusure (`/admin/calendar`, also linked from Settings → Loans), holds the weekly closed days (`loans.closed_weekdays`) and the closures: single days or periods, once or every year, for every branch or for one. Public holidays come from a country preset (see below), any other calendar can be imported from an ICS file, and a monthly preview shows which days are open. - **Due dates land on an open day.** The shift lives in `CirculationPolicy::dueDateFor()`, so the per-patron-type durations and renewal days of the circulation policy land on an open day too. A due date that falls on a closed day moves to the next open day: admin loans (proposed and saved), loan edits, reader requests from the website and the app, reservations and their promotion to a loan, renewals and bulk extensions, NCIP CheckOut, RequestItem and RenewItem. A closure of one branch counts for the loans of its copies: when the staff desk lends a copy, an NCIP CheckOutItem binds one, or a request is approved onto one, the due date moves to that branch's next open day (at approval only if the longer window still fits; the NCIP checkout is refused, like the desk's, when the longer window no longer fits). A refused renewal proposes the last open day before the conflict. - **Pickup days are open days.** The pickup deadline counts only the days the library is open, and the nightly sweep expires no pickup, no scheduled loan and no queue reservation on a closed day; a deadline that falls on a day closed afterwards moves to the next open day when the calendar is saved, so the desk can still hand the book over that day, but never past the loan's due date (a pickup after the loan window is refused), which the calendar save does not move. - **The request calendars know the closed days.** On the book page and in the admin loan form a closed day cannot be picked as the start (a request that reaches the server with one starts on the next open day); the availability payload carries `closed_dates`, on the website and in the Mobile API `GET /catalog/books/{id}/availability` (additive field). A request auto-approved on a closed day is reported by `POST /reservations` with its real state, `prenotato` (new enum value) with the start day in `start_date` (additive), instead of `da_ritirare`. - **ICS imports meet the form's limits.** Every event is checked like a closure typed in the form (a yearly closure within a year, a one-off within ten years, end not before start); the import message counts the events discarded. A yearly closure of a year or more, however stored, is applied once as the interval it names and never closes every day; a one-off closure of any admissible length (up to ten years) is stepped over whole, so a date inside it lands on the day after it ends; an ICS date-time in UTC is read in the application timezone. The calendar page follows the settings permission (`settings.manage`), so a custom role with that permission can use it. - With no closed weekday and no closure configured every date is computed exactly as before. +### Public holiday presets for 22 countries +- **Pick the country, then the holidays.** The holiday card of `/admin/calendar` replaces the Italian-only import: the librarian chooses the country (Italy, United States, United Kingdom, Ireland, Germany, France, Denmark, Spain, Portugal, Netherlands, Belgium, Austria, Switzerland, Sweden, Norway, Finland, Poland, Canada, Australia, Brazil, Argentina, Mexico; preselected from the installation language) and, where holidays differ, the region (German Länder, UK nations, Canadian provinces and territories, Australian states and territories, Alsace-Moselle, six Swiss cantons), sees every holiday with its native name and its date in the preview year, ticks the ones the library closes for (select all / none; customary holidays such as Christmas Eve start unticked) and applies. +- **Rules, not dates.** Fixed dates, Easter offsets (Good Friday, Ascension, Whit Monday, Corpus Christi, Carnival…), the nth weekday of a month (Thanksgiving, Memorial Day, bank holidays), the weekday on or before/after a date (Victoria Day, Buß- und Bettag, Midsommar), the weekend substitutes of the US, UK, Ireland, Canada and Australia (including Christmas and Boxing Day both on a weekend), Koningsdag, the Argentine movable holidays, first and last years (Store bededag until 2023, Juneteenth from 2021, San Francesco in Italy from 2026, Wigilia in Poland from 2025) and the one-year moves of the early May and spring bank holidays (VE Day 2020, the Jubilees). +- **Applying is idempotent and selective.** The choice is stored in `calendar.holiday_country`, `calendar.holiday_region` and `calendar.holiday_rules`; each ticked holiday becomes closures marked with its rule (`calendario_chiusure.regola_festivita`): a fixed date as one yearly closure, any other holiday dated per year up to two years ahead, extended every year by the maintenance run (`holiday_rows_added` in the cron log). Applying again adds nothing, unticking removes only that holiday, switching country replaces the preset; closures added by hand or imported from ICS are never touched. Holiday rows show a "Festività" badge in the list and are changed from the selection only. Rows stored by the former Italian import are recognised and adopted, not duplicated. +- **Yearly closures on and off.** The yearly closures added by hand or from ICS are listed in their own card and can be suspended and resumed without deleting them (`calendario_chiusure.attiva`); a suspended closure stays in the list with a "Sospesa" badge and closes no day. + ### Fines, patron ledger and debt block (plan §7) - **Late fines.** Settings → Loans → "Multe e blocco per debito": daily fine, grace days, maximum fine per loan. These are the global layer of `CirculationPolicy`: a patron type (Settings → Circulation) can set its own daily fine, grace days, cap per loan and debt threshold, and a cell of the rules matrix its own fine, cap and grace for one material; an empty field inherits, 0 switches fines (or the block) off for that type or material. Late days are the days the library is open (closures calendar: weekly closed days, closures, yearly closures, branch closures for the copy's branch); with no closure configured every day counts. The grace period is a threshold (SLiMS rule): up to the grace days nothing is charged, beyond it every late day counts from the first. With the daily fine empty or 0 (the default) nothing changes. The three amounts accept up to 9999.99 (the form says so when a larger figure is typed), and the fine of one loan never exceeds what the ledger stores (99,999,999.99), however long the loan was overdue. The fine is written at return, in the same transaction as the close, on every return path (return screen, quick return, NCIP check-in), and only once per return (idempotency key `late:`). Fines accruing on open overdue loans are shown on the loan, the reader record, the reader's own page and the app, never written in advance. - **Patron ledger.** New append-only table `movimenti_lettore` (charges, payments, waivers, reversals); the balance is the sum of the rows. The reader record has a "Movimenti" section: balance, accruing fines, list, register a payment (cash, card, transfer, other) with a PDF receipt, manual charge (description required), waiver (reason required). Each desk form is recorded once even when it is sent twice (the same form sent again with another amount, method or text is refused with "Richiesta già registrata" instead of being answered with the earlier movement; a page restored from the browser cache gets a fresh form), and a long ledger is shown 200 rows per page. Recording a payment, a charge or a waiver and downloading a receipt are loan-desk actions: they need the loans permission (`loans.manage`, as Staff has), so a custom role that only manages readers sees the ledger read-only. The return screen shows the fine before the confirmation and can waive it on the spot, with a reason; a waived fine no longer appears in the return email, the fine email or on the loan page. A reader with ledger rows is suspended instead of deleted. @@ -73,6 +79,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - `migrate_0.9.0.sql`, section "patron-cards-import": `import_logs.import_type` gains the value `users`; the new enum is computed from the current one, so `marc` and any value added by another section are kept. - `migrate_0.9.0.sql`, section "Circulation policy": creates `tipologie_lettore` and `regole_prestito`, seeds the default "Standard" type with every rule NULL, adds `utenti.tipologia_id` (FK `ON DELETE SET NULL`), `utenti.card_expiry_warning_sent_for` and `copie.solo_consultazione`. Idempotent; no existing row changes. - `migrate_0.9.0.sql`, section "closures calendar": creates `calendario_chiusure` (closure intervals, optional yearly recurrence and branch, FK to `sedi` ON DELETE CASCADE); the weekly closed days are the setting `loans.closed_weekdays`, no schema. Additive and idempotent. +- `migrate_0.9.0.sql`, section "holiday presets": adds `calendario_chiusure.regola_festivita` (varchar(64) NULL, indexed: the holiday rule a preset row stands for, NULL for manual and ICS rows) and `calendario_chiusure.attiva` (default 1). Guarded on `information_schema`, idempotent; existing rows stay manual and active until the first apply adopts the former Italian ones. - `migrate_0.9.0.sql`, section "fines-ledger": creates `movimenti_lettore` and converts every existing `prestiti.sanzione > 0` into one charge (`addebito_perdita` for a lost copy, `addebito_danno` otherwise, key `penalty:`), in batches; maintenance converts anything past the batches and records the scan as complete only once the table exists and a pass has run to the end. Until the migration has run, the queue selection and the eligibility check carry no debt predicate. `prestiti.sanzione` stays and is still written. ## [0.8.9] diff --git a/tests/closures-calendar-admin.spec.js b/tests/closures-calendar-admin.spec.js index aab642e16..e1f0a93ee 100644 --- a/tests/closures-calendar-admin.spec.js +++ b/tests/closures-calendar-admin.spec.js @@ -1,7 +1,7 @@ // @ts-check // Closures calendar, admin page /admin/calendar: a librarian manages the days // the library is closed — closures (add, edit, delete, yearly), the weekly -// closed days, the Italian holidays preset and an ICS import — and checks the +// closed days, the holiday preset (Italy) and an ICS import — and checks the // result in the monthly preview. DB only for setup and verification. // // Run: /tmp/run-e2e.sh tests/closures-calendar-admin.spec.js --config=tests/playwright.config.js --workers=1 @@ -42,6 +42,8 @@ function weekday(iso) { const RUN = Date.now().toString().slice(-7); const TAG = `E2E-CAL-${RUN}`; let startMaxId = 0; +let prevHolidayRows = ''; +let prevHolidaySettings = ''; let prevWeekdays = null; let sedeId = 0; let today = ''; @@ -83,6 +85,8 @@ test.describe('Closures calendar — admin page', () => { test.beforeAll(async ({ browser }) => { startMaxId = parseInt(db('SELECT COALESCE(MAX(id), 0) FROM calendario_chiusure') || '0', 10); + prevHolidayRows = db("SELECT IFNULL(GROUP_CONCAT(CONCAT_WS('|', id, IFNULL(regola_festivita,'-'), descrizione, attiva) ORDER BY id SEPARATOR ';'), '') FROM calendario_chiusure"); + prevHolidaySettings = db("SELECT IFNULL(GROUP_CONCAT(CONCAT_WS('|', setting_key, setting_value) SEPARATOR ';'), '') FROM system_settings WHERE category='calendar' AND setting_key LIKE 'holiday\\_%'"); prevWeekdays = db("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); db("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); db(`INSERT INTO sedi (nome) VALUES ('${TAG} Succursale')`); @@ -96,6 +100,16 @@ test.describe('Closures calendar — admin page', () => { test.afterAll(async () => { db(`DELETE FROM calendario_chiusure WHERE id > ${startMaxId}`); + // The preset may have adopted older rows: put them back as they were. + for (const row of prevHolidayRows ? prevHolidayRows.split(';') : []) { + const [id, rule, description, active] = row.split('|'); + db(`UPDATE calendario_chiusure SET regola_festivita=${rule === '-' ? 'NULL' : `'${rule}'`}, descrizione='${description.replace(/'/g, "''")}', attiva=${parseInt(active, 10)} WHERE id=${parseInt(id, 10)}`); + } + db("DELETE FROM system_settings WHERE category='calendar' AND setting_key LIKE 'holiday\\_%'"); + for (const row of prevHolidaySettings ? prevHolidaySettings.split(';') : []) { + const [key, value] = row.split('|'); + db(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('calendar','${key}','${value}')`); + } if (prevWeekdays === '') { db("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); } else { @@ -242,18 +256,18 @@ test.describe('Closures calendar — admin page', () => { await expect(page.locator(`a.nav-link[href$="/admin/calendar"]`).first()).toHaveCount(1); }); - // ── Italian holidays preset ────────────────────────────────────────────── + // ── Holiday preset (Italy) ─────────────────────────────────────────────── const presetYear = () => parseInt(today.slice(0, 4), 10) + 1; + const fixedItalian = "('01-01','01-06','04-25','05-01','06-02','08-15','11-01','12-08','12-25','12-26')"; - test('Preset 1: importing the Italian holidays adds 10 yearly holidays plus Easter and Easter Monday', async () => { - await page.goto(`${BASE}/admin/calendar`); - await page.selectOption('#preset_year', String(presetYear())); + test('Preset 1: applying the Italian holidays adds the 10 fixed ones as yearly closures plus the movable ones per year', async () => { + await page.goto(`${BASE}/admin/calendar?holiday_country=IT#calendar-preset`); + await page.locator('[data-holiday-select="all"]').click(); await submitAndLand(page, '#import-preset'); - await expect(page.locator('body')).toContainText(/Festività nazionali importate/); - const count = parseInt(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE id > ${startMaxId} AND descrizione IN ('Natale','Santo Stefano','Capodanno','Epifania','Ferragosto','Ognissanti','Immacolata Concezione','Festa della Liberazione','Festa del Lavoro','Festa della Repubblica') AND ricorrente_annuale=1`), 10); + await expect(page.locator('body')).toContainText(/Festività applicate/); + expect(parseInt(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE regola_festivita LIKE 'IT:%' AND ricorrente_annuale=1 AND DATE_FORMAT(data_inizio,'%m-%d') IN ${fixedItalian}`), 10)).toBe(10); // Fixed holidays already present before this run are not duplicated. - expect(count).toBeLessThanOrEqual(10); - expect(parseInt(db("SELECT COUNT(DISTINCT DATE_FORMAT(data_inizio,'%m-%d')) FROM calendario_chiusure WHERE ricorrente_annuale=1 AND sede_id IS NULL AND DATE_FORMAT(data_inizio,'%m-%d') IN ('01-01','01-06','04-25','05-01','06-02','08-15','11-01','12-08','12-25','12-26')"), 10)).toBe(10); + expect(parseInt(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE ricorrente_annuale=1 AND sede_id IS NULL AND DATE_FORMAT(data_inizio,'%m-%d') IN ${fixedItalian}`), 10)).toBe(10); }); test('Preset 2: Easter and Easter Monday are dated by the algorithm for that year', async () => { @@ -267,12 +281,11 @@ test.describe('Closures calendar — admin page', () => { await expect(page.locator(`[data-calendar-day="${addDays(easter, 2)}"]`)).toHaveAttribute('data-closed', '0'); }); - test('Preset 3: importing the same year again adds nothing', async () => { + test('Preset 3: applying the same selection again adds nothing', async () => { const before = db('SELECT COUNT(*) FROM calendario_chiusure'); - await page.goto(`${BASE}/admin/calendar`); - await page.selectOption('#preset_year', String(presetYear())); + await page.goto(`${BASE}/admin/calendar?holiday_country=IT#calendar-preset`); await submitAndLand(page, '#import-preset'); - await expect(page.locator('body')).toContainText(/0 nuove chiusure/); + await expect(page.locator('body')).toContainText(/0 aggiunte, 0 rimosse/); expect(db('SELECT COUNT(*) FROM calendario_chiusure')).toBe(before); }); @@ -283,11 +296,12 @@ test.describe('Closures calendar — admin page', () => { await expect(page.locator(`[data-calendar-day="${farYear}-12-27"]`)).toHaveAttribute('data-closed', '0'); }); - test('Preset 5: the holidays appear in the list as recurring rows', async () => { + test('Preset 5: the holidays appear in the list as recurring holiday rows', async () => { await page.goto(`${BASE}/admin/calendar`); const row = page.locator('#calendar-closures tr', { hasText: 'Ferragosto' }).first(); await expect(row).toContainText(/Ogni anno/); await expect(row).toContainText(/Tutte le sedi/); + await expect(row.locator('[data-badge="holiday"]')).toBeVisible(); }); // ── ICS import ─────────────────────────────────────────────────────────── diff --git a/tests/holidays-preset.spec.js b/tests/holidays-preset.spec.js new file mode 100644 index 000000000..f53eb766f --- /dev/null +++ b/tests/holidays-preset.spec.js @@ -0,0 +1,327 @@ +// @ts-check +// Public holiday presets on /admin/calendar, as a librarian uses them: pick +// the country (and region), look at the holidays with their dates for the +// preview year, tick the ones the library closes for and apply. Applying +// again changes nothing, unticking removes only that holiday, switching +// country replaces the preset, closures added by hand or from an ICS file +// stay, yearly closures can be switched off without deleting them, and the +// rows of the former Italian preset are adopted instead of duplicated. A loan +// due on a ticked holiday moves to the next open day; on an unticked one it +// stays. DB only for setup and verification; dates in the app timezone. +// +// Run: /tmp/run-e2e.sh tests/holidays-preset.spec.js --config=tests/playwright.config.js --workers=1 +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +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'); + +function db(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', '-e', sql); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} +const S = (v) => `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; +function addDays(iso, days) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) throw new Error(`not an ISO date: ${iso}`); + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} +function daysBetween(from, to) { + return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000); +} +function weekday(iso) { + return new Date(`${iso}T00:00:00Z`).getUTCDay(); +} +/** Gregorian Easter Sunday (anonymous algorithm), independent of the app. */ +function easter(year) { + const a = year % 19, b = Math.floor(year / 100), c = year % 100, d = Math.floor(b / 4), e = b % 4; + const f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3), h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7, m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31), day = ((h + l - 7 * m + 114) % 31) + 1; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +const RUN = Date.now().toString().slice(-7); +const TAG = `E2E-HOL-${RUN}`; +let startMaxId = 0; +let today = ''; +let year = 0; +let farYear = 0; +/** @type {string} snapshot of the rows that existed before the run */ +let oldRows = ''; +/** @type {Record} */ +const savedSettings = {}; +const SETTINGS = [['calendar', 'holiday_country'], ['calendar', 'holiday_region'], ['calendar', 'holiday_rules'], ['loans', 'closed_weekdays'], ['loans', 'loan_duration_days'], ['loans', 'max_loan_duration_days']]; +const getSetting = (cat, key) => { + const v = db(`SELECT IFNULL((SELECT setting_value FROM system_settings WHERE category=${S(cat)} AND setting_key=${S(key)}), '\\N')`); + return v === '\\N' || v === 'NULL' ? null : v; +}; +const setSetting = (cat, key, value) => { + if (value === null) db(`DELETE FROM system_settings WHERE category=${S(cat)} AND setting_key=${S(key)}`); + else db(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES (${S(cat)}, ${S(key)}, ${S(value)}) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); +}; +const presetCount = (where = '1=1') => parseInt(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE regola_festivita IS NOT NULL AND ${where}`), 10); +const nonPresetSnapshot = () => db(`SELECT IFNULL(GROUP_CONCAT(CONCAT_WS('|', id, data_inizio, data_fine, descrizione, ricorrente_annuale, attiva) ORDER BY id SEPARATOR ';'), '') FROM calendario_chiusure WHERE regola_festivita IS NULL AND descrizione LIKE ${S(TAG + '%')}`); + +async function login(page) { + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/admin/, { timeout: 30000 }); +} +async function submitAndLand(page, button) { + await Promise.all([page.waitForURL(/\/admin\/calendar/, { timeout: 15000 }), page.locator(button).click()]); +} +/** Choose a country (and region) in the card, as the librarian does: each change reloads the list. */ +async function chooseCountry(page, country, region = '') { + await page.goto(`${BASE}/admin/calendar`); + if ((await page.locator('#holiday_country').inputValue()) !== country) { + await Promise.all([page.waitForURL(new RegExp(`holiday_country=${country}`)), page.selectOption('#holiday_country', country)]); + } + if (region) { + await Promise.all([page.waitForURL(new RegExp(`holiday_region=${region}`)), page.selectOption('#holiday_region', region)]); + } +} +const box = (page, id) => page.locator(`input[data-holiday-rule][value="${id}"]`); +const holidayDate = (page, id) => page.locator(`[data-holiday-row="${id}"] [data-holiday-date]`).getAttribute('data-holiday-date'); + +test.describe.configure({ mode: 'serial' }); + +test.describe('Public holiday presets', () => { + /** @type {import('@playwright/test').Page} */ + let page; + + test.beforeAll(async ({ browser }) => { + startMaxId = parseInt(db('SELECT COALESCE(MAX(id), 0) FROM calendario_chiusure') || '0', 10); + oldRows = db("SELECT IFNULL(GROUP_CONCAT(CONCAT_WS('|', id, IFNULL(regola_festivita,'-'), descrizione, attiva) ORDER BY id SEPARATOR ';'), '') FROM calendario_chiusure"); + for (const [cat, key] of SETTINGS) savedSettings[`${cat}.${key}`] = getSetting(cat, key); + // A clean start: no choice saved, no weekly closed day, no preset rows. + for (const [cat, key] of SETTINGS.slice(0, 3)) setSetting(cat, key, null); + setSetting('loans', 'closed_weekdays', ''); + db('DELETE FROM calendario_chiusure WHERE regola_festivita IS NOT NULL'); + const tz = db("SELECT setting_value FROM system_settings WHERE category='app' AND setting_key='timezone' LIMIT 1") || 'Europe/Rome'; + today = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); + year = parseInt(today.slice(0, 4), 10); + farYear = year + 3; + page = await browser.newPage(); + await login(page); + }); + + test.afterAll(async () => { + db(`DELETE FROM calendario_chiusure WHERE id > ${startMaxId}`); + // Rows older than the run: back to what they were (adoption changes them). + for (const row of oldRows ? oldRows.split(';') : []) { + const [id, rule, description, active] = row.split('|'); + db(`UPDATE calendario_chiusure SET regola_festivita=${rule === '-' ? 'NULL' : S(rule)}, descrizione=${S(description)}, attiva=${parseInt(active, 10)} WHERE id=${parseInt(id, 10)}`); + } + for (const [cat, key] of SETTINGS) setSetting(cat, key, savedSettings[`${cat}.${key}`]); + await page?.close(); + }); + + test('1. the card preselects the country of the installation and lists its holidays with the dates of the preview year', async () => { + await page.goto(`${BASE}/admin/calendar`); + const locale = db("SELECT setting_value FROM system_settings WHERE category='app' AND setting_key='locale' LIMIT 1") || 'it_IT'; + const expected = { it: 'IT', en: 'US', de: 'DE', fr: 'FR', da: 'DK' }[locale.slice(0, 2)] || 'IT'; + await expect(page.locator('#holiday_country')).toHaveValue(expected); + await expect(page.locator('#calendar-preset h2')).toContainText(/Festività nazionali|Public holidays|Feiertage|Jours fériés|Helligdage/); + + await chooseCountry(page, 'IT'); + await expect(page.locator('#holiday_region')).toHaveCount(0); + await expect(page.locator('input[data-holiday-rule]')).toHaveCount(13); + // Native names, the computed date of the preview year, every statutory one ticked. + await expect(page.locator('[data-holiday-row="IT:republic"]')).toContainText('Festa della Repubblica'); + expect(await holidayDate(page, 'IT:easter_monday')).toBe(addDays(easter(year), 1)); + expect(await holidayDate(page, 'IT:christmas')).toBe(`${year}-12-25`); + await expect(page.locator('input[data-holiday-rule]:checked')).toHaveCount(13); + // Another preview year: the movable dates follow. + await Promise.all([page.waitForURL(/holiday_year=/), page.selectOption('#holiday_year', String(year + 1))]); + expect(await holidayDate(page, 'IT:easter_monday')).toBe(addDays(easter(year + 1), 1)); + expect(presetCount()).toBe(0); + }); + + test('2. Germany shows the Länder: Sachsen adds Buß- und Bettag on a Wednesday, Bayern adds Fronleichnam', async () => { + await chooseCountry(page, 'DE'); + await expect(page.locator('#holiday_region option')).toHaveCount(17); + await expect(box(page, 'DE:fronleichnam')).toHaveCount(0); + await expect(box(page, 'DE:tag_der_deutschen_einheit')).toHaveCount(1); + await Promise.all([page.waitForURL(/holiday_region=SN/), page.selectOption('#holiday_region', 'SN')]); + const buss = await holidayDate(page, 'DE:buss_und_bettag'); + expect(buss).toMatch(new RegExp(`^${year}-11-(1[6-9]|2[0-2])$`)); + expect(weekday(/** @type {string} */ (buss))).toBe(3); + await expect(page.locator('[data-holiday-row="DE:buss_und_bettag"]')).toContainText(/regionale|regional|régional/); + await Promise.all([page.waitForURL(/holiday_region=BY/), page.selectOption('#holiday_region', 'BY')]); + await expect(box(page, 'DE:buss_und_bettag')).toHaveCount(0); + expect(await holidayDate(page, 'DE:fronleichnam')).toBe(addDays(easter(year), 60)); + }); + + test('3. ticking a subset and applying stores only those holidays, shown as holidays without edit or delete', async () => { + // A closure by hand and an ICS import first: the preset must leave them alone. + await page.goto(`${BASE}/admin/calendar`); + await page.fill('#closure_start', `${farYear}-03-03`); + await page.fill('#closure_description', `${TAG} manual`); + await submitAndLand(page, '#save-closure'); + const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//E2E//Pinakes//IT', + 'BEGIN:VEVENT', 'UID:h1@e2e', `DTSTART;VALUE=DATE:${farYear}0707`, `SUMMARY:${TAG} ics once`, 'END:VEVENT', + 'BEGIN:VEVENT', 'UID:h2@e2e', `DTSTART;VALUE=DATE:${farYear}0909`, 'RRULE:FREQ=YEARLY', `SUMMARY:${TAG} ics yearly`, 'END:VEVENT', + 'END:VCALENDAR', ''].join('\r\n'); + await page.setInputFiles('#ics_file', { name: 'chiusure.ics', mimeType: 'text/calendar', buffer: Buffer.from(ics) }); + await submitAndLand(page, '#import-ics'); + expect(nonPresetSnapshot().split(';')).toHaveLength(3); + + await chooseCountry(page, 'US'); + await page.locator('[data-holiday-select="none"]').click(); + await expect(page.locator('input[data-holiday-rule]:checked')).toHaveCount(0); + for (const id of ['US:new_year', 'US:independence_day', 'US:thanksgiving', 'US:christmas']) await box(page, id).check(); + await submitAndLand(page, '#import-preset'); + await expect(page.locator('body')).toContainText(/Festività applicate: 12 aggiunte, 0 rimosse|Holidays applied: 12 added, 0 removed/); + expect(db("SELECT GROUP_CONCAT(DISTINCT regola_festivita ORDER BY regola_festivita) FROM calendario_chiusure WHERE regola_festivita IS NOT NULL")) + .toBe('US:christmas,US:independence_day,US:new_year,US:thanksgiving'); + // Observed rules: one row per year, from this year to two years ahead. + expect(presetCount("regola_festivita='US:thanksgiving' AND ricorrente_annuale=0")).toBe(3); + // The choice is what the card shows from now on. + await expect(page.locator('#holiday_country')).toHaveValue('US'); + await expect(box(page, 'US:thanksgiving')).toBeChecked(); + await expect(box(page, 'US:labor_day')).not.toBeChecked(); + // The list marks them as holidays; they are changed from the selection only. + const row = page.locator('#calendar-closures tr', { has: page.locator('[data-badge="holiday"]') }).filter({ hasText: 'Thanksgiving Day' }).first(); + await expect(row).toBeVisible(); + await expect(row.locator('[data-action="delete-closure"]')).toHaveCount(0); + await expect(row.locator('[data-action="edit-closure"]')).toHaveCount(0); + }); + + test('4. applying the same selection again adds and removes nothing', async () => { + const before = db('SELECT COUNT(*) FROM calendario_chiusure'); + await chooseCountry(page, 'US'); + await submitAndLand(page, '#import-preset'); + await expect(page.locator('body')).toContainText(/Festività applicate: 0 aggiunte, 0 rimosse|Holidays applied: 0 added, 0 removed/); + expect(db('SELECT COUNT(*) FROM calendario_chiusure')).toBe(before); + }); + + test('5. unticking one holiday removes only its rows', async () => { + await chooseCountry(page, 'US'); + await box(page, 'US:christmas').uncheck(); + await submitAndLand(page, '#import-preset'); + await expect(page.locator('body')).toContainText(/Festività applicate: 0 aggiunte, 3 rimosse|Holidays applied: 0 added, 3 removed/); + expect(presetCount("regola_festivita='US:christmas'")).toBe(0); + expect(presetCount()).toBe(9); + await expect(box(page, 'US:christmas')).not.toBeChecked(); + }); + + test('6. a loan due on a ticked holiday moves to the next open day, on an unticked holiday it stays', async () => { + // The loan duration is chosen (setup in the DB) so that the default due + // date of the admin loan form lands on the holiday; the form is used as-is. + const minDays = 2; + const ticked = db(`SELECT MIN(data_inizio) FROM calendario_chiusure WHERE regola_festivita IS NOT NULL AND data_inizio >= ${S(addDays(today, minDays))}`); + expect(ticked).toMatch(/^\d{4}-\d{2}-\d{2}$/); + const closed = new Set(db(`SELECT data_inizio FROM calendario_chiusure WHERE regola_festivita IS NOT NULL OR descrizione LIKE ${S(TAG + '%')}`).split('\n')); + let expectedDue = addDays(ticked, 1); + while (closed.has(expectedDue)) expectedDue = addDays(expectedDue, 1); + const dueFor = async (target) => { + const days = daysBetween(today, target); + setSetting('loans', 'max_loan_duration_days', String(Math.max(days, 90))); + setSetting('loans', 'loan_duration_days', String(days)); + await page.goto(`${BASE}/admin/loans/create`); + return page.locator('#data_scadenza').inputValue(); + }; + expect(await dueFor(ticked)).toBe(expectedDue); + + // Labor Day (first Monday of September) is not ticked: a loan due then stays. + await chooseCountry(page, 'US'); + let labor = await holidayDate(page, 'US:labor_day'); + if (!labor || labor < addDays(today, minDays)) { + await Promise.all([page.waitForURL(/holiday_year=/), page.selectOption('#holiday_year', String(year + 1))]); + labor = await holidayDate(page, 'US:labor_day'); + } + expect(weekday(/** @type {string} */ (labor))).toBe(1); + expect(await dueFor(/** @type {string} */ (labor))).toBe(labor); + }); + + test('7. switching country replaces the preset and select none clears it; manual and ICS closures stay', async () => { + const manual = nonPresetSnapshot(); + await chooseCountry(page, 'DE', 'BY'); + await box(page, 'DE:buss_und_bettag').count().then((n) => expect(n).toBe(0)); + await submitAndLand(page, '#import-preset'); + await expect(page.locator('body')).toContainText(/9 rimosse|9 removed/); + expect(presetCount("regola_festivita LIKE 'US:%'")).toBe(0); + expect(presetCount("regola_festivita='DE:fronleichnam'")).toBe(3); + expect(presetCount("regola_festivita='DE:neujahr' AND ricorrente_annuale=1")).toBe(1); + expect(nonPresetSnapshot()).toBe(manual); + // Bayern closes on Corpus Christi. + const corpus = addDays(easter(year + 1), 60); + await page.goto(`${BASE}/admin/calendar?month=${corpus.slice(0, 7)}`); + await expect(page.locator(`[data-calendar-day="${corpus}"]`)).toHaveAttribute('data-closed', '1'); + + await chooseCountry(page, 'DE', 'BY'); + await page.locator('[data-holiday-select="none"]').click(); + await submitAndLand(page, '#import-preset'); + expect(presetCount()).toBe(0); + expect(nonPresetSnapshot()).toBe(manual); + await page.goto(`${BASE}/admin/calendar?month=${corpus.slice(0, 7)}`); + await expect(page.locator(`[data-calendar-day="${corpus}"]`)).toHaveAttribute('data-closed', '0'); + await expect(page.locator('#calendar-closures')).toContainText(`${TAG} ics yearly`); + await expect(page.locator('#calendar-closures')).toContainText(`${TAG} manual`); + }); + + test('8. a yearly closure added by hand can be switched off and on without deleting it', async () => { + const day = `${farYear}-10-13`; + await page.goto(`${BASE}/admin/calendar`); + await page.fill('#closure_start', day); + await page.fill('#closure_description', `${TAG} patrono`); + await page.check('#closure_yearly'); + await submitAndLand(page, '#save-closure'); + const id = parseInt(db(`SELECT id FROM calendario_chiusure WHERE descrizione=${S(TAG + ' patrono')}`), 10); + await page.goto(`${BASE}/admin/calendar`); + // The ICS yearly closure is listed as well; the one-off ones are not. + await expect(page.locator('#calendar-yearly')).toContainText(`${TAG} ics yearly`); + await expect(page.locator('#calendar-yearly')).not.toContainText(`${TAG} manual`); + await expect(page.locator(`#yearly_closure_${id}`)).toBeChecked(); + await page.locator(`#yearly_closure_${id}`).uncheck(); + await submitAndLand(page, '#save-yearly'); + await expect(page.locator('body')).toContainText(/Chiusure annuali salvate: 1 modificate|Yearly closures saved: 1 changed/); + expect(db(`SELECT CONCAT(COUNT(*), '|', MIN(attiva)) FROM calendario_chiusure WHERE id=${id}`)).toBe('1|0'); + await expect(page.locator(`#calendar-closures tr[data-closure-id="${id}"] [data-badge="inactive"]`)).toBeVisible(); + await page.goto(`${BASE}/admin/calendar?month=${farYear + 1}-10`); + await expect(page.locator(`[data-calendar-day="${farYear + 1}-10-13"]`)).toHaveAttribute('data-closed', '0'); + + await page.goto(`${BASE}/admin/calendar`); + await page.locator(`#yearly_closure_${id}`).check(); + await submitAndLand(page, '#save-yearly'); + expect(db(`SELECT attiva FROM calendario_chiusure WHERE id=${id}`)).toBe('1'); + await page.goto(`${BASE}/admin/calendar?month=${farYear + 1}-10`); + await expect(page.locator(`[data-calendar-day="${farYear + 1}-10-13"]`)).toHaveAttribute('data-closed', '1'); + }); + + test('9. the rows of the former Italian preset are recognised, not duplicated', async () => { + // What the earlier importer stored: fixed holidays as yearly rows, Easter + // Monday dated for one year, labels in the installation language. + const pasquetta = addDays(easter(year + 1), 1); + db(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale) VALUES + ('${year}-12-25','${year}-12-25','Natale',1), ('${year}-08-15','${year}-08-15','Ferragosto',1), ('${pasquetta}','${pasquetta}','Lunedì dell''Angelo (Pasquetta)',0)`); + const legacyIds = db("SELECT GROUP_CONCAT(id ORDER BY id) FROM calendario_chiusure WHERE regola_festivita IS NULL AND descrizione IN ('Natale','Ferragosto','Lunedì dell''Angelo (Pasquetta)')"); + await chooseCountry(page, 'IT'); + await page.locator('[data-holiday-select="all"]').click(); + await submitAndLand(page, '#import-preset'); + await expect(page.locator('body')).toContainText(/3 già presenti riconosciute|3 already present recognised/); + expect(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE ricorrente_annuale=1 AND DATE_FORMAT(data_inizio,'%m-%d')='12-25' AND sede_id IS NULL`)).toBe('1'); + expect(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE data_inizio='${pasquetta}' AND ricorrente_annuale=0`)).toBe('1'); + expect(db("SELECT GROUP_CONCAT(id ORDER BY id) FROM calendario_chiusure WHERE regola_festivita IN ('IT:christmas','IT:ferragosto') OR (regola_festivita='IT:easter_monday' AND data_inizio=" + S(pasquetta) + ')')).toBe(legacyIds); + // Applying once more is a no-op. + await chooseCountry(page, 'IT'); + await submitAndLand(page, '#import-preset'); + await expect(page.locator('body')).toContainText(/Festività applicate: 0 aggiunte, 0 rimosse|Holidays applied: 0 added, 0 removed/); + }); +}); From 3f298d86c99abbc92fcd3344004ee4311af8552f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:31:49 +0100 Subject: [PATCH 197/398] Tests: feature toggles registry, route gate, services off, migration section, settings and stocktake The unit suites of the modules themselves switch their module on with Features::override(), so they test the module whatever the database says. --- tests/feature-toggle-stocktake.spec.js | 108 +++++++++ tests/feature-toggles-registry.unit.php | 124 +++++++++++ tests/feature-toggles-routes.unit.php | 138 ++++++++++++ tests/feature-toggles-services.unit.php | 210 ++++++++++++++++++ tests/feature-toggles-settings.spec.js | 180 +++++++++++++++ tests/helpers/feature-toggles.js | 191 ++++++++++++++++ tests/marc-import-robustness.unit.php | 3 + tests/marc-library.unit.php | 3 + .../migration-0.9.0-feature-toggles.unit.php | 183 +++++++++++++++ tests/migration-0.9.0-fines-ledger.unit.php | 3 + tests/minor-features-090.unit.php | 5 + tests/patron-ledger.unit.php | 3 + tests/stack-090-review-fixes.unit.php | 3 + tests/stocktake-snapshot.unit.php | 3 + 14 files changed, 1157 insertions(+) create mode 100644 tests/feature-toggle-stocktake.spec.js create mode 100644 tests/feature-toggles-registry.unit.php create mode 100644 tests/feature-toggles-routes.unit.php create mode 100644 tests/feature-toggles-services.unit.php create mode 100644 tests/feature-toggles-settings.spec.js create mode 100644 tests/helpers/feature-toggles.js create mode 100644 tests/migration-0.9.0-feature-toggles.unit.php diff --git a/tests/feature-toggle-stocktake.spec.js b/tests/feature-toggle-stocktake.spec.js new file mode 100644 index 000000000..ad422eacd --- /dev/null +++ b/tests/feature-toggle-stocktake.spec.js @@ -0,0 +1,108 @@ +// @ts-check +/** + * features.stocktake: an administrator switches the inventory stocktake off + * in Settings → Modules and back on; menu, pages, form posts, statistics and + * the custom-role navigation follow. DB only for setup and verification. + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-stocktake.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const RUN = Date.now().toString(36); +const NAME = `ZZ_FT_STOCK_${RUN}`; +const ROLE = `ZZ FT Revisore ${RUN}`; +const AUDITOR = `zz-ft-stock-${RUN}@test.local`; +const PASS = 'Test1234!'; + +function cleanup() { + ft.dbQuery(`DELETE FROM inventario_sessioni WHERE nome LIKE 'ZZ\\_FT\\_STOCK\\_%'`); + ft.removeUsers('zz-ft-stock-%@test.local'); + ft.dbQuery(`DELETE FROM ruoli WHERE nome LIKE 'ZZ FT Revisore %'`); +} + +test.describe.serial('Feature toggle: inventory stocktake', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['stocktake']); + cleanup(); + // A closed session, so the statistics section has something to show when on. + ft.dbQuery(`INSERT INTO inventario_sessioni (nome, stato, totale_attese, aperta_il, chiusa_il) VALUES (${ft.sqlString(NAME + '_closed')}, 'chiusa', 0, NOW() - INTERVAL 1 HOUR, NOW())`); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + cleanup(); + await page?.close(); + }); + + test('1. switched off: no sidebar entry, the page sends back to the dashboard with the reason', async () => { + await ft.setModuleInUi(page, 'stocktake', false); + await expect(page.locator('#feature_stocktake')).not.toBeChecked(); + await page.goto(`${ft.BASE}/admin/dashboard`); + await expect(page.locator('a.nav-link[href$="/admin/inventory"]')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/inventory`); + await expect(page).toHaveURL(/\/admin\/dashboard/); + await expect(page.locator('body')).toContainText('Funzione disattivata nelle impostazioni.'); + }); + + test('2. switched off: opening a session by form post is refused and nothing is written', async () => { + const csrf = await ft.csrfToken(page); + const res = await page.request.post(`${ft.BASE}/admin/inventory`, { form: { csrf_token: csrf, nome: `${NAME}_refused` }, maxRedirects: 0 }); + expect(res.status()).toBe(302); + expect(String(res.headers().location || '')).toContain('/admin/dashboard'); + expect(ft.dbQuery(`SELECT COUNT(*) FROM inventario_sessioni WHERE nome=${ft.sqlString(NAME + '_refused')}`)).toBe('0'); + // A JSON caller (the scanner) gets the machine-readable refusal. + const json = await page.request.get(`${ft.BASE}/admin/inventory/1/state`, { headers: { Accept: 'application/json' }, maxRedirects: 0 }); + expect(json.status()).toBe(404); + expect((await json.json()).code).toBe('feature_disabled'); + }); + + test('3. switched off: the statistics page has no stocktake section', async () => { + await page.goto(`${ft.BASE}/admin/statistics`); + await expect(page.locator('h1, h2').first()).toBeVisible(); + await expect(page.locator('#stats-stocktake')).toHaveCount(0); + }); + + test('4. switched back on: entry, page, form and statistics are back', async () => { + await ft.setModuleInUi(page, 'stocktake', true); + await page.goto(`${ft.BASE}/admin/dashboard`); + await expect(page.locator('a.nav-link[href$="/admin/inventory"]')).toHaveCount(1); + await page.locator('a.nav-link[href$="/admin/inventory"]').click(); + await expect(page).toHaveURL(/\/admin\/inventory$/); + await page.fill('#inventory-name', `${NAME}_open`); + await page.locator('#inventory-open-form button[type="submit"]').click(); + await expect(page).toHaveURL(/\/admin\/inventory\/\d+/); + expect(ft.dbQuery(`SELECT COUNT(*) FROM inventario_sessioni WHERE nome=${ft.sqlString(NAME + '_open')}`)).toBe('1'); + await page.goto(`${ft.BASE}/admin/statistics`); + await expect(page.locator('#stats-stocktake')).toBeVisible(); + }); + + test('5. a custom stocktake role finds nothing to open while the module is off', async ({ browser }) => { + ft.dbQuery(`INSERT INTO ruoli (nome, permessi) VALUES (${ft.sqlString(ROLE)}, '["backend.access","inventory.manage"]')`); + const roleId = Number(ft.dbQuery(`SELECT id FROM ruoli WHERE nome=${ft.sqlString(ROLE)}`)); + ft.createUser(AUDITOR, PASS, 'standard', roleId); + const auditor = await browser.newPage(); + try { + await ft.loginAs(auditor, AUDITOR, PASS); + await auditor.goto(`${ft.BASE}/admin/dashboard`); + await expect(auditor.locator(`a[href$="/admin/inventory"]`).first()).toBeVisible(); + await ft.setModuleInUi(page, 'stocktake', false); + await auditor.goto(`${ft.BASE}/admin/dashboard`); + await expect(auditor.locator(`a[href$="/admin/inventory"]`)).toHaveCount(0); + await auditor.goto(`${ft.BASE}/admin/inventory`); + await expect(auditor).not.toHaveURL(/\/admin\/inventory/); + } finally { + await auditor.close(); + await ft.setModuleInUi(page, 'stocktake', true); + } + }); +}); diff --git a/tests/feature-toggles-registry.unit.php b/tests/feature-toggles-registry.unit.php new file mode 100644 index 000000000..795856b72 --- /dev/null +++ b/tests/feature-toggles-registry.unit.php @@ -0,0 +1,124 @@ +getFileName()); +$check(str_contains($source, "'features' => array_map(static fn (string \$v): bool => \$v === '1', Features::DEFAULTS)"), 'ConfigStore::all() defaults come from Features::DEFAULTS'); +$method = $reflection->getMethod('loadDatabaseSettings'); +$body = implode('', array_slice(file((string) $reflection->getFileName()), $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1)); +$check(str_contains($body, "\$raw['features']") && str_contains($body, 'array_keys(Features::DEFAULTS)'), 'loadDatabaseSettings() maps the features category for every key'); + +// Unknown keys fail loudly. +$threw = false; +try { + Features::enabled('stocktak'); +} catch (\InvalidArgumentException) { + $threw = true; +} +$check($threw, 'an unknown key throws instead of silently answering'); +$threw = false; +try { + new \App\Middleware\FeatureGateMiddleware('nope'); +} catch (\InvalidArgumentException) { + $threw = true; +} +$check($threw, 'FeatureGateMiddleware refuses an unknown key at boot'); + +// Overrides (test seam) and catalogue mode. +Features::override('fines', true); +$check(Features::enabled('fines') === true, 'override(true) switches a module on'); +Features::override('fines', false); +$check(Features::enabled('fines') === false, 'override(false) switches it off'); +Features::resetOverrides(); + +// Installer seeds: FRESH, one row per key, every locale. +foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) { + $seed = (string) file_get_contents("{$root}/installer/database/data_{$locale}.sql"); + preg_match_all("/\('features', '([a-z_]+)', '([01])',/", $seed, $m, PREG_SET_ORDER); + $seeded = []; + foreach ($m as $row) { + $seeded[$row[1]] = $row[2]; + } + $check($seeded === Features::FRESH, "data_{$locale}.sql seeds Features::FRESH"); +} + +// Migration: the static upgrade defaults; the data-dependent ones fall back to '0'. +$migration = (string) file_get_contents($root . '/installer/database/migrations/migrate_0.9.0.sql'); +$check(preg_match('/-- ── feature toggles ──(.*?)-- ── end feature toggles ──/s', $migration, $sec) === 1, 'migrate_0.9.0.sql has the feature toggles section'); +$section = $sec[1] ?? ''; +foreach ($keys as $k) { + if (preg_match("/SELECT 'features', '{$k}', ('([01])'|@ft_val),/", $section, $mm) !== 1) { + $check(false, "migration seeds {$k}"); + continue; + } + $value = $mm[1] === '@ft_val' ? '0' : $mm[2]; + $check($value === Features::DEFAULTS[$k], "migration seeds {$k} with the upgrade default " . Features::DEFAULTS[$k] . ($mm[1] === '@ft_val' ? ' (unless the library has data for it)' : '')); +} + +// Settings views: a switch per key, in its own tab. +$modulesTab = (string) file_get_contents($root . '/app/Views/settings/modules-tab.php'); +foreach (Features::MODULES_TAB as $k) { + $check(str_contains($modulesTab, "'{$k}'"), "Settings → Modules renders {$k}"); +} +$loansTab = (string) file_get_contents($root . '/app/Views/settings/loans-tab.php'); +foreach (Features::LOANS_TAB as $k) { + $check(str_contains($loansTab, "id=\"feature_{$k}\""), "Settings → Loans renders feature_{$k}"); +} + +// Labels and help: a translation in every locale. +$featuresSource = (string) file_get_contents($root . '/app/Support/Features.php'); +preg_match_all("/=> __\\('((?:[^'\\\\]|\\\\.)*)'\\)/", $featuresSource, $strings); +$strings = array_map(static fn (string $s): string => str_replace("\\'", "'", $s), $strings[1]); +$check(count($strings) === 2 * count($keys), 'Features defines a label and a help text per module'); +foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) { + $catalogue = json_decode((string) file_get_contents("{$root}/locale/{$locale}.json"), true); + $missing = array_values(array_filter($strings, static fn (string $s): bool => !isset($catalogue[$s]) || $catalogue[$s] === '')); + $check($missing === [], "{$locale}: every label and help text is translated" . ($missing !== [] ? ' (missing: ' . $missing[0] . ')' : '')); +} +foreach ($keys as $k) { + $check(Features::label($k) !== '' && Features::help($k) !== '', "{$k}: label() and help() answer"); +} + +echo PHP_EOL . "{$passed} PASS, {$failed} FAIL" . PHP_EOL; +exit($failed === 0 ? 0 : 1); diff --git a/tests/feature-toggles-routes.unit.php b/tests/feature-toggles-routes.unit.php new file mode 100644 index 000000000..d6f34b92b --- /dev/null +++ b/tests/feature-toggles-routes.unit.php @@ -0,0 +1,138 @@ +), added + * FIRST (so it runs after authentication: a visitor who is not signed in + * goes to the login, not to a refusal that reveals the configuration); + * - no other route carries it (the loans ICS feed and rotate-link stay); + * - Permissions::permissionForPath() is unchanged (defence in depth); + * - off: 302 to the dashboard, or 404 feature_disabled for JSON callers; + * on: the request reaches the handler. + * + * Run: php tests/feature-toggles-routes.unit.php (the exit code is the verdict) + */ + +use App\Middleware\FeatureGateMiddleware; +use App\Support\Features; + +$root = dirname(__DIR__); +require $root . '/vendor/autoload.php'; + +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . PHP_EOL; + $ok ? $passed++ : $failed++; +}; + +/** The module of an admin path, null when the path is not gated (plan §2). */ +$expected = static function (string $path): ?string { + return match (true) { + str_starts_with($path, '/admin/inventory') => 'stocktake', + in_array($path, ['/admin/books/import/marc', '/admin/books/import/marc/upload', '/admin/books/import/marc/chunk', '/admin/books/export/marc'], true) => 'marc', + str_starts_with($path, '/admin/books/copy-cataloging') => 'marc', + in_array($path, ['/admin/books/{id:\d+}/catalogue-card', '/admin/books/catalogue-cards'], true) => 'catalogue_cards', + str_starts_with($path, '/admin/suppliers') => 'suppliers', + str_starts_with($path, '/admin/attendance') => 'visitor_log', + in_array($path, ['/admin/settings/cards', '/admin/users/{id:\d+}/card-pdf', '/admin/users/cards-pdf', '/admin/users/{id:\d+}/photo', '/admin/users/{id:\d+}/photo/delete'], true) => 'patron_cards', + str_starts_with($path, '/admin/users/import') => 'user_import', + str_starts_with($path, '/admin/settings/circulation'), $path === '/admin/books/copies/{id:\d+}/consultation' => 'circulation_rules', + ($path === '/admin/calendar' || str_starts_with($path, '/admin/calendar/')) && $path !== '/admin/calendar/rotate-link' => 'closures_calendar', + $path === '/admin/statistics/fines.csv', str_starts_with($path, '/admin/users/{id:\d+}/ledger/') => 'fines', + default => null, + }; +}; + +/** + * Each route registration: path, the gate key (or null) and whether the gate + * is the first middleware added. + * + * @return list + */ +$routes = static function (string $source): array { + $out = []; + preg_match_all('/\$app->(?:get|post)\(\'([^\']+)\',/', $source, $m, PREG_OFFSET_CAPTURE); + foreach ($m[1] as $i => [$path, $offset]) { + $next = $m[0][$i + 1][1] ?? strlen($source); + $chunk = substr($source, $offset, $next - $offset); + $gate = preg_match("/FeatureGateMiddleware\\('([a-z_]+)'\\)/", $chunk, $g) === 1 ? $g[1] : null; + // First ->add() after the handler closes (`})` or `$inventory('x'))`). + $first = $gate !== null && preg_match("/(?:\\}\\)|\\('[a-zA-Z]+'\\)\\))->add\\(new \\\\App\\\\Middleware\\\\FeatureGateMiddleware\\('{$gate}'\\)\\)/", $chunk) === 1; + $out[] = ['path' => $path, 'gate' => $gate, 'first' => $first]; + } + return $out; +}; + +$web = $routes((string) file_get_contents($root . '/app/Routes/web.php')); +$z39 = $routes((string) file_get_contents($root . '/storage/plugins/z39-server/Z39ServerPlugin.php')); +$all = array_merge($web, $z39); +$check(count($all) > 300, 'the route files were parsed (' . count($all) . ' routes)'); + +$gatedByKey = []; +$wrong = []; +$notFirst = []; +foreach ($all as $route) { + $want = $expected($route['path']); + if ($want !== $route['gate']) { + $wrong[] = $route['path'] . ' (gate ' . ($route['gate'] ?? 'none') . ', expected ' . ($want ?? 'none') . ')'; + } + if ($route['gate'] !== null) { + $gatedByKey[$route['gate']] = ($gatedByKey[$route['gate']] ?? 0) + 1; + if (!$route['first']) { + $notFirst[] = $route['path']; + } + } +} +$check($wrong === [], 'every module route carries its own gate and no other route carries one' . ($wrong !== [] ? ': ' . implode('; ', array_slice($wrong, 0, 5)) : '')); +$check($notFirst === [], 'the gate is the first middleware added (it runs after authentication)' . ($notFirst !== [] ? ': ' . implode('; ', $notFirst) : '')); +$routeModules = ['stocktake', 'marc', 'catalogue_cards', 'suppliers', 'visitor_log', 'patron_cards', 'user_import', 'circulation_rules', 'closures_calendar', 'fines']; +foreach ($routeModules as $key) { + $check(($gatedByKey[$key] ?? 0) > 0, "{$key}: " . ($gatedByKey[$key] ?? 0) . ' gated routes'); +} +// rda_types has no route of its own: its fields live in the book form. +$check(!isset($gatedByKey['rda_types']), 'rda_types gates no route (form fields only)'); +$check(count(array_filter($all, static fn (array $r): bool => $r['path'] === '/admin/calendar/rotate-link' && $r['gate'] === null)) === 1, 'the loans ICS rotate-link is not part of the closures calendar module'); + +// Defence in depth: the permission map is unchanged. +$check(\App\Support\Permissions::permissionForPath('/admin/inventory') === 'inventory.manage', 'permissionForPath(/admin/inventory) is still inventory.manage'); +$check(\App\Support\Permissions::permissionForPath('/admin/suppliers') !== null, 'permissionForPath(/admin/suppliers) is still mapped'); + +// Behaviour. +$handler = new class implements \Psr\Http\Server\RequestHandlerInterface { + public int $calls = 0; + public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface + { + $this->calls++; + return new \Slim\Psr7\Response(200); + } +}; +$factory = new \Slim\Psr7\Factory\ServerRequestFactory(); +$_SESSION = []; +Features::override('suppliers', false); +$res = (new FeatureGateMiddleware('suppliers'))->process($factory->createServerRequest('GET', '/admin/suppliers'), $handler); +$check($res->getStatusCode() === 302 && str_ends_with($res->getHeaderLine('Location'), '/admin/dashboard'), 'off, browser: 302 to the dashboard'); +$check(($_SESSION['error_message'] ?? '') !== '', 'off, browser: the reason is flashed'); +$check($handler->calls === 0, 'off: the handler never runs'); +$res = (new FeatureGateMiddleware('suppliers'))->process($factory->createServerRequest('POST', '/admin/suppliers/create')->withHeader('Accept', 'application/json'), $handler); +$payload = json_decode((string) $res->getBody(), true); +$check($res->getStatusCode() === 404 && ($payload['code'] ?? '') === 'feature_disabled' && ($payload['error'] ?? false) === true, 'off, JSON caller: 404 feature_disabled'); +$res = (new FeatureGateMiddleware('suppliers'))->process($factory->createServerRequest('POST', '/admin/inventory/1/scan')->withHeader('X-Requested-With', 'XMLHttpRequest'), $handler); +$check($res->getStatusCode() === 404, 'off, XHR: 404 feature_disabled'); +Features::override('suppliers', true); +$res = (new FeatureGateMiddleware('suppliers'))->process($factory->createServerRequest('GET', '/admin/suppliers'), $handler); +$check($res->getStatusCode() === 200 && $handler->calls === 1, 'on: the request reaches the handler'); +Features::resetOverrides(); + +// requireOrRedirect(): the controller-level re-check. +Features::override('marc', false); +$check(Features::requireOrRedirect('marc', new \Slim\Psr7\Response())?->getStatusCode() === 302, 'requireOrRedirect() refuses a module that is off'); +Features::override('marc', true); +$check(Features::requireOrRedirect('marc', new \Slim\Psr7\Response()) === null, 'requireOrRedirect() lets a module that is on through'); +Features::resetOverrides(); + +echo PHP_EOL . "{$passed} PASS, {$failed} FAIL" . PHP_EOL; +exit($failed === 0 ? 0 : 1); diff --git a/tests/feature-toggles-services.unit.php b/tests/feature-toggles-services.unit.php new file mode 100644 index 000000000..29551733b --- /dev/null +++ b/tests/feature-toggles-services.unit.php @@ -0,0 +1,210 @@ +set_charset('utf8mb4'); + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — the behavioural checks are mandatory: {$e->getMessage()}\n"); + exit(1); +} +$tableExists = static fn (string $t): bool => (int) $db->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . $db->real_escape_string($t) . "'")->fetch_row()[0] === 1; +foreach (['calendario_chiusure', 'tipologie_lettore', 'movimenti_lettore', 'presenze', 'fornitori'] as $t) { + if (!$tableExists($t)) { + fwrite(STDERR, "FAIL: {$t} missing — apply migrate_0.9.0.sql first\n"); + exit(1); + } +} + +$run = bin2hex(random_bytes(3)); +$saved = []; +foreach (['debt_block_threshold'] as $k) { + $r = $db->query("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = '{$k}'")->fetch_row(); + $saved[$k] = $r === null ? null : (string) $r[0]; +} +$closureIds = []; +$cleanup = static function () use ($db, &$closureIds): void { + foreach ($closureIds as $id) { + $db->query('DELETE FROM calendario_chiusure WHERE id = ' . (int) $id); + } + $db->query("DELETE m FROM movimenti_lettore m JOIN utenti u ON u.id = m.utente_id WHERE u.email LIKE 'zz-ftsvc-%@test.local'"); + $db->query("DELETE FROM presenze WHERE nome_ospite LIKE 'ZZ_FTSVC_%'"); + $db->query("DELETE c FROM copie c JOIN libri l ON l.id = c.libro_id WHERE l.titolo LIKE 'ZZ\\_FTSVC\\_%'"); + $db->query("DELETE FROM libri WHERE titolo LIKE 'ZZ\\_FTSVC\\_%'"); + $db->query("DELETE FROM fornitori WHERE nome LIKE 'ZZ_FTSVC_%'"); + $db->query("DELETE FROM utenti WHERE email LIKE 'zz-ftsvc-%@test.local'"); +}; +$cleanup(); + +try { + // ── A. closures calendar ──────────────────────────────────────────────── + $closed = LibraryCalendar::shift(DateHelper::today(), 3); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione) VALUES ('{$closed}', '{$closed}', 'ZZ_FTSVC closure')"); + $closureIds[] = (int) $db->insert_id; + Features::override('closures_calendar', false); + LibraryCalendar::reset(); + $check(!LibraryCalendar::forDb($db)->hasRules() || LibraryCalendar::fromDatabase($db)->closedDatesBetween($closed, $closed) === [], 'A off: the stored closure is not applied'); + $check(LibraryCalendar::dueDate($db, $closed) === $closed, 'A off: a due date on the closed day stays where it is (0.8.9 behaviour)'); + $check(LibraryCalendar::fromDatabase($db)->closedDatesBetween($closed, $closed) === [], 'A off: availability publishes no closed date'); + $check(CirculationPolicy::dueDateFor(DateHelper::today(), 3, $db) === $closed, 'A off: CirculationPolicy::dueDateFor (web, Mobile API, NCIP) agrees'); + Features::override('closures_calendar', true); + LibraryCalendar::reset(); + $check(LibraryCalendar::fromDatabase($db)->closedDatesBetween($closed, $closed) === [$closed], 'A on: the closure is published again'); + $check(LibraryCalendar::dueDate($db, $closed) > $closed, 'A on: the due date moves to the next open day'); + LibraryCalendar::reset(); + + // ── B. circulation rules ──────────────────────────────────────────────── + Features::override('circulation_rules', false); + $policy = new CirculationPolicy($db); + $check($policy->schemaReady() && !$policy->rulesActive(), 'B off: the tables exist, the rules do not apply'); + $check($policy->types() === [] && $policy->rules() === [], 'B off: no patron type and no rule'); + $check($policy->lendableCopySql('c') === '1 = 1', 'B off: every copy counts as lendable'); + $db->query("INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES ('ZZ_FTSVC_consult_{$run}', 1, 1)"); + $bookId = (int) $db->insert_id; + $db->query("INSERT INTO copie (libro_id, numero_inventario, stato, solo_consultazione) VALUES ({$bookId}, 'ZZFT{$run}', 'disponibile', 1)"); + $copyId = (int) $db->insert_id; + $check(!$policy->isConsultationCopy($copyId) && !$policy->onlyConsultationCopies($bookId), 'B off: a consultation-only copy is lendable'); + $check($policy->forViewer(null, $bookId)->loanable === true, 'B off: the title is loanable for everybody'); + Features::override('circulation_rules', true); + $policy = new CirculationPolicy($db); + $check($policy->rulesActive() && $policy->types() !== [], 'B on: the seeded patron types apply'); + $check($policy->isConsultationCopy($copyId) && $policy->onlyConsultationCopies($bookId), 'B on: the consultation flag applies again'); + + // ── C. fines ──────────────────────────────────────────────────────────── + $hash = password_hash('x', PASSWORD_DEFAULT); + $db->query("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, privacy_accettata) VALUES ('ZZFS{$run}', 'Zz', 'Svc', 'zz-ftsvc-{$run}@test.local', '" . $db->real_escape_string($hash) . "', 'attivo', 1, 'standard', 1)"); + $patron = (int) $db->insert_id; + $db->query("INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES ({$patron}, 'addebito_manuale', 100.00, 'ZZ_FTSVC debt', NOW())"); + $db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', 'debt_block_threshold', '1.00') ON DUPLICATE KEY UPDATE setting_value = '1.00'"); + \App\Support\ConfigStore::clearCache(); + Features::override('fines', false); + $check(PatronLedger::schemaReady($db) && !PatronLedger::active($db), 'C off: the ledger exists, fines do not apply'); + $check((new CirculationPolicy($db))->debtBlockError($patron) === null, 'C off: a patron 100 over the threshold is not blocked'); + $check(LoanEligibility::checkUser($db, $patron) === null, 'C off: LoanEligibility admits the patron (every channel)'); + $check(LoanEligibility::debtBlockSql('u', $db) === '1 = 1', 'C off: the queue predicate admits everybody'); + $check(PatronLedger::forDb($db)->chargeLateReturn(PHP_INT_MAX) === 0, 'C off: a late return charges nothing'); + $preview = PatronLedger::forDb($db)->previewLateFine(PHP_INT_MAX); + $check($preview['enabled'] === false && $preview['cents'] === 0, 'C off: the return screen previews no fine'); + $check(PatronLedger::forDb($db)->accruedFines($patron) === [], 'C off: no accruing fine is shown'); + $check(PatronLedger::forDb($db)->backfillLegacyPenalties() === null, 'C off: the legacy backfill waits (not marked as done)'); + $check((new \App\Support\NotificationService($db))->sendFineChargedNotification(PHP_INT_MAX) === false, 'C off: no fine_charged email'); + $check(PatronLedger::forDb($db)->balanceCents($patron) === 10000, 'C off: the rows already written stay'); + Features::override('fines', true); + $check((new CirculationPolicy($db))->debtBlockError($patron) === 'debt_block', 'C on: the same patron is blocked'); + $check(LoanEligibility::debtBlockSql('u', $db) !== '1 = 1', 'C on: the queue predicate checks the balance'); + + // ── D. RDA types ──────────────────────────────────────────────────────── + $row = ['tipo_media' => 'libro', 'rda_content_type' => 'prm', 'rda_media_type' => 'c', 'rda_carrier_type' => 'cr']; + Features::override('rda_types', false); + $derived = \App\Support\RdaTypes::derive('libro'); + $check(\App\Support\RdaTypes::overrides($row) === [], 'D off: the stored overrides are not applied'); + $check(\App\Support\RdaTypes::effective($row) === $derived, 'D off: the record shows the types derived from the material'); + $check(\App\Support\Marc\MarcMediaType::rdaCodes($row) === \App\Support\Marc\MarcMediaType::rdaCodes(['tipo_media' => 'libro']), 'D off: MARC 336/337/338 are those of 0.8.9'); + Features::override('rda_types', true); + $check(\App\Support\RdaTypes::effective($row)['content'] === 'prm', 'D on: the override applies again'); + + // ── E. suppliers ──────────────────────────────────────────────────────── + $db->query("INSERT INTO fornitori (nome) VALUES ('ZZ_FTSVC_A_{$run}'), ('ZZ_FTSVC_B_{$run}')"); + $supA = (int) $db->query("SELECT id FROM fornitori WHERE nome = 'ZZ_FTSVC_A_{$run}'")->fetch_row()[0]; + $supB = (int) $db->query("SELECT id FROM fornitori WHERE nome = 'ZZ_FTSVC_B_{$run}'")->fetch_row()[0]; + $db->query("UPDATE libri SET fornitore_id = {$supA} WHERE id = {$bookId}"); + Features::override('suppliers', false); + \App\Support\CataloguingExtras::save($db, $bookId, ['fornitore_id' => '']); + \App\Support\CataloguingExtras::assignSupplier($db, $bookId, $supB); + $check((int) $db->query("SELECT fornitore_id FROM libri WHERE id = {$bookId}")->fetch_row()[0] === $supA, 'E off: neither a save nor an import touches the stored supplier'); + Features::override('suppliers', true); + \App\Support\CataloguingExtras::save($db, $bookId, ['fornitore_id' => (string) $supB]); + $check((int) $db->query("SELECT fornitore_id FROM libri WHERE id = {$bookId}")->fetch_row()[0] === $supB, 'E on: the form writes the supplier'); + + // ── F. visitor log retention ──────────────────────────────────────────── + $db->query("INSERT INTO presenze (tipo, nome_ospite, created_at) VALUES ('ospite', 'ZZ_FTSVC_old_{$run}', NOW() - INTERVAL 20 YEAR)"); + $visit = (int) $db->insert_id; + $db->query("DELETE FROM system_settings WHERE category = 'maintenance' AND setting_key = 'last_run'"); + Features::override('visitor_log', false); + $result = (new \App\Support\MaintenanceService($db))->runAll(); + $check((int) ($result['attendance_purged'] ?? -1) === 0, 'F off: maintenance reports no visitor purged'); + $check((int) $db->query("SELECT COUNT(*) FROM presenze WHERE id = {$visit}")->fetch_row()[0] === 1, 'F off: a visit older than the retention stays'); + Features::override('visitor_log', true); + (new \App\Support\MaintenanceService($db))->runAll(); + $check((int) $db->query("SELECT COUNT(*) FROM presenze WHERE id = {$visit}")->fetch_row()[0] === 0, 'F on: maintenance purges it'); + + // ── G. Mobile API /health ─────────────────────────────────────────────── + require_once $root . '/storage/plugins/mobile-api/MobileApiPlugin.php'; + $health = static function () use ($db): array { + $req = (new \Slim\Psr7\Factory\ServerRequestFactory())->createServerRequest('GET', '/api/v1/health'); + $res = (new \App\Plugins\MobileApi\Controllers\HealthController($db))->index($req, new \Slim\Psr7\Response()); + $body = json_decode((string) $res->getBody(), true); + return (array) ($body['data']['features'] ?? []); + }; + foreach ([false, true] as $on) { + foreach (['fines', 'closures_calendar', 'circulation_rules'] as $k) { + Features::override($k, $on); + } + $flags = $health(); + $check(($flags['fines'] ?? null) === $on && ($flags['closures_calendar'] ?? null) === $on && ($flags['circulation_rules'] ?? null) === $on, + '/health features.fines, closures_calendar, circulation_rules = ' . ($on ? 'true' : 'false')); + } + $check(array_key_exists('loans', $health()), '/health keeps every existing flag (additive)'); +} finally { + Features::resetOverrides(); + LibraryCalendar::reset(); + foreach ($saved as $k => $v) { + if ($v === null) { + $db->query("DELETE FROM system_settings WHERE category = 'loans' AND setting_key = '{$k}'"); + } else { + $db->query("UPDATE system_settings SET setting_value = '" . $db->real_escape_string($v) . "' WHERE category = 'loans' AND setting_key = '{$k}'"); + } + } + \App\Support\ConfigStore::clearCache(); + $cleanup(); +} + +echo PHP_EOL . "{$passed} PASS, {$failed} FAIL" . PHP_EOL; +exit($failed === 0 ? 0 : 1); diff --git a/tests/feature-toggles-settings.spec.js b/tests/feature-toggles-settings.spec.js new file mode 100644 index 000000000..77543b4ba --- /dev/null +++ b/tests/feature-toggles-settings.spec.js @@ -0,0 +1,180 @@ +// @ts-check +/** + * Optional modules (features.*): the Settings → Modules tab and the three + * circulation switches of Settings → Loans, driven like an administrator. + * Every value is read back through HTTP (the rendered settings page), never + * from the database alone: a switch saved but never mapped by ConfigStore + * would look saved in the DB and stay inert (the allowlist trap). + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggles-settings.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); +const { flushCache } = require('./helpers/flush-cache'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const RUN = Date.now().toString(36); +const STAFF_EMAIL = `zz-ft-staff-${RUN}@test.local`; +const PASS = 'Test1234!'; +const MODULE_KEYS = ft.FEATURE_KEYS.filter((k) => !ft.LOANS_TAB_KEYS.includes(k)); + +test.describe.serial('Feature toggles: settings', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let catalogueMode; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(); + catalogueMode = ft.dbQuery("SELECT COALESCE(MAX(setting_value), '0') FROM system_settings WHERE category='system' AND setting_key='catalogue_mode'"); + ft.removeUsers('zz-ft-staff-%@test.local'); + ft.createUser(STAFF_EMAIL, PASS, 'staff'); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + ft.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('system', 'catalogue_mode', ${ft.sqlString(catalogueMode)}) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); + await ft.restoreFeatures(snapshot); + ft.removeUsers('zz-ft-staff-%@test.local'); + await page?.close(); + }); + + test('1. the administrator finds the Modules section with one switch per module', async () => { + await page.goto(`${ft.BASE}/admin/settings`); + await page.locator('a[data-settings-section="modules"]').first().click(); + await expect(page.locator('section[data-settings-panel="modules"]')).toBeVisible(); + for (const key of MODULE_KEYS) { + await expect(page.locator(`#feature_${key}`)).toHaveCount(1); + } + for (const group of ['catalogue', 'users', 'public']) { + await expect(page.locator(`[data-modules-group="${group}"]`)).toBeVisible(); + } + // The circulation modules sit in the Loans tab, next to their settings. + await page.goto(`${ft.BASE}/admin/settings?tab=loans`); + for (const key of ft.LOANS_TAB_KEYS) { + await expect(page.locator(`#feature_${key}`)).toBeEnabled(); + } + }); + + test('2. every switch is labelled and described, and the keyboard toggles it', async () => { + await page.goto(`${ft.BASE}/admin/settings?tab=modules`); + for (const key of MODULE_KEYS) { + const box = page.locator(`#feature_${key}`); + await expect(box).toHaveAttribute('aria-labelledby', `feature_${key}_label`); + await expect(box).toHaveAttribute('aria-describedby', `feature_${key}_desc`); + await expect(page.locator(`#feature_${key}_label`)).not.toBeEmpty(); + await expect(page.locator(`#feature_${key}_desc`)).not.toBeEmpty(); + } + const box = page.locator('#feature_marc'); + const before = await box.isChecked(); + await box.focus(); + await page.keyboard.press('Space'); + expect(await box.isChecked()).toBe(!before); + await page.keyboard.press('Space'); + expect(await box.isChecked()).toBe(before); + }); + + test('3. each module saved off and back on reads back from the page (HTTP round trip)', async () => { + // All Modules-tab switches off in one save. + await page.goto(`${ft.BASE}/admin/settings?tab=modules`); + for (const key of MODULE_KEYS) { + if (await page.locator(`#feature_${key}`).isChecked()) await page.locator(`label:has(#feature_${key})`).click(); + } + await page.locator('#modules-settings-form button[type="submit"]').click(); + await page.waitForURL(/tab=modules/); + await expect(page.locator('body')).toContainText('Moduli aggiornati correttamente.'); + for (const key of MODULE_KEYS) { + await expect(page.locator(`#feature_${key}`)).not.toBeChecked(); + } + // The circulation switches from the Loans tab. + for (const key of ft.LOANS_TAB_KEYS) { + await ft.setModuleInUi(page, key, false); + await expect(page.locator(`#feature_${key}`)).not.toBeChecked(); + } + // Back on, every one. + await page.goto(`${ft.BASE}/admin/settings?tab=modules`); + for (const key of MODULE_KEYS) { + await page.locator(`label:has(#feature_${key})`).click(); + } + await page.locator('#modules-settings-form button[type="submit"]').click(); + await page.waitForURL(/tab=modules/); + for (const key of MODULE_KEYS) { + await expect(page.locator(`#feature_${key}`)).toBeChecked(); + } + for (const key of ft.LOANS_TAB_KEYS) { + await ft.setModuleInUi(page, key, true); + await expect(page.locator(`#feature_${key}`)).toBeChecked(); + } + expect(Object.values(ft.readFeatures())).toEqual(ft.FEATURE_KEYS.map(() => '1')); + }); + + test('4. staff reaches the settings but neither sees the Modules section nor changes a module', async ({ browser }) => { + const staff = await browser.newPage(); + try { + await ft.loginAs(staff, STAFF_EMAIL, PASS); + await staff.goto(`${ft.BASE}/admin/settings?tab=modules`); + await expect(staff.locator('a[data-settings-section="modules"]')).toHaveCount(0); + await expect(staff.locator('section[data-settings-panel="modules"]')).toHaveCount(0); + // ?tab=modules falls back to the default section, like an unknown tab. + await expect(staff.locator('section[data-settings-panel="general"]')).toBeVisible(); + // In the Loans tab the circulation switches are shown, locked. + await staff.goto(`${ft.BASE}/admin/settings?tab=loans`); + await expect(staff.locator('#feature_fines')).toBeDisabled(); + await expect(staff.locator('[data-testid="modules-admin-only"]')).toBeVisible(); + // A hand-built POST changes nothing. + const csrf = await staff.locator('input[name="csrf_token"]').first().inputValue(); + const res = await staff.request.post(`${ft.BASE}/admin/settings/modules`, { form: { csrf_token: csrf }, maxRedirects: 0 }); + expect(res.status()).toBe(302); + } finally { + await staff.close(); + } + expect(Object.values(ft.readFeatures())).toEqual(ft.FEATURE_KEYS.map(() => '1')); + }); + + test('5. catalogue mode hides visitor log and closures calendar even with their modules on', async () => { + await page.goto(`${ft.BASE}/admin/dashboard`); + await expect(page.locator('a.nav-link[href$="/admin/attendance"]')).toHaveCount(1); + await expect(page.locator('a.nav-link[href$="/admin/calendar"]')).toHaveCount(1); + ft.dbQuery("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('system', 'catalogue_mode', '1') ON DUPLICATE KEY UPDATE setting_value='1'"); + await flushCache(); + await page.goto(`${ft.BASE}/admin/dashboard`); + await expect(page.locator('a.nav-link[href$="/admin/attendance"]')).toHaveCount(0); + await expect(page.locator('a.nav-link[href$="/admin/calendar"]')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/attendance`); + await expect(page).toHaveURL(/\/admin\/dashboard/); + // The switches themselves stay as saved. + await page.goto(`${ft.BASE}/admin/settings?tab=loans`); + await expect(page.locator('#feature_fines')).toBeChecked(); + ft.dbQuery(`UPDATE system_settings SET setting_value=${ft.sqlString(catalogueMode)} WHERE category='system' AND setting_key='catalogue_mode'`); + await flushCache(); + await page.goto(`${ft.BASE}/admin/dashboard`); + await expect(page.locator('a.nav-link[href$="/admin/attendance"]')).toHaveCount(1); + }); + + test('6. the labels are translated: an English administrator sees no Italian base string', async ({ browser }) => { + const email = `zz-ft-staff-en-${RUN}@test.local`; + const id = ft.createUser(email, PASS, 'admin'); + ft.dbQuery(`UPDATE utenti SET locale='en_US' WHERE id=${id}`); + const en = await browser.newPage(); + try { + await ft.loginAs(en, email, PASS); + await en.goto(`${ft.BASE}/admin/settings?tab=modules`); + const panel = en.locator('section[data-settings-panel="modules"]'); + await expect(panel).toBeVisible(); + const text = await panel.innerText(); + if (text.includes('MARC import and export')) { + expect(text).toContain('Visitor log'); + expect(text).not.toContain('Registro presenze'); + expect(text).not.toContain('Salva moduli'); + } else { + // The installation renders one language for everybody: still no raw key. + expect(text).toContain('Import ed export MARC'); + } + } finally { + await en.close(); + } + }); +}); diff --git a/tests/helpers/feature-toggles.js b/tests/helpers/feature-toggles.js new file mode 100644 index 000000000..341610682 --- /dev/null +++ b/tests/helpers/feature-toggles.js @@ -0,0 +1,191 @@ +// @ts-check +/** + * Optional 0.9.0 modules (features.* in system_settings, App\Support\Features). + * + * Two ways in: + * - enableFeatures()/restoreFeatures(): setup only, for the specs of a module + * that is off by default on some installs (an upgrade, or a fresh install + * for the visitor log and fines). They write the rows directly and flush + * the app cache, then put back exactly what was there. + * - setModuleInUi(): what the feature-toggle specs test, the administrator + * flipping the switch in Settings → Modules (or → Loans for the three + * circulation modules) and saving the form. + */ +const { execFileSync } = require('child_process'); +const { flushCache } = require('./flush-cache'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +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_NAME = process.env.E2E_DB_NAME || ''; + +/** Same order and keys as Features::DEFAULTS. */ +const FEATURE_KEYS = ['stocktake', 'marc', 'visitor_log', 'catalogue_cards', 'suppliers', 'rda_types', 'patron_cards', 'user_import', 'circulation_rules', 'closures_calendar', 'fines']; +/** Saved from Settings → Loans; every other key from Settings → Modules. */ +const LOANS_TAB_KEYS = ['circulation_rules', 'closures_calendar', 'fines']; + +function dbQuery(sql) { + const args = []; + if (DB_HOST) args.push('-h', DB_HOST); else if (DB_SOCKET) args.push('-S', DB_SOCKET); + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 15000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} + +function assertKey(key) { + if (!FEATURE_KEYS.includes(key)) throw new Error(`Unknown feature: ${key}`); +} + +/** The stored rows: key → '1' | '0' | null (no row = upgrade default). */ +function readFeatures(keys = FEATURE_KEYS) { + /** @type {Record} */ + const out = {}; + for (const key of keys) { + assertKey(key); + const n = dbQuery(`SELECT COUNT(*) FROM system_settings WHERE category='features' AND setting_key='${key}'`); + out[key] = n === '1' + ? dbQuery(`SELECT setting_value FROM system_settings WHERE category='features' AND setting_key='${key}'`) + : null; + } + return out; +} + +/** Write key → '1'/'0'/true/false (null deletes the row), then flush the app cache. */ +async function writeFeatures(values) { + for (const [key, value] of Object.entries(values)) { + assertKey(key); + if (value === null || value === undefined) { + dbQuery(`DELETE FROM system_settings WHERE category='features' AND setting_key='${key}'`); + } else { + const v = value === true || value === '1' ? '1' : '0'; + dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('features', '${key}', '${v}') ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); + } + } + await flushCache(); +} + +/** Switch $keys on for a spec's setup; returns the snapshot restoreFeatures() puts back. */ +async function enableFeatures(keys) { + const snapshot = readFeatures(keys); + await writeFeatures(Object.fromEntries(keys.map((k) => [k, '1']))); + return snapshot; +} + +/** Switch $keys off for a spec's setup; returns the snapshot. */ +async function disableFeatures(keys) { + const snapshot = readFeatures(keys); + await writeFeatures(Object.fromEntries(keys.map((k) => [k, '0']))); + return snapshot; +} + +async function restoreFeatures(snapshot) { + if (snapshot) await writeFeatures(snapshot); +} + +/** + * Flip one module like an administrator: open the settings tab that holds + * it, click the switch when it is not already in the wanted state, save. + * The page must be signed in as an administrator. + * + * @param {import('@playwright/test').Page} page + */ +async function setModuleInUi(page, key, on) { + assertKey(key); + const tab = LOANS_TAB_KEYS.includes(key) ? 'loans' : 'modules'; + await page.goto(`${BASE}/admin/settings?tab=${tab}`); + const box = page.locator(`#feature_${key}`); + await box.waitFor({ state: 'attached', timeout: 15000 }); + if ((await box.isChecked()) !== on) { + await page.locator(`label:has(#feature_${key})`).click(); + } + const form = tab === 'loans' ? 'form[action$="/admin/settings/loans"]' : '#modules-settings-form'; + await page.locator(`${form} button[type="submit"]`).click(); + await page.waitForURL(new RegExp(`tab=${tab}`), { timeout: 20000 }); +} + +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const configured = Boolean(ADMIN_EMAIL && ADMIN_PASS && DB_USER && DB_NAME); + +/** SQL string literal. */ +function sqlString(value) { + return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; +} + +/** + * Sign in through the login form (whatever the localized slug). + * @param {import('@playwright/test').Page} page + */ +async function loginAs(page, email, password) { + for (const slug of ['accedi', 'login', 'anmelden', 'connexion', 'log-ind']) { + const resp = await page.goto(`${BASE}/${slug}`).catch(() => null); + if (resp && resp.status() === 200 && (await page.locator('input[name="email"]').count()) > 0) break; + } + await page.fill('input[name="email"]', email); + await page.fill('input[name="password"]', password); + await page.locator('button[type="submit"]').first().click(); + await page.locator('input[name="password"]').waitFor({ state: 'detached', timeout: 45000 }); +} + +/** @param {import('@playwright/test').Page} page */ +async function loginAdmin(page) { + await page.goto(`${BASE}/admin/dashboard`); + if (/\/admin\//.test(page.url()) && (await page.locator('input[name="password"]').count()) === 0) return; + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + if (!/\/admin\//.test(page.url())) await page.goto(`${BASE}/admin/dashboard`); +} + +function phpHash(password) { + return execFileSync('php', ['-r', `echo password_hash(${JSON.stringify(password)}, PASSWORD_DEFAULT);`], { encoding: 'utf-8', timeout: 5000 }).trim(); +} + +/** + * A throwaway account (tipo_utente staff/standard, optional custom role). + * Returns its id; removeUsers() deletes it. + */ +function createUser(email, password, tipo = 'standard', ruoloId = null) { + const card = `ZZT${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`.slice(0, 20); + dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, ruolo_id, privacy_accettata, locale) VALUES (${sqlString(card)}, 'Zz', 'Toggle', ${sqlString(email)}, ${sqlString(phpHash(password))}, 'attivo', 1, ${sqlString(tipo)}, ${ruoloId === null ? 'NULL' : Number(ruoloId)}, 1, 'it_IT')`); + return Number(dbQuery(`SELECT id FROM utenti WHERE email=${sqlString(email)}`)); +} + +function removeUsers(emailLike) { + const ids = `SELECT id FROM utenti WHERE email LIKE ${sqlString(emailLike)}`; + for (const sql of [ + `DELETE FROM user_sessions WHERE utente_id IN (${ids})`, + `DELETE FROM log_modifiche WHERE tabella='utenti' AND record_id IN (${ids})`, + ]) { + try { dbQuery(sql); } catch (_) { /* optional table */ } + } + dbQuery(`DELETE FROM utenti WHERE email LIKE ${sqlString(emailLike)}`); +} + +/** + * The CSRF token of the signed-in session, read from a page that has one. + * @param {import('@playwright/test').Page} page + */ +async function csrfToken(page) { + await page.goto(`${BASE}/admin/settings?tab=loans`); + return page.locator('input[name="csrf_token"]').first().inputValue(); +} + +module.exports = { + BASE, + configured, + dbQuery, + sqlString, + loginAs, + loginAdmin, + createUser, + removeUsers, + csrfToken, + FEATURE_KEYS, + LOANS_TAB_KEYS, + readFeatures, + writeFeatures, + enableFeatures, + disableFeatures, + restoreFeatures, + setModuleInUi, +}; diff --git a/tests/marc-import-robustness.unit.php b/tests/marc-import-robustness.unit.php index 7c14691ca..e923d9f04 100644 --- a/tests/marc-import-robustness.unit.php +++ b/tests/marc-import-robustness.unit.php @@ -26,6 +26,9 @@ $root = dirname(__DIR__); require $root . '/vendor/autoload.php'; +// The module under test is on, whatever features.* says in the database +// this runs against (Settings → Modules / Loans). +\App\Support\Features::override('rda_types', true); require_once $root . '/app/helpers.php'; mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); diff --git a/tests/marc-library.unit.php b/tests/marc-library.unit.php index 8a6525041..78e5a8d32 100644 --- a/tests/marc-library.unit.php +++ b/tests/marc-library.unit.php @@ -28,6 +28,9 @@ $root = dirname(__DIR__); require $root . '/vendor/autoload.php'; +// The module under test is on, whatever features.* says in the database +// this runs against (Settings → Modules / Loans). +\App\Support\Features::override('rda_types', true); require_once $root . '/app/helpers.php'; $fx = __DIR__ . '/fixtures/marc'; diff --git a/tests/migration-0.9.0-feature-toggles.unit.php b/tests/migration-0.9.0-feature-toggles.unit.php new file mode 100644 index 000000000..0a068e5ae --- /dev/null +++ b/tests/migration-0.9.0-feature-toggles.unit.php @@ -0,0 +1,183 @@ +_migtest_ft"). Run: php tests/migration-0.9.0-feature-toggles.unit.php + */ + +use App\Support\Features; + +require dirname(__DIR__) . '/vendor/autoload.php'; +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + +$root = dirname(__DIR__); +$env = Dotenv\Dotenv::parse((string) file_get_contents($root . '/.env')); +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null); +$sandbox = getenv('E2E_MIG_DB') ?: preg_replace('/^fabiodal_biblioteca_/', 'fabiodal_', (string) $env['DB_NAME']) . '_migtest_ft'; +if (preg_match('/^[A-Za-z0-9_]+$/', $sandbox) !== 1) { + fwrite(STDERR, "invalid sandbox name\n"); + exit(1); +} + +$checks = 0; +$check = static function (bool $ok, string $label) use (&$checks): void { + if (!$ok) { + throw new RuntimeException('FAIL ' . $label); + } + $checks++; + echo "OK {$label}\n"; +}; + +$migration = (string) file_get_contents($root . '/installer/database/migrations/migrate_0.9.0.sql'); +if (preg_match('/-- ── feature toggles ──(.*?)-- ── end feature toggles ──/s', $migration, $m) !== 1) { + fwrite(STDERR, "feature toggles section not found in migrate_0.9.0.sql\n"); + exit(1); +} +$section = $m[1]; +$schema = (string) file_get_contents($root . '/installer/database/schema.sql'); +if (preg_match('/CREATE TABLE `system_settings` \(.*?\) ENGINE=InnoDB[^;]*;/s', $schema, $ss) !== 1) { + fwrite(STDERR, "system_settings not found in schema.sql\n"); + exit(1); +} +$settingsTable = $ss[0]; + +$host = $env['DB_HOST'] ?? 'localhost'; +$port = (int) ($env['DB_PORT'] ?? 3306); +$admin = new mysqli($host, $env['DB_USER'], $env['DB_PASS'] ?? '', '', $port, $socket); + +/** Execute SQL the way the Updater does. */ +$run = static function (mysqli $db, string $sql): void { + $sql = implode("\n", array_filter(explode("\n", $sql), static fn (string $l): bool => !str_starts_with(trim($l), '--'))); + foreach (explode(';', $sql) as $statement) { + if (trim($statement) !== '') { + $result = $db->query($statement); + if ($result instanceof mysqli_result) { + $result->free(); + } + } + } +}; +$fresh = static function () use ($admin, $sandbox, $host, $env, $port, $socket, $settingsTable): mysqli { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); + $admin->query("CREATE DATABASE `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $db = new mysqli($host, $env['DB_USER'], $env['DB_PASS'] ?? '', $sandbox, $port, $socket); + $db->set_charset('utf8mb4'); + $db->query($settingsTable); + return $db; +}; +/** @return array */ +$rows = static function (mysqli $db): array { + $out = []; + foreach ($db->query("SELECT setting_key, setting_value FROM system_settings WHERE category = 'features' ORDER BY setting_key")->fetch_all(MYSQLI_ASSOC) as $r) { + $out[$r['setting_key']] = (string) $r['setting_value']; + } + return $out; +}; +$sorted = static function (array $a): array { + ksort($a); + return $a; +}; + +$exit = 1; +try { + // A. 0.8.9: no 0.9.0 table at all. + $db = $fresh(); + $check($rows($db) === [], 'A: the 0.8.9 database has no features rows'); + $run($db, $section); + $check($rows($db) === $sorted(Features::DEFAULTS), 'A: one row per module with the upgrade default (Features::DEFAULTS)'); + $run($db, $section); + $check((int) $db->query("SELECT COUNT(*) FROM system_settings WHERE category = 'features'")->fetch_row()[0] === count(Features::DEFAULTS), 'A: a second run adds no row'); + $check($rows($db) === $sorted(Features::DEFAULTS), 'A: a second run changes no value'); + + // B. a saved value survives. + $db->query("UPDATE system_settings SET setting_value = '1' WHERE category = 'features' AND setting_key = 'fines'"); + $db->query("UPDATE system_settings SET setting_value = '0' WHERE category = 'features' AND setting_key = 'marc'"); + $run($db, $section); + $after = $rows($db); + $check($after['fines'] === '1' && $after['marc'] === '0', 'B: values the operator saved are never overwritten'); + $db->close(); + + // C. an RC upgrade with data for the modules. + $db = $fresh(); + $db->query('CREATE TABLE inventario_sessioni (id int NOT NULL AUTO_INCREMENT PRIMARY KEY, nome varchar(255) NOT NULL)'); + $db->query('CREATE TABLE fornitori (id int NOT NULL AUTO_INCREMENT PRIMARY KEY, nome varchar(255) NOT NULL)'); + $db->query('CREATE TABLE presenze (id int NOT NULL AUTO_INCREMENT PRIMARY KEY, tipo varchar(10) NOT NULL)'); + $db->query('CREATE TABLE libri (id int NOT NULL AUTO_INCREMENT PRIMARY KEY, titolo varchar(255) NOT NULL, deleted_at datetime DEFAULT NULL, rda_content_type varchar(10) DEFAULT NULL, rda_media_type varchar(10) DEFAULT NULL, rda_carrier_type varchar(10) DEFAULT NULL)'); + $db->query('CREATE TABLE movimenti_lettore (id int NOT NULL AUTO_INCREMENT PRIMARY KEY, importo decimal(10,2) NOT NULL, chiave_idempotenza varchar(100) DEFAULT NULL)'); + $run($db, $section); + $empty = $rows($db); + $check($empty === $sorted(Features::DEFAULTS), 'C: the 0.9.0 tables empty (a plain 0.8.9 upgrade): the upgrade defaults'); + $db->query("DELETE FROM system_settings WHERE category = 'features'"); + $db->query("INSERT INTO inventario_sessioni (nome) VALUES ('Scaffale A')"); + $db->query("INSERT INTO fornitori (nome) VALUES ('Libreria')"); + $db->query("INSERT INTO presenze (tipo) VALUES ('ospite')"); + $db->query("INSERT INTO libri (titolo, rda_content_type) VALUES ('Con override', 'txt')"); + $db->query("INSERT INTO movimenti_lettore (importo, chiave_idempotenza) VALUES (5.00, 'penalty:1')"); + $run($db, $section); + $used = $rows($db); + $check($used['stocktake'] === '1', 'C: a stocktake session keeps the stocktake on'); + $check($used['suppliers'] === '1', 'C: a supplier keeps the suppliers on'); + $check($used['visitor_log'] === '1', 'C: a visit keeps the visitor log on'); + $check($used['rda_types'] === '1', 'C: an RDA override keeps the RDA types on'); + $check($used['fines'] === '0', 'C: legacy penalties converted by the migration alone do not turn fines on'); + $db->query("DELETE FROM system_settings WHERE category = 'features' AND setting_key = 'fines'"); + $db->query("INSERT INTO movimenti_lettore (importo, chiave_idempotenza) VALUES (0.50, 'late:7')"); + $run($db, $section); + $check($rows($db)['fines'] === '1', 'C: a late fine already charged keeps fines on'); + $db->query("DELETE FROM system_settings WHERE category = 'features' AND setting_key = 'fines'"); + $db->query('DELETE FROM movimenti_lettore'); + $db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', 'daily_fine', '0,50')"); + $run($db, $section); + $check($rows($db)['fines'] === '1', 'C: a configured daily fine keeps fines on'); + $db->query("DELETE FROM system_settings WHERE category = 'features' AND setting_key = 'rda_types'"); + $db->query("UPDATE libri SET deleted_at = NOW()"); + $run($db, $section); + $check($rows($db)['rda_types'] === '0', 'C: an override on an archived (soft-deleted) title does not count'); + $db->close(); + + // D. the whole file in order, then the section again: idempotent with every section. + $old = shell_exec('git -C ' . escapeshellarg($root) . ' show v0.8.9:installer/database/schema.sql 2>/dev/null') + ?: shell_exec('git -C ' . escapeshellarg($root) . ' show 12f05750:installer/database/schema.sql 2>/dev/null'); + if (is_string($old) && str_contains($old, 'CREATE TABLE `system_settings`')) { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); + $admin->query("CREATE DATABASE `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $db = new mysqli($host, $env['DB_USER'], $env['DB_PASS'] ?? '', $sandbox, $port, $socket); + $db->set_charset('utf8mb4'); + $db->multi_query($old); + do { + if ($r = $db->store_result()) { + $r->free(); + } + } while ($db->more_results() && $db->next_result()); + $run($db, $migration); + $check($rows($db) === $sorted(Features::DEFAULTS), 'D: the full migrate_0.9.0.sql on the 0.8.9 schema seeds the upgrade defaults'); + $run($db, $migration); + $check($rows($db) === $sorted(Features::DEFAULTS), 'D: the full file a second time changes nothing'); + $db->close(); + } else { + echo "0.8.9 schema.sql not in the local git history (shallow clone): full-file run skipped, sections A-C cover the effect\n"; + } + + $version = json_decode((string) file_get_contents($root . '/version.json'), true)['version']; + $check(version_compare('0.9.0', $version, '<='), 'the release version includes the migration'); + echo "SUCCESS {$checks} migration checks\n"; + $exit = 0; +} catch (\Throwable $e) { + echo $e->getMessage() . "\n"; +} finally { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); +} +exit($exit); diff --git a/tests/migration-0.9.0-fines-ledger.unit.php b/tests/migration-0.9.0-fines-ledger.unit.php index ac143c6a0..a539d814d 100644 --- a/tests/migration-0.9.0-fines-ledger.unit.php +++ b/tests/migration-0.9.0-fines-ledger.unit.php @@ -25,6 +25,9 @@ $root = dirname(__DIR__); require $root . '/vendor/autoload.php'; +// The module under test is on, whatever features.* says in the database +// this runs against (Settings → Modules / Loans). +\App\Support\Features::override('fines', true); mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); $passed = 0; diff --git a/tests/minor-features-090.unit.php b/tests/minor-features-090.unit.php index 166064485..02a65b390 100644 --- a/tests/minor-features-090.unit.php +++ b/tests/minor-features-090.unit.php @@ -6,6 +6,11 @@ * helpers, the bulk-card id parser and the statistics extension-point shape. */ require dirname(__DIR__) . '/vendor/autoload.php'; +// The module under test is on, whatever features.* says in the database +// this runs against (Settings → Modules / Loans). +\App\Support\Features::override('rda_types', true); +\App\Support\Features::override('suppliers', true); +\App\Support\Features::override('visitor_log', true); if (!function_exists('__')) { function __(string $s, mixed ...$a): string { return $a ? sprintf($s, ...$a) : $s; } } use App\Controllers\CatalogueCardController; diff --git a/tests/patron-ledger.unit.php b/tests/patron-ledger.unit.php index 00d04d4e6..0ddf1f4b7 100644 --- a/tests/patron-ledger.unit.php +++ b/tests/patron-ledger.unit.php @@ -25,6 +25,9 @@ $root = dirname(__DIR__); require $root . '/vendor/autoload.php'; +// The module under test is on, whatever features.* says in the database +// this runs against (Settings → Modules / Loans). +\App\Support\Features::override('fines', true); mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); $passed = 0; diff --git a/tests/stack-090-review-fixes.unit.php b/tests/stack-090-review-fixes.unit.php index 99f388387..95a8ec8a3 100644 --- a/tests/stack-090-review-fixes.unit.php +++ b/tests/stack-090-review-fixes.unit.php @@ -32,6 +32,9 @@ $root = dirname(__DIR__); require $root . '/vendor/autoload.php'; +// The 0.9.0 modules under test are on, whatever features.* says in the +// database this runs against (Settings → Modules / Loans). +\App\Support\Features::overrideAll(true); require_once $root . '/app/helpers.php'; $pass = 0; diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index 9db378e23..ba77e23f7 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -14,6 +14,9 @@ */ require dirname(__DIR__) . '/vendor/autoload.php'; +// The module under test is on, whatever features.* says in the database +// this runs against (Settings → Modules / Loans). +\App\Support\Features::override('stocktake', true); require_once dirname(__DIR__) . '/app/helpers.php'; mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); From f4f51dae81ffaafc1266e9217a6988249bd48a19 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:39:45 +0100 Subject: [PATCH 198/398] =?UTF-8?q?Tests:=20catalogue=20and=20users=20modu?= =?UTF-8?q?les=20switched=20off=20and=20on=20from=20Settings=20=E2=86=92?= =?UTF-8?q?=20Modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/feature-toggle-catalogue-cards.spec.js | 66 ++++++++++++++ tests/feature-toggle-marc.spec.js | 85 ++++++++++++++++++ tests/feature-toggle-patron-cards.spec.js | 92 +++++++++++++++++++ tests/feature-toggle-rda-types.spec.js | 80 +++++++++++++++++ tests/feature-toggle-suppliers.spec.js | 95 ++++++++++++++++++++ tests/feature-toggle-user-import.spec.js | 70 +++++++++++++++ tests/feature-toggle-visitor-log.spec.js | 92 +++++++++++++++++++ 7 files changed, 580 insertions(+) create mode 100644 tests/feature-toggle-catalogue-cards.spec.js create mode 100644 tests/feature-toggle-marc.spec.js create mode 100644 tests/feature-toggle-patron-cards.spec.js create mode 100644 tests/feature-toggle-rda-types.spec.js create mode 100644 tests/feature-toggle-suppliers.spec.js create mode 100644 tests/feature-toggle-user-import.spec.js create mode 100644 tests/feature-toggle-visitor-log.spec.js diff --git a/tests/feature-toggle-catalogue-cards.spec.js b/tests/feature-toggle-catalogue-cards.spec.js new file mode 100644 index 000000000..83440eac4 --- /dev/null +++ b/tests/feature-toggle-catalogue-cards.spec.js @@ -0,0 +1,66 @@ +// @ts-check +/** + * features.catalogue_cards: the ISBD catalogue card of the book record and + * of the list selection follows the module (Settings → Modules). + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-catalogue-cards.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); +const mf = require('./helpers/minor-features-090'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const TITLE = `ZZ_FT_CARD_${Date.now().toString(36)}`; + +test.describe.serial('Feature toggle: catalogue cards', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let bookId = 0; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['catalogue_cards']); + bookId = mf.seedBook({ titolo: TITLE, copie_totali: 1, copie_disponibili: 1 }); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + mf.deleteBooks([bookId]); + await page?.close(); + }); + + test('1. switched off: no catalogue card button on the book record nor in the selection bar', async () => { + await ft.setModuleInUi(page, 'catalogue_cards', false); + await page.goto(`${ft.BASE}/admin/books/${bookId}`); + await expect(page.locator('h1').first()).toContainText(TITLE); + await expect(page.locator('#catalogue-card-link')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/books`); + await expect(page.locator('#bulk-export')).toBeAttached(); + await expect(page.locator('#bulk-catalogue-cards')).toHaveCount(0); + }); + + test('2. switched off: the card addresses are refused, no PDF', async () => { + for (const url of [`/admin/books/${bookId}/catalogue-card`, `/admin/books/catalogue-cards?ids=${bookId}`]) { + const res = await page.request.get(`${ft.BASE}${url}`, { maxRedirects: 0 }); + expect(res.status()).toBe(302); + expect(String(res.headers()['content-type'] || '')).not.toContain('pdf'); + } + }); + + test('3. switched back on: the button opens the card of this book', async () => { + await ft.setModuleInUi(page, 'catalogue_cards', true); + await page.goto(`${ft.BASE}/admin/books/${bookId}`); + await expect(page.locator('#catalogue-card-link')).toBeVisible(); + const href = await page.locator('#catalogue-card-link').getAttribute('href'); + const res = await page.request.get(`${ft.BASE}${String(href).replace(/^https?:\/\/[^/]+/, '')}`); + expect(res.status()).toBe(200); + expect(String(res.headers()['content-type'] || '')).toContain('application/pdf'); + expect(mf.pdfText(await res.body())).toContain(TITLE); + await page.goto(`${ft.BASE}/admin/books`); + await expect(page.locator('#bulk-catalogue-cards')).toBeAttached(); + }); +}); diff --git a/tests/feature-toggle-marc.spec.js b/tests/feature-toggle-marc.spec.js new file mode 100644 index 000000000..f0619925c --- /dev/null +++ b/tests/feature-toggle-marc.spec.js @@ -0,0 +1,85 @@ +// @ts-check +/** + * features.marc: MARC bulk import/export and copy cataloguing follow the + * module an administrator switches in Settings → Modules. + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-marc.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +test.describe.serial('Feature toggle: MARC import and export', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let z39Active = false; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['marc']); + z39Active = ft.dbQuery("SELECT COALESCE(MAX(is_active), 0) FROM plugins WHERE name='z39-server'") === '1'; + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + await page?.close(); + }); + + test('1. switched off: the book list has no MARC entry in Export, Import nor the selection bar', async () => { + await ft.setModuleInUi(page, 'marc', false); + await page.goto(`${ft.BASE}/admin/books`); + await expect(page.locator('.export-btn')).toBeVisible(); + await expect(page.locator('a[href*="/admin/books/export/marc"]')).toHaveCount(0); + await expect(page.locator('a[href$="/admin/books/import/marc"]')).toHaveCount(0); + await expect(page.locator('a[href$="/admin/books/copy-cataloging"]')).toHaveCount(0); + await expect(page.locator('#bulk-export-marc')).toHaveCount(0); + // The CSV and LibraryThing entries are still there. + await expect(page.locator('a[href$="/admin/books/export/csv"]').first()).toBeAttached(); + }); + + test('2. switched off: the import page and its upload are refused', async () => { + await page.goto(`${ft.BASE}/admin/books/import/marc`); + await expect(page).toHaveURL(/\/admin\/dashboard/); + await expect(page.locator('body')).toContainText('Funzione disattivata nelle impostazioni.'); + const csrf = await ft.csrfToken(page); + const res = await page.request.post(`${ft.BASE}/admin/books/import/marc/upload`, { form: { csrf_token: csrf }, maxRedirects: 0 }); + expect(res.status()).toBe(302); + }); + + test('3. switched off: the export downloads nothing', async () => { + const res = await page.request.get(`${ft.BASE}/admin/books/export/marc?format=marcxml`, { maxRedirects: 0 }); + expect(res.status()).toBe(302); + expect(String(res.headers().location || '')).toContain('/admin/dashboard'); + expect(String(res.headers()['content-type'] || '')).not.toContain('xml'); + }); + + test('4. switched off: copy cataloguing (z39-server) is refused too', async () => { + test.skip(!z39Active, 'z39-server plugin not active on this installation'); + const res = await page.request.get(`${ft.BASE}/admin/books/copy-cataloging`, { maxRedirects: 0 }); + expect(res.status()).toBe(302); + const csrf = await ft.csrfToken(page); + const search = await page.request.post(`${ft.BASE}/admin/books/copy-cataloging/search`, { + headers: { Accept: 'application/json' }, form: { csrf_token: csrf, term: 'x' }, maxRedirects: 0, + }); + expect(search.status()).toBe(404); + expect((await search.json()).code).toBe('feature_disabled'); + }); + + test('5. switched back on: menu entries, import page and the export file are back', async () => { + await ft.setModuleInUi(page, 'marc', true); + await page.goto(`${ft.BASE}/admin/books`); + await expect(page.locator('[data-marc-export="marcxml"]')).toBeAttached(); + await expect(page.locator('a[href$="/admin/books/import/marc"]').first()).toBeAttached(); + await page.goto(`${ft.BASE}/admin/books/import/marc`); + await expect(page).toHaveURL(/\/admin\/books\/import\/marc/); + const id = ft.dbQuery('SELECT MIN(id) FROM libri WHERE deleted_at IS NULL'); + const res = await page.request.get(`${ft.BASE}/admin/books/export/marc?format=marcxml&ids=${id}`); + expect(res.status()).toBe(200); + expect(String(res.headers()['content-type'] || '')).toContain('xml'); + expect(await res.text()).toContain(' { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let readerId = 0; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['patron_cards']); + ft.removeUsers('zz-ft-card-%@test.local'); + readerId = ft.createUser(READER, 'Test1234!', 'standard'); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + ft.removeUsers('zz-ft-card-%@test.local'); + await page?.close(); + }); + + test('1. switched off: the Cards settings section is gone and ?tab=cards falls back', async () => { + await ft.setModuleInUi(page, 'patron_cards', false); + await page.goto(`${ft.BASE}/admin/settings?tab=cards`); + await expect(page.locator('a[data-settings-section="cards"]')).toHaveCount(0); + await expect(page.locator('section[data-settings-panel="cards"]')).toHaveCount(0); + await expect(page.locator('section[data-settings-panel="general"]')).toBeVisible(); + }); + + test('2. switched off: the reader record has neither the print button nor the photo panel', async () => { + await page.goto(`${ft.BASE}/admin/users/details/${readerId}`); + await expect(page.locator('h1').first()).toBeVisible(); + await expect(page.locator('#print-card')).toHaveCount(0); + await expect(page.locator('#patron-card-section')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/users/edit/${readerId}`); + await expect(page.locator('input[name="email"]')).toBeVisible(); + await expect(page.locator('#patron-photo-widget')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/users`); + await expect(page.locator('#bulk-cards-sheet')).toHaveCount(0); + }); + + test('3. switched off: the card PDF is refused', async () => { + for (const url of [`/admin/users/${readerId}/card-pdf`, `/admin/users/cards-pdf?format=sheet&ids=${readerId}`]) { + const res = await page.request.get(`${ft.BASE}${url}`, { maxRedirects: 0 }); + expect(res.status()).toBe(302); + expect(String(res.headers()['content-type'] || '')).not.toContain('pdf'); + } + }); + + test('4. switched off: a photo upload is refused and nothing is stored', async () => { + const csrf = await ft.csrfToken(page); + const res = await page.request.post(`${ft.BASE}/admin/users/${readerId}/photo`, { + headers: { Accept: 'application/json' }, + multipart: { csrf_token: csrf, photo: { name: 'p.jpg', mimeType: 'image/jpeg', buffer: JPEG } }, + maxRedirects: 0, + }); + expect(res.status()).toBe(404); + expect((await res.json()).code).toBe('feature_disabled'); + expect(ft.dbQuery(`SELECT COALESCE(foto_profilo_url, '') FROM utenti WHERE id=${readerId}`)).toBe(''); + }); + + test('5. switched back on: settings tab, buttons and the PDF card are back', async () => { + await ft.setModuleInUi(page, 'patron_cards', true); + await page.goto(`${ft.BASE}/admin/settings?tab=cards`); + await expect(page.locator('section[data-settings-panel="cards"]')).toBeVisible(); + await page.goto(`${ft.BASE}/admin/users/details/${readerId}`); + await expect(page.locator('#print-card')).toBeVisible(); + await expect(page.locator('#patron-card-section')).toBeVisible(); + const res = await page.request.get(`${ft.BASE}/admin/users/${readerId}/card-pdf`); + expect(res.status()).toBe(200); + expect(String(res.headers()['content-type'] || '')).toContain('application/pdf'); + await page.goto(`${ft.BASE}/admin/users/edit/${readerId}`); + await expect(page.locator('#patron-photo-widget')).toBeVisible(); + }); +}); diff --git a/tests/feature-toggle-rda-types.spec.js b/tests/feature-toggle-rda-types.spec.js new file mode 100644 index 000000000..ef6e21b75 --- /dev/null +++ b/tests/feature-toggle-rda-types.spec.js @@ -0,0 +1,80 @@ +// @ts-check +/** + * features.rda_types: the three RDA selects of the book form, the RDA row of + * the record and the MARC 336/337/338 overrides follow the module (Settings + * → Modules). Off, the stored overrides are kept but not applied: exports + * derive the RDA types from the material, as before 0.9.0. + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-rda-types.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); +const mf = require('./helpers/minor-features-090'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const TITLE = `ZZ_FT_RDA_${Date.now().toString(36)}`; + +/** The $b of the first of a MARCXML export. */ +function contentType(xml) { + const field = /<(?:marc:)?datafield[^>]*tag="336"[^>]*>([\s\S]*?)<\/(?:marc:)?datafield>/.exec(xml); + const code = field ? /<(?:marc:)?subfield code="b">([^<]*) { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let bookId = 0; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['rda_types', 'marc']); + await ft.writeFeatures({ marc: '1' }); + bookId = mf.seedBook({ titolo: TITLE, tipo_media: 'libro', copie_totali: 1, copie_disponibili: 1 }); + ft.dbQuery(`UPDATE libri SET rda_content_type='prm', rda_media_type='n', rda_carrier_type='nc' WHERE id=${bookId}`); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + mf.deleteBooks([bookId]); + await page?.close(); + }); + + test('1. switched off: no RDA selects in the form, no RDA row in the record', async () => { + await ft.setModuleInUi(page, 'rda_types', false); + await page.goto(`${ft.BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible(); + await expect(page.locator('#rda-types')).toHaveCount(0); + await expect(page.locator('#rda_content_type')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/books/${bookId}`); + await expect(page.locator('h1').first()).toContainText(TITLE); + await expect(page.locator('#book-rda-types')).toHaveCount(0); + }); + + test('2. switched off: saving the book keeps its stored overrides', async () => { + await page.goto(`${ft.BASE}/admin/books/edit/${bookId}`); + await page.fill('#titolo', `${TITLE} edited`); + await mf.submitBookForm(page); + expect(ft.dbQuery(`SELECT CONCAT_WS('/', rda_content_type, rda_media_type, rda_carrier_type) FROM libri WHERE id=${bookId}`)).toBe('prm/n/nc'); + }); + + test('3. switched off: the MARC export uses the types derived from the material', async () => { + const res = await page.request.get(`${ft.BASE}/admin/books/export/marc?format=marcxml&ids=${bookId}`); + expect(res.status()).toBe(200); + expect(contentType(await res.text())).toBe('txt'); + }); + + test('4. switched back on: selects, record row and export show the override again', async () => { + await ft.setModuleInUi(page, 'rda_types', true); + await page.goto(`${ft.BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#rda_content_type')).toHaveValue('prm'); + await page.goto(`${ft.BASE}/admin/books/${bookId}`); + await expect(page.locator('#book-rda-types')).toHaveAttribute('data-rda', 'prm/n/nc'); + const res = await page.request.get(`${ft.BASE}/admin/books/export/marc?format=marcxml&ids=${bookId}`); + expect(contentType(await res.text())).toBe('prm'); + }); +}); diff --git a/tests/feature-toggle-suppliers.spec.js b/tests/feature-toggle-suppliers.spec.js new file mode 100644 index 000000000..ff99895a1 --- /dev/null +++ b/tests/feature-toggle-suppliers.spec.js @@ -0,0 +1,95 @@ +// @ts-check +/** + * features.suppliers: the supplier register, the book form field, the book + * record row and the import choice follow the module (Settings → Modules). + * A book saved while the module is off keeps the supplier it had. + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-suppliers.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); +const mf = require('./helpers/minor-features-090'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const STAMP = Date.now().toString(36); +const TITLE = `ZZ_FT_SUP_${STAMP}`; +const SUPPLIER = `ZZ_FT_SUP Libreria ${STAMP}`; + +test.describe.serial('Feature toggle: suppliers', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let bookId = 0; + let supplierId = 0; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['suppliers']); + ft.dbQuery(`INSERT INTO fornitori (nome) VALUES (${ft.sqlString(SUPPLIER)})`); + supplierId = Number(ft.dbQuery(`SELECT MAX(id) FROM fornitori WHERE nome=${ft.sqlString(SUPPLIER)}`)); + bookId = mf.seedBook({ titolo: TITLE, copie_totali: 1, copie_disponibili: 1 }); + ft.dbQuery(`UPDATE libri SET fornitore_id=${supplierId} WHERE id=${bookId}`); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + mf.deleteBooks([bookId]); + ft.dbQuery(`DELETE FROM fornitori WHERE id=${supplierId}`); + await page?.close(); + }); + + test('1. switched off: no Fornitori entry in the sidebar, the register is refused', async () => { + await ft.setModuleInUi(page, 'suppliers', false); + await page.goto(`${ft.BASE}/admin/dashboard`); + await expect(page.locator('a.nav-link[href$="/admin/suppliers"]')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/suppliers`); + await expect(page).toHaveURL(/\/admin\/dashboard/); + const csrf = await ft.csrfToken(page); + const res = await page.request.post(`${ft.BASE}/admin/suppliers/create`, { form: { csrf_token: csrf, nome: `${SUPPLIER} refused` }, maxRedirects: 0 }); + expect(res.status()).toBe(302); + expect(ft.dbQuery(`SELECT COUNT(*) FROM fornitori WHERE nome=${ft.sqlString(SUPPLIER + ' refused')}`)).toBe('0'); + }); + + test('2. switched off: neither the book form nor the book record show a supplier', async () => { + await page.goto(`${ft.BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible(); + await expect(page.locator('#fornitore_id')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/books/${bookId}`); + await expect(page.locator('#book-supplier')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/books`); + await expect(page.locator('#fornitore_filter')).toHaveCount(0); + }); + + test('3. switched off: saving the book from the form keeps the supplier it had', async () => { + await page.goto(`${ft.BASE}/admin/books/edit/${bookId}`); + await page.fill('#titolo', `${TITLE} edited`); + await mf.submitBookForm(page); + expect(ft.dbQuery(`SELECT titolo FROM libri WHERE id=${bookId}`)).toBe(`${TITLE} edited`); + expect(ft.dbQuery(`SELECT fornitore_id FROM libri WHERE id=${bookId}`)).toBe(String(supplierId)); + }); + + test('4. switched off: the CSV and MARC import pages offer no supplier for the batch', async () => { + await page.goto(`${ft.BASE}/admin/books/import`); + await expect(page.locator('input[type="file"]').first()).toBeAttached(); + await expect(page.locator('#fornitore_id')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/books/import/marc`); + await expect(page.locator('#fornitore_id')).toHaveCount(0); + }); + + test('5. switched back on: entry, field and record row are back, with the supplier preserved', async () => { + await ft.setModuleInUi(page, 'suppliers', true); + await page.goto(`${ft.BASE}/admin/dashboard`); + await page.locator('a.nav-link[href$="/admin/suppliers"]').click(); + await expect(page).toHaveURL(/\/admin\/suppliers$/); + await expect(page.locator('body')).toContainText(SUPPLIER); + await page.goto(`${ft.BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#fornitore_id')).toHaveValue(String(supplierId)); + await page.goto(`${ft.BASE}/admin/books/${bookId}`); + await expect(page.locator('#book-supplier')).toContainText(SUPPLIER); + await page.goto(`${ft.BASE}/admin/books/import`); + await expect(page.locator('#fornitore_id')).toBeAttached(); + }); +}); diff --git a/tests/feature-toggle-user-import.spec.js b/tests/feature-toggle-user-import.spec.js new file mode 100644 index 000000000..7b2f9a1fd --- /dev/null +++ b/tests/feature-toggle-user-import.spec.js @@ -0,0 +1,70 @@ +// @ts-check +/** + * features.user_import: the CSV import of patrons follows the module + * (Settings → Modules): the two buttons of the users list, the wizard and its + * upload. + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-user-import.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const RUN = Date.now().toString(36); +const CSV = `nome;cognome;email\nZz;Import${RUN};zz-ft-imp-${RUN}@test.local\n`; + +test.describe.serial('Feature toggle: user import', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['user_import']); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + ft.removeUsers('zz-ft-imp-%@test.local'); + await page?.close(); + }); + + test('1. switched off: the users list has no import button and the wizard is refused', async () => { + await ft.setModuleInUi(page, 'user_import', false); + await page.goto(`${ft.BASE}/admin/users`); + await expect(page.locator('a[href$="/admin/users/create"]').first()).toBeAttached(); + await expect(page.locator('a[href$="/admin/users/import"]')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/users/import`); + await expect(page).toHaveURL(/\/admin\/dashboard/); + await expect(page.locator('body')).toContainText('Funzione disattivata nelle impostazioni.'); + }); + + test('2. switched off: an upload is refused and no account is created', async () => { + const csrf = await ft.csrfToken(page); + const res = await page.request.post(`${ft.BASE}/admin/users/import/upload`, { + headers: { Accept: 'application/json' }, + multipart: { csrf_token: csrf, csv_file: { name: 'u.csv', mimeType: 'text/csv', buffer: Buffer.from(CSV) } }, + maxRedirects: 0, + }); + expect(res.status()).toBe(404); + expect((await res.json()).code).toBe('feature_disabled'); + expect(ft.dbQuery(`SELECT COUNT(*) FROM utenti WHERE email LIKE 'zz-ft-imp-%@test.local'`)).toBe('0'); + const example = await page.request.get(`${ft.BASE}/admin/users/import/example`, { maxRedirects: 0 }); + expect(example.status()).toBe(302); + }); + + test('3. switched back on: the button leads to the wizard and the upload is accepted', async () => { + await ft.setModuleInUi(page, 'user_import', true); + await page.goto(`${ft.BASE}/admin/users`); + await page.locator('a[href$="/admin/users/import"]:visible').first().click(); + await expect(page).toHaveURL(/\/admin\/users\/import$/); + await page.setInputFiles('#user_import_file', { name: 'u.csv', mimeType: 'text/csv', buffer: Buffer.from(CSV) }); + const upload = page.waitForResponse((r) => r.url().includes('/admin/users/import/upload'), { timeout: 20000 }); + await page.locator('#user-import-upload-btn').click(); + const res = await upload; + expect(res.status()).toBe(200); + }); +}); diff --git a/tests/feature-toggle-visitor-log.spec.js b/tests/feature-toggle-visitor-log.spec.js new file mode 100644 index 000000000..49da79aa5 --- /dev/null +++ b/tests/feature-toggle-visitor-log.spec.js @@ -0,0 +1,92 @@ +// @ts-check +/** + * features.visitor_log: the visitor log is opt-in (personal data, off on a + * fresh install too). The sidebar entry, the kiosk, its scans, the + * statistics section and the retention purge follow the module (Settings → + * Modules). + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-visitor-log.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const RUN = Date.now().toString(36); +const MEMBER = `zz-ft-visit-${RUN}@test.local`; + +test.describe.serial('Feature toggle: visitor log', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let memberId = 0; + let card = ''; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['visitor_log']); + ft.removeUsers('zz-ft-visit-%@test.local'); + memberId = ft.createUser(MEMBER, 'Test1234!', 'standard'); + card = ft.dbQuery(`SELECT codice_tessera FROM utenti WHERE id=${memberId}`); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + ft.dbQuery(`DELETE FROM presenze WHERE utente_id=${memberId}`); + ft.removeUsers('zz-ft-visit-%@test.local'); + await page?.close(); + }); + + test('1. switched off (the fresh-install default): no Presenze entry, the kiosk is refused', async () => { + await ft.setModuleInUi(page, 'visitor_log', false); + await expect(page.locator('#feature_visitor_log')).not.toBeChecked(); + await page.goto(`${ft.BASE}/admin/dashboard`); + await expect(page.locator('a.nav-link[href$="/admin/attendance"]')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/attendance`); + await expect(page).toHaveURL(/\/admin\/dashboard/); + // The Italian alias goes the same way. + await page.goto(`${ft.BASE}/admin/presenze`); + await expect(page).toHaveURL(/\/admin\/dashboard/); + }); + + test('2. switched off: a card scan posted to the kiosk records nothing', async () => { + const csrf = await ft.csrfToken(page); + const res = await page.request.post(`${ft.BASE}/admin/attendance/scan`, { form: { csrf_token: csrf, codice_tessera: card }, maxRedirects: 0 }); + expect(res.status()).toBe(302); + expect(String(res.headers().location || '')).toContain('/admin/dashboard'); + expect(ft.dbQuery(`SELECT COUNT(*) FROM presenze WHERE utente_id=${memberId}`)).toBe('0'); + }); + + test('3. switched off: the statistics page has no visitors section', async () => { + ft.dbQuery(`INSERT INTO presenze (utente_id, tipo) VALUES (${memberId}, 'socio')`); + await page.goto(`${ft.BASE}/admin/statistics`); + await expect(page.locator('h1, h2').first()).toBeVisible(); + await expect(page.locator('#stats-visitors')).toHaveCount(0); + ft.dbQuery(`DELETE FROM presenze WHERE utente_id=${memberId}`); + }); + + test('4. switched on: the entry opens the kiosk and a card scan records the visit', async () => { + await ft.setModuleInUi(page, 'visitor_log', true); + await page.goto(`${ft.BASE}/admin/dashboard`); + await page.locator('a.nav-link[href$="/admin/attendance"]').click(); + await expect(page).toHaveURL(/\/admin\/attendance/); + await page.fill('#codice_tessera', card); + await page.locator('#attendance-scan-form button[type="submit"]').click(); + await page.waitForLoadState('domcontentloaded'); + await expect.poll(() => ft.dbQuery(`SELECT COUNT(*) FROM presenze WHERE utente_id=${memberId}`)).toBe('1'); + await page.goto(`${ft.BASE}/admin/statistics`); + await expect(page.locator('#stats-visitors')).toBeVisible(); + }); + + test('5. switched off again: the visits stay stored, and come back with the module', async () => { + await ft.setModuleInUi(page, 'visitor_log', false); + expect(ft.dbQuery(`SELECT COUNT(*) FROM presenze WHERE utente_id=${memberId}`)).toBe('1'); + await page.goto(`${ft.BASE}/admin/statistics`); + await expect(page.locator('#stats-visitors')).toHaveCount(0); + await ft.setModuleInUi(page, 'visitor_log', true); + await page.goto(`${ft.BASE}/admin/statistics`); + await expect(page.locator('#stats-visitors [data-visitors="soci"]')).not.toHaveText('0'); + }); +}); From 3429116ea3b493cc2a71423f1a92cb80dbaa622e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:41:24 +0100 Subject: [PATCH 199/398] Tests: UK nations, an abolished holiday and Alsace-Moselle in the holiday card --- tests/holidays-preset.spec.js | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/holidays-preset.spec.js b/tests/holidays-preset.spec.js index f53eb766f..a5e272f01 100644 --- a/tests/holidays-preset.spec.js +++ b/tests/holidays-preset.spec.js @@ -324,4 +324,40 @@ test.describe('Public holiday presets', () => { await submitAndLand(page, '#import-preset'); await expect(page.locator('body')).toContainText(/Festività applicate: 0 aggiunte, 0 rimosse|Holidays applied: 0 added, 0 removed/); }); + + test('10. the United Kingdom always works on a nation: Scotland has 2 January and St Andrew, not Easter Monday', async () => { + await chooseCountry(page, 'GB'); + // No "national holidays only" choice: England and Wales is the default nation. + await expect(page.locator('#holiday_region')).toHaveValue('ENG'); + await expect(page.locator('#holiday_region option[value=""]')).toHaveCount(0); + await expect(box(page, 'GB:easter_monday')).toHaveCount(1); + await Promise.all([page.waitForURL(/holiday_region=SCT/), page.selectOption('#holiday_region', 'SCT')]); + await expect(box(page, 'GB:easter_monday')).toHaveCount(0); + await expect(box(page, 'GB:second_january')).toHaveCount(1); + const andrew = /** @type {string} */ (await holidayDate(page, 'GB:st_andrew')); + // 30 November, or the Monday after when it falls on a weekend. + expect([`${year}-11-30`, `${year}-12-01`, `${year}-12-02`]).toContain(andrew); + expect([0, 6]).not.toContain(weekday(andrew)); + }); + + test('11. a holiday abolished before the preview year is listed as not held that year (Store bededag in Denmark)', async () => { + await chooseCountry(page, 'DK'); + const row = page.locator('[data-holiday-row="DK:store_bededag"]'); + await expect(row).toContainText(/non prevista nel|not held in/); + expect(await holidayDate(page, 'DK:store_bededag')).toBe(''); + // Customary days start unticked, statutory ones ticked. + await expect(box(page, 'DK:juleaftensdag')).not.toBeChecked(); + await expect(box(page, 'DK:grundlovsdag')).toBeChecked(); + await expect(page.locator('[data-holiday-row="DK:juleaftensdag"]')).toContainText(/facoltativa|optional/); + }); + + test('12. France: Alsace-Moselle adds Good Friday and St Stephen to the national list', async () => { + await chooseCountry(page, 'FR'); + const national = await page.locator('input[data-holiday-rule]').count(); + await expect(box(page, 'FR:vendredi_saint')).toHaveCount(0); + await Promise.all([page.waitForURL(/holiday_region=ALM/), page.selectOption('#holiday_region', 'ALM')]); + await expect(page.locator('input[data-holiday-rule]')).toHaveCount(national + 2); + expect(await holidayDate(page, 'FR:vendredi_saint')).toBe(addDays(easter(year), -2)); + expect(await holidayDate(page, 'FR:saint_etienne')).toBe(`${year}-12-26`); + }); }); From c19846b7cc8eddd5114a2ff8419bdf9b80424656 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:46:11 +0100 Subject: [PATCH 200/398] =?UTF-8?q?Tests:=20circulation=20modules=20switch?= =?UTF-8?q?ed=20off=20and=20on=20from=20Settings=20=E2=86=92=20Loans;=20th?= =?UTF-8?q?e=20reader=20record=20hides=20the=20patron=20type=20with=20the?= =?UTF-8?q?=20module=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Views/utenti/dettagli_utente.php | 2 + .../feature-toggle-circulation-rules.spec.js | 106 ++++++++++++++++ .../feature-toggle-closures-calendar.spec.js | 89 ++++++++++++++ tests/feature-toggle-fines.spec.js | 114 ++++++++++++++++++ tests/helpers/feature-toggles.js | 2 +- 5 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 tests/feature-toggle-circulation-rules.spec.js create mode 100644 tests/feature-toggle-closures-calendar.spec.js create mode 100644 tests/feature-toggle-fines.spec.js diff --git a/app/Views/utenti/dettagli_utente.php b/app/Views/utenti/dettagli_utente.php index ec8a8d442..2a9db788a 100644 --- a/app/Views/utenti/dettagli_utente.php +++ b/app/Views/utenti/dettagli_utente.php @@ -290,6 +290,7 @@ +
      @@ -297,6 +298,7 @@ ()
      + diff --git a/tests/feature-toggle-circulation-rules.spec.js b/tests/feature-toggle-circulation-rules.spec.js new file mode 100644 index 000000000..f59bee968 --- /dev/null +++ b/tests/feature-toggle-circulation-rules.spec.js @@ -0,0 +1,106 @@ +// @ts-check +/** + * features.circulation_rules (Settings → Loans): patron types, the rules + * matrix and consultation-only copies. Off, only the global loan rules of the + * Loans tab apply, as before 0.9.0; the data (types, assignments, flags) is + * kept and comes back with the module. + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-circulation-rules.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); +const mf = require('./helpers/minor-features-090'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const RUN = Date.now().toString(36); +const TITLE = `ZZ_FT_CONS_${RUN}`; +const READER = `zz-ft-rules-${RUN}@test.local`; +const TYPE_CODE = `zzft-${RUN}`; + +test.describe.serial('Feature toggle: rules by patron type', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let bookId = 0; + let copyId = 0; + let readerId = 0; + let typeId = 0; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['circulation_rules']); + await ft.writeFeatures({ circulation_rules: '1' }); + ft.removeUsers('zz-ft-rules-%@test.local'); + bookId = mf.seedBook({ titolo: TITLE, copie_totali: 1, copie_disponibili: 1 }); + ft.dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato, solo_consultazione) VALUES (${bookId}, ${ft.sqlString(`ZZFTC-${RUN}`)}, 'disponibile', 1)`); + copyId = Number(ft.dbQuery(`SELECT id FROM copie WHERE libro_id=${bookId}`)); + ft.dbQuery(`INSERT INTO tipologie_lettore (codice, nome, ordinamento) VALUES (${ft.sqlString(TYPE_CODE)}, ${ft.sqlString(`ZZ FT ${RUN}`)}, 99)`); + typeId = Number(ft.dbQuery(`SELECT id FROM tipologie_lettore WHERE codice=${ft.sqlString(TYPE_CODE)}`)); + readerId = ft.createUser(READER, 'Test1234!', 'standard'); + ft.dbQuery(`UPDATE utenti SET tipologia_id=${typeId} WHERE id=${readerId}`); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + ft.removeUsers('zz-ft-rules-%@test.local'); + ft.dbQuery(`DELETE FROM tipologie_lettore WHERE id=${typeId}`); + mf.deleteBooks([bookId]); + await page?.close(); + }); + + test('1. switched off from Settings → Loans: the Circulation section and its pages are gone', async () => { + await page.goto(`${ft.BASE}/admin/settings?tab=loans`); + await expect(page.locator('#loans-circulation-rules')).toBeVisible(); + await ft.setModuleInUi(page, 'circulation_rules', false); + await expect(page.locator('#feature_circulation_rules')).not.toBeChecked(); + await expect(page.locator('a[data-settings-section="circulation"]')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/settings?tab=circulation`); + await expect(page.locator('section[data-settings-panel="general"]')).toBeVisible(); + await page.goto(`${ft.BASE}/admin/settings/circulation`); + await expect(page).toHaveURL(/\/admin\/dashboard/); + }); + + test('2. switched off: no patron type in the user forms, the record or the users filter', async () => { + await page.goto(`${ft.BASE}/admin/users/create`); + await expect(page.locator('input[name="email"]')).toBeVisible(); + await expect(page.locator('#tipologia_id')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/users/details/${readerId}`); + await expect(page.locator('[data-testid="patron-type-name"]')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/users`); + await expect(page.locator('#patron_type_filter')).toHaveCount(0); + }); + + test('3. switched off: a consultation-only copy is an ordinary lendable copy', async () => { + await page.goto(`${ft.BASE}/admin/books/${bookId}`); + await expect(page.locator('h1').first()).toContainText(TITLE); + await expect(page.locator('[data-testid="copy-consultation-badge"]')).toHaveCount(0); + await expect(page.locator('[data-testid="copy-consultation-toggle"]')).toHaveCount(0); + await page.goto(`${ft.BASE}/libro/${bookId}`); + await expect(page.locator('#consultation-only')).toHaveCount(0); + const csrf = await ft.csrfToken(page); + const res = await page.request.post(`${ft.BASE}/admin/books/copies/${copyId}/consultation`, { form: { csrf_token: csrf, solo_consultazione: '0' }, maxRedirects: 0 }); + expect(res.status()).toBe(302); + expect(ft.dbQuery(`SELECT solo_consultazione FROM copie WHERE id=${copyId}`)).toBe('1'); + }); + + test('4. switched off: saving the reader from the edit form keeps the patron type', async () => { + await page.goto(`${ft.BASE}/admin/users/edit/${readerId}`); + await page.locator('#user-form button[type="submit"]').click(); + await page.waitForURL((u) => !u.pathname.includes('/edit/'), { timeout: 20000 }); + expect(ft.dbQuery(`SELECT IFNULL(tipologia_id, 0) FROM utenti WHERE id=${readerId}`)).toBe(String(typeId)); + }); + + test('5. switched back on: section, select, badge and pill are back, with the data kept', async () => { + await ft.setModuleInUi(page, 'circulation_rules', true); + await expect(page.locator('a[data-settings-section="circulation"]').first()).toBeAttached(); + await page.goto(`${ft.BASE}/admin/users/edit/${readerId}`); + await expect(page.locator('#tipologia_id')).toHaveValue(String(typeId)); + await page.goto(`${ft.BASE}/admin/books/${bookId}`); + await expect(page.locator('[data-testid="copy-consultation-badge"]')).toBeVisible(); + await page.goto(`${ft.BASE}/libro/${bookId}`); + await expect(page.locator('#consultation-only')).toBeVisible(); + }); +}); diff --git a/tests/feature-toggle-closures-calendar.spec.js b/tests/feature-toggle-closures-calendar.spec.js new file mode 100644 index 000000000..2f4b7732a --- /dev/null +++ b/tests/feature-toggle-closures-calendar.spec.js @@ -0,0 +1,89 @@ +// @ts-check +/** + * features.closures_calendar (Settings → Loans): off, the calendar is not + * applied (due dates and the reader's request calendar see a library open + * every day, as before 0.9.0) and its pages are gone; the closures already + * stored are kept and apply again when the module is switched back on. + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-closures-calendar.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); +const mf = require('./helpers/minor-features-090'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const RUN = Date.now().toString(36); +const TITLE = `ZZ_FT_CAL_${RUN}`; +const NOTE = `ZZ_FT_CAL ${RUN}`; + +test.describe.serial('Feature toggle: closures calendar', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let bookId = 0; + let closed = ''; + + /** closed_dates of the public availability the request calendar reads. */ + async function closedDates() { + const res = await page.request.get(`${ft.BASE}/api/libro/${bookId}/availability`); + expect(res.status()).toBe(200); + const body = await res.json(); + const availability = body.availability || body.data?.availability || body; + return availability.closed_dates || []; + } + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['closures_calendar']); + await ft.writeFeatures({ closures_calendar: '1' }); + closed = ft.dbQuery('SELECT DATE_FORMAT(CURDATE() + INTERVAL 3 DAY, \'%Y-%m-%d\')'); + ft.dbQuery(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione) VALUES ('${closed}', '${closed}', ${ft.sqlString(NOTE)})`); + bookId = mf.seedBook({ titolo: TITLE, copie_totali: 1, copie_disponibili: 1 }); + ft.dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (${bookId}, ${ft.sqlString(`ZZFTK-${RUN}`)}, 'disponibile')`); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + ft.dbQuery(`DELETE FROM calendario_chiusure WHERE descrizione LIKE 'ZZ_FT_CAL %'`); + mf.deleteBooks([bookId]); + await page?.close(); + }); + + test('1. switched on: the closed day is published to the request calendar', async () => { + expect(await closedDates()).toContain(closed); + }); + + test('2. switched off from Settings → Loans: no sidebar entry, no calendar page, no button', async () => { + await ft.setModuleInUi(page, 'closures_calendar', false); + await expect(page.locator('#feature_closures_calendar')).not.toBeChecked(); + await expect(page.locator('#open-closures-calendar')).toHaveCount(0); + await expect(page.locator('a.nav-link[href$="/admin/calendar"]')).toHaveCount(0); + await page.goto(`${ft.BASE}/admin/calendar`); + await expect(page).toHaveURL(/\/admin\/dashboard/); + }); + + test('3. switched off: the request calendar has no closed day', async () => { + expect(await closedDates()).not.toContain(closed); + }); + + test('4. switched off: a closure posted to the calendar is refused', async () => { + const csrf = await ft.csrfToken(page); + const res = await page.request.post(`${ft.BASE}/admin/calendar/closures`, { + form: { csrf_token: csrf, data_inizio: closed, data_fine: closed, descrizione: `${NOTE} refused` }, maxRedirects: 0, + }); + expect(res.status()).toBe(302); + expect(ft.dbQuery(`SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione=${ft.sqlString(NOTE + ' refused')}`)).toBe('0'); + }); + + test('5. switched back on: entry, page and the closure stored before are back', async () => { + await ft.setModuleInUi(page, 'closures_calendar', true); + await expect(page.locator('#open-closures-calendar')).toBeVisible(); + await page.locator('#open-closures-calendar').click(); + await expect(page).toHaveURL(/\/admin\/calendar/); + await expect(page.locator('body')).toContainText(NOTE); + expect(await closedDates()).toContain(closed); + }); +}); diff --git a/tests/feature-toggle-fines.spec.js b/tests/feature-toggle-fines.spec.js new file mode 100644 index 000000000..32808042c --- /dev/null +++ b/tests/feature-toggle-fines.spec.js @@ -0,0 +1,114 @@ +// @ts-check +/** + * features.fines (Settings → Loans): fines and the patron ledger are opt-in. + * Off, the Movimenti section of the reader record, the reader's own balance, + * receipts and the fines report are gone; the ledger rows and the fine + * amounts already saved are kept and show again with the module on. + * + * Run: /Users/fabio/Documents/GitHub/run-e2e-wt3.sh tests/feature-toggle-fines.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const ft = require('./helpers/feature-toggles'); + +test.skip(!ft.configured, 'E2E credentials not configured'); + +const RUN = Date.now().toString(36); +const READER = `zz-ft-fines-${RUN}@test.local`; +const PASS = 'Test1234!'; + +test.describe.serial('Feature toggle: fines and patron ledger', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ + let page; + let snapshot; + let dailyFine = null; + let readerId = 0; + let movementId = 0; + + test.beforeAll(async ({ browser }) => { + snapshot = ft.readFeatures(['fines']); + const n = ft.dbQuery("SELECT COUNT(*) FROM system_settings WHERE category='loans' AND setting_key='daily_fine'"); + dailyFine = n === '1' ? ft.dbQuery("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='daily_fine'") : null; + ft.removeUsers('zz-ft-fines-%@test.local'); + readerId = ft.createUser(READER, PASS, 'standard'); + ft.dbQuery(`INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES (${readerId}, 'addebito_manuale', 3.00, 'ZZ FT fines', NOW())`); + ft.dbQuery(`INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, metodo_pagamento, created_at) VALUES (${readerId}, 'pagamento', -1.00, 'ZZ FT paid', 'contanti', NOW())`); + movementId = Number(ft.dbQuery(`SELECT MAX(id) FROM movimenti_lettore WHERE utente_id=${readerId}`)); + page = await browser.newPage(); + await ft.loginAdmin(page); + }); + + test.afterAll(async () => { + await ft.restoreFeatures(snapshot); + if (dailyFine === null) { + ft.dbQuery("DELETE FROM system_settings WHERE category='loans' AND setting_key='daily_fine'"); + } else { + ft.dbQuery(`UPDATE system_settings SET setting_value=${ft.sqlString(dailyFine)} WHERE category='loans' AND setting_key='daily_fine'`); + } + ft.dbQuery(`DELETE FROM movimenti_lettore WHERE utente_id=${readerId}`); + ft.removeUsers('zz-ft-fines-%@test.local'); + await page?.close(); + }); + + test('1. switched off: the reader record has no Movimenti section and receipts are refused', async () => { + await ft.setModuleInUi(page, 'fines', false); + await page.goto(`${ft.BASE}/admin/users/details/${readerId}`); + await expect(page.locator('h1').first()).toBeVisible(); + await expect(page.locator('#movimenti')).toHaveCount(0); + const res = await page.request.get(`${ft.BASE}/admin/users/${readerId}/ledger/receipt/${movementId}`, { maxRedirects: 0 }); + expect(res.status()).toBe(302); + const csrf = await ft.csrfToken(page); + const pay = await page.request.post(`${ft.BASE}/admin/users/${readerId}/ledger/payment`, { form: { csrf_token: csrf, importo: '1', metodo_pagamento: 'contanti', request_key: 'b'.repeat(32) }, maxRedirects: 0 }); + expect(pay.status()).toBe(302); + expect(ft.dbQuery(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${readerId}`)).toBe('2'); + }); + + test('2. switched off: the statistics page has no fines report and the CSV is refused', async () => { + await page.goto(`${ft.BASE}/admin/statistics`); + await expect(page.locator('h1, h2').first()).toBeVisible(); + await expect(page.locator('#fines-report')).toHaveCount(0); + await expect(page.locator('#stats-fines')).toHaveCount(0); + const csv = await page.request.get(`${ft.BASE}/admin/statistics/fines.csv`, { maxRedirects: 0 }); + expect(csv.status()).toBe(302); + }); + + test('3. switched off: the reader does not see a balance on their loans page', async ({ browser }) => { + const reader = await browser.newPage(); + try { + await ft.loginAs(reader, READER, PASS); + await reader.goto(`${ft.BASE}/prenotazioni`); + await expect(reader.locator('body')).not.toContainText('Saldo e movimenti'); + await expect(reader.locator('[data-patron-balance]')).toHaveCount(0); + } finally { + await reader.close(); + } + }); + + test('4. switched off: the fine amounts stay editable and are saved with the tab', async () => { + await page.goto(`${ft.BASE}/admin/settings?tab=loans`); + await expect(page.locator('#daily_fine')).toBeEnabled(); + await page.fill('#daily_fine', '0,40'); + await page.locator('form[action$="/admin/settings/loans"] button[type="submit"]').click(); + await page.waitForURL(/tab=loans/); + await expect(page.locator('#daily_fine')).toHaveValue(/0[.,]40/); + await expect(page.locator('#feature_fines')).not.toBeChecked(); + }); + + test('5. switched back on: Movimenti with the kept balance, receipt, report and the reader balance', async ({ browser }) => { + await ft.setModuleInUi(page, 'fines', true); + await page.goto(`${ft.BASE}/admin/users/details/${readerId}`); + await expect(page.locator('#movimenti [data-ledger-balance]')).toHaveAttribute('data-ledger-balance', '200'); + const receipt = await page.request.get(`${ft.BASE}/admin/users/${readerId}/ledger/receipt/${movementId}`); + expect(receipt.status()).toBe(200); + await page.goto(`${ft.BASE}/admin/statistics`); + await expect(page.locator('#fines-report')).toBeVisible(); + const reader = await browser.newPage(); + try { + await ft.loginAs(reader, READER, PASS); + await reader.goto(`${ft.BASE}/prenotazioni`); + await expect(reader.locator('[data-patron-balance]')).toHaveAttribute('data-patron-balance', '200'); + } finally { + await reader.close(); + } + }); +}); diff --git a/tests/helpers/feature-toggles.js b/tests/helpers/feature-toggles.js index 341610682..93b6dc2d5 100644 --- a/tests/helpers/feature-toggles.js +++ b/tests/helpers/feature-toggles.js @@ -146,7 +146,7 @@ function phpHash(password) { */ function createUser(email, password, tipo = 'standard', ruoloId = null) { const card = `ZZT${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`.slice(0, 20); - dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, ruolo_id, privacy_accettata, locale) VALUES (${sqlString(card)}, 'Zz', 'Toggle', ${sqlString(email)}, ${sqlString(phpHash(password))}, 'attivo', 1, ${sqlString(tipo)}, ${ruoloId === null ? 'NULL' : Number(ruoloId)}, 1, 'it_IT')`); + dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, telefono, indirizzo, email, password, stato, email_verificata, tipo_utente, ruolo_id, privacy_accettata, locale) VALUES (${sqlString(card)}, 'Zz', 'Toggle', '3330000000', 'Via Prova 1', ${sqlString(email)}, ${sqlString(phpHash(password))}, 'attivo', 1, ${sqlString(tipo)}, ${ruoloId === null ? 'NULL' : Number(ruoloId)}, 1, 'it_IT')`); return Number(dbQuery(`SELECT id FROM utenti WHERE email=${sqlString(email)}`)); } From 3d2a211670201a0a6391f6412f42f273375f1f9d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 01:16:03 +0100 Subject: [PATCH 201/398] Tests: the 0.9.0 suites switch on the module they test; CI runs the feature toggle suites Each suite of a module that is off by default on some installs enables it for its run and restores the stored value. Zero configuration now means the fines module is off, so the reader record shows no Movimenti section. The debt-block CLI probe loads .env like the cron bootstrap, so ConfigStore reads the module switch of the test database. CHANGELOG entry for the toggles. --- .github/workflows/ci-e2e.yml | 12 ++++++++++++ CHANGELOG.md | 8 ++++++++ tests/attendance-090.spec.js | 7 +++++++ tests/circulation-interop.spec.js | 7 +++++++ tests/debt-block.spec.js | 9 +++++++++ tests/fines-closed-days.spec.js | 7 +++++++ tests/fines-ledger.spec.js | 7 +++++++ tests/fines-patron-types.spec.js | 7 +++++++ tests/inventory-stocktake.spec.js | 7 +++++++ tests/marc-bulk-import.spec.js | 7 +++++++ tests/mobile-api-idempotency.spec.js | 7 +++++++ tests/rda-types-090.spec.js | 7 +++++++ tests/stack-090-integration.spec.js | 7 +++++++ tests/stack-090-review-fixes.spec.js | 7 +++++++ tests/stack-090-zero-config.spec.js | 7 +++++-- tests/statistics-090.spec.js | 7 +++++++ tests/suppliers-090.spec.js | 7 +++++++ 17 files changed, 125 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 2632a6f0f..f298757a7 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -420,6 +420,9 @@ jobs: tests/closures-calendar-circulation.spec.js tests/closures-calendar-loans.spec.js tests/debt-block.spec.js + tests/feature-toggle-circulation-rules.spec.js + tests/feature-toggle-closures-calendar.spec.js + tests/feature-toggle-fines.spec.js tests/fines-closed-days.spec.js tests/fines-ledger.spec.js tests/fines-patron-types.spec.js @@ -430,6 +433,15 @@ jobs: specs: >- tests/attendance-090.spec.js tests/catalogue-cards-090.spec.js + tests/feature-toggle-catalogue-cards.spec.js + tests/feature-toggle-marc.spec.js + tests/feature-toggle-patron-cards.spec.js + tests/feature-toggle-rda-types.spec.js + tests/feature-toggle-stocktake.spec.js + tests/feature-toggle-suppliers.spec.js + tests/feature-toggle-user-import.spec.js + tests/feature-toggle-visitor-log.spec.js + tests/feature-toggles-settings.spec.js tests/inventory-stocktake.spec.js tests/issue-501-admin-lists.spec.js tests/issue-501-exact-quoted-search.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 615fe5267..6175af709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,13 @@ Full version-by-version history for Pinakes. The README shows only the last thre - `app.currency` is now read from the database too (it was a code default only). - **Fine settings are administrators only.** Staff can still save the Loans tab, but the daily fine, grace days, cap per loan, debt threshold and fine email stay as they are unless an administrator changes them; the fields are read-only for staff. +### Feature toggles +- **Every optional 0.9.0 module has its own switch** (`features.*` in `system_settings`, `App\Support\Features`). A new Settings → Modules tab, administrators only, switches stocktake, MARC import and export (with copy cataloguing), catalogue cards, suppliers, RDA types, library cards and patron photos, user CSV import and the visitor log; the Loans tab switches the rules by patron type, the closures calendar and fines with the patron ledger, next to their settings. Staff sees the Loans switches locked and does not see the Modules tab; a hand-built post is refused by an inline `mayConfigure()` check. +- **A module switched off is gone**: no sidebar or menu entry, no button, no form field, no settings section; its routes answer as catalogue mode does (302 to the dashboard with "Funzione disattivata nelle impostazioni.", or 404 `feature_disabled` for JSON callers) through `FeatureGateMiddleware`, and the controllers re-check. The services carry the switch for every channel at once: with patron type rules off only the global loan rules apply and consultation-only copies are lendable; with the calendar off due dates ignore closures; with fines off nothing is charged, nobody is blocked for debt, previews, balances and the fine email are gone and `prestiti.sanzione` is written as before; with RDA types off the stored overrides are kept but exports derive the types from the material; with suppliers off a book save or an import never touches `libri.fornitore_id`; with the visitor log off maintenance purges nothing. Each statistics section follows its module. Data is never deleted: switching a module back on shows it again. +- **Defaults.** A fresh install starts with everything on except the visitor log (personal data) and fines (money). An upgrade keeps on what is reachable only from an explicit action (MARC, catalogue cards, library cards, user import, patron type rules, closures calendar) and starts off what adds sidebar entries, cataloguing fields, money or personal data (stocktake, suppliers, RDA types, visitor log, fines), unless the library already has data for the module, which then stays on. A missing row behaves as the upgrade default. +- **Catalogue mode** still wins: it also switches off the visitor log, the closures calendar, fines and patron type rules, and the Presenze sidebar entry, which used to stay visible in catalogue mode, now disappears. +- **Mobile API**: `/health.features` gains `fines`, `closures_calendar` and `circulation_rules`; with fines off `GET /me/movements` answers 404 `feature_disabled`. + ### Migration - `migrate_0.9.0.sql`, section "Inventory stocktake": creates `inventario_sessioni`, `inventario_attese` (with `esito_chiusura`, the bucket frozen at close; added with a guard to a table an earlier build created) and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes. @@ -74,6 +81,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - `migrate_0.9.0.sql`, section "Circulation policy": creates `tipologie_lettore` and `regole_prestito`, seeds the default "Standard" type with every rule NULL, adds `utenti.tipologia_id` (FK `ON DELETE SET NULL`), `utenti.card_expiry_warning_sent_for` and `copie.solo_consultazione`. Idempotent; no existing row changes. - `migrate_0.9.0.sql`, section "closures calendar": creates `calendario_chiusure` (closure intervals, optional yearly recurrence and branch, FK to `sedi` ON DELETE CASCADE); the weekly closed days are the setting `loans.closed_weekdays`, no schema. Additive and idempotent. - `migrate_0.9.0.sql`, section "fines-ledger": creates `movimenti_lettore` and converts every existing `prestiti.sanzione > 0` into one charge (`addebito_perdita` for a lost copy, `addebito_danno` otherwise, key `penalty:`), in batches; maintenance converts anything past the batches and records the scan as complete only once the table exists and a pass has run to the end. Until the migration has run, the queue selection and the eligibility check carry no debt predicate. `prestiti.sanzione` stays and is still written. +- `migrate_0.9.0.sql`, section "feature toggles": one `features.` row per module with the upgrade default, insert-if-missing (a saved value is never overwritten); stocktake, suppliers, visitor log, RDA types and fines start on when the library already has sessions, suppliers, visits, RDA overrides on live titles, ledger rows other than the converted legacy penalties, or a configured daily fine. The fresh-install defaults are in the five `data_*.sql` seeds. ## [0.8.9] diff --git a/tests/attendance-090.spec.js b/tests/attendance-090.spec.js index 545a6d5aa..5e290540d 100644 --- a/tests/attendance-090.spec.js +++ b/tests/attendance-090.spec.js @@ -2,6 +2,13 @@ // Visitor log (0.9.0): member card scan, guest form, statistics by day and // hour slot, CSV export, GDPR retention setting and purge (manual and cron). const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// visitor_log, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['visitor_log']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const { execFileSync } = require('child_process'); const path = require('path'); const h = require('./helpers/minor-features-090'); diff --git a/tests/circulation-interop.spec.js b/tests/circulation-interop.spec.js index 9e13630e8..90b452625 100644 --- a/tests/circulation-interop.spec.js +++ b/tests/circulation-interop.spec.js @@ -5,6 +5,13 @@ * the website refuses, with stable codes; the debt block (plan §7) too. */ const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// fines, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['fines']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const F = require('./helpers/circulation-fixtures'); const M = require('./helpers/mobile-api-fixtures'); diff --git a/tests/debt-block.spec.js b/tests/debt-block.spec.js index 334d88235..41f01d4cb 100644 --- a/tests/debt-block.spec.js +++ b/tests/debt-block.spec.js @@ -8,6 +8,13 @@ * Run: /tmp/run-e2e.sh tests/debt-block.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// fines, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['fines']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const { execFileSync } = require('child_process'); const { appTodayISO, appDateOffsetISO } = require('./helpers/app-date'); @@ -232,6 +239,8 @@ test.describe.serial('Debt block', () => { const resId = Number(dbQuery(`SELECT id FROM prenotazioni WHERE utente_id=${state.patronId} ORDER BY id DESC LIMIT 1`)); const out = execFileSync('php', ['-r', ` require 'vendor/autoload.php'; + // Load .env like the cron bootstrap does, so ConfigStore (features.fines) reads this database. + Dotenv\\Dotenv::createImmutable(getcwd())->safeLoad(); $env = Dotenv\\Dotenv::parse(file_get_contents('.env')); $sock = $env['DB_SOCKET'] ?? ''; $db = ($sock !== '' && file_exists($sock)) diff --git a/tests/fines-closed-days.spec.js b/tests/fines-closed-days.spec.js index 3ab01f1ad..2d8b25109 100644 --- a/tests/fines-closed-days.spec.js +++ b/tests/fines-closed-days.spec.js @@ -14,6 +14,13 @@ * Run: /tmp/run-e2e.sh tests/fines-closed-days.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// fines, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['fines']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const fs = require('fs'); const path = require('path'); const F = require('./helpers/circulation-fixtures'); diff --git a/tests/fines-ledger.spec.js b/tests/fines-ledger.spec.js index 9abd4b8f5..d3a5c28d1 100644 --- a/tests/fines-ledger.spec.js +++ b/tests/fines-ledger.spec.js @@ -9,6 +9,13 @@ * Run: /tmp/run-e2e.sh tests/fines-ledger.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// fines, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['fines']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); diff --git a/tests/fines-patron-types.spec.js b/tests/fines-patron-types.spec.js index 8ff44119b..86f3068ef 100644 --- a/tests/fines-patron-types.spec.js +++ b/tests/fines-patron-types.spec.js @@ -11,6 +11,13 @@ * Run: /tmp/run-e2e.sh tests/fines-patron-types.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// fines, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['fines']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const fs = require('fs'); const path = require('path'); const F = require('./helpers/circulation-fixtures'); diff --git a/tests/inventory-stocktake.spec.js b/tests/inventory-stocktake.spec.js index 23c5d6c70..f113b6945 100644 --- a/tests/inventory-stocktake.spec.js +++ b/tests/inventory-stocktake.spec.js @@ -13,6 +13,13 @@ // their copies (tokenised), and removes them in afterAll. const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// stocktake, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['stocktake']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const { execFileSync } = require('child_process'); const crypto = require('crypto'); const fs = require('fs'); diff --git a/tests/marc-bulk-import.spec.js b/tests/marc-bulk-import.spec.js index c36d9b31c..d95bcdc9c 100644 --- a/tests/marc-bulk-import.spec.js +++ b/tests/marc-bulk-import.spec.js @@ -3,6 +3,13 @@ // Files are generated per run (tests/helpers/marc-fixtures.php), so titles and // ISBNs never collide with an earlier run; afterAll removes what the run made. const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// suppliers, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['suppliers']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const { listServerDir, readServerFile, writeServerFile } = require('./helpers/server-files'); const path = require('path'); const { BASE, ROOT, dbQuery, sqlEscape, loginAsAdmin, marcFile, cleanupRun, uploadForPreview, confirmImport } = require('./helpers/marc-e2e'); diff --git a/tests/mobile-api-idempotency.spec.js b/tests/mobile-api-idempotency.spec.js index 2413f419c..1c5faba26 100644 --- a/tests/mobile-api-idempotency.spec.js +++ b/tests/mobile-api-idempotency.spec.js @@ -18,6 +18,13 @@ // Run: /tmp/run-e2e.sh tests/mobile-api-idempotency.spec.js --config=tests/playwright.config.js --workers=1 const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// fines, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['fines']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const { execFileSync } = require('child_process'); const { flushCache } = require('./helpers/flush-cache'); const fs = require('fs'); diff --git a/tests/rda-types-090.spec.js b/tests/rda-types-090.spec.js index 3b3b6978e..954523b9a 100644 --- a/tests/rda-types-090.spec.js +++ b/tests/rda-types-090.spec.js @@ -4,6 +4,13 @@ // output carrying the effective triple (derived types for every record, the // override when one is set: a book keeps txt/n/nc, an audiobook gets spw/s/sd). const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// rda_types, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['rda_types']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const h = require('./helpers/minor-features-090'); test.skip(!h.configured, 'E2E credentials not configured'); diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js index 4a55ff423..b64544b99 100644 --- a/tests/stack-090-integration.spec.js +++ b/tests/stack-090-integration.spec.js @@ -17,6 +17,13 @@ * Run: /tmp/run-e2e.sh tests/stack-090-integration.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// fines, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['fines']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const fs = require('fs'); const path = require('path'); const F = require('./helpers/circulation-fixtures'); diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js index 72dc760c5..becbf63fe 100644 --- a/tests/stack-090-review-fixes.spec.js +++ b/tests/stack-090-review-fixes.spec.js @@ -20,6 +20,13 @@ * Run: /tmp/run-e2e.sh tests/stack-090-review-fixes.spec.js --config=tests/playwright.config.js --workers=1 */ const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// visitor_log / stocktake / suppliers / rda_types / fines, so it switches them on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['visitor_log', 'stocktake', 'suppliers', 'rda_types', 'fines']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const fs = require('fs'); const os = require('os'); const path = require('path'); diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js index f89ae6bf6..f74c45749 100644 --- a/tests/stack-090-zero-config.spec.js +++ b/tests/stack-090-zero-config.spec.js @@ -297,10 +297,13 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { await ctx.close(); } expect(F.dbQuery(`SELECT stato FROM prestiti WHERE utente_id=${debtor.id} AND libro_id=${books.web.id}`)).toBe('pendente'); - // The reader record shows the balance, without any block. + // Fines are an opt-in module (features.fines, off by default): the reader + // record has no Movimenti section and no block; the ledger row stays. await admin.goto(`${F.BASE}/admin/users/details/${debtor.id}`); - await expect(admin.locator('[data-ledger-balance]')).toHaveAttribute('data-ledger-balance', '5000'); + await expect(admin.locator('h1').first()).toBeVisible(); + await expect(admin.locator('#movimenti')).toHaveCount(0); await expect(admin.locator('[data-ledger-blocked]')).toHaveCount(0); + expect(Math.round(Number(F.dbQuery(`SELECT COALESCE(SUM(importo),0) FROM movimenti_lettore WHERE utente_id=${debtor.id}`)) * 100)).toBe(5000); }); test('7. money owed does not block the Mobile API request', async ({ request }) => { diff --git a/tests/statistics-090.spec.js b/tests/statistics-090.spec.js index 8b55098c7..8c3e3a32b 100644 --- a/tests/statistics-090.spec.js +++ b/tests/statistics-090.spec.js @@ -4,6 +4,13 @@ // (readers without a type count under the default type), and the fines // section from the patron ledger (hidden with no fine and no movement). const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// visitor_log / stocktake / fines, so it switches them on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['visitor_log', 'stocktake', 'fines']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const h = require('./helpers/minor-features-090'); test.skip(!h.configured, 'E2E credentials not configured'); diff --git a/tests/suppliers-090.spec.js b/tests/suppliers-090.spec.js index e60f91d06..4f58d97d7 100644 --- a/tests/suppliers-090.spec.js +++ b/tests/suppliers-090.spec.js @@ -2,6 +2,13 @@ // Suppliers (0.9.0): CRUD from the admin pages, assignment in the book form, // the book-list filter and "delete supplier → books keep existing, link NULL". const { test, expect } = require('@playwright/test'); + +// 0.9.0 optional modules (Settings → Modules / Loans): this suite tests +// suppliers, so it switches it on for its run and puts back what was there. +const featureToggles = require('./helpers/feature-toggles'); +let featureSnapshot; +test.beforeAll(async () => { featureSnapshot = await featureToggles.enableFeatures(['suppliers']); }); +test.afterAll(async () => { await featureToggles.restoreFeatures(featureSnapshot); }); const h = require('./helpers/minor-features-090'); test.skip(!h.configured, 'E2E credentials not configured'); From ac5f1b1e76b30837cc2a8636ade6f732bd463864 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 01:41:49 +0100 Subject: [PATCH 202/398] Allowlist the two made-up site verification tokens of the SEO test --- .gitleaksignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index e22b9b419..79f4d3147 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -37,3 +37,8 @@ abbe641a9221c29f1ed009bde2873835b53c20f7:tests/mobile-webpush-encryption.unit.ph ac4fcabc00953f3fd5f5c9d59fe5fb5da7f744cb:tests/schema-integrity.spec.js:generic-api-key:91 5eb52517f88d1ea3fadfffc1f92af21dbd9a1da2:tests/schema-integrity.spec.js:generic-api-key:117 8cfea8b8d47a8df9efef4d203ca96d8697203908:tests/security-hardening.spec.js:generic-api-key:37 +# +# seo-site-verification.spec.js: made-up Google and Bing verification tokens +# the test pastes into Settings > Advanced (0.9.0 stack) +8c61149bae9b82a4582337fc4220cd8b0968f3cb:tests/seo-site-verification.spec.js:generic-api-key:23 +8c61149bae9b82a4582337fc4220cd8b0968f3cb:tests/seo-site-verification.spec.js:generic-api-key:24 From 55926fe43b2127fca40b535ac4b429fc9940a7d7 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 02:02:23 +0100 Subject: [PATCH 203/398] Feature toggles and holidays: fixes from my review of the merged branch - The fines probe of the migration also keeps fines on when a debt threshold or a fine is configured globally, on a patron type or in the rules matrix, so an upgrade from a release candidate that used only the debt block keeps it. - An ICS closure on a day a ticked holiday already covers is imported as its own closure: unticking the holiday later no longer reopens a day the file says is closed. - Switching the closures calendar on or off from Settings > Loans re-checks the pending pickup deadlines at once, as saving the calendar does. --- .../Admin/LibraryCalendarController.php | 9 ++++--- app/Controllers/SettingsController.php | 12 +++++++++ .../database/migrations/migrate_0.9.0.sql | 15 +++++++++-- .../feature-toggle-closures-calendar.spec.js | 8 ++++++ tests/holidays-preset.spec.js | 22 ++++++++++++++++ .../migration-0.9.0-feature-toggles.unit.php | 25 ++++++++++++++++++- 6 files changed, 85 insertions(+), 6 deletions(-) diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php index 5f1294d4a..bce727264 100644 --- a/app/Controllers/Admin/LibraryCalendarController.php +++ b/app/Controllers/Admin/LibraryCalendarController.php @@ -439,7 +439,9 @@ private function insertClosure(\mysqli $db, array $row): void /** * An identical closure is already stored (same interval, recurrence and * branch; a yearly row matches the same month-day span in any year), so - * importing a preset or a file twice adds nothing. + * importing a file twice adds nothing. Holiday preset rows do not count: + * unticking that holiday later removes them, and the day the file + * names must stay closed. * * @param array{start: string, end: string, description: string, yearly: bool, sede: ?int} $row */ @@ -447,13 +449,14 @@ private function alreadyClosed(\mysqli $db, array $row): bool { $yearly = $row['yearly'] ? 1 : 0; $sede = $row['sede'] ?? 0; + $manual = HolidayPresets::schemaReady($db) ? ' AND regola_festivita IS NULL' : ''; if ($yearly === 1) { $from = substr($row['start'], 5); $to = substr($row['end'], 5); - $stmt = $db->prepare("SELECT 1 FROM calendario_chiusure WHERE ricorrente_annuale = 1 AND DATE_FORMAT(data_inizio, '%m-%d') = ? AND DATE_FORMAT(data_fine, '%m-%d') = ? AND COALESCE(sede_id, 0) = ? LIMIT 1"); + $stmt = $db->prepare("SELECT 1 FROM calendario_chiusure WHERE ricorrente_annuale = 1 AND DATE_FORMAT(data_inizio, '%m-%d') = ? AND DATE_FORMAT(data_fine, '%m-%d') = ? AND COALESCE(sede_id, 0) = ?{$manual} LIMIT 1"); $stmt->bind_param('ssi', $from, $to, $sede); } else { - $stmt = $db->prepare('SELECT 1 FROM calendario_chiusure WHERE ricorrente_annuale = 0 AND data_inizio = ? AND data_fine = ? AND COALESCE(sede_id, 0) = ? LIMIT 1'); + $stmt = $db->prepare("SELECT 1 FROM calendario_chiusure WHERE ricorrente_annuale = 0 AND data_inizio = ? AND data_fine = ? AND COALESCE(sede_id, 0) = ?{$manual} LIMIT 1"); $stmt->bind_param('ssi', $row['start'], $row['end'], $sede); } $stmt->execute(); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 65632e58e..f865af885 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -1810,7 +1810,19 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $repository->set('loans', 'fine_charged_email', $fineEmail ? '1' : '0'); // The three circulation modules of this tab (patron type rules, // closures calendar, fines): library policy, administrators only. + $calendarWasOn = \App\Support\Features::enabled('closures_calendar'); $this->saveFeatureSwitches($repository, $data, \App\Support\Features::LOANS_TAB); + // The calendar switched on applies its closures at once: a pending + // pickup whose deadline now falls on a closed day moves to the + // next open day, as when the calendar itself is saved. + if (\App\Support\Features::enabled('closures_calendar') !== $calendarWasOn) { + \App\Support\LibraryCalendar::reset(); + try { + \App\Support\LibraryCalendar::extendPickupDeadlines($db); + } catch (\Throwable $e) { + \App\Support\SecureLogger::warning('Calendar: pickup deadlines not extended: ' . $e->getMessage()); + } + } } // App timezone: DateHelper computes the loan clock ("today"/"now") from diff --git a/installer/database/migrations/migrate_0.9.0.sql b/installer/database/migrations/migrate_0.9.0.sql index f114936cf..d99e2678e 100644 --- a/installer/database/migrations/migrate_0.9.0.sql +++ b/installer/database/migrations/migrate_0.9.0.sql @@ -439,13 +439,24 @@ INSERT INTO system_settings (category, setting_key, setting_value, description) SELECT 'features', 'rda_types', @ft_val, 'Optional module: RDA types' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'rda_types'); -- Fines: a ledger row the legacy-penalty conversion did not write (key penalty:), --- or a daily fine already configured. +-- a daily fine or a debt threshold already configured, globally, on a patron +-- type or on a cell of the rules matrix. SET @ft_has = (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'movimenti_lettore'); SET @ft_sql = IF(@ft_has > 0, 'SELECT IF(EXISTS(SELECT 1 FROM movimenti_lettore WHERE chiave_idempotenza IS NULL OR chiave_idempotenza NOT LIKE ''penalty:%''), ''1'', ''0'') INTO @ft_val', 'SELECT ''0'' INTO @ft_val'); PREPARE ft_stmt FROM @ft_sql; EXECUTE ft_stmt; DEALLOCATE PREPARE ft_stmt; -SET @ft_val = IF(@ft_val = '1' OR EXISTS(SELECT 1 FROM system_settings WHERE category = 'loans' AND setting_key = 'daily_fine' AND setting_value REGEXP '^[0-9]{1,8}([.,][0-9]{1,2})?$' AND CAST(REPLACE(setting_value, ',', '.') AS DECIMAL(10,2)) > 0), '1', '0'); +SET @ft_val = IF(@ft_val = '1' OR EXISTS(SELECT 1 FROM system_settings WHERE category = 'loans' AND setting_key IN ('daily_fine', 'debt_block_threshold') AND setting_value REGEXP '^[0-9]{1,8}([.,][0-9]{1,2})?$' AND CAST(REPLACE(setting_value, ',', '.') AS DECIMAL(10,2)) > 0), '1', '0'); +SET @ft_has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tipologie_lettore' AND COLUMN_NAME IN ('multa_giornaliera', 'soglia_blocco_debito')); +SET @ft_sql = IF(@ft_has = 2 AND @ft_val = '0', 'SELECT IF(EXISTS(SELECT 1 FROM tipologie_lettore WHERE multa_giornaliera > 0 OR soglia_blocco_debito > 0), ''1'', ''0'') INTO @ft_val', 'SELECT @ft_val INTO @ft_val'); +PREPARE ft_stmt FROM @ft_sql; +EXECUTE ft_stmt; +DEALLOCATE PREPARE ft_stmt; +SET @ft_has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'regole_prestito' AND COLUMN_NAME = 'multa_giornaliera'); +SET @ft_sql = IF(@ft_has = 1 AND @ft_val = '0', 'SELECT IF(EXISTS(SELECT 1 FROM regole_prestito WHERE multa_giornaliera > 0), ''1'', ''0'') INTO @ft_val', 'SELECT @ft_val INTO @ft_val'); +PREPARE ft_stmt FROM @ft_sql; +EXECUTE ft_stmt; +DEALLOCATE PREPARE ft_stmt; INSERT INTO system_settings (category, setting_key, setting_value, description) SELECT 'features', 'fines', @ft_val, 'Optional module: Fines and patron ledger' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'fines'); diff --git a/tests/feature-toggle-closures-calendar.spec.js b/tests/feature-toggle-closures-calendar.spec.js index 690ecab90..7377f5192 100644 --- a/tests/feature-toggle-closures-calendar.spec.js +++ b/tests/feature-toggle-closures-calendar.spec.js @@ -51,6 +51,7 @@ test.describe.serial('Feature toggle: closures calendar', () => { test.afterAll(async () => { await ft.restoreFeatures(snapshot); ft.dbQuery(`DELETE FROM calendario_chiusure WHERE descrizione LIKE 'ZZ_FT_CAL %'`); + if (bookId > 0) ft.dbQuery(`DELETE FROM prestiti WHERE libro_id=${bookId}`); mf.deleteBooks([bookId]); await page?.close(); }); @@ -107,7 +108,14 @@ test.describe.serial('Feature toggle: closures calendar', () => { }); test('6. switched back on: entry, page and the closure stored before are back', async () => { + // A pickup set up while the calendar was off, due on the closed day. + const copyId = ft.dbQuery(`SELECT id FROM copie WHERE libro_id=${bookId} ORDER BY id LIMIT 1`); + const userId = ft.dbQuery('SELECT id FROM utenti ORDER BY id LIMIT 1'); + ft.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo, pickup_deadline) VALUES (${bookId}, ${copyId}, ${userId}, CURDATE(), CURDATE() + INTERVAL 30 DAY, 'da_ritirare', 1, '${closed}')`); await ft.setModuleInUi(page, 'closures_calendar', true); + // Switching the calendar on moves it to the next open day at once. + const deadline = ft.dbQuery(`SELECT DATE_FORMAT(pickup_deadline, '%Y-%m-%d') FROM prestiti WHERE libro_id=${bookId} AND stato='da_ritirare'`); + expect(deadline > closed).toBe(true); await expect(page.locator('#open-closures-calendar')).toBeVisible(); await page.locator('#open-closures-calendar').click(); await expect(page).toHaveURL(/\/admin\/calendar/); diff --git a/tests/holidays-preset.spec.js b/tests/holidays-preset.spec.js index a5e272f01..caeef520e 100644 --- a/tests/holidays-preset.spec.js +++ b/tests/holidays-preset.spec.js @@ -360,4 +360,26 @@ test.describe('Public holiday presets', () => { expect(await holidayDate(page, 'FR:vendredi_saint')).toBe(addDays(easter(year), -2)); expect(await holidayDate(page, 'FR:saint_etienne')).toBe(`${year}-12-26`); }); + + test('13. a file that closes on a ticked holiday adds its own closure: unticking the holiday later keeps the day closed', async () => { + await chooseCountry(page, 'IT'); + await page.locator('[data-holiday-select="none"]').click(); + await box(page, 'IT:christmas').check(); + await submitAndLand(page, '#import-preset'); + expect(presetCount("regola_festivita='IT:christmas'")).toBe(1); + const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//E2E//Pinakes//IT', + 'BEGIN:VEVENT', 'UID:h13@e2e', `DTSTART;VALUE=DATE:${year}1225`, 'RRULE:FREQ=YEARLY', `SUMMARY:${TAG} ics natale`, 'END:VEVENT', + 'END:VCALENDAR', ''].join('\r\n'); + await page.goto(`${BASE}/admin/calendar`); + await page.setInputFiles('#ics_file', { name: 'chiusure.ics', mimeType: 'text/calendar', buffer: Buffer.from(ics) }); + await submitAndLand(page, '#import-ics'); + expect(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE regola_festivita IS NULL AND ricorrente_annuale=1 AND descrizione=${S(TAG + ' ics natale')}`)).toBe('1'); + // The library no longer closes for the holiday, but the file still says closed. + await chooseCountry(page, 'IT'); + await page.locator('[data-holiday-select="none"]').click(); + await submitAndLand(page, '#import-preset'); + expect(presetCount()).toBe(0); + await page.goto(`${BASE}/admin/calendar?month=${year + 1}-12`); + await expect(page.locator(`[data-calendar-day="${year + 1}-12-25"]`)).toHaveAttribute('data-closed', '1'); + }); }); diff --git a/tests/migration-0.9.0-feature-toggles.unit.php b/tests/migration-0.9.0-feature-toggles.unit.php index 0a068e5ae..2ca7bd321 100644 --- a/tests/migration-0.9.0-feature-toggles.unit.php +++ b/tests/migration-0.9.0-feature-toggles.unit.php @@ -10,7 +10,8 @@ * the eleven rows with Features::DEFAULTS, a second run changes nothing; * B. a value the operator already saved is never overwritten; * C. an upgrade from a 0.9.0 release candidate whose library used a module - * (sessions, suppliers, visits, RDA overrides, ledger rows, a daily fine) + * (sessions, suppliers, visits, RDA overrides, ledger rows, a daily fine, + * a debt threshold, globally or per patron type, a fine in the rules matrix) * starts that module ON; legacy-penalty rows alone do not turn fines on; * D. the full migration file, every section, then this one: same defaults. * @@ -142,6 +143,28 @@ $db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', 'daily_fine', '0,50')"); $run($db, $section); $check($rows($db)['fines'] === '1', 'C: a configured daily fine keeps fines on'); + $db->query("DELETE FROM system_settings WHERE category = 'features' AND setting_key = 'fines'"); + $db->query("UPDATE system_settings SET setting_value = '' WHERE category = 'loans' AND setting_key = 'daily_fine'"); + $db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', 'debt_block_threshold', '10.00')"); + $run($db, $section); + $check($rows($db)['fines'] === '1', 'C: a configured debt threshold alone keeps fines on (the debt block stays)'); + $db->query("DELETE FROM system_settings WHERE category = 'features' AND setting_key = 'fines'"); + $db->query("UPDATE system_settings SET setting_value = '0' WHERE category = 'loans' AND setting_key = 'debt_block_threshold'"); + $db->query('CREATE TABLE tipologie_lettore (id int NOT NULL AUTO_INCREMENT PRIMARY KEY, multa_giornaliera decimal(10,2) DEFAULT NULL, soglia_blocco_debito decimal(10,2) DEFAULT NULL)'); + $db->query('CREATE TABLE regole_prestito (id int NOT NULL AUTO_INCREMENT PRIMARY KEY, multa_giornaliera decimal(10,2) DEFAULT NULL)'); + $db->query('INSERT INTO tipologie_lettore (multa_giornaliera, soglia_blocco_debito) VALUES (NULL, NULL), (0.00, 0.00)'); + $db->query('INSERT INTO regole_prestito (multa_giornaliera) VALUES (NULL), (0.00)'); + $run($db, $section); + $check($rows($db)['fines'] === '0', 'C: a zero threshold and types and rules without amounts leave fines off'); + $db->query("DELETE FROM system_settings WHERE category = 'features' AND setting_key = 'fines'"); + $db->query('UPDATE tipologie_lettore SET soglia_blocco_debito = 20.00 WHERE id = 2'); + $run($db, $section); + $check($rows($db)['fines'] === '1', 'C: a debt threshold on a patron type keeps fines on'); + $db->query("DELETE FROM system_settings WHERE category = 'features' AND setting_key = 'fines'"); + $db->query('UPDATE tipologie_lettore SET soglia_blocco_debito = NULL'); + $db->query('UPDATE regole_prestito SET multa_giornaliera = 0.20 WHERE id = 2'); + $run($db, $section); + $check($rows($db)['fines'] === '1', 'C: a daily fine on a cell of the rules matrix keeps fines on'); $db->query("DELETE FROM system_settings WHERE category = 'features' AND setting_key = 'rda_types'"); $db->query("UPDATE libri SET deleted_at = NOW()"); $run($db, $section); From a2f57c957388cd11c19fbe0e259961a31ef4542d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 02:10:16 +0100 Subject: [PATCH 204/398] Fines: a loss penalty assessed while fines are off reaches the ledger once they are back on The legacy backfill marker is re-armed when the penalty is written with the module off, so the next maintenance run after fines are switched back on mirrors it, even in a library whose backfill was already recorded as done. --- app/Services/PatronLedger.php | 10 ++++++++-- tests/feature-toggles-services.unit.php | 24 ++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/Services/PatronLedger.php b/app/Services/PatronLedger.php index ddb33f3d5..e7c422616 100644 --- a/app/Services/PatronLedger.php +++ b/app/Services/PatronLedger.php @@ -250,9 +250,15 @@ public function chargeLateReturn(int $loanId, ?int $staffId = null): int */ public function chargeLossPenalty(int $loanId, ?int $staffId = null): int { - // Fines off: prestiti.sanzione alone, as before 0.9.0. The same key - // as the legacy backfill, which mirrors it once the module is back on. + // Fines off: prestiti.sanzione alone, as before 0.9.0. The legacy + // backfill mirrors it (same key) once the module is back on; its + // marker is re-armed here, because a library that already had fines + // on has the scan recorded as done. Same transaction as the close. if (!self::active($this->db)) { + $loan = $this->loanRow($loanId); + if ($loan !== null && FineParameters::decimalToCents($loan['sanzione'] ?? 0) > 0) { + (new \App\Models\SettingsRepository($this->db))->set('loans', 'legacy_penalties_backfilled', '0'); + } return 0; } $loan = $this->loanRow($loanId); diff --git a/tests/feature-toggles-services.unit.php b/tests/feature-toggles-services.unit.php index a2c82220c..dba3db90d 100644 --- a/tests/feature-toggles-services.unit.php +++ b/tests/feature-toggles-services.unit.php @@ -12,7 +12,8 @@ * B. circulation rules: off = no patron types, every copy lendable, the * consultation flag ignored; on = the types and the flag apply; * C. fines: off = no charge, no preview, no debt block in PHP nor in SQL, - * even for a patron far above the threshold; on = blocked; + * even for a patron far above the threshold; on = blocked; a loss + * penalty assessed while off reaches the ledger once fines are back on; * D. RDA types: off = stored overrides ignored by the record view and MARC; * E. suppliers: off = a book save never writes fornitore_id (kept); * F. visitor log: off = maintenance purges nothing; on = the old visit goes; @@ -66,7 +67,7 @@ $run = bin2hex(random_bytes(3)); $saved = []; -foreach (['debt_block_threshold'] as $k) { +foreach (['debt_block_threshold', 'legacy_penalties_backfilled'] as $k) { $r = $db->query("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = '{$k}'")->fetch_row(); $saved[$k] = $r === null ? null : (string) $r[0]; } @@ -86,6 +87,7 @@ } $db->query("DELETE m FROM movimenti_lettore m JOIN utenti u ON u.id = m.utente_id WHERE u.email LIKE 'zz-ftsvc-%@test.local'"); $db->query("DELETE FROM presenze WHERE nome_ospite LIKE 'ZZ_FTSVC_%'"); + $db->query("DELETE p FROM prestiti p JOIN libri l ON l.id = p.libro_id WHERE l.titolo LIKE 'ZZ\\_FTSVC\\_%'"); $db->query("DELETE c FROM copie c JOIN libri l ON l.id = c.libro_id WHERE l.titolo LIKE 'ZZ\\_FTSVC\\_%'"); $db->query("DELETE FROM libri WHERE titolo LIKE 'ZZ\\_FTSVC\\_%'"); $db->query("DELETE FROM fornitori WHERE nome LIKE 'ZZ_FTSVC_%'"); @@ -170,6 +172,24 @@ $check((new CirculationPolicy($db))->debtBlockError($patron) === 'debt_block', 'C on: the same patron is blocked'); $check(LoanEligibility::debtBlockSql('u', $db) !== '1 = 1', 'C on: the queue predicate checks the balance'); + // C2. A loss penalty assessed while fines are off, in a library whose + // legacy backfill is already recorded as done: it reaches the ledger with + // the first maintenance run after fines are switched back on. + $db->query("INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES ('ZZ_FTSVC_lost_{$run}', 1, 0)"); + $lostBook = (int) $db->insert_id; + $db->query("INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, stato, attivo, sanzione) VALUES ({$lostBook}, {$patron}, CURDATE() - INTERVAL 40 DAY, CURDATE() - INTERVAL 10 DAY, 'perso', 0, 12.00)"); + $lostLoan = (int) $db->insert_id; + $marker = static fn (): string => (string) ($db->query("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = 'legacy_penalties_backfilled'")->fetch_row()[0] ?? ''); + $penaltyRows = static fn (): int => (int) $db->query("SELECT COUNT(*) FROM movimenti_lettore WHERE chiave_idempotenza = 'penalty:{$lostLoan}'")->fetch_row()[0]; + $db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', 'legacy_penalties_backfilled', '1') ON DUPLICATE KEY UPDATE setting_value = '1'"); + Features::override('fines', false); + $check(PatronLedger::forDb($db)->chargeLossPenalty($lostLoan) === 0 && $penaltyRows() === 0, 'C2 off: the loss penalty stays in prestiti.sanzione only'); + $check($marker() === '0', 'C2 off: the legacy backfill is re-armed'); + Features::override('fines', true); + $db->query("DELETE FROM system_settings WHERE category = 'maintenance' AND setting_key = 'last_run'"); + (new \App\Support\MaintenanceService($db))->runAll(); + $check($penaltyRows() === 1 && $marker() === '1', 'C2 on: maintenance mirrors it into the ledger and records the scan again'); + // ── D. RDA types ──────────────────────────────────────────────────────── $row = ['tipo_media' => 'libro', 'rda_content_type' => 'prm', 'rda_media_type' => 'c', 'rda_carrier_type' => 'cr']; Features::override('rda_types', false); From cac20f9f67882b1c20c60254f331e4af0dcbe5c1 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 07:38:30 +0100 Subject: [PATCH 205/398] Fix holiday horizons and serialize calendar preset updates --- CHANGELOG.md | 1 + app/Support/Holidays/HolidayPresets.php | 96 ++++++++++++++++++++----- app/Support/LibraryCalendar.php | 58 ++++++++++++--- tests/holiday-presets-apply.unit.php | 72 +++++++++++++++++++ tests/holidays-preset.spec.js | 18 +++++ tests/library-calendar.unit.php | 30 ++++++++ 6 files changed, 250 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbf25de34..e2841ca09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Pick the country, then the holidays.** The holiday card of `/admin/calendar` replaces the Italian-only import: the librarian chooses the country (Italy, United States, United Kingdom, Ireland, Germany, France, Denmark, Spain, Portugal, Netherlands, Belgium, Austria, Switzerland, Sweden, Norway, Finland, Poland, Canada, Australia, Brazil, Argentina, Mexico; preselected from the installation language) and, where holidays differ, the region (German Länder, UK nations, Canadian provinces and territories, Australian states and territories, Alsace-Moselle, six Swiss cantons), sees every holiday with its native name and its date in the preview year, ticks the ones the library closes for (select all / none; customary holidays such as Christmas Eve start unticked) and applies. - **Rules, not dates.** Fixed dates, Easter offsets (Good Friday, Ascension, Whit Monday, Corpus Christi, Carnival…), the nth weekday of a month (Thanksgiving, Memorial Day, bank holidays), the weekday on or before/after a date (Victoria Day, Buß- und Bettag, Midsommar), the weekend substitutes of the US, UK, Ireland, Canada and Australia (including Christmas and Boxing Day both on a weekend), Koningsdag, the Argentine movable holidays, first and last years (Store bededag until 2023, Juneteenth from 2021, San Francesco in Italy from 2026, Wigilia in Poland from 2025) and the one-year moves of the early May and spring bank holidays (VE Day 2020, the Jubilees). - **Applying is idempotent and selective.** The choice is stored in `calendar.holiday_country`, `calendar.holiday_region` and `calendar.holiday_rules`; each ticked holiday becomes closures marked with its rule (`calendario_chiusure.regola_festivita`): a fixed date as one yearly closure, any other holiday dated per year up to two years ahead, extended every year by the maintenance run (`holiday_rows_added` in the cron log). Applying again adds nothing, unticking removes only that holiday, switching country replaces the preset; closures added by hand or imported from ICS are never touched. Holiday rows show a "Festività" badge in the list and are changed from the selection only. Rows stored by the former Italian import are recognised and adopted, not duplicated. +- **Holiday rules also cover distant and historical dates.** The dated rows are the admin preview, not a limit on loan policy: due dates, pickup days, renewal caps, availability and late-fine days evaluate the selected rules for the year requested, including an observed New Year that falls in December of the preceding year. Switching the calendar back on works immediately, without waiting for maintenance. Applying a selection and yearly upkeep share a transaction lock and current reads, so a concurrent save cannot restore an unticked holiday or duplicate its rows; manual and ICS closures remain independent. - **Yearly closures on and off.** The yearly closures added by hand or from ICS are listed in their own card and can be suspended and resumed without deleting them (`calendario_chiusure.attiva`); a suspended closure stays in the list with a "Sospesa" badge and closes no day. ### Fines, patron ledger and debt block (plan §7) diff --git a/app/Support/Holidays/HolidayPresets.php b/app/Support/Holidays/HolidayPresets.php index f5a131020..5144cace4 100644 --- a/app/Support/Holidays/HolidayPresets.php +++ b/app/Support/Holidays/HolidayPresets.php @@ -8,6 +8,7 @@ use App\Support\DateHelper; use App\Support\DbAtomic; use App\Support\I18n; +use App\Support\LibraryCalendar; /** * The holidays the library chose, and their rows in calendario_chiusure. @@ -16,8 +17,9 @@ * holiday_region and holiday_rules (a JSON list of "CC:rule" ids). Each chosen * rule is materialised as closures marked with regola_festivita = its id: a * fixed date that never moves as ONE yearly row, any other rule as one row - * per year, from the current year to YEARS_AHEAD after it. LibraryCalendar - * keeps reading calendario_chiusure only, so the due-date engine is the same. + * per year, from the current year to YEARS_AHEAD after it, for the admin list. + * LibraryCalendar also evaluates the selected rules on demand, so due dates + * and fine days are never limited to this materialised preview window. * * Rows with regola_festivita NULL (added by hand or from an ICS file) are * never changed or removed here, with one exception: a row that is exactly @@ -76,15 +78,36 @@ public static function defaultCountry(): string */ public static function selection(\mysqli $db): array { - $repo = new SettingsRepository($db); - $country = strtoupper((string) $repo->get(self::SETTINGS_CATEGORY, 'holiday_country', '')); + return self::readSelection($db); + } + + /** + * Read all three values in one statement: a concurrent apply cannot mix + * one country's code with another selection's rules. Writers use a + * locking current read, including inside an existing REPEATABLE READ + * transaction whose ordinary reads may still see an older snapshot. + * + * @return array{country: string, region: string, rules: list, saved: bool} + */ + private static function readSelection(\mysqli $db, bool $forUpdate = false): array + { + $result = $db->query("SELECT setting_key, setting_value FROM system_settings WHERE category = 'calendar' AND setting_key IN ('holiday_country', 'holiday_region', 'holiday_rules')" . ($forUpdate ? ' FOR UPDATE' : '')); + if (!$result instanceof \mysqli_result) { + throw new \RuntimeException('Holiday selection could not be read'); + } + $values = []; + while ($row = $result->fetch_assoc()) { + $values[(string) $row['setting_key']] = (string) $row['setting_value']; + } + $result->free(); + $country = strtoupper($values['holiday_country'] ?? ''); $saved = $country !== '' && HolidayCalculator::country($country) !== null; if (!$saved) { $country = self::defaultCountry(); } - $region = HolidayCalculator::normaliseRegion($country, (string) $repo->get(self::SETTINGS_CATEGORY, 'holiday_region', '')); + $region = HolidayCalculator::normaliseRegion($country, $values['holiday_region'] ?? ''); $rules = []; - $decoded = json_decode((string) $repo->get(self::SETTINGS_CATEGORY, 'holiday_rules', '[]'), true); + $decoded = json_decode($values['holiday_rules'] ?? '[]', true); foreach (is_array($decoded) ? $decoded : [] as $id) { if (is_string($id)) { $rules[] = $id; @@ -93,6 +116,41 @@ public static function selection(\mysqli $db): array return ['country' => $country, 'region' => $region, 'rules' => $saved ? self::filterIds($country, $region, $rules) : [], 'saved' => $saved]; } + /** + * The rules the due-date engine evaluates for any requested year. null + * means there is no saved selection (or no migration yet): retain the + * dated closures, including those of the former Italian preset. + * + * @return list>|null + */ + public static function calendarRules(\mysqli $db): ?array + { + if (!self::schemaReady($db)) { + return null; + } + $selection = self::selection($db); + if (!$selection['saved']) { + return null; + } + $wanted = array_flip($selection['rules']); + return array_values(array_filter( + HolidayCalculator::rulesFor($selection['country'], $selection['region']), + static fn (array $rule): bool => isset($wanted[$selection['country'] . ':' . (string) $rule['id']]) + )); + } + + /** + * Shared transaction lock for apply and upkeep. The row exists even + * before the first selection; a no-op upsert serialises that first save + * too. The lock lasts until the owning transaction commits or rolls back. + */ + private static function lockSelection(\mysqli $db): void + { + if (!$db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('calendar', 'holiday_rules', '[]') ON DUPLICATE KEY UPDATE setting_key = setting_key")) { + throw new \RuntimeException('Holiday selection could not be locked'); + } + } + /** * Keep the ids that belong to $country / $region, in table order. * @@ -184,6 +242,7 @@ public static function apply(\mysqli $db, string $country, string $region, array $counts = ['added' => 0, 'removed' => 0, 'adopted' => 0]; DbAtomic::run($db, static function () use ($db, $country, $region, $ids, $firstYear, &$counts): void { + self::lockSelection($db); $repo = new SettingsRepository($db); $repo->set(self::SETTINGS_CATEGORY, 'holiday_country', $country); $repo->set(self::SETTINGS_CATEGORY, 'holiday_region', $region); @@ -213,6 +272,7 @@ public static function apply(\mysqli $db, string $country, string $region, array } }, 'HolidayPresets::apply'); + LibraryCalendar::reset(); return $counts; } @@ -226,13 +286,14 @@ public static function ensureYears(\mysqli $db, ?string $today = null): int if (!self::schemaReady($db)) { return 0; } - $selection = self::selection($db); - if (!$selection['saved'] || $selection['rules'] === []) { - return 0; - } $firstYear = (int) substr($today ?? DateHelper::today(), 0, 4); $added = 0; - DbAtomic::run($db, static function () use ($db, $selection, $firstYear, &$added): void { + DbAtomic::run($db, static function () use ($db, $firstYear, &$added): void { + self::lockSelection($db); + $selection = self::readSelection($db, true); + if (!$selection['saved'] || $selection['rules'] === []) { + return; + } foreach (self::expectedRows($selection['country'], $selection['region'], $selection['rules'], $firstYear) as $ruleId => $rows) { foreach ($rows as $row) { if (self::ensureRow($db, $ruleId, $row) !== null) { @@ -241,6 +302,9 @@ public static function ensureYears(\mysqli $db, ?string $today = null): int } } }, 'HolidayPresets::ensureYears'); + // Even a no-op upkeep may have read a newer selection than a calendar + // cached earlier in this process, before another writer committed. + LibraryCalendar::reset(); return $added; } @@ -262,7 +326,7 @@ private static function pruneStale(\mysqli $db, string $ruleId, array $rows, int } } $from = $keepDates !== [] ? min(array_keys($keepDates)) : sprintf('%04d-01-01', $firstYear); - $stmt = $db->prepare('SELECT id, data_inizio, ricorrente_annuale FROM calendario_chiusure WHERE regola_festivita = ?'); + $stmt = $db->prepare('SELECT id, data_inizio, ricorrente_annuale FROM calendario_chiusure WHERE regola_festivita = ? FOR UPDATE'); $stmt->bind_param('s', $ruleId); $stmt->execute(); $existing = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); @@ -297,10 +361,10 @@ private static function ensureRow(\mysqli $db, string $ruleId, array $row): ?str $yearly = $row['yearly'] ? 1 : 0; if ($yearly === 1) { $md = substr($row['start'], 5); - $stmt = $db->prepare("SELECT 1 FROM calendario_chiusure WHERE regola_festivita = ? AND ricorrente_annuale = 1 AND DATE_FORMAT(data_inizio, '%m-%d') = ? LIMIT 1"); + $stmt = $db->prepare("SELECT 1 FROM calendario_chiusure WHERE regola_festivita = ? AND ricorrente_annuale = 1 AND DATE_FORMAT(data_inizio, '%m-%d') = ? LIMIT 1 FOR UPDATE"); $stmt->bind_param('ss', $ruleId, $md); } else { - $stmt = $db->prepare('SELECT 1 FROM calendario_chiusure WHERE regola_festivita = ? AND ricorrente_annuale = 0 AND data_inizio = ? LIMIT 1'); + $stmt = $db->prepare('SELECT 1 FROM calendario_chiusure WHERE regola_festivita = ? AND ricorrente_annuale = 0 AND data_inizio = ? LIMIT 1 FOR UPDATE'); $stmt->bind_param('ss', $ruleId, $row['start']); } $stmt->execute(); @@ -316,11 +380,11 @@ private static function ensureRow(\mysqli $db, string $ruleId, array $row): ?str if ($yearly === 1) { $md = substr($row['start'], 5); $sql = "SELECT id FROM calendario_chiusure WHERE regola_festivita IS NULL AND sede_id IS NULL AND ricorrente_annuale = 1 - AND DATE_FORMAT(data_inizio, '%m-%d') = ? AND DATE_FORMAT(data_fine, '%m-%d') = ? AND descrizione IN ({$placeholders}) ORDER BY id LIMIT 1"; + AND DATE_FORMAT(data_inizio, '%m-%d') = ? AND DATE_FORMAT(data_fine, '%m-%d') = ? AND descrizione IN ({$placeholders}) ORDER BY id LIMIT 1 FOR UPDATE"; $params = array_merge([$md, $md], $legacy); } else { $sql = "SELECT id FROM calendario_chiusure WHERE regola_festivita IS NULL AND sede_id IS NULL AND ricorrente_annuale = 0 - AND data_inizio = ? AND data_fine = ? AND descrizione IN ({$placeholders}) ORDER BY id LIMIT 1"; + AND data_inizio = ? AND data_fine = ? AND descrizione IN ({$placeholders}) ORDER BY id LIMIT 1 FOR UPDATE"; $params = array_merge([$row['start'], $row['end']], $legacy); } $stmt = $db->prepare($sql); diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index 207e3dedb..1d0520485 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -9,10 +9,11 @@ /** * The days the library is closed, and every date rule that depends on them. * - * Two sources: the weekly closed days (`loans.closed_weekdays`, a comma list - * of 0 = Sunday … 6 = Saturday) and the `calendario_chiusure` table (one row + * Sources: the weekly closed days (`loans.closed_weekdays`, a comma list + * of 0 = Sunday … 6 = Saturday), the `calendario_chiusure` table (one row * per closed interval, optionally recurring every year, optionally for one - * branch only). With neither configured every day is open, and each method + * branch only), and the selected national holiday rules, evaluated for any + * requested year. With none configured every day is open, and each method * returns exactly what the code did before the calendar existed: that is the * zero-config equivalence the loan paths rely on. * @@ -48,6 +49,12 @@ final class LibraryCalendar /** @var list */ private array $closures = []; + /** @var list> selected holiday rules, evaluated for any year */ + private array $holidayRules; + + /** @var array> holiday dates per requested calendar year */ + private array $holidayDates = []; + /** @var array per-connection cache, keyed by spl_object_id */ private static array $instances = []; @@ -57,9 +64,11 @@ final class LibraryCalendar /** * @param list|array $closedWeekdays 0 = Sunday … 6 = Saturday * @param list $closures + * @param list> $holidayRules */ - public function __construct(array $closedWeekdays = [], array $closures = []) + public function __construct(array $closedWeekdays = [], array $closures = [], array $holidayRules = []) { + $this->holidayRules = $holidayRules; foreach ($closedWeekdays as $weekday) { $weekday = (int) $weekday; if ($weekday >= 0 && $weekday <= 6) { @@ -107,7 +116,8 @@ public static function fromDatabase(\mysqli $db): self if (!Features::enabled('closures_calendar')) { return new self([], []); } - return new self(self::loadClosedWeekdays($db), self::loadClosures($db)); + $holidayRules = Holidays\HolidayPresets::calendarRules($db); + return new self(self::loadClosedWeekdays($db), self::loadClosures($db, $holidayRules !== null), $holidayRules ?? []); } /** @@ -223,10 +233,10 @@ public static function extendPickupDeadlines(\mysqli $db): int // ── Queries ───────────────────────────────────────────────────────────── - /** True when a weekly closed day or a closure row is configured. */ + /** True when weekly closures, closure rows or selected holidays apply. */ public function hasRules(): bool { - return $this->closedWeekdays !== [] || $this->closures !== []; + return $this->closedWeekdays !== [] || $this->closures !== [] || $this->holidayRules !== []; } /** @@ -255,7 +265,31 @@ public function isOpen(string $ymd, ?int $sedeId = null): bool return false; } } - return true; + return !$this->isHoliday($ymd); + } + + /** Selected holidays apply beyond the admin list's materialised years. */ + private function isHoliday(string $ymd): bool + { + if ($this->holidayRules === []) { + return false; + } + $year = (int) substr($ymd, 0, 4); + if (!isset($this->holidayDates[$year])) { + $dates = []; + // An observed New Year's Day can land on 31 December of the + // preceding year. Include adjacent source years before filtering. + for ($sourceYear = max(1, $year - 1); $sourceYear <= min(9999, $year + 1); $sourceYear++) { + foreach ($this->holidayRules as $rule) { + $date = Holidays\HolidayCalculator::dateOf($rule, $sourceYear); + if ($date !== null && (int) substr($date, 0, 4) === $year) { + $dates[$date] = true; + } + } + } + $this->holidayDates[$year] = $dates; + } + return isset($this->holidayDates[$year][$ymd]); } /** @@ -661,7 +695,7 @@ private static function loadClosedWeekdays(\mysqli $db): array } /** @return list> */ - private static function loadClosures(\mysqli $db): array + private static function loadClosures(\mysqli $db, bool $rulesFromSelection = false): array { try { // Before the 0.9.0 migration ran the table does not exist: no closures. @@ -674,6 +708,12 @@ private static function loadClosures(\mysqli $db): array // the column is missing and every row counts, as before. $hasActive = $db->query("SHOW COLUMNS FROM calendario_chiusure LIKE 'attiva'"); $filter = ($hasActive instanceof \mysqli_result && $hasActive->num_rows > 0) ? ' WHERE attiva = 1' : ''; + // Once a selection exists it alone defines preset holidays. Old + // materialised rows cannot reintroduce unticked holidays, and a + // missing future row cannot make a selected holiday an open day. + if ($rulesFromSelection) { + $filter .= ($filter === '' ? ' WHERE ' : ' AND ') . 'regola_festivita IS NULL'; + } $result = $db->query('SELECT data_inizio, data_fine, ricorrente_annuale, sede_id FROM calendario_chiusure' . $filter); return $result === false ? [] : $result->fetch_all(MYSQLI_ASSOC); } catch (\Throwable $e) { diff --git a/tests/holiday-presets-apply.unit.php b/tests/holiday-presets-apply.unit.php index a9dbbdb9d..f6badf08f 100644 --- a/tests/holiday-presets-apply.unit.php +++ b/tests/holiday-presets-apply.unit.php @@ -162,6 +162,78 @@ $check(LibraryCalendar::dueDate($db, '2026-07-03') === '2026-07-04', 'a due date on a chosen holiday moves to the next open day'); $check(LibraryCalendar::dueDate($db, '2026-09-07') === '2026-09-07', 'a due date on an unchosen holiday (Labor Day) stays'); + // 12. The materialised rows are a preview, not a horizon on loan policy. + $manualBeforeRegressions = $manualSnapshot(); + HolidayPresets::apply($db, 'IT', '', ['IT:easter_monday'], $today); + $check($count("regola_festivita = 'IT:easter_monday' AND data_inizio = '2029-04-02'") === 0, 'the preview still stores only its three years'); + $check(LibraryCalendar::dueDate($db, '2029-04-02') === '2029-04-03', 'a future due date skips Easter Monday without writing extra rows'); + $check(LibraryCalendar::forDb($db)->openDaysBetween('2029-03-31', '2029-04-03') === 2, 'future fine days skip the selected holiday too'); + \App\Support\Features::override('closures_calendar', false); + LibraryCalendar::reset(); + $check(LibraryCalendar::dueDate($db, '2029-04-02') === '2029-04-02', 'switching the calendar off also ignores dynamic holidays'); + \App\Support\Features::override('closures_calendar', true); + LibraryCalendar::reset(); + $check(LibraryCalendar::dueDate($db, '2036-04-14') === '2036-04-15', 'switching it back on immediately covers distant years, before maintenance'); + HolidayPresets::apply($db, 'US', '', ['US:new_year'], $today); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, regola_festivita) VALUES ('2029-04-02','2029-04-02','Stale preset','IT:easter_monday')"); + LibraryCalendar::reset(); + $check(LibraryCalendar::dueDate($db, '2029-04-02') === '2029-04-02', 'a stale preset row cannot close a holiday absent from the saved selection'); + $check(LibraryCalendar::dueDate($db, '2038-12-31') === '2039-01-01', 'observed New Year is applied in the preceding calendar year beyond the preview'); + + // 13. A UI apply commits after the cron's transaction snapshot was taken. + // Upkeep must use current locking reads, never reinsert the old country's + // holidays or duplicate the new country's rows hidden by that snapshot. + $other = new mysqli($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $sandbox, (int) ($env['DB_PORT'] ?? 3306), $socket); + $other->set_charset('utf8mb4'); + try { + HolidayPresets::apply($db, 'IT', '', ['IT:easter_monday'], $today); + $db->begin_transaction(); + $check($one("SELECT setting_value FROM system_settings WHERE category = 'calendar' AND setting_key = 'holiday_country'") === 'IT', 'the cron snapshot starts with Italy'); + HolidayPresets::apply($other, 'US', '', ['US:new_year'], '2030-01-01'); + $check(HolidayPresets::ensureYears($db, '2030-01-01') === 0, 'upkeep sees the newly committed US selection and its already materialised rows'); + $check(\App\Support\DbAtomic::inTransaction($db), 'upkeep leaves its caller transaction open'); + $db->commit(); + $check(HolidayPresets::selection($db)['country'] === 'US' && $count("regola_festivita = 'IT:easter_monday'") === 0, 'upkeep never restores the old country after a concurrent apply'); + $check($count("regola_festivita = 'US:new_year'") === 3, 'a stale transaction snapshot cannot duplicate the new holiday rows'); + + // Both writers contend for the same selection row, including when + // the maintenance date is the first one not materialised yet. + $other->begin_transaction(); + $other->query("SELECT setting_value FROM system_settings WHERE category = 'calendar' AND setting_key = 'holiday_rules' FOR UPDATE"); + $db->query('SET SESSION innodb_lock_wait_timeout = 1'); + foreach (['upkeep' => static fn () => HolidayPresets::ensureYears($db, '2033-01-01'), 'apply' => static fn () => HolidayPresets::apply($db, 'IT', '', ['IT:easter_monday'], $today)] as $name => $operation) { + $blocked = false; + try { + $operation(); + } catch (\mysqli_sql_exception $e) { + $blocked = $e->getCode() === 1205; + } + $check($blocked, "{$name} waits on the shared selection lock"); + } + $other->rollback(); + $db->query('SET SESSION innodb_lock_wait_timeout = DEFAULT'); + $check(HolidayPresets::selection($db)['country'] === 'US' && $count("regola_festivita = 'IT:easter_monday'") === 0, 'a lock timeout leaves selection and closure rows unchanged'); + $check(HolidayPresets::ensureYears($db, '2033-01-01') === 3, 'upkeep can retry successfully after the lock is released'); + + // Snapshot writes and the selection itself are one transaction. + $before = $count("regola_festivita = 'US:new_year'"); + $db->query("CREATE TRIGGER hp_fail_insert BEFORE INSERT ON calendario_chiusure FOR EACH ROW BEGIN IF NEW.regola_festivita = 'IT:easter_monday' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Simulated holiday write failure'; END IF; END"); + $failedApply = false; + try { + HolidayPresets::apply($db, 'IT', '', ['IT:easter_monday'], $today); + } catch (\mysqli_sql_exception) { + $failedApply = true; + } + $db->query('DROP TRIGGER hp_fail_insert'); + $check($failedApply && HolidayPresets::selection($db)['country'] === 'US', 'a failed apply rolls back its selection'); + $check($count("regola_festivita = 'US:new_year'") === $before && $count("regola_festivita = 'IT:easter_monday'") === 0, 'a failed apply restores the previous closures'); + $check($manualSnapshot() === $manualBeforeRegressions, 'future-date reads, concurrent changes and rollbacks preserve every manual and ICS closure'); + } finally { + $db->rollback(); + $other->rollback(); + $other->close(); + } + $db->close(); echo "SUCCESS {$checks} holiday preset checks\n"; $exit = 0; diff --git a/tests/holidays-preset.spec.js b/tests/holidays-preset.spec.js index caeef520e..15193381b 100644 --- a/tests/holidays-preset.spec.js +++ b/tests/holidays-preset.spec.js @@ -382,4 +382,22 @@ test.describe('Public holiday presets', () => { await page.goto(`${BASE}/admin/calendar?month=${year + 1}-12`); await expect(page.locator(`[data-calendar-day="${year + 1}-12-25"]`)).toHaveAttribute('data-closed', '1'); }); + + test('14. a holiday beyond the preview years closes the calendar and moves the loan form due date', async () => { + await chooseCountry(page, 'IT'); + await page.locator('[data-holiday-select="none"]').click(); + await box(page, 'IT:easter_monday').check(); + await submitAndLand(page, '#import-preset'); + const distantHoliday = addDays(easter(farYear), 1); + expect(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE regola_festivita='IT:easter_monday' AND data_inizio=${S(distantHoliday)}`)).toBe('0'); + + const days = daysBetween(today, distantHoliday); + setSetting('loans', 'max_loan_duration_days', String(days)); + setSetting('loans', 'loan_duration_days', String(days)); + await page.goto(`${BASE}/admin/loans/create`); + await expect(page.locator('#data_scadenza')).toHaveValue(addDays(distantHoliday, 1)); + + await page.goto(`${BASE}/admin/calendar?month=${distantHoliday.slice(0, 7)}`); + await expect(page.locator(`[data-calendar-day="${distantHoliday}"]`)).toHaveAttribute('data-closed', '1'); + }); }); diff --git a/tests/library-calendar.unit.php b/tests/library-calendar.unit.php index f87bf360b..a3427c29e 100644 --- a/tests/library-calendar.unit.php +++ b/tests/library-calendar.unit.php @@ -226,5 +226,35 @@ $eq(LibraryCalendar::spanDays($tenYears[0]['start'], $tenYears[0]['end']) > LibraryCalendar::MAX_YEARLY_SPAN_DAYS, true, 'ICS: and exceeds the yearly limit the import applies'); $eq(count(LibraryCalendar::parseIcs(str_repeat("BEGIN:VEVENT\nDTSTART;VALUE=DATE:20260101\nEND:VEVENT\n", 50), 10)), 10, 'ICS: the event cap holds'); +// Selected holidays are evaluated for the date being asked for, independently +// of the three years materialised for the admin list (review #512). +$rules = static fn (string $country, string $id): array => array_values(array_filter( + \App\Support\Holidays\HolidayCalculator::rulesFor($country), + static fn (array $rule): bool => $rule['id'] === $id +)); +$easterRules = $rules('IT', 'easter_monday'); +$dynamic = new LibraryCalendar([], [], $easterRules); +$eq($dynamic->hasRules(), true, 'selected holidays alone make a calendar non-empty'); +$eq($dynamic->nextOpenDay('2029-04-02'), '2029-04-03', 'a selected Easter Monday beyond the preview horizon moves the due date'); +$eq($dynamic->isOpen('2036-04-14'), false, 'a selected Easter Monday ten years ahead still closes'); +$eq($dynamic->isOpen('2020-04-13'), false, 'historical fine days use the same selected holiday rule'); +$eq($dynamic->previousOpenDay('2029-04-02'), '2029-04-01', 'renewal caps step back over dynamic holidays too'); +$eq($dynamic->closedDatesBetween('2029-04-01', '2029-04-03'), ['2029-04-02'], 'availability publishes the dynamic holiday'); +$eq($dynamic->openDaysBetween('2029-03-31', '2029-04-03'), 2, 'a dynamic holiday does not count as a late fine day'); +$eq($dynamic->isOpen('2029-12-25'), true, 'an unselected holiday stays open'); +$branchHoliday = new LibraryCalendar([], [['start' => '2029-04-03', 'end' => '2029-04-03', 'sede' => 2]], $easterRules); +$eq($branchHoliday->nextOpenDay('2029-04-02', 2), '2029-04-04', 'a branch closure after a holiday delays the date further'); +$eq($branchHoliday->nextOpenDay('2029-04-02', 1), '2029-04-03', 'the other branch opens after the holiday'); +$pickupHolidays = new LibraryCalendar([0], [], $easterRules); +$eq($pickupHolidays->addOpenDays('2029-03-31', 2), '2029-04-04', 'pickup days skip both the weekly closure and a dynamic holiday'); +$usNewYear = new LibraryCalendar([], [], $rules('US', 'new_year')); +$eq($usNewYear->isOpen('2027-12-31'), false, 'observed New Year from the next source year closes December 31'); +$eq($usNewYear->nextOpenDay('2027-12-31'), '2028-01-01', 'only the observed date closes when no weekly rule is set'); +$longJump = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2035-12-31']], $rules('IT', 'new_year')); +$eq($longJump->nextOpenDay('2026-10-29'), '2036-01-02', 'a long closure jump also checks holidays in the year it lands in'); +$introducedHoliday = new LibraryCalendar([], [], $rules('IT', 'san_francesco')); +$eq($introducedHoliday->isOpen('2025-10-04'), true, 'a dynamic holiday respects its first year'); +$eq($introducedHoliday->isOpen('2036-10-04'), false, 'a holiday with a first year keeps closing beyond the preview window'); + echo ($failures === 0 ? 'SUCCESS' : 'FAILURE') . " {$checks} checks, {$failures} failed\n"; exit($failures === 0 ? 0 : 1); From 3960c194ba88145a7c5c068accc49169fc7cf1c6 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 07:38:39 +0100 Subject: [PATCH 206/398] Respect disabled patron rules throughout CSV imports --- app/Support/PatronTypeImport.php | 10 ++++++++-- tests/feature-toggles-services.unit.php | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/Support/PatronTypeImport.php b/app/Support/PatronTypeImport.php index 3fd6c5037..cc506b9fc 100644 --- a/app/Support/PatronTypeImport.php +++ b/app/Support/PatronTypeImport.php @@ -23,7 +23,8 @@ * does not guess between them. * * The table and column names are constructor parameters only so the unit test - * can run against sandbox tables. + * can run against sandbox tables. While the circulation rules module is off, + * available() is false and assign() does nothing even on an existing importer. */ final class PatronTypeImport { @@ -40,9 +41,14 @@ public function __construct( ) { } - /** True when the patron types schema exists (checked once per instance). */ + /** True when the rules module is on and its schema exists (schema cached per instance). */ public function available(): bool { + // Check before the schema cache: a long-running import must also + // stop assigning types when the circulation rules are switched off. + if (!Features::enabled('circulation_rules')) { + return false; + } if ($this->available !== null) { return $this->available; } diff --git a/tests/feature-toggles-services.unit.php b/tests/feature-toggles-services.unit.php index dba3db90d..7d5cf82eb 100644 --- a/tests/feature-toggles-services.unit.php +++ b/tests/feature-toggles-services.unit.php @@ -153,6 +153,19 @@ $hash = password_hash('x', PASSWORD_DEFAULT); $db->query("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, privacy_accettata) VALUES ('ZZFS{$run}', 'Zz', 'Svc', 'zz-ftsvc-{$run}@test.local', '" . $db->real_escape_string($hash) . "', 'attivo', 1, 'standard', 1)"); $patron = (int) $db->insert_id; + // A CSV import must not expose or assign types while their module is off, + // including on an importer whose schema availability was already cached. + $typeImporter = new \App\Support\PatronTypeImport($db); + $check($typeImporter->available(), 'B on: user import offers patron types'); + $defaultType = $policy->types()[$policy->defaultTypeId()]; + Features::override('circulation_rules', false); + $check(!$typeImporter->available(), 'B off: an existing importer stops offering patron types'); + $check($typeImporter->assign($patron, (string) $defaultType['codice']) === 'skipped', 'B off: user import assigns no patron type'); + $check($db->query("SELECT tipologia_id FROM utenti WHERE id = {$patron}")->fetch_row()[0] === null, 'B off: the imported reader keeps their stored type'); + $fields = (new \ReflectionMethod(\App\Controllers\UserImportController::class, 'targetFields'))->invoke(new \App\Controllers\UserImportController(), $db); + $check(!isset($fields['tipologia']), 'B off: the CSV mapping omits the patron type'); + Features::override('circulation_rules', true); + $check($typeImporter->available(), 'B on: the same importer offers patron types again'); $db->query("INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES ({$patron}, 'addebito_manuale', 100.00, 'ZZ_FTSVC debt', NOW())"); $db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', 'debt_block_threshold', '1.00') ON DUPLICATE KEY UPDATE setting_value = '1.00'"); \App\Support\ConfigStore::clearCache(); From e1ba8f3bc5ab95da878662da8f0ef2fe60fe6175 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 07:57:08 +0100 Subject: [PATCH 207/398] Add 25 persisted-settings integration scenarios for 0.9.0 --- ...stack-090-settings-flexibility-25.unit.php | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 tests/stack-090-settings-flexibility-25.unit.php diff --git a/tests/stack-090-settings-flexibility-25.unit.php b/tests/stack-090-settings-flexibility-25.unit.php new file mode 100644 index 000000000..901829dc3 --- /dev/null +++ b/tests/stack-090-settings-flexibility-25.unit.php @@ -0,0 +1,392 @@ +getValue(); +$previousSession = $_SESSION ?? []; +$passed = 0; +$failed = 0; + +$assert = static function (bool $condition, string $message): void { + if (!$condition) { + throw new RuntimeException($message); + } +}; +$request = static fn (string $path, array $body = []): ServerRequestInterface => + (new ServerRequestFactory())->createServerRequest('POST', $path)->withParsedBody($body); +$cases = []; +$test = static function (string $name, Closure $body) use (&$cases): void { + $cases[] = [$name, $body]; +}; + +try { + $admin = new mysqli($host, $user, $pass, '', $port, $socket ?: null); + $admin->query("CREATE DATABASE `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $mysql = ['mysql', '--host=' . $host, '--port=' . $port, '--user=' . $user]; + if ($socket !== '') { + $mysql[] = '--socket=' . $socket; + } + $mysql[] = $sandbox; + foreach (['schema.sql', 'data_it_IT.sql', 'triggers.sql'] as $file) { + $process = proc_open($mysql, [0 => ['file', $root . '/installer/database/' . $file, 'r'], 1 => STDOUT, 2 => STDERR], $pipes, $root, array_merge(getenv(), ['MYSQL_PWD' => $pass])); + if (!is_resource($process) || proc_close($process) !== 0) { + throw new RuntimeException('Sandbox import failed: ' . $file); + } + } + $db = new mysqli($host, $user, $pass, $sandbox, $port, $socket ?: null); + $db->set_charset('utf8mb4'); + $shared->setValue(null, $db); + ConfigStore::clearCache(); + DateHelper::synchronizeDatabaseSession($db); + $settings = new SettingsRepository($db); + $baseline = $db->query('SELECT category, setting_key, setting_value FROM system_settings')->fetch_all(MYSQLI_ASSOC); + $one = static fn (string $sql): mixed => $db->query($sql)->fetch_row()[0] ?? null; + $set = static function (string $category, string $key, string $value) use ($settings): void { + $settings->set($category, $key, $value); + LibraryCalendar::reset(); + }; + $flag = static fn (string $key, bool $on) => $set('features', $key, $on ? '1' : '0'); + $serial = 0; + $patron = static function (string $kind = 'standard') use ($db, &$serial): int { + $n = ++$serial; + $db->query("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, privacy_accettata) VALUES ('ZZCFG{$n}', 'ZZ Config', 'Test', 'zz-cfg-{$n}@test.local', 'x', '{$kind}', 'attivo', 1, 1)"); + return (int) $db->insert_id; + }; + $operator = $patron('admin'); + $asAdmin = static function () use ($operator): void { + $_SESSION = ['user' => ['id' => $operator, 'tipo_utente' => 'admin', 'stato' => 'attivo']]; + }; + $book = static function (string $media = 'libro') use ($db, &$serial): array { + $n = ++$serial; + $db->query("INSERT INTO libri (titolo, tipo_media, collocazione, copie_totali, copie_disponibili) VALUES ('ZZCFG book {$n}', '{$media}', 'ZZCFG.{$n}', 1, 1)"); + $id = (int) $db->insert_id; + $db->query("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES ({$id}, 'ZZCFG-C{$n}', 'disponibile')"); + return ['id' => $id, 'copy' => (int) $db->insert_id, 'code' => "ZZCFG-C{$n}", 'title' => "ZZCFG book {$n}", 'location' => "ZZCFG.{$n}"]; + }; + $type = static function (array $fields = []) use ($db, &$serial): int { + $n = ++$serial; + $db->query("INSERT INTO tipologie_lettore (codice, nome) VALUES ('zzcfg{$n}', 'ZZ Config {$n}')"); + $id = (int) $db->insert_id; + foreach ($fields as $column => $value) { + $stmt = $db->prepare("UPDATE tipologie_lettore SET `{$column}` = ? WHERE id = ?"); + $stmt->bind_param('si', $value, $id); + $stmt->execute(); + $stmt->close(); + } + return $id; + }; + $loan = static function (int $reader, array $title, string $due, string $returned) use ($db): int { + $stmt = $db->prepare("INSERT INTO prestiti (utente_id, libro_id, copia_id, data_prestito, data_scadenza, data_restituzione, stato, attivo, origine) VALUES (?, ?, ?, '2026-01-01', ?, ?, 'restituito', 0, 'diretto')"); + $stmt->bind_param('iiiss', $reader, $title['id'], $title['copy'], $due, $returned); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; + }; + $loansBody = static fn (array $extra = []): array => array_replace([ + 'loan_duration_days' => '30', 'pickup_expiry_days' => '3', 'max_renewals' => '3', + 'max_loan_duration_days' => '3650', 'feature_circulation_rules' => '1', + 'feature_closures_calendar' => '1', 'feature_fines' => '1', + ], $extra); + $saveLoans = static function (array $extra = []) use ($request, $db, $loansBody): void { + (new SettingsController())->updateLoansSettings($request('/admin/settings/loans', $loansBody($extra)), new Response(), $db); + }; + + $test('01 Modules: a mixed selection persists and is read by the runtime', static function () use ($request, $db, $assert): void { + (new SettingsController())->updateModules($request('/admin/settings/modules', ['feature_marc' => '1', 'feature_user_import' => '1']), new Response(), $db); + foreach (Features::MODULES_TAB as $key) { + $assert(Features::enabled($key) === in_array($key, ['marc', 'user_import'], true), 'Saved switch is inert or wrong: ' . $key); + } + }); + $test('02 A Modules save cannot change switches owned by the Loans tab', static function () use ($request, $db, $flag, $assert): void { + foreach (Features::LOANS_TAB as $key) { $flag($key, true); } + (new SettingsController())->updateModules($request('/admin/settings/modules', ['feature_fines' => '0', 'feature_closures_calendar' => '0', 'feature_circulation_rules' => '0']), new Response(), $db); + foreach (Features::LOANS_TAB as $key) { $assert(Features::stored($key), 'Foreign-tab switch changed: ' . $key); } + }); + $test('03 Staff cannot change the optional modules', static function () use ($request, $db, $assert): void { + $before = Features::snapshot(); + $_SESSION['user']['tipo_utente'] = 'staff'; + (new SettingsController())->updateModules($request('/admin/settings/modules', []), new Response(), $db); + $assert(Features::snapshot() === $before, 'Staff changed a module'); + }); + $test('04 Loans switches are independent and preserve explicit zero values', static function () use ($saveLoans, $settings, $assert): void { + $saveLoans(['feature_circulation_rules' => '0', 'feature_closures_calendar' => '0', 'daily_fine' => '0', 'debt_block_threshold' => '0', 'fine_grace_days' => '0', 'max_renewals' => '0']); + $assert(Features::enabled('fines') && !Features::enabled('closures_calendar') && !Features::enabled('circulation_rules'), 'Loans switches are coupled'); + $assert($settings->get('loans', 'max_renewals') === '0' && $settings->get('loans', 'fine_grace_days') === '0', 'Zero was replaced by a default'); + }); + $test('05 Staff cannot switch circulation modules or rewrite money policy', static function () use ($saveLoans, $flag, $set, $settings, $assert): void { + foreach (Features::LOANS_TAB as $key) { $flag($key, true); } + $set('loans', 'daily_fine', '0.75'); + $_SESSION['user']['tipo_utente'] = 'staff'; + $saveLoans(['feature_circulation_rules' => '0', 'feature_closures_calendar' => '0', 'feature_fines' => '0', 'daily_fine' => '9.99']); + foreach (Features::LOANS_TAB as $key) { $assert(Features::stored($key), 'Staff changed a circulation switch'); } + $assert($settings->get('loans', 'daily_fine') === '0.75', 'Staff changed fine policy'); + }); + $test('06 Catalogue mode suspends circulation without erasing saved choices', static function () use ($flag, $set, $assert): void { + foreach (Features::CIRCULATION as $key) { $flag($key, true); } + $set('system', 'catalogue_mode', '1'); + foreach (Features::CIRCULATION as $key) { $assert(Features::stored($key) && !Features::enabled($key), 'Catalogue mode erased or ignored a saved choice'); } + $set('system', 'catalogue_mode', '0'); + foreach (Features::CIRCULATION as $key) { $assert(Features::enabled($key), 'Leaving catalogue mode did not restore a module'); } + }); + $test('07 A disabled module returns JSON without invoking the endpoint', static function () use ($flag, $assert): void { + $flag('catalogue_cards', false); + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface { throw new RuntimeException('Disabled endpoint was invoked'); } + }; + $req = (new ServerRequestFactory())->createServerRequest('GET', '/library/admin/books/catalogue-cards')->withHeader('Accept', 'application/json'); + $res = (new FeatureGateMiddleware('catalogue_cards'))->process($req, $handler); + $assert($res->getStatusCode() === 404 && json_decode((string) $res->getBody(), true)['code'] === 'feature_disabled', 'Disabled module returned an unusable API response'); + }); + $test('08 Weekly closures can change, but closing all seven days preserves the prior value', static function () use ($request, $db, $settings, $assert): void { + $calendar = new LibraryCalendarController(); + $calendar->saveWeekdays($request('/admin/calendar/weekdays', ['closed_weekdays' => ['0', '3']]), new Response(), $db); + $calendar->saveWeekdays($request('/admin/calendar/weekdays', ['closed_weekdays' => range(0, 6)]), new Response(), $db); + $assert($settings->get('loans', 'closed_weekdays') === '0,3', 'Invalid weekly closure replaced the saved calendar'); + }); + $test('09 A global holiday and a branch closure produce different due dates per branch', static function () use ($db, $book, $assert): void { + $db->query("INSERT INTO sedi (nome) VALUES ('ZZ Config A')"); $a = (int) $db->insert_id; + $db->query("INSERT INTO sedi (nome) VALUES ('ZZ Config B')"); $b = (int) $db->insert_id; + $x = $book(); $y = $book(); + $db->query("UPDATE copie SET sede_id={$a} WHERE id={$x['copy']}"); + $db->query("UPDATE copie SET sede_id={$b} WHERE id={$y['copy']}"); + HolidayPresets::apply($db, 'IT', '', ['IT:easter_monday'], '2026-10-29'); + $db->query("INSERT INTO calendario_chiusure (data_inizio,data_fine,descrizione,sede_id) VALUES ('2029-04-03','2029-04-03','ZZ branch',{$a})"); + LibraryCalendar::reset(); + $assert(CirculationPolicy::dueDateFor('2029-04-02', 0, $db, $x['copy']) === '2029-04-04', 'Branch closure was omitted'); + $assert(CirculationPolicy::dueDateFor('2029-04-02', 0, $db, $y['copy']) === '2029-04-03', 'Branch closure leaked to another branch'); + }); + $test('10 Re-enabling a calendar immediately restores distant holidays without a maintenance run', static function () use ($db, $flag, $assert, $one): void { + HolidayPresets::apply($db, 'US', '', ['US:new_year'], '2026-10-29'); + $flag('closures_calendar', false); + $assert(LibraryCalendar::dueDate($db, '2038-12-31') === '2038-12-31', 'Disabled calendar still shifted dates'); + $flag('closures_calendar', true); + $assert(LibraryCalendar::dueDate($db, '2038-12-31') === '2039-01-01', 'Re-enabled calendar needs maintenance to work'); + $assert((int) $one("SELECT COUNT(*) FROM calendario_chiusure WHERE data_inizio='2038-12-31'") === 0, 'Reading a far date unexpectedly writes rows'); + }); + $test('11 A regional holiday can be unticked without deleting an inactive manual closure', static function () use ($db, $assert, $one): void { + $db->query("INSERT INTO calendario_chiusure (data_inizio,data_fine,descrizione,ricorrente_annuale,attiva) VALUES ('2026-08-17','2026-08-17','ZZ manual off',1,0)"); + $id = (int) $db->insert_id; + HolidayPresets::apply($db, 'DE', 'BY', ['DE:mariae_himmelfahrt'], '2026-10-29'); + $assert(!LibraryCalendar::forDb($db)->isOpen('2032-08-15'), 'Selected regional holiday is open'); + HolidayPresets::apply($db, 'DE', '', ['DE:mariae_himmelfahrt'], '2026-10-29'); + $assert(LibraryCalendar::forDb($db)->isOpen('2032-08-15'), 'Regional holiday survived removal of its region'); + $assert((string) $one("SELECT attiva FROM calendario_chiusure WHERE id={$id}") === '0', 'The inactive manual closure was changed'); + }); + $test('12 Holiday application joins an existing transaction and rolls back with its caller', static function () use ($db, $assert, $one): void { + HolidayPresets::apply($db, 'IT', '', ['IT:christmas'], '2026-10-29'); + $before = (string) $one('SELECT GROUP_CONCAT(id ORDER BY id) FROM calendario_chiusure'); + $db->begin_transaction(); + try { + HolidayPresets::apply($db, 'US', '', ['US:new_year'], '2026-10-29'); + } finally { $db->rollback(); LibraryCalendar::reset(); } + $assert(HolidayPresets::selection($db)['country'] === 'IT', 'Nested holiday save committed its caller'); + $assert((string) $one('SELECT GROUP_CONCAT(id ORDER BY id) FROM calendario_chiusure') === $before, 'Rollback did not restore closure rows'); + }); + $test('13 A matrix cell disables renewals while its blank duration inherits the patron type', static function () use ($request, $db, $type, $book, $patron, $assert): void { + $t = $type(['durata_prestito_giorni' => '17']); $p = $patron(); $b = $book('dvd'); + $db->query("UPDATE utenti SET tipologia_id={$t} WHERE id={$p}"); + (new CirculationSettingsController())->saveRules($request('/admin/settings/circulation/rules', ['rules' => [$t => ['dvd' => ['renewals' => '0', 'duration' => '']]]]), new Response(), $db); + $policy = (new CirculationPolicy($db))->forLoan($p, $b['id']); + $assert($policy->maxRenewals === 0 && $policy->durationDays === 17, 'Zero and inheritance were conflated'); + }); + $test('14 Disabling type rules uses global duration and ignores consultation, then restores both', static function () use ($db, $type, $book, $patron, $flag, $set, $assert): void { + $t = $type(['durata_prestito_giorni' => '9']); $p = $patron(); $b = $book(); + $db->query("UPDATE utenti SET tipologia_id={$t} WHERE id={$p}"); + $db->query("UPDATE copie SET solo_consultazione=1 WHERE id={$b['copy']}"); + $set('loans', 'loan_duration_days', '23'); $flag('circulation_rules', false); + $off = (new CirculationPolicy($db))->forLoan($p, $b['id'], $b['copy']); + $assert($off->durationDays === 23 && $off->loanable, 'Off does not restore the global policy'); + $flag('circulation_rules', true); + $on = (new CirculationPolicy($db))->forLoan($p, $b['id'], $b['copy']); + $assert($on->durationDays === 9 && !$on->loanable, 'The saved type or consultation flag was lost'); + }); + $test('15 Debt blocking can work with automatic late fines disabled', static function () use ($saveLoans, $db, $patron, $book, $loan, $assert): void { + $saveLoans(['daily_fine' => '0', 'debt_block_threshold' => '5']); $p = $patron(); + $ledger = new PatronLedger($db); + $ledger->manualCharge($p, 600, 'ZZ manual debt', null); + $id = $loan($p, $book(), '2026-10-01', '2026-10-04'); + $assert($ledger->chargeLateReturn($id) === 0 && PatronLedger::isDebtBlocked($db, $p), 'The fine rate and debt threshold cannot be configured independently'); + }); + $test('16 Late fines can work with debt blocking disabled', static function () use ($saveLoans, $db, $patron, $book, $loan, $assert): void { + $saveLoans(['daily_fine' => '0,50', 'debt_block_threshold' => '0', 'fine_grace_days' => '0']); $p = $patron(); + $id = $loan($p, $book(), '2026-10-01', '2026-10-04'); $ledger = new PatronLedger($db); + $assert($ledger->chargeLateReturn($id) === 150 && $ledger->balanceCents($p) === 150, 'The configured fine was not charged'); + $assert(!PatronLedger::isDebtBlocked($db, $p), 'A zero debt threshold still blocks'); + }); + $test('17 A patron type can opt out of global fines and debt blocking with explicit zeros', static function () use ($saveLoans, $db, $type, $patron, $book, $loan, $assert): void { + $saveLoans(['daily_fine' => '1', 'debt_block_threshold' => '5']); + $t = $type(['multa_giornaliera' => '0.00', 'soglia_blocco_debito' => '0.00']); $p = $patron(); + $db->query("UPDATE utenti SET tipologia_id={$t} WHERE id={$p}"); + $ledger = new PatronLedger($db); $ledger->manualCharge($p, 800, 'ZZ existing debt', null); + $id = $loan($p, $book(), '2026-10-01', '2026-10-04'); + $assert($ledger->chargeLateReturn($id) === 0 && !PatronLedger::isDebtBlocked($db, $p), 'Explicit type zeros inherited the global amounts'); + }); + $test('18 A CSV importer stops assigning types while off and resumes on the same instance', static function () use ($db, $type, $patron, $flag, $one, $assert): void { + $t = $type(); $p = $patron(); $code = (string) $one("SELECT codice FROM tipologie_lettore WHERE id={$t}"); + $importer = new PatronTypeImport($db); + $assert($importer->available(), 'Patron types are not initially available'); + $flag('circulation_rules', false); + $assert($importer->assign($p, $code) === 'skipped' && $one("SELECT tipologia_id FROM utenti WHERE id={$p}") === null, 'A stale importer wrote a disabled field'); + $flag('circulation_rules', true); + $assert($importer->assign($p, $code) === 'assigned' && (int) $one("SELECT tipologia_id FROM utenti WHERE id={$p}") === $t, 'Re-enabled assignment did not resume'); + }); + $test('19 CSV code columns preserve the chosen type even when names are ambiguous', static function () use ($db, $type, $patron, $one, $assert): void { + $a = $type(['nome' => 'ZZ Shared']); $b = $type(['nome' => 'ZZ Shared']); $p = $patron(); + $code = (string) $one("SELECT codice FROM tipologie_lettore WHERE id={$b}"); + $mapping = UserImport::guessMapping(['Tipologia lettore', 'Codice tipologia lettore', 'Email'], UserImport::targetFields([], true)); + $importer = new PatronTypeImport($db); + $assert($mapping[0] === '' && $mapping[1] === 'tipologia', 'The export name took precedence over its code'); + $assert($importer->assign($p, 'ZZ Shared') === 'unknown' && $importer->assign($p, $code) === 'assigned', 'A shared name was guessed or the exact code failed'); + $assert((int) $one("SELECT tipologia_id FROM utenti WHERE id={$p}") === $b && $a !== $b, 'The CSV assigned the wrong patron type'); + }); + $test('20 MARC fill and overwrite respect different media choices and preserve price and format', static function () use ($db, $book, $one, $assert): void { + $b = $book(); + $db->query("UPDATE libri SET isbn13='9780306406157', prezzo=17.25, formato='ZZ format', rda_carrier_type='nc' WHERE id={$b['id']}"); + $data = ['titolo' => $b['title'], 'isbn13' => '9780306406157', 'tipo_media' => 'dvd'] + MarcBookData::empty(); + $counts = []; $warnings = []; + $filled = (new MarcBookImporter($db, MarcBookImporter::MODE_FILL, false))->import($data, $counts, $warnings); + $assert($filled['action'] === 'updated' && $filled['id'] === $b['id'], 'Fill did not match the existing book'); + $assert($one("SELECT tipo_media FROM libri WHERE id={$b['id']}") === 'libro', 'Fill overwrote the selected material'); + $overwritten = (new MarcBookImporter($db, MarcBookImporter::MODE_OVERWRITE, false))->import($data, $counts, $warnings); + $assert($overwritten['action'] === 'updated' && $overwritten['id'] === $b['id'], 'Overwrite did not match the existing book'); + $assert($one("SELECT CONCAT(tipo_media,'|',prezzo,'|',formato) FROM libri WHERE id={$b['id']}") === 'dvd|17.25|ZZ format', 'Overwrite lost unrelated cataloguing choices'); + $assert($one("SELECT rda_carrier_type FROM libri WHERE id={$b['id']}") === null, 'The old material carrier survived an overwrite'); + }); + $test('21 RDA can be suspended and restored without losing manual overrides', static function () use ($db, $book, $flag, $one, $assert): void { + $b = $book(); $db->query("UPDATE libri SET rda_content_type='prm' WHERE id={$b['id']}"); + $flag('rda_types', false); + CataloguingExtras::save($db, $b['id'], ['rda_content_type' => '']); + $row = $db->query("SELECT * FROM libri WHERE id={$b['id']}")->fetch_assoc(); + $assert(RdaTypes::effective($row)['content'] !== 'prm' && $one("SELECT rda_content_type FROM libri WHERE id={$b['id']}") === 'prm', 'Disabling RDA deleted or applied an override'); + $flag('rda_types', true); + $assert(RdaTypes::effective($row)['content'] === 'prm', 'Re-enabling RDA did not restore the override'); + }); + $test('22 Supplier choices survive suspension and deleting a supplier never deletes its book', static function () use ($db, $book, $flag, $one, $assert): void { + $b = $book(); $repo = new SupplierRepository($db); + $id = $repo->create(SupplierRepository::normalize(['nome' => 'ZZ supplier'])); + CataloguingExtras::assignSupplier($db, $b['id'], $id); + $flag('suppliers', false); CataloguingExtras::save($db, $b['id'], ['fornitore_id' => '']); + $assert((int) $one("SELECT fornitore_id FROM libri WHERE id={$b['id']}") === $id, 'A save while off erased the supplier'); + $flag('suppliers', true); $repo->delete($id); + $assert($one("SELECT fornitore_id FROM libri WHERE id={$b['id']}") === null && $one("SELECT titolo FROM libri WHERE id={$b['id']}") === $b['title'], 'Deleting a supplier damaged its catalogue'); + }); + $test('23 Card presentation choices persist independently of module visibility', static function () use ($request, $db, $settings, $flag, $assert): void { + (new SettingsController())->updateCards($request('/admin/settings/cards', ['card_layout' => 'portrait', 'card_code_type' => 'qr', 'card_format' => 'sheet', 'card_title' => 'ZZ tessera', 'card_back_text' => "Riga 1\nRiga 2"]), new Response(), $db); + $flag('patron_cards', false); $flag('patron_cards', true); + $card = PatronCardSettings::resolve($settings); + $assert($card['layout'] === 'portrait' && $card['code_type'] === 'qr' && $card['format'] === 'sheet', 'Card layout choices were reset'); + $assert(!$card['show_photo'] && !$card['show_logo'] && !$card['show_expiry'] && $card['back_text'] === "Riga 1\nRiga 2", 'Optional card fields cannot be turned off independently'); + }); + $test('24 Visitor retention supports both no automatic purge and a custom retention window', static function () use ($request, $db, $settings, $flag, $one, $assert): void { + $flag('visitor_log', true); + $db->query("INSERT INTO presenze (tipo, nome_ospite, created_at) VALUES ('ospite','ZZ old', NOW() - INTERVAL 40 DAY), ('ospite','ZZ recent',NOW())"); + $controller = new AttendanceController(); $repo = new AttendanceRepository($db); + $controller->saveSettings($request('/admin/attendance/settings', ['retention_days' => '0']), new Response(), $db); + $assert($repo->purge(AttendanceRepository::retentionDays($settings)) === 0, 'Retention zero still purges visits'); + $controller->saveSettings($request('/admin/attendance/settings', ['retention_days' => '7']), new Response(), $db); + $assert($repo->purge(AttendanceRepository::retentionDays($settings)) === 1 && $one('SELECT nome_ospite FROM presenze') === 'ZZ recent', 'The custom retention did not keep recent visits'); + }); + $test('25 Stocktake can close without marking missing copies lost and retains reports while disabled', static function () use ($db, $book, $request, $flag, $one, $assert): void { + $b = $book(); $service = new StocktakeService($db); + $id = $service->open('ZZ optional loss', null, ['collocazione_da' => $b['location'], 'collocazione_a' => $b['location']], null); + $assert($service->close($id, false, null) === ['marked' => 0, 'failed' => 0], 'Closing without loss action changed copies'); + $before = $service->summary($id); $flag('stocktake', false); + $off = (new InventoryController())->scan($request('/admin/inventory/' . $id . '/scan', ['codice' => $b['code']]), new Response(), $db, $id); + $assert($off->getStatusCode() === 302, 'The disabled inventory endpoint was not gated'); + $flag('stocktake', true); + $assert($service->summary($id) === $before && $before['mancanti'] === 1, 'Disabling stocktake damaged its frozen report'); + $assert($one("SELECT stato FROM copie WHERE id={$b['copy']}") === 'disponibile', 'Optional loss action was applied implicitly'); + }); + + $assert(count($cases) === 25, 'This suite must register exactly 25 scenarios'); + foreach ($cases as [$name, $body]) { + try { + $db->rollback(); + $db->query('DELETE FROM calendario_chiusure'); + $db->query('DELETE FROM regole_prestito'); + $db->query('DELETE FROM presenze'); + $db->query("DELETE FROM tipologie_lettore WHERE codice <> 'standard'"); + $db->query('DELETE FROM system_settings'); + $restore = $db->prepare('INSERT INTO system_settings (category, setting_key, setting_value) VALUES (?, ?, ?)'); + foreach ($baseline as $row) { + $restore->bind_param('sss', $row['category'], $row['setting_key'], $row['setting_value']); + $restore->execute(); + } + $restore->close(); + Features::resetOverrides(); ConfigStore::clearCache(); LibraryCalendar::reset(); + CirculationPolicy::resetSchemaCache(); PatronLedger::resetSchemaCache(); + $asAdmin(); + $body(); + echo "PASS {$name}\n"; + $passed++; + } catch (Throwable $e) { + echo "FAIL {$name}: {$e->getMessage()}\n"; + $failed++; + } + } +} catch (Throwable $e) { + fwrite(STDERR, 'FAIL configuration suite setup: ' . $e->getMessage() . PHP_EOL); + $failed++; +} finally { + Features::resetOverrides(); LibraryCalendar::reset(); + $shared->setValue(null, $previousConnection); ConfigStore::clearCache(); + $_SESSION = $previousSession; + if ($db instanceof mysqli) { $db->rollback(); $db->close(); } + if ($admin instanceof mysqli) { $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); $admin->close(); } +} +echo "Settings flexibility: {$passed}/25 passed, {$failed} failed\n"; +exit($passed === 25 && $failed === 0 ? 0 : 1); From d72fa1d464006067b6fe0680faaf03bab977631d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 09:45:13 +0100 Subject: [PATCH 208/398] Protect saved preferences and behaviour across repeated upgrades --- .gitignore | 2 + tests/fixtures/upgrades/README.md | 12 + tests/fixtures/upgrades/schema-0.8.9.sql | 1486 +++++++++++++++++ ...> stack-090-settings-flexibility.unit.php} | 127 +- 4 files changed, 1622 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/upgrades/README.md create mode 100644 tests/fixtures/upgrades/schema-0.8.9.sql rename tests/{stack-090-settings-flexibility-25.unit.php => stack-090-settings-flexibility.unit.php} (75%) diff --git a/.gitignore b/.gitignore index 7aed0fa9f..257a5ded3 100644 --- a/.gitignore +++ b/.gitignore @@ -476,6 +476,8 @@ tests/fixtures/* !tests/fixtures/sru/ !tests/fixtures/htaccess-history/ !tests/fixtures/marc/ +!tests/fixtures/upgrades/ +!tests/fixtures/upgrades/*.sql !tests/helpers/ tests/helpers/* !tests/helpers/*.php diff --git a/tests/fixtures/upgrades/README.md b/tests/fixtures/upgrades/README.md new file mode 100644 index 000000000..bae0d73a2 --- /dev/null +++ b/tests/fixtures/upgrades/README.md @@ -0,0 +1,12 @@ +# Schemi storici per i test di aggiornamento + +`schema-0.8.9.sql` è la copia integrale di `installer/database/schema.sql` del tag +`v0.8.9`, ottenuta con: + +```sh +git show v0.8.9:installer/database/schema.sql +``` + +La fixture mantiene obbligatorio il test di conservazione delle preferenze +anche nei checkout CI senza tag o cronologia completa. Non adattarla alle nuove +colonne: rappresenta il database precedente alla migrazione 0.9.0. diff --git a/tests/fixtures/upgrades/schema-0.8.9.sql b/tests/fixtures/upgrades/schema-0.8.9.sql new file mode 100644 index 000000000..ac62cf8ea --- /dev/null +++ b/tests/fixtures/upgrades/schema-0.8.9.sql @@ -0,0 +1,1486 @@ + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `admin_notifications` ( + `id` int NOT NULL AUTO_INCREMENT, + `type` enum('new_message','new_reservation','new_user','overdue_loan','new_loan_request','new_review','general') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `link` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_read` tinyint(1) DEFAULT '0', + `related_id` int DEFAULT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_is_read` (`is_read`), + KEY `idx_type` (`type`), + KEY `idx_created` (`created_at`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +CREATE TABLE `email_delivery_outbox` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `recipient_email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `template_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `variables_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `attempts` int unsigned NOT NULL DEFAULT '0', + `available_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `claim_token` char(32) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, + `claimed_at` datetime DEFAULT NULL, + `last_error` text COLLATE utf8mb4_unicode_ci, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_email_outbox_due` (`available_at`,`claim_token`), + KEY `idx_email_outbox_claimed` (`claimed_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `api_keys` ( + `id` int NOT NULL AUTO_INCREMENT, + `api_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text COLLATE utf8mb4_unicode_ci, + `is_active` tinyint(1) DEFAULT '1', + `last_used_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `api_key` (`api_key`), + KEY `idx_api_key` (`api_key`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `autori` ( + `id` int NOT NULL AUTO_INCREMENT, + `data_nascita` date DEFAULT NULL, + `data_morte` date DEFAULT NULL, + `nazionalità` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pseudonimo` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `biografia` text COLLATE utf8mb4_unicode_ci, + `sito_web` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `foto` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `collegamenti` json DEFAULT NULL, + `gnd_id` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `viaf_id` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `viaf_uri` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `isni_id` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `isni_uri` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `authority_source` enum('manual','viaf','isni','sbn','wikidata') COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `authority_confidence` enum('exact','probable','candidate','rejected') COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `nome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_isni_id` (`isni_id`), + KEY `idx_viaf_id` (`viaf_id`), + UNIQUE KEY `uq_autori_gnd` (`gnd_id`), + FULLTEXT KEY `ft_autori_nome` (`nome`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `author_authority_alternates` ( + `id` int NOT NULL AUTO_INCREMENT, + `autore_id` int NOT NULL, + `source` enum('viaf','isni','sbn','wikidata','manual') COLLATE utf8mb4_unicode_ci NOT NULL, + `authority_id` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `label` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `uri` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `confidence` enum('exact','probable','candidate','rejected') COLLATE utf8mb4_unicode_ci DEFAULT 'candidate', + `payload_json` json DEFAULT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_autore_id` (`autore_id`), + KEY `idx_authority` (`source`,`authority_id`), + CONSTRAINT `fk_author_authority_alternates_autore` FOREIGN KEY (`autore_id`) REFERENCES `autori` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `cms_pages` ( + `id` int NOT NULL AUTO_INCREMENT, + `slug` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `locale` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'en_US', + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `image` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `meta_description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `is_active` tinyint(1) DEFAULT '1', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`), + KEY `idx_slug` (`slug`), + KEY `idx_active` (`is_active`), + KEY `idx_cms_locale` (`locale`), + KEY `idx_cms_slug_locale` (`slug`,`locale`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `contact_messages` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `cognome` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `telefono` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `indirizzo` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `messaggio` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `privacy_accepted` tinyint(1) DEFAULT '0', + `ip_address` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `is_read` tinyint(1) DEFAULT '0', + `is_archived` tinyint(1) DEFAULT '0', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `read_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_email` (`email`), + KEY `idx_read` (`is_read`), + KEY `idx_archived` (`is_archived`), + KEY `idx_created` (`created_at`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `copie` ( + `id` int NOT NULL AUTO_INCREMENT, + `libro_id` int NOT NULL, + `sede_id` int DEFAULT NULL, + `numero_inventario` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `stato` enum('disponibile','prestato','prenotato','manutenzione','in_restauro','perso','danneggiato','in_trasferimento') COLLATE utf8mb4_unicode_ci DEFAULT 'disponibile', + `note` text COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_numero_inventario` (`numero_inventario`), + KEY `idx_libro_id` (`libro_id`), + KEY `idx_stato` (`stato`), + KEY `idx_sede_id` (`sede_id`), + CONSTRAINT `copie_ibfk_1` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE, + CONSTRAINT `copie_ibfk_2` FOREIGN KEY (`sede_id`) REFERENCES `sedi` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `donazioni` ( + `id` int NOT NULL AUTO_INCREMENT, + `donatore_id` int NOT NULL, + `data_donazione` datetime DEFAULT CURRENT_TIMESTAMP, + `descrizione` text COLLATE utf8mb4_unicode_ci, + `stato` enum('in_valutazione','accettata','rifiutata') COLLATE utf8mb4_unicode_ci DEFAULT 'in_valutazione', + `valore` decimal(10,2) DEFAULT NULL, + `tipo_donazione` enum('libri','fondi','materiali','altro') COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tax_receipt` tinyint(1) DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `donatore_id` (`donatore_id`), + KEY `idx_donazioni_tipo_donazione` (`tipo_donazione`), + CONSTRAINT `donazioni_ibfk_1` FOREIGN KEY (`donatore_id`) REFERENCES `utenti` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `editori` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `indirizzo` text COLLATE utf8mb4_unicode_ci, + `sito_web` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `telefono` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `referente_nome` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `referente_telefono` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `referente_email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `codice_fiscale` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `codice_fiscale` (`codice_fiscale`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `email_templates` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `locale` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'it_IT', + `subject` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `body` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `active` tinyint(1) DEFAULT '1', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `name_locale` (`name`,`locale`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `feedback` ( + `id` int NOT NULL AUTO_INCREMENT, + `utente_id` int NOT NULL, + `tipo` enum('feedback','problema','suggerimento') COLLATE utf8mb4_unicode_ci NOT NULL, + `messaggio` text COLLATE utf8mb4_unicode_ci NOT NULL, + `data_invio` datetime DEFAULT CURRENT_TIMESTAMP, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `utente_id` (`utente_id`), + KEY `idx_feedback_tipo` (`tipo`), + CONSTRAINT `feedback_ibfk_1` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `generi` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `descrizione` text COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `parent_id` int DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_nome_parent` (`nome`,`parent_id`), + KEY `parent_id` (`parent_id`), + CONSTRAINT `generi_ibfk_1` FOREIGN KEY (`parent_id`) REFERENCES `generi` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `home_content` ( + `id` int NOT NULL AUTO_INCREMENT, + `section_key` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Chiave sezione (hero, features, cta, etc)', + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Titolo principale', + `subtitle` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Sottotitolo o descrizione', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Contenuto aggiuntivo (HTML permesso)', + `button_text` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Testo pulsante CTA', + `button_link` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Link pulsante CTA', + `background_image` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Percorso immagine di sfondo', + `seo_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Custom SEO title (overrides default)', + `seo_description` text COLLATE utf8mb4_unicode_ci COMMENT 'Custom meta description', + `seo_keywords` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SEO keywords (comma-separated)', + `og_image` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Custom Open Graph image (overrides hero background)', + `og_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Open Graph Title', + `og_description` text COLLATE utf8mb4_unicode_ci COMMENT 'Open Graph Description', + `og_type` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT 'website' COMMENT 'Open Graph Type (website, article, etc.)', + `og_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Open Graph URL', + `twitter_card` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT 'summary_large_image' COMMENT 'Twitter Card Type', + `twitter_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Twitter Card Title', + `twitter_description` text COLLATE utf8mb4_unicode_ci COMMENT 'Twitter Card Description', + `twitter_image` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Twitter Card Image URL', + `is_active` tinyint(1) DEFAULT '1' COMMENT 'Se 0 la sezione non viene mostrata', + `display_order` int DEFAULT '0' COMMENT 'Ordine di visualizzazione', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `section_key` (`section_key`), + KEY `idx_active` (`is_active`), + KEY `idx_order` (`display_order`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Contenuti editabili homepage'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `import_logs` +-- Import tracking system for CSV and LibraryThing imports +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `import_logs` ( + `id` int NOT NULL AUTO_INCREMENT, + `import_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Unique identifier for this import session', + `import_type` enum('csv','librarything') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import', + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Original filename uploaded', + `user_id` int DEFAULT NULL COMMENT 'User who initiated the import', + `total_rows` int NOT NULL DEFAULT '0' COMMENT 'Total rows in CSV', + `imported` int NOT NULL DEFAULT '0' COMMENT 'Successfully imported books (new)', + `updated` int NOT NULL DEFAULT '0' COMMENT 'Updated existing books', + `failed` int NOT NULL DEFAULT '0' COMMENT 'Failed rows', + `authors_created` int NOT NULL DEFAULT '0' COMMENT 'New authors created', + `publishers_created` int NOT NULL DEFAULT '0' COMMENT 'New publishers created', + `scraped` int NOT NULL DEFAULT '0' COMMENT 'Books enriched via scraping', + `errors_json` mediumtext COLLATE utf8mb4_unicode_ci COMMENT 'JSON array of errors with line numbers and messages', + `started_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'When import started', + `completed_at` timestamp NULL DEFAULT NULL COMMENT 'When import completed or failed', + `status` enum('processing','completed','failed') COLLATE utf8mb4_unicode_ci DEFAULT 'processing' COMMENT 'Current import status', + PRIMARY KEY (`id`), + UNIQUE KEY `import_id` (`import_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_import_type` (`import_type`), + KEY `idx_started_at` (`started_at`), + KEY `idx_status` (`status`), + KEY `idx_type_status` (`import_type`,`status`), + CONSTRAINT `import_logs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `utenti` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Import tracking and error logging'; +/*!40101 SET character_set_client = @saved_cs_client */; + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `events` ( + `id` int NOT NULL AUTO_INCREMENT, + `title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `slug` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `content` text COLLATE utf8mb4_unicode_ci, + `event_date` date NOT NULL, + `event_time` time DEFAULT NULL, + `featured_image` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `seo_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `seo_description` text COLLATE utf8mb4_unicode_ci, + `seo_keywords` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `og_image` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `og_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `og_description` text COLLATE utf8mb4_unicode_ci, + `og_type` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT 'article', + `og_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `twitter_card` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT 'summary_large_image', + `twitter_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `twitter_description` text COLLATE utf8mb4_unicode_ci, + `twitter_image` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_active` tinyint(1) DEFAULT '1', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`), + KEY `idx_events_active` (`is_active`), + KEY `idx_events_date` (`event_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Library events and activities'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `languages` ( + `id` int NOT NULL AUTO_INCREMENT, + `code` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Language code: it_IT, en_US, es_ES, fr_FR, de_DE, etc.', + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'English name: Italian, English, Spanish, French, German', + `native_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Native name: Italiano, English, Español, Français, Deutsch', + `flag_emoji` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT 0xF09F8C90 COMMENT 'Flag emoji: ??, ??, ??, ??, ??', + `is_default` tinyint(1) DEFAULT '0' COMMENT 'Is this the default language for new users?', + `is_active` tinyint(1) DEFAULT '1' COMMENT 'Is this language active and selectable?', + `translation_file` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Path to translation file: locale/es_ES.json', + `total_keys` int DEFAULT '0' COMMENT 'Total translation keys in system', + `translated_keys` int DEFAULT '0' COMMENT 'Number of translated keys for this language', + `completion_percentage` decimal(5,2) DEFAULT '0.00' COMMENT 'Translation completion percentage', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `unique_code` (`code`), + KEY `idx_is_active` (`is_active`), + KEY `idx_is_default` (`is_default`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Stores available languages for the application'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `libri` ( + `id` int NOT NULL AUTO_INCREMENT, + `isbn10` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `isbn13` varchar(13) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `issn` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ISSN for periodicals (LibraryThing)', + `titolo` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `sottotitolo` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `anno_pubblicazione` smallint DEFAULT NULL, + `lingua` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `original_languages` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Original languages (LibraryThing)', + `edizione` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `luogo_pubblicazione` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Place of publication as printed (MARC 264 $a)', + `numero_pagine` int DEFAULT NULL, + `genere_id` int DEFAULT NULL, + `sottogenere_id` int DEFAULT NULL, + `scaffale_id` int DEFAULT NULL, + `mensola_id` int DEFAULT NULL, + `posizione_progressiva` int DEFAULT NULL, + `collocazione` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `posizione_id` int DEFAULT NULL, + `stato` enum('disponibile','prestato','prenotato','perso','danneggiato','non_disponibile') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT 'disponibile' COMMENT 'Derived availability summary, auto-managed by the loan engine (DataIntegrity::recalculateBookAvailability); non_disponibile = has copies but none circulating', + `data_acquisizione` date DEFAULT NULL, + `entry_date` date DEFAULT NULL COMMENT 'LibraryThing entry date', + `date_started` date DEFAULT NULL COMMENT 'Date started reading (LibraryThing)', + `date_read` date DEFAULT NULL COMMENT 'Date finished reading (LibraryThing)', + `tipo_acquisizione` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT 'acquisto' COMMENT 'Free-text acquisition type (e.g. acquisto, donazione, prestito)', + `copertina_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `descrizione` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `descrizione_plain` text DEFAULT NULL, + `review` text COLLATE utf8mb4_unicode_ci COMMENT 'Book review (LibraryThing)', + `rating` tinyint unsigned DEFAULT NULL COMMENT 'Rating 1-5 (LibraryThing)', + `comment` text COLLATE utf8mb4_unicode_ci COMMENT 'Public comment (LibraryThing)', + `private_comment` text COLLATE utf8mb4_unicode_ci COMMENT 'Private comment (LibraryThing)', + `parole_chiave` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `search_index` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `catalog_author_display` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Derived principal-author display used by public catalog listings', + `catalog_author_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Derived canonical name of the principal catalog author', + `catalog_author_sort` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Derived surname/sort key for catalog author ordering', + `formato` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT 'cartaceo', + `tipo_media` enum('libro','disco','audiolibro','dvd','altro') NOT NULL DEFAULT 'libro', + `peso` float DEFAULT NULL, + `dimensioni` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `physical_description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Physical description (LibraryThing)', + `prezzo` decimal(10,2) DEFAULT NULL, + `value` decimal(10,2) DEFAULT NULL COMMENT 'Current value (LibraryThing)', + `condition_lt` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Physical condition (LibraryThing)', + `lt_fields_visibility` json DEFAULT NULL COMMENT 'Frontend visibility preferences for LibraryThing fields', + `copie_totali` int DEFAULT '1', + `copie_disponibili` int DEFAULT '1', + `editore_id` int DEFAULT NULL, + `source` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Source/vendor (LibraryThing)', + `from_where` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'From where acquired (LibraryThing)', + `lending_patron` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Current lending patron (LibraryThing)', + `lending_status` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Lending status (LibraryThing)', + `lending_start` date DEFAULT NULL COMMENT 'Lending start date (LibraryThing)', + `lending_end` date DEFAULT NULL COMMENT 'Lending end date (LibraryThing)', + `numero_inventario` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `classificazione_dewey` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `dewey_wording` text COLLATE utf8mb4_unicode_ci COMMENT 'Dewey classification description (LibraryThing)', + `lccn` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Library of Congress Control Number (LibraryThing)', + `lc_classification` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'LC Classification (LibraryThing)', + `other_call_number` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Other call number (LibraryThing)', + `collana` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `numero_serie` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `note_varie` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `file_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `audio_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `traduttore` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `illustratore` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `curatore` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `ean` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'European Article Number', + `bcid` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'BCID (LibraryThing)', + `barcode` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Physical barcode (LibraryThing)', + `oclc` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'OCLC number (LibraryThing)', + `work_id` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'LibraryThing Work ID', + `data_pubblicazione` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT 'Original publication date in Italian format', + `deleted_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `isbn13` (`isbn13`), + UNIQUE KEY `isbn10` (`isbn10`), + UNIQUE KEY `ean` (`ean`), + KEY `genere_id` (`genere_id`), + KEY `idx_libri_sottogenere` (`sottogenere_id`), + KEY `posizione_id` (`posizione_id`), + KEY `idx_libri_titolo_sottotitolo` (`titolo`,`sottotitolo`), + KEY `editore_id` (`editore_id`), + KEY `idx_libri_stato` (`stato`), + KEY `idx_libri_tipo_media_deleted_at` (`deleted_at`,`tipo_media`), + KEY `fk_libri_mensola` (`mensola_id`), + KEY `idx_libri_scaffale_mensola` (`scaffale_id`,`mensola_id`), + KEY `idx_libri_posizione_progressiva` (`posizione_progressiva`), + KEY `idx_collana` (`collana`), + KEY `idx_lt_rating` (`rating`), + KEY `idx_lt_date_read` (`date_read`), + KEY `idx_lt_lending_status` (`lending_status`), + KEY `idx_lt_lccn` (`lccn`), + KEY `idx_lt_barcode` (`barcode`), + KEY `idx_lt_oclc` (`oclc`), + KEY `idx_lt_work_id` (`work_id`), + KEY `idx_lt_issn` (`issn`), + KEY `idx_libri_updated_at` (`updated_at`), + KEY `idx_libri_deleted_created` (`deleted_at`,`created_at`), + KEY `idx_libri_genere_deleted_created` (`genere_id`,`deleted_at`,`created_at`), + KEY `idx_libri_catalog_author_sort` (`catalog_author_sort`,`id`), + FULLTEXT KEY `ft_libri_search` (`titolo`,`sottotitolo`,`descrizione`,`parole_chiave`), + FULLTEXT KEY `ft_libri_search_index` (`search_index`), + CONSTRAINT `fk_libri_mensola` FOREIGN KEY (`mensola_id`) REFERENCES `mensole` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_libri_scaffale` FOREIGN KEY (`scaffale_id`) REFERENCES `scaffali` (`id`) ON DELETE SET NULL, + CONSTRAINT `libri_ibfk_1` FOREIGN KEY (`editore_id`) REFERENCES `editori` (`id`) ON DELETE SET NULL, + CONSTRAINT `libri_ibfk_3` FOREIGN KEY (`genere_id`) REFERENCES `generi` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_libri_sottogenere` FOREIGN KEY (`sottogenere_id`) REFERENCES `generi` (`id`) ON DELETE SET NULL, + CONSTRAINT `libri_ibfk_5` FOREIGN KEY (`posizione_id`) REFERENCES `posizioni` (`id`) ON DELETE SET NULL, + CONSTRAINT `chk_lt_rating` CHECK ((`rating` is null or (`rating` between 1 and 5))) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `digital_assets` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `libro_id` int NOT NULL, + `url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL, + `md5_hash` char(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `filesize` bigint unsigned NOT NULL DEFAULT '0', + `image_width` int unsigned NOT NULL DEFAULT '0', + `image_height` int unsigned NOT NULL DEFAULT '0', + `ppi` smallint unsigned NOT NULL DEFAULT '300', + `filetype` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PDF', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_libro_id` (`libro_id`), + CONSTRAINT `digital_assets_ibfk_1` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `libri_autori` ( + `libro_id` int NOT NULL, + `autore_id` int NOT NULL, + `ruolo` enum('principale','co-autore','traduttore','illustratore','curatore','colorista') COLLATE utf8mb4_unicode_ci NOT NULL, + `ordine_credito` int DEFAULT NULL, + PRIMARY KEY (`libro_id`,`autore_id`,`ruolo`), + KEY `libro_id` (`libro_id`), + KEY `autore_id` (`autore_id`), + CONSTRAINT `libri_autori_ibfk_1` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE, + CONSTRAINT `libri_autori_ibfk_2` FOREIGN KEY (`autore_id`) REFERENCES `autori` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE `catalog_materialized_snapshots` ( + `cache_key` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `generation` bigint NOT NULL, + `payload` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`cache_key`), + KEY `idx_catalog_snapshots_updated` (`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Generation-bound shared materialization of bounded catalog counts and facets'; +-- Tracks only role links created by authoritative importers. Keeping this +-- provenance outside libri_autori lets a re-import replace its own stale +-- contributors without deleting an identical association added manually. +CREATE TABLE `libri_autori_import_sources` ( + `libro_id` int NOT NULL, + `autore_id` int NOT NULL, + `ruolo` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `source` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`libro_id`,`autore_id`,`ruolo`,`source`), + KEY `idx_lais_autore` (`autore_id`), + CONSTRAINT `libri_autori_import_sources_book_fk` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE, + CONSTRAINT `libri_autori_import_sources_author_fk` FOREIGN KEY (`autore_id`) REFERENCES `autori` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +-- Multi-publisher junction (issue #143). A book may have more than one +-- publisher; libri.editore_id is retained as the PRIMARY publisher (ordine=0) +-- for backward compatibility with single-publisher consumers. +CREATE TABLE `libri_editori` ( + `libro_id` int NOT NULL, + `editore_id` int NOT NULL, + `ordine` int DEFAULT NULL, + PRIMARY KEY (`libro_id`,`editore_id`), + KEY `libro_id` (`libro_id`), + KEY `editore_id` (`editore_id`), + CONSTRAINT `libri_editori_ibfk_1` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE, + CONSTRAINT `libri_editori_ibfk_2` FOREIGN KEY (`editore_id`) REFERENCES `editori` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `libri_donati` ( + `id` int NOT NULL AUTO_INCREMENT, + `donazione_id` int NOT NULL, + `libro_id` int NOT NULL, + `quantita` int DEFAULT '1', + `condizione` enum('nuovo','ottimo','buono','discreto') COLLATE utf8mb4_unicode_ci DEFAULT 'ottimo', + `note_condizione` text COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `libro_id` (`libro_id`), + KEY `idx_libri_donati_donazione_id` (`donazione_id`), + CONSTRAINT `libri_donati_ibfk_1` FOREIGN KEY (`donazione_id`) REFERENCES `donazioni` (`id`), + CONSTRAINT `libri_donati_ibfk_2` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `libri_tag` ( + `libro_id` int NOT NULL, + `tag_id` int NOT NULL, + PRIMARY KEY (`libro_id`,`tag_id`), + KEY `tag_id` (`tag_id`), + CONSTRAINT `libri_tag_ibfk_1` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE, + CONSTRAINT `libri_tag_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tag` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `log_modifiche` ( + `id` int NOT NULL AUTO_INCREMENT, + `tabella` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `record_id` int NOT NULL, + `azione` enum('inserimento','aggiornamento','cancellazione') COLLATE utf8mb4_unicode_ci NOT NULL, + `dati_precedenti` text COLLATE utf8mb4_unicode_ci, + `dati_nuovi` text COLLATE utf8mb4_unicode_ci, + `utente_id` int DEFAULT NULL, + `data_modifica` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `utente_id` (`utente_id`), + KEY `idx_log_modifiche_tabella_record_id` (`tabella`,`record_id`), + CONSTRAINT `log_modifiche_ibfk_1` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `mensole` ( + `id` int NOT NULL AUTO_INCREMENT, + `scaffale_id` int NOT NULL, + `numero_livello` int NOT NULL, + `genere_id` int DEFAULT NULL, + `descrizione` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `ordine` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `scaffale_id` (`scaffale_id`,`numero_livello`), + KEY `genere_id` (`genere_id`), + CONSTRAINT `mensole_ibfk_1` FOREIGN KEY (`scaffale_id`) REFERENCES `scaffali` (`id`) ON DELETE CASCADE, + CONSTRAINT `mensole_ibfk_2` FOREIGN KEY (`genere_id`) REFERENCES `generi` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `plugin_data` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `plugin_id` int unsigned NOT NULL COMMENT 'Reference to plugin', + `data_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Data key', + `data_value` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Data value (can store JSON)', + `data_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'string' COMMENT 'Data type hint', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_plugin_key` (`plugin_id`,`data_key`), + KEY `idx_plugin_id` (`plugin_id`), + CONSTRAINT `fk_plugin_data_plugin` FOREIGN KEY (`plugin_id`) REFERENCES `plugins` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Generic plugin data storage'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `plugin_hooks` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `plugin_id` int unsigned NOT NULL COMMENT 'Reference to plugin', + `hook_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Hook identifier', + `callback_class` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PHP class to call', + `callback_method` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Method name in class', + `priority` int NOT NULL DEFAULT '10' COMMENT 'Execution priority (lower = earlier)', + `is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Whether hook is active', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_plugin_hook_callback` (`plugin_id`,`hook_name`,`callback_method`), + KEY `idx_hook_name` (`hook_name`,`priority`), + KEY `idx_plugin_id` (`plugin_id`), + KEY `idx_active` (`is_active`), + CONSTRAINT `fk_plugin_hooks_plugin` FOREIGN KEY (`plugin_id`) REFERENCES `plugins` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Plugin hooks registry'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `plugin_logs` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `plugin_id` int unsigned DEFAULT NULL COMMENT 'Reference to plugin (NULL for system logs)', + `level` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'info' COMMENT 'Log level', + `message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Log message', + `context` json DEFAULT NULL COMMENT 'Additional context data', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_plugin_id` (`plugin_id`), + KEY `idx_level` (`level`), + KEY `idx_created_at` (`created_at`), + CONSTRAINT `fk_plugin_logs_plugin` FOREIGN KEY (`plugin_id`) REFERENCES `plugins` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Plugin activity and error logs'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `plugin_settings` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `plugin_id` int unsigned NOT NULL COMMENT 'Reference to plugin', + `setting_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Setting key', + `setting_value` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Setting value (can store JSON)', + `autoload` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Whether to autoload this setting', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `unique_plugin_setting` (`plugin_id`,`setting_key`), + KEY `idx_plugin_id` (`plugin_id`), + KEY `idx_autoload` (`autoload`), + CONSTRAINT `fk_plugin_settings_plugin` FOREIGN KEY (`plugin_id`) REFERENCES `plugins` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Plugin-specific settings and configuration'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `plugins` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Plugin unique identifier', + `display_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Human-readable plugin name', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Plugin description', + `version` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Plugin version', + `author` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Plugin author name', + `author_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Author website URL', + `plugin_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Plugin website/repository URL', + `is_active` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Whether plugin is activated', + `path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Plugin directory path', + `main_file` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Main plugin file', + `requires_php` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Minimum PHP version required', + `requires_app` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Minimum app version required', + `metadata` json DEFAULT NULL COMMENT 'Additional plugin metadata', + `installed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Installation timestamp', + `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 'Last update timestamp', + `activated_at` datetime DEFAULT NULL COMMENT 'Last activation timestamp', + PRIMARY KEY (`id`), + UNIQUE KEY `unique_plugin_name` (`name`), + KEY `idx_active` (`is_active`), + KEY `idx_name` (`name`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Plugin registry and metadata'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `oai_deleted_records` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `entity_type` enum('book','archival_unit') COLLATE utf8mb4_unicode_ci NOT NULL, + `entity_id` bigint unsigned NOT NULL, + `oai_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `datestamp` datetime NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_entity` (`entity_type`,`entity_id`), + KEY `idx_oai_id` (`oai_id`), + KEY `idx_datestamp` (`datestamp`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `oai_resumption_tokens` ( + `token` varchar(64) NOT NULL, + `payload` json NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`token`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `mag_project_config` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `project_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PINAKES', + `institution_code` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'IT', + `collection_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Biblioteca Pinakes', + `rights_statement` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'In Copyright', + `base_url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_code` (`project_code`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ncip_partners` ( + `id` int NOT NULL AUTO_INCREMENT, + `code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `agency_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `endpoint_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `isil` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `notes` text COLLATE utf8mb4_unicode_ci, + `active` tinyint(1) NOT NULL DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_code` (`code`), + KEY `idx_active` (`active`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ncip_transactions` ( + `id` int NOT NULL AUTO_INCREMENT, + `partner_id` int DEFAULT NULL, + `message_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `prestito_id` int DEFAULT NULL, + `prenotazione_id` int DEFAULT NULL, + `request_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` enum('pending','success','error') NOT NULL DEFAULT 'pending', + `error_msg` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_partner` (`partner_id`), + KEY `idx_status` (`status`), + KEY `idx_prestito` (`prestito_id`), + KEY `idx_prenotazione` (`prenotazione_id`), + CONSTRAINT `ncip_transactions_ibfk_1` FOREIGN KEY (`partner_id`) REFERENCES `ncip_partners` (`id`) ON DELETE SET NULL, + CONSTRAINT `ncip_transactions_ibfk_2` FOREIGN KEY (`prestito_id`) REFERENCES `prestiti` (`id`) ON DELETE SET NULL, + CONSTRAINT `ncip_transactions_ibfk_3` FOREIGN KEY (`prenotazione_id`) REFERENCES `prenotazioni` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `posizioni` ( + `id` int NOT NULL AUTO_INCREMENT, + `scaffale_id` int NOT NULL, + `mensola_id` int NOT NULL, + `genere_id` int NOT NULL, + `descrizione` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `ordine` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `scaffale_id` (`scaffale_id`,`mensola_id`,`genere_id`), + KEY `mensola_id` (`mensola_id`), + KEY `genere_id` (`genere_id`), + CONSTRAINT `posizioni_ibfk_1` FOREIGN KEY (`scaffale_id`) REFERENCES `scaffali` (`id`) ON DELETE CASCADE, + CONSTRAINT `posizioni_ibfk_2` FOREIGN KEY (`mensola_id`) REFERENCES `mensole` (`id`) ON DELETE CASCADE, + CONSTRAINT `posizioni_ibfk_3` FOREIGN KEY (`genere_id`) REFERENCES `generi` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `preferenze_notifica_utenti` ( + `utente_id` int NOT NULL, + `notifica_tipo` enum('email','sms','push') COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`utente_id`,`notifica_tipo`), + CONSTRAINT `preferenze_notifica_utenti_ibfk_1` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `prenotazioni` ( + `id` int NOT NULL AUTO_INCREMENT, + `libro_id` int NOT NULL, + `utente_id` int NOT NULL, + `data_prenotazione` datetime DEFAULT CURRENT_TIMESTAMP, + `data_inizio_richiesta` date DEFAULT NULL, + `data_fine_richiesta` date DEFAULT NULL, + `data_scadenza_prenotazione` datetime DEFAULT NULL, + `queue_position` int DEFAULT NULL, + `notifica_inviata` tinyint(1) DEFAULT '0', + `stato` enum('attiva','completata','annullata') COLLATE utf8mb4_unicode_ci DEFAULT 'attiva', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `libro_id` (`libro_id`), + KEY `utente_id` (`utente_id`), + KEY `idx_prenotazioni_data_scadenza_prenotazione` (`data_scadenza_prenotazione`), + KEY `idx_stato_libro` (`stato`,`libro_id`), + KEY `idx_queue_position` (`queue_position`), + CONSTRAINT `prenotazioni_ibfk_1` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`), + CONSTRAINT `prenotazioni_ibfk_2` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `prestiti` ( + `id` int NOT NULL AUTO_INCREMENT, + `libro_id` int NOT NULL, + `copia_id` int DEFAULT NULL, + `utente_id` int NOT NULL, + `data_prestito` date NOT NULL, + `data_scadenza` date NOT NULL, + `pickup_deadline` date DEFAULT NULL, + `data_restituzione` date DEFAULT NULL, + `restituito_in_ritardo` tinyint(1) NOT NULL DEFAULT '0', + `stato` enum('pendente','prenotato','da_ritirare','in_corso','restituito','in_ritardo','perso','danneggiato','annullato','scaduto') COLLATE utf8mb4_unicode_ci DEFAULT 'pendente', + `origine` enum('richiesta','prenotazione','diretto','ncip') COLLATE utf8mb4_unicode_ci DEFAULT 'richiesta', + `ncip_request_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sanzione` decimal(10,2) DEFAULT '0.00', + `renewals` int DEFAULT '0', + `processed_by` int DEFAULT NULL, + `note` text COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `attivo` tinyint(1) NOT NULL DEFAULT '1', + `warning_sent` tinyint(1) DEFAULT '0', + `overdue_notification_sent` tinyint(1) DEFAULT '0', + `pickup_notification_sent` tinyint(1) DEFAULT '0', + `pickup_notification_claim_token` char(32) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL, + `pickup_notification_last_attempt_at` datetime DEFAULT NULL, + `recall_count` int NOT NULL DEFAULT '0', + `last_recall_at` datetime DEFAULT NULL, + `pickup_reminder_sent_for` date DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `libro_id` (`libro_id`), + KEY `utente_id` (`utente_id`), + KEY `processed_by` (`processed_by`), + KEY `idx_prestiti_data_scadenza` (`data_scadenza`), + KEY `idx_prestiti_status` (`stato`,`data_scadenza`), + KEY `idx_prestiti_libro_stato` (`libro_id`,`stato`), + KEY `idx_prestiti_attivo_stato` (`attivo`,`stato`), + KEY `idx_prestiti_stato_origine` (`stato`,`origine`), + KEY `idx_copia_id` (`copia_id`), + KEY `idx_prestiti_pickup_deadline` (`pickup_deadline`), + KEY `idx_origine` (`origine`), + KEY `idx_prestiti_ncip_request_id` (`ncip_request_id`), + KEY `idx_libro_utente` (`libro_id`,`utente_id`), + CONSTRAINT `fk_prestiti_copia` FOREIGN KEY (`copia_id`) REFERENCES `copie` (`id`) ON DELETE RESTRICT, + CONSTRAINT `prestiti_ibfk_1` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`), + CONSTRAINT `prestiti_ibfk_2` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`), + CONSTRAINT `prestiti_ibfk_3` FOREIGN KEY (`processed_by`) REFERENCES `utenti` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `recensioni` ( + `id` int NOT NULL AUTO_INCREMENT, + `libro_id` int NOT NULL, + `utente_id` int NOT NULL, + `stelle` int NOT NULL DEFAULT '0', + `titolo` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `descrizione` text COLLATE utf8mb4_unicode_ci, + `stato` enum('pendente','approvata','rifiutata') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pendente', + `approved_by` int DEFAULT NULL, + `approved_at` timestamp NULL DEFAULT NULL, + `data_recensione` datetime DEFAULT CURRENT_TIMESTAMP, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `unique_user_book_review` (`libro_id`,`utente_id`), + KEY `idx_recensioni_libro_id` (`libro_id`), + KEY `idx_recensioni_utente_id` (`utente_id`), + KEY `idx_recensioni_stato` (`stato`), + KEY `idx_recensioni_approved_by` (`approved_by`), + CONSTRAINT `recensioni_ibfk_1` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE, + CONSTRAINT `recensioni_ibfk_2` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`) ON DELETE CASCADE, + CONSTRAINT `recensioni_ibfk_3` FOREIGN KEY (`approved_by`) REFERENCES `utenti` (`id`) ON DELETE SET NULL, + CONSTRAINT `recensioni_chk_1` CHECK ((`stelle` between 1 and 5)) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `scaffali` ( + `id` int NOT NULL AUTO_INCREMENT, + `codice` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `nome` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `lettera` varchar(1) COLLATE utf8mb4_unicode_ci NOT NULL, + `descrizione` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `ordine` int DEFAULT '0', + PRIMARY KEY (`id`), + -- #153: `lettera` is NOT unique — bookcase codes are multi-character (L1, L2…) + -- and only `codice` identifies a bookcase. `lettera` is a derived helper. + KEY `lettera` (`lettera`), + UNIQUE KEY `uniq_codice` (`codice`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sedi` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Nome della sede (es. Sede Centrale, Succursale Nord)', + `codice` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Codice identificativo della sede', + `indirizzo` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `citta` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `cap` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `provincia` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `telefono` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `orari_apertura` text COLLATE utf8mb4_unicode_ci COMMENT 'Orari di apertura in formato libero', + `note` text COLLATE utf8mb4_unicode_ci, + `is_active` tinyint(1) DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_codice` (`codice`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Sedi della biblioteca (per sistemi multi-sede)'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `staff` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `cognome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `telefono` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `ruolo` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `data_assunzione` date DEFAULT NULL, + `stato` enum('attivo','inattivo') COLLATE utf8mb4_unicode_ci DEFAULT 'attivo', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `system_settings` ( + `id` int NOT NULL AUTO_INCREMENT, + `category` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `setting_key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `setting_value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `unique_setting` (`category`,`setting_key`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `tag` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `descrizione` text COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `nome` (`nome`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE IF NOT EXISTS `ruoli` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `permessi` text COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ruoli_nome` (`nome`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +CREATE TABLE `utenti` ( + `id` int NOT NULL AUTO_INCREMENT, + `codice_tessera` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `nome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `cognome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `telefono` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `indirizzo` text COLLATE utf8mb4_unicode_ci, + `data_nascita` date DEFAULT NULL, + `sesso` enum('M','F','Altro') COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `foto_profilo_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `data_registrazione` datetime DEFAULT CURRENT_TIMESTAMP, + `data_scadenza_tessera` date DEFAULT NULL, + `stato` enum('attivo','sospeso','scaduto') COLLATE utf8mb4_unicode_ci DEFAULT 'attivo', + `ruolo_id` int DEFAULT NULL, + `accesso_digitale` tinyint(1) NOT NULL DEFAULT 0, + `tipo_utente` enum('standard','premium','staff','admin') COLLATE utf8mb4_unicode_ci DEFAULT 'standard', + `cod_fiscale` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `note_utente` text COLLATE utf8mb4_unicode_ci, + `email_verificata` tinyint(1) DEFAULT '0', + `privacy_accettata` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'GDPR privacy policy acceptance flag', + `data_accettazione_privacy` datetime DEFAULT NULL COMMENT 'UTC timestamp of privacy acceptance', + `privacy_policy_version` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Version of accepted privacy policy', + `locale` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT 'it_IT', + `token_verifica_email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `data_token_verifica` datetime DEFAULT NULL, + `token_reset_password` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `data_token_reset` datetime DEFAULT NULL, + `data_ultimo_accesso` datetime DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `codice_tessera` (`codice_tessera`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `cod_fiscale` (`cod_fiscale`), + KEY `idx_utenti_email` (`email`), + KEY `idx_utenti_codice_tessera` (`codice_tessera`), + KEY `idx_utenti_cod_fiscale` (`cod_fiscale`), + KEY `idx_utenti_data_scadenza_tessera` (`data_scadenza_tessera`), + KEY `idx_utenti_data_ultimo_accesso` (`data_ultimo_accesso`), + KEY `idx_tipo_utente` (`tipo_utente`), + KEY `idx_utenti_ruolo` (`ruolo_id`), + CONSTRAINT `fk_utenti_ruolo` FOREIGN KEY (`ruolo_id`) REFERENCES `ruoli` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `registrazione_campi` ( + `id` int NOT NULL AUTO_INCREMENT, + `etichetta` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `tipo` enum('text','textarea','email','url','number','checkbox') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'text', + `obbligatorio` tinyint(1) NOT NULL DEFAULT '0', + `attivo` tinyint(1) NOT NULL DEFAULT '1', + `ordine` int NOT NULL DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `utenti_campi_valori` ( + `utente_id` int NOT NULL, + `campo_id` int NOT NULL, + `valore` text COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`utente_id`, `campo_id`), + KEY `idx_ucv_campo` (`campo_id`), + CONSTRAINT `utenti_campi_valori_utente_fk` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`) ON DELETE CASCADE, + CONSTRAINT `utenti_campi_valori_campo_fk` FOREIGN KEY (`campo_id`) REFERENCES `registrazione_campi` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `wishlist` ( + `id` int NOT NULL AUTO_INCREMENT, + `utente_id` int NOT NULL, + `libro_id` int NOT NULL, + `data_aggiunta` datetime DEFAULT CURRENT_TIMESTAMP, + `notified` tinyint(1) DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `utente_id` (`utente_id`,`libro_id`), + KEY `idx_wishlist_utente_id` (`utente_id`), + KEY `idx_wishlist_libro_id` (`libro_id`), + CONSTRAINT `wishlist_ibfk_1` FOREIGN KEY (`utente_id`) REFERENCES `utenti` (`id`) ON DELETE CASCADE, + CONSTRAINT `wishlist_ibfk_2` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `collane` +-- Series metadata: name, description +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `collane` ( + `id` int NOT NULL AUTO_INCREMENT, + `parent_id` int DEFAULT NULL COMMENT 'Parent series/cycle for nested series hierarchies', + `tipo` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'serie' COMMENT 'Series kind: serie, universo, ciclo, stagione, spin_off, arco, collezione_editoriale, altro', + `nome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Series name (must match libri.collana values)', + `descrizione` text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Series description', + `gruppo_serie` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Umbrella series/universe grouping for spin-offs', + `ciclo` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Cycle or season label inside the series group', + `ordine_ciclo` smallint unsigned DEFAULT NULL COMMENT 'Sort order for cycle/season inside the group', + `is_completa` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Admin flag: all expected volumes are present in the catalogue', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_collana_nome` (`nome`), + KEY `idx_collane_parent` (`parent_id`), + KEY `idx_collane_tipo` (`tipo`), + KEY `idx_collane_gruppo_serie` (`gruppo_serie`), + KEY `idx_collane_gruppo_ordine` (`gruppo_serie`,`ordine_ciclo`,`nome`), + CONSTRAINT `fk_collane_parent` FOREIGN KEY (`parent_id`) REFERENCES `collane` (`id`) ON DELETE SET NULL, + CONSTRAINT `chk_collane_tipo` CHECK (`tipo` IN ('serie','universo','ciclo','stagione','spin_off','arco','collezione_editoriale','altro')) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `libri_collane` ( + `id` int NOT NULL AUTO_INCREMENT, + `libro_id` int NOT NULL, + `collana_id` int NOT NULL, + `numero_serie` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tipo_appartenenza` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'principale', + `is_principale` tinyint(1) NOT NULL DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_libro_collana` (`libro_id`,`collana_id`), + KEY `idx_lc_collana` (`collana_id`), + KEY `idx_lc_principale` (`libro_id`,`is_principale`), + CONSTRAINT `fk_lc_collana` FOREIGN KEY (`collana_id`) REFERENCES `collane` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_lc_libro` FOREIGN KEY (`libro_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE, + CONSTRAINT `chk_lc_principale_consistency` CHECK ( + (`tipo_appartenenza` = 'principale' AND `is_principale` = 1) + OR (`tipo_appartenenza` <> 'principale' AND `is_principale` = 0) + ) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `volumi` +-- Multi-volume works: links parent works to individual volume books +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `volumi` ( + `id` int NOT NULL AUTO_INCREMENT, + `opera_id` int NOT NULL COMMENT 'Parent work (the complete multi-volume work)', + `volume_id` int NOT NULL COMMENT 'Child book (individual volume)', + `numero_volume` smallint unsigned DEFAULT '1' COMMENT 'Volume number within the work', + `titolo_volume` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Override title for this volume', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_volume_id` (`volume_id`), + KEY `idx_opera` (`opera_id`), + CONSTRAINT `fk_volumi_opera` FOREIGN KEY (`opera_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_volumi_volume` FOREIGN KEY (`volume_id`) REFERENCES `libri` (`id`) ON DELETE CASCADE, + CONSTRAINT `chk_volumi_not_self` CHECK (`opera_id` <> `volume_id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `z39_access_logs` +-- Plugin: Z39.50/SRU Server +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `z39_access_logs` ( + `id` int NOT NULL AUTO_INCREMENT, + `ip_address` varchar(45) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Client IP address', + `user_agent` text COLLATE utf8mb4_unicode_ci COMMENT 'Client user agent', + `operation` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SRU operation (explain, searchRetrieve, scan)', + `query` text COLLATE utf8mb4_unicode_ci COMMENT 'CQL query string', + `format` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Record format requested', + `num_records` int DEFAULT '0' COMMENT 'Number of records returned', + `response_time_ms` int DEFAULT NULL COMMENT 'Response time in milliseconds', + `http_status` int DEFAULT NULL COMMENT 'HTTP status code', + `error_message` text COLLATE utf8mb4_unicode_ci COMMENT 'Error message if any', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ip` (`ip_address`), + KEY `idx_operation` (`operation`), + KEY `idx_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `z39_rate_limits` +-- Plugin: Z39.50/SRU Server +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `z39_rate_limits` ( + `id` int NOT NULL AUTO_INCREMENT, + `ip_address` varchar(45) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_count` int DEFAULT '1', + `window_start` datetime NOT NULL, + `last_request` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `unique_ip_window` (`ip_address`,`window_start`), + KEY `idx_window` (`window_start`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `themes` +-- Theme Management System +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `themes` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Theme display name', + `slug` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Unique theme identifier', + `version` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '1.0.0' COMMENT 'Theme version', + `author` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 'Admin' COMMENT 'Theme author', + `description` text COLLATE utf8mb4_unicode_ci COMMENT 'Theme description', + `active` tinyint(1) DEFAULT '0' COMMENT '1 = active theme, 0 = inactive', + `settings` json DEFAULT NULL COMMENT 'Theme settings (colors, typography, logo, advanced)', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`), + KEY `idx_active` (`active`), + KEY `idx_slug` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Theme management system'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `migrations` +-- Application Update System +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `migrations` ( + `id` int NOT NULL AUTO_INCREMENT, + `version` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Version number (e.g., 0.3.0)', + `filename` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Migration filename', + `batch` int NOT NULL DEFAULT '1' COMMENT 'Batch number for rollback', + `executed_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT 'When migration was executed', + PRIMARY KEY (`id`), + UNIQUE KEY `unique_version` (`version`), + KEY `idx_batch` (`batch`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tracks executed database migrations'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `update_logs` +-- Application Update System +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `update_logs` ( + `id` int NOT NULL AUTO_INCREMENT, + `from_version` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `to_version` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` enum('started','completed','failed','rolled_back') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'started', + `backup_path` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Path to backup file', + `error_message` text COLLATE utf8mb4_unicode_ci, + `started_at` datetime DEFAULT CURRENT_TIMESTAMP, + `completed_at` datetime DEFAULT NULL, + `executed_by` int DEFAULT NULL COMMENT 'User ID who initiated update', + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_started` (`started_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Logs all update attempts'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_sessions` +-- GDPR: Database-backed persistent sessions ("Remember Me") +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_sessions` ( + `id` int NOT NULL AUTO_INCREMENT, + `utente_id` int NOT NULL, + `token_hash` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SHA-256 hash of the token', + `device_info` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Browser/device identifier', + `ip_address` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'IPv4 or IPv6', + `user_agent` text COLLATE utf8mb4_unicode_ci, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `expires_at` datetime NOT NULL, + `last_used_at` datetime DEFAULT NULL, + `is_revoked` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `idx_user_sessions_utente_id` (`utente_id`), + KEY `idx_user_sessions_token_hash` (`token_hash`), + KEY `idx_user_sessions_expires_at` (`expires_at`), + KEY `idx_user_sessions_is_revoked` (`is_revoked`), + CONSTRAINT `fk_user_sessions_utente` FOREIGN KEY (`utente_id`) + REFERENCES `utenti` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Database-backed persistent sessions for Remember Me'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `gdpr_requests` +-- GDPR: Track data export/delete/rectification requests +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `gdpr_requests` ( + `id` int NOT NULL AUTO_INCREMENT, + `utente_id` int DEFAULT NULL COMMENT 'NULL if user deleted', + `utente_email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Preserved for audit', + `request_type` enum('export','delete','rectification') COLLATE utf8mb4_unicode_ci NOT NULL, + `status` enum('pending','processing','completed','rejected') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending', + `requested_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `processed_at` datetime DEFAULT NULL, + `processed_by` int DEFAULT NULL COMMENT 'Admin user ID', + `notes` text COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`id`), + KEY `idx_gdpr_requests_utente_id` (`utente_id`), + KEY `idx_gdpr_requests_status` (`status`), + KEY `idx_gdpr_requests_type` (`request_type`), + CONSTRAINT `fk_gdpr_requests_utente` FOREIGN KEY (`utente_id`) + REFERENCES `utenti` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_gdpr_requests_admin` FOREIGN KEY (`processed_by`) + REFERENCES `utenti` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='GDPR data subject request tracking'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `consent_log` +-- GDPR Article 7: Audit trail for consent changes +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `consent_log` ( + `id` int NOT NULL AUTO_INCREMENT, + `utente_id` int DEFAULT NULL, + `consent_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'privacy_policy, marketing, analytics, etc.', + `consent_given` tinyint(1) NOT NULL, + `consent_version` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `ip_address` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `user_agent` text COLLATE utf8mb4_unicode_ci, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_consent_log_utente_id` (`utente_id`), + KEY `idx_consent_log_type` (`consent_type`), + KEY `idx_consent_log_created_at` (`created_at`), + KEY `idx_consent_log_utente_type` (`utente_id`, `consent_type`, `created_at`), + CONSTRAINT `fk_consent_log_utente` FOREIGN KEY (`utente_id`) + REFERENCES `utenti` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='GDPR Article 7 consent audit trail'; +/*!40101 SET character_set_client = @saved_cs_client */; + + +-- +-- Mobile API plugin tables (bundled + active by default since 0.7.21). +-- Mirrors MobileApiPlugin::ensureSchema() (idempotent, single source of truth +-- on activation/upgrade). Kept here so a fresh install — where the plugin is +-- auto-activated — has the tables ready. FK_CHECKS is off during this import. +-- +CREATE TABLE IF NOT EXISTS mobile_app_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token_hash CHAR(64) NOT NULL, + device_name VARCHAR(190) NULL, + device_id VARCHAR(190) NULL, + platform VARCHAR(32) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME NULL, + revoked_at DATETIME NULL, + expires_at DATETIME NULL, + UNIQUE KEY uq_token_hash (token_hash), + KEY idx_user (user_id), + KEY idx_user_active (user_id, revoked_at), + CONSTRAINT fk_mobile_token_user + FOREIGN KEY (user_id) REFERENCES utenti (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mobile_push_subscriptions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token_id INT NULL, + provider ENUM('unifiedpush','fcm') NOT NULL DEFAULT 'unifiedpush', + endpoint VARCHAR(500) NULL, + registration_id VARCHAR(500) NULL, + public_key VARCHAR(255) NULL, + auth VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_ok_at DATETIME NULL, + failure_count INT NOT NULL DEFAULT 0, + KEY idx_user (user_id), + KEY idx_token (token_id), + CONSTRAINT fk_mobile_push_user + FOREIGN KEY (user_id) REFERENCES utenti (id) ON DELETE CASCADE, + CONSTRAINT fk_mobile_push_token + FOREIGN KEY (token_id) REFERENCES mobile_app_tokens (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mobile_push_prefs ( + user_id INT NOT NULL PRIMARY KEY, + loan_due TINYINT(1) NOT NULL DEFAULT 1, + loan_overdue TINYINT(1) NOT NULL DEFAULT 1, + reservation_ready TINYINT(1) NOT NULL DEFAULT 1, + new_message TINYINT(1) NOT NULL DEFAULT 1, + book_available TINYINT(1) NOT NULL DEFAULT 1, + quiet_start TIME NULL, + quiet_end TIME NULL, + CONSTRAINT fk_mobile_prefs_user + FOREIGN KEY (user_id) REFERENCES utenti (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mobile_availability_watchers ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + libro_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_user_book (user_id, libro_id), + KEY idx_libro (libro_id), + CONSTRAINT fk_mobile_watch_user + FOREIGN KEY (user_id) REFERENCES utenti (id) ON DELETE CASCADE, + CONSTRAINT fk_mobile_watch_book + FOREIGN KEY (libro_id) REFERENCES libri (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mobile_push_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + event_key VARCHAR(191) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_user_event (user_id, event_key), + KEY idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +INSERT IGNORE INTO ruoli (nome, permessi) VALUES +('Catalogatore', '["backend.access","catalog.manage"]'), +('Addetto prestiti', '["backend.access","loans.manage"]'), +('Archivista', '["backend.access","catalog.manage","digital.manage","digital.access"]'); diff --git a/tests/stack-090-settings-flexibility-25.unit.php b/tests/stack-090-settings-flexibility.unit.php similarity index 75% rename from tests/stack-090-settings-flexibility-25.unit.php rename to tests/stack-090-settings-flexibility.unit.php index 901829dc3..7eead48d9 100644 --- a/tests/stack-090-settings-flexibility-25.unit.php +++ b/tests/stack-090-settings-flexibility.unit.php @@ -2,12 +2,13 @@ declare(strict_types=1); /** - * 25 independently named integration scenarios for the 0.9.0 settings. + * 26 independently named integration scenarios for the 0.9.0 settings: + * 25 configuration scenarios and upgrade preservation of saved choices. * Real controllers, persisted settings, feature gates and domain services; * no Features::override(), no source-text assertions, no network requests. * Creates a fresh random sandbox database and drops it in finally. The * supplied account must have CREATE DATABASE permission, as for migrations. - * Run: php tests/stack-090-settings-flexibility-25.unit.php + * Run: php tests/stack-090-settings-flexibility.unit.php */ use App\Controllers\Admin\LibraryCalendarController; use App\Controllers\AttendanceController; @@ -352,7 +353,123 @@ public function handle(ServerRequestInterface $request): ResponseInterface { thr $assert($one("SELECT stato FROM copie WHERE id={$b['copy']}") === 'disponibile', 'Optional loss action was applied implicitly'); }); - $assert(count($cases) === 25, 'This suite must register exactly 25 scenarios'); + $test('26 Repeated upgrades preserve saved settings and effective behaviour in every switch state', static function () use ($root, $db, $admin, $sandbox, $settings, $set, $flag, $book, $patron, $assert): void { + $p = $patron(); $b = $book(); + $db->query("UPDATE libri SET rda_content_type='prm' WHERE id={$b['id']}"); + $db->query("UPDATE copie SET solo_consultazione=1 WHERE id={$b['copy']}"); + $db->query("INSERT INTO fornitori (nome) VALUES ('ZZ upgrade supplier')"); + $db->query("INSERT INTO presenze (tipo,nome_ospite) VALUES ('ospite','ZZ upgrade guest')"); + $db->query("INSERT INTO calendario_chiusure (data_inizio,data_fine,descrizione,ricorrente_annuale,attiva) VALUES ('2026-08-20','2026-08-20','ZZ upgrade manual',1,0)"); + (new StocktakeService($db))->open('ZZ upgrade inventory', null, ['collocazione_da' => $b['location'], 'collocazione_a' => $b['location']], null); + $flag('fines', true); + (new PatronLedger($db))->manualCharge($p, 400, 'ZZ upgrade existing debt', null); + // Existing data would activate the migration's RC defaults. An explicit + // saved OFF must still win; a saved ON must survive a default of OFF. + $sql = (string) file_get_contents($root . '/installer/database/migrations/migrate_0.9.0.sql'); + $sql = implode("\n", array_filter(explode("\n", $sql), static fn (string $line): bool => !str_starts_with(trim($line), '--'))); + $statements = array_filter(explode(';', $sql), static fn (string $statement): bool => trim($statement) !== ''); + $capture = static function () use ($db, $settings, $p, $b): array { + ConfigStore::clearCache(); LibraryCalendar::reset(); + CirculationPolicy::resetSchemaCache(); PatronLedger::resetSchemaCache(); + $policy = (new CirculationPolicy($db))->forLoan($p, $b['id'], $b['copy']); + $enabled = []; + foreach (Features::keys() as $key) { $enabled[$key] = Features::enabled($key); } + return [ + 'settings' => $db->query('SELECT * FROM system_settings ORDER BY category, setting_key')->fetch_all(MYSQLI_ASSOC), + 'enabled' => $enabled, + 'stored' => Features::snapshot(), + 'policy' => [$policy->durationDays, $policy->maxRenewals, $policy->loanable, $policy->maxActiveLoans], + 'debt_blocked' => PatronLedger::isDebtBlocked($db, $p), + 'balance' => (new PatronLedger($db))->balanceCents($p), + 'due_date' => LibraryCalendar::dueDate($db, '2038-08-15', $b['copy']), + 'card' => PatronCardSettings::resolve($settings), + 'retention' => AttendanceRepository::retentionDays($settings), + 'book' => $db->query("SELECT * FROM libri WHERE id={$b['id']}")->fetch_assoc(), + 'copy' => $db->query("SELECT * FROM copie WHERE id={$b['copy']}")->fetch_assoc(), + 'closures' => $db->query('SELECT * FROM calendario_chiusure ORDER BY id')->fetch_all(MYSQLI_ASSOC), + ]; + }; + foreach (['all off', 'all on', 'mixed in catalogue mode'] as $profile => $label) { + foreach (Features::keys() as $index => $key) { + $flag($key, $profile === 1 || ($profile === 2 && $index % 2 === 0)); + } + foreach ([ + ['system', 'catalogue_mode', $profile === 2 ? '1' : '0'], + ['app', 'timezone', 'America/New_York'], ['app', 'currency', 'USD'], + ['loans', 'loan_duration_days', '17'], ['loans', 'pickup_expiry_days', '9'], + ['loans', 'max_renewals', '0'], ['loans', 'max_active_loans_per_user', '0'], + ['loans', 'daily_fine', $profile === 1 ? '0.37' : '0'], + ['loans', 'debt_block_threshold', $profile === 1 ? '0' : '2.75'], + ['loans', 'fine_grace_days', '0'], ['loans', 'closed_weekdays', '0,1'], + ['cards', 'layout', 'portrait'], ['cards', 'code_type', 'qr'], + ['cards', 'show_photo', '0'], ['cards', 'show_logo', '1'], ['cards', 'show_expiry', '0'], + ['attendance', 'retention_days', '0'], + ['calendar', 'holiday_country', 'DE'], ['calendar', 'holiday_region', 'BY'], + ['calendar', 'holiday_rules', '["DE:mariae_himmelfahrt"]'], + ['custom_library', 'operator_choice', 'preserve even an unknown preference'], + ] as [$category, $key, $value]) { $set($category, $key, $value); } + $before = $capture(); + for ($pass = 1; $pass <= 2; $pass++) { + foreach ($statements as $statement) { + $result = $db->query($statement); + if ($result instanceof mysqli_result) { $result->free(); } + } + $assert($capture() === $before, "Upgrade changed saved choices or behaviour: {$label}, pass {$pass}"); + } + } + + // Also exercise the real 0.8.9 -> 0.9.0 schema transition. A checked-in + // release fixture makes this mandatory even in a shallow CI checkout. + $legacySchema = (string) file_get_contents($root . '/tests/fixtures/upgrades/schema-0.8.9.sql'); + $assert(str_contains($legacySchema, 'CREATE TABLE `system_settings`'), 'The 0.8.9 release schema fixture is missing'); + foreach (['0', '1'] as $catalogueMode) { + $admin->query("DROP DATABASE `{$sandbox}`"); + $admin->query("CREATE DATABASE `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $db->select_db($sandbox); + $db->multi_query($legacySchema); + do { + if ($result = $db->store_result()) { $result->free(); } + } while ($db->more_results() && $db->next_result()); + ConfigStore::clearCache(); LibraryCalendar::reset(); + CirculationPolicy::resetSchemaCache(); PatronLedger::resetSchemaCache(); + foreach ([ + ['system', 'catalogue_mode', $catalogueMode], ['app', 'timezone', 'America/New_York'], + ['app', 'currency', 'USD'], ['loans', 'loan_duration_days', '17'], + ['loans', 'max_renewals', '0'], ['loans', 'max_active_loans_per_user', '0'], + ['loans', 'pickup_expiry_days', '9'], ['loans', 'auto_approve_requests', '0'], + ['label', 'width', '62'], ['label', 'height', '31'], + ['custom_library', 'operator_choice', 'keep the existing choice'], + ] as [$category, $key, $value]) { $set($category, $key, $value); } + $legacyReader = $patron(); $legacyBook = $book(); + $captureLegacy = static function () use ($db, $legacyReader, $legacyBook): array { + ConfigStore::clearCache(); LibraryCalendar::reset(); + CirculationPolicy::resetSchemaCache(); PatronLedger::resetSchemaCache(); + $policy = (new CirculationPolicy($db))->forLoan($legacyReader, $legacyBook['id'], $legacyBook['copy']); + return [ + 'saved' => $db->query("SELECT * FROM system_settings WHERE category <> 'features' ORDER BY category, setting_key")->fetch_all(MYSQLI_ASSOC), + 'mode' => ConfigStore::isCatalogueMode(), + 'currency' => FineParameters::currency(), + 'policy' => [$policy->durationDays, $policy->maxRenewals, $policy->maxActiveLoans, $policy->loanable], + 'debt_blocked' => PatronLedger::isDebtBlocked($db, $legacyReader), + 'due_date' => LibraryCalendar::dueDate($db, '2038-08-15', $legacyBook['copy']), + 'reader' => $db->query("SELECT codice_tessera, data_scadenza_tessera, stato FROM utenti WHERE id={$legacyReader}")->fetch_assoc(), + 'book' => $db->query("SELECT titolo, tipo_media, formato, prezzo FROM libri WHERE id={$legacyBook['id']}")->fetch_assoc(), + 'copy' => $db->query("SELECT numero_inventario, stato FROM copie WHERE id={$legacyBook['copy']}")->fetch_assoc(), + ]; + }; + $before = $captureLegacy(); + $assert($before['policy'] === [17, 0, 0, true] && !$before['debt_blocked'] && $before['due_date'] === '2038-08-15', 'The legacy reference behaviour is wrong'); + for ($pass = 1; $pass <= 2; $pass++) { + foreach ($statements as $statement) { + $result = $db->query($statement); + if ($result instanceof mysqli_result) { $result->free(); } + } + $assert($captureLegacy() === $before, "0.8.9 upgrade changed saved choices or behaviour: catalogue mode {$catalogueMode}, pass {$pass}"); + } + } + }); + + $assert(count($cases) === 26, 'This suite must register exactly 26 scenarios'); foreach ($cases as [$name, $body]) { try { $db->rollback(); @@ -388,5 +505,5 @@ public function handle(ServerRequestInterface $request): ResponseInterface { thr if ($db instanceof mysqli) { $db->rollback(); $db->close(); } if ($admin instanceof mysqli) { $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); $admin->close(); } } -echo "Settings flexibility: {$passed}/25 passed, {$failed} failed\n"; -exit($passed === 25 && $failed === 0 ? 0 : 1); +echo "Settings flexibility: {$passed}/26 passed, {$failed} failed\n"; +exit($passed === 26 && $failed === 0 ? 0 : 1); From cb02424782ba94db3fa88e5cfb56df581da243d2 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 09:57:21 +0100 Subject: [PATCH 209/398] Document PR 512 CI results and settings flexibility review --- .../pr-512-settings-flexibility-2026-10-29.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/reviews/pr-512-settings-flexibility-2026-10-29.md diff --git a/docs/reviews/pr-512-settings-flexibility-2026-10-29.md b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md new file mode 100644 index 000000000..dd24a7195 --- /dev/null +++ b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md @@ -0,0 +1,125 @@ +# PR #512: workflow, 26 test e flessibilità delle impostazioni + +Verifica del 29 ottobre 2026 sulla catena #503–#512, con #502 esclusa dalla review come richiesto. Le correzioni funzionali della #512 sono nel commit `3960c194`; la suite nel commit `d72fa1d4` comprende i 25 scenari richiesti e la regressione aggiuntiva sulla conservazione del comportamento durante gli aggiornamenti. + +## Esito dei workflow + +La tabella distingue la batteria avviata sulle correzioni funzionali dalla successiva verifica della nuova suite. Gli esiti si riferiscono al commit indicato, non a run precedenti della stessa PR. + +Tutti i dieci workflow delle correzioni sono terminati con successo. Anche Code Quality e la matrice database sulla suite aggiornata di 26 scenari sono verdi: non sono emersi errori CI da correggere. + +### Correzioni funzionali: `3960c194` + +| Workflow | Esito | Run | +| --- | --- | --- | +| Code Quality | Superato | [40830554173](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830554173) | +| E2E Test Suite | Superato: generale, catalogazione e circolazione 0.9.0 | [40830557203](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830557203) | +| Browser Compatibility and DAST | Superato | [40830559955](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830559955) | +| Deep Regression Gate | Superato: quattro shard browser e cinque installazioni localizzate | [40830562699](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830562699) | +| Database Compatibility | Superato | [40830565681](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830565681) | +| Security and Supply Chain | Superato | [40830568665](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830568665) | +| Real Upgrade, interfaccia admin | Superato | [40830571304](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830571304) | +| Upgrade Smoke Test | Superato | [40830574075](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830574075) | +| Test Database Migrations | Superato | [40830576583](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830576583) | +| CodeQL Security Analysis | Superato | [40830579689](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830579689) | + +### Nuova suite: `d72fa1d4`, 26 scenari + +| Verifica | Esito | Run | +| --- | --- | --- | +| Esecuzione locale, MySQL isolato | 26 superati, 0 falliti | `php tests/stack-090-settings-flexibility.unit.php` | +| Code Quality | Superato | [40844504696](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40844504696) | +| Database Compatibility | Superato: 26/26 scenari su MySQL 8.0/8.4 e MariaDB 10.11/11.4 | [40844508132](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40844508132) | + +## Cosa è già configurabile + +Tutti gli undici moduli opzionali introdotti hanno un interruttore persistente. Otto sono in **Impostazioni → Moduli**; regole lettore, calendario e multe sono in **Impostazioni → Prestiti**. Gli interruttori sono scelte di configurazione: lo staff non può modificarli; valgono i controlli amministrativi e la capacità di configurazione previsti dall'applicazione. + +Spegnere un modulo conserva i suoi dati. La modalità catalogo sospende regole lettore, calendario, multe e presenze senza cancellare le scelte salvate: uscendone, i moduli riprendono lo stato scelto. + +| Ambito | Dove si spegne | Scelte già disponibili | +| --- | --- | --- | +| Revisione inventariale | Moduli → Revisione inventariale | Sede e filtri per sessione; chiusura con o senza dichiarazione di perdita delle copie mancanti; annullamento; report congelato. | +| MARC | Moduli → Import ed export MARC | Duplicati: salta, completa, sovrascrivi; copertine facoltative; export per formato e selezione di libri. | +| Schede catalografiche | Moduli → Schede catalografiche | Stampa di un libro o di una selezione; l'esportazione richiede un'azione esplicita. | +| Fornitori | Moduli → Fornitori | Fornitore facoltativo per libro o importazione; eliminare il fornitore scollega i libri senza eliminarli. | +| Tipi RDA | Moduli → Tipi RDA | Valori derivati dal materiale oppure override per record; gli override rimangono conservati quando il modulo è spento. | +| Tessere e foto | Moduli → Tessere e foto dei lettori | Impostazioni → Tessere: layout, codice a barre/QR, foglio/singola, titolo e retro; foto, logo e scadenza si possono omettere dalla stampa. | +| Import utenti CSV | Moduli → Importazione utenti da CSV | Mappatura dei campi e inviti email facoltativi; tipologia esclusa dalla mappatura e dall'assegnazione quando le regole lettore sono spente. | +| Presenze | Moduli → Registro presenze | Conservazione configurabile nel registro; `0` disattiva la cancellazione automatica. La gestione della conservazione segue il controllo amministrativo del registro. | +| Regole lettore | Prestiti → Regole per tipologia di lettore | Regole globali, tipologie e matrice per materiale; durata, rinnovi, limiti, prenotazioni, validità tessera e consultazione. | +| Calendario | Prestiti → Calendario chiusure | Giorni settimanali, paese/regione e festività selezionate, chiusure per sede, intervalli, ricorrenze, import ICS; chiusure annuali manuali disattivabili singolarmente. | +| Multe e registro | Prestiti → Multe e registro movimenti | Tariffa, tolleranza, tetto e soglia debito; override per tipologia e materiale; avviso email facoltativo. Tariffa e blocco sono indipendenti. | + +I valori numerici hanno significati diversi che vanno conservati: `0` rinnovi vieta i rinnovi; `0` per i limiti quantitativi significa nessun limite; tariffa o soglia debito a `0` disattivano quel singolo automatismo. Il valore vuoto nei campi di ereditarietà lascia applicare il livello sottostante. Queste combinazioni sono verificate dai test, non soltanto dalla presenza dei campi nel form. + +Per usare solo il registro contabile mantenere il modulo multe acceso e impostare tariffa e soglia debito a zero. Per addebitare ritardi senza bloccare i prestiti, scegliere una tariffa positiva e una soglia a zero. Per bloccare il debito senza nuove multe automatiche, usare tariffa zero e soglia positiva. + +## I 26 nuovi scenari + +File: [`tests/stack-090-settings-flexibility.unit.php`](../../tests/stack-090-settings-flexibility.unit.php). Esecuzione locale aggiornata: **26/26 superati, nessun fallimento**. + +La suite usa controller reali, impostazioni salvate e servizi applicativi. Non forza i moduli con `Features::override()`. Crea un database casuale con schema, dati iniziali e trigger reali, e lo elimina alla fine; non modifica i dati della biblioteca. La CI la raccoglie automaticamente come suite `*.unit.php`. + +| N. | Scenario | +| --- | --- | +| 1 | Salvataggio di una selezione mista dei moduli e lettura effettiva da parte del runtime. | +| 2 | Un salvataggio della scheda Moduli non altera gli interruttori della scheda Prestiti. | +| 3 | Lo staff non può modificare i moduli opzionali. | +| 4 | Gli interruttori della circolazione sono indipendenti e gli zeri espliciti vengono conservati. | +| 5 | Lo staff non può modificare interruttori della circolazione o importi delle multe. | +| 6 | La modalità catalogo sospende e ripristina le scelte di circolazione senza cancellarle. | +| 7 | Un modulo spento restituisce `feature_disabled` ai client JSON senza eseguire l'endpoint. | +| 8 | La chiusura di tutti e sette i giorni viene rifiutata senza sovrascrivere il calendario precedente. | +| 9 | Festività globali e chiusure di sede producono scadenze diverse per le copie di due sedi. | +| 10 | Riaccendere il calendario applica subito una festività lontana senza attendere la manutenzione. | +| 11 | Rimuovere una regione elimina la sua festività senza alterare una chiusura manuale disattivata. | +| 12 | L'applicazione delle festività partecipa alla transazione del chiamante e ne segue il rollback. | +| 13 | Una cella della matrice vieta i rinnovi con `0` e continua a ereditare la durata lasciata vuota. | +| 14 | Spegnere le regole ripristina durata globale e prestabilità; riaccenderle recupera tipologia e consultazione. | +| 15 | Il blocco per debito funziona con le nuove multe automatiche disattivate. | +| 16 | Le multe automatiche funzionano con il blocco per debito disattivato. | +| 17 | Una tipologia può escludere tariffa e blocco globali con zeri espliciti. | +| 18 | Un importer CSV già creato smette di assegnare tipologie quando il modulo viene spento e riprende alla riaccensione. | +| 19 | Il codice CSV identifica la tipologia esatta anche quando due tipologie hanno lo stesso nome. | +| 20 | MARC completa/sovrascrivi rispettano scelte diverse sul materiale e preservano prezzo e formato del libro esistente. | +| 21 | Disattivare e riattivare RDA conserva gli override manuali. | +| 22 | La sospensione dei fornitori conserva l'associazione; eliminarne uno conserva il libro. | +| 23 | Layout tessera, QR, formato e omissione di foto/logo/scadenza rimangono indipendenti dalla visibilità del modulo. | +| 24 | La conservazione delle presenze supporta sia nessuna cancellazione automatica sia una finestra personalizzata. | +| 25 | L'inventario si chiude senza marcare perdite e conserva i report quando il modulo viene spento. | +| 26 | La migrazione completa, ripetuta, conserva preferenze salvate, dati e comportamento effettivo: anche con tutti i moduli spenti, tutti accesi, scelte miste e aggiornamento dallo schema 0.8.9. | + +## Vincolo permanente per gli aggiornamenti + +Gli utenti devono mantenere il comportamento già scelto a ogni aggiornamento. Un default nuovo si applica a una preferenza assente; non sostituisce un valore salvato, compresi `0`, stringa vuota o modulo esplicitamente spento. La presenza di dati del modulo non autorizza a riaccenderlo se l'utente lo ha disattivato. + +Il test 26 esegue l'intera `migrate_0.9.0.sql` due volte per cinque configurazioni: moduli tutti spenti, tutti accesi, scelte miste in modalità catalogo e schema 0.8.9 nelle due modalità operative. Controlla i valori persistiti e ricostruisce i servizi dopo la migrazione, verificando durata/rinnovi/prestabilità, debito, scadenze, tessere, conservazione, dati del lettore e del catalogo. Copre anche preferenze sconosciute al core e scelte di spegnimento in presenza di dati esistenti. + +La fixture dello schema 0.8.9 è inclusa nel repository: la verifica del vero aggiornamento resta obbligatoria anche nei checkout CI senza cronologia o tag. + +## Cosa cambierei + +Le proposte seguenti ampliano le scelte dell'utente. Sono proposte di prodotto, separate dalle verifiche e dalle correzioni già pubblicate. + +I nuovi controlli devono partire dall'assetto effettivo della biblioteca, conservando il comportamento esistente fino a una scelta esplicita. L'aggiornamento non deve attivare tariffe, cambiare la prestabilità di copie o avviare cancellazioni di dati come effetto collaterale dell'aggiunta di un'opzione. + +Consentirei inoltre agli amministratori di preparare calendari e regole mentre il relativo modulo è spento, con un riepilogo prima dell'attivazione. Oggi alcuni pannelli seguono lo stesso blocco delle pagine operative: configurare in anticipo renderebbe più controllabile il passaggio all'uso effettivo. + +### Priorità alta + +1. **Aperture straordinarie per data e sede.** Oggi il calendario descrive chiusure: non si può aprire una singola domenica mantenendo tutte le altre domeniche chiuse, oppure aprire una sola festività senza deselezionarla per tutti gli anni. Aggiungerei un'eccezione di apertura esplicita per quella data e sede, prioritaria rispetto a ricorrenze e preset, visibile nell'anteprima e applicata su tutti i canali. +2. **Separare le foto dalla stampa delle tessere.** Oggi si può togliere la foto dal PDF, ma acquisizione fotografica e stampa condividono lo stesso modulo. Aggiungerei un controllo dedicato all'acquisizione/visualizzazione delle foto, lasciando utilizzabili tessere e codici a barre. La riattivazione conserverebbe le foto già registrate. +3. **Separare consultazione e regole per tipologia.** Spegnere le regole oggi rende prestabili anche le copie segnate in sola consultazione. Consentirei di mantenere quel vincolo fisico senza usare tipologie e matrice. La scelta dovrebbe valere su banco, sito, app e NCIP. +4. **Mostrare l'effetto delle modifiche sul lavoro già aperto.** Prima di applicare un calendario o riattivare il registro, offrirei un riepilogo dei ritiri interessati e delle penalità già registrate che verranno riportate nel registro. Le scadenze dei prestiti in corso resterebbero modificabili con un'azione esplicita e tracciata. + +### Priorità successiva + +5. **Festività selezionabili per sede.** I preset sono globali; le eccezioni di sede richiedono chiusure manuali/ICS. Aggiungerei una selezione ereditata dal calendario generale, con eccezioni di sede chiaramente visibili nell'anteprima. +6. **Separare raccolta e conservazione delle presenze.** Spegnere il registro oggi sospende anche la cancellazione automatica. Offrirei la scelta di fermare i nuovi ingressi continuando ad applicare la finestra di conservazione ai dati esistenti. +7. **Ampliare la scelta dell'anno nell'anteprima delle festività.** Il motore ora calcola anche anni lontani; il selettore dell'anteprima mostra ancora anno corrente e due successivi. Consentirei di scegliere direttamente un altro anno senza trasformare il buffer tecnico in una preferenza dell'utente. +8. **Rendere esplicita la semantica di vuoto e zero.** Mostrerei vicino a ogni campo «eredita», «senza limite» o «disattiva», secondo il suo significato, e un riepilogo della regola effettiva per lettore e materiale. +9. **Profili iniziali modificabili.** Proporrei configurazioni «catalogo», «prestiti essenziali» e «circolazione completa», accompagnate dal riepilogo degli interruttori modificati. Ogni modulo resterebbe poi configurabile singolarmente. +10. **Opzioni aggiuntive per multe e tessere.** Valuterei una scelta esplicita tra tolleranza come soglia e giorni effettivamente abbuonati, e tra validità tessera calcolata da oggi o dalla scadenza esistente. Manterrei il comportamento attuale come default e verificherei le nuove opzioni su tutti i canali. + +Terrei interni dimensione dei batch, timeout dei lock e finestra di materializzazione delle festività: sono parametri operativi. Le impostazioni rivolte al bibliotecario dovrebbero descrivere regole di servizio, conservazione dei dati e presentazione dei documenti. From 71514e3a47a4126946869384eab3d1249e7a973b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 10:39:00 +0100 Subject: [PATCH 210/398] Separate optional automations and preserve upgrade preferences --- .../Admin/LibraryCalendarController.php | 7 +- .../CirculationSettingsController.php | 19 +- app/Controllers/PatronCardsController.php | 6 +- app/Controllers/SettingsController.php | 32 +++- app/Controllers/UsersController.php | 5 + app/Middleware/FeatureGateMiddleware.php | 4 +- app/Routes/web.php | 44 ++--- app/Services/CirculationPolicy.php | 16 +- app/Services/FineCalculator.php | 5 +- app/Services/FineParameters.php | 11 +- app/Services/PatronLedger.php | 10 +- app/Support/ConfigStore.php | 4 + app/Support/Features.php | 88 +++++++-- app/Support/LibraryCalendar.php | 4 +- app/Support/LoanEligibility.php | 2 +- app/Support/MaintenanceService.php | 19 +- app/Support/PatronCardPdf.php | 4 +- app/Support/PatronTypeAssignment.php | 20 +- app/Views/libri/scheda_libro.php | 4 +- app/Views/partials/settings-sections.php | 7 +- app/Views/settings/cards-tab.php | 3 + app/Views/settings/circulation-tab.php | 3 + app/Views/settings/index.php | 2 +- app/Views/settings/loans-tab.php | 31 +++- app/Views/settings/modules-tab.php | 16 +- .../partials/circulation-type-fields.php | 2 + .../settings/partials/feature-switch.php | 20 ++ app/Views/utenti/crea_utente.php | 2 + app/Views/utenti/dettagli_utente.php | 6 +- app/Views/utenti/modifica_utente.php | 4 +- app/Views/utenti/tipologia-lettore.php | 2 +- .../pr-512-settings-flexibility-2026-10-29.md | 175 +++++++----------- installer/database/data_da_DK.sql | 9 +- installer/database/data_de_DE.sql | 9 +- installer/database/data_en_US.sql | 9 +- installer/database/data_fr_FR.sql | 9 +- installer/database/data_it_IT.sql | 9 +- .../database/migrations/migrate_0.9.0.sql | 34 +++- locale/da_DK.json | 43 ++++- locale/de_DE.json | 43 ++++- locale/en_US.json | 43 ++++- locale/fr_FR.json | 43 ++++- locale/it_IT.json | 43 ++++- .../src/Controllers/HealthController.php | 3 + .../feature-toggle-circulation-rules.spec.js | 49 ++++- .../feature-toggle-closures-calendar.spec.js | 29 +-- tests/feature-toggle-patron-cards.spec.js | 25 ++- tests/feature-toggles-registry.unit.php | 6 +- tests/feature-toggles-routes.unit.php | 10 +- tests/feature-toggles-services.unit.php | 6 + tests/feature-toggles-settings.spec.js | 67 +++++++ tests/helpers/feature-toggles.js | 6 +- tests/migration-0.9.0-fines-ledger.unit.php | 3 + tests/patron-ledger.unit.php | 1 + tests/stack-090-settings-flexibility.unit.php | 141 +++++++++++++- 55 files changed, 940 insertions(+), 277 deletions(-) create mode 100644 app/Views/settings/partials/feature-switch.php diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php index bce727264..1cfddf081 100644 --- a/app/Controllers/Admin/LibraryCalendarController.php +++ b/app/Controllers/Admin/LibraryCalendarController.php @@ -41,7 +41,7 @@ public function index(Request $request, Response $response, \mysqli $db): Respon $branches = $this->branches($db); $previewSede = isset($query['sede']) && is_numeric($query['sede']) && isset($branches[(int) $query['sede']]) ? (int) $query['sede'] : null; - $calendar = LibraryCalendar::fromDatabase($db); + $calendar = LibraryCalendar::fromDatabase($db, configuration: true); $closedWeekdays = $calendar->closedWeekdays(); $closures = $this->closures($db); @@ -331,9 +331,6 @@ private function calendarChanged(\mysqli $db): void private function guard(Response $response): ?Response { - if (($off = \App\Support\Features::requireOrRedirect('closures_calendar', $response)) !== null) { - return $off; - } if (\App\Support\Permissions::mayConfigure()) { return null; } @@ -521,7 +518,7 @@ private function holidayPreview(\mysqli $db, array $query, int $currentYear): ar ? $query['holiday_region'] : ($country === $selection['country'] ? $selection['region'] : ''); $region = HolidayCalculator::normaliseRegion($country, $rawRegion); - $years = range($currentYear, $currentYear + HolidayPresets::YEARS_AHEAD); + $years = range(1900, 2199); $year = is_scalar($query['holiday_year'] ?? null) && in_array((int) $query['holiday_year'], $years, true) ? (int) $query['holiday_year'] : $currentYear; $savedHere = $selection['saved'] && $selection['country'] === $country && $selection['region'] === $region; $ticked = array_flip($selection['rules']); diff --git a/app/Controllers/CirculationSettingsController.php b/app/Controllers/CirculationSettingsController.php index 180607247..a59d03451 100644 --- a/app/Controllers/CirculationSettingsController.php +++ b/app/Controllers/CirculationSettingsController.php @@ -59,7 +59,7 @@ class CirculationSettingsController */ public static function viewData(mysqli $db): array { - $policy = new CirculationPolicy($db); + $policy = new CirculationPolicy($db, configuration: true); $types = $policy->types(); $media = self::mediaTypes(); $globals = $policy->globalLayer(); @@ -135,9 +135,6 @@ public static function mediaTypes(): array */ private function guard(Response $response): ?Response { - if (($off = \App\Support\Features::requireOrRedirect('circulation_rules', $response)) !== null) { - return $off; - } if (\App\Support\Permissions::mayConfigure()) { return null; } @@ -158,7 +155,7 @@ public function editForm(Request $request, Response $response, mysqli $db, int $ if ($guard = $this->guard($response)) { return $guard; } - $policy = new CirculationPolicy($db); + $policy = new CirculationPolicy($db, configuration: true); $type = $policy->types()[$id] ?? null; if ($type === null) { $_SESSION['error_message'] = __('Tipologia lettore non trovata.'); @@ -187,7 +184,7 @@ public function createType(Request $request, Response $response, mysqli $db): Re $_SESSION['error_message'] = $fields; return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); } - if (!(new CirculationPolicy($db))->schemaReady()) { + if (!(new CirculationPolicy($db, configuration: true))->schemaReady()) { $_SESSION['error_message'] = __('Le tabelle della circolazione non sono ancora state create: completa l\'aggiornamento del database.'); return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); } @@ -221,7 +218,7 @@ public function updateType(Request $request, Response $response, mysqli $db, int return $guard; } $data = (array) ($request->getParsedBody() ?? []); - $policy = new CirculationPolicy($db); + $policy = new CirculationPolicy($db, configuration: true); $current = $policy->types()[$id] ?? null; if ($current === null) { $_SESSION['error_message'] = __('Tipologia lettore non trovata.'); @@ -261,7 +258,7 @@ public function deleteType(Request $request, Response $response, mysqli $db, int if ($guard = $this->guard($response)) { return $guard; } - $policy = new CirculationPolicy($db); + $policy = new CirculationPolicy($db, configuration: true); $type = $policy->types()[$id] ?? null; if ($type === null) { $_SESSION['error_message'] = __('Tipologia lettore non trovata.'); @@ -300,7 +297,7 @@ public function setDefault(Request $request, Response $response, mysqli $db, int if ($guard = $this->guard($response)) { return $guard; } - if (!isset((new CirculationPolicy($db))->types()[$id])) { + if (!isset((new CirculationPolicy($db, configuration: true))->types()[$id])) { $_SESSION['error_message'] = __('Tipologia lettore non trovata.'); return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); } @@ -325,7 +322,7 @@ public function moveType(Request $request, Response $response, mysqli $db, int $ } $data = (array) ($request->getParsedBody() ?? []); $direction = ($data['direction'] ?? '') === 'up' ? -1 : 1; - $ids = array_keys((new CirculationPolicy($db))->types()); + $ids = array_keys((new CirculationPolicy($db, configuration: true))->types()); $pos = array_search($id, $ids, true); if ($pos !== false && isset($ids[$pos + $direction])) { $swap = $ids[$pos + $direction]; @@ -357,7 +354,7 @@ public function saveRules(Request $request, Response $response, mysqli $db): Res return $guard; } $data = (array) ($request->getParsedBody() ?? []); - $policy = new CirculationPolicy($db); + $policy = new CirculationPolicy($db, configuration: true); if (!$policy->schemaReady()) { $_SESSION['error_message'] = __('Le tabelle della circolazione non sono ancora state create: completa l\'aggiornamento del database.'); return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); diff --git a/app/Controllers/PatronCardsController.php b/app/Controllers/PatronCardsController.php index 00778c4c8..3b09b8bb6 100644 --- a/app/Controllers/PatronCardsController.php +++ b/app/Controllers/PatronCardsController.php @@ -77,7 +77,7 @@ public function bulkPdf(Request $request, Response $response, mysqli $db): Respo /** Stream the stored photo (never cached by shared caches). */ public function photo(Request $request, Response $response, mysqli $db, int $id): Response { - if (($off = \App\Support\Features::requireOrRedirect('patron_cards', $response)) !== null) { + if (($off = \App\Support\Features::requireOrRedirect('patron_photos', $response)) !== null) { return $off; } if (!\App\Support\Permissions::can('users.manage')) { @@ -103,7 +103,7 @@ public function photo(Request $request, Response $response, mysqli $db, int $id) /** Save a photo sent as multipart field `photo` (webcam frame or picked file). JSON reply. */ public function uploadPhoto(Request $request, Response $response, mysqli $db, int $id): Response { - if (($off = \App\Support\Features::requireOrRedirect('patron_cards', $response)) !== null) { + if (($off = \App\Support\Features::requireOrRedirect('patron_photos', $response)) !== null) { return $off; } $guard = $this->editGuard($db, $id); @@ -140,7 +140,7 @@ public function uploadPhoto(Request $request, Response $response, mysqli $db, in /** Remove the photo (the reader's right to erasure). JSON for fetch(), redirect for a plain form. */ public function deletePhoto(Request $request, Response $response, mysqli $db, int $id): Response { - if (($off = \App\Support\Features::requireOrRedirect('patron_cards', $response)) !== null) { + if (($off = \App\Support\Features::requireOrRedirect('patron_photos', $response)) !== null) { return $off; } $wantsJson = str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json'); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index f865af885..cc8d6ee53 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -84,11 +84,17 @@ public function index(Request $request, Response $response, mysqli $db): Respons // administrators like every CirculationSettingsController action, so // staff gets no data and the view renders no panel (the section is // absent from the settings catalogue for them too). - $circulationData = \App\Support\Permissions::mayConfigure() && \App\Support\Features::enabled('circulation_rules') + $circulationData = \App\Support\Permissions::mayConfigure() ? CirculationSettingsController::viewData($db) : null; // Optional modules (Settings → Modules): the saved switches. $moduleSettings = \App\Support\Features::snapshot(); + $legacyPenaltyImpact = ['count' => 0, 'cents' => 0]; + if (\App\Support\Permissions::mayConfigure() && \App\Services\PatronLedger::schemaReady($db)) { + $row = $db->query("SELECT COUNT(*) AS n, COALESCE(SUM(p.sanzione),0) AS amount FROM prestiti p WHERE p.sanzione > 0 AND NOT EXISTS (SELECT 1 FROM movimenti_lettore m WHERE m.chiave_idempotenza = CONCAT('penalty:',p.id))")->fetch_assoc(); + $legacyPenaltyImpact = ['count' => (int) ($row['n'] ?? 0), 'cents' => \App\Services\FineParameters::decimalToCents($row['amount'] ?? 0)]; + } + $attendanceRetentionDays = \App\Models\AttendanceRepository::retentionDays($repository); $contactMessages = $this->loadContactMessages($db); $cookieBannerTexts = $this->resolveCookieBannerTexts($repository); // Custom registration fields (issue #255) — edited from the email tab's @@ -125,6 +131,8 @@ public function index(Request $request, Response $response, mysqli $db): Respons 'loansSettings', 'circulationData', 'moduleSettings', + 'attendanceRetentionDays', + 'legacyPenaltyImpact', 'contactMessages', 'activeTab', 'db', @@ -1706,6 +1714,8 @@ private function resolveLoansSettings(SettingsRepository $repository): array 'app_timezone' => (string) \App\Support\ConfigStore::get('app.timezone', 'Europe/Rome'), // Plan §7: fines and debt block. Empty string = not set (disabled). 'daily_fine' => (string) ($repository->get('loans', 'daily_fine', '') ?? ''), + 'fine_grace_mode' => (string) $repository->get('loans', 'fine_grace_mode', 'threshold'), + 'card_renewal_base' => (string) $repository->get('loans', 'card_renewal_base', 'today'), 'fine_grace_days' => (int) ($repository->get('loans', 'fine_grace_days', '0') ?? 0), 'fine_max_per_loan' => (string) ($repository->get('loans', 'fine_max_per_loan', '') ?? ''), 'debt_block_threshold' => (string) ($repository->get('loans', 'debt_block_threshold', '') ?? ''), @@ -1810,8 +1820,18 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $repository->set('loans', 'fine_charged_email', $fineEmail ? '1' : '0'); // The three circulation modules of this tab (patron type rules, // closures calendar, fines): library policy, administrators only. + \App\Support\Features::preserveInherited($repository); + foreach (['fine_grace_mode' => ['threshold', 'deduct'], 'card_renewal_base' => ['today', 'expiry']] as $key => $allowed) { + if (isset($data[$key]) && is_string($data[$key]) && in_array($data[$key], $allowed, true)) { + $repository->set('loans', $key, $data[$key]); + } + } + $lossWasOn = \App\Support\Features::enabled('loss_penalties'); $calendarWasOn = \App\Support\Features::enabled('closures_calendar'); $this->saveFeatureSwitches($repository, $data, \App\Support\Features::LOANS_TAB); + if (!$lossWasOn && \App\Support\Features::enabled('loss_penalties')) { + $repository->set('loans', 'legacy_penalties_backfilled', '0'); + } // The calendar switched on applies its closures at once: a pending // pickup whose deadline now falls on a closed day moves to the // next open day, as when the calendar itself is saved. @@ -1856,7 +1876,13 @@ public function updateModules(Request $request, Response $response, mysqli $db): $repository = new SettingsRepository($db); $repository->ensureTables(); + \App\Support\Features::preserveInherited($repository); $this->saveFeatureSwitches($repository, $data, \App\Support\Features::MODULES_TAB); + $retention = filter_var($data['attendance_retention_days'] ?? null, FILTER_VALIDATE_INT); + if ($retention !== false && $retention >= 0) { + $days = min(\App\Models\AttendanceRepository::MAX_RETENTION_DAYS, $retention); + $repository->set('attendance', 'retention_days', (string) $days); + } $_SESSION['success_message'] = __('Moduli aggiornati correttamente.'); return $this->redirect($response, '/admin/settings?tab=modules'); @@ -1874,6 +1900,10 @@ private function saveFeatureSwitches(SettingsRepository $repository, array $data { foreach ($keys as $key) { $field = 'feature_' . $key; + // Old pages/API clients do not know the new controls: absence preserves them. + if ((isset(\App\Support\Features::INHERITED[$key]) || $key === 'attendance_retention') && !array_key_exists($field, $data)) { + continue; + } $on = isset($data[$field]) && is_scalar($data[$field]) && (string) $data[$field] === '1'; $repository->set('features', $key, $on ? '1' : '0'); } diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index a1c5a6bd2..c2cd0499e 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -813,6 +813,11 @@ public function details(Request $request, Response $response, mysqli $db, int $i // Effective circulation rules of this patron (patron type → globals). $circulation = new \App\Services\CirculationPolicy($db); $patronPolicy = $circulation->forType($circulation->patronTypeOf($id)); + $cardRenewalPreview = \App\Support\PatronTypeAssignment::renewalDate( + $patronPolicy->user->cardDurationDays ?? \App\Support\PatronTypeAssignment::DEFAULT_RENEWAL_DAYS, + $utente['data_scadenza_tessera'], + (string) (new \App\Models\SettingsRepository($db))->get('loans', 'card_renewal_base', 'today') + ); $patronTypeName = $patronPolicy->user->patronTypeName; $patronTypeIsOwn = isset($utente['tipologia_id']); // "Movimenti" section (plan §7): balance, ledger rows, fines accruing on diff --git a/app/Middleware/FeatureGateMiddleware.php b/app/Middleware/FeatureGateMiddleware.php index 8390e5a36..329a73c1f 100644 --- a/app/Middleware/FeatureGateMiddleware.php +++ b/app/Middleware/FeatureGateMiddleware.php @@ -22,7 +22,7 @@ */ final class FeatureGateMiddleware implements MiddlewareInterface { - public function __construct(private string $feature) + public function __construct(private string $feature, private bool $configuration = false) { // Fail at boot on a typo, not at the first request. if (!in_array($feature, Features::keys(), true)) { @@ -37,7 +37,7 @@ public function feature(): string public function process(Request $request, RequestHandler $handler): Response { - if (Features::enabled($this->feature)) { + if (Features::enabled($this->feature) || ($this->configuration && \App\Support\Permissions::mayConfigure())) { return $handler->handle($request); } diff --git a/app/Routes/web.php b/app/Routes/web.php index 88c607eba..6c9be6085 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -722,12 +722,12 @@ // the labels one; the GET form of the address opens that tab. $app->get('/admin/settings/cards', function ($request, $response) { return $response->withHeader('Location', url('/admin/settings?tab=cards'))->withStatus(302); - })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards', configuration: true))->add(new AdminAuthMiddleware()); $app->post('/admin/settings/cards', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); $controller = new SettingsController(); return $controller->updateCards($request, $response, $db); - })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/sharing', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); @@ -788,42 +788,42 @@ // Circulation policy: patron types and rules matrix (Settings → Circulation) $app->get('/admin/settings/circulation', function ($request, $response) { return (new \App\Controllers\CirculationSettingsController())->index($request, $response); - })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules', configuration: true))->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->createType($request, $response, $db); - })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->get('/admin/settings/circulation/types/{id:\d+}', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->editForm($request, $response, $db, (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules', configuration: true))->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types/{id:\d+}', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->updateType($request, $response, $db, (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types/{id:\d+}/delete', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->deleteType($request, $response, $db, (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types/{id:\d+}/default', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->setDefault($request, $response, $db, (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types/{id:\d+}/move', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->moveType($request, $response, $db, (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/rules', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->saveRules($request, $response, $db); - })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // API Settings Routes $app->post('/admin/settings/api/toggle', function ($request, $response) use ($app) { @@ -855,42 +855,42 @@ $app->get('/admin/calendar', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->index($request, $response, $db); - })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar', configuration: true))->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/weekdays', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->saveWeekdays($request, $response, $db); - })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/closures', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->store($request, $response, $db); - })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/closures/{id:\d+}/update', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->update($request, $response, $db, (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/closures/{id:\d+}/delete', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->delete($request, $response, $db, (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/preset', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->importPreset($request, $response, $db); - })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/yearly', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->saveYearly($request, $response, $db); - })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/import-ics', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->importIcs($request, $response, $db); - })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar', configuration: true))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // Language Management Routes (Admin Only) $app->get('/admin/languages', function ($request, $response) use ($app) { @@ -1413,15 +1413,15 @@ $app->get('/admin/users/{id:\d+}/photo', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\PatronCardsController(); return $controller->photo($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_photos'))->add(new AdminAuthMiddleware()); $app->post('/admin/users/{id:\d+}/photo', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\PatronCardsController(); return $controller->uploadPhoto($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_photos'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->post('/admin/users/{id:\d+}/photo/delete', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\PatronCardsController(); return $controller->deletePhoto($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_photos'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->get('/admin/users/import', function ($request, $response) use ($app) { $controller = new \App\Controllers\UserImportController(); return $controller->page($request, $response, $app->getContainer()->get('db')); @@ -1857,7 +1857,7 @@ $controller = new \App\Controllers\CopyController(); $db = $app->getContainer()->get('db'); return $controller->toggleConsultation($request, $response, $db, (int) $args['id']); - })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('consultation_only'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/books/copies/{id:\d+}/delete', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\CopyController(); diff --git a/app/Services/CirculationPolicy.php b/app/Services/CirculationPolicy.php index 457383822..cdcb22a53 100644 --- a/app/Services/CirculationPolicy.php +++ b/app/Services/CirculationPolicy.php @@ -60,7 +60,7 @@ final class CirculationPolicy private SettingsRepository $settings; - public function __construct(private mysqli $db, ?SettingsRepository $settings = null) + public function __construct(private mysqli $db, ?SettingsRepository $settings = null, private bool $configuration = false) { $this->settings = $settings ?? new SettingsRepository($db); } @@ -368,7 +368,7 @@ public function materialRefusedReservationUserIds(int $libroId): array public function debtBlockError(int $userId): ?string { // No ledger table (code deployed before its migration): no balance, no block. - if (!PatronLedger::active($this->db)) { + if (!PatronLedger::active($this->db) || !\App\Support\Features::enabled('debt_block')) { return null; } $threshold = FineParameters::moneyToCents($this->forUser($userId)->debtBlockThreshold); @@ -626,7 +626,7 @@ public function mediaOf(int $libroId): string /** Whether the copy is kept for in-house consultation only. */ public function isConsultationCopy(int $copiaId): bool { - if (!$this->rulesActive()) { + if (!\App\Support\Features::enabled('consultation_only') || !$this->schemaReady()) { return false; } $stmt = $this->db->prepare('SELECT solo_consultazione FROM copie WHERE id = ?'); @@ -644,7 +644,7 @@ public function isConsultationCopy(int $copiaId): bool */ public function onlyConsultationCopies(int $libroId): bool { - if (!$this->rulesActive()) { + if (!\App\Support\Features::enabled('consultation_only') || !$this->schemaReady()) { return false; } $stmt = $this->db->prepare( @@ -668,23 +668,23 @@ public function onlyConsultationCopies(int $libroId): bool */ public function lendableCopySql(string $alias = 'c'): string { - if (!$this->rulesActive()) { + if (!\App\Support\Features::enabled('consultation_only') || !$this->schemaReady()) { return '1 = 1'; } return ($alias !== '' ? $alias . '.' : '') . 'solo_consultazione = 0'; } /** - * Whether patron types, the rules matrix and consultation-only copies + * Whether patron types and the rules matrix * apply: their tables exist AND the module is on (Settings -> Loans, * features.circulation_rules; also off in catalogue mode). Off, every * rule is the global layer, exactly as before 0.9.0, on every channel * at once (web, Mobile API, NCIP, maintenance), since they all resolve - * through this class. The data stays: switching back on restores it. + * through this class. Consultation uses its own switch. The data stays: switching back on restores it. */ public function rulesActive(): bool { - return \App\Support\Features::enabled('circulation_rules') && $this->schemaReady(); + return ($this->configuration || \App\Support\Features::enabled('circulation_rules')) && $this->schemaReady(); } /** diff --git a/app/Services/FineCalculator.php b/app/Services/FineCalculator.php index 980d7c20e..e3e814cbd 100644 --- a/app/Services/FineCalculator.php +++ b/app/Services/FineCalculator.php @@ -15,7 +15,7 @@ * of that branch only does not count either. * - the grace period is a THRESHOLD, as in SLiMS: up to graceDays late there is * no fine; once a loan is later than that, every late day is charged from - * day one. + * day one. In deduct mode the grace days are always subtracted instead. * - the optional cap per loan limits the total; * - the total never exceeds PatronLedger::MAX_AMOUNT_CENTS, and the day walk * stops at MAX_DAYS with or without a calendar. @@ -75,7 +75,8 @@ public function lateFineCents(FineParameters $params, string $dueDate, string $r if ($days <= $params->graceDays) { return 0; } - $amount = $days * $params->dailyFineCents; + $chargedDays = $params->graceMode === 'deduct' ? $days - $params->graceDays : $days; + $amount = $chargedDays * $params->dailyFineCents; if ($params->maxFinePerLoanCents !== null) { $amount = min($amount, $params->maxFinePerLoanCents); } diff --git a/app/Services/FineParameters.php b/app/Services/FineParameters.php index d1b2fabc8..938e06500 100644 --- a/app/Services/FineParameters.php +++ b/app/Services/FineParameters.php @@ -44,13 +44,14 @@ public function __construct( public readonly ?int $maxFinePerLoanCents, public readonly ?int $debtBlockThresholdCents, public readonly string $currency, + public readonly string $graceMode = 'threshold', ) { } /** Parameters that govern the late fine of one loan (its patron's type × its material). */ public static function forLoan(mysqli $db, int $loanId, ?CirculationPolicy $policy = null): self { - return self::fromPolicy(($policy ?? new CirculationPolicy($db))->forExistingLoan($loanId)); + return self::fromPolicy(($policy ?? new CirculationPolicy($db))->forExistingLoan($loanId), graceMode: (string) (new \App\Models\SettingsRepository($db))->get('loans', 'fine_grace_mode', 'threshold')); } /** @@ -60,18 +61,18 @@ public static function forLoan(mysqli $db, int $loanId, ?CirculationPolicy $poli public static function forUser(mysqli $db, int $userId, ?CirculationPolicy $policy = null): self { $policy ??= new CirculationPolicy($db); - return self::fromPolicy($policy->forType($policy->patronTypeOf($userId))); + return self::fromPolicy($policy->forType($policy->patronTypeOf($userId)), graceMode: (string) (new \App\Models\SettingsRepository($db))->get('loans', 'fine_grace_mode', 'threshold')); } /** The global settings only (no patron type, no rule). */ public static function global(mysqli $db): self { $globals = (new CirculationPolicy($db))->globalLayer(); - return self::fromPolicy(PolicyResolver::resolve([], $globals, PolicyResolver::resolveUser(null, $globals))); + return self::fromPolicy(PolicyResolver::resolve([], $globals, PolicyResolver::resolveUser(null, $globals)), graceMode: (string) (new \App\Models\SettingsRepository($db))->get('loans', 'fine_grace_mode', 'threshold')); } /** Cents view of a resolved circulation policy. */ - public static function fromPolicy(LoanPolicy $policy, ?string $currency = null): self + public static function fromPolicy(LoanPolicy $policy, ?string $currency = null, string $graceMode = 'threshold'): self { $daily = self::moneyToCents($policy->dailyFine) ?? 0; $cap = self::moneyToCents($policy->maxFinePerLoan); @@ -83,6 +84,7 @@ public static function fromPolicy(LoanPolicy $policy, ?string $currency = null): $cap !== null && $cap > 0 ? $cap : null, $threshold !== null && $threshold > 0 ? $threshold : null, $currency ?? self::currency(), + $graceMode === 'deduct' ? 'deduct' : 'threshold', ); } @@ -110,6 +112,7 @@ public static function fromSettings(array $settings, string $currency = 'EUR'): $cap !== null && $cap > 0 ? $cap : null, $threshold !== null && $threshold > 0 ? $threshold : null, $currency, + ($settings['fine_grace_mode'] ?? '') === 'deduct' ? 'deduct' : 'threshold', ); } diff --git a/app/Services/PatronLedger.php b/app/Services/PatronLedger.php index e7c422616..6c196a88f 100644 --- a/app/Services/PatronLedger.php +++ b/app/Services/PatronLedger.php @@ -217,7 +217,7 @@ public function movement(int $movementId): ?array */ public function chargeLateReturn(int $loanId, ?int $staffId = null): int { - if (!self::active($this->db)) { + if (!self::active($this->db) || !\App\Support\Features::enabled('automatic_fines')) { return 0; } $loan = $this->loanRow($loanId); @@ -254,7 +254,7 @@ public function chargeLossPenalty(int $loanId, ?int $staffId = null): int // backfill mirrors it (same key) once the module is back on; its // marker is re-armed here, because a library that already had fines // on has the scan recorded as done. Same transaction as the close. - if (!self::active($this->db)) { + if (!self::active($this->db) || !\App\Support\Features::enabled('loss_penalties')) { $loan = $this->loanRow($loanId); if ($loan !== null && FineParameters::decimalToCents($loan['sanzione'] ?? 0) > 0) { (new \App\Models\SettingsRepository($this->db))->set('loans', 'legacy_penalties_backfilled', '0'); @@ -407,7 +407,7 @@ public function manualCharge(int $userId, int $cents, string $description, ?int */ public function previewLateFine(int $loanId, ?string $returnDate = null): array { - if (!self::active($this->db)) { + if (!self::active($this->db) || !\App\Support\Features::enabled('automatic_fines')) { return ['days' => 0, 'cents' => 0, 'enabled' => false]; } $loan = $this->loanRow($loanId); @@ -432,7 +432,7 @@ public function previewLateFine(int $loanId, ?string $returnDate = null): array */ public function accruedFines(int $userId): array { - if (!self::active($this->db)) { + if (!self::active($this->db) || !\App\Support\Features::enabled('automatic_fines')) { return []; } $today = DateHelper::today(); @@ -483,7 +483,7 @@ public function backfillLegacyPenalties(int $batch = 500, int $maxBatches = 1000 { // null also while the fines module is off: the scan is not recorded // as done and runs when the module is switched back on. - if (!self::active($this->db)) { + if (!self::active($this->db) || !\App\Support\Features::enabled('loss_penalties')) { return null; } $written = 0; diff --git a/app/Support/ConfigStore.php b/app/Support/ConfigStore.php index ef99b6f85..6fcb041b6 100644 --- a/app/Support/ConfigStore.php +++ b/app/Support/ConfigStore.php @@ -159,6 +159,10 @@ public static function all(): array 'features' => array_map(static fn (string $v): bool => $v === '1', Features::DEFAULTS), ]; + // NULL marks an absent granular preference, distinct from an explicit OFF. + foreach (Features::INHERITED as $key => $parent) { + $defaults['features'][$key] = null; + } $localizedDefaults = self::getLocaleDefaultTexts(); if (!empty($localizedDefaults)) { $defaults = self::mergeRecursiveDistinct($defaults, $localizedDefaults); diff --git a/app/Support/Features.php b/app/Support/Features.php index d6883a6e5..23acde353 100644 --- a/app/Support/Features.php +++ b/app/Support/Features.php @@ -8,18 +8,20 @@ /** * Optional 0.9.0 modules, each with a `features.` row in system_settings. * - * DEFAULTS is the value an install without the row behaves with: the upgrade - * default, also what the migration seeds and the code fallback. FRESH is what - * a new installation starts with (installer/database/data_*.sql). Both lists + * DEFAULTS provides the static upgrade defaults. INHERITED controls instead + * preserve their former parent when the row is absent. FRESH is what + * a new installation starts with (installer/database/data_*.sql). Split controls + * inherit their former parent until the migration or a settings save freezes + * that choice; explicit OFF always wins. Both lists * are checked against the seeds, the migration and ConfigStore by * tests/feature-toggles-registry.unit.php. * - * The four circulation modules are also off while catalogue mode is on: that + * Circulation modules and their controls are also off in catalogue mode: that * switch stays the master switch of circulation. */ final class Features { - /** @var array upgrade default = code fallback */ + /** @var array Static defaults; INHERITED controls preserve the former parent. */ public const DEFAULTS = [ 'stocktake' => '0', 'marc' => '1', @@ -32,6 +34,13 @@ final class Features 'circulation_rules' => '1', 'closures_calendar' => '1', 'fines' => '0', + 'patron_photos' => '1', + 'consultation_only' => '1', + 'automatic_card_expiry' => '1', + 'automatic_fines' => '0', + 'debt_block' => '0', + 'loss_penalties' => '0', + 'attendance_retention' => '0', ]; /** @var array fresh-install default (data_*.sql seeds) */ @@ -47,16 +56,36 @@ final class Features 'circulation_rules' => '1', 'closures_calendar' => '1', 'fines' => '0', + 'patron_photos' => '1', + 'consultation_only' => '1', + 'automatic_card_expiry' => '1', + 'automatic_fines' => '0', + 'debt_block' => '0', + 'loss_penalties' => '0', + 'attendance_retention' => '0', ]; /** Modules that belong to circulation: off whenever catalogue mode is on. */ - public const CIRCULATION = ['circulation_rules', 'closures_calendar', 'fines', 'visitor_log']; + public const CIRCULATION = ['circulation_rules', 'closures_calendar', 'fines', 'visitor_log', 'consultation_only', 'automatic_card_expiry', 'automatic_fines', 'debt_block', 'loss_penalties']; - /** Modules saved from Settings → Modules (the other three live in the Loans tab). */ - public const MODULES_TAB = ['stocktake', 'marc', 'catalogue_cards', 'suppliers', 'rda_types', 'patron_cards', 'user_import', 'visitor_log']; + /** Controls saved from Settings → Modules. */ + public const MODULES_TAB = ['stocktake', 'marc', 'catalogue_cards', 'suppliers', 'rda_types', 'patron_cards', 'user_import', 'visitor_log', 'patron_photos', 'attendance_retention']; /** Modules saved from Settings → Loans. */ - public const LOANS_TAB = ['circulation_rules', 'closures_calendar', 'fines']; + public const LOANS_TAB = ['circulation_rules', 'closures_calendar', 'fines', 'consultation_only', 'automatic_card_expiry', 'automatic_fines', 'debt_block', 'loss_penalties']; + + /** Missing granular choices reproduce the old parent setting until saved. */ + public const INHERITED = [ + 'patron_photos' => 'patron_cards', + 'consultation_only' => 'circulation_rules', + 'automatic_card_expiry' => 'circulation_rules', + 'automatic_fines' => 'fines', + 'debt_block' => 'fines', + 'loss_penalties' => 'fines', + ]; + + /** Dependent automations require their parent module as well as their own switch. */ + private const PARENTS = ['automatic_fines' => 'fines', 'debt_block' => 'fines', 'loss_penalties' => 'fines', 'automatic_card_expiry' => 'circulation_rules']; /** @var array test seam: forced values, ahead of the stored setting */ private static array $overrides = []; @@ -82,6 +111,9 @@ public static function enabled(string $feature): bool if (in_array($feature, self::CIRCULATION, true) && ConfigStore::isCatalogueMode()) { return false; } + if (isset(self::PARENTS[$feature]) && !self::enabled(self::PARENTS[$feature])) { + return false; + } return self::stored($feature); } @@ -95,12 +127,26 @@ public static function stored(string $feature): bool return self::$overrides[$feature]; } $value = ConfigStore::get('features.' . $feature, self::DEFAULTS[$feature] === '1'); + if ($value === null && isset(self::INHERITED[$feature])) { + return self::stored(self::INHERITED[$feature]); + } if (is_bool($value)) { return $value; } return in_array(strtolower(trim((string) $value)), ['1', 'true', 'yes', 'on'], true); } + /** Freeze inherited choices BEFORE changing their former parent (also for old forms). */ + public static function preserveInherited(\App\Models\SettingsRepository $repository): void + { + $values = self::snapshot(); + foreach (self::INHERITED as $key => $parent) { + if ($repository->get('features', $key) === null) { + $repository->set('features', $key, $values[$key] ? '1' : '0'); + } + } + } + /** * The saved switches keyed by feature (Settings → Modules, Loans tab). * @@ -137,12 +183,19 @@ public static function label(string $feature): string 'catalogue_cards' => __('Schede catalografiche'), 'suppliers' => __('Fornitori'), 'rda_types' => __('Tipi RDA'), - 'patron_cards' => __('Tessere e foto dei lettori'), + 'patron_cards' => __('Stampa delle tessere'), 'user_import' => __('Importazione utenti da CSV'), 'visitor_log' => __('Registro presenze'), 'circulation_rules' => __('Regole per tipologia di lettore'), 'closures_calendar' => __('Calendario chiusure'), - 'fines' => __('Multe e registro movimenti'), + 'fines' => __('Registro movimenti'), + 'patron_photos' => __('Foto dei lettori'), + 'consultation_only' => __('Copie in sola consultazione'), + 'automatic_card_expiry' => __('Validità automatica delle tessere'), + 'automatic_fines' => __('Multe automatiche per ritardo'), + 'debt_block' => __('Blocco dei prestiti per debito'), + 'loss_penalties' => __('Riporta penalità per perdita e danno nel registro'), + 'attendance_retention' => __('Conservazione a registro presenze sospeso'), default => throw new \InvalidArgumentException('Unknown feature: ' . $feature), }; } @@ -156,12 +209,19 @@ public static function help(string $feature): string 'catalogue_cards' => __('Stampa della scheda catalografica di un libro o di una selezione.'), 'suppliers' => __('Anagrafica fornitori e campo "Fornitore" nella scheda libro e nell\'import CSV.'), 'rda_types' => __('Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.'), - 'patron_cards' => __('Stampa delle tessere in PDF, foto dalla webcam e scheda Tessere nelle impostazioni.'), + 'patron_cards' => __('Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.'), 'user_import' => __('Caricamento massivo dei lettori con invito via email.'), 'visitor_log' => __('Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.'), - 'circulation_rules' => __('Tipologie di lettore, matrice delle regole per tipo di materiale, copie in sola consultazione, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.'), + 'circulation_rules' => __('Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.'), 'closures_calendar' => __('Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.'), - 'fines' => __('Multe per ritardo, addebiti, pagamenti, ricevute e blocco per debito. Spente, nessun importo viene addebitato e i movimenti non sono visibili.'), + 'fines' => __('Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.'), + 'patron_photos' => __('Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.'), + 'consultation_only' => __('Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.'), + 'automatic_card_expiry' => __('Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.'), + 'automatic_fines' => __('Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.'), + 'debt_block' => __('Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.'), + 'loss_penalties' => __('Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.'), + 'attendance_retention' => __('Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.'), default => throw new \InvalidArgumentException('Unknown feature: ' . $feature), }; } diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index 1d0520485..bbf89c713 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -107,13 +107,13 @@ public function __construct(array $closedWeekdays = [], array $closures = [], ar } /** The calendar stored in the database (settings + closures table). */ - public static function fromDatabase(\mysqli $db): self + public static function fromDatabase(\mysqli $db, bool $configuration = false): self { // Closures calendar module off (Settings -> Loans; also off in // catalogue mode): an empty calendar, so due dates, pickup deadlines // and fine days are those of a library open every day, as before // 0.9.0. The stored closures stay for when it is switched back on. - if (!Features::enabled('closures_calendar')) { + if (!$configuration && !Features::enabled('closures_calendar')) { return new self([], []); } $holidayRules = Holidays\HolidayPresets::calendarRules($db); diff --git a/app/Support/LoanEligibility.php b/app/Support/LoanEligibility.php index 8e28f53a2..e1ebc2b05 100644 --- a/app/Support/LoanEligibility.php +++ b/app/Support/LoanEligibility.php @@ -176,7 +176,7 @@ public static function eligibleUserWhere(string $alias, \mysqli $db): string */ public static function debtBlockSql(string $alias, \mysqli $db): string { - if (!\App\Services\PatronLedger::active($db)) { + if (!\App\Services\PatronLedger::active($db) || !Features::enabled('debt_block')) { return '1 = 1'; } $threshold = self::debtThresholdSql($alias, $db); diff --git a/app/Support/MaintenanceService.php b/app/Support/MaintenanceService.php index fea3a54ba..e346d38c2 100644 --- a/app/Support/MaintenanceService.php +++ b/app/Support/MaintenanceService.php @@ -100,6 +100,18 @@ public function runIfNeeded(int $cooldownMinutes = 60): array return $this->runAll(); } + /** Apply retention independently of new visit collection when explicitly enabled. */ + public function purgeAttendance(): int + { + if (!Features::enabled('visitor_log') && !Features::enabled('attendance_retention')) { + return 0; + } + $attendance = new \App\Models\AttendanceRepository($this->db); + return $attendance->isAvailable() + ? $attendance->purge(\App\Models\AttendanceRepository::retentionDays(new \App\Models\SettingsRepository($this->db))) + : 0; + } + /** * Run all maintenance tasks immediately * @@ -357,12 +369,7 @@ private function runAllLocked(): array // configured number of days are deleted. 0 days = keep forever; with // no setting the default retention applies. try { - $attendance = new \App\Models\AttendanceRepository($this->db); - // Visitor log module off (Settings -> Modules): nothing is purged - // either, the rows wait for the module to be switched back on. - $results['attendance_purged'] = Features::enabled('visitor_log') && $attendance->isAvailable() - ? $attendance->purge(\App\Models\AttendanceRepository::retentionDays(new \App\Models\SettingsRepository($this->db))) - : 0; + $results['attendance_purged'] = $this->purgeAttendance(); } catch (\Throwable $e) { $results['errors'][] = 'purgeAttendance: ' . $e->getMessage(); SecureLogger::error(__('MaintenanceService errore pulizia presenze'), ['error' => $e->getMessage()]); diff --git a/app/Support/PatronCardPdf.php b/app/Support/PatronCardPdf.php index 5b923076a..cf61a215d 100644 --- a/app/Support/PatronCardPdf.php +++ b/app/Support/PatronCardPdf.php @@ -179,7 +179,7 @@ private function frontClassic(TCPDF $pdf, float $x, float $y, float $w, float $h $pad = 3.0; $top = $y + $band + 3; $textX = $x + $pad; - if ($this->settings['show_photo']) { + if (($this->settings['show_photo'] && Features::enabled('patron_photos'))) { $this->photo($pdf, $x + $pad, $top, 22, 28, $card); $textX = $x + $pad + 22 + 3; } @@ -247,7 +247,7 @@ private function frontPortrait(TCPDF $pdf, float $x, float $y, float $w, float $ $this->band($pdf, $x, $y, $w, $band); $pad = 3.0; $cursor = $y + $band + 3; - if ($this->settings['show_photo']) { + if (($this->settings['show_photo'] && Features::enabled('patron_photos'))) { $this->photo($pdf, $x + ($w - 26) / 2, $cursor, 26, 32, $card); $cursor += 34; } else { diff --git a/app/Support/PatronTypeAssignment.php b/app/Support/PatronTypeAssignment.php index d6b6b383a..1daa5e11d 100644 --- a/app/Support/PatronTypeAssignment.php +++ b/app/Support/PatronTypeAssignment.php @@ -73,6 +73,9 @@ public static function of(mysqli $db, int $userId): ?int */ public static function automaticCardExpiry(mysqli $db, ?int $typeId): ?string { + if (!Features::enabled('automatic_card_expiry')) { + return null; + } $days = (new CirculationPolicy($db))->forType($typeId)->user->cardDurationDays; return $days !== null ? self::todayPlus($days) : null; } @@ -154,7 +157,8 @@ public static function renewCard(mysqli $db, int $userId): ?string } $policy = new CirculationPolicy($db); $days = $policy->forUser($userId)->cardDurationDays ?? self::DEFAULT_RENEWAL_DAYS; - $expiry = self::todayPlus($days); + $expiry = self::renewalDate($days, $user['data_scadenza_tessera'], + (string) (new \App\Models\SettingsRepository($db))->get('loans', 'card_renewal_base', 'today')); $state = $user['stato'] === 'scaduto' ? 'attivo' : (string) $user['stato']; $flagSql = self::hasExpiryWarningFlag($db) ? ', card_expiry_warning_sent_for = NULL' : ''; $update = $db->prepare('UPDATE utenti SET data_scadenza_tessera = ?, stato = ?' . $flagSql . ' WHERE id = ?'); @@ -173,12 +177,26 @@ public static function renewCard(mysqli $db, int $userId): ?string return $expiry; } + /** Shared by the confirmation preview and the actual renewal. */ + public static function renewalDate(int $days, ?string $currentExpiry, string $baseMode = 'today'): string + { + $base = DateHelper::today(); + if ($baseMode === 'expiry' && $currentExpiry !== null + && DateHelper::isISODateFormat($currentExpiry) && $currentExpiry > $base) { + $base = $currentExpiry; + } + return (new \DateTimeImmutable($base))->modify('+' . max(0, $days) . ' days')->format('Y-m-d'); + } + /** * Apply the patron type's card duration on registration approval: only * when the type sets one (otherwise the stored expiry is kept). */ public static function applyCardDurationOnApproval(mysqli $db, int $userId): void { + if (!Features::enabled('automatic_card_expiry')) { + return; + } $policy = new CirculationPolicy($db); $days = $policy->forUser($userId)->cardDurationDays; if ($days === null) { diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php index acf638c85..e8ccc1ab6 100644 --- a/app/Views/libri/scheda_libro.php +++ b/app/Views/libri/scheda_libro.php @@ -1182,7 +1182,7 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm
      - diff --git a/tests/holidays-world.spec.js b/tests/holidays-world.spec.js index d443965ff..8c0829d58 100644 --- a/tests/holidays-world.spec.js +++ b/tests/holidays-world.spec.js @@ -292,12 +292,18 @@ test.describe('Holidays for every country and calendar', () => { setSetting('calendar', 'holiday_country', 'IT'); setSetting('calendar', 'holiday_region', ''); setSetting('calendar', 'holiday_rules', '["IT:new_year","IT:zz_vanished_rule","IT:christmas"]'); + db(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale, sede_id, regola_festivita, attiva) VALUES ('2000-03-03', '2000-03-03', ${S(TAG + ' vanished')}, 1, NULL, 'IT:zz_vanished_rule', 1)`); + const vanishedId = db(`SELECT id FROM calendario_chiusure WHERE regola_festivita = 'IT:zz_vanished_rule' ORDER BY id DESC LIMIT 1`); try { await page.goto(`${BASE}/admin/calendar`); const notice = page.locator('[data-holiday-missing]'); await expect(notice).toBeVisible(); await expect(notice).toContainText('zz_vanished_rule'); await expect(notice).not.toContainText('new_year'); + // Its stored row is listed as suspended, as the notice says, not as a live holiday. + const vanishedRow = page.locator(`tr[data-closure-id="${vanishedId}"]`); + await expect(vanishedRow.locator('[data-badge="holiday"]')).toHaveCount(1); + await expect(vanishedRow.locator('[data-badge="inactive"]')).toHaveCount(1); // The known choices are still ticked; the vanished one is not a row. await expect(page.locator('input[data-holiday-rule][value="IT:christmas"]')).toBeChecked(); await expect(page.locator('[data-holiday-row="IT:zz_vanished_rule"]')).toHaveCount(0); @@ -310,6 +316,7 @@ test.describe('Holidays for every country and calendar', () => { await expect(page.locator('[data-holiday-missing]')).toHaveCount(0); expect(db("SELECT setting_value FROM system_settings WHERE category='calendar' AND setting_key='holiday_rules'")).not.toContain('zz_vanished_rule'); } finally { + db(`DELETE FROM calendario_chiusure WHERE regola_festivita = 'IT:zz_vanished_rule'`); ['holiday_country', 'holiday_region', 'holiday_rules'].forEach((k, i) => setSetting('calendar', k, before[i])); } }); From 412826f93f5ac2416d1e9d3eacf83131f044fc44 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:10:56 +0100 Subject: [PATCH 377/398] Copy cataloguing answers a retried import instead of running it twice The page now sends an import token it keeps until an import succeeds. The same request sent again after its answer was lost (same token, ids, mode and covers) gets the first answer back from the session, so an overwrite is not written twice and the history gets one entry. A deliberate re-import carries a new token. --- .../plugins/z39-server/Z39ServerPlugin.php | 5 ++-- .../z39-server/classes/CopyCataloging.php | 29 ++++++++++++++++++- .../z39-server/views/copy-cataloging.php | 10 ++++++- tests/copy-cataloging-limits.unit.php | 19 ++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/storage/plugins/z39-server/Z39ServerPlugin.php b/storage/plugins/z39-server/Z39ServerPlugin.php index d2a3e732e..74dd513c1 100644 --- a/storage/plugins/z39-server/Z39ServerPlugin.php +++ b/storage/plugins/z39-server/Z39ServerPlugin.php @@ -814,7 +814,7 @@ public function copyCatalogingSearchAction(ServerRequestInterface $request, Resp return $this->jsonResponse($response, ['success' => true] + $result); } - /** POST /admin/books/copy-cataloging/import — JSON {ids, duplicates, covers} */ + /** POST /admin/books/copy-cataloging/import — JSON {ids, duplicates, covers, import_token} */ public function copyCatalogingImportAction(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { @ini_set('display_errors', '0'); @@ -828,7 +828,8 @@ public function copyCatalogingImportAction(ServerRequestInterface $request, Resp $result = $this->copyCataloging()->import( array_slice($ids, 0, 100), (string) ($payload['duplicates'] ?? 'skip'), - !empty($payload['covers']) + !empty($payload['covers']), + substr((string) preg_replace('/[^A-Za-z0-9-]/', '', (string) ($payload['import_token'] ?? '')), 0, 64) ); return $this->jsonResponse($response, ['success' => true] + $result); } diff --git a/storage/plugins/z39-server/classes/CopyCataloging.php b/storage/plugins/z39-server/classes/CopyCataloging.php index 5c924a1a9..f0a364277 100644 --- a/storage/plugins/z39-server/classes/CopyCataloging.php +++ b/storage/plugins/z39-server/classes/CopyCataloging.php @@ -23,6 +23,8 @@ final class CopyCataloging { private const SESSION_KEY = 'copy_cataloging_results'; + /** The last import's token, request fingerprint and answer (replay of a lost response). */ + private const LAST_IMPORT_KEY = 'copy_cataloging_last_import'; private const MAX_RESULTS = 100; /** * Largest total of book data kept in the session for one search (JSON @@ -232,10 +234,35 @@ public function search(array $serverKeys, string $index, string $term, int $max) /** * Import the chosen results of the last search. * + * $token is the page's import token: the page keeps it until an import + * succeeds, so the same request sent again after its answer was lost + * (same token, same ids, mode and covers) gets the first answer back + * instead of writing the records again and adding a second history + * entry. A deliberate re-import comes with a new token. + * + * @param list $ids + * @return array{imported: int, updated: int, skipped: int, failed: int, covers: int, items: list>, import_id: ?string} + */ + public function import(array $ids, string $mode, bool $covers, string $token = ''): array + { + $fingerprint = hash('sha256', (string) json_encode([array_values(array_unique($ids)), $mode, $covers])); + $last = $_SESSION[self::LAST_IMPORT_KEY] ?? null; + if ($token !== '' && is_array($last) && is_array($last['result'] ?? null) + && hash_equals((string) ($last['token'] ?? ''), $token) && hash_equals((string) ($last['fingerprint'] ?? ''), $fingerprint)) { + return $last['result']; + } + $result = $this->runImport($ids, $mode, $covers); + if ($token !== '') { + $_SESSION[self::LAST_IMPORT_KEY] = ['token' => $token, 'fingerprint' => $fingerprint, 'result' => $result]; + } + return $result; + } + + /** * @param list $ids * @return array{imported: int, updated: int, skipped: int, failed: int, covers: int, items: list>, import_id: ?string} */ - public function import(array $ids, string $mode, bool $covers): array + private function runImport(array $ids, string $mode, bool $covers): array { $stored = is_array($_SESSION[self::SESSION_KEY] ?? null) ? $_SESSION[self::SESSION_KEY] : []; $importer = new MarcBookImporter($this->db, in_array($mode, MarcBookImporter::MODES, true) ? $mode : MarcBookImporter::MODE_SKIP, $covers); diff --git a/storage/plugins/z39-server/views/copy-cataloging.php b/storage/plugins/z39-server/views/copy-cataloging.php index 0774b6f88..14180900d 100644 --- a/storage/plugins/z39-server/views/copy-cataloging.php +++ b/storage/plugins/z39-server/views/copy-cataloging.php @@ -212,6 +212,12 @@ const importBtn = document.getElementById('copyImportBtn'); const rowsEl = document.getElementById('copy-result-rows'); + // One token per import: kept until an import succeeds, so the same click + // sent again after a lost answer is recognised by the server as a retry. + const newImportToken = () => (window.crypto && typeof window.crypto.randomUUID === 'function') + ? window.crypto.randomUUID() + : Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); + let importToken = newImportToken(); const selectedIds = () => Array.from(rowsEl.querySelectorAll('input[data-result-id]:checked')).map((c) => c.dataset.resultId); const refreshSelection = () => { const count = selectedIds().length; @@ -294,7 +300,8 @@ function renderResults(data) { body: JSON.stringify({ ids, duplicates: (document.querySelector('input[name="copy_duplicates"]:checked') || {}).value || 'skip', - covers: document.getElementById('copy_covers').checked + covers: document.getElementById('copy_covers').checked, + import_token: importToken }) }) .then((r) => r.json()) @@ -302,6 +309,7 @@ function renderResults(data) { importBtn.innerHTML = importBtn.dataset.label; refreshSelection(); if (!data.success) { window.SwalApp.error(T.error, data.error || T.error); return; } + importToken = newImportToken(); let summary = fmt(T.summary, data.imported, data.updated, data.skipped, data.failed); if (data.covers > 0) summary += ', ' + fmt(T.covers, data.covers); document.getElementById('copy-import-summary').textContent = summary; diff --git a/tests/copy-cataloging-limits.unit.php b/tests/copy-cataloging-limits.unit.php index 002bb990a..e5215f56e 100644 --- a/tests/copy-cataloging-limits.unit.php +++ b/tests/copy-cataloging-limits.unit.php @@ -229,6 +229,25 @@ public function searchServer(array $server, string $cql, int $max = 10): array $delStmt->close(); } $check($imp['failed'] === 1 && $logged === 1, '15 the history counts an id sent three times once, as the import handles it'); + + echo "Import replay\n"; + // The same import sent again after its answer was lost (same token, same + // ids) gets the first answer back: no second run, no second history row. + $_SESSION['copy_cataloging_results'] = []; + $token = 'zz-token-' . bin2hex(random_bytes(4)); + $firstImport = $ccSbn->import(['zz-gone-a'], 'overwrite', false, $token); + $replayed = $ccSbn->import(['zz-gone-a'], 'overwrite', false, $token); + $otherIds = $ccSbn->import(['zz-gone-b'], 'overwrite', false, $token); + $fresh = $ccSbn->import(['zz-gone-a'], 'overwrite', false, $token . '-new'); + $replayIds = array_values(array_filter([$firstImport['import_id'] ?? null, $replayed['import_id'] ?? null, $otherIds['import_id'] ?? null, $fresh['import_id'] ?? null])); + $check($firstImport['import_id'] !== null && $replayed === $firstImport, '16 a retry with the same import token returns the first answer instead of importing again'); + $check(($otherIds['import_id'] ?? null) !== null && $otherIds['import_id'] !== $firstImport['import_id'] && ($fresh['import_id'] ?? null) !== $firstImport['import_id'], '17 other ids, or a new token (a deliberate re-import), run a new import'); + foreach (array_unique($replayIds) as $replayId) { + $delStmt = $db->prepare('DELETE FROM import_logs WHERE import_id = ?'); + $delStmt->bind_param('s', $replayId); + $delStmt->execute(); + $delStmt->close(); + } } echo "\n{$pass} passed, {$fail} failed\n"; From 0470b7d70d0848fe347bdb4afc7c2c155690ea4d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:11:02 +0100 Subject: [PATCH 378/398] ICS import: the whole-year guard carries its state from event to event importIcs() ran yearCoverageError() after every yearly event, and each call re-read every yearly row, rebuilt the calendar and re-scanned 28 years from Jan 1: 360 consecutive one-day yearly events took about 50 seconds, all under the calendar lock other writers give up on after 10. The import now passes a state through the calls: the yearly rows are read once, and each event looks again only at the years whose first open day it closes, from that day on (closures only grow during an import). A refused event leaves the state untouched, so the single offending event is still the one discarded. The same 360 events now take about a second. --- .../Admin/LibraryCalendarController.php | 62 +++++++++++++++---- tests/library-calendar-loans.unit.php | 38 ++++++++++++ 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php index 46637bee8..cf887b6d0 100644 --- a/app/Controllers/Admin/LibraryCalendarController.php +++ b/app/Controllers/Admin/LibraryCalendarController.php @@ -268,6 +268,10 @@ public function importIcs(Request $request, Response $response, \mysqli $db): Re $coverageRejected = 0; $events = LibraryCalendar::parseIcs($ics); $error = $this->commitChange($db, function () use ($db, $events, $sede, &$added, &$skipped, &$rejected, &$coverageRejected): ?string { + // The whole-year guard's state, carried from event to event: the + // yearly rows are read once and each check only looks again at + // the years whose first open day the new event closes. + $coverage = null; foreach ($events as $event) { // The same limits as the form: a yearly event longer than a // year would otherwise be stored as a closure of every day. @@ -289,7 +293,7 @@ public function importIcs(Request $request, Response $response, \mysqli $db): Re $newId = $this->insertClosure($db, $row); // Like the form: yearly rows that together close every day // of the year are refused, the event is not kept. - if ($this->yearCoverageError($db, $row) !== null) { + if ($this->yearCoverageError($db, $row, null, $coverage) !== null) { $this->deleteClosure($db, $newId); $coverageRejected++; continue; @@ -403,21 +407,45 @@ private function lockName(\mysqli $db): string * Null when every calendar year keeps an open day: then two open days are * never more than two years apart, well inside the scan bound. * + * $state carries the check across the rows of one import. Null on the + * first call: the stored yearly rows are read once. On success it holds + * those rows, the weekly days and the first open day found per branch and + * year; the next call adds its $row (already written, not yet in the + * state) and looks again only at the years whose first open day that row + * closes, from that day on: closures only grow during an import, so an + * earlier day cannot reopen. A failed check leaves $state as it was (the + * caller deletes the row). Without it every event re-read and rebuilt + * every yearly row and re-scanned every year from Jan 1, and a large ICS + * import outlasted the calendar lock other writers wait for. + * * @param array{start: string, end: string, description: string, yearly: bool, sede: ?int} $row * @param list|null $closedWeekdays + * @param array{rows: list>, weekdays: list, open: array}|null $state */ - private function yearCoverageError(\mysqli $db, array $row, ?array $closedWeekdays = null): ?string + private function yearCoverageError(\mysqli $db, array $row, ?array $closedWeekdays = null, ?array &$state = null): ?string { if (!$row['yearly']) { return null; } - $result = $db->query('SELECT data_inizio, data_fine, ricorrente_annuale, sede_id FROM calendario_chiusure WHERE ricorrente_annuale = 1'); - $rows = $result instanceof \mysqli_result ? $result->fetch_all(MYSQLI_ASSOC) : []; - $closedWeekdays ??= LibraryCalendar::parseWeekdays((new SettingsRepository($db))->get('loans', 'closed_weekdays', '')); - $calendar = new LibraryCalendar($closedWeekdays, $rows); + $added = null; + if ($state === null) { + $result = $db->query('SELECT data_inizio, data_fine, ricorrente_annuale, sede_id FROM calendario_chiusure WHERE ricorrente_annuale = 1'); + $next = [ + 'rows' => $result instanceof \mysqli_result ? $result->fetch_all(MYSQLI_ASSOC) : [], + 'weekdays' => $closedWeekdays ?? LibraryCalendar::parseWeekdays((new SettingsRepository($db))->get('loans', 'closed_weekdays', '')), + 'open' => [], + ]; + } else { + $next = $state; + $stored = ['data_inizio' => $row['start'], 'data_fine' => $row['end'], 'ricorrente_annuale' => 1, 'sede_id' => $row['sede']]; + $next['rows'][] = $stored; + // The new row alone: does it close a day found open before? + $added = new LibraryCalendar([], [$stored]); + } + $calendar = null; $branches = [$row['sede']]; if ($row['sede'] === null) { - foreach ($rows as $stored) { + foreach ($next['rows'] as $stored) { if ($stored['sede_id'] !== null) { $branches[] = (int) $stored['sede_id']; } @@ -428,17 +456,29 @@ private function yearCoverageError(\mysqli $db, array $row, ?array $closedWeekda // with a common or leap year, so the weekly closed days meet each // possible year. A common year with only Feb 29 open fails here. for ($year = 2025; $year < 2053; $year++) { + $key = ($branch ?? 'library') . '|' . $year; $day = $year . '-01-01'; - $open = false; - while (!$open && str_starts_with($day, (string) $year)) { - $open = $calendar->isOpen($day, $branch); + if (isset($next['open'][$key])) { + if ($added !== null && $added->isOpen($next['open'][$key], $branch)) { + continue; + } + $day = LibraryCalendar::shift($next['open'][$key], 1); + } + $calendar ??= new LibraryCalendar($next['weekdays'], $next['rows']); + $open = null; + while ($open === null && str_starts_with($day, (string) $year)) { + if ($calendar->isOpen($day, $branch)) { + $open = $day; + } $day = LibraryCalendar::shift($day, 1); } - if (!$open) { + if ($open === null) { return __('Con questa chiusura annuale la biblioteca resterebbe chiusa tutto l\'anno: almeno un giorno deve restare aperto.'); } + $next['open'][$key] = $open; } } + $state = $next; return null; } diff --git a/tests/library-calendar-loans.unit.php b/tests/library-calendar-loans.unit.php index d69315d8a..54d8ea452 100644 --- a/tests/library-calendar-loans.unit.php +++ b/tests/library-calendar-loans.unit.php @@ -525,6 +525,44 @@ public function prepare(string $query): mysqli_stmt|false && !str_contains($ok, __('File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).', 1, 0, 1)), 'ICS: a whole-year discard is reported as such, not as a span over the limit'); $clearClosures(); +// A whole year of one-day yearly events: only the last one (Dec 31, which +// closes every common year) is discarded, and the whole-year guard reads the +// yearly rows once for the import instead of once per event. +$countingDb = new class ($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null)) extends mysqli { + public int $yearlyReads = 0; + public function query(string $query, int $result_mode = MYSQLI_STORE_RESULT): mysqli_result|bool + { + if (str_contains($query, 'WHERE ricorrente_annuale = 1')) { + $this->yearlyReads++; + } + return parent::query($query, $result_mode); + } +}; +$countingDb->set_charset('utf8mb4'); +$wholeYear = "BEGIN:VCALENDAR\n"; +for ($d = '2026-01-01'; $d <= '2026-12-31'; $d = $shift($d, 1)) { + $wholeYear .= "BEGIN:VEVENT\nDTSTART;VALUE=DATE:" . str_replace('-', '', $d) . "\nRRULE:FREQ=YEARLY\nSUMMARY:{$tag}\nEND:VEVENT\n"; +} +$wholeYear .= "END:VCALENDAR\n"; +$controller->importIcs($post(['sede_id' => ''])->withUploadedFiles(['ics_file' => $icsFile($wholeYear)]), new \Slim\Psr7\Response(), $countingDb); +[$ok, $err] = $flash(); +$stillOpen = (int) $db->query("SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione = '{$tag}' AND DATE_FORMAT(data_inizio, '%m-%d') = '12-31'")->fetch_row()[0]; +$check($ok !== null && $taggedRows() === 364 && $stillOpen === 0, 'ICS: 365 one-day yearly events keep 364, the one that closes the whole year is discarded (' . $taggedRows() . ' kept)'); +$check($countingDb->yearlyReads === 1, "ICS: the whole-year guard reads the yearly rows once per import ({$countingDb->yearlyReads} reads)"); +$countingDb->close(); +$clearClosures(); +// Carried from event to event per branch too: with a branch closed Jan 1 - +// Jun 30 every year, a library-wide import keeps Jul 1 - Dec 30 and drops the +// Dec 31 that follows it, which would close that branch's whole year. +$addClosure('2026-01-01', '2026-06-30', $sedeId, 1); +$branchYear = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20260701\nDTEND;VALUE=DATE:20261231\nRRULE:FREQ=YEARLY\nSUMMARY:{$tag}\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20261231\nRRULE:FREQ=YEARLY\nSUMMARY:{$tag}\nEND:VEVENT\nEND:VCALENDAR\n"; +$controller->importIcs($post(['sede_id' => ''])->withUploadedFiles(['ics_file' => $icsFile($branchYear)]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$keptBranchYear = $db->query("SELECT DATE_FORMAT(data_inizio, '%m-%d') FROM calendario_chiusure WHERE descrizione = '{$tag}' AND sede_id IS NULL")->fetch_all(); +$check($ok !== null && array_column($keptBranchYear, 0) === ['07-01'], 'ICS: a library-wide event that would close a branch\'s last open day is discarded after an earlier event of the same import moved it'); +$clearClosures(); + // update() re-checks the closure under the calendar lock: another operator // holds the lock, deletes the closure meanwhile, and the update that was // waiting for the lock reports "not found" instead of "updated". From 294f79b55f7c365c39af9753e9192e49d78f5bfd Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:14:43 +0100 Subject: [PATCH 379/398] Debt block fails closed on a probe error and counts unconverted legacy penalties A failed movimenti_lettore probe no longer reads as "table absent" on the decision paths: CirculationPolicy::debtBlockError(), the debtBlockSql() fragment and the user deletion guard use schemaReadyOrFail(), which throws on a probe error, so a database error refuses the loan or rolls back the deletion instead of disabling the debt block or skipping the ledger rows. Display paths keep schemaReady(), which logs and answers false. While loans.legacy_penalties_backfilled is unset (the upgrade's backfill was deferred or failed), the debt balance in PHP and SQL adds the prestiti.sanzione amounts that have no penalty: row yet, so a patron over the threshold stays blocked until maintenance completes the conversion. Once the marker is set the balance is the ledger alone. --- app/Controllers/UsersController.php | 6 +- app/Services/CirculationPolicy.php | 9 +- app/Services/PatronLedger.php | 91 +++++++++++++++++++-- app/Support/LoanEligibility.php | 9 +- tests/migration-0.9.0-fines-ledger.unit.php | 3 + tests/patron-ledger.unit.php | 38 ++++++++- 6 files changed, 139 insertions(+), 17 deletions(-) diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index 670374b5f..a3d2fe2a7 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -706,8 +706,10 @@ public function delete(Request $request, Response $response, mysqli $db, int $id $lockUser->bind_param('i', $id); $lockUser->execute(); $lockUser->close(); - // Before the 0.9.0 migration there is no ledger to count. - $loanCheck = \App\Services\PatronLedger::schemaReady($db) + // Before the 0.9.0 migration there is no ledger to count. A failed + // probe throws (rollback below) instead of skipping the ledger + // rows and letting the cascade erase them. + $loanCheck = \App\Services\PatronLedger::schemaReadyOrFail($db) ? $db->prepare("SELECT (SELECT COUNT(*) FROM prestiti WHERE utente_id = ?) + (SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id = ?) AS count") : $db->prepare("SELECT (SELECT COUNT(*) FROM prestiti WHERE utente_id = ?) + 0 * ? AS count"); $loanCheck->bind_param("ii", $id, $id); diff --git a/app/Services/CirculationPolicy.php b/app/Services/CirculationPolicy.php index 4a9fff88f..845857f5f 100644 --- a/app/Services/CirculationPolicy.php +++ b/app/Services/CirculationPolicy.php @@ -367,15 +367,18 @@ public function materialRefusedReservationUserIds(int $libroId): array */ public function debtBlockError(int $userId): ?string { - // No ledger table (code deployed before its migration): no balance, no block. - if (!PatronLedger::schemaReady($this->db)) { + // No ledger table (code deployed before its migration): no balance, no + // block. A failed probe throws: an error must not admit the patron. + if (!PatronLedger::schemaReadyOrFail($this->db)) { return null; } $threshold = FineParameters::moneyToCents($this->forUser($userId)->debtBlockThreshold); if ($threshold === null || $threshold <= 0) { return null; } - return PatronLedger::forDb($this->db)->balanceCents($userId) > $threshold ? 'debt_block' : null; + // debtBalanceCents(): legacy penalties the upgrade did not convert yet + // count too, until the backfill completes. + return PatronLedger::forDb($this->db)->debtBalanceCents($userId) > $threshold ? 'debt_block' : null; } /** diff --git a/app/Services/PatronLedger.php b/app/Services/PatronLedger.php index ec9994f90..938eff6c1 100644 --- a/app/Services/PatronLedger.php +++ b/app/Services/PatronLedger.php @@ -82,25 +82,49 @@ public static function forDb(mysqli $db): self /** * Whether movimenti_lettore exists. Code ships before its migration runs - * during an upgrade: until then there is no balance, so no debt block in - * SQL or PHP, and nothing for the maintenance backfill to do. + * during an upgrade: until then there is no balance, so nothing to show + * and nothing for the maintenance backfill to do. + * + * A probe that fails (connection lost, information_schema unreadable) + * also answers false here: the display paths (reader record, reports, + * notifications) then show no balance instead of a 500. The decisions + * that must not drop a rule on an error use schemaReadyOrFail(). * * Only a positive answer is cached: a long-running process (cron) that * probed before the migration sees the table as soon as it exists. */ public static function schemaReady(mysqli $db): bool + { + try { + return self::probeSchema($db); + } catch (\Throwable $e) { + \App\Support\SecureLogger::warning('Ledger schema probe failed', ['error' => $e->getMessage()]); + return false; + } + } + + /** + * schemaReady() for the debt block and the deletion guard: false only + * when the table is really absent (upgrade window). A failed probe + * throws, so a database error refuses the loan or the deletion instead + * of reading as "no ledger" and admitting a patron over the threshold. + */ + public static function schemaReadyOrFail(mysqli $db): bool + { + return self::probeSchema($db); + } + + private static function probeSchema(mysqli $db): bool { self::$schemaReady ??= new \WeakMap(); if (isset(self::$schemaReady[$db])) { return true; } - $ready = false; - try { - $result = $db->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'movimenti_lettore'"); - $ready = $result instanceof \mysqli_result && (int) ($result->fetch_row()[0] ?? 0) === 1; - } catch (\Throwable) { - $ready = false; + $result = $db->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'movimenti_lettore'"); + if (!$result instanceof \mysqli_result) { + throw new \RuntimeException('Ledger schema probe failed: ' . $db->error); } + $ready = (int) ($result->fetch_row()[0] ?? 0) === 1; if ($ready) { self::$schemaReady[$db] = true; } @@ -119,6 +143,57 @@ public static function balanceSql(string $userIdExpr): string return "(SELECT COALESCE(SUM(ml.importo), 0) FROM movimenti_lettore ml WHERE ml.utente_id = {$userIdExpr})"; } + /** + * Whether the legacy penalty conversion has not completed yet + * (loans.legacy_penalties_backfilled unset): the upgrade's pass was + * deferred or failed and maintenance has not finished it. + */ + public static function legacyPenaltiesPending(mysqli $db): bool + { + return (new \App\Models\SettingsRepository($db))->get('loans', 'legacy_penalties_backfilled', '0') !== '1'; + } + + /** + * SQL expression for the prestiti.sanzione amounts of $userIdExpr that + * have no penalty: ledger row yet: exactly what the backfill will + * charge, so adding it gives the balance the patron will have once the + * conversion completes. $userIdExpr is a fixed column reference. + */ + public static function pendingLegacyPenaltiesSql(string $userIdExpr): string + { + return "(SELECT COALESCE(SUM(lp.sanzione), 0) FROM prestiti lp WHERE lp.utente_id = {$userIdExpr} AND lp.sanzione > 0" + . " AND NOT EXISTS (SELECT 1 FROM movimenti_lettore lm WHERE lm.chiave_idempotenza = CONCAT('penalty:', lp.id)))"; + } + + /** + * The balance the debt block compares with the threshold: the ledger, + * plus the legacy penalties not converted yet while the backfill is + * pending, so a deferred or failed conversion does not reopen loans to a + * patron over the threshold until maintenance finishes it. Once the + * marker is set this is balanceSql() and costs nothing more. + */ + public static function debtBalanceSql(string $userIdExpr, mysqli $db): string + { + if (!self::legacyPenaltiesPending($db)) { + return self::balanceSql($userIdExpr); + } + return '(' . self::balanceSql($userIdExpr) . ' + ' . self::pendingLegacyPenaltiesSql($userIdExpr) . ')'; + } + + /** PHP twin of debtBalanceSql() for one patron. */ + public function debtBalanceCents(int $userId): int + { + if (!self::legacyPenaltiesPending($this->db)) { + return $this->balanceCents($userId); + } + $stmt = $this->db->prepare('SELECT ' . self::debtBalanceSql('?', $this->db)); + $stmt->bind_param('ii', $userId, $userId); + $stmt->execute(); + $value = $stmt->get_result()->fetch_row()[0] ?? 0; + $stmt->close(); + return FineParameters::decimalToCents($value); + } + public function balanceCents(int $userId): int { if (!self::schemaReady($this->db)) { diff --git a/app/Support/LoanEligibility.php b/app/Support/LoanEligibility.php index 9389826ee..811160249 100644 --- a/app/Support/LoanEligibility.php +++ b/app/Support/LoanEligibility.php @@ -171,17 +171,20 @@ public static function eligibleUserWhere(string $alias, \mysqli $db): string * (first predefinita = 1 in display order, as CirculationPolicy::types()), * the global loans.debt_block_threshold when it is a well-formed amount. * A threshold of NULL or 0 never blocks. With no movimenti_lettore table - * (PatronLedger::schemaReady()) nobody has a balance: "1 = 1". + * (PatronLedger::schemaReadyOrFail()) nobody has a balance: "1 = 1"; a + * failed probe throws rather than dropping the predicate. While the + * legacy penalty backfill is pending the unconverted prestiti.sanzione + * amounts count too (PatronLedger::debtBalanceSql()). */ public static function debtBlockSql(string $alias, \mysqli $db): string { - if (!\App\Services\PatronLedger::schemaReady($db)) { + if (!\App\Services\PatronLedger::schemaReadyOrFail($db)) { return '1 = 1'; } $threshold = self::debtThresholdSql($alias, $db); return "( COALESCE({$threshold}, 0) <= 0 - OR " . \App\Services\PatronLedger::balanceSql($alias . '.id') . " <= {$threshold} + OR " . \App\Services\PatronLedger::debtBalanceSql($alias . '.id', $db) . " <= {$threshold} )"; } diff --git a/tests/migration-0.9.0-fines-ledger.unit.php b/tests/migration-0.9.0-fines-ledger.unit.php index d58b81bc9..d983d8269 100644 --- a/tests/migration-0.9.0-fines-ledger.unit.php +++ b/tests/migration-0.9.0-fines-ledger.unit.php @@ -92,6 +92,9 @@ // the ledger read; copie.sede_id is the copy's branch for closed days). $db->query("CREATE TABLE utenti (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL) ENGINE=InnoDB"); $db->query("CREATE TABLE copie (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, sede_id INT DEFAULT NULL) ENGINE=InnoDB"); + // system_settings is in every 0.8.9 install: the debt block reads the + // legacy penalty backfill marker from it. + $db->query("CREATE TABLE system_settings (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, category VARCHAR(50) NOT NULL, setting_key VARCHAR(100) NOT NULL, setting_value TEXT, description TEXT, created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY unique_setting (category, setting_key)) ENGINE=InnoDB"); $db->query("CREATE TABLE prestiti ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, utente_id INT NOT NULL, copia_id INT DEFAULT NULL, stato ENUM('pendente','prenotato','da_ritirare','in_corso','restituito','in_ritardo','perso','danneggiato','annullato','scaduto') DEFAULT 'pendente', diff --git a/tests/patron-ledger.unit.php b/tests/patron-ledger.unit.php index ee50da8f5..0e02b1050 100644 --- a/tests/patron-ledger.unit.php +++ b/tests/patron-ledger.unit.php @@ -60,7 +60,7 @@ } $run = bin2hex(random_bytes(4)); -$settingKeys = ['daily_fine', 'fine_grace_days', 'fine_max_per_loan', 'debt_block_threshold']; +$settingKeys = ['daily_fine', 'fine_grace_days', 'fine_max_per_loan', 'debt_block_threshold', 'legacy_penalties_backfilled']; $savedSettings = []; foreach ($settingKeys as $k) { $r = $db->query("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = '{$k}'")->fetch_row(); @@ -233,6 +233,42 @@ $check(LoanEligibility::checkUser($db, $u1) === null && $sqlEligible($u1), 'after paying down to 5.00: eligible again'); $check(str_contains(LoanEligibility::errorMessage('debt_block'), 'soglia') || LoanEligibility::errorMessage('debt_block') !== '', 'debt_block has its own message'); + echo "D2. Legacy penalties not converted yet, and a failed schema probe\n"; + // A prestiti.sanzione the upgrade's backfill did not reach (deferred or + // failed): no penalty: row, the marker unset. + $u5 = $newUser('lg'); + $legacyLoan = $newLoan($u5, $daysAgo(40), $daysAgo(30)); + $db->query("UPDATE prestiti SET sanzione = 8.00, stato = 'perso', attivo = 0 WHERE id = {$legacyLoan}"); + $setSetting('debt_block_threshold', '5'); + $setSetting('legacy_penalties_backfilled', null); + $check($ledger->balanceCents($u5) === 0 && $ledger->debtBalanceCents($u5) === 800, 'backfill pending: the debt balance counts the unconverted 8.00, the ledger balance does not'); + $check(LoanEligibility::checkUser($db, $u5) === 'debt_block' && !$sqlEligible($u5), 'backfill pending, unconverted 8.00 over the threshold 5: blocked in PHP and SQL'); + $setSetting('legacy_penalties_backfilled', '1'); + $check(LoanEligibility::checkUser($db, $u5) === null && $sqlEligible($u5), 'marker set: the debt block reads the ledger alone, as before'); + $setSetting('legacy_penalties_backfilled', null); + $db->query("INSERT INTO movimenti_lettore (utente_id, prestito_id, tipo, importo, descrizione, chiave_idempotenza) VALUES ({$u5}, {$legacyLoan}, 'addebito_perdita', 8.00, 'zz legacy', 'penalty:{$legacyLoan}')"); + $check($ledger->balanceCents($u5) === 800 && $ledger->debtBalanceCents($u5) === 800, 'a converted penalty is counted once, never twice'); + $db->query("DELETE FROM movimenti_lettore WHERE utente_id = {$u5}"); + $setSetting('debt_block_threshold', null); + + // A probe that errors is not "table absent": the debt block refuses + // instead of switching itself off; display paths still answer. + $dead = $socket !== '' && file_exists($socket) + ? new mysqli(null, $user, $pass, $name, 0, $socket) + : new mysqli(getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), $user, $pass, $name, (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))); + $dead->close(); + $throwsAny = static function (callable $fn): bool { + try { + $fn(); + } catch (Throwable) { + return true; + } + return false; + }; + $check(PatronLedger::schemaReady($dead) === false, 'failed probe: schemaReady() answers false for the display paths'); + $check($throwsAny(fn () => (new \App\Services\CirculationPolicy($dead))->debtBlockError($u1)), 'failed probe: debtBlockError() throws instead of admitting the patron'); + $check($throwsAny(fn () => LoanEligibility::debtBlockSql('u', $dead)), 'failed probe: debtBlockSql() throws instead of returning 1 = 1'); + // EXPLAIN output differs (MySQL 9 tree format, MariaDB/MySQL 8 table): // look for the index name anywhere in the plan. $plan = static function (string $sql) use ($db): string { From 334ac9197d484405e0eb3ce2c9db8bb1f598a5a4 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:14:47 +0100 Subject: [PATCH 380/398] Closed-days suite restores closure descriptions byte for byte The closures set aside before the suite now carry descrizione as HEX() and come back through UNHEX(): mysql -B escaped backslashes, tabs and newlines, and the restore wrote those escapes back doubled or literal. --- tests/fines-closed-days.spec.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/fines-closed-days.spec.js b/tests/fines-closed-days.spec.js index 1d48ad080..28edf81c0 100644 --- a/tests/fines-closed-days.spec.js +++ b/tests/fines-closed-days.spec.js @@ -133,8 +133,9 @@ test.describe.serial('Late fines skip the days the library is closed', () => { // there are put aside and come back afterwards with their id and // created_at. Run it on a dedicated E2E database; a closure another // process adds meanwhile is never deleted by the teardown (only this - // suite's TAG rows are). - savedClosures = F.dbQuery('SELECT CONCAT_WS(0x1f, id, data_inizio, data_fine, ricorrente_annuale, IFNULL(sede_id, \'\'), IFNULL(descrizione, \'\'), created_at) FROM calendario_chiusure').split('\n').filter(Boolean); + // suite's TAG rows are). descrizione travels as hex: `mysql -B` escapes + // backslashes, tabs and newlines, and a newline would split the row. + savedClosures = F.dbQuery('SELECT CONCAT_WS(0x1f, id, data_inizio, data_fine, ricorrente_annuale, IFNULL(sede_id, \'\'), HEX(descrizione), created_at) FROM calendario_chiusure').split('\n').filter(Boolean); F.dbQuery('DELETE FROM calendario_chiusure'); restoreSetting('closed_weekdays', null); F.dbQuery(`INSERT INTO sedi (nome) VALUES (${F.sqlStr(`${TAG} Succursale`)})`); @@ -159,9 +160,9 @@ test.describe.serial('Late fines skip the days the library is closed', () => { clearConfigCache(); F.dbQuery(`DELETE FROM calendario_chiusure WHERE descrizione LIKE ${F.sqlStr(`${TAG}%`)}`); for (const line of savedClosures) { - const [id, start, end, yearly, sede, description, createdAt] = line.split('\x1f'); + const [id, start, end, yearly, sede, descriptionHex, createdAt] = line.split('\x1f'); // INSERT IGNORE: a row someone re-created with the same id meanwhile wins. - F.dbQuery(`INSERT IGNORE INTO calendario_chiusure (id, data_inizio, data_fine, ricorrente_annuale, sede_id, descrizione, created_at) VALUES (${Number(id)}, '${start}', '${end}', ${Number(yearly)}, ${sede === '' ? 'NULL' : Number(sede)}, ${F.sqlStr(description)}, ${createdAt ? F.sqlStr(createdAt) : 'CURRENT_TIMESTAMP'})`); + F.dbQuery(`INSERT IGNORE INTO calendario_chiusure (id, data_inizio, data_fine, ricorrente_annuale, sede_id, descrizione, created_at) VALUES (${Number(id)}, '${start}', '${end}', ${Number(yearly)}, ${sede === '' ? 'NULL' : Number(sede)}, CONVERT(UNHEX(${F.sqlStr(descriptionHex)}) USING utf8mb4), ${createdAt ? F.sqlStr(createdAt) : 'CURRENT_TIMESTAMP'})`); } try { F.dbQuery(`DELETE FROM movimenti_lettore WHERE utente_id=${patron?.id || 0}`); } catch { /* */ } F.deleteTitles(titles); From 8a5b3e4b792f2a655aa7649910f8d08679fa5a47 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:16:26 +0100 Subject: [PATCH 381/398] Stocktake code import reads only the code cell, with the quotes it holds A .txt line that starts with a tab has no code: the first cell is the one before the tab, so a title in the next column is no longer recorded as an unknown code. The CSV delimiter is counted on the first record rather than its first physical line, so a quoted first cell that spans two lines no longer hides the semicolon after it. A quote left in a .csv cell after fgetcsv() is part of the code and is kept; a .txt line drops only one pair of quotes wrapping it whole. --- app/Services/StocktakeService.php | 76 ++++++++++++++----------- tests/stocktake-classification.unit.php | 4 ++ 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index c6f9dc4e1..4e43f8797 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -175,7 +175,7 @@ private static function assertCodeLength(string $code): void * A .txt line is the code itself: only a tab (which no code can hold, see * normalizeCode()) separates it from trailing scanner columns, so a code * such as "INV,001" survives. A .csv is read record by record with a real - * CSV parser, using the delimiter of its first line, so a quoted "INV,001" + * CSV parser, using the delimiter of its first record, so a quoted "INV,001" * survives too, and so does a quoted cell that spans several lines. Only * its first column is the code: a row whose first cell is empty has none. * @@ -192,7 +192,14 @@ public static function parseCodeList(string $text, bool $csv = false): array $codes = []; $first = true; foreach ($csv ? self::csvFirstCells($text) : self::txtFirstCells($text) as $cell) { - $cell = self::normalizeCode(trim($cell, " \"'")); + $cell = trim($cell); + // fgetcsv() already removed the CSV quoting, so a quote left in a + // .csv cell is part of the code. A .txt line has no quoting rules: + // only one pair of quotes wrapping the whole line is dropped. + if (!$csv && preg_match('/^(["\'])(.*)\1$/su', $cell, $m) === 1) { + $cell = $m[2]; + } + $cell = self::normalizeCode($cell); if ($cell === '') { continue; } @@ -207,7 +214,9 @@ public static function parseCodeList(string $text, bool $csv = false): array } /** - * The first non-blank cell of every .txt line. + * The first cell of every .txt line: what comes before its first tab. A + * line that starts with a tab has no code, as a .csv row whose first cell + * is empty: the next cell is a scanner column (a title, a date), never a code. * * @return list */ @@ -215,12 +224,7 @@ private static function txtFirstCells(string $text): array { $cells = []; foreach (preg_split('/\r\n|\r|\n/', $text) ?: [] as $line) { - foreach (explode("\t", $line) as $candidate) { - if (trim($candidate, " \"'") !== '') { - $cells[] = $candidate; - break; - } - } + $cells[] = explode("\t", $line, 2)[0]; } return $cells; } @@ -254,37 +258,43 @@ private static function csvFirstCells(string $text): array } /** - * The delimiter of a CSV export: the most frequent of , ; and tab on its - * first non-blank line, counting only those outside quoted cells (comma - * when the line has none, a one-column file). + * The delimiter of a CSV export: the most frequent of , ; and tab in its + * first non-blank record, counting only those outside quoted cells (comma + * when the record has none, a one-column file). The record ends at the + * first line break outside quotes, so a quoted first cell that spans + * several lines does not hide the delimiter that follows it. */ private static function csvDelimiter(string $text): string { - foreach (explode("\n", $text) as $line) { - if (trim($line) === '') { - continue; - } - $counts = [',' => 0, ';' => 0, "\t" => 0]; - $quoted = false; - for ($i = 0, $n = strlen($line); $i < $n; $i++) { - $char = $line[$i]; - if ($char === '"') { - $quoted = !$quoted; // an escaped "" flips twice - } elseif (!$quoted && isset($counts[$char])) { - $counts[$char]++; + $counts = [',' => 0, ';' => 0, "\t" => 0]; + $quoted = false; + $blank = true; + for ($i = 0, $n = strlen($text); $i < $n; $i++) { + $char = $text[$i]; + if ($char === '"') { + $quoted = !$quoted; // an escaped "" flips twice + $blank = false; + } elseif (!$quoted && $char === "\n") { + if (!$blank) { + break; } + $counts = [',' => 0, ';' => 0, "\t" => 0]; // a blank line: keep looking + } elseif (!$quoted && isset($counts[$char])) { + $counts[$char]++; + $blank = false; + } elseif (!ctype_space($char)) { + $blank = false; } - $best = ','; - $bestCount = 0; - foreach ($counts as $candidate => $count) { - if ($count > $bestCount) { - $best = (string) $candidate; - $bestCount = $count; - } + } + $best = ','; + $bestCount = 0; + foreach ($counts as $candidate => $count) { + if ($count > $bestCount) { + $best = (string) $candidate; + $bestCount = $count; } - return $best; } - return ','; + return $best; } /** diff --git a/tests/stocktake-classification.unit.php b/tests/stocktake-classification.unit.php index 72debade6..95401d3d4 100644 --- a/tests/stocktake-classification.unit.php +++ b/tests/stocktake-classification.unit.php @@ -82,6 +82,10 @@ $check(S::parseCodeList("INV,001\nINV;002\n") === ['INV,001', 'INV;002'], 'a .txt line is the whole code: commas and semicolons stay'); $check(S::parseCodeList("INV-5\t2026-10-30 10:00\n") === ['INV-5'], 'a .txt scanner export drops the columns after a tab'); $check(S::parseCodeList("Barcode\n123") === ['123'], 'an English header is skipped too'); +$check(S::parseCodeList("\tIl nome della rosa\nINV-6\tx\n") === ['INV-6'], 'a .txt line that starts with a tab has no code: the title after it is never recorded as one'); +$check(S::parseCodeList("\"INV\n1\";Titolo\nINV-2;Altro", true) === ['INV1', 'INV-2'], 'the delimiter is read from the first record: a quoted first cell spanning two lines does not hide the semicolon after it'); +$check(S::parseCodeList("codice\n\"\"\"INV-7\"\"\"\nO'BRIEN-1'\n", true) === ['"INV-7"', "O'BRIEN-1'"], 'a quote that is part of a .csv code is kept: fgetcsv() already removed the CSV quoting'); +$check(S::parseCodeList("\"INV-8\"\n'INV-9'\nINV-10\"\n") === ['INV-8', 'INV-9', 'INV-10"'], 'a .txt line drops only one pair of quotes wrapping the whole code'); $check(S::parseCodeList("codice-1\ncodice") === ['codice-1', 'codice'], 'only the first line can be a header'); $latin1 = "INV-\xE9"; $check(S::parseCodeList($latin1) === ['INV-é'], 'a Latin-1 export is read as UTF-8'); From 7bbb31221b56555960225adf2c43ee422d0ff078 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:16:31 +0100 Subject: [PATCH 382/398] SRU server writes the UNIMARC genre as a 608 $2 local too The z39 SRU server's UNIMARC/XML formatter still wrote the genre as a plain 606, which the UNIMARC mapper now reads as a keyword, so a copy catalogued from another Pinakes lost its genre. Both writers now take the field from one place, BookMarcBuilder::unimarcGenreField(), and the formatter writes it after the 606 keywords as the builder does. --- app/Support/Marc/BookMarcBuilder.php | 25 ++++++++++++++---- .../classes/UNIMARCXMLFormatter.php | 13 ++++++---- tests/unimarc-standards.unit.php | 26 +++++++++++++++++++ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/app/Support/Marc/BookMarcBuilder.php b/app/Support/Marc/BookMarcBuilder.php index fea5eaa91..55db4cfda 100644 --- a/app/Support/Marc/BookMarcBuilder.php +++ b/app/Support/Marc/BookMarcBuilder.php @@ -244,6 +244,22 @@ public static function marc21(array $row, array $authors, array $publishers, ?ar return $record; } + /** + * The UNIMARC field of a book's genre: a 608 form / genre access point + * with $2 local, the counterpart of the MARC 21 655 the genre goes to. + * A 606 is a topical subject, so another catalogue's 606 $2 local stays a + * keyword on re-import (UnimarcLibriMapper). Every UNIMARC writer of this + * application (this builder, the SRU server's UNIMARC/XML formatter) uses + * it, so a Pinakes-to-Pinakes copy keeps the genre. Null without a genre. + * + * @return array{0: string, 1: string, 2: string, 3: list}|null + */ + public static function unimarcGenreField(string $genre): ?array + { + $genre = trim($genre); + return $genre === '' ? null : ['608', ' ', ' ', [['a', $genre], ['2', 'local']]]; + } + /** * @param array $row * @param list> $authors @@ -382,11 +398,10 @@ public static function unimarc(array $row, array $authors, array $publishers, ?a foreach (self::keywords($row) as $kw) { $record->addDataField('606', ' ', ' ', [['a', $kw]]); } - // 608 — Form / genre access point, $2 local: the UNIMARC counterpart - // of the MARC 21 655 the genre goes to. A 606 is a topical subject, so - // another catalogue's 606 $2 local stays a keyword on re-import. - if ($genre !== null && !empty($genre['nome'])) { - $record->addDataField('608', ' ', ' ', [['a', (string) $genre['nome']], ['2', 'local']]); + // 608 — the genre (unimarcGenreField()). + $genreField = self::unimarcGenreField($genre !== null ? (string) ($genre['nome'] ?? '') : ''); + if ($genreField !== null) { + $record->addDataField($genreField[0], $genreField[1], $genreField[2], $genreField[3]); } // 676 — Dewey (extended) diff --git a/storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php b/storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php index e68e94222..25bfaf817 100644 --- a/storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php +++ b/storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php @@ -214,11 +214,6 @@ public function format(array $record): \DOMElement ])); } - // 606 — Subject — genre - if (!empty($record['genere'])) { - $recordEl->appendChild($this->df('606', ' ', ' ', [['a', (string) $record['genere']]])); - } - // 606 — Subject — keywords if (!empty($record['parole_chiave'])) { foreach (explode(',', (string) $record['parole_chiave']) as $kw) { @@ -229,6 +224,14 @@ public function format(array $record): \DOMElement } } + // 608 — the genre, as every UNIMARC record of this application + // writes it (BookMarcBuilder::unimarcGenreField(): 608 $2 local), so a + // copy catalogued from this server gets it back as the genre. + $genreField = \App\Support\Marc\BookMarcBuilder::unimarcGenreField((string) ($record['genere'] ?? '')); + if ($genreField !== null) { + $recordEl->appendChild($this->df($genreField[0], $genreField[1], $genreField[2], $genreField[3])); + } + // 700/701/702 — creators and secondary intellectual responsibility. // UNIMARC ind1=undefined (space), ind2=form of name: '1' entry under // surname ($a surname, $b forename), '0' direct order (whole name in $a). diff --git a/tests/unimarc-standards.unit.php b/tests/unimarc-standards.unit.php index bff19f6eb..a99285bb7 100644 --- a/tests/unimarc-standards.unit.php +++ b/tests/unimarc-standards.unit.php @@ -129,6 +129,32 @@ 'UNIMARC export → import round-trips every name unchanged (got ' . json_encode($names, JSON_UNESCAPED_UNICODE) . ')' ); +// Pinakes-to-Pinakes copy cataloguing: the SRU server's UNIMARC/XML record +// of a book with a genre maps back to that genre, not to a keyword, because +// the formatter writes it as BookMarcBuilder does (608 $2 local). +$genreBook = $book + ['genere' => 'Narrativa storica', 'parole_chiave' => 'medioevo, abbazia']; +$genreDoc = new DOMDocument('1.0', 'UTF-8'); +$genreDoc->appendChild((new \Z39Server\UNIMARCXMLFormatter($genreDoc))->format($genreBook)); +$genreXml = (string) $genreDoc->saveXML(); +$sruRecord = null; +foreach (\App\Support\Marc\MarcXmlReader::fromString($genreXml) as $item) { + if ($item instanceof \App\Support\Marc\MarcRecord) { + $sruRecord = $item; + break; + } +} +$sruMapped = $sruRecord !== null ? (new \App\Support\Marc\UnimarcLibriMapper())->map($sruRecord) : []; +$builderGenre = \App\Support\Marc\BookMarcBuilder::unimarc(['id' => 1, 'titolo' => 'T'], [], [], ['nome' => 'Narrativa storica']); +$check( + $sruRecord !== null && $sruRecord->subfield('608', 'a') === 'Narrativa storica' && $sruRecord->subfield('608', '2') === 'local' + && $builderGenre->subfield('608', 'a') === 'Narrativa storica' && $builderGenre->subfield('608', '2') === 'local', + 'the SRU UNIMARC formatter writes the genre as BookMarcBuilder does: 608 $a $2 local' +); +$check( + ($sruMapped['genere'] ?? null) === 'Narrativa storica' && ($sruMapped['parole_chiave'] ?? null) === ['medioevo', 'abbazia'], + 'an SRU UNIMARC record of a book with a genre maps back to the genre, not to a keyword (got ' . json_encode([$sruMapped['genere'] ?? null, $sruMapped['parole_chiave'] ?? null], JSON_UNESCAPED_UNICODE) . ')' +); + echo "\n================================\n"; echo "Passed: {$passed} Failed: {$failed}\n"; exit($failed > 0 ? 1 : 0); From c096cf1941ecf9217ed647154b2da407e39cc217 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:19:24 +0100 Subject: [PATCH 383/398] Stocktake scan reads the loan status after a return in progress commits record() read the copy state and the active loan before its transaction and the session lock, so a return committed in between left the scan stored as on loan for a copy already back on the shelf, and the close froze that outcome. The reads now run inside the transaction, after the session lock, and the loan and copy state are locking reads taken in the order a return takes its locks (the loan, then the copy): a return still in progress is waited for. --- app/Services/StocktakeService.php | 118 ++++++++++++++++++------------ tests/stocktake-snapshot.unit.php | 34 +++++++++ 2 files changed, 105 insertions(+), 47 deletions(-) diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index 4e43f8797..4f6db77d1 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -577,50 +577,6 @@ public function importCodes(int $sessionId, array $codes, ?int $operatorId, ?int */ private function record(int $sessionId, string $code, ?int $operatorId, ?int $scanShelf, bool $insideTransaction = false): array { - // `copie` has no deleted_at: a copy of a book removed from the catalogue is unknown. - $stmt = $this->db->prepare( - 'SELECT c.id, c.libro_id, c.stato, l.titolo, l.collocazione FROM copie c ' - . 'JOIN libri l ON l.id = c.libro_id AND l.deleted_at IS NULL ' - . 'WHERE c.numero_inventario = ? LIMIT 1' - ); - if ($stmt === false) { - throw new \RuntimeException($this->db->error); - } - $stmt->bind_param('s', $code); - $stmt->execute(); - $copy = $stmt->get_result()->fetch_assoc(); - $stmt->close(); - - $copyId = is_array($copy) ? (int) $copy['id'] : null; - $expected = null; - $alreadyScanned = false; - $onLoan = false; - if ($copyId !== null) { - $stmt = $this->db->prepare('SELECT scaffale_atteso FROM inventario_attese WHERE sessione_id = ? AND copia_id = ?'); - if ($stmt === false) { - throw new \RuntimeException($this->db->error); - } - $stmt->bind_param('ii', $sessionId, $copyId); - $stmt->execute(); - $expected = $stmt->get_result()->fetch_assoc(); - $stmt->close(); - - $stmt = $this->db->prepare('SELECT 1 FROM inventario_scansioni WHERE sessione_id = ? AND copia_id = ? LIMIT 1'); - if ($stmt === false) { - throw new \RuntimeException($this->db->error); - } - $stmt->bind_param('ii', $sessionId, $copyId); - $stmt->execute(); - $alreadyScanned = $stmt->get_result()->fetch_row() !== null; - $stmt->close(); - - $onLoan = in_array((string) ($copy['stato'] ?? ''), self::ON_LOAN_COPY_STATES, true) || $this->isOnLoan($copyId); - } - - $expectedShelf = is_array($expected) && $expected['scaffale_atteso'] !== null ? (int) $expected['scaffale_atteso'] : null; - $outcome = self::classify($copyId !== null, $alreadyScanned, is_array($expected), $onLoan, $expectedShelf, $scanShelf); - - $storedCopyId = in_array($outcome, ['sconosciuta', 'duplicata'], true) ? null : $copyId; // The scan is written under a shared lock on the session row. close() // and cancel() flip the state with an UPDATE that waits for that lock, // so a scan either lands before the session leaves 'aperta' (and close() @@ -632,6 +588,56 @@ private function record(int $sessionId, string $code, ?int $operatorId, ?int $sc if (!$insideTransaction) { $this->lockOpenSession($sessionId); } + // `copie` has no deleted_at: a copy of a book removed from the catalogue is unknown. + $stmt = $this->db->prepare( + 'SELECT c.id, c.libro_id, c.stato, l.titolo, l.collocazione FROM copie c ' + . 'JOIN libri l ON l.id = c.libro_id AND l.deleted_at IS NULL ' + . 'WHERE c.numero_inventario = ? LIMIT 1' + ); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('s', $code); + $stmt->execute(); + $copy = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + + $copyId = is_array($copy) ? (int) $copy['id'] : null; + $expected = null; + $alreadyScanned = false; + $onLoan = false; + if ($copyId !== null) { + $stmt = $this->db->prepare('SELECT scaffale_atteso FROM inventario_attese WHERE sessione_id = ? AND copia_id = ?'); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('ii', $sessionId, $copyId); + $stmt->execute(); + $expected = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + + $stmt = $this->db->prepare('SELECT 1 FROM inventario_scansioni WHERE sessione_id = ? AND copia_id = ? LIMIT 1'); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('ii', $sessionId, $copyId); + $stmt->execute(); + $alreadyScanned = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + + // The loan status is read inside the transaction with locking + // reads, in the order a return takes its locks (the loan, then + // the copy): a return in progress is waited for, so the scan + // never records 'in_prestito' for a copy already back on the shelf. + $onLoan = $this->isOnLoan($copyId, true); + $copy['stato'] = $this->lockedCopyState($copyId) ?? (string) ($copy['stato'] ?? ''); + $onLoan = $onLoan || in_array($copy['stato'], self::ON_LOAN_COPY_STATES, true); + } + + $expectedShelf = is_array($expected) && $expected['scaffale_atteso'] !== null ? (int) $expected['scaffale_atteso'] : null; + $outcome = self::classify($copyId !== null, $alreadyScanned, is_array($expected), $onLoan, $expectedShelf, $scanShelf); + + $storedCopyId = in_array($outcome, ['sconosciuta', 'duplicata'], true) ? null : $copyId; try { $this->insertScan($sessionId, $storedCopyId, $code, $outcome, $operatorId); } catch (\mysqli_sql_exception $e) { @@ -735,10 +741,14 @@ private static function onLoanSql(): string . 'SELECT 1 FROM prestiti p WHERE p.copia_id = a.copia_id AND p.attivo = 1 AND p.stato IN (' . self::sqlList(self::ON_LOAN_LOAN_STATES) . ')))'; } - /** Lent, or set aside on the pickup shelf: see ON_LOAN_LOAN_STATES. */ - private function isOnLoan(int $copyId): bool + /** + * Lent, or set aside on the pickup shelf: see ON_LOAN_LOAN_STATES. With + * $lock the read is a locking one (inside a transaction), which waits for + * a return of the copy still in progress and reads what it committed. + */ + private function isOnLoan(int $copyId, bool $lock = false): bool { - $stmt = $this->db->prepare('SELECT 1 FROM prestiti WHERE copia_id = ? AND attivo = 1 AND stato IN (' . self::sqlList(self::ON_LOAN_LOAN_STATES) . ') LIMIT 1'); + $stmt = $this->db->prepare('SELECT 1 FROM prestiti WHERE copia_id = ? AND attivo = 1 AND stato IN (' . self::sqlList(self::ON_LOAN_LOAN_STATES) . ') LIMIT 1' . ($lock ? ' LOCK IN SHARE MODE' : '')); if ($stmt === false) { return false; } @@ -749,6 +759,20 @@ private function isOnLoan(int $copyId): bool return $found; } + /** The copy's state, read under a shared lock (inside a transaction); null when it is gone. */ + private function lockedCopyState(int $copyId): ?string + { + $stmt = $this->db->prepare('SELECT stato FROM copie WHERE id = ? LOCK IN SHARE MODE'); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('i', $copyId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_row(); + $stmt->close(); + return $row === null ? null : (string) $row[0]; + } + /** @var array */ private array $shelfLabels = []; diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index de18c6edb..269536a92 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -52,6 +52,7 @@ $sessionIds = []; $triggers = []; $borrowerId = 0; +$other = null; try { foreach (['X', 'Y'] as $tag) { $db->query("INSERT INTO scaffali (codice, nome, lettera) VALUES ('{$p}{$tag}', 'Stocktake {$tag}', '{$tag}')"); @@ -273,6 +274,7 @@ $db->query('SET SESSION innodb_lock_wait_timeout = DEFAULT'); $other->commit(); $other->close(); + $other = null; $check($blocked && (int) $one("SELECT COUNT(*) FROM inventario_scansioni WHERE sessione_id = {$third}") === 0, 'a scan racing a pending close is not recorded'); $check($refused(static fn () => $service->scan($third, "{$p}-C5", null, null)), 'once the close commits the scan is refused'); @@ -408,8 +410,40 @@ (new App\Controllers\InventoryController())->close($request, new Slim\Psr7\Response(), $db, $fifth); $check(($_SESSION['error_message'] ?? '') === __('Azione non valida.') && $one("SELECT stato FROM inventario_sessioni WHERE id = {$fifth}") === 'aperta', 'an unknown missing-copies action is refused and the session stays open'); + // A scan reads the loan status under the locks a return takes (review + // #504): while a return of the copy is still uncommitted the scan waits + // for it, instead of recording the copy as on loan from the state before. + $returnBook = $addBook('B9', $shelves['X']); + $returnCopy = $addCopy($returnBook, 'R1', 'prestato'); + $db->query("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES ({$returnBook}, {$returnCopy}, {$borrowerId}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 14 DAY), 'in_corso', 'diretto', 1)"); + $returnLoan = (int) $db->insert_id; + $returnSession = $service->open('ZZST return ' . $run, null, ['collocazione_da' => "{$p}.B9", 'collocazione_a' => "{$p}.B9"], null); + $sessionIds[] = $returnSession; + $other = new mysqli($env['DB_HOST'] ?? '127.0.0.1', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null)); + $other->begin_transaction(); + $other->query("SELECT id FROM prestiti WHERE id = {$returnLoan} FOR UPDATE"); + $other->query("UPDATE prestiti SET stato = 'restituito', attivo = 0, data_restituzione = CURDATE() WHERE id = {$returnLoan}"); + $other->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$returnCopy}"); + $db->query('SET SESSION innodb_lock_wait_timeout = 1'); + $waited = false; + try { + $service->scan($returnSession, "{$p}-R1", null, $shelves['X']); + } catch (mysqli_sql_exception $e) { + $waited = $e->getCode() === 1205; + } + $db->query('SET SESSION innodb_lock_wait_timeout = DEFAULT'); + $check($waited && (int) $one("SELECT COUNT(*) FROM inventario_scansioni WHERE sessione_id = {$returnSession}") === 0, 'a scan during an uncommitted return of the copy waits for it and records nothing from the state before the return'); + $other->commit(); + $other->close(); + $other = null; + $check($service->scan($returnSession, "{$p}-R1", null, $shelves['X'])['esito'] === 'trovata', 'once the return is committed the same scan finds the copy on the shelf'); + echo "SUCCESS {$checks} stocktake snapshot checks\n"; } finally { + if ($other instanceof mysqli) { + $other->rollback(); // a failed run must not leave the return holding its locks + $other->close(); + } foreach ($triggers as $trigger) { $db->query("DROP TRIGGER IF EXISTS {$trigger}"); } From c9b59f604ed5c74d0aa6da2d57b4fc4d8eba56fe Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:24:46 +0100 Subject: [PATCH 384/398] Stocktake CSV report is streamed and the PDF checks the rows it renders The CSV export collected every report row into one PHP array and the whole file into one string, so a stocktake of a large catalogue could exhaust memory_limit, while that export is the way out for reports too large for the PDF. The report rows now come from a generator over the buffered results and are written one by one into a temporary stream that becomes the response body. The PDF export counted the rows and then read them with a second query, so scans recorded in between could push the rendered report past MAX_ROWS. It now reads the rows once, one past the limit, and refuses when those very rows exceed it; the generator itself also refuses more than MAX_ROWS rows. --- app/Controllers/InventoryController.php | 31 +++++-- app/Services/StocktakeService.php | 113 +++++++++++++++++------- app/Support/StocktakePdfGenerator.php | 4 + tests/stocktake-snapshot.unit.php | 21 +++++ 4 files changed, 130 insertions(+), 39 deletions(-) diff --git a/app/Controllers/InventoryController.php b/app/Controllers/InventoryController.php index 1da44e8a9..a2477f0e2 100644 --- a/app/Controllers/InventoryController.php +++ b/app/Controllers/InventoryController.php @@ -10,6 +10,7 @@ use mysqli; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Slim\Psr7\Factory\StreamFactory; /** * Admin → Inventory stocktake (/admin/inventory). Route access is @@ -390,9 +391,18 @@ public function exportCsv(Request $request, Response $response, mysqli $db, int return $response->withStatus(404); } $labels = self::outcomeLabels(); - $writer = Csv::writerToString(',', "'"); + // Written row by row into a temporary stream (spilled to disk past + // 2 MB), never held as one string: a stocktake of the whole catalogue + // has as many rows as copies, and this export is the way to get a + // report too large for the PDF. + $stream = fopen('php://temp', 'w+b'); + if ($stream === false) { + throw new \RuntimeException('Cannot open a temporary stream for the CSV report'); + } + fwrite($stream, "\xEF\xBB\xBF"); + $writer = Csv::writerToStream($stream, ',', "'"); $writer->insertOne([__('Esito'), __('Codice copia'), __('Titolo'), __('Collocazione'), __('Scaffale atteso'), __('Stato copia'), __('Operatore'), __('Data scansione'), __('Tipo media')]); - foreach ($service->reportRows($id) as $row) { + foreach ($service->eachReportRow($id) as $row) { $writer->insertOne([ $labels[$row['categoria']] ?? $row['categoria'], $row['codice'], @@ -406,8 +416,9 @@ public function exportCsv(Request $request, Response $response, mysqli $db, int $row['tipo_media'] !== '' ? \App\Support\MediaLabels::tipoMediaDisplayName($row['tipo_media']) : '', ]); } - $response->getBody()->write("\xEF\xBB\xBF" . $writer->toString()); + rewind($stream); return $response + ->withBody((new StreamFactory())->createStreamFromResource($stream)) ->withHeader('Content-Type', 'text/csv; charset=utf-8') ->withHeader('Content-Disposition', 'attachment; filename="' . $this->fileBase($session) . '.csv"') ->withHeader('Cache-Control', 'no-store'); @@ -421,15 +432,21 @@ public function exportPdf(Request $request, Response $response, mysqli $db, int $response->getBody()->write(__('Revisione non trovata.')); return $response->withStatus(404); } - $rows = $service->reportRowCount($id); - if ($rows > StocktakePdfGenerator::MAX_ROWS) { - $_SESSION['error_message'] = sprintf(__('Il report ha troppe righe per il PDF (%d, al massimo %d): scarica il report CSV.'), $rows, StocktakePdfGenerator::MAX_ROWS); + // The count refuses a large report before any row is loaded. The rows + // are then read once, one past the limit, and checked again: scans + // recorded between the two reads may have pushed the report over it, + // and the rows checked are the very ones the PDF is built from. + $total = $service->reportRowCount($id); + $rows = $total <= StocktakePdfGenerator::MAX_ROWS ? $service->reportRows($id, StocktakePdfGenerator::MAX_ROWS + 1) : []; + if ($total > StocktakePdfGenerator::MAX_ROWS || count($rows) > StocktakePdfGenerator::MAX_ROWS) { + $total = max($total, count($rows), $service->reportRowCount($id)); + $_SESSION['error_message'] = sprintf(__('Il report ha troppe righe per il PDF (%d, al massimo %d): scarica il report CSV.'), $total, StocktakePdfGenerator::MAX_ROWS); return $this->redirect($response, '/admin/inventory/' . $id); } $pdf = (new StocktakePdfGenerator())->generate( $session, $service->summary($id), - $service->reportRows($id), + $rows, $this->filterLabels($db, $service, $session['filtro']), self::outcomeLabels() ); diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index 4f6db77d1..c1bb1baa8 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -911,6 +911,14 @@ public function summary(int $sessionId, ?int $operatorId = null): array * @return list> */ private function unscannedExpected(int $sessionId, bool $frozen = false): array + { + /** @var list> $rows */ + $rows = $this->unscannedExpectedResult($sessionId, $frozen)->fetch_all(MYSQLI_ASSOC); + return $rows; + } + + /** The unscanned expected copies as a buffered result, read one row at a time by the report. */ + private function unscannedExpectedResult(int $sessionId, bool $frozen): \mysqli_result { $stmt = $this->db->prepare( 'SELECT a.copia_id, a.scaffale_atteso, a.stato_all_apertura, a.esito_chiusura, ' @@ -929,10 +937,12 @@ private function unscannedExpected(int $sessionId, bool $frozen = false): array } $stmt->bind_param('i', $sessionId); $stmt->execute(); - /** @var list> $rows */ - $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $result = $stmt->get_result(); $stmt->close(); - return $rows; + if ($result === false) { + throw new \RuntimeException($this->db->error); + } + return $result; } /** @@ -942,39 +952,69 @@ private function unscannedExpected(int $sessionId, bool $frozen = false): array * close marked lost stays a `mancante` row, with `stato_copia` saying * 'perso'. `stato_copia` is always the copy's state today. * + * With $limit at most that many rows are read: the PDF export asks for one + * more than it can hold, to tell a report that grew past its limit. + * * @return list */ - public function reportRows(int $sessionId): array + public function reportRows(int $sessionId, ?int $limit = null): array { $rows = []; - foreach ($this->unscannedExpected($sessionId, $this->isFrozen($sessionId)) as $row) { - $rows[] = [ - 'categoria' => (int) $row['in_prestito'] === 1 ? 'in_prestito' : 'mancante', - 'codice' => (string) ($row['numero_inventario'] ?? ('#' . (int) $row['copia_id'])), - 'titolo' => (string) ($row['titolo'] ?? ''), - 'tipo_media' => (string) ($row['tipo_media'] ?? ''), - 'collocazione' => (string) ($row['collocazione'] ?? ''), - 'scaffale_atteso' => $row['scaffale_atteso'] !== null ? $this->shelfLabel((int) $row['scaffale_atteso']) : '', - 'stato_copia' => (string) ($row['stato_attuale'] ?? ''), - 'operatore' => '', - 'quando' => '', - ]; + foreach ($this->eachReportRow($sessionId) as $row) { + if ($limit !== null && count($rows) >= $limit) { + break; + } + $rows[] = $row; + } + return $rows; + } + + /** + * The rows of reportRows() one at a time, for an export that writes them + * as they come (the CSV) instead of holding the whole report: the database + * results are buffered by the driver, but never turned into one PHP array. + * + * @return \Generator + */ + public function eachReportRow(int $sessionId): \Generator + { + $result = $this->unscannedExpectedResult($sessionId, $this->isFrozen($sessionId)); + try { + while (is_array($row = $result->fetch_assoc())) { + yield [ + 'categoria' => (int) $row['in_prestito'] === 1 ? 'in_prestito' : 'mancante', + 'codice' => (string) ($row['numero_inventario'] ?? ('#' . (int) $row['copia_id'])), + 'titolo' => (string) ($row['titolo'] ?? ''), + 'tipo_media' => (string) ($row['tipo_media'] ?? ''), + 'collocazione' => (string) ($row['collocazione'] ?? ''), + 'scaffale_atteso' => $row['scaffale_atteso'] !== null ? $this->shelfLabel((int) $row['scaffale_atteso']) : '', + 'stato_copia' => (string) ($row['stato_attuale'] ?? ''), + 'operatore' => '', + 'quando' => '', + ]; + } + } finally { + $result->free(); } // Every scan, not the latest N: the summary counts them all, so must the report. - foreach ($this->scans($sessionId, null, null) as $scan) { - $rows[] = [ - 'categoria' => (string) $scan['esito'], - 'codice' => (string) $scan['codice_letto'], - 'titolo' => (string) ($scan['titolo'] ?? ''), - 'tipo_media' => (string) ($scan['tipo_media'] ?? ''), - 'collocazione' => (string) ($scan['collocazione'] ?? ''), - 'scaffale_atteso' => $scan['scaffale_atteso'] !== null ? $this->shelfLabel((int) $scan['scaffale_atteso']) : '', - 'stato_copia' => (string) ($scan['stato_copia'] ?? ''), - 'operatore' => (string) ($scan['operatore'] ?? ''), - 'quando' => (string) ($scan['scansionato_il'] ?? ''), - ]; + $result = $this->scansResult($sessionId, null, null); + try { + while (is_array($scan = $result->fetch_assoc())) { + yield [ + 'categoria' => (string) $scan['esito'], + 'codice' => (string) $scan['codice_letto'], + 'titolo' => (string) ($scan['titolo'] ?? ''), + 'tipo_media' => (string) ($scan['tipo_media'] ?? ''), + 'collocazione' => (string) ($scan['collocazione'] ?? ''), + 'scaffale_atteso' => $scan['scaffale_atteso'] !== null ? $this->shelfLabel((int) $scan['scaffale_atteso']) : '', + 'stato_copia' => (string) ($scan['stato_copia'] ?? ''), + 'operatore' => (string) ($scan['operatore'] ?? ''), + 'quando' => (string) ($scan['scansionato_il'] ?? ''), + ]; + } + } finally { + $result->free(); } - return $rows; } /** How many rows reportRows() returns, counted without loading them. */ @@ -1003,6 +1043,13 @@ public function reportRowCount(int $sessionId): int * @return list> */ public function scans(int $sessionId, ?int $operatorId = null, ?int $limit = 200): array + { + /** @var list> $rows */ + $rows = $this->scansResult($sessionId, $operatorId, $limit)->fetch_all(MYSQLI_ASSOC); + return $rows; + } + + private function scansResult(int $sessionId, ?int $operatorId, ?int $limit): \mysqli_result { $limit = $limit === null ? null : max(1, min(100000, $limit)); $sql = 'SELECT s.id, s.codice_letto, s.esito, s.scansionato_il, s.scansionato_da, s.copia_id, ' @@ -1027,10 +1074,12 @@ public function scans(int $sessionId, ?int $operatorId = null, ?int $limit = 200 $stmt->bind_param('i', $sessionId); } $stmt->execute(); - /** @var list> $rows */ - $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $result = $stmt->get_result(); $stmt->close(); - return $rows; + if ($result === false) { + throw new \RuntimeException($this->db->error); + } + return $result; } /** diff --git a/app/Support/StocktakePdfGenerator.php b/app/Support/StocktakePdfGenerator.php index ae753a73d..1cf25a22e 100644 --- a/app/Support/StocktakePdfGenerator.php +++ b/app/Support/StocktakePdfGenerator.php @@ -31,9 +31,13 @@ class StocktakePdfGenerator * @param list> $rows * @param list $filterLabels * @param array $labels + * @throws \LengthException with more than MAX_ROWS rows: the caller refuses those before */ public function generate(array $session, array $summary, array $rows, array $filterLabels, array $labels): string { + if (count($rows) > self::MAX_ROWS) { + throw new \LengthException(sprintf('A stocktake PDF holds at most %d rows, %d given', self::MAX_ROWS, count($rows))); + } $appName = (string) ConfigStore::get('app.name', 'Biblioteca'); $title = __('Revisione inventariale'); diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index 269536a92..8897d3e4f 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -231,6 +231,27 @@ $pdfResponse = (new App\Controllers\InventoryController())->exportPdf((new Slim\Psr7\Factory\ServerRequestFactory())->createServerRequest('GET', '/'), new Slim\Psr7\Response(), $db, $big); $check($pdfResponse->getStatusCode() === 302 && str_ends_with($pdfResponse->getHeaderLine('Location'), '/admin/inventory/' . $big) && (string) $pdfResponse->getBody() === '', 'a report over the PDF row limit is not rendered: the export goes back to the session'); $check(($_SESSION['error_message'] ?? '') === sprintf(__('Il report ha troppe righe per il PDF (%d, al massimo %d): scarica il report CSV.'), $bigRows, App\Support\StocktakePdfGenerator::MAX_ROWS), 'with a message that points to the CSV report'); + // The rows checked against the limit are the rows the PDF is built from + // (review #504): the generator itself refuses more, and the export reads + // the report once, one row past the limit, instead of counting it apart. + $overLimit = false; + try { + $pdfGenerator->generate((array) $service->find($big), [], $service->reportRows($big), [], []); + } catch (LengthException $e) { + $overLimit = true; + } + $check($overLimit, 'the PDF generator refuses more rows than MAX_ROWS instead of building them'); + $check(count($service->reportRows($big, App\Support\StocktakePdfGenerator::MAX_ROWS + 1)) === App\Support\StocktakePdfGenerator::MAX_ROWS + 1 && count($service->reportRows($sessionId, 2)) === 2, 'reportRows() with a limit reads no more rows than asked'); + // The CSV export writes the report row by row into a temporary stream + // (review #504), so a report too large for the PDF is still exported whole. + memory_reset_peak_usage(); + $memBefore = memory_get_usage(); + $bigCsv = (new App\Controllers\InventoryController())->exportCsv((new Slim\Psr7\Factory\ServerRequestFactory())->createServerRequest('GET', '/'), new Slim\Psr7\Response(), $db, $big); + $csvPeak = memory_get_peak_usage() - $memBefore; + $bigCsvBody = (string) $bigCsv->getBody(); + // Holding every row as a PHP array took about 19 times the file size; streaming them takes about 2. + $check($csvPeak < 5 * strlen($bigCsvBody), 'exporting the CSV never holds the whole report in memory: the peak stays within a few times the file size'); + $check(str_starts_with($bigCsvBody, "\xEF\xBB\xBF") && substr_count($bigCsvBody, "\n") === $bigRows + 1 && str_contains($bigCsvBody, "{$p}-BIG-" . ($bigRows - 1)), 'the streamed CSV keeps the BOM, the header and every one of the rows over the PDF limit'); $smallResponse = (new App\Controllers\InventoryController())->exportPdf((new Slim\Psr7\Factory\ServerRequestFactory())->createServerRequest('GET', '/'), new Slim\Psr7\Response(), $db, $second); $check($smallResponse->getStatusCode() === 200 && str_starts_with((string) $smallResponse->getBody(), '%PDF'), 'a report within the limit is still exported as PDF'); From b43a046412752c225c1791e8174fb046e67eb42b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:25:25 +0100 Subject: [PATCH 385/398] Stocktake PDF test prints a 100-character code whole The fixture codes were short, so the test would still pass if the code cell were cut to fit its column like the others. A found row now carries a code of MAX_CODE_LENGTH characters and the test checks it is written whole. --- tests/stocktake-pdf.unit.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/stocktake-pdf.unit.php b/tests/stocktake-pdf.unit.php index 54de6599e..d8e3ed883 100644 --- a/tests/stocktake-pdf.unit.php +++ b/tests/stocktake-pdf.unit.php @@ -78,8 +78,11 @@ protected function createDocument(): TCPDF 'categoria' => $category, 'codice' => $code, 'titolo' => 'Titolo ' . $code, 'tipo_media' => 'libro', 'collocazione' => 'A.1', 'scaffale_atteso' => '[X] Scaffale', 'stato_copia' => 'disponibile', 'operatore' => '', 'quando' => '', ]; +// A code as long as the column allows (MAX_CODE_LENGTH): the cell must print it whole, never cut to fit. +$longCode = 'PDF-LONG-' . str_repeat('L', 91); $rows = [ $row('trovata', 'PDF-FOUND-1'), + ['titolo' => 'Titolo lungo'] + $row('trovata', $longCode), $row('mancante', 'PDF-MISSING-1'), $row('fuori_posto', 'PDF-MISPLACED-1'), $row('in_prestito', 'PDF-LENT-1'), @@ -89,7 +92,7 @@ protected function createDocument(): TCPDF $row('mancante', 'PDF-MISSING-2'), ]; $session = ['nome' => 'Scaffale X', 'stato' => 'chiusa', 'aperta_il' => '2026-10-01 09:00:00', 'chiusa_il' => '2026-10-02 18:00:00', 'sede_nome' => '']; -$summary = ['attese' => 5, 'trovate' => 1, 'fuori_posto' => 1, 'in_prestito' => 1, 'mancanti' => 2, 'sconosciute' => 1, 'fuori_ambito' => 1, 'duplicate' => 1, 'perse' => 0]; +$summary = ['attese' => 6, 'trovate' => 2, 'fuori_posto' => 1, 'in_prestito' => 1, 'mancanti' => 2, 'sconosciute' => 1, 'fuori_ambito' => 1, 'duplicate' => 1, 'perse' => 0]; $generator = new RecordingStocktakePdfGenerator(); $bytes = $generator->generate($session, $summary, $rows, [], $labels); @@ -118,8 +121,9 @@ protected function createDocument(): TCPDF $positions = array_keys($texts, $r['codice'], true); $check(count($positions) === 1, "{$r['codice']} is written once"); $check($sectionOf($positions[0]) === $r['categoria'], "{$r['codice']} is in the {$r['categoria']} section"); - $check(in_array('Titolo ' . $r['codice'], $texts, true), "{$r['codice']} carries its title"); + $check(in_array($r['titolo'], $texts, true), "{$r['codice']} carries its title"); } +$check(strlen($longCode) === 100 && in_array($longCode, $texts, true), 'a 100-character code is written whole'); $check(array_keys($headingAt) === ['mancante', 'fuori_posto', 'in_prestito', 'sconosciuta', 'fuori_ambito', 'duplicata', 'trovata'], 'sections follow the reading order, missing copies first'); // An empty section is left out instead of printing a bare heading. From b6126a6d2b9c9f12b6e802a19de4f97892e08d3d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:27:05 +0100 Subject: [PATCH 386/398] Integration suite empties the default patron type fines for its run The plain reader of test 9 belongs to the default type, so a daily fine, grace, cap or debt threshold set on that type by the installation would charge or block it and fail the test for the database state. beforeAll now saves those four columns, sets them to NULL, and afterAll puts them back, SQL NULL included. --- tests/stack-090-integration.spec.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js index 76fba8eb4..b579e67a9 100644 --- a/tests/stack-090-integration.spec.js +++ b/tests/stack-090-integration.spec.js @@ -31,6 +31,8 @@ const PASS = 'Circ1234!'; const TYPE_DAYS = 10; const DEBT_MESSAGE = 'Saldo a debito oltre la soglia consentita'; const LOAN_SETTINGS = ['daily_fine', 'fine_grace_days', 'fine_max_per_loan', 'debt_block_threshold', 'closed_weekdays', 'auto_approve_requests', 'fine_charged_email']; +// Columns of the default patron type that charge or block its readers. +const DEFAULT_TYPE_FINES = ['multa_giornaliera', 'giorni_tolleranza', 'multa_massima_prestito', 'soglia_blocco_debito']; const NCIP_NS = 'http://www.niso.org/2008/ncip'; const ncipXml = (service, userId, itemId) => ` @@ -112,6 +114,8 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a const errors = []; let today = ''; let deskLoan = 0; + /** Fine columns of the default type before the suite: [column, value or null] pairs. */ + let savedDefaultFines = null; test.beforeAll(async ({ browser }) => { for (const k of LOAN_SETTINGS) saved[k] = F.readSettingState('loans', k); @@ -121,6 +125,14 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a savedRules = F.stashRows('regole_prestito'); F.dbQuery('DELETE FROM calendario_chiusure'); F.dbQuery('DELETE FROM regole_prestito'); + // The plain reader belongs to the default type: a fine or threshold the + // installation set on it would charge or block that reader in test 9, so + // they are emptied for the run and put back in afterAll (NULL included). + savedDefaultFines = DEFAULT_TYPE_FINES.map((c) => { + const raw = F.dbQuery(`SELECT IF(\`${c}\` IS NULL, 'n', CONCAT('v', \`${c}\`)) FROM tipologie_lettore WHERE predefinita=1 ORDER BY id LIMIT 1`); + return [c, raw.startsWith('v') ? raw.slice(1) : null]; + }); + F.dbQuery(`UPDATE tipologie_lettore SET ${DEFAULT_TYPE_FINES.map((c) => `\`${c}\`=NULL`).join(', ')} WHERE predefinita=1`); // Global fines and threshold empty: only the patron type charges and blocks. for (const k of ['daily_fine', 'fine_max_per_loan', 'debt_block_threshold']) writeSetting(k, ''); writeSetting('fine_grace_days', '0'); @@ -164,6 +176,11 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a () => clearConfigCache(), () => F.unstashRows(savedClosures), () => F.unstashRows(savedRules), + () => { + if (!savedDefaultFines) return; + const sets = savedDefaultFines.map(([c, v]) => `\`${c}\`=${v === null ? 'NULL' : F.sqlStr(v)}`).join(', '); + F.dbQuery(`UPDATE tipologie_lettore SET ${sets} WHERE predefinita=1`); + }, () => { for (const p of [reader, plain]) { if (!p) continue; From 204394bdadb791bc4288bb60e9722b30225fa6fc Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:29:22 +0100 Subject: [PATCH 387/398] Source guard sees a placeholder wrapped in any function after LIKE Check 18 recognised LIKE ?, LIKE :name and LIKE CONCAT(, but not LOWER(nome) LIKE LOWER(?), which bypasses the quote rule just as well. The pattern now flags any function call right after LIKE, and a new check 18a runs it against probe statements so the guard itself is tested. --- tests/stack-090-review-fixes.unit.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/stack-090-review-fixes.unit.php b/tests/stack-090-review-fixes.unit.php index 37bb785a9..999cf41a0 100644 --- a/tests/stack-090-review-fixes.unit.php +++ b/tests/stack-090-review-fixes.unit.php @@ -162,6 +162,10 @@ $allowed = [ 'app/Services/StocktakeService.php' => ['(l.collocazione <= ? OR l.collocazione LIKE ?)'], ]; +// A placeholder, or any function call (CONCAT(, LOWER(?), UPPER(:q)...) +// right after LIKE: wrapping the typed value in a function bypasses the +// quote rule just as well. +$bareLike = '/[^\n]*\bLIKE\s+(?:\?|:\w+|\w+\s*\()[^\n]*/i'; $offenders = []; foreach ($files as $rel) { $src = (string) @file_get_contents($root . '/' . $rel); @@ -169,7 +173,7 @@ $offenders[] = "{$rel} (missing)"; continue; } - if (preg_match_all('/[^\n]*\bLIKE\s+(?:\?|:\w+|CONCAT\s*\()[^\n]*/i', $src, $hits) > 0) { + if (preg_match_all($bareLike, $src, $hits) > 0) { foreach ($hits[0] as $line) { $ok = false; foreach ($allowed[$rel] ?? [] as $fragment) { @@ -181,7 +185,10 @@ } } } -$check($offenders === [], '18 no bare LIKE on typed input (?, :name or CONCAT) in the 0.9.0 search code' . ($offenders !== [] ? ' — ' . implode(' | ', $offenders) : '')); +$probes = ["WHERE nome LIKE ?", "WHERE nome LIKE :q", "LIKE CONCAT('%', ?, '%')", "WHERE LOWER(nome) LIKE LOWER(?)", "AND cognome LIKE UPPER( :term )"]; +$check(array_filter($probes, static fn (string $sql): bool => preg_match($bareLike, $sql) !== 1) === [] && preg_match($bareLike, "WHERE nome = ? AND note NOT LIKE 'x%'") !== 1, + '18a the guard sees a placeholder after LIKE bare or wrapped in any function (LOWER(?), UPPER(:term)), not a literal pattern'); +$check($offenders === [], '18 no bare LIKE on typed input (?, :name or a function around them) in the 0.9.0 search code' . ($offenders !== [] ? ' — ' . implode(' | ', $offenders) : '')); $view = (string) file_get_contents($root . '/app/Views/fornitori/index.php'); $check(!str_contains($view, ".includes(q)") && str_contains($view, 'QUOTES.includes(ch)') && str_contains($view, "normalize('NFD')") && str_contains($view, 'search_fornitore_hint'), '19 the suppliers box tokenises quoted groups, folds accents for plain words and shows the quote hint'); From 0d6e0e7c935b69feee8f613526fac8f48ea09d62 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:29:22 +0100 Subject: [PATCH 388/398] Review-fix specs assert what the page shows, not only the API answer D4 expects exactly the 20 contributors of a reduced MARC record instead of anything from 1 to 20. E4 and E5 check that the users and books tables draw exactly the rows of the filtered ids after each response, E3 checks that a box holding only quotes hides no supplier row at all, and the zero-config return email may not carry a fine amount even without its label. --- tests/stack-090-review-fixes.spec.js | 21 ++++++++++++++++++--- tests/stack-090-zero-config.spec.js | 3 +++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js index 1cb22faef..8bb2d9e77 100644 --- a/tests/stack-090-review-fixes.spec.js +++ b/tests/stack-090-review-fixes.spec.js @@ -416,9 +416,9 @@ test.describe.serial('D. MARC import and export review fixes', () => { expect(read.errors).toBe(0); expect(read.records).toHaveLength(1); expect(read.records[0].titolo).toBe(title); - // Reduced: at most 20 contributors, the first one kept. - expect(read.records[0].autori.length).toBeGreaterThan(0); - expect(read.records[0].autori.length).toBeLessThanOrEqual(20); + // Reduced: exactly 20 contributors (MarcExportController::REDUCED_AUTHORS) + // of the 450, the first one kept. Fewer would mean the export lost some. + expect(read.records[0].autori).toHaveLength(20); expect(read.records[0].autori[0]).toContain(`Autore ${MRUN} 001`); }); }); @@ -494,6 +494,8 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { expect(await visibleSuppliers()).toEqual([]); await filterSuppliers('""'); expect((await visibleSuppliers()).filter((n) => n.endsWith(ERUN))).toEqual(Object.values(suppliers).map((s) => s.name).sort()); + // Every supplier, not only this run's: no row of the list is hidden. + expect(await page.locator('[data-supplier-row]:not(.hidden)').count()).toBe(await page.locator('[data-supplier-row]').count()); }); /** The next list response whose query string has every given value. */ @@ -503,18 +505,27 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { return Object.entries(params).every(([k, v]) => (q.get(k) || '') === String(v)); }, { timeout: 15000 }); + /** The table ends up drawing exactly the rows of these ids (the API answer alone does not prove it). */ + const expectShownIds = async (table, ids) => { + const boxes = page.locator(`${table} tbody .row-select`); + await expect.poll(async () => (await boxes.evaluateAll((els) => els.map((e) => Number(e.getAttribute('data-id'))))).sort((a, b) => a - b), { timeout: 15000 }) + .toEqual([...ids].sort((a, b) => a - b)); + }; + test('E4. users list: the patron type filter and a quoted surname combine', async () => { await page.goto(`${F.BASE}/admin/users`); let resp = listResponse('/api/utenti', { patron_type_filter: typeId }); await page.locator('#patron_type_filter').selectOption(String(typeId)); let json = await (await resp).json(); const rowsOf = (j) => (j.data || []).map((r) => JSON.stringify(r)).join(' '); + await expectShownIds('#utenti-table', [patrons[0].id, patrons[1].id]); resp = listResponse('/api/utenti', { patron_type_filter: typeId, search_text: `"Häffner${ERUN}"` }); await page.locator('#search_text').fill(`"Häffner${ERUN}"`); await page.locator('#search_text').dispatchEvent('keyup'); json = await (await resp).json(); expect(json.recordsFiltered).toBe(1); expect(rowsOf(json)).toContain(patrons[0].email); + await expectShownIds('#utenti-table', [patrons[0].id]); resp = listResponse('/api/utenti', { patron_type_filter: typeId, search_text: `haffner${ERUN}` }); await page.locator('#search_text').fill(`haffner${ERUN}`); await page.locator('#search_text').dispatchEvent('keyup'); @@ -522,6 +533,7 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { // Unquoted: both spellings of this type, never the reader of the default type. expect(json.recordsFiltered).toBe(2); expect(rowsOf(json)).not.toContain(patrons[2].email); + await expectShownIds('#utenti-table', [patrons[0].id, patrons[1].id]); }); test('E5. book list: the supplier filter and a quoted title combine', async () => { @@ -532,6 +544,7 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { let json = await (await resp).json(); const rowsOf = (j) => (j.data || []).map((r) => JSON.stringify(r)).join(' '); expect(json.recordsFiltered).toBe(2); + await expectShownIds('#libri-table', [books[0], books[1]]); resp = listResponse('/api/libri', { fornitore_filter: suppliers.umlaut.id, search_text: `"Brücke" ${ERUN}` }); await page.locator('#search_text').fill(`"Brücke" ${ERUN}`); await page.locator('#search_text').dispatchEvent('keyup'); @@ -539,11 +552,13 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { expect(json.recordsFiltered).toBe(1); expect(rowsOf(json)).toContain(`Brücke ${ERUN}`); expect(rowsOf(json)).not.toContain('senza'); + await expectShownIds('#libri-table', [books[0]]); resp = listResponse('/api/libri', { fornitore_filter: suppliers.umlaut.id, search_text: `brucke ${ERUN}` }); await page.locator('#search_text').fill(`brucke ${ERUN}`); await page.locator('#search_text').dispatchEvent('keyup'); json = await (await resp).json(); expect(json.recordsFiltered).toBe(2); expect(rowsOf(json)).not.toContain('senza'); + await expectShownIds('#libri-table', [books[0], books[1]]); }); }); diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js index b50780351..b68a27b6e 100644 --- a/tests/stack-090-zero-config.spec.js +++ b/tests/stack-090-zero-config.spec.js @@ -308,6 +308,9 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { expect(returned, 'loan_returned email in Mailpit').not.toBeNull(); expect(returned.HTML).not.toContain('Multa'); expect(returned.HTML).not.toContain('{{multa}}'); + // Not even an amount without its label (a "0,00 €" left in the {{multa}} + // paragraph): FineParameters::format() writes " €" or " ". + expect(returned.HTML).not.toMatch(/\d[.,]\d{2}\s*(?:€|[A-Z]{3}\b)|(?:€|\b[A-Z]{3})\s*-?\d+[.,]\d{2}/); expect(await search(`to:${reader.email} subject:"Multa per ritardo"`)).toEqual([]); }); From 4aef5ad55133f0bb4ce232f2ef11f6c4774a2419 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:51:45 +0100 Subject: [PATCH 389/398] Unconverted legacy penalties block nobody while loss penalties are off With loss penalties off the backfill never completes and chargeLossPenalty() re-arms the marker, so debtBalanceCents() and debtBalanceSql() kept adding every unconverted prestiti.sanzione: patrons were refused loans for amounts the library chose not to mirror, while the balance shown to staff and patrons stayed under the threshold. legacyPenaltiesPending() now answers false when loss penalties are off, so the debt block reads the ledger alone in PHP and SQL. --- app/Services/PatronLedger.php | 11 +++++++++-- tests/patron-ledger.unit.php | 5 +++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/Services/PatronLedger.php b/app/Services/PatronLedger.php index 002fd3584..9e9a90ff2 100644 --- a/app/Services/PatronLedger.php +++ b/app/Services/PatronLedger.php @@ -166,11 +166,18 @@ public static function balanceSql(string $userIdExpr): string /** * Whether the legacy penalty conversion has not completed yet - * (loans.legacy_penalties_backfilled unset): the upgrade's pass was - * deferred or failed and maintenance has not finished it. + * (loans.legacy_penalties_backfilled unset) while loss penalties are + * on: the upgrade's pass was deferred or failed and maintenance has not + * finished it. */ public static function legacyPenaltiesPending(mysqli $db): bool { + // With loss penalties off the library does not mirror prestiti.sanzione + // in the ledger (the backfill never runs), so nothing is pending: the + // debt block reads the ledger alone, the balance staff and patrons see. + if (!\App\Support\Features::enabled('loss_penalties')) { + return false; + } return (new \App\Models\SettingsRepository($db))->get('loans', 'legacy_penalties_backfilled', '0') !== '1'; } diff --git a/tests/patron-ledger.unit.php b/tests/patron-ledger.unit.php index 6c14c550a..3d3a86d1c 100644 --- a/tests/patron-ledger.unit.php +++ b/tests/patron-ledger.unit.php @@ -247,6 +247,11 @@ $setSetting('legacy_penalties_backfilled', null); $check($ledger->balanceCents($u5) === 0 && $ledger->debtBalanceCents($u5) === 800, 'backfill pending: the debt balance counts the unconverted 8.00, the ledger balance does not'); $check(LoanEligibility::checkUser($db, $u5) === 'debt_block' && !$sqlEligible($u5), 'backfill pending, unconverted 8.00 over the threshold 5: blocked in PHP and SQL'); + // Loss penalties off: the library does not mirror those amounts, the + // backfill never completes, and the visible balance leaves them out. + \App\Support\Features::override('loss_penalties', false); + $check($ledger->debtBalanceCents($u5) === 0 && LoanEligibility::checkUser($db, $u5) === null && $sqlEligible($u5), 'loss penalties off, marker unset: the unconverted 8.00 blocks nobody in PHP or SQL'); + \App\Support\Features::override('loss_penalties', true); $setSetting('legacy_penalties_backfilled', '1'); $check(LoanEligibility::checkUser($db, $u5) === null && $sqlEligible($u5), 'marker set: the debt block reads the ledger alone, as before'); $setSetting('legacy_penalties_backfilled', null); From 72524a1143ad67e58f5993d43abb61a163b05ae9 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:55:26 +0100 Subject: [PATCH 390/398] A stocktake file upload no longer locks loan and copy rows importCodes() keeps one transaction for the whole file, so the locking reads record() takes for a single scan held the copie and prestiti locks of every recorded code until the last one: returns waited for the upload, and a desk loan of a copy further down the file could deadlock with it. The upload now reads the committed loan state without locks; a single scan, in its own short transaction, keeps the locking reads that wait for a return in progress. --- app/Services/StocktakeService.php | 22 ++++++++++++++++------ tests/stocktake-snapshot.unit.php | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index c1bb1baa8..2e9b2e732 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -625,12 +625,22 @@ private function record(int $sessionId, string $code, ?int $operatorId, ?int $sc $alreadyScanned = $stmt->get_result()->fetch_row() !== null; $stmt->close(); - // The loan status is read inside the transaction with locking - // reads, in the order a return takes its locks (the loan, then - // the copy): a return in progress is waited for, so the scan - // never records 'in_prestito' for a copy already back on the shelf. - $onLoan = $this->isOnLoan($copyId, true); - $copy['stato'] = $this->lockedCopyState($copyId) ?? (string) ($copy['stato'] ?? ''); + if ($insideTransaction) { + // A file upload holds one transaction for the whole file: + // locking reads would keep loans and returns of every copy + // already recorded waiting until the last code, and deadlock + // with a loan of a copy further down the file. It reads the + // committed state, as the upload found it. + $onLoan = $this->isOnLoan($copyId); + } else { + // A single scan reads the loan status inside its short + // transaction with locking reads, in the order a return + // takes its locks (the loan, then the copy): a return in + // progress is waited for, so the scan never records + // 'in_prestito' for a copy already back on the shelf. + $onLoan = $this->isOnLoan($copyId, true); + $copy['stato'] = $this->lockedCopyState($copyId) ?? (string) ($copy['stato'] ?? ''); + } $onLoan = $onLoan || in_array($copy['stato'], self::ON_LOAN_COPY_STATES, true); } diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index 8897d3e4f..39ddcbe2e 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -299,6 +299,32 @@ $check($blocked && (int) $one("SELECT COUNT(*) FROM inventario_scansioni WHERE sessione_id = {$third}") === 0, 'a scan racing a pending close is not recorded'); $check($refused(static fn () => $service->scan($third, "{$p}-C5", null, null)), 'once the close commits the scan is refused'); + // A loan at the desk holds the copy row (review #504): a single scan of it + // waits for the loan, a file upload does not lock circulation rows at all. + $locked = $service->open('ZZST locks ' . $run, null, ['collocazione_da' => "{$p}.B2", 'collocazione_a' => "{$p}.B2"], null); + $sessionIds[] = $locked; + $other = new mysqli($env['DB_HOST'] ?? '127.0.0.1', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null)); + $other->begin_transaction(); + $other->query("SELECT id FROM copie WHERE id = {$c5} FOR UPDATE"); + $db->query('SET SESSION innodb_lock_wait_timeout = 1'); + try { + $service->scan($locked, "{$p}-C5", null, null); + $scanWaited = false; + } catch (\Throwable) { + $scanWaited = true; + } + try { + $uploadTotal = $service->importCodes($locked, ["{$p}-C5"], null, null)['total']; + } catch (\Throwable) { + $uploadTotal = -1; + } + $db->query('SET SESSION innodb_lock_wait_timeout = DEFAULT'); + $other->rollback(); + $other->close(); + $other = null; + $check($scanWaited, 'a single scan of a copy a loan holds waits for the loan'); + $check($uploadTotal === 1 && (int) $one("SELECT COUNT(*) FROM inventario_scansioni WHERE sessione_id = {$locked}") === 1, 'a file upload of the same copy is recorded without waiting on the loan'); + // Closing with "mark lost" from the page must run to the end: the session // is claimed first, so a request cut off halfway could not be retried. $fourth = $service->open('ZZST4 ' . $run, null, ['collocazione_da' => "{$p}.B2", 'collocazione_a' => "{$p}.B2"], null); From c70ef66b92f9fb50ac571d7d82f9d82ab705f572 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:55:49 +0100 Subject: [PATCH 391/398] The upload form comes back after a cancel that could not be logged A cancel whose history entry failed hid the resume panel but left the upload step hidden too (it is rendered hidden while a run is resumable), so the page showed only the warning and no way to load a new file until a reload. The upload step is now shown under the warning. --- app/Views/utenti/import.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Views/utenti/import.php b/app/Views/utenti/import.php index 656102556..9a6276ce4 100644 --- a/app/Views/utenti/import.php +++ b/app/Views/utenti/import.php @@ -436,9 +436,11 @@ function renderMapping(data) { } if (closed.history_warning) { // Closed, but not written to the history: say it here, a reload - // would show the empty upload form as if nothing had happened. + // would show the empty upload form as if nothing had happened. The + // upload form comes back under the warning so a new file can follow. previewId = null; $('user-import-step-resume').classList.add('hidden'); + $('user-import-step-upload').classList.remove('hidden'); showError(closed.history_warning); return; } From 228abca99da67d04c8bb2aa5acead8bed33fa851 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 11:06:26 +0100 Subject: [PATCH 392/398] Book request: a late start still reaches its full loan window 0162a39c stopped the longest return date at the last loaded availability day, but the book page loads 180 days and lets a reader start on almost any of them: a start four months ahead could no longer have its return date in the following month. The public availability route now loads occupancy data past the 180 start days by the longest loan plus the closed-day margin (the same horizon the closed days already use), and returns start_until, the last day a request may start on, which the start picker uses as its maximum. The clamp from 0162a39c stays as a safety net. --- app/Controllers/ReservationsController.php | 11 ++++++++++- app/Routes/web.php | 8 ++++++-- app/Views/frontend/book-detail.php | 8 ++++++-- tests/loan-window-cap.unit.php | 17 +++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index 3f25d1b52..d5e75bc6e 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -782,7 +782,7 @@ private function autoApproveLoanRequest($request, int $loanId, ?bool $knownEnabl * answer the filter would give, without hiding the record from the * operator or from the reader who already has it reserved. */ - public function getBookAvailabilityData($bookId, ?string $startDate = null, int $days = 730, ?int $excludeUserId = null): ?array + public function getBookAvailabilityData($bookId, ?string $startDate = null, int $days = 730, ?int $excludeUserId = null, bool $coverLoanWindow = false): ?array { $bookStmt = $this->db->prepare("SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL"); $bookStmt->bind_param('i', $bookId); @@ -838,6 +838,14 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int // a closed day forward to the next open day (additive field). $from = $startDate ?? \App\Support\DateHelper::today(); $closedHorizon = max(0, $days - 1) + max($policy->durationDays, $policy->maxRequestDays) + 31; + // start_until is the last day a request may start on. A start picked + // there still needs occupancy data for its whole loan window, closed + // days included: with $coverLoanWindow the days run on as far as the + // closed days do, or a booked day past them would read as free. + $startUntil = \App\Support\LibraryCalendar::shift($from, max(0, $days - 1)); + if ($coverLoanWindow) { + $days = $closedHorizon + 1; + } $closedDates = \App\Support\LibraryCalendar::forDb($this->db) ->closedDatesBetween($from, \App\Support\LibraryCalendar::shift($from, $closedHorizon)); return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId) + [ @@ -845,6 +853,7 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int 'max_loan_duration_days' => $policy->maxRequestDays, 'staff_loan_duration_days' => $policy->durationDays, 'closed_dates' => $closedDates, + 'start_until' => $startUntil, ]; } diff --git a/app/Routes/web.php b/app/Routes/web.php index a1bdf5074..a21eeb4ba 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -3105,7 +3105,9 @@ if ($forUser !== null && is_numeric($forUser) && \App\Support\Permissions::can('loans.manage')) { $excludeUserId = (int) $forUser; } - $availability = $controller->getBookAvailabilityData($bookId, \App\Support\DateHelper::today(), 180, $excludeUserId); + // 180 days to start in, and occupancy data past them for the + // longest loan a start on the last of them can ask for. + $availability = $controller->getBookAvailabilityData($bookId, \App\Support\DateHelper::today(), 180, $excludeUserId, true); if ($availability === null) { $response->getBody()->write(json_encode(['success' => false, 'message' => __('Libro non trovato')])); return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); @@ -3130,7 +3132,9 @@ // this material (CirculationPolicy), for the admin form. 'staff_loan_duration_days' => $availability['staff_loan_duration_days'] ?? null, // Closures calendar: days the library is closed (not a start/pickup day). - 'closed_dates' => $availability['closed_dates'] ?? [] + 'closed_dates' => $availability['closed_dates'] ?? [], + // Last day a request may start on; 'days' run past it. + 'start_until' => $availability['start_until'] ?? null ] ]; $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE)); diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index 2602e1d55..e0f662a15 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -1793,8 +1793,12 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays, clos } return acc; }, {}); - // Get the last date in the availability data to set maxDate - if (availData.availability.days.length > 0) { + // The last start day sets maxDate: the days run past it so a + // late start still has data for its whole loan window. + const startUntil = String(availData.availability.start_until || ''); + if (/^\d{4}-\d{2}-\d{2}$/.test(startUntil)) { + maxAvailableDate = startUntil; + } else if (availData.availability.days.length > 0) { const lastDay = availData.availability.days[availData.availability.days.length - 1]; if (lastDay && lastDay.date) { maxAvailableDate = lastDay.date; diff --git a/tests/loan-window-cap.unit.php b/tests/loan-window-cap.unit.php index c63f96ef1..5f5c6ec43 100644 --- a/tests/loan-window-cap.unit.php +++ b/tests/loan-window-cap.unit.php @@ -224,6 +224,19 @@ '4 availability payload: free the day before the scheduled loan, borrowed from its first day', json_encode([$byDate[$expectedMaxEnd] ?? null, $byDate[$conflictStart] ?? null])); +// A start on the last offered day still has occupancy data for its whole loan +// window: the public route asks for it, the default call is unchanged. +$plain = (new ReservationsController($db))->getBookAvailabilityData($singleBook, $today, 180, $requester); +$covered = (new ReservationsController($db))->getBookAvailabilityData($singleBook, $today, 180, $requester, true); +$lastPlain = is_array($plain) ? (string) (end($plain['days'])['date'] ?? '') : ''; +$lastCovered = is_array($covered) ? (string) (end($covered['days'])['date'] ?? '') : ''; +$startUntil = $addDays($today, 179); +$check(($plain['start_until'] ?? null) === $startUntil && $lastPlain === $startUntil + && ($covered['start_until'] ?? null) === $startUntil + && $lastCovered >= $addDays($startUntil, (int) ($covered['max_loan_duration_days'] ?? 0) + 31), + '4b the availability covering the loan window runs past the last start day by the longest loan plus the closed-day margin', + json_encode([$plain['start_until'] ?? null, $lastPlain, $lastCovered, $covered['max_loan_duration_days'] ?? null])); + // ═════════ 5-7: the public calendar endpoint explains the cap ════════════════ $r = $requestCalendar($singleBook, ['start_date' => $today]); $check($r['status'] === 400 @@ -327,6 +340,10 @@ && str_contains($viewSrc, 'Durata massima del prestito: %1$d giorni') && str_contains($viewSrc, 'LoanWindow::explainFromParams($_GET)'), '20 book-detail.php caps the return picker, explains it with the shared message and renders the redirect explanation'); +$routes = (string) file_get_contents($root . '/app/Routes/web.php'); +$check(str_contains($viewSrc, 'availData.availability.start_until') + && str_contains($routes, "getBookAvailabilityData(\$bookId, \\App\\Support\\DateHelper::today(), 180, \$excludeUserId, true)"), + '21 the book page starts no later than start_until, and its route loads the days past it'); $cleanup(); $db->close(); From a26a4ed4982de675b8b6c8d4a149dd1cef5d289f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 14:58:28 +0100 Subject: [PATCH 393/398] Installer: stop warning about triggers that were created importTriggers() counted a trigger only when the chunk between two $$ delimiters started with CREATE TRIGGER, but every chunk starts with comments and a DROP TRIGGER. The count stayed at 0, so every successful install ended on the "manual action required" page, blaming database permissions while all the triggers were in place. The final page now also shows the database error behind a warning, since the fixed permissions text hid the real cause whatever it was. --- installer/classes/Installer.php | 3 +- installer/steps/step7.php | 3 + tests/installer-trigger-import.unit.php | 124 ++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 tests/installer-trigger-import.unit.php diff --git a/installer/classes/Installer.php b/installer/classes/Installer.php index 98f144926..80fb81842 100755 --- a/installer/classes/Installer.php +++ b/installer/classes/Installer.php @@ -612,7 +612,8 @@ public function importTriggers() { try { $pdo->exec($statement); - if (stripos($statement, 'CREATE TRIGGER') === 0) { + // A chunk carries its comments and DROP TRIGGER before the CREATE + if (preg_match('/^\s*CREATE\s+TRIGGER\b/mi', $statement) === 1) { $executedTriggers++; } } catch (PDOException $e) { diff --git a/installer/steps/step7.php b/installer/steps/step7.php index 6e1ec11bf..a5b607859 100755 --- a/installer/steps/step7.php +++ b/installer/steps/step7.php @@ -145,6 +145,9 @@

      + +

      +

      installer/database/triggers.sql.

      diff --git a/tests/installer-trigger-import.unit.php b/tests/installer-trigger-import.unit.php new file mode 100644 index 000000000..9856f5add --- /dev/null +++ b/tests/installer-trigger-import.unit.php @@ -0,0 +1,124 @@ +set_charset('utf8mb4'); + +$pass = 0; +$fail = 0; +$check = static function (bool $ok, string $label) use (&$pass, &$fail): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . "\n"; + $ok ? $pass++ : $fail++; +}; + +$sandbox = $setting('DB_NAME') . '_migtrg'; +$schema = (string) file_get_contents($root . '/installer/database/schema.sql'); +$triggersSql = (string) file_get_contents($root . '/installer/database/triggers.sql'); +preg_match_all('/^\s*CREATE\s+TRIGGER\s+`([^`]+)`/mi', $triggersSql, $declared); +$expected = $declared[1]; +sort($expected); + +$resetSandbox = static function () use ($admin, $sandbox, $schema): void { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); + $admin->query("CREATE DATABASE `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $admin->select_db($sandbox); + $admin->multi_query($schema); + do { + if ($result = $admin->store_result()) { + $result->free(); + } + } while ($admin->more_results() && $admin->next_result()); +}; + +// A new Installer per run, as each install request builds its own. +$import = static function () use ($root, $sandbox, $setting, $useSocket, $socket, $tcpHost, $port): array { + $installer = new Installer($root); + (new ReflectionProperty($installer, 'config'))->setValue($installer, [ + 'DB_HOST' => $useSocket ? 'localhost' : $tcpHost, + 'DB_PORT' => $port, + 'DB_NAME' => $sandbox, + 'DB_USER' => $setting('DB_USER'), + 'DB_PASS' => $setting('DB_PASS'), + 'DB_SOCKET' => $useSocket ? $socket : '', + ]); + $installer->importTriggers(); + return $installer->getTriggerWarnings(); +}; + +$installed = static function () use ($admin, $sandbox): array { + $rows = $admin->query( + "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = '{$sandbox}' ORDER BY TRIGGER_NAME" + )->fetch_all(); + return array_column($rows, 0); +}; + +try { + $check(count($expected) >= 5, 'triggers.sql declares the triggers this test relies on'); + + echo "Fresh install\n"; + $resetSandbox(); + $warnings = $import(); + $check($warnings === [], 'all triggers created: no warning (got: ' . implode(' | ', $warnings) . ')'); + $check($installed() === $expected, 'every trigger declared in triggers.sql exists'); + + echo "Second run\n"; + $warnings = $import(); + $check($warnings === [], 're-importing over existing triggers stays silent (got: ' . implode(' | ', $warnings) . ')'); + $check($installed() === $expected, 'the triggers are still all there'); + + echo "A trigger that really fails\n"; + $resetSandbox(); + $admin->query('SET foreign_key_checks = 0'); + $admin->query('DROP TABLE `prestiti`'); + $admin->query('SET foreign_key_checks = 1'); + $warnings = $import(); + $joined = implode(' | ', $warnings); + $check($warnings !== [], 'a failed CREATE TRIGGER is still reported'); + $check(str_contains($joined, 'prestiti'), 'the warning carries the real database error (got: ' . $joined . ')'); + $check(!str_contains($joined, 'Nessun trigger valido'), 'triggers that did get created are not reported as none found'); + $check( + $installed() === array_values(array_filter($expected, static fn (string $name): bool => !str_contains($name, 'prestito'))), + 'the failure does not take the healthy triggers down with it' + ); +} finally { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); +} + +echo "\n{$pass} passed, {$fail} failed\n"; +exit($fail > 0 ? 1 : 0); From 704c726ab749026c1b73640cc74f96e3f7d76494 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 15:16:30 +0100 Subject: [PATCH 394/398] Release gate: accept a CodeRabbit skip caused only by its file limit CodeRabbit refuses to review a pull request with more than 100 files and now reports that as a skipped check run instead of a successful status. A release PR that carries a stacked series always crosses the limit, so the prerelease gate vetoed v0.9.0-rc.1 even though every PR in the series was reviewed on its own. The gate now reads the CodeRabbit app's check run on the tagged commit and accepts the skip only when every run comes from the coderabbitai app, targets that exact commit, and carries the file-limit title. A rate-limit skip, a skip on another commit or a run posted by another app still vetoes, and the skip never excuses any other failing check. --- scripts/ci-verify-release-source.sh | 33 +++++++++++++++++++++++++++-- tests/release-source-policy.test.sh | 21 +++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/scripts/ci-verify-release-source.sh b/scripts/ci-verify-release-source.sh index f37d041b4..c0b47ccb4 100755 --- a/scripts/ci-verify-release-source.sh +++ b/scripts/ci-verify-release-source.sh @@ -4,6 +4,7 @@ # Stable versions must already be contained in main. Prereleases may be cut # before merge, but only from the exact, merge-ready head of an internal # release/* pull request whose required checks (including CodeRabbit) passed. +# The one exception is a CodeRabbit check skipped only for its file limit. set -euo pipefail @@ -161,6 +162,29 @@ elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then exit 1 fi + # CodeRabbit refuses to review a PR with more than its file limit and now + # reports that as a skipped check instead of a successful status. A release + # PR carrying a stacked series always crosses that limit, while every PR of + # the series was reviewed on its own. Accept exactly that skip, read from the + # CodeRabbit app's own check run on the tagged commit; any other skip + # (rate limit, paused reviews, a skip posted by another app) still vetoes. + coderabbit_file_limit_skip=false + coderabbit_runs="$(gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs" \ + -f check_name=CodeRabbit || true)" + if jq -e --arg sha "$GITHUB_SHA" ' + (.check_runs // []) as $runs + | ($runs | length) > 0 + and all($runs[]; + .app.slug == "coderabbitai" + and .head_sha == $sha + and .conclusion == "skipped" + and ((.output.title // "") | test("^Review skipped: [0-9]+ files exceed the limit of [0-9]+$"))) + ' >/dev/null 2>&1 <<<"$coderabbit_runs"; then + coderabbit_file_limit_skip=true + echo "CodeRabbit skipped PR #${pr_number} only because of its file limit; the stacked PRs were reviewed one by one" >&2 + fi + # Every required check except this release workflow's own run must be in the # "pass" bucket (pending/fail/cancel/skipping all veto). The self-exclusion # is defense in depth for the circularity above: should someone ever mark @@ -169,14 +193,19 @@ elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then # The decision is computed from the filtered JSON, not from gh's exit code, # because the exit code would also trip on the self check being "pending". failing_checks="$(jq -r --arg self_workflow "Verified Release" \ - '.[] | select(.workflow != $self_workflow) | select(.bucket != "pass") | "\(.name): \(.state)"' \ + --argjson cr_skip "$coderabbit_file_limit_skip" \ + '.[] | select(.workflow != $self_workflow) | select(.bucket != "pass") + | select(($cr_skip and .name == "CodeRabbit" and .bucket == "skipping") | not) + | "\(.name): \(.state)"' \ <<<"$checks_json")" if [[ -n "$failing_checks" ]]; then echo "Prerelease PR #${pr_number} has required checks that did not pass:" >&2 printf '%s\n' "$failing_checks" >&2 exit 1 fi - if ! jq -e 'any(.[]; .name == "CodeRabbit" and .bucket == "pass")' >/dev/null <<<"$checks_json"; then + if ! jq -e --argjson cr_skip "$coderabbit_file_limit_skip" \ + 'any(.[]; .name == "CodeRabbit" and (.bucket == "pass" or ($cr_skip and .bucket == "skipping")))' \ + >/dev/null <<<"$checks_json"; then echo "Prerelease PR #${pr_number} is missing a passing required CodeRabbit check" >&2 exit 1 fi diff --git a/tests/release-source-policy.test.sh b/tests/release-source-policy.test.sh index 72b8d0985..752bb318f 100755 --- a/tests/release-source-policy.test.sh +++ b/tests/release-source-policy.test.sh @@ -23,7 +23,17 @@ FAKEGIT cat >"$test_root/bin/gh" <<'FAKEGH' #!/usr/bin/env bash -if [[ "${1:-}" == api ]]; then +if [[ "${1:-}" == api && "$*" == *"/check-runs"* ]]; then + title='Review skipped: 654 files exceed the limit of 100' + case "${FAKE_CR_RUN:-none}" in + file_limit) printf '{"total_count":1,"check_runs":[{"app":{"slug":"coderabbitai"},"head_sha":"sha-rc","conclusion":"skipped","output":{"title":"%s"}}]}\n' "$title" ;; + wrong_sha) printf '{"total_count":1,"check_runs":[{"app":{"slug":"coderabbitai"},"head_sha":"other-sha","conclusion":"skipped","output":{"title":"%s"}}]}\n' "$title" ;; + rate_limit) printf '{"total_count":1,"check_runs":[{"app":{"slug":"coderabbitai"},"head_sha":"sha-rc","conclusion":"skipped","output":{"title":"Review skipped: rate limit exceeded"}}]}\n' ;; + other_app) printf '{"total_count":1,"check_runs":[{"app":{"slug":"impostor-bot"},"head_sha":"sha-rc","conclusion":"skipped","output":{"title":"%s"}}]}\n' "$title" ;; + mixed) printf '{"total_count":2,"check_runs":[{"app":{"slug":"coderabbitai"},"head_sha":"sha-rc","conclusion":"skipped","output":{"title":"%s"}},{"app":{"slug":"impostor-bot"},"head_sha":"sha-rc","conclusion":"skipped","output":{"title":"%s"}}]}\n' "$title" "$title" ;; + *) printf '{"total_count":0,"check_runs":[]}\n' ;; + esac +elif [[ "${1:-}" == api ]]; then case "${FAKE_PR_KIND:-valid}" in none) printf '[[]]\n' ;; draft) printf '[[{"number":341,"draft":true,"base":{"ref":"main"},"head":{"sha":"sha-rc","ref":"release/0.7.59-rc.1","repo":{"full_name":"fabiodalez-dev/Pinakes"}}}]]\n' ;; @@ -64,6 +74,8 @@ elif [[ "${1:-} ${2:-}" == "pr checks" ]]; then exit 8 fi ;; + coderabbit_skipped) printf '[{"name":"CodeRabbit","state":"SKIPPED","bucket":"skipping","workflow":""},{"name":"Full E2E","state":"SUCCESS","bucket":"pass","workflow":"E2E"}]\n' ;; + coderabbit_skipped_e2e_failed) printf '[{"name":"CodeRabbit","state":"SKIPPED","bucket":"skipping","workflow":""},{"name":"Full E2E","state":"FAILURE","bucket":"fail","workflow":"E2E"}]\n'; exit 1 ;; missing_coderabbit) printf '[{"name":"Full E2E","state":"SUCCESS","bucket":"pass","workflow":"E2E"}]\n' ;; empty) printf '[]\n' ;; *) printf '[{"name":"CodeRabbit","state":"SUCCESS","bucket":"pass","workflow":""},{"name":"Full E2E","state":"SUCCESS","bucket":"pass","workflow":"E2E"}]\n' ;; @@ -129,6 +141,13 @@ run_case "RC rejects self-blocked state when GitHub reports a conflict" fail "0. run_case "RC rejects self-blocked state when a review is required" fail "0.7.59-rc.3" "sha-rc" FAKE_MERGE_STATE=BLOCKED FAKE_CHECKS=self_blocked FAKE_REVIEW_DECISION=REVIEW_REQUIRED run_case "RC with pending required check" fail "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=pending run_case "RC without required CodeRabbit" fail "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=missing_coderabbit +run_case "RC accepts CodeRabbit skipped only for its file limit" pass "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=coderabbit_skipped FAKE_CR_RUN=file_limit +run_case "RC rejects CodeRabbit skipped without a check run" fail "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=coderabbit_skipped +run_case "RC rejects CodeRabbit skipped for a rate limit" fail "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=coderabbit_skipped FAKE_CR_RUN=rate_limit +run_case "RC rejects a file-limit skip posted by another app" fail "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=coderabbit_skipped FAKE_CR_RUN=other_app +run_case "RC rejects a file-limit skip next to another app's run" fail "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=coderabbit_skipped FAKE_CR_RUN=mixed +run_case "RC rejects a file-limit skip on another commit" fail "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=coderabbit_skipped FAKE_CR_RUN=wrong_sha +run_case "RC file-limit skip does not excuse a failed check" fail "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=coderabbit_skipped_e2e_failed FAKE_CR_RUN=file_limit run_case "RC without required checks" fail "0.7.59-rc.3" "sha-rc" FAKE_CHECKS=empty run_case "unsupported prerelease channel" fail "0.7.59-preview.1" "sha-rc" From 32de1eb72717f473ba42afe0724429e804424088 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 15:18:01 +0100 Subject: [PATCH 395/398] Trigger import test: never drop the sandbox database The test dropped and recreated _migtrg with DROP DATABASE, so a database that already carried that name lost its data without warning. It now follows the other sandbox suites: the name can be set with INSTALLER_TRIGGER_SANDBOX_DB and is refused when it is the installation's own database, the database is created only if missing, and a database that already holds tables without this test's marker table is refused. Between scenarios and at the end only the tables are emptied. --- tests/installer-trigger-import.unit.php | 39 +++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/tests/installer-trigger-import.unit.php b/tests/installer-trigger-import.unit.php index 9856f5add..199038427 100644 --- a/tests/installer-trigger-import.unit.php +++ b/tests/installer-trigger-import.unit.php @@ -11,6 +11,9 @@ * * Runs the real importer and the real triggers.sql against a sandbox database: * importTriggers() drops and creates triggers, so it must never see a shared one. + * The sandbox (INSTALLER_TRIGGER_SANDBOX_DB, default _migtrg) is never + * dropped: the test empties only its tables, and refuses a database that holds + * tables without the marker this test leaves behind. * * Run: php tests/installer-trigger-import.unit.php (exit 0 iff all pass) */ @@ -47,17 +50,43 @@ $ok ? $pass++ : $fail++; }; -$sandbox = $setting('DB_NAME') . '_migtrg'; +$sandbox = getenv('INSTALLER_TRIGGER_SANDBOX_DB') ?: ($setting('DB_NAME') . '_migtrg'); +$ownNames = array_map('strtolower', array_filter([$setting('DB_NAME'), (string) getenv('E2E_DB_NAME'), (string) ($env['DB_NAME'] ?? '')], static fn (string $name): bool => $name !== '')); +if (in_array(strtolower($sandbox), $ownNames, true) || !preg_match('/^[A-Za-z0-9_]{1,64}$/', $sandbox)) { + fwrite(STDERR, "FAIL: the sandbox name is the installation's own database or not a plain identifier. Name a disposable one.\n"); + exit(1); +} +$marker = '_installer_trigger_sandbox'; $schema = (string) file_get_contents($root . '/installer/database/schema.sql'); $triggersSql = (string) file_get_contents($root . '/installer/database/triggers.sql'); preg_match_all('/^\s*CREATE\s+TRIGGER\s+`([^`]+)`/mi', $triggersSql, $declared); $expected = $declared[1]; sort($expected); -$resetSandbox = static function () use ($admin, $sandbox, $schema): void { - $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); - $admin->query("CREATE DATABASE `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); +$admin->query("CREATE DATABASE IF NOT EXISTS `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); +$admin->select_db($sandbox); +$tables = array_column($admin->query('SHOW TABLES')->fetch_all(), 0); +if ($tables !== [] && !in_array($marker, $tables, true)) { + fwrite(STDERR, "FAIL: database `{$sandbox}` already holds tables this test did not create. Set INSTALLER_TRIGGER_SANDBOX_DB to a disposable name.\n"); + exit(1); +} + +// Empties the sandbox but keeps the database and the ownership marker: the +// tables' triggers go with them. +$wipeSandbox = static function () use ($admin, $sandbox, $marker): void { $admin->select_db($sandbox); + $admin->query('SET foreign_key_checks = 0'); + foreach (array_column($admin->query('SHOW TABLES')->fetch_all(), 0) as $table) { + if ($table !== $marker) { + $admin->query('DROP TABLE IF EXISTS `' . $table . '`'); + } + } + $admin->query('SET foreign_key_checks = 1'); + $admin->query("CREATE TABLE IF NOT EXISTS `{$marker}` (id TINYINT PRIMARY KEY)"); +}; + +$resetSandbox = static function () use ($admin, $schema, $wipeSandbox): void { + $wipeSandbox(); $admin->multi_query($schema); do { if ($result = $admin->store_result()) { @@ -117,7 +146,7 @@ 'the failure does not take the healthy triggers down with it' ); } finally { - $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); + $wipeSandbox(); } echo "\n{$pass} passed, {$fail} failed\n"; From 23fa2dd92b6d84adfb2c1d32d7482bc857cf29f6 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 15:18:36 +0100 Subject: [PATCH 396/398] CHANGELOG: installer trigger warning fix in 0.9.0-rc.1 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 978583add..2c31fdb0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,9 @@ Release candidate: published as a GitHub prerelease, offered only to installatio - **Catalogue mode** still wins: it also switches off the visitor log, the closures calendar, fines and patron type rules, and the Presenze sidebar entry, which used to stay visible in catalogue mode, now disappears. - **Mobile API**: `/health.features` gains `fines`, `closures_calendar` and `circulation_rules`; with fines off `GET /me/movements` answers 404 `feature_disabled`. +### Fixes +- **Installer: no false "triggers missing" warning.** Every fresh install ended on a page saying the database user could not create triggers and asking for `installer/database/triggers.sql` to be run by hand, even when every trigger had been created: the importer counted a trigger only when its chunk began with `CREATE TRIGGER`, while each chunk begins with comments and a `DROP TRIGGER`. The count now finds the statement wherever it sits, and when a trigger really fails the final page shows the database's own message (escaped) instead of the fixed permissions text. + ### Migration - `migrate_0.9.0-rc.1.sql`, section "MARC bulk import / copy cataloguing": `import_logs.import_type` gains the value `marc`, appended to the enum computed from the current one, so the order of the sections does not matter. Idempotent; existing rows are untouched. From 57959df4d32be8e2a4ee9da0f03d31069d719820 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 15:35:06 +0100 Subject: [PATCH 397/398] Installer: name no cause the trigger warning cannot know The final page opened the trigger warning with "the database user lacks the permissions to create triggers", and since the previous commit it shows the real database error right under it. When a trigger fails for another reason, such as a missing table, the two lines contradicted each other. The opening sentence now only says that some triggers could not be created, in all five locales, and the CHANGELOG entry says both lines are shown. --- CHANGELOG.md | 2 +- installer/steps/step7.php | 2 +- locale/da_DK.json | 2 +- locale/de_DE.json | 2 +- locale/en_US.json | 2 +- locale/fr_FR.json | 2 +- locale/it_IT.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c31fdb0a..e0945f174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,7 +86,7 @@ Release candidate: published as a GitHub prerelease, offered only to installatio - **Mobile API**: `/health.features` gains `fines`, `closures_calendar` and `circulation_rules`; with fines off `GET /me/movements` answers 404 `feature_disabled`. ### Fixes -- **Installer: no false "triggers missing" warning.** Every fresh install ended on a page saying the database user could not create triggers and asking for `installer/database/triggers.sql` to be run by hand, even when every trigger had been created: the importer counted a trigger only when its chunk began with `CREATE TRIGGER`, while each chunk begins with comments and a `DROP TRIGGER`. The count now finds the statement wherever it sits, and when a trigger really fails the final page shows the database's own message (escaped) instead of the fixed permissions text. +- **Installer: no false "triggers missing" warning.** Every fresh install ended on a page saying the database user could not create triggers and asking for `installer/database/triggers.sql` to be run by hand, even when every trigger had been created: the importer counted a trigger only when its chunk began with `CREATE TRIGGER`, while each chunk begins with comments and a `DROP TRIGGER`. The count now finds the statement wherever it sits, and when a trigger really fails the final page says that some triggers could not be created, instead of blaming the database user's permissions, and shows the database's own message (escaped) under it. ### Migration diff --git a/installer/steps/step7.php b/installer/steps/step7.php index a5b607859..0dbfea91b 100755 --- a/installer/steps/step7.php +++ b/installer/steps/step7.php @@ -144,7 +144,7 @@

      -

      +

      diff --git a/locale/da_DK.json b/locale/da_DK.json index 1a8c4d784..47c8b1159 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -2732,7 +2732,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "Begivenhedens tidspunkt skal være i det korrekte format (TT:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "Klokkeslættet skal være i det korrekte format (TT:MM).", "L'ultima sezione che invita all'azione": "Den sidste opfordring til handling", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Databasebrugeren har ikke tilladelse til at oprette TRIGGERES. Installationen er fuldført, men for at garantere fuld dataintegritet er det nødvendigt at installere dem manuelt.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Nogle databasetriggere kunne ikke oprettes. Installationen er gennemført, men for at sikre fuld dataintegritet skal de installeres manuelt.", "L'utente ha ritirato il libro?": "Har brugeren afhentet bogen?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "Brugeren er ikke i suspenderet tilstand. Kun suspenderede brugere kræver godkendelse.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "Brugeren modtager en e-mail med et bekræftelseslink (gyldigt i 7 dage) og kan selv aktivere kontoen.", diff --git a/locale/de_DE.json b/locale/de_DE.json index 1772e371d..7fe802c81 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -2732,7 +2732,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "Die Uhrzeit des Ereignisses muss im korrekten Format vorliegen (HH:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "Die Uhrzeit muss im korrekten Format vorliegen (HH:MM).", "L'ultima sezione che invita all'azione": "Der abschließende Call-to-Action-Bereich", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Der Datenbankbenutzer hat keine Berechtigung zum Erstellen von TRIGGERN. Die Installation wurde abgeschlossen, aber um die vollständige Datenintegrität zu gewährleisten, müssen diese manuell installiert werden.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Einige Datenbank-Trigger konnten nicht erstellt werden. Die Installation wurde abgeschlossen, aber um die vollständige Datenintegrität zu gewährleisten, müssen sie manuell installiert werden.", "L'utente ha ritirato il libro?": "Hat der Benutzer das Buch abgeholt?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "Der Benutzer befindet sich nicht im gesperrten Status. Nur gesperrte Benutzer erfordern eine Genehmigung.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "Der Benutzer erhält eine E-Mail mit Bestätigungslink (7 Tage gültig) und kann das Konto selbstständig aktivieren.", diff --git a/locale/en_US.json b/locale/en_US.json index 4adae6abd..5313d62c8 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -2732,7 +2732,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "Event time must be in the correct format (HH:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "Time must be in the correct format (HH:MM).", "L'ultima sezione che invita all'azione": "The final call-to-action section", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "The database user does not have permissions to create TRIGGERS. The installation has been completed, but to ensure full data integrity it is necessary to install them manually.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Some database triggers could not be created. The installation has been completed, but to ensure full data integrity they must be installed manually.", "L'utente ha ritirato il libro?": "Has the user picked up the book?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "User is not in suspended state. Only suspended users require approval.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "User will receive an email with verification link (valid for 7 days) and can activate the account autonomously.", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 3b1147f20..168cba691 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -2732,7 +2732,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "L'heure de l'événement doit être au format correct (HH:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "L'heure doit être au format correct (HH:MM).", "L'ultima sezione che invita all'azione": "La dernière section d'appel à l'action", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "L'utilisateur de la base de données n'a pas les permissions pour créer les TRIGGERS. L'installation a été effectuée, mais pour garantir la pleine intégrité des données, il est nécessaire de les installer manuellement.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Certains déclencheurs (triggers) de la base de données n'ont pas pu être créés. L'installation est terminée, mais pour garantir la pleine intégrité des données, ils doivent être installés manuellement.", "L'utente ha ritirato il libro?": "L'utilisateur a-t-il récupéré le livre ?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "L'utilisateur n'est pas dans l'état suspendu. Seuls les utilisateurs suspendus nécessitent une approbation.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "L'utilisateur recevra un e-mail avec un lien de vérification (valide 7 jours) et pourra activer le compte de manière autonome.", diff --git a/locale/it_IT.json b/locale/it_IT.json index 55e6c6b43..517e8d7db 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -2732,7 +2732,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "L'ora dell'evento deve essere nel formato corretto (HH:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "L'ora deve essere nel formato corretto (HH:MM).", "L'ultima sezione che invita all'azione": "L'ultima sezione che invita all'azione", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.", "L'utente ha ritirato il libro?": "L'utente ha ritirato il libro?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.", From 803c7ebf4e67fbfab5bb54dbf86f57f02dd4fbf8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 15:35:15 +0100 Subject: [PATCH 398/398] Installer: name no cause the trigger warning cannot know The final page opened the trigger warning with "the database user lacks the permissions to create triggers", and since the previous commit it shows the real database error right under it. When a trigger fails for another reason, such as a missing table, the two lines contradicted each other. The opening sentence now only says that some triggers could not be created, in all five locales. --- installer/steps/step7.php | 2 +- locale/da_DK.json | 2 +- locale/de_DE.json | 2 +- locale/en_US.json | 2 +- locale/fr_FR.json | 2 +- locale/it_IT.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/installer/steps/step7.php b/installer/steps/step7.php index a5b607859..0dbfea91b 100755 --- a/installer/steps/step7.php +++ b/installer/steps/step7.php @@ -144,7 +144,7 @@

      -

      +

      diff --git a/locale/da_DK.json b/locale/da_DK.json index 6f22831d9..9b5870753 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -2731,7 +2731,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "Begivenhedens tidspunkt skal være i det korrekte format (TT:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "Klokkeslættet skal være i det korrekte format (TT:MM).", "L'ultima sezione che invita all'azione": "Den sidste opfordring til handling", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Databasebrugeren har ikke tilladelse til at oprette TRIGGERES. Installationen er fuldført, men for at garantere fuld dataintegritet er det nødvendigt at installere dem manuelt.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Nogle databasetriggere kunne ikke oprettes. Installationen er gennemført, men for at sikre fuld dataintegritet skal de installeres manuelt.", "L'utente ha ritirato il libro?": "Har brugeren afhentet bogen?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "Brugeren er ikke i suspenderet tilstand. Kun suspenderede brugere kræver godkendelse.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "Brugeren modtager en e-mail med et bekræftelseslink (gyldigt i 7 dage) og kan selv aktivere kontoen.", diff --git a/locale/de_DE.json b/locale/de_DE.json index ce1ecce77..f5182c7f2 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -2731,7 +2731,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "Die Uhrzeit des Ereignisses muss im korrekten Format vorliegen (HH:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "Die Uhrzeit muss im korrekten Format vorliegen (HH:MM).", "L'ultima sezione che invita all'azione": "Der abschließende Call-to-Action-Bereich", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Der Datenbankbenutzer hat keine Berechtigung zum Erstellen von TRIGGERN. Die Installation wurde abgeschlossen, aber um die vollständige Datenintegrität zu gewährleisten, müssen diese manuell installiert werden.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Einige Datenbank-Trigger konnten nicht erstellt werden. Die Installation wurde abgeschlossen, aber um die vollständige Datenintegrität zu gewährleisten, müssen sie manuell installiert werden.", "L'utente ha ritirato il libro?": "Hat der Benutzer das Buch abgeholt?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "Der Benutzer befindet sich nicht im gesperrten Status. Nur gesperrte Benutzer erfordern eine Genehmigung.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "Der Benutzer erhält eine E-Mail mit Bestätigungslink (7 Tage gültig) und kann das Konto selbstständig aktivieren.", diff --git a/locale/en_US.json b/locale/en_US.json index 22c255445..d58a2d64d 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -2731,7 +2731,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "Event time must be in the correct format (HH:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "Time must be in the correct format (HH:MM).", "L'ultima sezione che invita all'azione": "The final call-to-action section", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "The database user does not have permissions to create TRIGGERS. The installation has been completed, but to ensure full data integrity it is necessary to install them manually.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Some database triggers could not be created. The installation has been completed, but to ensure full data integrity they must be installed manually.", "L'utente ha ritirato il libro?": "Has the user picked up the book?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "User is not in suspended state. Only suspended users require approval.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "User will receive an email with verification link (valid for 7 days) and can activate the account autonomously.", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index f1b9d12dd..3e0f8c8f0 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -2731,7 +2731,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "L'heure de l'événement doit être au format correct (HH:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "L'heure doit être au format correct (HH:MM).", "L'ultima sezione che invita all'azione": "La dernière section d'appel à l'action", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "L'utilisateur de la base de données n'a pas les permissions pour créer les TRIGGERS. L'installation a été effectuée, mais pour garantir la pleine intégrité des données, il est nécessaire de les installer manuellement.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Certains déclencheurs (triggers) de la base de données n'ont pas pu être créés. L'installation est terminée, mais pour garantir la pleine intégrité des données, ils doivent être installés manuellement.", "L'utente ha ritirato il libro?": "L'utilisateur a-t-il récupéré le livre ?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "L'utilisateur n'est pas dans l'état suspendu. Seuls les utilisateurs suspendus nécessitent une approbation.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "L'utilisateur recevra un e-mail avec un lien de vérification (valide 7 jours) et pourra activer le compte de manière autonome.", diff --git a/locale/it_IT.json b/locale/it_IT.json index a25b40902..fb0cc7463 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -2731,7 +2731,7 @@ "L'ora dell'evento deve essere nel formato corretto (HH:MM).": "L'ora dell'evento deve essere nel formato corretto (HH:MM).", "L'ora deve essere nel formato corretto (HH:MM).": "L'ora deve essere nel formato corretto (HH:MM).", "L'ultima sezione che invita all'azione": "L'ultima sezione che invita all'azione", - "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "L'utente del database non ha i permessi per creare i TRIGGER. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.", + "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.": "Alcuni trigger del database non sono stati creati. L'installazione è stata completata, ma per garantire la piena integrità dei dati è necessario installarli manualmente.", "L'utente ha ritirato il libro?": "L'utente ha ritirato il libro?", "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.": "L'utente non è in stato sospeso. Solo gli utenti sospesi richiedono approvazione.", "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.": "L'utente riceverà un'email con link di verifica (valido 7 giorni) e potrà attivare autonomamente l'account.",
      - + diff --git a/tests/fines-ledger.spec.js b/tests/fines-ledger.spec.js index 52924f8a2..adb9d947e 100644 --- a/tests/fines-ledger.spec.js +++ b/tests/fines-ledger.spec.js @@ -88,6 +88,7 @@ function cleanup() { dbQuery(`DELETE FROM libri WHERE titolo LIKE 'ZZ\\_FINES\\_%'`); dbQuery(`DELETE FROM user_sessions WHERE utente_id IN (SELECT id FROM utenti WHERE email LIKE 'zz-fines-%@test.local')`); dbQuery(`DELETE FROM utenti WHERE email LIKE 'zz-fines-%@test.local'`); + dbQuery(`DELETE FROM ruoli WHERE nome LIKE 'ZZ Fines %'`); } function readSetting(category, key) { const v = dbQuery(`SELECT setting_value FROM system_settings WHERE category='${category}' AND setting_key='${key}'`); @@ -510,4 +511,59 @@ test.describe.serial('Fines and patron ledger', () => { await expect(page.locator('#daily_fine')).toBeEnabled(); await expect(page.locator('[data-testid="fines-admin-only"]')).toHaveCount(0); }); + + test('18. a custom role reads the ledger with users.manage and moves money only with loans.manage', async ({ browser }) => { + // Two custom roles: one manages readers only, the other also runs the desk. + dbQuery(`INSERT INTO ruoli (nome, permessi) VALUES ('ZZ Fines utenti ${RUN}', '["backend.access","users.manage"]'), ('ZZ Fines banco ${RUN}', '["backend.access","users.manage","loans.manage"]')`); + const usersRole = Number(dbQuery(`SELECT id FROM ruoli WHERE nome='ZZ Fines utenti ${RUN}'`)); + const deskRole = Number(dbQuery(`SELECT id FROM ruoli WHERE nome='ZZ Fines banco ${RUN}'`)); + const email = `zz-fines-role-${RUN}@test.local`; + dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, ruolo_id, privacy_accettata) VALUES ('${`ZZR${RUN}`.slice(0, 20)}', 'Zz', 'Role', '${email}', '${esc(phpHash(PATRON_PASS))}', 'attivo', 1, 'standard', ${usersRole}, 1)`); + const operatorId = Number(dbQuery(`SELECT id FROM utenti WHERE email='${email}'`)); + dbQuery(`INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES (${state.patronId}, 'addebito_manuale', 2.00, 'ZZ ruolo', NOW())`); + const before = Number(dbQuery(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${state.patronId}`)); + const operator = await browser.newPage(); + try { + await operator.goto(`${BASE}/accedi`); + await operator.fill('input[name="email"]', email); + await operator.fill('input[name="password"]', PATRON_PASS); + await operator.locator('button[type="submit"]').click(); + await operator.waitForURL((u) => !u.toString().includes('/accedi'), { timeout: 30000 }); + // users.manage: the reader record and its ledger are readable, nothing else. + await operator.goto(`${BASE}/admin/users/details/${state.patronId}`); + await expect(operator.locator('#movimenti [data-ledger-balance]')).toBeVisible(); + await expect(operator.locator('#ledger-movements tbody tr')).not.toHaveCount(0); + await expect(operator.locator('#ledger-payment-form')).toHaveCount(0); + await expect(operator.locator('#ledger-charge-form')).toHaveCount(0); + await expect(operator.locator('#ledger-waive-form')).toHaveCount(0); + await expect(operator.locator('#movimenti a[href*="/ledger/receipt/"]')).toHaveCount(0); + // A hand-built POST is refused before the controller runs (no ledger flash, nothing written). + const csrfInputs = operator.locator('input[name="csrf_token"]'); + const csrf = (await csrfInputs.count()) > 0 ? await csrfInputs.first().inputValue() : ''; + const refused = await operator.request.post(`${BASE}/admin/users/${state.patronId}/ledger/payment`, { form: { csrf_token: csrf, importo: '1', metodo_pagamento: 'contanti', request_key: 'a'.repeat(32) }, maxRedirects: 0 }); + expect([302, 403]).toContain(refused.status()); + expect(String(refused.headers().location || '')).not.toContain('ledger='); + expect(Number(dbQuery(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${state.patronId}`))).toBe(before); + const payment = Number(dbQuery(`SELECT id FROM movimenti_lettore WHERE utente_id=${state.patronId} AND tipo='pagamento' ORDER BY id DESC LIMIT 1`)); + expect((await operator.request.get(`${BASE}/admin/users/${state.patronId}/ledger/receipt/${payment}`, { maxRedirects: 0 })).status()).not.toBe(200); + + // loans.manage added (the role is re-read on every request): the desk forms appear and work. + dbQuery(`UPDATE utenti SET ruolo_id=${deskRole} WHERE id=${operatorId}`); + await operator.goto(`${BASE}/admin/users/details/${state.patronId}#movimenti`); + await expect(operator.locator('#ledger-payment-form')).toBeVisible(); + await operator.fill('#ledger_pay_amount', '1,00'); + await operator.selectOption('#ledger_pay_method', 'contanti'); + await operator.locator('#ledger-payment-form button[type="submit"]').click(); + await expect(operator.locator('[data-ledger-flash="paid"]')).toContainText('Pagamento registrato.'); + expect(Number(dbQuery(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${state.patronId}`))).toBe(before + 1); + expect(Number(dbQuery(`SELECT creato_da FROM movimenti_lettore WHERE utente_id=${state.patronId} ORDER BY id DESC LIMIT 1`))).toBe(operatorId); + const href = await operator.locator('[data-ledger-receipt]').getAttribute('href'); + expect((await operator.request.get(new URL(String(href), BASE).toString())).status()).toBe(200); + } finally { + await operator.close(); + dbQuery(`DELETE FROM user_sessions WHERE utente_id=${operatorId}`); + dbQuery(`DELETE FROM utenti WHERE id=${operatorId}`); + dbQuery(`DELETE FROM ruoli WHERE id IN (${usersRole}, ${deskRole})`); + } + }); }); diff --git a/tests/ledger-permission.unit.php b/tests/ledger-permission.unit.php new file mode 100644 index 000000000..237e54ead --- /dev/null +++ b/tests/ledger-permission.unit.php @@ -0,0 +1,60 @@ + 'standard', 'stato' => 'attivo', 'permissions' => ['backend.access', 'loans.manage']]; +$check(P::allowsPath('/admin/users/5/ledger/payment', $desk, 'POST'), 'a custom role with loans.manage only records a payment'); +$check(P::allowsPath('/admin/users/5/ledger/waive', $desk, 'POST') && P::allowsPath('/admin/users/5/ledger/charge', $desk, 'POST'), '...and a waiver or a manual charge'); +$check(P::allowsPath('/admin/users/5/ledger/receipt/12', $desk), '...and downloads the receipt'); +$check(!P::allowsPath('/admin/users/details/5', $desk), 'loans.manage alone does not open the reader record'); + +$users = ['tipo_utente' => 'standard', 'stato' => 'attivo', 'permissions' => ['backend.access', 'users.manage']]; +$check(P::allowsPath('/admin/users/details/5', $users), 'a users-only role opens the reader record (ledger read-only)'); +foreach (['/admin/users/5/ledger/payment', '/admin/users/5/ledger/charge', '/admin/users/5/ledger/waive'] as $path) { + $check(!P::allowsPath($path, $users, 'POST'), "a users-only role cannot POST {$path}"); +} +$check(!P::allowsPath('/admin/users/5/ledger/receipt/12', $users), 'a users-only role does not download receipts'); + +$both = ['tipo_utente' => 'standard', 'stato' => 'attivo', 'permissions' => ['backend.access', 'loans.manage', 'users.manage']]; +$check(P::allowsPath('/admin/users/5/ledger/payment', $both, 'POST') && P::allowsPath('/admin/users/details/5', $both), 'with both capabilities the record and its desk actions are reachable'); +$check(P::allowsPath('/admin/users/5/ledger/payment', ['tipo_utente' => 'staff', 'stato' => 'attivo'], 'POST'), 'Staff records payments'); +$check(!P::allowsPath('/admin/users/5/ledger/payment', ['tipo_utente' => 'staff', 'stato' => 'sospeso'], 'POST'), 'a suspended operator cannot'); +$check(!P::allowsPath('/admin/users/5/ledger/payment', ['tipo_utente' => 'standard', 'stato' => 'attivo', 'permissions' => []], 'POST'), 'a reader without a role cannot'); + +$view = (string) file_get_contents(dirname(__DIR__) . '/app/Views/utenti/_movimenti.php'); +$check(str_contains($view, "Permissions::can('loans.manage')") && substr_count($view, '$ledgerCanManage') >= 4, 'the movements partial renders forms and receipt links only with loans.manage'); +$controller = (string) file_get_contents(dirname(__DIR__) . '/app/Controllers/PatronLedgerController.php'); +$check(substr_count($controller, "Permissions::can('loans.manage')") === 2, 'the controller demands the same capability on writes and receipts'); + +echo "SUCCESS {$checks} ledger permission checks\n"; From 487ec78dac9a7b094368904bb3a2d9759e2bea07 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:15:26 +0000 Subject: [PATCH 136/398] Mobile API: a request auto-approved on a closed day is reported as prenotato POST /reservations answered da_ritirare for every auto-approved request, while UserActionsController::loan() already threads the persisted state through its redirect: with the library closed today the loan starts on the next open day and is prenotato. The app now gets that state, the start day (start_date, additive) and a message naming it; the OpenAPI enum lists prenotato. The mapping lives in CirculationOutcome, pinned by the unit test. --- 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 + .../src/Controllers/ActionsController.php | 47 ++++++++++++++++--- .../src/Controllers/OpenApiController.php | 5 +- .../src/Support/CirculationOutcome.php | 20 ++++++++ tests/mobile-api-circulation.spec.js | 42 +++++++++++++++++ tests/mobile-api-circulation.unit.php | 13 +++++ 10 files changed, 123 insertions(+), 9 deletions(-) diff --git a/locale/da_DK.json b/locale/da_DK.json index 27c603aff..fccb3b37b 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Godkend anmodninger automatisk", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Spring manuel godkendelse over for nye lån, som brugerne anmoder om.", "Prestito approvato. Il libro è in attesa di ritiro.": "Lånet er godkendt. Bogen afventer afhentning.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Lånet er godkendt: du kan afhente bogen fra %s.", "Stile interfaccia": "Grænsefladestil", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Skift opbygningen af det offentlige websted. CMS-indhold, hero-baggrund, farver og funktioner forbliver uændrede.", "Un solo frontend": "Én fælles grænseflade", diff --git a/locale/de_DE.json b/locale/de_DE.json index c04270293..35f732eb5 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Anfragen automatisch genehmigen", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Überspringt die manuelle Genehmigung neuer Ausleihanfragen.", "Prestito approvato. Il libro è in attesa di ritiro.": "Ausleihe genehmigt. Das Buch wartet auf Abholung.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Ausleihe genehmigt: Sie können das Buch ab dem %s abholen.", "Stile interfaccia": "Oberflächenstil", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Ändert den Aufbau der öffentlichen Website. CMS-Inhalte, Hero-Hintergrund, Farben und Funktionen bleiben unverändert.", "Un solo frontend": "Eine gemeinsame Oberfläche", diff --git a/locale/en_US.json b/locale/en_US.json index 59f593fb5..78f476df9 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Auto-approve requests", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Skip manual approval for new loans requested by patrons.", "Prestito approvato. Il libro è in attesa di ritiro.": "Loan approved. The book is awaiting pickup.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Loan approved: you can pick up the book from %s.", "Stile interfaccia": "Interface style", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Change the public site's composition. CMS content, hero background, colors, and features remain unchanged.", "Un solo frontend": "One shared frontend", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index a128829d9..871761a21 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Approuver automatiquement", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Désactive l'approbation manuelle des nouveaux prêts demandés par les usagers.", "Prestito approvato. Il libro è in attesa di ritiro.": "Prêt approuvé. Le livre est en attente de retrait.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Prêt approuvé : vous pourrez retirer le livre à partir du %s.", "Stile interfaccia": "Style de l'interface", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Modifie la composition du site public. Les contenus CMS, l'arrière-plan principal, les couleurs et les fonctionnalités restent inchangés.", "Un solo frontend": "Une interface partagée", diff --git a/locale/it_IT.json b/locale/it_IT.json index 4fb40824b..a4d69106c 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Auto-approva le richieste", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.", "Prestito approvato. Il libro è in attesa di ritiro.": "Prestito approvato. Il libro è in attesa di ritiro.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Prestito approvato: potrai ritirare il libro dal %s.", "Stile interfaccia": "Stile interfaccia", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.", "Un solo frontend": "Un solo frontend", diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index 0247c656c..dbc46bf9c 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -10,9 +10,11 @@ use App\Controllers\UserActionsController; use App\Controllers\UserWishlistController; use App\Plugins\MobileApi\Support\AppAuthMiddleware; +use App\Plugins\MobileApi\Support\CirculationOutcome; use App\Plugins\MobileApi\Support\JsonBody; use App\Plugins\MobileApi\Support\ResponseEnvelope; use App\Support\DateHelper; +use App\Support\LoanWindow; use App\Support\SecureLogger; use mysqli; use Psr\Http\Message\ResponseInterface; @@ -257,19 +259,28 @@ public function requestReservation(Request $request, ResponseInterface $response $outcome = $this->redirectOutcome($result); if (isset($outcome['loan_request_success'])) { - $autoApproved = ($outcome['auto_approved'] ?? '0') === '1'; + // The persisted state, not a guess: with the library closed + // today the approved loan starts on the next open day and is + // `prenotato`, not awaiting pickup. + $status = CirculationOutcome::requestState($outcome); + $loanId = isset($outcome['loan_id']) ? (int) $outcome['loan_id'] : null; + $startDate = $status === 'prenotato' ? $this->loanStartDate($loanId) : null; + $message = match ($status) { + 'prenotato' => __('Prestito approvato: potrai ritirare il libro dal %s.', LoanWindow::displayDate($startDate ?? $today)), + 'da_ritirare' => __('Prestito approvato. Il libro è in attesa di ritiro.'), + default => __('Richiesta di prestito inviata. In attesa di approvazione.'), + }; return ResponseEnvelope::success( $response, [ 'type' => 'loan', 'book_id' => $bookId, - 'loan_id' => isset($outcome['loan_id']) ? (int) $outcome['loan_id'] : null, - 'auto_approved' => $autoApproved, - 'status' => $autoApproved ? 'da_ritirare' : 'pendente', + 'loan_id' => $loanId, + 'auto_approved' => $status !== 'pendente', + 'status' => $status, + 'start_date' => $startDate, ], - ['message' => $autoApproved - ? __('Prestito approvato. Il libro è in attesa di ritiro.') - : __('Richiesta di prestito inviata. In attesa di approvazione.')], + ['message' => $message], 201 ); } @@ -1186,6 +1197,28 @@ private function classifyCancellable(int $userId, int $id): ?string * * @return array */ + /** The stored start day (prestiti.data_prestito) of a loan, null when unknown. */ + private function loanStartDate(?int $loanId): ?string + { + if ($loanId === null || $loanId <= 0) { + return null; + } + try { + $stmt = $this->db->prepare('SELECT data_prestito FROM prestiti WHERE id = ?'); + if ($stmt === false) { + return null; + } + $stmt->bind_param('i', $loanId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_row(); + $stmt->close(); + $start = $row === null || $row[0] === null ? '' : substr((string) $row[0], 0, 10); + return DateHelper::isISODateFormat($start) ? $start : null; + } catch (\Throwable $e) { + return null; + } + } + private function redirectOutcome(ResponseInterface $result): array { $location = $result->getHeaderLine('Location'); diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 2132d9679..950f9ea4d 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -712,7 +712,7 @@ private function paths(): array 'post' => [ 'tags' => ['loans'], 'summary' => 'Request a reservation / loan', - 'description' => 'Honors existing overlap, availability, max-active-loans, and automatic-approval settings (same as the web form). For immediate loans, data.auto_approved and data.status tell the client whether the request is pending or ready for pickup.', + 'description' => 'Honors existing overlap, availability, max-active-loans, and automatic-approval settings (same as the web form). For immediate loans, data.auto_approved and data.status tell the client whether the request is pending (pendente), ready for pickup (da_ritirare) or, when the library is closed today, scheduled to start on the next open day (prenotato, with data.start_date; Pinakes 0.9.0).', 'operationId' => 'postReservation', 'security' => [['bearerAuth' => []]], 'requestBody' => [ @@ -731,7 +731,8 @@ private function paths(): array 'book_id' => ['type' => 'integer'], 'loan_id' => ['type' => 'integer', 'nullable' => true], 'auto_approved' => ['type' => 'boolean', 'nullable' => true], - 'status' => ['type' => 'string', 'nullable' => true, 'enum' => ['pendente', 'da_ritirare']], + 'status' => ['type' => 'string', 'nullable' => true, 'enum' => ['pendente', 'da_ritirare', 'prenotato']], + 'start_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'description' => 'Start day of a scheduled (prenotato) loan; null otherwise.'], ], ]], ]]], diff --git a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php index 1a1f5b538..a97908e2d 100644 --- a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php +++ b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php @@ -217,6 +217,26 @@ public static function actionsFor(array $row, int $maxRenewals, string $today): } } + /** + * The state of a reader's loan request after UserActionsController::loan() + * (POST /reservations on an available title): the request stays + * `pendente` unless it was auto-approved, in which case loan() threads + * the persisted state through the redirect as `loan_state`: `prenotato` + * when the start day is ahead (the library is closed today, so the loan + * starts on the next open day), `da_ritirare` when it starts today. An + * approved request without a usable state is reported as `da_ritirare`. + * + * @param array $outcome the redirect query of loan() + */ + public static function requestState(array $outcome): string + { + if (($outcome['auto_approved'] ?? '0') !== '1') { + return 'pendente'; + } + $state = (string) ($outcome['loan_state'] ?? ''); + return in_array($state, ['prenotato', 'da_ritirare'], true) ? $state : 'da_ritirare'; + } + /** * Presentation hint for a reservation row. * diff --git a/tests/mobile-api-circulation.spec.js b/tests/mobile-api-circulation.spec.js index c7e63b122..d428089b7 100644 --- a/tests/mobile-api-circulation.spec.js +++ b/tests/mobile-api-circulation.spec.js @@ -363,6 +363,48 @@ test.describe('Borrower request lifecycle', () => { }); }); +test.describe('Reader request on a closed day', () => { + // The library is closed today: the auto-approved request starts on the + // next open day, so the app is told `prenotato` (with the start day), not + // `da_ritirare` as every approved request used to be reported. + test('with today closed and auto-approval on, POST /reservations answers status prenotato', async ({ request }) => { + const TAG = `E2E-MOBCAL-${RUN}`; + const prevAuto = F.dbScalar("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='auto_approve_requests'"); + const prevWeekdays = F.dbScalar("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + const setSetting = (key, value) => { + if (value === '' || value === null || value === undefined) { + F.dbExec(`DELETE FROM system_settings WHERE category='loans' AND setting_key='${key}'`); + } else { + F.dbExec(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans','${key}',${F.sqlStr(value)}) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); + } + }; + const email = `circ_closed_${RUN}@pinakes.test`; + const userId = F.createUser(email, PASS, 'standard', 'CIRX'); + ctx.users.push(userId); + const token = await F.login(request, email, PASS, 'circ-closed'); + const bookId = await staffBook(request, 'Circ closed today', 1); + setSetting('auto_approve_requests', '1'); + setSetting('closed_weekdays', ''); + F.dbExec(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione) VALUES ('${ctx.today}', '${ctx.today}', '${TAG}')`); + try { + const r = await F.api(request, 'POST', '/reservations', { token, body: { book_id: bookId } }); + expect(r.status, r.text.slice(0, 300)).toBe(201); + expect(r.json.data.type).toBe('loan'); + expect(r.json.data.auto_approved).toBe(true); + expect(r.json.data.status).toBe('prenotato'); + // The next open day: tomorrow, unless another closure follows. + expect(r.json.data.start_date >= isoDay(1), r.json.data.start_date).toBe(true); + expect(r.json.meta.message).not.toMatch(/attesa di ritiro/); + const stored = F.dbScalar(`SELECT CONCAT(stato,'|',data_prestito) FROM prestiti WHERE id=${r.json.data.loan_id}`); + expect(stored).toBe(`prenotato|${r.json.data.start_date}`); + } finally { + F.dbExec(`DELETE FROM calendario_chiusure WHERE descrizione='${TAG}'`); + setSetting('auto_approve_requests', prevAuto); + setSetting('closed_weekdays', prevWeekdays); + } + }); +}); + test.describe('Reservations and queue', () => { test('create → queue position, duplicate 409, cancel, reactivate; queue + availability', async ({ request }) => { const r = await F.api(request, 'POST', '/staff/reservations', { diff --git a/tests/mobile-api-circulation.unit.php b/tests/mobile-api-circulation.unit.php index 4bf886d81..4fcbc2b14 100644 --- a/tests/mobile-api-circulation.unit.php +++ b/tests/mobile-api-circulation.unit.php @@ -171,6 +171,19 @@ } } +// The reader's request (POST /reservations → UserActionsController::loan()): +// the status the app gets is the persisted state, not "approved = da_ritirare". +echo "\nD2. Reader request state from the loan() redirect\n"; +$check(CirculationOutcome::requestState(['loan_request_success' => '1', 'auto_approved' => '0', 'loan_state' => '']) === 'pendente', 'not auto-approved → pendente'); +$check(CirculationOutcome::requestState(['loan_request_success' => '1']) === 'pendente', 'no auto_approved key → pendente'); +$check(CirculationOutcome::requestState(['auto_approved' => '1', 'loan_state' => 'da_ritirare']) === 'da_ritirare', 'approved, starts today → da_ritirare'); +$check(CirculationOutcome::requestState(['auto_approved' => '1', 'loan_state' => 'prenotato']) === 'prenotato', 'approved, library closed today → prenotato (starts on the next open day)'); +$check(CirculationOutcome::requestState(['auto_approved' => '1', 'loan_state' => '']) === 'da_ritirare', 'approved without a state → da_ritirare (fallback)'); +$check(CirculationOutcome::requestState(['auto_approved' => '1', 'loan_state' => 'in_corso']) === 'da_ritirare', 'approved with an unexpected state → da_ritirare'); +$check(CirculationOutcome::requestState(['auto_approved' => '0', 'loan_state' => 'prenotato']) === 'pendente', 'a state without approval is still pendente'); +$openApi = (string) file_get_contents(__DIR__ . '/../storage/plugins/mobile-api/src/Controllers/OpenApiController.php'); +$check(str_contains($openApi, "'enum' => ['pendente', 'da_ritirare', 'prenotato']"), 'the OpenAPI status enum of POST /reservations lists prenotato'); + // ── E. Health flags ────────────────────────────────────────────────────────── echo "\nE. staff_* flags only for mounted areas\n"; $Areas = \App\Plugins\MobileApi\Support\StaffAreas::class; From 727b60744e3420c63a23ab167ffea47e8f980d45 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:15:26 +0000 Subject: [PATCH 137/398] Changelog: ICS import limits, calendar permission, mobile request state on a closed day --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d52d57b2b..d009fcf0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,8 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **The library's closed days.** A new admin page, Admin → Calendario chiusure (`/admin/calendar`, also linked from Settings → Loans), holds the weekly closed days (`loans.closed_weekdays`) and the closures: single days or periods, once or every year, for every branch or for one. The Italian national holidays come with one click (fixed holidays as yearly closures, Easter and Easter Monday computed for the chosen year), any other calendar can be imported from an ICS file, and a monthly preview shows which days are open. - **Due dates land on an open day.** The shift lives in `CirculationPolicy::dueDateFor()`, so the per-patron-type durations and renewal days of the circulation policy land on an open day too. A due date that falls on a closed day moves to the next open day: admin loans (proposed and saved), loan edits, reader requests from the website and the app, reservations and their promotion to a loan, renewals and bulk extensions, NCIP CheckOut, RequestItem and RenewItem. A closure of one branch counts for the loans of its copies: when the staff desk lends a copy, or a request is approved onto one, the due date moves to that branch's next open day (at approval only if the longer window still fits). A refused renewal proposes the last open day before the conflict. - **Pickup days are open days.** The pickup deadline counts only the days the library is open, and the nightly sweep expires no pickup, no scheduled loan and no queue reservation on a closed day; a deadline that falls on a day closed afterwards moves to the next open day when the calendar is saved, so the desk can still hand the book over that day. -- **The request calendars know the closed days.** On the book page and in the admin loan form a closed day cannot be picked as the start (a request that reaches the server with one starts on the next open day); the availability payload carries `closed_dates`, on the website and in the Mobile API `GET /catalog/books/{id}/availability` (additive field). +- **The request calendars know the closed days.** On the book page and in the admin loan form a closed day cannot be picked as the start (a request that reaches the server with one starts on the next open day); the availability payload carries `closed_dates`, on the website and in the Mobile API `GET /catalog/books/{id}/availability` (additive field). A request auto-approved on a closed day is reported by `POST /reservations` with its real state, `prenotato` (new enum value) with the start day in `start_date` (additive), instead of `da_ritirare`. +- **ICS imports meet the form's limits.** Every event is checked like a closure typed in the form (a yearly closure within a year, a one-off within ten years, end not before start); the import message counts the events discarded. A yearly closure of a year or more, however stored, is applied once as the interval it names and never closes every day; an ICS date-time in UTC is read in the application timezone. The calendar page follows the settings permission (`settings.manage`), so a custom role with that permission can use it. - With no closed weekday and no closure configured every date is computed exactly as before. ### Migration From b89478224218cf4b04b855b86f754657effefc1c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:15:35 +0000 Subject: [PATCH 138/398] MARC: the media type sets leader/06, 007 and RDA 336-338, and is read back BookMarcBuilder always wrote leader "na"+"m" and claimed RDA in 040 $e without a 336/337/338, and the mappers never set tipo_media, so a DVD or an audiobook left and entered the catalogue as a book. MarcMediaType is the one table from tipo_media to leader/06 (a, j, i, g), the 007 category of material and the RDA content/media/carrier codes (libro txt/n/nc, disco prm/s/sd, audiolibro spw/s/sd, dvd tdi/v/vd, altro xxx/x/zu), with the book's own rda_content_type / rda_media_type / rda_carrier_type row keys as overrides when present (the columns arrive with the RDA types of a later change, which can replace the table). Export emits 336-338 with the English RDA term in $a, the code in $b and the vocabulary in $2; the UNIMARC builder sets label/06 the same way. Import: Marc21LibriMapper derives tipo_media from leader/06 (a/t libro, g dvd, i audiolibro, j disco, refined by 336 $b: performed music is a disco, spoken word an audiolibro) and keeps the RDA codes the media type does not imply as the book's own; UnimarcLibriMapper reads label/06. tipo_media travels in the import row (parseCsvRow already takes it); the RDA codes are written by MarcBookImporter with the other extra columns, each only when the libri table has it, so the import does not fail before the migration that adds them. The OAI-PMH MARC 21 records change on purpose (007, 336-338; leader/06 for non-book media): the golden files are regenerated through the plugin's own methods, and the golden test now builds the plugin without a database when none answers, since the serializations never touch it. --- CHANGELOG.md | 1 + app/Support/Marc/BookMarcBuilder.php | 29 +++- app/Support/Marc/Marc21LibriMapper.php | 10 ++ app/Support/Marc/MarcBookData.php | 7 + app/Support/Marc/MarcBookImporter.php | 12 +- app/Support/Marc/MarcMediaType.php | 151 ++++++++++++++++++ app/Support/Marc/UnimarcLibriMapper.php | 2 + .../marc/oai-golden/minimal.marcxml.xml | 2 +- .../marc/oai-golden/monograph.marcxml.xml | 2 +- .../marc/oai-golden/serial.marcxml.xml | 2 +- tests/marc-library.unit.php | 31 ++++ tests/marc-oai-byte-identity.unit.php | 17 +- 12 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 app/Support/Marc/MarcMediaType.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c81bb77c..80d806f3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Bulk MARC import** (Books → Import → MARC, `/admin/books/import/marc`). A `.mrc`/`.iso` (ISO 2709) or `.xml` (MARCXML) file is read once, a preview of the first ten mapped records says which are already in the catalogue, and the import runs in chunks with a progress bar through the CSV import's own row pipeline: authors and publishers are found with the exact-spelling rules of #501, one copy per book, the import history and its error download work as for CSV. A record matching a book by ISBN-13, ISBN-10 or EAN (deleted books have them cleared, so they never match) is skipped, completed in its empty fields, or overwritten with what the record carries. Covers come from 856. Holdings (852/949/952) are not turned into copies. - **Bulk MARC export** (Books → Export, and "Esporta in MARC" on a selection). The catalogue or the selected books as MARC 21 `.mrc`, a MARCXML collection or UNIMARC `.mrc`, written while the download is sent, so a large catalogue never sits in memory. Deleted books are never exported. The records carry the EAN, the LC classification, the Dewey number and the cover, so an export re-imports without duplicates. - **Copy cataloguing** (Books → Import → Copy cataloging, `/admin/books/copy-cataloging`, z39-server plugin). Search by title, author, ISBN/ISSN or all fields in the configured SRU servers, SBN, the Library of Congress, BnF, DNB, K10plus and this installation's own SRU server; look at each record's MARC fields; import the chosen ones with the same duplicate modes and cover download. `SruClient::search()` returns every record of a query. Another Pinakes installation is a preset in the plugin settings, and its SRU server's UNIMARC records now carry the cover in 856 (the MARCXML ones already did). +- **The media type travels in MARC.** A book's media type (libro, disco, audiolibro, dvd, altro) sets leader/06 (a, j, i, g), the 007 category of material and the RDA content, media and carrier types in 336/337/338 (`$a` term, `$b` code, `$2` vocabulary; a book's own RDA codes, when it has them, override the implied ones); UNIMARC records set label/06 the same way. On import the media type is read from leader/06 (a sound recording of performed music is a disco, of spoken word an audiolibro) and RDA codes the media type does not imply are kept as the book's own, once the catalogue has the columns for them. OAI-PMH MARC 21 records now carry leader/06 by media type and RDA 336-338 (the golden files are regenerated). ### Migration diff --git a/app/Support/Marc/BookMarcBuilder.php b/app/Support/Marc/BookMarcBuilder.php index e27210b60..1a11b2991 100644 --- a/app/Support/Marc/BookMarcBuilder.php +++ b/app/Support/Marc/BookMarcBuilder.php @@ -38,10 +38,12 @@ final class BookMarcBuilder public static function marc21(array $row, array $authors, array $publishers, ?array $genre, bool $extended = false): MarcRecord { $isSerial = self::isSerialRecord($row); - // Leader: type 'a' (language material), bibliographic level 'm' + $tipoMedia = isset($row['tipo_media']) ? (string) $row['tipo_media'] : null; + // Leader: type of record from the media type (a language material, + // i/j sound recording, g projected medium), bibliographic level 'm' // (monograph), 's' for a serial. Leader/18 stays blank: the record // carries no ISBD punctuation, so 'i' (ISBD) would be a false claim. - $record = new MarcRecord('00000na' . ($isSerial ? 's' : 'm') . ' a2200000 4500'); + $record = new MarcRecord('00000n' . MarcMediaType::leaderType($tipoMedia) . ($isSerial ? 's' : 'm') . ' a2200000 4500'); // 001 — Control number (book id); 003 — MARC organization code $record->addControlField('001', (string) $row['id']); @@ -49,6 +51,12 @@ public static function marc21(array $row, array $authors, array $publishers, ?ar // 005 — Date/time of latest transaction $ts = strtotime((string) ($row['updated_at'] ?? 'now')) ?: time(); $record->addControlField('005', gmdate('YmdHis', $ts) . '.0'); + // 007 — Physical description: the category of material the media + // type says (text, sound disc, videodisc); none for "altro". + $f007 = MarcMediaType::field007($tipoMedia); + if ($f007 !== null) { + $record->addControlField('007', $f007); + } // 008 — Fixed-length data elements (exactly 40 characters) $record->addControlField('008', self::marc21Field008($row)); @@ -163,6 +171,20 @@ public static function marc21(array $row, array $authors, array $publishers, ?ar $record->addDataField('300', ' ', ' ', $physSubs); } + // 336 / 337 / 338 — RDA content, media and carrier types (040 $e + // says rda): what the media type implies, or the book's own codes. + $rda = MarcMediaType::rdaCodes($row); + foreach (['336' => ['content', 'rdacontent'], '337' => ['media', 'rdamedia'], '338' => ['carrier', 'rdacarrier']] as $tag => [$kind, $source]) { + $subs = []; + $term = MarcMediaType::term($kind, $rda[$kind]); + if ($term !== null) { + $subs[] = ['a', $term]; + } + $subs[] = ['b', $rda[$kind]]; + $subs[] = ['2', $source]; + $record->addDataField((string) $tag, ' ', ' ', $subs); + } + // 490 — Series statement if (!empty($row['collana'])) { $serSubs = [['a', (string) $row['collana']]]; @@ -218,7 +240,8 @@ public static function marc21(array $row, array $authors, array $publishers, ?ar */ public static function unimarc(array $row, array $authors, array $publishers, ?array $genre, bool $extended = false): MarcRecord { - $record = new MarcRecord(self::UNIMARC_LEADER_TEMPLATE); + // Label/06 (type of record) from the media type, as in MARC 21. + $record = new MarcRecord(substr_replace(self::UNIMARC_LEADER_TEMPLATE, MarcMediaType::leaderType(isset($row['tipo_media']) ? (string) $row['tipo_media'] : null), 6, 1)); $record->setFormat('UNIMARC'); // 001 — Record identifier diff --git a/app/Support/Marc/Marc21LibriMapper.php b/app/Support/Marc/Marc21LibriMapper.php index 7c6cadbd4..42e9b546d 100644 --- a/app/Support/Marc/Marc21LibriMapper.php +++ b/app/Support/Marc/Marc21LibriMapper.php @@ -21,6 +21,8 @@ * 260 / 264 $a place, $b publishers, $c year (else 008/07-10) * 300 $a pages, $c dimensions 490 $a $v series (else 830) * 520 $a summary 650 / 651 / 653 $a keywords 856 $u cover image + * leader/06 (and 336 $b) media type; 336 / 337 / 338 $b RDA codes the media + * type does not imply */ final class Marc21LibriMapper implements MarcLibriMapper { @@ -167,6 +169,14 @@ public function map(MarcRecord $record): array $data['copertina_url'] = MarcBookData::coverUrlOf($record); + // Media type from leader/06, refined by the RDA content type (336); + // RDA codes the media type does not imply are kept as the book's own. + $content = $record->subfield('336', 'b'); + $data['tipo_media'] = MarcMediaType::tipoMediaOf(substr($record->leader() . ' ', 6, 1), $content); + foreach (MarcMediaType::overrides($data['tipo_media'], $content, $record->subfield('337', 'b'), $record->subfield('338', 'b')) as $column => $code) { + $data[$column] = $code; + } + return $data; } } diff --git a/app/Support/Marc/MarcBookData.php b/app/Support/Marc/MarcBookData.php index 9939b81df..44a17b4c3 100644 --- a/app/Support/Marc/MarcBookData.php +++ b/app/Support/Marc/MarcBookData.php @@ -7,6 +7,10 @@ * The book data a MARC record maps to, flavour-independent, and its two * conversions: from the book shape the catalogue scrapers return (the SRU * client, danMARC2) and to the canonical import row the CSV pipeline reads. + * + * Besides the keys of empty(), a record may carry the RDA codes its media + * type does not imply (`rda_content_type`, `rda_media_type`, + * `rda_carrier_type`), which the MARC importer writes on its own. */ final class MarcBookData { @@ -24,6 +28,7 @@ public static function empty(): array 'collana' => '', 'numero_serie' => '', 'descrizione' => '', 'parole_chiave' => [], 'classificazione_dewey' => '', 'lc_classification' => '', 'isbn13' => '', 'isbn10' => '', 'ean' => '', 'issn' => '', 'copertina_url' => '', + 'tipo_media' => '', 'genere' => '', ]; } @@ -150,6 +155,8 @@ public static function toImportRow(array $data): array 'isbn10' => (string) ($data['isbn10'] ?? ''), 'isbn13' => (string) ($data['isbn13'] ?? ''), 'ean' => (string) ($data['ean'] ?? ''), + 'tipo_media' => (string) ($data['tipo_media'] ?? ''), + 'genere' => (string) ($data['genere'] ?? ''), ]; foreach (['traduttore', 'illustratore', 'curatore', 'colorista'] as $role) { if (((array) ($data[$role] ?? [])) !== []) { diff --git a/app/Support/Marc/MarcBookImporter.php b/app/Support/Marc/MarcBookImporter.php index afa102333..4a7785ebc 100644 --- a/app/Support/Marc/MarcBookImporter.php +++ b/app/Support/Marc/MarcBookImporter.php @@ -25,8 +25,8 @@ * the record does not carry is kept * * The columns the CSV pipeline does not write (place of publication, - * dimensions, LC classification, ISSN) and the cover (856) follow the same - * mode. + * dimensions, LC classification, ISSN, the book's own RDA codes) and the + * cover (856) follow the same mode. */ final class MarcBookImporter { @@ -72,12 +72,18 @@ final class MarcBookImporter 'contributors' => ['traduttore', 'illustratore', 'curatore', 'colorista'], ]; - /** Columns written here, not by the CSV pipeline. */ + /** + * Columns written here, not by the CSV pipeline, each only when the + * libri table has it (the RDA columns arrive with a later migration). + */ private const EXTRA_COLUMNS = [ 'luogo_pubblicazione' => 255, 'dimensioni' => 50, 'lc_classification' => 100, 'issn' => 20, + 'rda_content_type' => 10, + 'rda_media_type' => 10, + 'rda_carrier_type' => 10, ]; private CsvImportController $pipeline; diff --git a/app/Support/Marc/MarcMediaType.php b/app/Support/Marc/MarcMediaType.php new file mode 100644 index 000000000..97b93b90d --- /dev/null +++ b/app/Support/Marc/MarcMediaType.php @@ -0,0 +1,151 @@ + ['a', 'ta', 'txt', 'n', 'nc'], + 'disco' => ['j', 'sd |||||||||||', 'prm', 's', 'sd'], + 'audiolibro' => ['i', 'sd |||||||||||', 'spw', 's', 'sd'], + 'dvd' => ['g', 'vd |v||||', 'tdi', 'v', 'vd'], + 'altro' => ['a', null, 'xxx', 'x', 'zu'], + ]; + + /** RDA content types (rdacontent): code → English term. */ + private const CONTENT_TERMS = [ + 'txt' => 'text', 'prm' => 'performed music', 'spw' => 'spoken word', 'snd' => 'sounds', + 'tdi' => 'two-dimensional moving image', 'tdm' => 'three-dimensional moving image', + 'sti' => 'still image', 'tci' => 'tactile image', 'ntm' => 'notated music', 'tcm' => 'tactile notated music', + 'cri' => 'cartographic image', 'crm' => 'cartographic moving image', 'crd' => 'cartographic dataset', + 'crf' => 'cartographic three-dimensional form', 'crn' => 'cartographic tactile image', + 'cod' => 'computer dataset', 'cop' => 'computer program', 'tdf' => 'three-dimensional form', + 'tct' => 'tactile text', 'xxx' => 'other', 'zzz' => 'unspecified', + ]; + + /** RDA media types (rdamedia): code → English term. */ + private const MEDIA_TERMS = [ + 'n' => 'unmediated', 's' => 'audio', 'v' => 'video', 'c' => 'computer', 'g' => 'projected', + 'h' => 'microform', 'e' => 'stereographic', 'p' => 'microscopic', 'x' => 'other', 'z' => 'unspecified', + ]; + + /** RDA carrier types (rdacarrier): code → English term. */ + private const CARRIER_TERMS = [ + 'nc' => 'volume', 'nb' => 'sheet', 'na' => 'roll', 'no' => 'card', 'nn' => 'flipchart', 'nr' => 'object', 'nz' => 'other', + 'sd' => 'audio disc', 'ss' => 'audiocassette', 'st' => 'audiotape reel', 'sz' => 'other', + 'vd' => 'videodisc', 'vf' => 'videocassette', 'vr' => 'videotape reel', 'vz' => 'other', + 'cr' => 'online resource', 'cd' => 'computer disc', 'ck' => 'computer card', 'cz' => 'other', + 'zu' => 'unspecified', + ]; + + /** The media type as stored, defaulting to a book. */ + public static function normalise(?string $tipoMedia): string + { + $type = \App\Support\MediaLabels::normalizeTipoMedia($tipoMedia); + return $type !== null && isset(self::TYPES[$type]) ? $type : self::DEFAULT; + } + + /** Leader/06 (type of record) for a media type. */ + public static function leaderType(?string $tipoMedia): string + { + return self::TYPES[self::normalise($tipoMedia)][0]; + } + + /** MARC 21 007 for a media type, null when it has none worth claiming. */ + public static function field007(?string $tipoMedia): ?string + { + return self::TYPES[self::normalise($tipoMedia)][1]; + } + + /** + * The RDA codes of a book: those its media type implies, each replaced + * by the row's own (`rda_content_type`, `rda_media_type`, + * `rda_carrier_type`) when present. + * + * @param array $row a libri row + * @return array{content: string, media: string, carrier: string} + */ + public static function rdaCodes(array $row): array + { + [, , $content, $media, $carrier] = self::TYPES[self::normalise(isset($row['tipo_media']) ? (string) $row['tipo_media'] : null)]; + $own = static fn (string $key): string => strtolower(trim((string) ($row[$key] ?? ''))); + return [ + 'content' => $own('rda_content_type') !== '' ? $own('rda_content_type') : $content, + 'media' => $own('rda_media_type') !== '' ? $own('rda_media_type') : $media, + 'carrier' => $own('rda_carrier_type') !== '' ? $own('rda_carrier_type') : $carrier, + ]; + } + + /** The English RDA term of a code; null when the code is not known. */ + public static function term(string $kind, string $code): ?string + { + $code = strtolower(trim($code)); + return match ($kind) { + 'content' => self::CONTENT_TERMS[$code] ?? null, + 'media' => self::MEDIA_TERMS[$code] ?? null, + 'carrier' => self::CARRIER_TERMS[$code] ?? null, + default => null, + }; + } + + /** + * The media type a record's leader/06 says, refined by its RDA content + * type (a sound recording of performed music is a disco whatever the + * leader says, of spoken word an audiolibro); '' when the leader names + * a kind the catalogue has no media type for (a map, a computer file). + */ + public static function tipoMediaOf(string $leaderType, ?string $contentCode = null): string + { + $content = strtolower(trim((string) $contentCode)); + return match (strtolower($leaderType)) { + 'a', 't' => self::DEFAULT, + 'g' => 'dvd', + 'i' => $content === 'prm' ? 'disco' : 'audiolibro', + 'j' => $content === 'spw' ? 'audiolibro' : 'disco', + default => '', + }; + } + + /** + * The RDA codes a record carries that its media type does not imply, + * keyed as the libri columns (`rda_content_type`, ...): what an import + * stores as the book's own, so a plain book stores nothing. + * + * @return array + */ + public static function overrides(string $tipoMedia, ?string $contentCode, ?string $mediaCode, ?string $carrierCode): array + { + [, , $content, $media, $carrier] = self::TYPES[self::normalise($tipoMedia)]; + $out = []; + foreach ([ + 'rda_content_type' => [$contentCode, $content, 'content'], + 'rda_media_type' => [$mediaCode, $media, 'media'], + 'rda_carrier_type' => [$carrierCode, $carrier, 'carrier'], + ] as $column => [$given, $implied, $kind]) { + $code = strtolower(trim((string) $given)); + if ($code !== '' && $code !== $implied && self::term($kind, $code) !== null) { + $out[$column] = $code; + } + } + return $out; + } +} diff --git a/app/Support/Marc/UnimarcLibriMapper.php b/app/Support/Marc/UnimarcLibriMapper.php index b2a50ab12..30638d0b3 100644 --- a/app/Support/Marc/UnimarcLibriMapper.php +++ b/app/Support/Marc/UnimarcLibriMapper.php @@ -10,6 +10,7 @@ * * 011 $a ISSN 073 $a EAN 210 / 214 $a place, every $c publisher * 215 $d dimensions 330 $a summary 856 $u cover image + * label/06 media type * * The reader's roles (from the IFLA $4 relator codes) sort translators, * editors, illustrators and colorists out of the authors. @@ -86,6 +87,7 @@ public function map(MarcRecord $record): array } $data['issn'] = MarcBookData::clean($record->subfield('011', 'a')); $data['copertina_url'] = MarcBookData::coverUrlOf($record); + $data['tipo_media'] = MarcMediaType::tipoMediaOf(substr($record->leader() . ' ', 6, 1)); return $data; } diff --git a/tests/fixtures/marc/oai-golden/minimal.marcxml.xml b/tests/fixtures/marc/oai-golden/minimal.marcxml.xml index 26b31293e..14f31068e 100644 --- a/tests/fixtures/marc/oai-golden/minimal.marcxml.xml +++ b/tests/fixtures/marc/oai-golden/minimal.marcxml.xml @@ -1,2 +1,2 @@ -00000nam a2200000 45001IT-Pinakes20220203020202.0220202s xx d0306406152IT-PinakesitardaIT-PinakesPlatoneauthorRossi, MarioillustratorÆgir — ein ÖlbuchLaterzan.d.Serie0317-8471 +00000nam a2200000 45001IT-Pinakes20220203020202.0ta220202s xx d0306406152IT-PinakesitardaIT-PinakesPlatoneauthorRossi, MarioillustratorÆgir — ein ÖlbuchLaterzan.d.texttxtrdacontentunmediatednrdamediavolumencrdacarrierSerie0317-8471 diff --git a/tests/fixtures/marc/oai-golden/monograph.marcxml.xml b/tests/fixtures/marc/oai-golden/monograph.marcxml.xml index f278bca55..50e90c156 100644 --- a/tests/fixtures/marc/oai-golden/monograph.marcxml.xml +++ b/tests/fixtures/marc/oai-golden/monograph.marcxml.xml @@ -1,2 +1,2 @@ -00000nam a2200000 45004711IT-Pinakes20250602111213.0240301s1980 xx ita d97888452926138845292614IT-PinakesitardaIT-Pinakesita853.91423Umberto EcoauthorMario RossiauthorWilliam WeavertranslatorLùcia FerréeditorIl nome della rosaRomanzo «storico»Seconda edizioneMilanoBompianiFabbri Editori1980503 pages21 cmTascabili12Un monaco &amp; un delitto nell’abbazia.NarrativamedioevogialloabbaziaUgo Disegniillustrator +00000nam a2200000 45004711IT-Pinakes20250602111213.0ta240301s1980 xx ita d97888452926138845292614IT-PinakesitardaIT-Pinakesita853.91423Umberto EcoauthorMario RossiauthorWilliam WeavertranslatorLùcia FerréeditorIl nome della rosaRomanzo «storico»Seconda edizioneMilanoBompianiFabbri Editori1980503 pages21 cmtexttxtrdacontentunmediatednrdamediavolumencrdacarrierTascabili12Un monaco &amp; un delitto nell’abbazia.NarrativamedioevogialloabbaziaUgo Disegniillustrator diff --git a/tests/fixtures/marc/oai-golden/serial.marcxml.xml b/tests/fixtures/marc/oai-golden/serial.marcxml.xml index c79650ffc..a168f6b89 100644 --- a/tests/fixtures/marc/oai-golden/serial.marcxml.xml +++ b/tests/fixtures/marc/oai-golden/serial.marcxml.xml @@ -1,2 +1,2 @@ -00000nas a2200000 45009IT-Pinakes20230102000000.0230101c 9999xx eng d1234-5679IT-PinakesitardaIT-PinakesengRivista di studitraduzione di Ann TranslatorAnn TranslatortranslatorEd Itoreditor +00000nas a2200000 45009IT-Pinakes20230102000000.0ta230101c 9999xx eng d1234-5679IT-PinakesitardaIT-PinakesengRivista di studitraduzione di Ann TranslatortexttxtrdacontentunmediatednrdamediavolumencrdacarrierAnn TranslatortranslatorEd Itoreditor diff --git a/tests/marc-library.unit.php b/tests/marc-library.unit.php index 2a171d876..c94174232 100644 --- a/tests/marc-library.unit.php +++ b/tests/marc-library.unit.php @@ -215,6 +215,37 @@ $plain = BookMarcBuilder::marc21($row, $authors, [], null, false); $check($plain->first('024') === null && $plain->first('856') === null && $plain->first('050') === null, '64 without $extended the record is the OAI one (no 024/050/856)'); +echo "Media type and RDA types\n"; +$typed = static fn (string $tipo, array $extra = []): MarcRecord => BookMarcBuilder::marc21($row + ['tipo_media' => $tipo] + $extra, $authors, [], null); +$rdaOf = static fn (MarcRecord $r): array => [$r->subfield('336', 'b'), $r->subfield('337', 'b'), $r->subfield('338', 'b')]; +$book = $typed('libro'); +$check($book->leader()[6] === 'a' && $book->controlField('007') === 'ta' && $rdaOf($book) === ['txt', 'n', 'nc'], '67a a book: leader/06 a, 007 text, 336-338 txt/n/nc'); +$check($book->subfield('336', 'a') === 'text' && $book->subfield('336', '2') === 'rdacontent' && $book->subfield('337', 'a') === 'unmediated' && $book->subfield('338', 'a') === 'volume' && $book->subfield('338', '2') === 'rdacarrier', '67b the RDA term in $a, the vocabulary in $2'); +$dvd = $typed('dvd'); +$check($dvd->leader()[6] === 'g' && str_starts_with((string) $dvd->controlField('007'), 'vd') && $rdaOf($dvd) === ['tdi', 'v', 'vd'], '67c a DVD: leader/06 g, 007 videodisc, tdi/v/vd'); +$check($typed('disco')->leader()[6] === 'j' && $rdaOf($typed('disco')) === ['prm', 's', 'sd'] && $typed('audiolibro')->leader()[6] === 'i' && $rdaOf($typed('audiolibro')) === ['spw', 's', 'sd'], '67d a disco is a musical sound recording (j, prm), an audiolibro a non-musical one (i, spw)'); +$check($typed('altro')->controlField('007') === null && $rdaOf($typed('altro')) === ['xxx', 'x', 'zu'], '67e altro: no 007, RDA other/other/unspecified'); +$own = $typed('libro', ['rda_content_type' => 'ntm', 'rda_carrier_type' => 'nb']); +$check($rdaOf($own) === ['ntm', 'n', 'nb'] && $own->subfield('336', 'a') === 'notated music' && $own->subfield('338', 'a') === 'sheet', '67f the book\'s own RDA codes override what the media type implies'); +$check(BookMarcBuilder::unimarc($row + ['tipo_media' => 'dvd'], $authors, [], null)->leader()[6] === 'g' && BookMarcBuilder::unimarc($row, $authors, [], null)->leader()[6] === 'a', '67g UNIMARC label/06 follows the media type too'); +$viaIso = static fn (MarcRecord $r): array => (new Marc21LibriMapper())->map($records($all(Iso2709Reader::fromString(Iso2709Writer::serialize($r))))[0]); +$backDvd = $viaIso($dvd); +$check($backDvd['tipo_media'] === 'dvd' && !isset($backDvd['rda_content_type']) && !isset($backDvd['rda_media_type']) && !isset($backDvd['rda_carrier_type']), '67h export → import: a DVD comes back a DVD, the implied RDA codes are not stored as its own'); +$check($viaIso($typed('disco'))['tipo_media'] === 'disco' && $viaIso($typed('audiolibro'))['tipo_media'] === 'audiolibro' && $viaIso($book)['tipo_media'] === 'libro', '67i disco, audiolibro and libro round trip'); +$backOwn = $viaIso($own); +$check($backOwn['tipo_media'] === 'libro' && ($backOwn['rda_content_type'] ?? '') === 'ntm' && ($backOwn['rda_carrier_type'] ?? '') === 'nb' && !isset($backOwn['rda_media_type']), '67j only the codes the media type does not imply come back as the book\'s own'); +$music = new MarcRecord('00000nim a2200000 4500'); +$music->addDataField('245', '0', '0', [['a', 'Sonate']]); +$music->addDataField('336', ' ', ' ', [['b', 'prm'], ['2', 'rdacontent']]); +$check((new Marc21LibriMapper())->map($music)['tipo_media'] === 'disco', '67k leader/06 i with 336 prm (performed music) is a disco'); +$map = new MarcRecord('00000nem a2200000 4500'); +$map->addDataField('245', '0', '0', [['a', 'Atlas']]); +$map->addDataField('336', ' ', ' ', [['b', 'cri'], ['2', 'rdacontent']]); +$mapped = (new Marc21LibriMapper())->map($map); +$check($mapped['tipo_media'] === '' && ($mapped['rda_content_type'] ?? '') === 'cri', '67l a kind the catalogue has no media type for leaves it unset, its content type kept'); +$check((new UnimarcLibriMapper())->map($records($all(Iso2709Reader::fromString(Iso2709Writer::serialize(BookMarcBuilder::unimarc($row + ['tipo_media' => 'audiolibro'], $authors, [], null)))))[0])['tipo_media'] === 'audiolibro', '67m UNIMARC export → import keeps the media type'); +$check(MarcBookData::toImportRow($backDvd)['tipo_media'] === 'dvd', '67n the import row carries tipo_media'); + echo "Import row\n"; $importRow = MarcBookData::toImportRow($m); $check($importRow['autori'] === 'Eco, Umberto;Rossi, Mario' && $importRow['editore'] === 'Bompiani' && $importRow['curatore'] === 'Ferré, Lùcia' && !array_key_exists('traduttore', $importRow), '65 names joined with ";", an absent role is not a column'); diff --git a/tests/marc-oai-byte-identity.unit.php b/tests/marc-oai-byte-identity.unit.php index 7b2a1d2b4..664d9f679 100644 --- a/tests/marc-oai-byte-identity.unit.php +++ b/tests/marc-oai-byte-identity.unit.php @@ -36,15 +36,22 @@ $user = getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''); $dbPass = getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ''); $name = getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''); -$db = ($socket !== '' && file_exists($socket)) - ? new mysqli(null, $user, $dbPass, $name, 0, $socket) - : new mysqli($env['DB_HOST'] ?? '127.0.0.1', $user, $dbPass, $name, (int) ($env['DB_PORT'] ?? 3306)); -$db->set_charset('utf8mb4'); if (!isset($_ENV['APP_CANONICAL_URL'])) { $_ENV['APP_CANONICAL_URL'] = 'https://library.example.org'; } -$plugin = new OaiPmhServerPlugin($db, new HookManager($db)); +// The three serializations never touch the database: without one (no +// MySQL on the machine) the plugin is built without its constructor. +try { + $db = ($socket !== '' && file_exists($socket)) + ? new mysqli(null, $user, $dbPass, $name, 0, $socket) + : new mysqli($env['DB_HOST'] ?? '127.0.0.1', $user, $dbPass, $name, (int) ($env['DB_PORT'] ?? 3306)); + $db->set_charset('utf8mb4'); + $plugin = new OaiPmhServerPlugin($db, new HookManager($db)); +} catch (Throwable $e) { + echo " (no database: " . $e->getMessage() . "; the plugin is built without one)\n"; + $plugin = (new ReflectionClass(OaiPmhServerPlugin::class))->newInstanceWithoutConstructor(); +} $call = static function (string $method, array $args) use ($plugin) { $m = new ReflectionMethod(OaiPmhServerPlugin::class, $method); return $m->invokeArgs($plugin, $args); From 2fdcf9eb760b62d3b008996570e67e42af4a2527 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:15:57 +0000 Subject: [PATCH 139/398] User import applies the card duration of the patron type The CSV import assigned the patron type after the insert but never applied the type's card duration, so an imported reader of a 365-day type had no card expiry unless the file carried one. After the type is assigned, completeImportedUser() now stores PatronTypeAssignment::automaticCardExpiry() exactly like the user form does with "calcola la scadenza" ticked: only when the file gave no expiry (an explicit date wins), and from the default type when the row has no type or an unknown one. A type without duration leaves the expiry empty. The rule is written on the import page help and in the changelog. E2E: the fixture type gets a 200-day duration and the CSV a "Scadenza tessera" column filled on one row only; the imported expiries are today + 200, the explicit date, and the default type's rule. --- CHANGELOG.md | 2 +- app/Controllers/UserImportController.php | 23 +++++++++++++++++ app/Views/utenti/import.php | 1 + locale/da_DK.json | 4 ++- locale/de_DE.json | 4 ++- locale/en_US.json | 4 ++- locale/fr_FR.json | 4 ++- locale/it_IT.json | 4 ++- tests/user-import.spec.js | 33 +++++++++++++++++++----- 9 files changed, 67 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdbb9ccb..33b5be165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets. - **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card. - **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. -- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A patron-type column ("Tipologia") assigns the type by its code, in any case; an empty code leaves the reader on the default type, and so does an unknown or deactivated one, reported as a warning with its line. +- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A patron-type column ("Tipologia") assigns the type by its code, in any case; an empty code leaves the reader on the default type, and so does an unknown or deactivated one, reported as a warning with its line. The card expiry follows the user form: when the file gives none, it is computed from the card duration of the assigned patron type (the default type when the row has no type), and an expiry present in the CSV always wins. ### Circulation policy - **One source for the circulation rules.** Loan duration, request window, renewals and the active-loan limit were read straight from the loans settings in about twenty places (staff desk, reader requests on the website and in the app, approval, queue promotion, renewals, NCIP). They now all go through `App\Services\CirculationPolicy`, which returns immutable `UserPolicy`/`LoanPolicy` objects, and every due date is computed by `CirculationPolicy::dueDateFor()`. With only the global settings the numbers are the same as before (a unit test compares every old read site over a grid of stored values); a CI step rejects any new direct read of those settings. diff --git a/app/Controllers/UserImportController.php b/app/Controllers/UserImportController.php index 6c59acb53..de0da2a13 100644 --- a/app/Controllers/UserImportController.php +++ b/app/Controllers/UserImportController.php @@ -7,6 +7,7 @@ use App\Support\ImportLogger; use App\Support\NotificationService; use App\Support\PasswordSetupToken; +use App\Support\PatronTypeAssignment; use App\Support\PatronTypeImport; use App\Support\RegistrationFields; use App\Support\SecureLogger; @@ -466,6 +467,28 @@ private function completeImportedUser(mysqli $db, array &$state, int $userId, in } catch (\Throwable $e) { $warn(__('Utente importato, ma la tipologia lettore non è stata assegnata.'), $e); } + // Card expiry of the patron type, as the user form computes it with + // "calcola la scadenza" ticked: only when the file gave no expiry (an + // explicit date always wins), and from the default type when the row + // has no type or an unknown one, so a file without a type column gets + // the default type's card duration too. No duration: the expiry stays + // empty, as on the form. + if (($parsed['record']['data_scadenza_tessera'] ?? null) === null) { + try { + $expiry = PatronTypeAssignment::automaticCardExpiry($db, PatronTypeAssignment::of($db, $userId)); + if ($expiry !== null) { + $stmt = $db->prepare('UPDATE utenti SET data_scadenza_tessera = ? WHERE id = ?'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + $stmt->bind_param('si', $expiry, $userId); + $stmt->execute(); + $stmt->close(); + } + } catch (\Throwable $e) { + $warn(__('Utente importato, ma la scadenza della tessera non è stata calcolata.'), $e); + } + } if ($notifier !== null) { try { $token = PasswordSetupToken::issue($db, $userId); diff --git a/app/Views/utenti/import.php b/app/Views/utenti/import.php index 6d5ead1f0..aa16db993 100644 --- a/app/Views/utenti/import.php +++ b/app/Views/utenti/import.php @@ -202,6 +202,7 @@
    • +
    • diff --git a/locale/da_DK.json b/locale/da_DK.json index 86d8f9863..f111dab81 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8889,5 +8889,7 @@ "Gg/rinnovo": "Dage/forny.", "Rinnova prestito (+%d giorni)": "Forny lån (+%d dage)", "La scadenza verrà estesa di %d giorni": "Fristen forlænges med %d dage", - "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Vil du fornye lånet? Udløbsdatoen udvides med %d dage." + "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Vil du fornye lånet? Udløbsdatoen udvides med %d dage.", + "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Uden udløbsdato for kortet i filen beregnes datoen ud fra lånertypens kortvarighed (den angivne eller standardtypen); en udløbsdato i CSV-filen har forrang.", + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Bruger importeret, men kortets udløbsdato kunne ikke beregnes." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 9938ac749..030a6f064 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8889,5 +8889,7 @@ "Gg/rinnovo": "Tage/Verl.", "Rinnova prestito (+%d giorni)": "Ausleihe verlängern (+%d Tage)", "La scadenza verrà estesa di %d giorni": "Die Frist wird um %d Tage verlängert", - "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Ausleihe verlängern? Das Fälligkeitsdatum wird um %d Tage verlängert." + "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Ausleihe verlängern? Das Fälligkeitsdatum wird um %d Tage verlängert.", + "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Fehlt das Ablaufdatum des Ausweises in der Datei, wird es aus der Ausweisdauer des Lesertyps berechnet (des angegebenen oder des Standardtyps); ein im CSV vorhandenes Ablaufdatum hat Vorrang.", + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Benutzer importiert, aber das Ablaufdatum des Ausweises konnte nicht berechnet werden." } diff --git a/locale/en_US.json b/locale/en_US.json index b078eafce..a35c75798 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8889,5 +8889,7 @@ "Gg/rinnovo": "Days/renewal", "Rinnova prestito (+%d giorni)": "Renew loan (+%d days)", "La scadenza verrà estesa di %d giorni": "The due date will be extended by %d days", - "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renew loan? The due date will be extended by %d days." + "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renew loan? The due date will be extended by %d days.", + "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Without a card expiry in the file, the date is computed from the card duration of the patron type (the one given or the default one); an expiry present in the CSV takes precedence.", + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "User imported, but the card expiry could not be computed." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 96663fb5b..ed3f21e5f 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8889,5 +8889,7 @@ "Gg/rinnovo": "J./renouv.", "Rinnova prestito (+%d giorni)": "Renouveler l'emprunt (+%d jours)", "La scadenza verrà estesa di %d giorni": "La date d'échéance sera prolongée de %d jours", - "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renouveler l'emprunt ? La date d'échéance sera prolongée de %d jours." + "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renouveler l'emprunt ? La date d'échéance sera prolongée de %d jours.", + "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Sans date d'expiration de la carte dans le fichier, la date est calculée à partir de la durée de carte du type de lecteur (celui indiqué ou celui par défaut) ; une expiration présente dans le CSV a la priorité.", + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Utilisateur importé, mais l'expiration de la carte n'a pas pu être calculée." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 98d3347e8..c09c9510c 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8889,5 +8889,7 @@ "Gg/rinnovo": "Gg/rinnovo", "Rinnova prestito (+%d giorni)": "Rinnova prestito (+%d giorni)", "La scadenza verrà estesa di %d giorni": "La scadenza verrà estesa di %d giorni", - "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Rinnovare il prestito? La scadenza verrà estesa di %d giorni." + "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.", + "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.", + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Utente importato, ma la scadenza della tessera non è stata calcolata." } diff --git a/tests/user-import.spec.js b/tests/user-import.spec.js index 6143fdc7e..ed474e8bc 100644 --- a/tests/user-import.spec.js +++ b/tests/user-import.spec.js @@ -13,6 +13,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { serverFileExists, listServerDir, writeServerFile } = require('./helpers/server-files'); +const { appDateOffsetISO } = require('./helpers/app-date'); const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; const ROOT = path.resolve(__dirname, '..'); @@ -65,24 +66,28 @@ const EXISTING_CODE = `ZEX${RUN_UP}`; const SECRET = 'Secret123!'; const TYPE_CODE = `st${RUN}`; const TYPE_NAME = `Studenti ${RUN}`; +const TYPE_CARD_DAYS = 200; const UNKNOWN_TYPE = `nessuna${RUN}`; +const MANUAL_EXPIRY = '2031-12-31'; /** * 20 data rows: 17 valid, a duplicate email (line 6), a duplicate card code (line 13), a bad email (line 17). * Patron type: readers 1-15 carry TYPE_CODE (upper case for the even ones), 16 none, 17 (line 21) an unknown code. + * Card expiry: only reader 3 carries one (MANUAL_EXPIRY); every other row leaves the column empty. */ function buildCsv() { - const lines = [['Nome', 'Cognome', 'E-mail', 'Tel. cellulare', 'Data di nascita', 'Tessera', 'Password', CUSTOM_LABEL, 'Tipologia'].join(';')]; + const lines = [['Nome', 'Cognome', 'E-mail', 'Tel. cellulare', 'Data di nascita', 'Tessera', 'Password', CUSTOM_LABEL, 'Tipologia', 'Scadenza tessera'].join(';')]; let n = 0; for (let row = 1; row <= 20; row++) { const line = row + 1; - if (line === 6) { lines.push(['Doppia', 'Email', EXISTING_EMAIL, '', '', '', SECRET, '', ''].join(';')); continue; } - if (line === 13) { lines.push(['Doppio', 'Codice', `dupcode.${RUN}@${DOMAIN}`, '', '', EXISTING_CODE, SECRET, '', ''].join(';')); continue; } - if (line === 17) { lines.push(['Sbagliata', 'Riga', 'not-an-email', '', '', '', SECRET, '', ''].join(';')); continue; } + if (line === 6) { lines.push(['Doppia', 'Email', EXISTING_EMAIL, '', '', '', SECRET, '', '', ''].join(';')); continue; } + if (line === 13) { lines.push(['Doppio', 'Codice', `dupcode.${RUN}@${DOMAIN}`, '', '', EXISTING_CODE, SECRET, '', '', ''].join(';')); continue; } + if (line === 17) { lines.push(['Sbagliata', 'Riga', 'not-an-email', '', '', '', SECRET, '', '', ''].join(';')); continue; } n++; const code = n % 2 === 0 ? `ZIM${RUN_UP}${n}` : ''; const type = n === 17 ? UNKNOWN_TYPE : n === 16 ? '' : (n % 2 === 0 ? TYPE_CODE.toUpperCase() : TYPE_CODE); - lines.push([`Imp${n}`, `Run${RUN}`, `imp${n}.${RUN}@${DOMAIN}`, `333000${String(n).padStart(4, '0')}`, `${String(n).padStart(2, '0')}/03/1990`, code, SECRET, `@imp${n}`, type].join(';')); + const expiry = n === 3 ? MANUAL_EXPIRY : ''; + lines.push([`Imp${n}`, `Run${RUN}`, `imp${n}.${RUN}@${DOMAIN}`, `333000${String(n).padStart(4, '0')}`, `${String(n).padStart(2, '0')}/03/1990`, code, SECRET, `@imp${n}`, type, expiry].join(';')); } return lines.join('\n') + '\n'; } @@ -108,7 +113,8 @@ test.describe.serial('User import from CSV (phase 7)', () => { dbQuery(`INSERT INTO utenti (nome, cognome, email, password, codice_tessera, stato, tipo_utente, email_verificata) VALUES ('Gia', 'Presente', ${sqlStr(EXISTING_EMAIL)}, 'x', ${sqlStr(EXISTING_CODE)}, 'attivo', 'standard', 1)`); existingId = Number(dbQuery(`SELECT id FROM utenti WHERE email = ${sqlStr(EXISTING_EMAIL)}`)); - dbQuery(`INSERT INTO tipologie_lettore (codice, nome, attiva, ordinamento) VALUES (${sqlStr(TYPE_CODE)}, ${sqlStr(TYPE_NAME)}, 1, 90)`); + // The type carries a card duration: imported readers of this type get today + TYPE_CARD_DAYS. + dbQuery(`INSERT INTO tipologie_lettore (codice, nome, attiva, ordinamento, durata_tessera_giorni) VALUES (${sqlStr(TYPE_CODE)}, ${sqlStr(TYPE_NAME)}, 1, 90, ${TYPE_CARD_DAYS})`); typeId = Number(dbQuery(`SELECT id FROM tipologie_lettore WHERE codice = ${sqlStr(TYPE_CODE)}`)); // Route the application mail to Mailpit for this suite, restored afterwards. @@ -190,6 +196,7 @@ test.describe.serial('User import from CSV (phase 7)', () => { // Patron types exist: the "Tipologia" header is recognised as the patron type code. await expect(select(8).locator('option[value="tipologia"]')).toHaveCount(1); await expect(select(8)).toHaveValue('tipologia'); + await expect(select(9)).toHaveValue('data_scadenza_tessera'); // Without an email column the import refuses to start. await select(2).selectOption(''); @@ -289,6 +296,20 @@ test.describe.serial('User import from CSV (phase 7)', () => { await expect(page.getByTestId('patron-type-name')).toContainText('predefinita'); }); + test('the card expiry follows the patron type duration unless the CSV gives one', async () => { + // Readers of the 200-day type without an expiry in the file: today + 200. + const typedExpiry = appDateOffsetISO(TYPE_CARD_DAYS); + expect(dbQuery(`SELECT COUNT(*) FROM utenti WHERE email LIKE ${sqlStr(`imp%.${RUN}@${DOMAIN}`)} AND tipologia_id = ${typeId} AND data_scadenza_tessera = ${sqlStr(typedExpiry)}`)).toBe('14'); + // The explicit date in the CSV wins over the type duration. + expect(dbQuery(`SELECT IFNULL(data_scadenza_tessera,'NULL') FROM utenti WHERE email = ${sqlStr(`imp3.${RUN}@${DOMAIN}`)}`)).toBe(MANUAL_EXPIRY); + // Readers on the default type (no code, unknown code) get its duration, or no expiry when it has none. + const defaultDays = dbQuery('SELECT IFNULL(durata_tessera_giorni, \'\') FROM tipologie_lettore WHERE predefinita = 1 LIMIT 1'); + const expected = defaultDays === '' ? 'NULL' : appDateOffsetISO(Number(defaultDays)); + for (const n of [16, 17]) { + expect(dbQuery(`SELECT IFNULL(data_scadenza_tessera,'NULL') FROM utenti WHERE email = ${sqlStr(`imp${n}.${RUN}@${DOMAIN}`)}`), `reader ${n}`).toBe(expected); + } + }); + test('the run is in the import history as a users import', async () => { const importId = dbQuery(`SELECT import_id FROM import_logs WHERE file_name = ${sqlStr(path.basename(csvFile))} ORDER BY id DESC LIMIT 1`); expect(importId).not.toBe(''); From 0b6ba9582c7e22ad87ec492262e2b83284969475 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:16:28 +0000 Subject: [PATCH 140/398] MARC 21 import: Dewey prime marks dropped, corporate and meeting authors read 082 $a often carries the segmentation marks of the printed schedule ("891.73/3", "813/.54", "823'.912"); the number is what remains without them. "[Fic]" still maps to no number. A record whose main entry is a corporate body (110) or a meeting (111), and the added entries 710/711, were not read at all, so a report of an institution came in with no author. They are now entered like persons: $a with its subordinate units ($b) joined with ". ", the part from $4 / $e as for 700, a name-title entry (with $t) left out. --- app/Support/Marc/Marc21LibriMapper.php | 69 ++++++++++++++++++-------- tests/marc-library.unit.php | 17 +++++++ 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/app/Support/Marc/Marc21LibriMapper.php b/app/Support/Marc/Marc21LibriMapper.php index 42e9b546d..139c7cae2 100644 --- a/app/Support/Marc/Marc21LibriMapper.php +++ b/app/Support/Marc/Marc21LibriMapper.php @@ -17,6 +17,8 @@ * else the 245 $c statement of responsibility (RelatorRoles, the same * reading the SRU client and danMARC2 use): editors, translators, * illustrators and colorists go to their own columns, the rest stay authors + * 110 / 111 $a $b corporate body or meeting as the main entry, 710 / 711 as + * added entries, with the same roles * 245 $a title, $b rest of title 250 $a edition * 260 / 264 $a place, $b publishers, $c year (else 008/07-10) * 300 $a pages, $c dimensions 490 $a $v series (else 830) @@ -33,6 +35,25 @@ final class Marc21LibriMapper implements MarcLibriMapper 'colorist' => 'colorista', ]; + /** + * The name of a name entry: a person's $a; a corporate body's or + * meeting's $a with its subordinate units ($b), "United States. Congress". + */ + private static function entryName(MarcField $field): string + { + $name = MarcBookData::cleanName($field->subfield('a')); + if (!in_array($field->tag, ['110', '111', '710', '711'], true)) { + return $name; + } + $parts = []; + foreach (array_merge([$name], array_map([MarcBookData::class, 'cleanName'], $field->subfieldValues('b'))) as $part) { + if ($part !== '') { + $parts[] = $part; + } + } + return implode('. ', $parts); + } + public function map(MarcRecord $record): array { $data = MarcBookData::empty(); @@ -58,27 +79,33 @@ public function map(MarcRecord $record): array $contributors[$column][] = $name; } }; - foreach ($record->fieldsByTag('100') as $field) { - $name = MarcBookData::cleanName($field->subfield('a')); - $codes = $field->subfieldValues('4'); - $term = $field->subfieldValues('e'); - // The main entry is the author unless its own relator says otherwise. - $role = ($codes === [] && $term === []) - ? 'author' - : RelatorRoles::resolve($codes, $term === [] ? null : implode(' ', $term), null, $name, 'marc21'); - $file($name, $role === 'other' ? 'author' : $role); - } - foreach ($record->fieldsByTag('700') as $field) { - if ($field->subfield('t') !== null) { - continue; // a name-title entry is a work, not a person's part + // Main entry: a person (100), a corporate body (110) or a meeting + // (111); added entries the same way (700, 710, 711). + foreach (['100', '110', '111'] as $tag) { + foreach ($record->fieldsByTag($tag) as $field) { + $name = self::entryName($field); + $codes = $field->subfieldValues('4'); + $term = $field->subfieldValues('e'); + // The main entry is the author unless its own relator says otherwise. + $role = ($codes === [] && $term === []) + ? 'author' + : RelatorRoles::resolve($codes, $term === [] ? null : implode(' ', $term), null, $name, 'marc21'); + $file($name, $role === 'other' ? 'author' : $role); } - $name = MarcBookData::cleanName($field->subfield('a')); - if ($name === '') { - continue; + } + foreach (['700', '710', '711'] as $tag) { + foreach ($record->fieldsByTag($tag) as $field) { + if ($field->subfield('t') !== null) { + continue; // a name-title entry is a work, not a person's part + } + $name = self::entryName($field); + if ($name === '') { + continue; + } + $term = $field->subfieldValues('e'); + $role = RelatorRoles::resolve($field->subfieldValues('4'), $term === [] ? null : implode(' ', $term), $statement, $name, 'marc21'); + $file($name, $role); } - $term = $field->subfieldValues('e'); - $role = RelatorRoles::resolve($field->subfieldValues('4'), $term === [] ? null : implode(' ', $term), $statement, $name, 'marc21'); - $file($name, $role); } $data['autori'] = $authors; foreach ($contributors as $column => $names) { @@ -137,7 +164,9 @@ public function map(MarcRecord $record): array } $data['parole_chiave'] = $keywords; - $dewey = (string) $record->subfield('082', 'a'); + // 082 $a with its prime marks removed: "891.73/3" is 891.733, + // "813/.54" is 813.54; "[Fic]" is no number. + $dewey = str_replace(['/', "'"], '', (string) $record->subfield('082', 'a')); if (preg_match('/(\d{3}(?:\.\d+)?)/', $dewey, $m) === 1) { $data['classificazione_dewey'] = $m[1]; } diff --git a/tests/marc-library.unit.php b/tests/marc-library.unit.php index c94174232..27be55633 100644 --- a/tests/marc-library.unit.php +++ b/tests/marc-library.unit.php @@ -173,6 +173,23 @@ $crime = (new Marc21LibriMapper())->map($xml[0]); $check($crime['autori'] === ['Dostoyevsky, Fyodor'] && $crime['traduttore'] === ['McDuff, David'] && $crime['copertina_url'] === 'https://covers.example.org/crime.jpeg', '51 "translated with an introduction" → translator; 856 $q image/jpeg'); +$deweyOf = static function (string $value): string { + $r = new MarcRecord(); + $r->addDataField('082', '0', '4', [['a', $value], ['2', '23']]); + return (new Marc21LibriMapper())->map($r)['classificazione_dewey']; +}; +$check($deweyOf('891.73/3') === '891.733' && $deweyOf('813/.54') === '813.54' && $deweyOf("823'.912") === '823.912' && $deweyOf('[Fic]') === '' && $deweyOf('853.914') === '853.914', '51a 082 prime marks (/ and \') are dropped, [Fic] is no number'); +$corp = new MarcRecord(); +$corp->addDataField('110', '2', ' ', [['a', 'United States.'], ['b', 'Congress.'], ['b', 'Senate.']]); +$corp->addDataField('245', '1', '0', [['a', 'Report']]); +$corp->addDataField('710', '2', ' ', [['a', 'Istituto nazionale di statistica,'], ['e', 'editor.'], ['4', 'edt']]); +$corp->addDataField('711', '2', ' ', [['a', 'Convegno di studi'], ['d', '(1998 : Roma)']]); +$corp->addDataField('710', '2', ' ', [['a', 'Einaudi,'], ['e', 'publisher.'], ['4', 'pbl']]); +$corp->addDataField('710', '2', ' ', [['a', 'Some body'], ['t', 'A work']]); +$c = (new Marc21LibriMapper())->map($corp); +$check($c['autori'] === ['United States. Congress. Senate', 'Convegno di studi'], '51b 110 $a $b joined and a 711 meeting are authors; a publisher (710 $4 pbl) and a name-title entry are not'); +$check($c['curatore'] === ['Istituto nazionale di statistica'], '51c a 710 with $4 edt is the editor'); + echo "UNIMARC mapping\n"; $u = (new UnimarcLibriMapper())->map($uni[0]); $check($u['titolo'] === 'Se questo è un uomo' && $u['sottotitolo'] === 'testimonianza' && $u['isbn13'] === '9788806219499' && $u['ean'] === '8001234567894', '52 200, 010, 073'); From 57435c3db87347153e5b177e1f2ea04efc72e449 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:16:39 +0000 Subject: [PATCH 141/398] Create-user form: a typed card expiry wins over the automatic one "Calcola la scadenza dalla durata tessera" is ticked by default on the create form, so the automatic expiry silently replaced a date the librarian had typed. The automatic value now fills an empty date only; the checkbox keeps its meaning and the edit form, where it is an explicit opt-in on a pre-filled date, still recomputes. The label says so, and the E2E adds the case (typed date kept with the box ticked). --- CHANGELOG.md | 2 +- app/Controllers/UsersController.php | 9 ++++++--- app/Views/utenti/crea_utente.php | 2 +- locale/da_DK.json | 3 ++- locale/de_DE.json | 3 ++- locale/en_US.json | 3 ++- locale/fr_FR.json | 3 ++- locale/it_IT.json | 3 ++- tests/circulation-card.spec.js | 7 +++++++ 9 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b5be165..ab767196e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Consultation-only copies.** A single copy can be kept for in-house consultation from the book page (`copie.solo_consultazione`): it is never allocated to a loan nor counted as lendable capacity, and a title whose every copy is kept for consultation is consultation only. The book page shows "Solo consultazione" instead of the request button. The availability counter `libri.copie_disponibili` (book page button and badge, catalogue "available" filter, OPAC and mobile availability) is recalculated without the consultation copies, so a title whose only copy on the shelf is a consultation copy offers "Prenota quando disponibile", not "Richiedi prestito"; `copie_totali` keeps counting every physical copy in circulation. Existing installations get the counters recalculated by the upgrade (the Updater re-derives availability after the migrations) or from Maintenance → "Ricalcola disponibilità". - **The same rules on every channel.** Staff desk, reader requests, approval, queue promotion, renewals, the Mobile API and NCIP all refuse with the same codes: `media_limit_reached`, `not_loanable`, `reservations_disabled`, `reservation_limit_reached` (Mobile API `CirculationOutcome`, NCIP *Item Does Not Circulate* / *Maximum Check Outs Exceeded*). `GET /api/v1/catalog/books/{id}/availability` gains `loanable` and `reservations_enabled`. - **Reservation limits per patron type.** A type without reservations does not see "reserve when available" on the book page and a request that would join the waitlist is refused (`reservations_disabled`); `max_prenotazioni` counts the active queue reservations plus the loan requests still awaiting approval (`reservation_limit_reached`). Website, app, NCIP RequestItem and the admin reservation form apply them; the admin form can override them with an explicit "Ignora i limiti di prenotazione" tick (`override_limits: true` from the staff app). -- **Card duration of the patron type.** When the type sets a card duration, the card expiry is computed on creation and edit ("Calcola la scadenza dalla durata tessera", on by default when creating), on self-registration and on registration approval; the date can still be set by hand. The reader page has a **"Rinnova tessera"** button: expiry = today + the type's duration (one year when the type sets none), an expired account becomes active again, and the renewal is in the activity log. +- **Card duration of the patron type.** When the type sets a card duration, the card expiry is computed on creation and edit ("Calcola la scadenza dalla durata tessera", on by default when creating, where it fills an empty date only: a date typed by the librarian wins), on self-registration and on registration approval; the date can still be set by hand. The reader page has a **"Rinnova tessera"** button: expiry = today + the type's duration (one year when the type sets none), an expired account becomes active again, and the renewal is in the activity log. - **"Tessera in scadenza" email** (`card_expiring`, five languages). Sent `loans.card_expiry_warning_days` before the card expires (Settings → Loans, default 0 = off) by `cron/automatic-notifications.php`, once per expiry date (`utenti.card_expiry_warning_sent_for`); a renewed card re-arms it. - **Administrators only.** Patron types and the rules matrix (Settings → Circulation, the type pages and every form behind them) are reserved to administrators and to custom roles with the settings permission; a staff account is sent back to the dashboard and nothing changes. diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index 316be81b0..1531953ef 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -176,10 +176,13 @@ public function store(Request $request, Response $response, mysqli $db): Respons $dataScadenzaTessera = $dataScadenzaInput !== '' ? $dataScadenzaInput : null; } // Patron type (circulation rules) and, when asked, the card expiry it - // implies (today + the type's card duration). + // implies (today + the type's card duration). The checkbox is ticked + // by default on this form, so it fills an empty date only: a date the + // librarian typed wins. (The edit form opts in explicitly and + // recomputes, see update().) $patronTypeId = \App\Support\PatronTypeAssignment::fromSubmission($db, $data); - if (!$isAdmin && in_array($role, ['standard', 'premium'], true) && !empty($data['card_auto'])) { - $dataScadenzaTessera = \App\Support\PatronTypeAssignment::automaticCardExpiry($db, $patronTypeId) ?? $dataScadenzaTessera; + if (!$isAdmin && in_array($role, ['standard', 'premium'], true) && !empty($data['card_auto']) && $dataScadenzaInput === '') { + $dataScadenzaTessera = \App\Support\PatronTypeAssignment::automaticCardExpiry($db, $patronTypeId); } $stato = (string) ($data['stato'] ?? ($isAdmin ? 'attivo' : 'attivo')); diff --git a/app/Views/utenti/crea_utente.php b/app/Views/utenti/crea_utente.php index d87a851dc..5a60fd032 100644 --- a/app/Views/utenti/crea_utente.php +++ b/app/Views/utenti/crea_utente.php @@ -176,7 +176,7 @@
      diff --git a/locale/da_DK.json b/locale/da_DK.json index f111dab81..0c858c8fd 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8891,5 +8891,6 @@ "La scadenza verrà estesa di %d giorni": "Fristen forlænges med %d dage", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Vil du fornye lånet? Udløbsdatoen udvides med %d dage.", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Uden udløbsdato for kortet i filen beregnes datoen ud fra lånertypens kortvarighed (den angivne eller standardtypen); en udløbsdato i CSV-filen har forrang.", - "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Bruger importeret, men kortets udløbsdato kunne ikke beregnes." + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Bruger importeret, men kortets udløbsdato kunne ikke beregnes.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Beregn udløbsdatoen ud fra lånertypens kortvarighed (hvis angivet, og hvis datoen ovenfor er tom)" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 030a6f064..54fac1df6 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8891,5 +8891,6 @@ "La scadenza verrà estesa di %d giorni": "Die Frist wird um %d Tage verlängert", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Ausleihe verlängern? Das Fälligkeitsdatum wird um %d Tage verlängert.", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Fehlt das Ablaufdatum des Ausweises in der Datei, wird es aus der Ausweisdauer des Lesertyps berechnet (des angegebenen oder des Standardtyps); ein im CSV vorhandenes Ablaufdatum hat Vorrang.", - "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Benutzer importiert, aber das Ablaufdatum des Ausweises konnte nicht berechnet werden." + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Benutzer importiert, aber das Ablaufdatum des Ausweises konnte nicht berechnet werden.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Ablaufdatum aus der Ausweisdauer des Lesertyps berechnen (falls angegeben und falls das Datum oben leer bleibt)" } diff --git a/locale/en_US.json b/locale/en_US.json index a35c75798..2249c0a9a 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8891,5 +8891,6 @@ "La scadenza verrà estesa di %d giorni": "The due date will be extended by %d days", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renew loan? The due date will be extended by %d days.", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Without a card expiry in the file, the date is computed from the card duration of the patron type (the one given or the default one); an expiry present in the CSV takes precedence.", - "Utente importato, ma la scadenza della tessera non è stata calcolata.": "User imported, but the card expiry could not be computed." + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "User imported, but the card expiry could not be computed.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Compute the expiry from the card duration of the patron type (when set, and when the date above is left empty)" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index ed3f21e5f..4dffa1268 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8891,5 +8891,6 @@ "La scadenza verrà estesa di %d giorni": "La date d'échéance sera prolongée de %d jours", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renouveler l'emprunt ? La date d'échéance sera prolongée de %d jours.", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Sans date d'expiration de la carte dans le fichier, la date est calculée à partir de la durée de carte du type de lecteur (celui indiqué ou celui par défaut) ; une expiration présente dans le CSV a la priorité.", - "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Utilisateur importé, mais l'expiration de la carte n'a pas pu être calculée." + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Utilisateur importé, mais l'expiration de la carte n'a pas pu être calculée.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Calculer l'expiration à partir de la durée de carte du type de lecteur (si indiquée et si la date ci-dessus est laissée vide)" } diff --git a/locale/it_IT.json b/locale/it_IT.json index c09c9510c..5bbd98905 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8891,5 +8891,6 @@ "La scadenza verrà estesa di %d giorni": "La scadenza verrà estesa di %d giorni", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.", - "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Utente importato, ma la scadenza della tessera non è stata calcolata." + "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Utente importato, ma la scadenza della tessera non è stata calcolata.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)" } diff --git a/tests/circulation-card.spec.js b/tests/circulation-card.spec.js index b80e0094d..26cdda804 100644 --- a/tests/circulation-card.spec.js +++ b/tests/circulation-card.spec.js @@ -103,6 +103,13 @@ test.describe.serial('Card duration, renewal and expiry warning', () => { expect(cardOf(id)).toBe(manual); }); + test('with "calcola" left ticked (the default), a date typed by the librarian still wins', async () => { + const manual = F.appDateOffsetISO(50); + const id = await createUserViaForm(admin, { email: `${PREFIX}-a4@circ.test`, surname: `CDD${RUN}`, typeId: yearType, manualExpiry: manual }); + users.push(id); + expect(cardOf(id)).toBe(manual); + }); + test('editing a patron to a 30-day type with "calcola" recomputes the card', async () => { const id = users[0]; await admin.goto(`${F.BASE}/admin/users/edit/${id}`); From 5802159b827cf07a51a455d510a5c68afb551a90 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:17:37 +0000 Subject: [PATCH 142/398] Settings page: no circulation section for staff Every CirculationSettingsController action is reserved to Permissions::mayConfigure(), but the settings page still listed the Circulation section for staff, computed its view data and included the tab, so /admin/settings?tab=circulation showed the whole panel to a staff account (the forms were refused, the data was not). The section is now added to the settings catalogue only for those who may configure, the controller skips viewData() for everyone else and the view includes no panel; ?tab=circulation falls back to the default section like any unknown tab. E2E: staff gets the general section, without the panel, the forms or the menu entry. --- CHANGELOG.md | 2 +- app/Controllers/SettingsController.php | 7 +++++-- app/Views/partials/settings-sections.php | 12 ++++++++++-- app/Views/settings/index.php | 4 ++-- tests/circulation-settings-access.spec.js | 11 +++++++++++ 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab767196e..8090ec020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Reservation limits per patron type.** A type without reservations does not see "reserve when available" on the book page and a request that would join the waitlist is refused (`reservations_disabled`); `max_prenotazioni` counts the active queue reservations plus the loan requests still awaiting approval (`reservation_limit_reached`). Website, app, NCIP RequestItem and the admin reservation form apply them; the admin form can override them with an explicit "Ignora i limiti di prenotazione" tick (`override_limits: true` from the staff app). - **Card duration of the patron type.** When the type sets a card duration, the card expiry is computed on creation and edit ("Calcola la scadenza dalla durata tessera", on by default when creating, where it fills an empty date only: a date typed by the librarian wins), on self-registration and on registration approval; the date can still be set by hand. The reader page has a **"Rinnova tessera"** button: expiry = today + the type's duration (one year when the type sets none), an expired account becomes active again, and the renewal is in the activity log. - **"Tessera in scadenza" email** (`card_expiring`, five languages). Sent `loans.card_expiry_warning_days` before the card expires (Settings → Loans, default 0 = off) by `cron/automatic-notifications.php`, once per expiry date (`utenti.card_expiry_warning_sent_for`); a renewed card re-arms it. -- **Administrators only.** Patron types and the rules matrix (Settings → Circulation, the type pages and every form behind them) are reserved to administrators and to custom roles with the settings permission; a staff account is sent back to the dashboard and nothing changes. +- **Administrators only.** Patron types and the rules matrix (Settings → Circulation, the type pages and every form behind them) are reserved to administrators and to custom roles with the settings permission; a staff account is sent back to the dashboard and nothing changes. For staff the Circulation section is absent from the settings navigation and `/admin/settings?tab=circulation` opens the default section without the circulation panel. ### Migration diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 94cd42f49..0245b8d2f 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -80,8 +80,11 @@ public function index(Request $request, Response $response, mysqli $db): Respons $eventsHeaderDefaults = \App\Support\CatalogHeader::defaultsFor(array_map('strval', array_keys($catalogHeaderLocales)), 'events'); $advancedSettings = $this->resolveAdvancedSettings($repository); $loansSettings = $this->resolveLoansSettings($repository); - // Patron types and rules matrix (Settings → Circulation). - $circulationData = CirculationSettingsController::viewData($db); + // Patron types and rules matrix (Settings → Circulation): reserved to + // administrators like every CirculationSettingsController action, so + // staff gets no data and the view renders no panel (the section is + // absent from the settings catalogue for them too). + $circulationData = \App\Support\Permissions::mayConfigure() ? CirculationSettingsController::viewData($db) : null; $contactMessages = $this->loadContactMessages($db); $cookieBannerTexts = $this->resolveCookieBannerTexts($repository); // Custom registration fields (issue #255) — edited from the email tab's diff --git a/app/Views/partials/settings-sections.php b/app/Views/partials/settings-sections.php index 136c0c012..7caba4f35 100644 --- a/app/Views/partials/settings-sections.php +++ b/app/Views/partials/settings-sections.php @@ -1,6 +1,6 @@ ['group' => __('Biblioteca'), 'label' => __('Identità'), 'description' => __('Nome, logo e informazioni della biblioteca mostrate nel sito.'), 'icon' => 'fa-building'], 'email' => ['group' => __('Comunicazione'), 'label' => __('Email'), 'description' => __('Configura il servizio di invio email e verifica la consegna.'), 'icon' => 'fa-envelope'], 'registration' => ['group' => __('Biblioteca'), 'label' => __('Registrazione'), 'description' => __('Gestisci iscrizioni, approvazioni e campi degli utenti.'), 'icon' => 'fa-user-plus'], @@ -14,5 +14,13 @@ 'sharing' => ['group' => __('Sito e sistema'), 'label' => __('Condivisione'), 'description' => __('Personalizza le anteprime e i contenuti condivisi dal sito.'), 'icon' => 'fa-share-alt'], 'advanced' => ['group' => __('Sito e sistema'), 'label' => __('Avanzate'), 'description' => __('Gestisci integrazioni e configurazioni avanzate del sistema.'), 'icon' => 'fa-cogs'], 'loans' => ['group' => __('Biblioteca'), 'label' => __('Prestiti'), 'description' => __('Configura le regole e le automazioni della circolazione.'), 'icon' => 'fa-book-open'], - 'circulation' => ['group' => __('Biblioteca'), 'label' => __('Circolazione'), 'description' => __('Tipologie di lettore e regole di prestito per tipo di materiale.'), 'icon' => 'fa-id-badge'], ]; +// Patron types and the rules matrix are library policy: administrators and +// custom roles with the settings permission only (CirculationSettingsController +// guards every page and form the same way). Staff reaches the settings page, +// but neither sees the section in the navigation nor reaches its tab: +// ?tab=circulation falls back to the default section like an unknown tab. +if (\App\Support\Permissions::mayConfigure()) { + $settingsCatalogue['circulation'] = ['group' => __('Biblioteca'), 'label' => __('Circolazione'), 'description' => __('Tipologie di lettore e regole di prestito per tipo di materiale.'), 'icon' => 'fa-id-badge']; +} +return $settingsCatalogue; diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index ad87eb906..fbfde5f49 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -957,8 +957,8 @@ class="sharing-provider-checkbox w-4 h-4 rounded text-gray-900 focus:ring-gray-5 - - + + diff --git a/tests/circulation-settings-access.spec.js b/tests/circulation-settings-access.spec.js index 6311329a2..bfb18a5fe 100644 --- a/tests/circulation-settings-access.spec.js +++ b/tests/circulation-settings-access.spec.js @@ -54,6 +54,17 @@ test.describe.serial('Circulation settings: administrators only', () => { await expect(staffPage.locator('[data-testid="circulation-type-edit"]')).toHaveCount(0); }); + test('staff opening the settings page on the circulation tab gets the default section, without the panel or the menu entry', async () => { + await staffPage.goto(`${F.BASE}/admin/settings?tab=circulation`); + await expect(staffPage).toHaveURL(/\/admin\/settings/); + await expect(staffPage.locator('[data-settings-panel="general"]')).toBeVisible(); + await expect(staffPage.locator('[data-settings-panel="circulation"]')).toHaveCount(0); + await expect(staffPage.locator('[data-testid="circulation-type-create"]')).toHaveCount(0); + await expect(staffPage.locator('form[action$="/admin/settings/circulation/rules"]')).toHaveCount(0); + await expect(staffPage.locator('[data-settings-section="circulation"]')).toHaveCount(0); + await expect(staffPage.locator('[data-settings-section="loans"]').first()).toBeAttached(); + }); + test('every circulation form sent by staff is refused and changes nothing', async () => { await staffPage.goto(`${F.BASE}/admin/dashboard`); const csrf = await staffPage.locator('meta[name="csrf-token"]').getAttribute('content'); From 3505f884c977358f6c837e110fafd9033a23a420 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:18:17 +0000 Subject: [PATCH 143/398] Equivalence test documents the NCIP RequestItem due date cap handleRequestItem() now takes requestDurationDays(), the loan duration capped by the patron type's request window, while the test still described RequestItem as reading the bare NCIP duration. The comment names the change and a check pins the capped value next to the one for website and app requests; the changelog says NCIP RequestItem due dates are now capped by the maximum loan duration like every other channel. --- CHANGELOG.md | 2 +- tests/circulation-policy-equivalence.unit.php | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8090ec020..87e7ee899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Patron types** (Settings → Circulation). Every reader belongs to a patron type (child, student, teacher…) that decides the circulation rules; the role (`tipo_utente`) keeps deciding only the permissions. A type can set the total active-loan limit, loan duration, longest request, renewals and days per renewal, whether reservations are allowed and how many, and the card duration; an empty field inherits the global loans setting. Types are created, edited, ordered and deleted from the new tab, one of them is the default for patrons without a type. The user forms have a "Tipologia lettore" select, the reader page shows the effective rules, the user list filters by type and the CSV export has the column. Existing users are not migrated: everyone starts on the default "Standard" type, which inherits everything. - **Rules matrix patron type × material** (`libri.tipo_media`). A cell can make a material consultation only, cap the loans of that material on top of the total limit, and set its duration, renewals and days per renewal; the matrix shows the inherited value of every empty cell in grey. The book page's renew button and its confirmation say the real renewal length of the loan instead of a fixed "+14 giorni". Per field, the first value wins among rule (type, material) → rule (type, every material) → rule (every type, material) → patron type → global settings. - **Consultation-only copies.** A single copy can be kept for in-house consultation from the book page (`copie.solo_consultazione`): it is never allocated to a loan nor counted as lendable capacity, and a title whose every copy is kept for consultation is consultation only. The book page shows "Solo consultazione" instead of the request button. The availability counter `libri.copie_disponibili` (book page button and badge, catalogue "available" filter, OPAC and mobile availability) is recalculated without the consultation copies, so a title whose only copy on the shelf is a consultation copy offers "Prenota quando disponibile", not "Richiedi prestito"; `copie_totali` keeps counting every physical copy in circulation. Existing installations get the counters recalculated by the upgrade (the Updater re-derives availability after the migrations) or from Maintenance → "Ricalcola disponibilità". -- **The same rules on every channel.** Staff desk, reader requests, approval, queue promotion, renewals, the Mobile API and NCIP all refuse with the same codes: `media_limit_reached`, `not_loanable`, `reservations_disabled`, `reservation_limit_reached` (Mobile API `CirculationOutcome`, NCIP *Item Does Not Circulate* / *Maximum Check Outs Exceeded*). `GET /api/v1/catalog/books/{id}/availability` gains `loanable` and `reservations_enabled`. +- **The same rules on every channel.** Staff desk, reader requests, approval, queue promotion, renewals, the Mobile API and NCIP all refuse with the same codes: `media_limit_reached`, `not_loanable`, `reservations_disabled`, `reservation_limit_reached` (Mobile API `CirculationOutcome`, NCIP *Item Does Not Circulate* / *Maximum Check Outs Exceeded*). `GET /api/v1/catalog/books/{id}/availability` gains `loanable` and `reservations_enabled`. NCIP RequestItem due dates are now capped by the maximum loan duration like every other channel (they used to take the bare loan duration). - **Reservation limits per patron type.** A type without reservations does not see "reserve when available" on the book page and a request that would join the waitlist is refused (`reservations_disabled`); `max_prenotazioni` counts the active queue reservations plus the loan requests still awaiting approval (`reservation_limit_reached`). Website, app, NCIP RequestItem and the admin reservation form apply them; the admin form can override them with an explicit "Ignora i limiti di prenotazione" tick (`override_limits: true` from the staff app). - **Card duration of the patron type.** When the type sets a card duration, the card expiry is computed on creation and edit ("Calcola la scadenza dalla durata tessera", on by default when creating, where it fills an empty date only: a date typed by the librarian wins), on self-registration and on registration approval; the date can still be set by hand. The reader page has a **"Rinnova tessera"** button: expiry = today + the type's duration (one year when the type sets none), an expired account becomes active again, and the renewal is in the activity log. - **"Tessera in scadenza" email** (`card_expiring`, five languages). Sent `loans.card_expiry_warning_days` before the card expires (Settings → Loans, default 0 = off) by `cron/automatic-notifications.php`, once per expiry date (`utenti.card_expiry_warning_sent_for`); a renewed card re-arms it. diff --git a/tests/circulation-policy-equivalence.unit.php b/tests/circulation-policy-equivalence.unit.php index 170ddceb5..deac5831b 100644 --- a/tests/circulation-policy-equivalence.unit.php +++ b/tests/circulation-policy-equivalence.unit.php @@ -66,7 +66,10 @@ public function get(string $category, string $key, ?string $default = null): ?st $old = [ // SettingsRepository::loanDurationDays() (PrestitiController store/createForm/renew, LoanRepository, StaffLoanReader) 'duration' => static fn (SettingsRepository $s): int => (($d = (int) ($s->get('loans', 'loan_duration_days', '30') ?? 30)) >= 1 ? $d : 30), - // ReservationsAdminController::loanDurationDays(), NCIP CheckOut/RequestItem/RenewItem + // ReservationsAdminController::loanDurationDays(), NCIP CheckOut/RenewItem. + // NCIP RequestItem used this bare duration too; it now takes + // requestDurationDays() (duration capped by the request window), the + // same due date as a website or app request — checked below. 'duration_ncip' => static fn (SettingsRepository $s): int => (($d = (int) ($s->get('loans', 'loan_duration_days', '30') ?? 30)) > 0 ? $d : 30), // SettingsRepository::maxLoanDurationDays() (ReservationsController, UserActionsController, FrontendController) 'max_request' => static fn (SettingsRepository $s): int => (($d = (int) ($s->get('loans', 'max_loan_duration_days', '90') ?? 90)) >= 1 ? $d : 90), @@ -110,6 +113,9 @@ public function get(string $category, string $key, ?string $default = null): ?st $check($p->renewalDays === $old['duration']($settings), "renewal length = loan duration #{$i} {$tag}"); $check($p->maxRequestDays === $old['max_request']($settings), "max request #{$i} {$tag}"); $check($p->requestDurationDays() === min($old['duration']($settings), $old['max_request']($settings)), "request default #{$i} {$tag}"); + // NCIP RequestItem (NcipServerPlugin::handleRequestItem) no longer + // equals the bare NCIP duration: it is capped like every other channel. + $check($p->requestDurationDays() === min($old['duration_ncip']($settings), $old['max_request']($settings)), "NCIP RequestItem due = duration capped by the request window #{$i} {$tag}"); $check($p->maxRenewals === $old['renewals']($settings), "renewals #{$i} {$tag}"); $check($p->maxRenewals === $old['renewals_numeric']($settings), "renewals (numeric form) #{$i} {$tag}"); $check(($p->maxActiveLoans > 0) === ($old['max_loans']($settings) > 0), "limit enabled #{$i} {$tag}"); From 366ef466c2bc329867cf1520693e913f36b669db Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:18:51 +0000 Subject: [PATCH 144/398] Return template of an upgraded install gains the {{multa}} line EmailService serves the stored loan_returned row, and ensureEmailTemplates only healed rows still carrying the Italian text, so an install upgraded to 0.9.0 never saw the new placeholder unless an administrator pasted it in. SettingsMailTemplates::retiredBodies() lists what loan_returned shipped with before 0.9.0 per locale: the in-code/mail_templates default (the current one without the added line, the only change in the git history) and the English, German and French installer seeds, which had their own wording. In the same ensureEmailTemplates pass a stored row equal to one of them (raw or as normalizeContent stored it) is moved to the current body, keeping its subject, description and active flag; any other text is a customisation and stays. Idempotent: a healed row matches nothing retired. The fines migration test seeds each retired body per locale, runs the pass twice, and checks a customised row is untouched; it also derives the pre-0.9.0 installer seed from the current data_.sql and asserts it is among the retired bodies. --- CHANGELOG.md | 2 +- app/Models/SettingsRepository.php | 39 ++++++++++ app/Support/SettingsMailTemplates.php | 79 +++++++++++++++++++++ tests/migration-0.9.0-fines-ledger.unit.php | 57 ++++++++++++++- 4 files changed, 175 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b847eb6b..1a9625447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Late fines.** Settings → Loans → "Multe e blocco per debito": daily fine, grace days, maximum fine per loan. These are the global layer of `CirculationPolicy`: a patron type (Settings → Circulation) can set its own daily fine, grace days, cap per loan and debt threshold, and a cell of the rules matrix its own fine, cap and grace for one material; an empty field inherits, 0 switches fines (or the block) off for that type or material. Late days are the days the library is open (closures calendar: weekly closed days, closures, yearly closures, branch closures for the copy's branch); with no closure configured every day counts. The grace period is a threshold (SLiMS rule): up to the grace days nothing is charged, beyond it every late day counts from the first. With the daily fine empty or 0 (the default) nothing changes. The fine is written at return, in the same transaction as the close, on every return path (return screen, quick return, NCIP check-in), and only once per return (idempotency key `late:`). Fines accruing on open overdue loans are shown on the loan, the reader record, the reader's own page and the app, never written in advance. - **Patron ledger.** New append-only table `movimenti_lettore` (charges, payments, waivers, reversals); the balance is the sum of the rows. The reader record has a "Movimenti" section: balance, accruing fines, list, register a payment (cash, card, transfer, other) with a PDF receipt, manual charge (description required), waiver (reason required). Each desk form is recorded once even when it is sent twice, and a long ledger is shown 200 rows per page. Recording a payment, a charge or a waiver and downloading a receipt are loan-desk actions: they need the loans permission (`loans.manage`, as Staff has), so a custom role that only manages readers sees the ledger read-only. The return screen shows the fine before the confirmation and can waive it on the spot, with a reason; a waived fine no longer appears in the return email, the fine email or on the loan page. A reader with ledger rows is suspended instead of deleted. - **Debt block.** With a threshold set (the patron type's, else the global one), a reader who owes more than it gets `debt_block` ("Saldo a debito oltre la soglia consentita…") on loan requests, admin loans, approvals, renewals and the reservation queue, on the website, the Mobile API and NCIP. -- **Reports and email.** Statistics: charged, collected, waived and reversed per month with CSV export. Return email: new placeholder `{{multa}}` (empty when nothing was charged). New optional email "Multa per ritardo addebitata" (`fine_charged`), off by default. +- **Reports and email.** Statistics: charged, collected, waived and reversed per month with CSV export. Return email: new placeholder `{{multa}}` (empty when nothing was charged); an upgraded install whose stored "Restituzione confermata" template is still the previously shipped text gets the line when Settings → Email templates is opened, a customised template is left as it is. New optional email "Multa per ritardo addebitata" (`fine_charged`), off by default. - **Mobile API 1.9.0** (requires 0.9.0): `GET /me/movements` with balance, movements and accruing fines; `debt_block` arrives as `not_eligible` with `meta.reason`. - `app.currency` is now read from the database too (it was a code default only). - **Fine settings are administrators only.** Staff can still save the Loans tab, but the daily fine, grace days, cap per loan, debt threshold and fine email stay as they are unless an administrator changes them; the fields are read-only for staff. diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index 487c88a23..4dcd3428d 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -209,8 +209,10 @@ public function ensureEmailTemplates(array $templates, ?string $locale = null): true, $locale ); + continue; } } + $this->healRetiredDefault($name, $existing, $template, $locale); continue; } $this->saveEmailTemplate( @@ -224,6 +226,43 @@ public function ensureEmailTemplates(array $templates, ?string $locale = null): } } + /** + * A stored row that still equals a default this template shipped with + * BEFORE the current one (SettingsMailTemplates::retiredBodies(): the + * 0.9.0 loan_returned without its {{multa}} line) gets the current body, + * so a new placeholder reaches an upgraded install. Any other text is a + * customisation and is left exactly as it is; the subject, the description + * and the active flag of the row are kept. Idempotent: once healed the row + * equals the current default and matches nothing retired. + * + * @param array $existing + * @param array{subject:string, body:string, description?:string} $template + */ + private function healRetiredDefault(string $name, array $existing, array $template, string $locale): bool + { + $canon = static fn (string $html): string => trim(str_replace("\r\n", "\n", $html)); + $stored = $canon((string) $existing['body']); + $current = $canon((string) $template['body']); + if ($stored === '' || $stored === $current) { + return false; + } + foreach (\App\Support\SettingsMailTemplates::retiredBodies($name, $locale) as $retired) { + if ($stored !== $canon($retired) && $stored !== $canon(\App\Support\EmailLayout::normalizeContent($retired))) { + continue; + } + $this->saveEmailTemplate( + $name, + (string) $existing['subject'], + $template['body'], + isset($existing['description']) ? (string) $existing['description'] : ($template['description'] ?? null), + (int) ($existing['active'] ?? 1) === 1, + $locale + ); + return true; + } + return false; + } + /** * #299: repair email templates already stored with links double-prefixed by * the admin base — the WYSIWYG editor used to resolve placeholder URLs (e.g. diff --git a/app/Support/SettingsMailTemplates.php b/app/Support/SettingsMailTemplates.php index d78f207c1..0b138e150 100644 --- a/app/Support/SettingsMailTemplates.php +++ b/app/Support/SettingsMailTemplates.php @@ -715,6 +715,85 @@ public static function get(string $template, ?string $locale = null): ?array return $all[$template] ?? null; } + /** + * Bodies a template shipped with BEFORE its current default, so an + * upgraded install's stored row can be told apart from a customised one: + * SettingsRepository::ensureEmailTemplates() moves a row that still + * equals one of them to the current default and leaves any other text + * alone. Empty for a template whose default never changed. + * + * loan_returned (0.9.0): the only change was the added `

      {{multa}}

      ` + * line, so the previous in-code/mail_templates default is the current one + * without that line; the English, German and French installer seeds of + * the same template carried their own wording (and the {{user_name}} / + * {{book_title}} aliases), listed as they were shipped. + * + * @return list + */ + public static function retiredBodies(string $template, ?string $locale = null): array + { + if ($template !== 'loan_returned') { + return []; + } + $current = (string) (self::get($template, $locale)['body'] ?? ''); + $bodies = []; + $previous = str_replace("

      {{multa}}

      \n", '', $current); + if ($previous !== $current) { + $bodies[] = $previous; + } + $seeds = [ + 'en_US' => <<<'HTML' +

      Return Confirmed

      +

      Hello {{user_name}},

      +

      We confirm the return of the following book. Thank you!

      +
      +

      Book: {{book_title}}

      +

      Return date: {{data_restituzione}}

      +
      +

      We hope you enjoyed the book. See you soon at the library!

      +

      Best regards,
      The Library Team

      +HTML, + 'de_DE' => <<<'HTML' +

      Rückgabe bestätigt

      +

      Guten Tag {{user_name}},

      +

      wir bestätigen die Rückgabe des folgenden Buches. Vielen Dank!

      +
      +

      Buch: {{book_title}}

      +

      Rückgabedatum: {{data_restituzione}}

      +
      +

      Wir hoffen, die Lektüre hat Ihnen gefallen. Bis bald in der Bibliothek!

      +

      Mit freundlichen Grüßen,
      Das Bibliotheksteam

      +HTML, + 'fr_FR' => <<<'HTML' +

      Retour confirmé

      +

      Bonjour {{user_name}},

      +

      Nous confirmons le retour du livre suivant. Merci !

      +
      +

      Livre : {{book_title}}

      +

      Date de retour : {{data_restituzione}}

      +
      +

      Nous espérons que la lecture vous a plu. À bientôt à la bibliothèque !

      +

      Cordialement,
      L'équipe de la bibliothèque

      +HTML, + ]; + $key = self::shippedLocaleKey($locale); + if ($key !== null && isset($seeds[$key])) { + $bodies[] = $seeds[$key]; + } + return $bodies; + } + + /** The mail_templates file key ($locale → en_US/de_DE/fr_FR/da_DK), null for Italian or an unknown locale. */ + private static function shippedLocaleKey(?string $locale): ?string + { + $locale = str_replace('-', '_', trim((string) $locale)); + if ($locale === '' || str_starts_with($locale, 'it')) { + return null; + } + $map = ['en' => 'en_US', 'de' => 'de_DE', 'fr' => 'fr_FR', 'da' => 'da_DK', 'en_US' => 'en_US', 'de_DE' => 'de_DE', 'fr_FR' => 'fr_FR', 'da_DK' => 'da_DK']; + return $map[$locale] ?? ($map[substr($locale, 0, 2)] ?? null); + } + /** * #360: whether this locale has SHIPPED template texts — the in-code * Italian base or a mail_templates/.php override. Distinguishes a diff --git a/tests/migration-0.9.0-fines-ledger.unit.php b/tests/migration-0.9.0-fines-ledger.unit.php index 995c35759..aa399d42f 100644 --- a/tests/migration-0.9.0-fines-ledger.unit.php +++ b/tests/migration-0.9.0-fines-ledger.unit.php @@ -11,7 +11,11 @@ * - a second run (and the PHP backfill) adds nothing; prestiti.sanzione is * left untouched (it stays the mirror for emails and the activity log); * - a penalty assessed after the upgrade is picked up by the maintenance - * backfill once, with the same key. + * backfill once, with the same key; + * - the stored loan_returned template of an upgraded install (the previous + * shipped default, in-code or installer seed) gains the {{multa}} line + * through SettingsRepository::ensureEmailTemplates(); a customised row is + * left alone. * * Sandbox: _migfines, dropped at the end (override with MIG_SANDBOX_DB). * Run: php tests/migration-0.9.0-fines-ledger.unit.php (exit code is the verdict) @@ -121,6 +125,57 @@ $check($ledger->backfillLegacyPenalties() === 0 && $scalar("SELECT COUNT(*) FROM movimenti_lettore WHERE chiave_idempotenza = 'penalty:15'") === '1', '...exactly once'); $check($ledger->chargeLossPenalty(15) === 0, 'the live path (same key) does not double it either'); + echo "Email templates: {{multa}} reaches a stored loan_returned default (SettingsRepository::ensureEmailTemplates)\n"; + $repo = new \App\Models\SettingsRepository($db); + $repo->ensureTables(); + $defaults = static function (string $locale): array { + $out = []; + foreach (\App\Support\SettingsMailTemplates::all($locale) as $name => $meta) { + $out[$name] = ['subject' => $meta['subject'], 'body' => \App\Support\EmailLayout::normalizeContent((string) $meta['body']), 'description' => $meta['description'] ?? null]; + } + return $out; + }; + $seedRow = static function (string $locale, string $body, string $subject = 'Old subject') use ($db): void { + $stmt = $db->prepare("INSERT INTO email_templates (name, locale, subject, body, description, active) VALUES ('loan_returned', ?, ?, ?, 'seeded', 1) + ON DUPLICATE KEY UPDATE subject = VALUES(subject), body = VALUES(body), description = VALUES(description), active = 1"); + $stmt->bind_param('sss', $locale, $subject, $body); + $stmt->execute(); + $stmt->close(); + }; + // The body of the installer seed of $locale (its SQL escapes decoded). + $installerSeed = static function (string $locale) use ($root): string { + $sql = (string) file_get_contents($root . '/installer/database/data_' . $locale . '.sql'); + if (preg_match("/INSERT (?:IGNORE )?INTO `email_templates` VALUES \\(\\d+,'loan_returned','" . $locale . "','(?:[^'\\\\]|\\\\.|'')*','((?:[^'\\\\]|\\\\.|'')*)'/", $sql, $m) !== 1) { + return ''; + } + $body = str_replace("''", "'", $m[1]); + return (string) preg_replace_callback('/\\\\(.)/', static fn (array $x): string => $x[1] === 'n' ? "\n" : $x[1], $body); + }; + foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) { + $current = (string) $defaults($locale)['loan_returned']['body']; + $retired = \App\Support\SettingsMailTemplates::retiredBodies('loan_returned', $locale); + $check($retired !== [] && str_contains($current, '{{multa}}') && !str_contains(implode('', $retired), '{{multa}}'), "{$locale}: the current default carries {{multa}}, the retired bodies do not"); + $seed = $installerSeed($locale); + $check($seed !== '' && in_array(trim(str_replace("

      {{multa}}

      \n", '', $seed)), array_map('trim', $retired), true), "{$locale}: the installer seed without its {{multa}} line is a retired body (what a pre-0.9.0 install stored)"); + foreach ($retired as $i => $old) { + $seedRow($locale, $old); + $repo->ensureEmailTemplates($defaults($locale), $locale); + $row = $repo->getEmailTemplate('loan_returned', $locale); + $check(is_array($row) && trim((string) $row['body']) === trim($current) && str_contains((string) $row['body'], '{{multa}}'), "{$locale}: stored retired body #{$i} is moved to the current default"); + $check(is_array($row) && (string) $row['subject'] === 'Old subject' && (string) $row['description'] === 'seeded', "{$locale}: subject and description of the row are kept"); + $repo->ensureEmailTemplates($defaults($locale), $locale); + $again = $repo->getEmailTemplate('loan_returned', $locale); + $check(is_array($again) && (string) $again['body'] === (string) $row['body'], "{$locale}: a second pass changes nothing (idempotent)"); + } + $custom = str_replace('', ' (customised)', (string) $retired[0]); + $seedRow($locale, $custom, 'My subject'); + $repo->ensureEmailTemplates($defaults($locale), $locale); + $row = $repo->getEmailTemplate('loan_returned', $locale); + $check(is_array($row) && (string) $row['body'] === $custom && (string) $row['subject'] === 'My subject', "{$locale}: a customised body (one word away from the old default) is left alone"); + $check(is_array($row) && !str_contains((string) $row['body'], '{{multa}}'), "{$locale}: ...and gets no {{multa}} added behind the administrator's back"); + } + $check((int) $scalar("SELECT COUNT(*) FROM email_templates WHERE name = 'loan_returned'") === 5, 'one loan_returned row per locale after the passes'); + $schema = (string) file_get_contents($root . '/installer/database/schema.sql'); $check(str_contains($schema, 'CREATE TABLE `movimenti_lettore`') && str_contains($schema, 'UNIQUE KEY `uniq_movimenti_chiave` (`chiave_idempotenza`)'), 'fresh install (schema.sql) has the same table'); $version = (string) (json_decode((string) file_get_contents($root . '/version.json'), true)['version'] ?? ''); From 3277da86d30273c559fe507eaf6005fddb619e4d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:18:52 +0000 Subject: [PATCH 145/398] MARC import: fill mode completes a family as soon as one column is empty The "fill empty fields" mode decided per CSV family and only when every column of the family was empty, so a book with an ISBN-13 and no ISBN-10, or a series without its number, never gained the missing half. A family is now written when any of its columns is empty, and the planning hands every column the book already has back to the pipeline unchanged, so no non-empty field is overwritten in fill mode whatever the record says (ISBN-13/ISBN-10, series and number, the legacy contributor columns); in overwrite mode a column the record leaves empty keeps the book's value as before. The decision is a pure static method (MarcBookImporter::plan()) with its own checks. The genre becomes a family a record may write (the next change emits and reads it); the format and the price stay out of a MARC record's reach. --- app/Support/Marc/MarcBookImporter.php | 66 +++++++++++++++++---------- tests/marc-import-robustness.unit.php | 35 +++++++++++++- 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/app/Support/Marc/MarcBookImporter.php b/app/Support/Marc/MarcBookImporter.php index 4a7785ebc..a8298c9f2 100644 --- a/app/Support/Marc/MarcBookImporter.php +++ b/app/Support/Marc/MarcBookImporter.php @@ -20,7 +20,8 @@ * * skip the book is left as it is * fill only the fields the book has empty are filled from the record - * (BookDataMerger decides what is empty) + * (BookDataMerger decides what is empty); a field the book has + * is never overwritten * overwrite every field the record carries replaces the book's; a field * the record does not carry is kept * @@ -45,11 +46,12 @@ final class MarcBookImporter 'lingua' => ['lingua'], 'edizione' => ['edizione'], 'pagine' => ['numero_pagine'], - 'collana' => ['collana'], + 'collana' => ['collana', 'numero_serie'], 'dewey' => ['classificazione_dewey'], 'keywords' => ['parole_chiave'], 'description' => ['descrizione'], 'publisher' => ['editore_id'], + 'genre' => ['genere_id'], 'contributors' => ['traduttore', 'illustratore', 'curatore'], ]; @@ -68,6 +70,7 @@ final class MarcBookImporter 'keywords' => ['parole_chiave'], 'description' => ['descrizione'], 'publisher' => ['editori'], + 'genre' => ['genere'], 'authors' => ['autori'], 'contributors' => ['traduttore', 'illustratore', 'curatore', 'colorista'], ]; @@ -141,15 +144,7 @@ public function import(array $data, array &$counters, array &$warnings = []): ar } $families = []; if ($existing !== null) { - $families = $this->families($data, $existing); - // A family writes several columns at once (ISBN-13 and ISBN-10, - // series and number, the legacy contributor columns): the ones - // the record leaves empty keep the book's value. - foreach (['isbn10', 'isbn13', 'numero_serie', 'traduttore', 'illustratore', 'curatore'] as $column) { - if (empty($parsed[$column]) && !empty($existing[$column])) { - $parsed[$column] = $existing[$column]; - } - } + [$families, $parsed] = self::plan($this->mode, $data, $parsed, $existing); } $result = $this->pipeline->importParsedRow( @@ -257,13 +252,24 @@ public function findExisting(array $parsed): ?int } /** - * Which CSV update families the record may write on an existing book. + * What an existing book gets from a record: the CSV update families the + * record may write, and the parsed row as the pipeline must receive it. * - * @param array> $data - * @param array $existing - * @return array + * A family writes several columns at once (ISBN-13 and ISBN-10, series + * and number, the legacy contributor columns). In every mode a column + * the record leaves empty keeps the book's value. In fill mode a family + * is written as soon as one of its columns is empty, and every column + * the book already has is handed back to the pipeline unchanged, so a + * non-empty field is never overwritten: a book with an ISBN-13 and no + * ISBN-10 gains the ISBN-10 and keeps its ISBN-13 even when the record + * names another. + * + * @param array> $data book data (MarcBookData shape) + * @param array $parsed the row as normalizeImportRow() returns it + * @param array $existing the libri row (with _principal_authors) + * @return array{0: array, 1: array} */ - private function families(array $data, array $existing): array + public static function plan(string $mode, array $data, array $parsed, array $existing): array { $carries = static function (string $family) use ($data): bool { foreach (self::FAMILY_DATA[$family] ?? [] as $key) { @@ -281,23 +287,37 @@ private function families(array $data, array $existing): array $families[$family] = false; continue; } - if ($this->mode === self::MODE_OVERWRITE) { + if ($mode === self::MODE_OVERWRITE) { $families[$family] = true; continue; } - // fill: only a family the book has nothing in + // fill: a family with at least one column the book has empty if ($family === 'authors') { $families[$family] = (int) ($existing['_principal_authors'] ?? 0) === 0; continue; } - $columns = self::FAMILY_COLUMNS[$family]; - $families[$family] = count(array_intersect_key(array_flip($columns), $empty)) === count($columns); + $families[$family] = array_intersect_key(array_flip(self::FAMILY_COLUMNS[$family]), $empty) !== []; } - // Never from a MARC record: the genre, the format and the price. - foreach (['genre', 'formato', 'prezzo'] as $family) { + // Never from a MARC record: the format and the price. + foreach (['formato', 'prezzo'] as $family) { $families[$family] = false; } - return $families; + foreach (self::FAMILY_COLUMNS as $family => $columns) { + foreach ($columns as $column) { + if ($column === 'editore_id' || $column === 'genere_id') { + continue; // the pipeline resolves the publisher and the genre by name + } + if (isset($empty[$column]) || !array_key_exists($column, $existing)) { + continue; + } + $value = $parsed[$column] ?? null; + $recordEmpty = $value === null || (is_string($value) && trim($value) === ''); + if ($recordEmpty || ($mode === self::MODE_FILL && ($families[$family] ?? false))) { + $parsed[$column] = $existing[$column]; + } + } + } + return [$families, $parsed]; } /** @return array|null */ diff --git a/tests/marc-import-robustness.unit.php b/tests/marc-import-robustness.unit.php index c2cde7b75..9a6cd26ce 100644 --- a/tests/marc-import-robustness.unit.php +++ b/tests/marc-import-robustness.unit.php @@ -8,7 +8,9 @@ * - a cover copied from this installation keeps the extension of its bytes * (WebP stays .webp, GIF stays .gif); * - a re-import full of duplicates lists at most MAX_LISTED_DUPLICATES of - * them in the session state and the import log. + * them in the session state and the import log; + * - in fill mode a family is written as soon as one of its columns is empty + * and no field the book has is overwritten. * * The database part touches only the book it creates (title prefixed ZZMR) * and removes it with the probe trigger. @@ -86,7 +88,36 @@ @unlink($file); } -// 3. Post-commit failures are warnings, not failed records. +// 3. Fill mode never overwrites a field the book has; a family is filled as +// soon as one of its columns is empty (pure: MarcBookImporter::plan()). +$existing = [ + 'id' => 7, 'isbn13' => '9788845292613', 'isbn10' => '', 'ean' => '', 'titolo' => 'Old title', 'sottotitolo' => '', + 'anno_pubblicazione' => null, 'lingua' => 'Italiano', 'edizione' => '', 'numero_pagine' => null, + 'collana' => 'Serie', 'numero_serie' => '', 'classificazione_dewey' => '', 'parole_chiave' => 'old', + 'descrizione' => '', 'editore_id' => 5, 'genere_id' => null, 'traduttore' => 'Old Translator', 'illustratore' => '', + 'curatore' => '', '_principal_authors' => 1, +]; +$data = \App\Support\Marc\MarcBookData::empty(); +$data = [ + 'titolo' => 'New title', 'isbn13' => '9780140449136', 'isbn10' => '0140449132', 'collana' => 'Altra', 'numero_serie' => '3', + 'traduttore' => ['New Translator'], 'illustratore' => ['Ill Ustrator'], 'autori' => ['Some Author'], 'editori' => ['Pub'], + 'genere' => 'Narrativa', 'anno_pubblicazione' => '2001', 'parole_chiave' => ['new'], +] + $data; +$csv = new \App\Controllers\CsvImportController(); +$parsed = $csv->normalizeImportRow(\App\Support\Marc\MarcBookData::toImportRow($data)); +[$fill, $row] = MarcBookImporter::plan(MarcBookImporter::MODE_FILL, $data, $parsed, $existing); +$check($fill['isbn'] === true && $row['isbn13'] === '9788845292613' && $row['isbn10'] === '0140449132', 'fill: the ISBN family is written for the empty ISBN-10, the book\'s ISBN-13 is kept'); +$check($fill['collana'] === true && $row['collana'] === 'Serie' && $row['numero_serie'] === '3', 'fill: the series number is filled, the series name kept'); +$check($fill['contributors'] === true && $row['traduttore'] === 'Old Translator' && $row['illustratore'] === 'Ill Ustrator', 'fill: the illustrator is filled, the translator kept'); +$check($fill['titolo'] === false && $fill['keywords'] === false && $fill['authors'] === false && $fill['publisher'] === false, 'fill: title, keywords, authors and publisher the book has are not touched'); +$check($fill['anno'] === true && $fill['genre'] === true && $fill['sottotitolo'] === false && $fill['formato'] === false, 'fill: an empty year and genre are filled; a family the record does not carry, and the format, are not'); +[$over, $row] = MarcBookImporter::plan(MarcBookImporter::MODE_OVERWRITE, $data, $parsed, $existing); +$check($over['isbn'] === true && $over['titolo'] === true && $row['isbn13'] === '9780140449136' && $row['titolo'] === 'New title' && $row['traduttore'] === 'New Translator', 'overwrite: the record\'s values replace the book\'s'); +$partial = ['isbn13' => '9780140449136', 'titolo' => 'New title'] + \App\Support\Marc\MarcBookData::empty(); +[$over, $row] = MarcBookImporter::plan(MarcBookImporter::MODE_OVERWRITE, $partial, $csv->normalizeImportRow(\App\Support\Marc\MarcBookData::toImportRow($partial)), ['isbn10' => '8845292614', 'traduttore' => 'Old Translator'] + $existing); +$check($row['isbn10'] === '8845292614' && $row['traduttore'] === 'Old Translator' && $over['contributors'] === false, 'overwrite: a column the record leaves empty keeps the book\'s value'); + +// 4. Post-commit failures are warnings, not failed records. $envFile = $root . '/.env'; if (!is_file($envFile)) { echo " SKIP database checks: no .env\n"; From dd1a4c40d7f2548e2f44c62cb1c5194ca10d2361 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 15:18:54 +0000 Subject: [PATCH 146/398] MARC and CSV imports tag the batch with a supplier CataloguingExtras::save() was reached only from the book form, so a batch bought from one supplier (#505's MARC import, the CSV import) had to be tagged book by book afterwards. Both import pages now carry a "Fornitore" select, validated once at upload and kept in the staging state like the duplicate mode. Each chunk writes it with CataloguingExtras::assignSupplier(), a guarded UPDATE that only ever links a supplier that still exists: on the books the import creates and, for MARC in "Sovrascrivi" mode, on the books it overwrites. Skipped duplicates and books completed in their empty fields keep their own; the CSV import, whose update path is per field family, tags new books only. A failure to tag is a warning on an imported record, never a failed row. The CSV page now receives the request's mysqli connection, so the Emeroteca check uses it instead of opening a second one. --- CHANGELOG.md | 2 +- app/Controllers/CsvImportController.php | 45 ++++++++++++------------ app/Controllers/MarcImportController.php | 28 ++++++++++++++- app/Routes/web.php | 10 +++--- app/Support/CataloguingExtras.php | 27 ++++++++++++-- app/Views/admin/csv_import.php | 16 +++++++++ app/Views/admin/marc_import.php | 16 +++++++++ locale/da_DK.json | 5 ++- locale/de_DE.json | 5 ++- locale/en_US.json | 5 ++- locale/fr_FR.json | 5 ++- locale/it_IT.json | 5 ++- tests/helpers/marc-e2e.js | 4 ++- tests/marc-bulk-import.spec.js | 35 ++++++++++++++++++ tests/minor-features-090.unit.php | 4 +++ 15 files changed, 175 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dd0b1a50..447da474c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Visitor log** (`/admin/attendance`, sidebar "Presenze"). A kiosk page records each visit: a member scans the library card (or the desk types the code), a guest is recorded with an optional name and reason, and an optional branch when the installation has more than one. The page shows today's visits, visits per day and per hour slot over 7/30/90/365 days, the latest visits with members' names shortened to "Name S.", and a CSV export of the daily counts. New table `presenze`. - **GDPR retention for the visitor log.** Visits are kept for 365 days by default; an administrator can change the number of days (0 keeps them forever) and apply it immediately. The scheduled maintenance (`cron/full-maintenance.php`) deletes older visits on every run. Deleting a member keeps the visit, anonymised. - **ISBD catalogue cards.** "Scheda catalografica" on the book page and "Schede catalografiche" on a selection in the book list produce a PDF of 125 × 75 mm cards, one per book: call number, main entry heading (principal author, surname first), ISBD description, standard number and subject tracings. Deleted books are never printed. -- **Suppliers** (`/admin/suppliers`, sidebar "Fornitori"). New table `fornitori` with name, contact, VAT number, website, address and notes; a supplier select in the book form ("Dettagli Acquisizione"), the supplier on the book page and a "Fornitore" filter in the book list. Deleting a supplier keeps its books and clears their supplier (`libri.fornitore_id`, ON DELETE SET NULL). An e-mail or website that is not one is refused by the supplier form with a message, never saved as empty. +- **Suppliers** (`/admin/suppliers`, sidebar "Fornitori"). New table `fornitori` with name, contact, VAT number, website, address and notes; a supplier select in the book form ("Dettagli Acquisizione"), the supplier on the book page and a "Fornitore" filter in the book list. Deleting a supplier keeps its books and clears their supplier (`libri.fornitore_id`, ON DELETE SET NULL). An e-mail or website that is not one is refused by the supplier form with a message, never saved as empty. The MARC and CSV import pages have a "Fornitore" select: the chosen supplier goes on the books the import creates and, for a MARC import in "Sovrascrivi" mode, on the books it overwrites; skipped duplicates and books only completed in their empty fields keep theirs. - **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME output carries the same effective types: books keep txt/n/nc as before; DVD, disc and audiobook records now emit their real RDA types (tdi/v/vd, prm/s/sd, spw/s/sd) instead of a book's; an override wins over the derived value. - **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. The outcomes of the last closed stocktake (expected, found, misplaced, on loan, missing, unknown codes) appear once a session has been closed. Loans by patron type and fines have their place on the page and appear as soon as those features provide data (`App\Support\StatisticsSections`). diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 23fdacedd..d21db43ce 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -131,31 +131,15 @@ private function safeUnlinkImportTmp(string $path): void * Whether the Emeroteca (periodicals) plugin is active, so the CSV * import page can link to its article importer only when that route * actually exists — otherwise it points to Plugins instead (#article - * link 404 fix). showImportPage() isn't routed with a mysqli connection - * (see web.php), so this opens its own short-lived one — same fallback - * pattern as ReservationsController's constructor — and queries the - * plugins table directly, mirroring frontend/layout.php's - * $publicPluginIsActive() fallback (no DI container available here). + * link 404 fix). Queries the plugins table directly, mirroring + * frontend/layout.php's $publicPluginIsActive() fallback (no DI + * container available here). */ - private function isEmerotecaPluginActive(): bool + private function isEmerotecaPluginActive(\mysqli $db): bool { try { - $settings = require __DIR__ . '/../../config/settings.php'; - $cfg = $settings['db']; - $db = new \mysqli( - $cfg['hostname'], - $cfg['username'], - $cfg['password'], - $cfg['database'], - $cfg['port'], - $cfg['socket'] ?? null - ); - if ($db->connect_error) { - return false; - } $stmt = $db->prepare('SELECT is_active FROM plugins WHERE name = ? LIMIT 1'); if ($stmt === false) { - $db->close(); return false; } $pluginName = 'emeroteca'; @@ -166,7 +150,6 @@ private function isEmerotecaPluginActive(): bool $active = $result instanceof \mysqli_result && (int) ($result->fetch_assoc()['is_active'] ?? 0) === 1; } $stmt->close(); - $db->close(); return $active; } catch (\Throwable $e) { return false; @@ -176,11 +159,12 @@ private function isEmerotecaPluginActive(): bool /** * Mostra la pagina di import CSV */ - public function showImportPage(Request $request, Response $response): Response + public function showImportPage(Request $request, Response $response, \mysqli $db): Response { ob_start(); $title = "Import Libri da CSV"; - $emerotecaAvailable = $this->isEmerotecaPluginActive(); + $emerotecaAvailable = $this->isEmerotecaPluginActive($db); + $fornitori = (new \App\Models\SupplierRepository($db))->options(); include __DIR__ . '/../Views/admin/csv_import.php'; $content = ob_get_clean(); @@ -319,6 +303,9 @@ public function processImport(Request $request, Response $response, \mysqli $db) 'total_rows' => $totalRows, 'enable_scraping' => !empty($data['enable_scraping']), 'update_fields' => $updateFields, + // The supplier chosen for the batch, validated once; written on + // the books this import creates (CataloguingExtras::assignSupplier). + 'fornitore_id' => \App\Support\CataloguingExtras::existingSupplierId($db, $data['fornitore_id'] ?? null), 'imported' => 0, 'updated' => 0, 'scraped' => 0, @@ -371,6 +358,7 @@ public function processChunk(Request $request, Response $response, \mysqli $db): $chunkSize = max(1, min($chunkSize, self::CHUNK_SIZE)); // Capped at CHUNK_SIZE $enableScraping = (bool) ($importData['enable_scraping'] ?? false); + $supplierId = (int) ($importData['fornitore_id'] ?? 0); // #380: field families the user allowed this import to update on books // that already exist. A family absent from the session (pre-#380 upload @@ -453,6 +441,17 @@ public function processChunk(Request $request, Response $response, \mysqli $db): $bookId = $upsertResult['id']; $action = $upsertResult['action']; + // The batch's supplier goes on new books only: an existing + // book keeps its own. The row is already committed and + // counted, so a failure here is logged, not a failed row. + if ($supplierId > 0 && $action === 'created') { + try { + \App\Support\CataloguingExtras::assignSupplier($db, (int) $bookId, $supplierId); + } catch (\Throwable $supplierError) { + \App\Support\SecureLogger::warning('[CSV] supplier not assigned', ['line' => $lineNumber, 'book_id' => (int) $bookId, 'message' => $supplierError->getMessage()]); + } + } + // Scraping (if enabled and ISBN exists) if ($enableScraping && !empty($parsedData['isbn13'])) { try { diff --git a/app/Controllers/MarcImportController.php b/app/Controllers/MarcImportController.php index 8edf02abb..de34ba10f 100644 --- a/app/Controllers/MarcImportController.php +++ b/app/Controllers/MarcImportController.php @@ -3,6 +3,8 @@ namespace App\Controllers; +use App\Models\SupplierRepository; +use App\Support\CataloguingExtras; use App\Support\Marc\Iso2709Reader; use App\Support\Marc\MarcBookImporter; use App\Support\Marc\MarcFormat; @@ -38,10 +40,11 @@ class MarcImportController public const MAX_LISTED_DUPLICATES = 100; private const EXTENSIONS = ['mrc', 'marc', 'iso', 'dat', 'xml', 'marcxml']; - public function showImportPage(Request $request, Response $response): Response + public function showImportPage(Request $request, Response $response, \mysqli $db): Response { ob_start(); $title = __('Import MARC'); + $fornitori = (new SupplierRepository($db))->options(); include __DIR__ . '/../Views/admin/marc_import.php'; $content = (string) ob_get_clean(); $response->getBody()->write($content); @@ -144,6 +147,9 @@ public function upload(Request $request, Response $response, \mysqli $db): Respo 'total_rows' => $total, 'mode' => $mode, 'covers' => !empty($data['download_covers']), + // Validated once here; a supplier deleted during the import is + // simply not assigned (CataloguingExtras::assignSupplier). + 'fornitore_id' => CataloguingExtras::existingSupplierId($db, $data['fornitore_id'] ?? null), 'offset' => 0, 'processed' => 0, 'counters' => [], @@ -186,6 +192,7 @@ public function chunk(Request $request, Response $response, \mysqli $db): Respon fseek($handle, (int) $state['offset']); $importer = new MarcBookImporter($db, (string) $state['mode'], (bool) $state['covers']); $counters = (array) $state['counters']; + $supplierId = (int) ($state['fornitore_id'] ?? 0); for ($i = 0; $i < $size; $i++) { $raw = fgets($handle); if ($raw === false) { @@ -215,6 +222,14 @@ public function chunk(Request $request, Response $response, \mysqli $db): Respon if ($result['action'] === 'skipped') { self::listSkippedDuplicate($state, $position, $title, (int) $result['id']); } + if ($supplierId > 0 && $result['id'] !== null && self::supplierApplies((string) $result['action'], (string) $state['mode'])) { + try { + CataloguingExtras::assignSupplier($db, (int) $result['id'], $supplierId); + } catch (\Throwable $e) { + $warnings[] = __('Fornitore non assegnato') . ': ' . $e->getMessage(); + \App\Support\SecureLogger::warning('[MARC import] supplier not assigned', ['libro_id' => (int) $result['id'], 'error' => $e->getMessage()]); + } + } foreach ($warnings as $warning) { $state['errors'][] = ['line' => $position, 'title' => $title, 'message' => $warning, 'type' => 'cover']; } @@ -268,6 +283,17 @@ public function chunk(Request $request, Response $response, \mysqli $db): Respon ]); } + /** + * Whether the supplier chosen for the batch is written on a record's + * book: a book the import created, or one it overwrote. A skipped + * duplicate and a book only completed in its empty fields ("fill") keep + * the supplier they have. + */ + public static function supplierApplies(string $action, string $mode): bool + { + return $action === 'created' || ($action === 'updated' && $mode === MarcBookImporter::MODE_OVERWRITE); + } + /** * Note a skipped duplicate in the run's report, up to MAX_LISTED_DUPLICATES. * diff --git a/app/Routes/web.php b/app/Routes/web.php index 65b12fff7..e9fb2fc4f 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -1451,9 +1451,10 @@ })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); // 30 requests per minute for bulk operations // CSV Import routes - $app->get('/admin/books/import', function ($request, $response) { + $app->get('/admin/books/import', function ($request, $response) use ($app) { $controller = new \App\Controllers\CsvImportController(); - return $controller->showImportPage($request, $response); + $db = $app->getContainer()->get('db'); + return $controller->showImportPage($request, $response, $db); })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); // 30 requests per minute $app->get('/admin/books/import/example', function ($request, $response) { @@ -1494,9 +1495,10 @@ // MARC bulk import (ISO 2709 / MARCXML; MARC 21, UNIMARC, danMARC2) on // the CSV import infrastructure: upload + preview, then chunks. No // RateLimitMiddleware on /chunk for the same reason as the CSV chunk (#113). - $app->get('/admin/books/import/marc', function ($request, $response) { + $app->get('/admin/books/import/marc', function ($request, $response) use ($app) { $controller = new \App\Controllers\MarcImportController(); - return $controller->showImportPage($request, $response); + $db = $app->getContainer()->get('db'); + return $controller->showImportPage($request, $response, $db); })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->post('/admin/books/import/marc/upload', function ($request, $response) use ($app) { diff --git a/app/Support/CataloguingExtras.php b/app/Support/CataloguingExtras.php index f373e8a4d..671f3ca97 100644 --- a/app/Support/CataloguingExtras.php +++ b/app/Support/CataloguingExtras.php @@ -13,7 +13,8 @@ * They are written after the core save, and only for the keys the request * actually carries: the book form always posts them, while CSV/LibraryThing * imports, the mobile API and plugins do not, so those paths leave the stored - * values untouched. + * values untouched. The MARC and CSV imports tag a whole batch with one + * supplier through assignSupplier() instead. */ final class CataloguingExtras { @@ -75,8 +76,30 @@ public static function save(mysqli $db, int $bookId, array $data): void } } + /** + * Link a book to a supplier chosen for an import batch. Nothing is + * written when the supplier was deleted meanwhile or the column does not + * exist yet: the import must never clear a supplier on its own. + */ + public static function assignSupplier(mysqli $db, int $bookId, int $supplierId): void + { + if (!self::hasColumn($db, 'fornitore_id') || self::existingSupplierId($db, $supplierId) === null) { + return; + } + $stmt = $db->prepare('UPDATE libri SET fornitore_id = ? WHERE id = ? AND deleted_at IS NULL'); + if ($stmt === false) { + throw new \RuntimeException('Unable to prepare the supplier update'); + } + try { + $stmt->bind_param('ii', $supplierId, $bookId); + $stmt->execute(); + } finally { + $stmt->close(); + } + } + /** A submitted supplier id that exists, or NULL ("no supplier"). */ - private static function existingSupplierId(mysqli $db, mixed $raw): ?int + public static function existingSupplierId(mysqli $db, mixed $raw): ?int { $id = filter_var($raw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); if ($id === false) { diff --git a/app/Views/admin/csv_import.php b/app/Views/admin/csv_import.php index ec31b3893..0846d63af 100644 --- a/app/Views/admin/csv_import.php +++ b/app/Views/admin/csv_import.php @@ -170,6 +170,22 @@ + +
      + + +

      +
      + +
      diff --git a/app/Views/admin/marc_import.php b/app/Views/admin/marc_import.php index 865e6dd3e..2309aea4e 100644 --- a/app/Views/admin/marc_import.php +++ b/app/Views/admin/marc_import.php @@ -147,6 +147,22 @@
      + +
      + + +

      +
      + +
      - + @@ -83,7 +82,7 @@
      -
      +
      maxActiveLoans > 0 ? (string) $cEff->maxActiveLoans : '∞', $cEff->durationDays, $cEff->maxRenewals) ?> @@ -140,14 +139,14 @@
      - + [__('Max prestiti'), 'max_prestiti', $cInh['max'] > 0 ? (string) $cInh['max'] : '∞'], 'duration' => [__('Giorni'), 'durata_prestito_giorni', (string) $cInh['duration']], 'renewals' => [__('Rinnovi'), 'max_rinnovi', (string) $cInh['renewals']], 'renewal_days' => [__('Gg/rinnovo'), 'giorni_rinnovo', (string) $cInh['renewal_days']]] as $cField => [$cLabel, $cColumn, $cPlaceholder]): ?> diff --git a/app/Views/settings/circulation-type-edit.php b/app/Views/settings/circulation-type-edit.php index 359bc092d..f04f80bdf 100644 --- a/app/Views/settings/circulation-type-edit.php +++ b/app/Views/settings/circulation-type-edit.php @@ -4,7 +4,6 @@ * @var array $globals */ use App\Support\Csrf; -use App\Support\HtmlHelper; $csrfToken = Csrf::ensureToken(); $typeForm = $type; @@ -17,7 +16,7 @@ -

      +

      diff --git a/app/Views/utenti/dettagli_utente.php b/app/Views/utenti/dettagli_utente.php index 5ba066830..eb4390d9d 100644 --- a/app/Views/utenti/dettagli_utente.php +++ b/app/Views/utenti/dettagli_utente.php @@ -289,7 +289,7 @@
      - + ()
      diff --git a/app/Views/utenti/index.php b/app/Views/utenti/index.php index 6bf4fc4f7..83b69f4e9 100644 --- a/app/Views/utenti/index.php +++ b/app/Views/utenti/index.php @@ -241,7 +241,7 @@ class="btn-icon btn-secondary p-2 text-gray-500 hover:text-gray-700 hover:bg-gra diff --git a/app/Views/utenti/tipologia-lettore.php b/app/Views/utenti/tipologia-lettore.php index 1d5bc5da1..d04f7abeb 100644 --- a/app/Views/utenti/tipologia-lettore.php +++ b/app/Views/utenti/tipologia-lettore.php @@ -6,7 +6,6 @@ * @var int|null $patronTypeId current type (null = default) * @var int|null $defaultPatronTypeId */ -use App\Support\HtmlHelper; $ptDefaultName = $defaultPatronTypeId !== null && isset($patronTypes[$defaultPatronTypeId]) ? (string) $patronTypes[$defaultPatronTypeId]['nome'] @@ -16,9 +15,9 @@

      From dbc3870e41bdbf30a90a71f7cc059947dc9d0633 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:56:34 +0100 Subject: [PATCH 164/398] Create-user form: the suggested 5-year expiry no longer blocks the patron type's card duration The form pre-fills a 5-year date, so after the typed-date-wins change every new patron kept 5 years even with a type card duration. The suggestion is now flagged until the librarian edits the field; a typed date still wins, and the suggestion stays when the type sets no duration. --- app/Controllers/UsersController.php | 9 +++++++-- app/Views/utenti/crea_utente.php | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index e63289dc8..c94503757 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -181,8 +181,13 @@ public function store(Request $request, Response $response, mysqli $db): Respons // librarian typed wins. (The edit form opts in explicitly and // recomputes, see update().) $patronTypeId = \App\Support\PatronTypeAssignment::fromSubmission($db, $data); - if (!$isAdmin && in_array($role, ['standard', 'premium'], true) && !empty($data['card_auto']) && $dataScadenzaInput === '') { - $dataScadenzaTessera = \App\Support\PatronTypeAssignment::automaticCardExpiry($db, $patronTypeId); + // The form pre-fills a 5-year date as a suggestion and flags it + // (card_expiry_prefilled=1) until the librarian edits the field: that + // date is not "typed", the type's card duration replaces it, and it + // stays when the type has no card duration. + $expiryTyped = $dataScadenzaInput !== '' && (string) ($data['card_expiry_prefilled'] ?? '0') !== '1'; + if (!$isAdmin && in_array($role, ['standard', 'premium'], true) && !empty($data['card_auto']) && !$expiryTyped) { + $dataScadenzaTessera = \App\Support\PatronTypeAssignment::automaticCardExpiry($db, $patronTypeId) ?? $dataScadenzaTessera; } $stato = (string) ($data['stato'] ?? ($isAdmin ? 'attivo' : 'attivo')); diff --git a/app/Views/utenti/crea_utente.php b/app/Views/utenti/crea_utente.php index 5a60fd032..b5236ee5b 100644 --- a/app/Views/utenti/crea_utente.php +++ b/app/Views/utenti/crea_utente.php @@ -218,6 +218,20 @@ const day = String(fiveYearsLater.getDate()).padStart(2, '0'); dataScadenzaTesseraField.value = `${year}-${month}-${day}`; dataScadenzaTesseraField.dataset.originalValue = dataScadenzaTesseraField.value; + // The 5-year date is a suggestion, not a date the librarian typed: tell + // the server, so "Calcola la scadenza dalla durata tessera" still applies + // the patron type's card duration. Any edit of the field clears the flag. + const userForm = document.getElementById('user-form'); + if (userForm) { + const prefilled = document.createElement('input'); + prefilled.type = 'hidden'; + prefilled.name = 'card_expiry_prefilled'; + prefilled.value = '1'; + userForm.appendChild(prefilled); + const typed = () => { prefilled.value = '0'; }; + dataScadenzaTesseraField.addEventListener('input', typed); + dataScadenzaTesseraField.addEventListener('change', typed); + } } const storeOriginalState = (container) => { From b73ecf467a7170a36d410a30edd89680afd000fb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:01:18 +0000 Subject: [PATCH 165/398] Stocktake: add inventario_attese.esito_chiusura for the frozen report The report of a closed session is computed from the live state of copies and loans, so it changes after the close: an unscanned lent copy moves from "in prestito" to "mancante" once the loan is returned. The bucket each expected copy ended in must be recorded at close; this adds the column (NULL while the session is open) in the fresh schema and, guarded with the usual INFORMATION_SCHEMA + PREPARE pattern, in the 0.9.0 stocktake section for tables an earlier build of the branch created. The migration test drops the column between runs to cover that path. --- installer/database/migrations/migrate_0.9.0.sql | 11 +++++++++++ installer/database/schema.sql | 1 + tests/migration-0.9.0-stocktake.unit.php | 12 +++++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/installer/database/migrations/migrate_0.9.0.sql b/installer/database/migrations/migrate_0.9.0.sql index 027391740..f0e51268a 100644 --- a/installer/database/migrations/migrate_0.9.0.sql +++ b/installer/database/migrations/migrate_0.9.0.sql @@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS `inventario_attese` ( `copia_id` int NOT NULL, `scaffale_atteso` int DEFAULT NULL, `stato_all_apertura` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `esito_chiusura` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, PRIMARY KEY (`sessione_id`,`copia_id`), KEY `idx_inventario_attese_copia` (`copia_id`), CONSTRAINT `fk_inventario_attese_sessione` FOREIGN KEY (`sessione_id`) REFERENCES `inventario_sessioni` (`id`) ON DELETE CASCADE @@ -53,6 +54,16 @@ CREATE TABLE IF NOT EXISTS `inventario_scansioni` ( CONSTRAINT `fk_inventario_scansioni_utente` FOREIGN KEY (`scansionato_da`) REFERENCES `utenti` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- inventario_attese.esito_chiusura freezes each expected copy's bucket when the session +-- is closed (trovata, fuori_posto, in_prestito, mancante, persa), so the report of a +-- closed session no longer follows later loans and returns. Added here for tables +-- created by an earlier build of 0.9.0; a fresh CREATE above already has it. +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'inventario_attese' AND COLUMN_NAME = 'esito_chiusura'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE inventario_attese ADD COLUMN esito_chiusura varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL AFTER stato_all_apertura', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + -- A starting profile for volunteers who only take part in stocktakes. INSERT IGNORE INTO ruoli (nome, permessi) VALUES ('Revisore inventario', '["backend.access","inventory.manage"]'); diff --git a/installer/database/schema.sql b/installer/database/schema.sql index f0bee3e32..28cedb842 100755 --- a/installer/database/schema.sql +++ b/installer/database/schema.sql @@ -1498,6 +1498,7 @@ CREATE TABLE `inventario_attese` ( `copia_id` int NOT NULL, `scaffale_atteso` int DEFAULT NULL, `stato_all_apertura` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `esito_chiusura` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, PRIMARY KEY (`sessione_id`,`copia_id`), KEY `idx_inventario_attese_copia` (`copia_id`), CONSTRAINT `fk_inventario_attese_sessione` FOREIGN KEY (`sessione_id`) REFERENCES `inventario_sessioni` (`id`) ON DELETE CASCADE diff --git a/tests/migration-0.9.0-stocktake.unit.php b/tests/migration-0.9.0-stocktake.unit.php index 6d7e084e8..a6c2fae9e 100644 --- a/tests/migration-0.9.0-stocktake.unit.php +++ b/tests/migration-0.9.0-stocktake.unit.php @@ -70,7 +70,8 @@ $columns = $one("SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$sessions}'"); $check($columns === 'id,nome,sede_id,filtro,stato,totale_attese,aperta_da,aperta_il,chiusa_da,chiusa_il,note', 'inventario_sessioni has the planned columns'); $check($one("SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$sessions}' AND COLUMN_NAME = 'stato'") === "enum('aperta','chiusa','annullata')", 'the session state is aperta/chiusa/annullata'); - $check($one("SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$expected}'") === 'sessione_id,copia_id,scaffale_atteso,stato_all_apertura', 'inventario_attese is the snapshot the plan describes'); + $check($one("SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$expected}'") === 'sessione_id,copia_id,scaffale_atteso,stato_all_apertura,esito_chiusura', 'inventario_attese is the snapshot the plan describes, with the bucket frozen at close'); + $check($one("SELECT CONCAT(COLUMN_TYPE, ':', IS_NULLABLE) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$expected}' AND COLUMN_NAME = 'esito_chiusura'") === 'varchar(20):YES', 'esito_chiusura is a nullable varchar(20): NULL while the session is open'); $check($one("SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$scans}' AND COLUMN_NAME = 'esito'") === "enum('trovata','fuori_posto','in_prestito','sconosciuta','duplicata','fuori_ambito')", 'the scan outcomes are the planned six'); $check($one("SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$scans}' AND NON_UNIQUE = 0 AND INDEX_NAME <> 'PRIMARY'") === 'sessione_id,copia_id', 'scans are UNIQUE on (sessione_id, copia_id)'); $check((int) $one("SELECT COUNT(*) FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = '{$expected}' AND REFERENCED_TABLE_NAME <> '{$sessions}'") === 0, 'the snapshot has no foreign key to copie: deleting a copy cannot rewrite it'); @@ -90,8 +91,16 @@ $check($e->getCode() === 1062, 'a second row for the same copy is rejected'); } + // A table created by an earlier build of 0.9.0 has no esito_chiusura: the + // guarded ADD COLUMN must add it without touching the rows. + $db->query("UPDATE {$expected} SET esito_chiusura = 'trovata' WHERE sessione_id = {$sid} AND copia_id = 10"); + $db->query("ALTER TABLE {$expected} DROP COLUMN esito_chiusura"); $runMigration(); $check((int) $one("SELECT COUNT(*) FROM {$expected} WHERE sessione_id = {$sid}") === 2 && (int) $one("SELECT COUNT(*) FROM {$scans} WHERE sessione_id = {$sid}") === 3, 'a second run is a no-op that keeps sessions, snapshots and scans'); + $check($one("SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$expected}'") === 'sessione_id,copia_id,scaffale_atteso,stato_all_apertura,esito_chiusura', 'the second run adds esito_chiusura to a snapshot table that lacks it'); + $check((int) $one("SELECT COUNT(*) FROM {$expected} WHERE sessione_id = {$sid} AND esito_chiusura IS NULL") === 2, 'the added column is NULL for existing snapshot rows'); + $runMigration(); + $check((int) $one("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$expected}' AND COLUMN_NAME = 'esito_chiusura'") === 1, 'a third run leaves the column alone'); $check((int) $one("SELECT COUNT(*) FROM {$p}ruoli WHERE nome = 'Revisore inventario'") === 1, 'the preset is not duplicated'); $db->query("DELETE FROM {$p}utenti WHERE id = 1"); @@ -103,6 +112,7 @@ foreach (['inventario_sessioni', 'inventario_attese', 'inventario_scansioni'] as $table) { $check(str_contains($schema, "CREATE TABLE `{$table}`"), "fresh install creates {$table}"); } + $check(str_contains($schema, "`esito_chiusura` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,"), 'fresh install creates inventario_attese.esito_chiusura'); $check(str_contains($schema, "('Revisore inventario', '[\"backend.access\",\"inventory.manage\"]')"), 'fresh install seeds the stocktake preset'); $version = json_decode((string) file_get_contents(dirname(__DIR__) . '/version.json'), true)['version']; $check(version_compare('0.9.0', (string) $version, '<='), 'the release includes migrate_0.9.0.sql'); From b3b1eb319cfdfdd5241f151cc9fe7c325d0e57e3 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:02:24 +0000 Subject: [PATCH 166/398] User import picks the CSV separator by column structure, not by count detectDelimiter() summed the raw occurrences of ";", "," and TAB over the first lines, so a ";" file whose address cells hold several commas ("Via Roma 1, int. 3, 00100 Roma") had more commas than semicolons, was read with ",", and its header came back as a single column: the mapping step had nothing to map and the import was unusable. Each candidate is now judged by what it yields: the header must split into more than one column, and the candidate whose column count is shared by the largest share of the data lines wins, the wider one on a tie. Lines are parsed as the reader parses them (enclosure '"', no escape character), so a separator inside a quoted field never counts. A header-only file still gets its delimiter from the header; a file no candidate splits is still refused with the same message. Unit cases cover the comma-rich ";" file, a "," file with quoted semicolons, a TAB file, a one-column file and a header-only file; a Playwright case uploads the ";" file and checks that the preview has every column with the address in one cell. --- CHANGELOG.md | 2 +- app/Support/UserImport.php | 45 ++++++++++++++++++++++++------ tests/patron-cards-import.unit.php | 5 ++++ tests/user-import.spec.js | 21 ++++++++++++++ 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3abd9e13..21192abc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets. - **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card. - **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo, also when the account has a loan history and is therefore only suspended. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. -- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. +- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. The separator (";", "," or TAB) is the one that splits the first lines into the same number of columns as the header, quoted fields respected, so a ";" file whose addresses hold commas is no longer read as a one-column "," file. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. ### Migration diff --git a/app/Support/UserImport.php b/app/Support/UserImport.php index cdd7a4081..70e9e1d03 100644 --- a/app/Support/UserImport.php +++ b/app/Support/UserImport.php @@ -359,25 +359,52 @@ public static function toUtf8(string $bytes): array } /** - * Delimiter of a CSV sample: the most frequent of ";", "," and TAB, or null - * when none occurs. + * Delimiter of a CSV sample (the header and the first few lines): the one + * of ";", "," and TAB that splits the lines into columns most consistently, + * or null when none splits the header at all. + * + * Each candidate is judged by the structure it yields, not by how often it + * occurs: a ";" file whose addresses hold commas ("Via Roma 1, int. 3, + * 00100 Roma") has more commas than semicolons, yet only ";" gives every + * line the header's column count. Quoted fields are respected as the + * reader respects them (enclosure '"', no escape character), so a comma + * inside "…" never counts. The candidate whose column count is shared by + * the largest share of the data lines wins; the wider one on a tie. * * @param list $lines */ public static function detectDelimiter(array $lines): ?string { - $candidates = [';' => 0, ',' => 0, "\t" => 0]; + $sample = []; foreach ($lines as $line) { - if (!is_string($line)) { + if (is_string($line) && trim($line) !== '') { + $sample[] = rtrim($line, "\r\n"); + } + } + if ($sample === []) { + return null; + } + $header = array_shift($sample); + $best = null; + $bestScore = [0.0, 1]; + foreach ([';', ',', "\t"] as $delimiter) { + $columns = count(str_getcsv($header, $delimiter, '"', '')); + if ($columns < 2) { continue; } - foreach (array_keys($candidates) as $delimiter) { - $candidates[$delimiter] += substr_count($line, $delimiter); + $matching = 0; + foreach ($sample as $line) { + if (count(str_getcsv($line, $delimiter, '"', '')) === $columns) { + $matching++; + } + } + $score = [$sample === [] ? 1.0 : $matching / count($sample), $columns]; + if ($score > $bestScore) { + $best = $delimiter; + $bestScore = $score; } } - arsort($candidates); - $best = (string) array_key_first($candidates); - return $candidates[$best] > 0 ? $best : null; + return $best; } /** A CSV cell as trimmed text, with the export's formula-escape apostrophe undone. */ diff --git a/tests/patron-cards-import.unit.php b/tests/patron-cards-import.unit.php index 192525a83..c361124bb 100644 --- a/tests/patron-cards-import.unit.php +++ b/tests/patron-cards-import.unit.php @@ -78,6 +78,11 @@ $check(UserImport::normalizeRow(['X', 'Y', 'x@example.org', '', 'M'], $map, $defs, $locales, ['sesso' => false, 'cod_fiscale' => false])['record']['sesso'] === null, 'a hidden profile field is not collected'); $check(UserImport::parseDate('2026-12-31') === '2026-12-31' && UserImport::parseDate('31.12.2026') === '2026-12-31' && UserImport::parseDate('12/31/2026') === null, 'date spellings: ISO, dotted, day-first only'); $check(UserImport::detectDelimiter(["a;b;c\n", "1;2;3\n"]) === ';' && UserImport::detectDelimiter(["a,b\n"]) === ',' && UserImport::detectDelimiter(["abc\n"]) === null, 'delimiter detection'); +$check(UserImport::detectDelimiter(["Nome;Cognome;Indirizzo\n", "Mario;Rossi;Via Roma 1, int. 3, scala B, piano 2, 00100 Roma\n", "Anna;Bianchi;Piazza Dante 5, int. 1, scala A, 20100 Milano, MI\n"]) === ';', 'a ";" file whose addresses hold more commas than there are separators is still a ";" file'); +$check(UserImport::detectDelimiter(["Nome,Cognome,Note\n", "\"Rossi, Mario\",x,\"a;b;c\"\n", "\"Bianchi; Anna\",y,\"d;e\"\n"]) === ',', 'a "," file with quoted fields full of semicolons is a "," file: a separator inside quotes never counts'); +$check(UserImport::detectDelimiter(["Nome\tCognome\tNote\n", "a\tb\tc, d; e\n"]) === "\t", 'a TAB file is a TAB file whatever its cells hold'); +$check(UserImport::detectDelimiter(["Nome\n", "Mario\n", "Anna\n"]) === null && UserImport::detectDelimiter([]) === null && UserImport::detectDelimiter([false]) === null, 'one column, or nothing, is no delimiter'); +$check(UserImport::detectDelimiter(["Nome;Cognome;E-mail\n"]) === ';' && UserImport::detectDelimiter(["Nome;Cognome;E-mail\n", ""]) === ';', 'a header alone tells the delimiter when it has more than one column'); // ── UserImport: file encoding ─────────────────────────────────────────── $u = UserImport::toUtf8("\xEF\xBB\xBFNome;Cognome\nCaff\xC3\xA8;Rossi\n"); diff --git a/tests/user-import.spec.js b/tests/user-import.spec.js index bdfc1bb05..648b748e7 100644 --- a/tests/user-import.spec.js +++ b/tests/user-import.spec.js @@ -328,6 +328,27 @@ test.describe.serial('User import from CSV (phase 7)', () => { } }); + test('a ";" CSV whose addresses hold more commas than semicolons is still read with ";": the preview has every column and the address is one cell', async () => { + const file = path.join(os.tmpdir(), `utenti-indirizzi-${RUN}.csv`); + // Three ";" per line against five commas per address: counting characters would pick ",". + fs.writeFileSync(file, [['Nome', 'Cognome', 'E-mail', 'Indirizzo'].join(';'), + ['Viale', 'Uno', `viale1.${RUN}@${DOMAIN}`, 'Via Roma 1, int. 3, scala B, piano 2, 00100 Roma, RM'].join(';'), + ['Viale', 'Due', `viale2.${RUN}@${DOMAIN}`, 'Piazza Dante 5, int. 1, scala A, piano 1, 20100 Milano, MI'].join(';')].join('\n') + '\n'); + try { + await page.goto(`${BASE}/admin/users/import`); + await page.locator('#user_import_file').setInputFiles(file); + await page.locator('#user-import-upload-btn').click(); + await expect(page.locator('#user-import-step-mapping')).toBeVisible(); + await expect(page.locator('#user-import-row-count')).toHaveText('2 righe da importare'); + await expect(page.locator('#user-import-preview thead tr').first().locator('th')).toHaveCount(4); + await expect(page.locator('select[data-import-column="3"]')).toHaveValue('indirizzo'); + await expect(page.locator('#user-import-preview tbody tr').first()).toContainText('Via Roma 1, int. 3, scala B, piano 2, 00100 Roma, RM'); + await page.locator('#user-import-cancel').click(); + } finally { + try { fs.unlinkSync(file); } catch { /* gone */ } + } + }); + test('a failure after the account is created is a warning on a created row, not a failed row', async () => { test.setTimeout(120_000); const trigger = `zz_ui_probe_${RUN.replace(/[^a-z0-9]/gi, '')}`; From 8ab67dcc1ef99d7dd32629e685d818a0c24eac4e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:02:38 +0000 Subject: [PATCH 167/398] NCIP CheckOutItem moves the due date with the chosen copy's branch handleCheckOutItem() computed the due date for the library as a whole and createLoanAtomic() stored it unchanged on whichever copy it picked, so a closure of that copy's branch left the loan due on a closed day, unlike a desk loan of the same copy. The copy is chosen only inside createLoanAtomic(), under the book lock: it now recomputes the date through CirculationPolicy::dueDateFor() with the copy, and when the date moves it checks the longer window again (title capacity and the copy's own commitments) so the shift cannot collide, refusing with the usual no_capacity / no_copy reasons as the desk does. $dueDate is passed by reference so the response's DateDue is the stored date. RequestItem binds no copy (approval applies the branch) and RenewItem already passes the loan's copy, so neither changes. The DB-backed calendar test drives createLoanAtomic() with a branch closure on the plain due date and with a commitment from the shifted day. --- CHANGELOG.md | 2 +- .../plugins/ncip-server/NcipServerPlugin.php | 43 ++++++++++++- tests/library-calendar-loans.unit.php | 60 ++++++++++++++++++- tests/ncip-circulation-coherence.unit.php | 2 +- 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 495a941f1..d7e926dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre ### Closures calendar - **The library's closed days.** A new admin page, Admin → Calendario chiusure (`/admin/calendar`, also linked from Settings → Loans), holds the weekly closed days (`loans.closed_weekdays`) and the closures: single days or periods, once or every year, for every branch or for one. The Italian national holidays come with one click (fixed holidays as yearly closures, Easter and Easter Monday computed for the chosen year), any other calendar can be imported from an ICS file, and a monthly preview shows which days are open. -- **Due dates land on an open day.** The shift lives in `CirculationPolicy::dueDateFor()`, so the per-patron-type durations and renewal days of the circulation policy land on an open day too. A due date that falls on a closed day moves to the next open day: admin loans (proposed and saved), loan edits, reader requests from the website and the app, reservations and their promotion to a loan, renewals and bulk extensions, NCIP CheckOut, RequestItem and RenewItem. A closure of one branch counts for the loans of its copies: when the staff desk lends a copy, or a request is approved onto one, the due date moves to that branch's next open day (at approval only if the longer window still fits). A refused renewal proposes the last open day before the conflict. +- **Due dates land on an open day.** The shift lives in `CirculationPolicy::dueDateFor()`, so the per-patron-type durations and renewal days of the circulation policy land on an open day too. A due date that falls on a closed day moves to the next open day: admin loans (proposed and saved), loan edits, reader requests from the website and the app, reservations and their promotion to a loan, renewals and bulk extensions, NCIP CheckOut, RequestItem and RenewItem. A closure of one branch counts for the loans of its copies: when the staff desk lends a copy, an NCIP CheckOutItem binds one, or a request is approved onto one, the due date moves to that branch's next open day (at approval only if the longer window still fits; the NCIP checkout is refused, like the desk's, when the longer window no longer fits). A refused renewal proposes the last open day before the conflict. - **Pickup days are open days.** The pickup deadline counts only the days the library is open, and the nightly sweep expires no pickup, no scheduled loan and no queue reservation on a closed day; a deadline that falls on a day closed afterwards moves to the next open day when the calendar is saved, so the desk can still hand the book over that day. - **The request calendars know the closed days.** On the book page and in the admin loan form a closed day cannot be picked as the start (a request that reaches the server with one starts on the next open day); the availability payload carries `closed_dates`, on the website and in the Mobile API `GET /catalog/books/{id}/availability` (additive field). A request auto-approved on a closed day is reported by `POST /reservations` with its real state, `prenotato` (new enum value) with the start day in `start_date` (additive), instead of `da_ritirare`. - **ICS imports meet the form's limits.** Every event is checked like a closure typed in the form (a yearly closure within a year, a one-off within ten years, end not before start); the import message counts the events discarded. A yearly closure of a year or more, however stored, is applied once as the interval it names and never closes every day; an ICS date-time in UTC is read in the application timezone. The calendar page follows the settings permission (`settings.manage`), so a custom role with that permission can use it. diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index c93e64464..4096c4bd8 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -889,6 +889,8 @@ private function handleCheckOutItem( } // Atomic checkout: lock the book row so concurrent requests serialize. + // The due date is the library-wide one; createLoanAtomic() moves it + // with the closures of the chosen copy's branch and hands it back. $today = \App\Support\DateHelper::today(); $loanDays = (new \App\Services\CirculationPolicy($this->db))->forLoan($userId, $itemId)->durationDays; $dueDate = \App\Services\CirculationPolicy::dueDateFor($today, $loanDays, $this->db); @@ -1858,9 +1860,13 @@ private function findActiveLoan( * terminal — 'not_found' | 'duplicate' | 'ineligible' | 'max_loans' | * 'no_capacity' | 'no_copy'; transient/retryable — 'invalid_due' | 'db_error'. * + * $dueDate is the due date computed for the library as a whole; the copy + * is chosen only here, so a closure of its branch can move it (same rule + * as the staff desk). On success it holds the date stored on the loan. + * * @param-out string $failureReason */ - private function createLoanAtomic(int $bookId, int $userId, string $dueDate, int $processedBy, ?string &$failureReason = null): ?int + private function createLoanAtomic(int $bookId, int $userId, string &$dueDate, int $processedBy, ?string &$failureReason = null): ?int { $failureReason = 'db_error'; $this->db->begin_transaction(); @@ -1998,6 +2004,41 @@ private function createLoanAtomic(int $bookId, int $userId, string $dueDate, int } $copyId = (int) $copyRow['id']; + // The copy is known only now: a due date closed at its branch moves + // to the branch's next open day, as PrestitiController::store() does + // for a desk loan, and the longer window is checked again, for the + // title's capacity and for the copy's own commitments (the copy was + // selected free up to the shorter date only). Both locks are held. + $branchDue = \App\Services\CirculationPolicy::dueDateFor($dueDate, 0, $this->db, $copyId); + if ($branchDue !== $dueDate) { + if (!$capacity->hasFreeCapacity($bookId, $today, $branchDue)) { + $failureReason = 'no_capacity'; + $this->db->rollback(); + return null; + } + $busy = $this->db->prepare( + "SELECT 1 FROM prestiti p + WHERE p.copia_id = ? + AND p.data_prestito <= ? + AND (p.stato = 'in_ritardo' + OR (p.stato = 'in_corso' AND p.data_scadenza < ?) + OR p.data_scadenza >= ?) + AND ((p.attivo = 1 AND p.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) + OR (p.attivo = 0 AND p.stato = 'pendente' AND p.copia_id IS NOT NULL)) + LIMIT 1 FOR UPDATE" + ); + $busy->bind_param('isss', $copyId, $branchDue, $today, $today); + $busy->execute(); + $copyBusy = (bool) $busy->get_result()->fetch_row(); + $busy->close(); + if ($copyBusy) { + $failureReason = 'no_copy'; + $this->db->rollback(); + return null; + } + $dueDate = $branchDue; + } + $ins = $this->db->prepare( "INSERT INTO prestiti (libro_id, utente_id, copia_id, data_prestito, data_scadenza, diff --git a/tests/library-calendar-loans.unit.php b/tests/library-calendar-loans.unit.php index 2eb8d6158..c68e4fd6b 100644 --- a/tests/library-calendar-loans.unit.php +++ b/tests/library-calendar-loans.unit.php @@ -5,8 +5,9 @@ * Closures calendar applied to the loan code, against the real database: * the dueDate()/pickupDeadline() funnels reading loans.closed_weekdays and * calendario_chiusure, branch closures through the copy's sede, the renewal - * cap stepping back to an open day, and the nightly expiry jobs doing nothing - * on a closed day. + * cap stepping back to an open day, the nightly expiry jobs doing nothing + * on a closed day, and the NCIP checkout moving its due date with the + * chosen copy's branch. * * Run: php tests/library-calendar-loans.unit.php */ @@ -47,6 +48,7 @@ $prevWeekdays = $db->query("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = 'closed_weekdays'")->fetch_row(); $bookId = 0; $userId = 0; +$otherUserId = 0; $sedeId = 0; $setWeekdays = static function (string $value) use ($db): void { $stmt = $db->prepare("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', 'closed_weekdays', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); @@ -66,7 +68,7 @@ $db->query("DELETE FROM calendario_chiusure WHERE descrizione = '{$tag}'"); LibraryCalendar::reset(); }; -$cleanup = static function () use ($db, $tag, $prevWeekdays, &$bookId, &$userId, &$sedeId): void { +$cleanup = static function () use ($db, $tag, $prevWeekdays, &$bookId, &$userId, &$otherUserId, &$sedeId): void { $db->query("DELETE FROM calendario_chiusure WHERE descrizione = '{$tag}'"); if ($prevWeekdays === null) { $db->query("DELETE FROM system_settings WHERE category = 'loans' AND setting_key = 'closed_weekdays'"); @@ -85,6 +87,9 @@ if ($userId > 0) { $db->query("DELETE FROM utenti WHERE id = {$userId}"); } + if ($otherUserId > 0) { + $db->query("DELETE FROM utenti WHERE id = {$otherUserId}"); + } if ($sedeId > 0) { $db->query("DELETE FROM sedi WHERE id = {$sedeId}"); } @@ -225,5 +230,54 @@ $check($resState() !== 'attiva', 'with the library open the expired queue reservation is cancelled as before'); $db->query("DELETE FROM prenotazioni WHERE id = {$queueRes}"); +// ── NCIP CheckOutItem: the due date moves with the chosen copy's branch ─── +// handleCheckOutItem() computes the library-wide due date before any copy is +// known; createLoanAtomic() picks the copy under the book lock and must apply +// that copy's branch closures to the date it stores (as the desk does), and +// hand the stored date back for the response's DateDue. +require_once $root . '/storage/plugins/ncip-server/NcipServerPlugin.php'; +$clearClosures(); +$db->query("DELETE FROM prestiti WHERE libro_id = {$bookId}"); +$db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$copyId}"); +$db->query("UPDATE libri SET copie_disponibili = 1 WHERE id = {$bookId}"); +$ncip = new \App\Plugins\NcipServer\NcipServerPlugin($db, new \App\Support\HookManager($db)); +$createLoanAtomic = new ReflectionMethod($ncip, 'createLoanAtomic'); +$ncipPlainDue = $shift($today, 15); +$addClosure($ncipPlainDue, $ncipPlainDue, $sedeId); +$check(LibraryCalendar::dueDate($db, $ncipPlainDue) === $ncipPlainDue, 'NCIP: the library-wide due date is open (the closure is of the branch only)'); +$ncipDue = $ncipPlainDue; +$ncipFailure = null; +$ncipLoan = $createLoanAtomic->invokeArgs($ncip, [$bookId, $userId, &$ncipDue, 0, &$ncipFailure]); +$check(is_int($ncipLoan) && $ncipLoan > 0, 'NCIP: the checkout with a branch closure on its due date is created (' . var_export($ncipFailure, true) . ')'); +$ncipStored = (string) $db->query('SELECT data_scadenza FROM prestiti WHERE id = ' . (int) $ncipLoan)->fetch_row()[0]; +$check($ncipStored === $shift($ncipPlainDue, 1), "NCIP: the stored data_scadenza is the next open day of the copy's branch"); +$check($ncipDue === $ncipStored, 'NCIP: the handler is handed the stored date (the DateDue of the response)'); + +// The shifted window is checked again: a commitment on the copy from the +// day after the closure refuses the checkout instead of storing a due date +// on the closed day (the title has one copy, so capacity refuses first). +$db->query('DELETE FROM prestiti WHERE id = ' . (int) $ncipLoan); +$db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$copyId}"); +$otherEmail = "{$tag}-b@test.local"; +$otherCard = strtoupper("ZZCAL{$run}B"); +$stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata) VALUES (?, 'Cal', 'Other', ?, ?, 'standard', 'attivo', 1)"); +$stmt->bind_param('sss', $otherCard, $otherEmail, $hash); +$stmt->execute(); +$otherUserId = (int) $db->insert_id; +$stmt->close(); +$heldFrom = $shift($ncipPlainDue, 1); +$heldTo = $shift($ncipPlainDue, 10); +$stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo) VALUES (?, ?, ?, ?, ?, 'prenotato', 1)"); +$stmt->bind_param('iiiss', $bookId, $copyId, $otherUserId, $heldFrom, $heldTo); +$stmt->execute(); +$stmt->close(); +$ncipDue = $ncipPlainDue; +$ncipFailure = null; +$ncipLoan2 = $createLoanAtomic->invokeArgs($ncip, [$bookId, $userId, &$ncipDue, 0, &$ncipFailure]); +$check($ncipLoan2 === null && in_array($ncipFailure, ['no_capacity', 'no_copy'], true), 'NCIP: a commitment from the shifted day refuses the checkout (' . var_export($ncipFailure, true) . ') rather than storing a closed due date'); +$check($ncipDue === $ncipPlainDue, 'NCIP: a refused checkout leaves the handed-in date alone'); +$check((int) $db->query("SELECT COUNT(*) FROM prestiti WHERE libro_id = {$bookId} AND utente_id = {$userId}")->fetch_row()[0] === 0, 'NCIP: nothing was stored for the refused checkout'); +$clearClosures(); + echo ($failed === 0 ? 'SUCCESS' : 'FAILURE') . " {$passed} passed, {$failed} failed\n"; exit($failed === 0 ? 0 : 1); diff --git a/tests/ncip-circulation-coherence.unit.php b/tests/ncip-circulation-coherence.unit.php index 4ef9c5271..cab71a338 100644 --- a/tests/ncip-circulation-coherence.unit.php +++ b/tests/ncip-circulation-coherence.unit.php @@ -372,7 +372,7 @@ $dDue = (new DateTimeImmutable($today))->modify("+{$loanDays} days")->format('Y-m-d'); $createLoanAtomic = new ReflectionMethod(NcipServerPlugin::class, 'createLoanAtomic'); $dFailure = null; -$dLoanId = $createLoanAtomic->invokeArgs($ncip, [$dBookId, $dUserId, $dDue, (int) $staffCaller['id'], &$dFailure]); +$dLoanId = $createLoanAtomic->invokeArgs($ncip, [$dBookId, $dUserId, &$dDue, (int) $staffCaller['id'], &$dFailure]); $check(is_int($dLoanId) && $dLoanId > 0, '13 atomic NCIP checkout creates the in_corso loan (' . var_export($dFailure, true) . ')'); $check($hasAuditEvent($dBookId, 'loan.created', 'ncip', (int) $dLoanId), '14 loan.created audit event recorded with source=ncip'); $dOutbox = 0; From 3250a1cfe3d26ef3c5c767ceb7cc57db40ac94fb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:03:34 +0000 Subject: [PATCH 168/398] Pickup deadline extension stops at the loan's due date LibraryCalendar::extendPickupDeadlines() moved a waiting pickup's deadline to the next open day without looking at the loan's data_scadenza. The deadline is capped at the due date on creation, so a closure on that day promised a pickup after the loan window, which confirmPickup() refuses. The sweep now reads data_scadenza and caps the new deadline at it, leaving the row alone when nothing can be added; the due date of an existing loan is never moved. The DB-backed calendar test covers the equal, the capped and the unchanged cases. --- CHANGELOG.md | 2 +- app/Support/LibraryCalendar.php | 17 ++++++++++++++--- tests/library-calendar-loans.unit.php | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e926dc2..0227f5a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre ### Closures calendar - **The library's closed days.** A new admin page, Admin → Calendario chiusure (`/admin/calendar`, also linked from Settings → Loans), holds the weekly closed days (`loans.closed_weekdays`) and the closures: single days or periods, once or every year, for every branch or for one. The Italian national holidays come with one click (fixed holidays as yearly closures, Easter and Easter Monday computed for the chosen year), any other calendar can be imported from an ICS file, and a monthly preview shows which days are open. - **Due dates land on an open day.** The shift lives in `CirculationPolicy::dueDateFor()`, so the per-patron-type durations and renewal days of the circulation policy land on an open day too. A due date that falls on a closed day moves to the next open day: admin loans (proposed and saved), loan edits, reader requests from the website and the app, reservations and their promotion to a loan, renewals and bulk extensions, NCIP CheckOut, RequestItem and RenewItem. A closure of one branch counts for the loans of its copies: when the staff desk lends a copy, an NCIP CheckOutItem binds one, or a request is approved onto one, the due date moves to that branch's next open day (at approval only if the longer window still fits; the NCIP checkout is refused, like the desk's, when the longer window no longer fits). A refused renewal proposes the last open day before the conflict. -- **Pickup days are open days.** The pickup deadline counts only the days the library is open, and the nightly sweep expires no pickup, no scheduled loan and no queue reservation on a closed day; a deadline that falls on a day closed afterwards moves to the next open day when the calendar is saved, so the desk can still hand the book over that day. +- **Pickup days are open days.** The pickup deadline counts only the days the library is open, and the nightly sweep expires no pickup, no scheduled loan and no queue reservation on a closed day; a deadline that falls on a day closed afterwards moves to the next open day when the calendar is saved, so the desk can still hand the book over that day, but never past the loan's due date (a pickup after the loan window is refused), which the calendar save does not move. - **The request calendars know the closed days.** On the book page and in the admin loan form a closed day cannot be picked as the start (a request that reaches the server with one starts on the next open day); the availability payload carries `closed_dates`, on the website and in the Mobile API `GET /catalog/books/{id}/availability` (additive field). A request auto-approved on a closed day is reported by `POST /reservations` with its real state, `prenotato` (new enum value) with the start day in `start_date` (additive), instead of `da_ritirare`. - **ICS imports meet the form's limits.** Every event is checked like a closure typed in the form (a yearly closure within a year, a one-off within ten years, end not before start); the import message counts the events discarded. A yearly closure of a year or more, however stored, is applied once as the interval it names and never closes every day; an ICS date-time in UTC is read in the application timezone. The calendar page follows the settings permission (`settings.manage`), so a custom role with that permission can use it. - With no closed weekday and no closure configured every date is computed exactly as before. diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index 492c03f10..d372c183f 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -156,7 +156,14 @@ public static function pickupDeadline(\mysqli $db, string $from, int $days, ?str * stored in prestiti.pickup_deadline, so every check that reads it (pickup * confirmation, the reader's cancellation, the expiry sweep, the expired * badges) applies the same rule. A deadline whose next open day is already - * past is left to the expiry sweep. Returns the number of pickups moved. + * past is left to the expiry sweep. + * + * The deadline never passes the loan's due date (prestiti.data_scadenza): + * pickupDeadline() caps it there on creation, and confirmPickup() refuses + * a pickup after the loan window, so an extension past it would promise a + * day the desk cannot honour. The due date of an existing loan is not + * moved here; when the cap leaves nothing to add the row is left alone. + * Returns the number of pickups moved. */ public static function extendPickupDeadlines(\mysqli $db): int { @@ -166,7 +173,7 @@ public static function extendPickupDeadlines(\mysqli $db): int } $today = DateHelper::today(); $result = $db->query( - "SELECT p.id, p.pickup_deadline, c.sede_id + "SELECT p.id, p.pickup_deadline, p.data_scadenza, c.sede_id FROM prestiti p LEFT JOIN copie c ON c.id = p.copia_id WHERE p.stato = 'da_ritirare' AND p.attivo = 1 AND p.pickup_deadline IS NOT NULL" @@ -185,7 +192,11 @@ public static function extendPickupDeadlines(\mysqli $db): int foreach ($rows as $row) { $deadline = substr((string) $row['pickup_deadline'], 0, 10); $next = $calendar->nextOpenDay($deadline, $row['sede_id'] !== null ? (int) $row['sede_id'] : null); - if ($next === $deadline || $next < $today) { + $dueDate = $row['data_scadenza'] !== null ? substr((string) $row['data_scadenza'], 0, 10) : ''; + if (DateHelper::isISODateFormat($dueDate) && $next > $dueDate) { + $next = $dueDate; + } + if ($next <= $deadline || $next < $today) { continue; } $id = (int) $row['id']; diff --git a/tests/library-calendar-loans.unit.php b/tests/library-calendar-loans.unit.php index c68e4fd6b..6ea2a3d87 100644 --- a/tests/library-calendar-loans.unit.php +++ b/tests/library-calendar-loans.unit.php @@ -206,6 +206,26 @@ $maintenance->checkExpiredPickups(); $check($stateOf($pickupLoan) === 'da_ritirare', 'a deadline on a later-declared closed day lasts until the next open day (today)'); $check(LibraryCalendar::extendPickupDeadlines($db) === 0, 'a second save moves nothing (idempotent)'); + +// The extension never passes the loan's due date (confirmPickup() refuses a +// pickup after the loan window), and the due date itself is not moved. +$clearClosures(); +$pickupDue = $shift($today, 3); +$db->query("UPDATE prestiti SET pickup_deadline = '{$pickupDue}', data_scadenza = '{$pickupDue}' WHERE id = {$pickupLoan}"); +$addClosure($pickupDue, $shift($pickupDue, 2)); +$check(LibraryCalendar::extendPickupDeadlines($db) === 0, 'a deadline equal to the due date is left alone when the due date is closed'); +$check($deadlineOf($pickupLoan) === $pickupDue, 'its stored deadline still is the due date'); +$clearClosures(); +$db->query("UPDATE prestiti SET pickup_deadline = '{$shift($pickupDue, -1)}' WHERE id = {$pickupLoan}"); +$addClosure($shift($pickupDue, -1), $shift($pickupDue, 2)); +$check(LibraryCalendar::extendPickupDeadlines($db) >= 1, 'a deadline the day before the due date, closed through after it, moves'); +$check($deadlineOf($pickupLoan) === $pickupDue, 'and stops at the due date, not at the next open day'); +$check((string) $db->query("SELECT data_scadenza FROM prestiti WHERE id = {$pickupLoan}")->fetch_row()[0] === $pickupDue, 'the due date of the existing loan is not moved'); +$clearClosures(); +$db->query("UPDATE prestiti SET pickup_deadline = '{$shift($pickupDue, -1)}', data_scadenza = '{$shift($pickupDue, 5)}' WHERE id = {$pickupLoan}"); +$addClosure($shift($pickupDue, -1), $pickupDue); +$check(LibraryCalendar::extendPickupDeadlines($db) >= 1 && $deadlineOf($pickupLoan) === $shift($pickupDue, 1), 'with room before the due date the deadline moves to the next open day as before'); +$db->query("UPDATE prestiti SET pickup_deadline = '{$yesterday}', data_scadenza = '{$loanEnd}' WHERE id = {$pickupLoan}"); $clearClosures(); $db->query("UPDATE prestiti SET pickup_deadline = '{$yesterday}' WHERE id = {$pickupLoan}"); $check(LibraryCalendar::extendPickupDeadlines($db) === 0, 'without closures no deadline moves'); From 5264575b60a68b3c2f42196a7fb1c7580284a9c1 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:05:13 +0000 Subject: [PATCH 169/398] Calendar scans step over a one-off closure instead of walking it A once closure may span up to ten years (MAX_ONCE_SPAN_DAYS) but nextOpenDay(), previousOpenDay() and addOpenDays() walked at most MAX_SCAN_DAYS days and then fell back to their input, so a date inside a closure longer than three years stayed on a closed day. A day inside a one-off closure now jumps to the day after (or before) that closure and the scan goes on from there; the bound stays as the safety net for closed weekdays and yearly closures, each shorter than a year, so an all-closed setup still ends after the bound as quickly as before. closedDatesBetween() keeps its day-by-day cap, which outlasts every caller's horizon, and says so. --- CHANGELOG.md | 2 +- app/Support/LibraryCalendar.php | 72 ++++++++++++++++++++++++++++++--- tests/library-calendar.unit.php | 35 ++++++++++++++++ 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0227f5a60..7b54c69d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Due dates land on an open day.** The shift lives in `CirculationPolicy::dueDateFor()`, so the per-patron-type durations and renewal days of the circulation policy land on an open day too. A due date that falls on a closed day moves to the next open day: admin loans (proposed and saved), loan edits, reader requests from the website and the app, reservations and their promotion to a loan, renewals and bulk extensions, NCIP CheckOut, RequestItem and RenewItem. A closure of one branch counts for the loans of its copies: when the staff desk lends a copy, an NCIP CheckOutItem binds one, or a request is approved onto one, the due date moves to that branch's next open day (at approval only if the longer window still fits; the NCIP checkout is refused, like the desk's, when the longer window no longer fits). A refused renewal proposes the last open day before the conflict. - **Pickup days are open days.** The pickup deadline counts only the days the library is open, and the nightly sweep expires no pickup, no scheduled loan and no queue reservation on a closed day; a deadline that falls on a day closed afterwards moves to the next open day when the calendar is saved, so the desk can still hand the book over that day, but never past the loan's due date (a pickup after the loan window is refused), which the calendar save does not move. - **The request calendars know the closed days.** On the book page and in the admin loan form a closed day cannot be picked as the start (a request that reaches the server with one starts on the next open day); the availability payload carries `closed_dates`, on the website and in the Mobile API `GET /catalog/books/{id}/availability` (additive field). A request auto-approved on a closed day is reported by `POST /reservations` with its real state, `prenotato` (new enum value) with the start day in `start_date` (additive), instead of `da_ritirare`. -- **ICS imports meet the form's limits.** Every event is checked like a closure typed in the form (a yearly closure within a year, a one-off within ten years, end not before start); the import message counts the events discarded. A yearly closure of a year or more, however stored, is applied once as the interval it names and never closes every day; an ICS date-time in UTC is read in the application timezone. The calendar page follows the settings permission (`settings.manage`), so a custom role with that permission can use it. +- **ICS imports meet the form's limits.** Every event is checked like a closure typed in the form (a yearly closure within a year, a one-off within ten years, end not before start); the import message counts the events discarded. A yearly closure of a year or more, however stored, is applied once as the interval it names and never closes every day; a one-off closure of any admissible length (up to ten years) is stepped over whole, so a date inside it lands on the day after it ends; an ICS date-time in UTC is read in the application timezone. The calendar page follows the settings permission (`settings.manage`), so a custom role with that permission can use it. - With no closed weekday and no closure configured every date is computed exactly as before. ### Migration diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index d372c183f..14b3c1626 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -27,7 +27,13 @@ */ final class LibraryCalendar { - /** Upper bound on day-by-day scans, so an all-closed setup cannot loop forever. */ + /** + * Upper bound on the scans, so an all-closed setup cannot loop forever. + * A scan steps over a one-off closure in one go (closedThrough()), so the + * bound only has to outlast the day-by-day part: closed weekdays and + * yearly closures, each shorter than a year. A one-off closure may span + * MAX_ONCE_SPAN_DAYS, far more than this: it is never walked. + */ private const MAX_SCAN_DAYS = 1100; /** Longest yearly closure (days between start and end): a year less a day. */ @@ -245,7 +251,13 @@ public function isOpen(string $ymd, ?int $sedeId = null): bool return true; } - /** $ymd itself when open, otherwise the first open day after it. */ + /** + * $ymd itself when open, otherwise the first open day after it. A day + * inside a one-off closure jumps to the day after that closure; the + * scan bound only applies to closed weekdays and yearly closures. With + * every day closed (two yearly closures covering the year) $ymd is + * returned after the bound, as before. + */ public function nextOpenDay(string $ymd, ?int $sedeId = null): string { if (!DateHelper::isISODateFormat($ymd)) { @@ -256,7 +268,7 @@ public function nextOpenDay(string $ymd, ?int $sedeId = null): string if ($this->isOpen($day, $sedeId)) { return $day; } - $day = self::shift($day, 1); + $day = self::shift($this->closedThrough($day, $sedeId) ?? $day, 1); } return $ymd; } @@ -267,7 +279,10 @@ public function shiftForward(string $ymd, ?int $sedeId = null): string return $this->nextOpenDay($ymd, $sedeId); } - /** $ymd itself when open, otherwise the last open day before it. */ + /** + * $ymd itself when open, otherwise the last open day before it: the + * mirror of nextOpenDay(), jumping to the day before a one-off closure. + */ public function previousOpenDay(string $ymd, ?int $sedeId = null): string { if (!DateHelper::isISODateFormat($ymd)) { @@ -278,12 +293,16 @@ public function previousOpenDay(string $ymd, ?int $sedeId = null): string if ($this->isOpen($day, $sedeId)) { return $day; } - $day = self::shift($day, -1); + $day = self::shift($this->closedFrom($day, $sedeId) ?? $day, -1); } return $ymd; } - /** The day $n open days after $from ($from itself excluded). */ + /** + * The day $n open days after $from ($from itself excluded). A one-off + * closure on the way is stepped over whole; after the scan bound (every + * day closed) the plain $from + $n is returned, as before. + */ public function addOpenDays(string $from, int $n, ?int $sedeId = null): string { if (!DateHelper::isISODateFormat($from) || $n <= 0) { @@ -295,6 +314,8 @@ public function addOpenDays(string $from, int $n, ?int $sedeId = null): string $day = self::shift($day, 1); if ($this->isOpen($day, $sedeId)) { $counted++; + } else { + $day = $this->closedThrough($day, $sedeId) ?? $day; } } return $counted < $n ? self::shift($from, $n) : $day; @@ -302,6 +323,9 @@ public function addOpenDays(string $from, int $n, ?int $sedeId = null): string /** * Closed days in [$from, $to], both included, for the request calendars. + * Listed day by day, so the list stops MAX_SCAN_DAYS days after $from: + * the callers ask for a month or a 400-day horizon, never a span that + * long, and a ten-year closure is not expanded into ten years of dates. * * @return list */ @@ -321,6 +345,42 @@ public function closedDatesBetween(string $from, string $to, ?int $sedeId = null return $closed; } + /** + * The last day of the one-off closures of $sedeId (or of every branch) + * that contain $ymd, the latest when several overlap; null when none + * does (the day is closed by a weekday or a yearly closure, or open). + * Yearly closures are not considered: they are shorter than a year and + * a scan walks them day by day, within its bound. + */ + private function closedThrough(string $ymd, ?int $sedeId): ?string + { + $through = null; + foreach ($this->closures as $closure) { + if ($closure['yearly'] || ($closure['sede'] !== null && $closure['sede'] !== $sedeId)) { + continue; + } + if ($ymd >= $closure['start'] && $ymd <= $closure['end'] && ($through === null || $closure['end'] > $through)) { + $through = $closure['end']; + } + } + return $through; + } + + /** The first day of the one-off closures containing $ymd (see closedThrough()). */ + private function closedFrom(string $ymd, ?int $sedeId): ?string + { + $from = null; + foreach ($this->closures as $closure) { + if ($closure['yearly'] || ($closure['sede'] !== null && $closure['sede'] !== $sedeId)) { + continue; + } + if ($ymd >= $closure['start'] && $ymd <= $closure['end'] && ($from === null || $closure['start'] < $from)) { + $from = $closure['start']; + } + } + return $from; + } + /** * Open days after $from up to and including $to: the days a late loan * counts when closed days do not (fines). 0 when $to <= $from. diff --git a/tests/library-calendar.unit.php b/tests/library-calendar.unit.php index ce7f6d852..b40d0f631 100644 --- a/tests/library-calendar.unit.php +++ b/tests/library-calendar.unit.php @@ -100,6 +100,41 @@ $eq($oneDayMore->isOpen('2030-12-25'), true, 'a 365-day yearly span does not recur'); $eq($oneDayMore->isOpen('2026-12-25'), false, 'but closes its own interval'); +// ── A one-off closure longer than the scan bound is stepped over whole ─── +// A once closure may span ten years (MAX_ONCE_SPAN_DAYS) while a scan walks +// at most ~3 years day by day: the scans jump to the end of the closure. +$fiveYears = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2030-12-31']]); +$eq(LibraryCalendar::spanDays('2026-01-01', '2030-12-31') <= LibraryCalendar::MAX_ONCE_SPAN_DAYS, true, 'a five-year once closure is admissible'); +$eq($fiveYears->nextOpenDay('2026-06-15'), '2031-01-01', 'nextOpenDay inside a five-year closure is the day after it ends'); +$eq($fiveYears->nextOpenDay('2026-01-01'), '2031-01-01', 'from its first day too'); +$eq($fiveYears->previousOpenDay('2030-06-15'), '2025-12-31', 'previousOpenDay inside it is the day before it starts'); +$eq($fiveYears->previousOpenDay('2030-12-31'), '2025-12-31', 'from its last day too'); +$eq($fiveYears->addOpenDays('2026-06-15', 3), '2031-01-03', 'addOpenDays from inside it counts open days after it'); +$eq($fiveYears->addOpenDays('2025-12-30', 3), '2031-01-02', 'addOpenDays across it: Dec 31, then Jan 1 and 2 of 2031'); +$eq($fiveYears->closedDatesBetween('2027-01-01', '2027-01-31'), array_map(static fn (int $d): string => sprintf('2027-01-%02d', $d), range(1, 31)), 'closedDatesBetween inside it lists every day of the month asked'); +$cut = $fiveYears->closedDatesBetween('2026-01-01', '2030-12-31'); +$eq($cut[0] === '2026-01-01' && count($cut) > 1000 && count($cut) < 1826 && LibraryCalendar::shift($cut[0], count($cut) - 1) === end($cut), true, 'closedDatesBetween over the whole closure is cut at the scan bound, consecutive from the start'); +$eq($fiveYears->openDaysBetween('2025-12-31', '2031-01-02'), 2, 'openDaysBetween across it counts only the two days after it'); +// Around the jump: closed weekdays and a branch closure right after it still apply. +$afterJump = new LibraryCalendar([3], [['start' => '2026-01-01', 'end' => '2030-12-31'], ['start' => '2031-01-02', 'end' => '2031-01-02', 'sede_id' => 2]]); +$eq($afterJump->nextOpenDay('2028-02-29'), '2031-01-02', '2031-01-01 is a Wednesday (closed weekday): the jump lands on the next open day'); +$eq($afterJump->nextOpenDay('2028-02-29', 2), '2031-01-03', 'and branch 2, closed on Jan 2 as well, opens on Jan 3'); +$eq($afterJump->previousOpenDay('2028-02-29'), '2025-12-30', '2025-12-31 is a Wednesday: previousOpenDay steps back once more'); +$twoCut = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2027-12-31'], ['start' => '2027-06-01', 'end' => '2030-12-31']]); +$eq($twoCut->nextOpenDay('2026-06-15'), '2031-01-01', 'overlapping once closures are stepped over one after the other'); +$eq($twoCut->previousOpenDay('2030-06-15'), '2025-12-31', 'backwards too'); +$branchOnly = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2030-12-31', 'sede_id' => 5]]); +$eq($branchOnly->nextOpenDay('2026-06-15'), '2026-06-15', 'a branch-only closure does not move the library as a whole'); +$eq($branchOnly->nextOpenDay('2026-06-15', 5), '2031-01-01', 'but moves its branch past it'); +// Every day closed (two yearly closures covering the year): the scans still +// end after the bound and fall back as before, quickly. +$allYear = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2026-06-30', 'yearly' => true], ['start' => '2026-07-01', 'end' => '2026-12-31', 'yearly' => true]]); +$started = microtime(true); +$eq($allYear->nextOpenDay('2026-06-15'), '2026-06-15', 'all days closed: nextOpenDay returns its input after the bound'); +$eq($allYear->previousOpenDay('2026-06-15'), '2026-06-15', 'all days closed: previousOpenDay returns its input'); +$eq($allYear->addOpenDays('2026-06-15', 3), '2026-06-18', 'all days closed: addOpenDays falls back to +N calendar days'); +$eq(microtime(true) - $started < 2.0, true, 'the all-closed fallbacks finish quickly'); + // ── Branch-specific vs all branches ─────────────────────────────────────── $branches = new LibraryCalendar([], [ ['start' => '2026-11-10', 'end' => '2026-11-10', 'sede_id' => 2], From 5cf87ef1be43df985f8fbaf64273d4b68e96dab7 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:07:00 +0000 Subject: [PATCH 170/398] MARC import: overwrite mode updates the media type of an existing book Marc21LibriMapper reads tipo_media from leader/06, but plan() turned the pipeline's `formato` family off (a record never carries a format or a price) and in updateBook() that family wrote both formato and tipo_media, so a DVD record matched to a printed book left it a libro even in overwrite mode. Letting the family through would have rewritten formato to cartaceo, which the CSV import derives from the row. The media type is now a family of its own in updateBook(): `tipo_media`, optional, defaulting to `formato` so a CSV row (the upload never sends it) keeps writing both columns as before; it is bound only when the column exists. plan() enables it in overwrite mode when the record carries a media type and leaves it off in fill mode, since a book always has one (NOT NULL, default libro). The RDA override columns already followed the mode. Covered by the pure plan() checks of marc-import-robustness (fail on the old code), a database case there, and a DVD step of the duplicates E2E test (new `dvd` fixture kind: the same records as videorecordings). --- CHANGELOG.md | 2 +- app/Controllers/CsvImportController.php | 20 ++++++++++-- app/Support/Marc/MarcBookImporter.php | 14 ++++++++- tests/helpers/marc-fixtures.php | 7 ++++- tests/marc-bulk-import.spec.js | 11 +++++++ tests/marc-import-robustness.unit.php | 42 +++++++++++++++++++++++-- 6 files changed, 87 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f0895cf..a6b0ce1a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Bulk MARC import** (Books → Import → MARC, `/admin/books/import/marc`). A `.mrc`/`.iso` (ISO 2709) or `.xml` (MARCXML) file is read once, a preview of the first ten mapped records says which are already in the catalogue, and the import runs in chunks with a progress bar through the CSV import's own row pipeline: authors and publishers are found with the exact-spelling rules of #501, one copy per book, the import history and its error download work as for CSV. A record matching a book by ISBN-13, ISBN-10 or EAN (deleted books have them cleared, so they never match) is skipped, completed in its empty fields, or overwritten with what the record carries. Covers come from 856. Holdings (852/949/952) are not turned into copies. - **Bulk MARC export** (Books → Export, and "Esporta in MARC" on a selection). The catalogue or the selected books as MARC 21 `.mrc`, a MARCXML collection or UNIMARC `.mrc`, written while the download is sent, so a large catalogue never sits in memory. Deleted books are never exported. The records carry the EAN, the LC classification, the Dewey number and the cover, so an export re-imports without duplicates. - **Copy cataloguing** (Books → Import → Copy cataloging, `/admin/books/copy-cataloging`, z39-server plugin). Search by title, author, ISBN/ISSN or all fields in the configured SRU servers, SBN, the Library of Congress, BnF, DNB, K10plus and this installation's own SRU server; look at each record's MARC fields; import the chosen ones with the same duplicate modes and cover download. `SruClient::search()` returns every record of a query. Another Pinakes installation is a preset in the plugin settings, and its SRU server's UNIMARC records now carry the cover in 856 (the MARCXML ones already did). -- **The media type travels in MARC.** A book's media type (libro, disco, audiolibro, dvd, altro) sets leader/06 (a, j, i, g), the 007 category of material and the RDA content, media and carrier types in 336/337/338 (`$a` term, `$b` code, `$2` vocabulary; a book's own RDA codes, when it has them, override the implied ones); UNIMARC records set label/06 the same way. On import the media type is read from leader/06 (a sound recording of performed music is a disco, of spoken word an audiolibro) and RDA codes the media type does not imply are kept as the book's own, once the catalogue has the columns for them. OAI-PMH MARC 21 records now carry leader/06 by media type and RDA 336-338 (the golden files are regenerated). +- **The media type travels in MARC.** A book's media type (libro, disco, audiolibro, dvd, altro) sets leader/06 (a, j, i, g), the 007 category of material and the RDA content, media and carrier types in 336/337/338 (`$a` term, `$b` code, `$2` vocabulary; a book's own RDA codes, when it has them, override the implied ones); UNIMARC records set label/06 the same way. On import the media type is read from leader/06 (a sound recording of performed music is a disco, of spoken word an audiolibro) and RDA codes the media type does not imply are kept as the book's own, once the catalogue has the columns for them; in overwrite mode they replace those of a book already in the catalogue (a DVD record matched to a printed book makes it a DVD), while its format and price are never read from a record. OAI-PMH MARC 21 records now carry leader/06 by media type and RDA 336-338 (the golden files are regenerated). - **The genre survives a MARC round trip.** A MARC 21 record carries the genre as 655 (ind2 7, `$2 local`), the keywords as 650; before, both were 650 and the genre came back as a keyword. On import a 655 marked `$2 local` becomes the book's genre (created when the catalogue lacks it, as the CSV import does); form headings of other schemes (LCGFT, RBMS) are left alone. In fill mode a book without a genre gets the record's. OAI-PMH MARC 21 records emit the genre as 655 too. - **Corporate and meeting authors are read** from MARC 21 110/111 and 710/711 (`$a` and `$b` joined), with the same roles as personal names; 082 prime marks ("891.73/3") are dropped from the Dewey number. - **Fill mode completes a family as soon as one of its columns is empty** (an ISBN-10 next to an existing ISBN-13, a series number next to its series) and never overwrites a field the book has. diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index fa3c34c74..059f03417 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -30,6 +30,12 @@ class CsvImportController * anno → anno_pubblicazione; pagine → numero_pagine; * dewey → classificazione_dewey. * + * The media type can also be selected apart from the format: a caller + * that passes the optional `tipo_media` family to importParsedRow() + * decides it on its own (the MARC import, whose records carry a media + * type but never a format or a price). The CSV upload never sends it, + * so a CSV row keeps writing both columns through `formato`. + * * @var list */ private const UPDATE_FIELD_FAMILIES = [ @@ -698,7 +704,8 @@ public function setImportSource(string $source): void * rethrown, so the caller records the row as failed and goes on. * * @param array $parsedData a row as parseCsvRow() returns it - * @param array $updateFields one entry per UPDATE_FIELD_FAMILIES family + * @param array $updateFields one entry per UPDATE_FIELD_FAMILIES family, + * plus the optional `tipo_media` family (defaults to the `formato` one) * @param array $importData * @param int|null $existingBookId the book to update when $resolveExisting is false * @param bool $resolveExisting true: match by ID/ISBN-13/EAN/title+author as the CSV import does @@ -1812,8 +1819,10 @@ private function findExistingBook(\mysqli $db, array $data): ?int * unchecked keeps the book's current values for its columns: * isbn → isbn10/isbn13; ean → ean; titolo/sottotitolo/anno/lingua/ * edizione/pagine/prezzo/dewey → the matching column; - * formato → formato + tipo_media; collana → collana + numero_serie - * (+ series membership sync skipped); + * formato → formato + tipo_media, unless $updateFields carries its own + * `tipo_media` entry, which then decides the media type alone (the MARC + * import overwrites the media type and never the format); + * collana → collana + numero_serie (+ series membership sync skipped); * genre → genere_id; publisher → editore_id (+ junction sync skipped); * description → descrizione/descrizione_plain; keywords → parole_chiave; * contributors → the legacy traduttore/illustratore/curatore TEXT columns. @@ -1832,6 +1841,9 @@ private function updateBook(\mysqli $db, int $bookId, array $data, ?int $editorI foreach (self::UPDATE_FIELD_FAMILIES as $family) { $apply[$family] = (bool) ($updateFields[$family] ?? true); } + // The media type follows the format family unless the caller selects + // it on its own (see UPDATE_FIELD_FAMILIES). + $apply['tipo_media'] = (bool) ($updateFields['tipo_media'] ?? $apply['formato']); $anyPreserved = in_array(false, $apply, true); $activityBefore = \App\Support\ActivityLog::loadBookSnapshot($db, $bookId); $hasTipoMedia = $this->hasTipoMediaColumn($db); @@ -1973,6 +1985,8 @@ classificazione_dewey = ?, } if (!$apply['formato']) { $formato = $existingDesc['formato']; + } + if (!$apply['tipo_media']) { // tipo_media updates via COALESCE(?, tipo_media): NULL keeps it. $tipoMedia = null; } diff --git a/app/Support/Marc/MarcBookImporter.php b/app/Support/Marc/MarcBookImporter.php index f56e8967f..e5d9b7dd5 100644 --- a/app/Support/Marc/MarcBookImporter.php +++ b/app/Support/Marc/MarcBookImporter.php @@ -25,6 +25,11 @@ * overwrite every field the record carries replaces the book's; a field * the record does not carry is kept * + * The format and the price are never read from a record. The media type + * (leader/06) is: in overwrite mode it replaces the book's, in fill mode it + * is left, since a book always has one (libri.tipo_media is NOT NULL with + * the default 'libro'). + * * The columns the CSV pipeline does not write (place of publication, * dimensions, LC classification, ISSN, the book's own RDA codes) and the * cover (856) follow the same mode. @@ -298,10 +303,17 @@ public static function plan(string $mode, array $data, array $parsed, array $exi } $families[$family] = array_intersect_key(array_flip(self::FAMILY_COLUMNS[$family]), $empty) !== []; } - // Never from a MARC record: the format and the price. + // Never from a MARC record: the format and the price. The media type + // is its own family of the pipeline, apart from the format (which the + // CSV import derives from the row): in overwrite mode a record that + // carries one sets it; in fill mode it is left, because a book always + // has a media type (libri.tipo_media is NOT NULL, default 'libro'), + // so the family never has an empty column to fill. foreach (['formato', 'prezzo'] as $family) { $families[$family] = false; } + $families['tipo_media'] = $mode === self::MODE_OVERWRITE + && trim((string) ($data['tipo_media'] ?? '')) !== ''; foreach (self::FAMILY_COLUMNS as $family => $columns) { foreach ($columns as $column) { if ($column === 'editore_id' || $column === 'genere_id') { diff --git a/tests/helpers/marc-fixtures.php b/tests/helpers/marc-fixtures.php index a0993cd04..7c8a40316 100644 --- a/tests/helpers/marc-fixtures.php +++ b/tests/helpers/marc-fixtures.php @@ -8,7 +8,7 @@ * * php tests/helpers/marc-fixtures.php fixtures * (re)writes the committed files in tests/fixtures/marc/ - * php tests/helpers/marc-fixtures.php e2e [first-index] + * php tests/helpers/marc-fixtures.php e2e [first-index] * a file of MARC 21 (or UNIMARC) books whose titles and ISBNs * carry , for the Playwright specs */ @@ -127,6 +127,11 @@ function e2eMarc21(string $run, int $i, string $digits): array ]); continue; } + if ($kind === 'dvd') { + // the same records as videorecordings (leader/06 g): a DVD of each book + $bytes .= iso('00000ngm a2200000 i 4500', $fields); + continue; + } if ($kind === 'xml') { $bytes .= xmlRecord('00000nam a2200000 i 4500', $fields); } elseif ($kind === 'unimarc') { diff --git a/tests/marc-bulk-import.spec.js b/tests/marc-bulk-import.spec.js index 3d23a3098..59acbc9ab 100644 --- a/tests/marc-bulk-import.spec.js +++ b/tests/marc-bulk-import.spec.js @@ -128,6 +128,17 @@ test.describe.serial('MARC bulk import', () => { // still one book per ISBN, one copy each expect(dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo LIKE '${sqlEscape(title1)}%' AND deleted_at IS NULL`)).toBe('1'); expect(dbQuery(`SELECT COUNT(*) FROM copie c JOIN libri l ON l.id=c.libro_id WHERE l.titolo='${sqlEscape(title1)}'`)).toBe('1'); + + // overwrite updates the media type too: the DVD record of book 03 (same ISBN, + // leader/06 g) makes the printed book a DVD; its format is left alone + const title3 = `Libro MARC ${RUN} 03`; + const mediaOf3 = () => dbQuery(`SELECT CONCAT(tipo_media,'|',formato) FROM libri WHERE titolo='${sqlEscape(title3)}' AND deleted_at IS NULL`); + expect(mediaOf3()).toBe('libro|cartaceo'); + upload = await uploadForPreview(page, marcFile(RUN, 1, 'dvd', 3), { duplicates: 'overwrite' }); + expect(upload.preview[0].existing_id).toBeGreaterThan(0); + result = await confirmImport(page); + expect([result.imported, result.updated, result.skipped]).toEqual([0, 1, 0]); + expect(mediaOf3()).toBe('dvd|cartaceo'); }); test('a deleted book does not count as a duplicate: its ISBN was cleared', async ({ page }) => { diff --git a/tests/marc-import-robustness.unit.php b/tests/marc-import-robustness.unit.php index 9a6cd26ce..ead25d234 100644 --- a/tests/marc-import-robustness.unit.php +++ b/tests/marc-import-robustness.unit.php @@ -10,7 +10,10 @@ * - a re-import full of duplicates lists at most MAX_LISTED_DUPLICATES of * them in the session state and the import log; * - in fill mode a family is written as soon as one of its columns is empty - * and no field the book has is overwritten. + * and no field the book has is overwritten; + * - overwrite mode updates the media type of an existing book (a DVD record + * matched to a printed book makes it a DVD) and never its format or price; + * fill mode leaves the media type, since a book always has one. * * The database part touches only the book it creates (title prefixed ZZMR) * and removes it with the probe trigger. @@ -95,13 +98,13 @@ 'anno_pubblicazione' => null, 'lingua' => 'Italiano', 'edizione' => '', 'numero_pagine' => null, 'collana' => 'Serie', 'numero_serie' => '', 'classificazione_dewey' => '', 'parole_chiave' => 'old', 'descrizione' => '', 'editore_id' => 5, 'genere_id' => null, 'traduttore' => 'Old Translator', 'illustratore' => '', - 'curatore' => '', '_principal_authors' => 1, + 'curatore' => '', 'formato' => 'cartaceo', 'tipo_media' => 'libro', 'prezzo' => null, '_principal_authors' => 1, ]; $data = \App\Support\Marc\MarcBookData::empty(); $data = [ 'titolo' => 'New title', 'isbn13' => '9780140449136', 'isbn10' => '0140449132', 'collana' => 'Altra', 'numero_serie' => '3', 'traduttore' => ['New Translator'], 'illustratore' => ['Ill Ustrator'], 'autori' => ['Some Author'], 'editori' => ['Pub'], - 'genere' => 'Narrativa', 'anno_pubblicazione' => '2001', 'parole_chiave' => ['new'], + 'genere' => 'Narrativa', 'anno_pubblicazione' => '2001', 'parole_chiave' => ['new'], 'tipo_media' => 'dvd', ] + $data; $csv = new \App\Controllers\CsvImportController(); $parsed = $csv->normalizeImportRow(\App\Support\Marc\MarcBookData::toImportRow($data)); @@ -111,11 +114,14 @@ $check($fill['contributors'] === true && $row['traduttore'] === 'Old Translator' && $row['illustratore'] === 'Ill Ustrator', 'fill: the illustrator is filled, the translator kept'); $check($fill['titolo'] === false && $fill['keywords'] === false && $fill['authors'] === false && $fill['publisher'] === false, 'fill: title, keywords, authors and publisher the book has are not touched'); $check($fill['anno'] === true && $fill['genre'] === true && $fill['sottotitolo'] === false && $fill['formato'] === false, 'fill: an empty year and genre are filled; a family the record does not carry, and the format, are not'); +$check($fill['tipo_media'] === false, 'fill: the media type of a book is left (a book always has one)'); [$over, $row] = MarcBookImporter::plan(MarcBookImporter::MODE_OVERWRITE, $data, $parsed, $existing); $check($over['isbn'] === true && $over['titolo'] === true && $row['isbn13'] === '9780140449136' && $row['titolo'] === 'New title' && $row['traduttore'] === 'New Translator', 'overwrite: the record\'s values replace the book\'s'); +$check($over['tipo_media'] === true && $row['tipo_media'] === 'dvd' && $over['formato'] === false && $over['prezzo'] === false, 'overwrite: a DVD record updates the media type of a printed book; the format and the price are not written'); $partial = ['isbn13' => '9780140449136', 'titolo' => 'New title'] + \App\Support\Marc\MarcBookData::empty(); [$over, $row] = MarcBookImporter::plan(MarcBookImporter::MODE_OVERWRITE, $partial, $csv->normalizeImportRow(\App\Support\Marc\MarcBookData::toImportRow($partial)), ['isbn10' => '8845292614', 'traduttore' => 'Old Translator'] + $existing); $check($row['isbn10'] === '8845292614' && $row['traduttore'] === 'Old Translator' && $over['contributors'] === false, 'overwrite: a column the record leaves empty keeps the book\'s value'); +$check($over['tipo_media'] === false, 'overwrite: a record without a media type leaves the book\'s'); // 4. Post-commit failures are warnings, not failed records. $envFile = $root . '/.env'; @@ -157,6 +163,36 @@ $stmt->execute(); $check((int) $stmt->get_result()->fetch_row()[0] === 1, 'the book is in the catalogue, matching the report'); $stmt->close(); + + // 5. Overwrite mode updates the media type of an existing book and + // leaves its format alone; fill mode leaves the media type. + $twelve = '979' . str_pad((string) random_int(0, 999999999), 9, '0', STR_PAD_LEFT); + $sum = 0; + for ($i = 0; $i < 12; $i++) { + $sum += (int) $twelve[$i] * ($i % 2 === 0 ? 1 : 3); + } + $isbn = $twelve . ((10 - $sum % 10) % 10); + $mediaTitle = 'ZZMR' . $run . ' media type'; + $counters = []; + $warnings = []; + $created = (new MarcBookImporter($db, MarcBookImporter::MODE_SKIP, false))->import(['titolo' => $mediaTitle, 'isbn13' => $isbn], $counters, $warnings); + $mediaId = (int) ($created['id'] ?? 0); + $mediaRow = static function () use ($db, $mediaId): array { + $stmt = $db->prepare('SELECT tipo_media, formato, prezzo FROM libri WHERE id = ?'); + $stmt->bind_param('i', $mediaId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc() ?: []; + $stmt->close(); + return $row; + }; + $before = $mediaRow(); + $check(($created['action'] ?? '') === 'created' && ($before['tipo_media'] ?? '') === 'libro' && ($before['formato'] ?? '') === 'cartaceo', 'a record with no media type creates a printed book'); + $updated = (new MarcBookImporter($db, MarcBookImporter::MODE_OVERWRITE, false))->import(['titolo' => $mediaTitle, 'isbn13' => $isbn, 'tipo_media' => 'dvd'], $counters, $warnings); + $after = $mediaRow(); + $check(($updated['action'] ?? '') === 'updated' && ($after['tipo_media'] ?? '') === 'dvd', 'overwrite: the DVD record makes the book a DVD'); + $check(($after['formato'] ?? '') === ($before['formato'] ?? '') && ($after['prezzo'] ?? null) === ($before['prezzo'] ?? null), 'overwrite: the format and the price are left as they were'); + (new MarcBookImporter($db, MarcBookImporter::MODE_FILL, false))->import(['titolo' => $mediaTitle, 'isbn13' => $isbn, 'tipo_media' => 'libro'], $counters, $warnings); + $check(($mediaRow()['tipo_media'] ?? '') === 'dvd', 'fill: the media type the book has is left'); } finally { $db->query("DROP TRIGGER IF EXISTS {$trigger}"); $like = 'ZZMR' . $run . '%'; From cbb42779b159efff40d386062788e236e00834a0 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:07:49 +0000 Subject: [PATCH 171/398] User import resolves the patron type by code or name; the export adds the code The users CSV export wrote the patron type name only, while the import resolved the column by code alone: an export moved to another installation with the same types ("Studenti", code "student") reported every type unknown and put each reader on the default type, with its rules and card duration. Now PatronTypeImport resolves a value by code first, then by name (trimmed, case-insensitive, active types only; a name shared by two active types is not guessed), and the export writes the type code in a "Codice tipologia lettore" column right after the name, so a round trip is exact even when two types share a name. The import recognises that column in the five languages and prefers it over the name column of the same file; the translated name headers of the export are recognised too. --- CHANGELOG.md | 4 +- app/Controllers/UsersController.php | 4 ++ app/Support/PatronTypeAssignment.php | 18 +++++- app/Support/PatronTypeImport.php | 78 ++++++++++++++++++-------- app/Support/UserImport.php | 55 ++++++++++++++---- app/Views/utenti/import.php | 2 +- locale/da_DK.json | 5 +- locale/de_DE.json | 5 +- locale/en_US.json | 5 +- locale/fr_FR.json | 5 +- locale/it_IT.json | 5 +- tests/circulation-patron-types.spec.js | 7 +++ tests/patron-cards-import.unit.php | 30 ++++++++++ tests/user-import.spec.js | 16 ++++-- 14 files changed, 184 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd46e170..5a552cd3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,11 +35,11 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets. - **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card. - **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo, also when the account has a loan history and is therefore only suspended. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. -- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column ("Tipologia") assigns the type by its code, in any case; an empty code leaves the reader on the default type, and so does an unknown or deactivated one, reported as a warning with its line. The card expiry follows the user form: when the file gives none, it is computed from the card duration of the assigned patron type (the default type when the row has no type), and an expiry present in the CSV always wins. +- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column ("Tipologia") assigns the type by its code or, failing that, by its name (both in any case; a name shared by two types is not guessed), so the users export of another installation with the same types re-imports cleanly; an empty value leaves the reader on the default type, and so does an unknown or deactivated one, reported as a warning with its line. The card expiry follows the user form: when the file gives none, it is computed from the card duration of the assigned patron type (the default type when the row has no type), and an expiry present in the CSV always wins. ### Circulation policy - **One source for the circulation rules.** Loan duration, request window, renewals and the active-loan limit were read straight from the loans settings in about twenty places (staff desk, reader requests on the website and in the app, approval, queue promotion, renewals, NCIP). They now all go through `App\Services\CirculationPolicy`, which returns immutable `UserPolicy`/`LoanPolicy` objects, and every due date is computed by `CirculationPolicy::dueDateFor()`. With only the global settings the numbers are the same as before (a unit test compares every old read site over a grid of stored values); a CI step rejects any new direct read of those settings. -- **Patron types** (Settings → Circulation). Every reader belongs to a patron type (child, student, teacher…) that decides the circulation rules; the role (`tipo_utente`) keeps deciding only the permissions. A type can set the total active-loan limit, loan duration, longest request, renewals and days per renewal, whether reservations are allowed and how many, and the card duration; an empty field inherits the global loans setting. Types are created, edited, ordered and deleted from the new tab, one of them is the default for patrons without a type. The user forms have a "Tipologia lettore" select, the reader page shows the effective rules, the user list filters by type and the CSV export has the column. Existing users are not migrated: everyone starts on the default "Standard" type, which inherits everything. +- **Patron types** (Settings → Circulation). Every reader belongs to a patron type (child, student, teacher…) that decides the circulation rules; the role (`tipo_utente`) keeps deciding only the permissions. A type can set the total active-loan limit, loan duration, longest request, renewals and days per renewal, whether reservations are allowed and how many, and the card duration; an empty field inherits the global loans setting. Types are created, edited, ordered and deleted from the new tab, one of them is the default for patrons without a type. The user forms have a "Tipologia lettore" select, the reader page shows the effective rules, the user list filters by type and the CSV export has the column, followed by the type's code ("Codice tipologia lettore") so a round trip through the user import is exact even when two types share a name. Existing users are not migrated: everyone starts on the default "Standard" type, which inherits everything. - **Rules matrix patron type × material** (`libri.tipo_media`). A cell can make a material consultation only, cap the loans of that material on top of the total limit, and set its duration, renewals and days per renewal; the matrix shows the inherited value of every empty cell in grey. The book page's renew button and its confirmation say the real renewal length of the loan instead of a fixed "+14 giorni". Per field, the first value wins among rule (type, material) → rule (type, every material) → rule (every type, material) → patron type → global settings. - **Consultation-only copies.** A single copy can be kept for in-house consultation from the book page (`copie.solo_consultazione`): it is never allocated to a loan nor counted as lendable capacity, and a title whose every copy is kept for consultation is consultation only. The book page shows "Solo consultazione" instead of the request button. The availability counter `libri.copie_disponibili` (book page button and badge, catalogue "available" filter, OPAC and mobile availability) is recalculated without the consultation copies, so a title whose only copy on the shelf is a consultation copy offers "Prenota quando disponibile", not "Richiedi prestito"; `copie_totali` keeps counting every physical copy in circulation. Existing installations get the counters recalculated by the upgrade (the Updater re-derives availability after the migrations) or from Maintenance → "Ricalcola disponibilità". - **The same rules on every channel.** Staff desk, reader requests, approval, queue promotion, renewals, the Mobile API and NCIP all refuse with the same codes: `media_limit_reached`, `not_loanable`, `reservations_disabled`, `reservation_limit_reached` (Mobile API `CirculationOutcome`, NCIP *Item Does Not Circulate* / *Maximum Check Outs Exceeded*). `GET /api/v1/catalog/books/{id}/availability` gains `loanable` and `reservations_enabled`. NCIP RequestItem due dates are now capped by the maximum loan duration like every other channel (they used to take the bare loan duration). diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index e63289dc8..d0a7ee305 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -1142,6 +1142,7 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res __('Stato'), __('Tipo Utente'), __('Tipologia lettore'), + __('Codice tipologia lettore'), __('Codice Fiscale'), __('Note Utente'), __('Email Verificata'), @@ -1174,6 +1175,9 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res $utente['stato'] ?? '', $utente['tipo_utente'] ?? '', \App\Support\PatronTypeAssignment::nameOf($db, $utente['tipologia_id'] ?? null), + // The code beside the name: the import resolves by code first, + // so this file re-imports exactly where the same types exist. + \App\Support\PatronTypeAssignment::codeOf($db, $utente['tipologia_id'] ?? null), $utente['cod_fiscale'] ?? '', $utente['note_utente'] ?? '', ($utente['email_verificata'] ?? 0) == 1 ? 'Sì' : 'No', diff --git a/app/Support/PatronTypeAssignment.php b/app/Support/PatronTypeAssignment.php index 921d02fdd..8d48c4ed6 100644 --- a/app/Support/PatronTypeAssignment.php +++ b/app/Support/PatronTypeAssignment.php @@ -104,12 +104,28 @@ public static function filterCondition(mysqli $db, mixed $value, string $column * Display name of a stored patron type (null/unknown = the default type). */ public static function nameOf(mysqli $db, mixed $typeId): string + { + return self::fieldOf($db, $typeId, 'nome'); + } + + /** + * Code of a stored patron type (null/unknown = the default type). The + * users CSV export writes it beside the name: the import resolves a type + * by its code first, so an export re-imports exactly on an installation + * with the same types, even when two of them share a name. + */ + public static function codeOf(mysqli $db, mixed $typeId): string + { + return self::fieldOf($db, $typeId, 'codice'); + } + + private static function fieldOf(mysqli $db, mixed $typeId, string $field): string { $policy = new CirculationPolicy($db); $types = $policy->types(); $id = is_numeric($typeId) ? (int) $typeId : null; $id = $id !== null && isset($types[$id]) ? $id : $policy->defaultTypeId(); - return $id !== null ? (string) $types[$id]['nome'] : ''; + return $id !== null ? (string) ($types[$id][$field] ?? '') : ''; } /** Card validity used by "Rinnova tessera" when the patron type sets none. */ diff --git a/app/Support/PatronTypeImport.php b/app/Support/PatronTypeImport.php index 1438948e6..3fd6c5037 100644 --- a/app/Support/PatronTypeImport.php +++ b/app/Support/PatronTypeImport.php @@ -13,8 +13,14 @@ * against that contract but stands on its own: when the table, its `codice` * column or `utenti.tipologia_id` is missing, available() is false, the import * does not offer the column, and assign() is a no-op. Once the patron types - * schema exists the very same code starts assigning the type by its code, with - * no other change to the import. + * schema exists the very same code starts assigning the type, with no other + * change to the import. + * + * A value of the patron type column resolves by the type's `codice` first, + * then by its `nome` (both trimmed and case-insensitive, active types only): + * the users CSV export writes the name and the code, a hand-made file may + * carry either. A name shared by two active types never resolves, the import + * does not guess between them. * * The table and column names are constructor parameters only so the unit test * can run against sandbox tables. @@ -23,6 +29,8 @@ final class PatronTypeImport { /** @var array|null code (lowercase) => id */ private ?array $codes = null; + /** @var array name (lowercase) => id, null when two active types share the name */ + private array $names = []; private ?bool $available = null; public function __construct( @@ -45,19 +53,19 @@ public function available(): bool } /** - * Assign the patron type with code $code to the user. + * Assign the patron type whose code, or else whose name, is $value to the user. * - * @return 'skipped'|'assigned'|'unknown' skipped = nothing to do (no code, - * or no patron types schema); unknown = the code matches no type, - * so the reader keeps the default type + * @return 'skipped'|'assigned'|'unknown' skipped = nothing to do (no value, + * or no patron types schema); unknown = the value matches no type + * by code or by name, so the reader keeps the default type */ - public function assign(int $userId, ?string $code): string + public function assign(int $userId, ?string $value): string { - $code = trim((string) $code); - if ($code === '' || $userId <= 0 || !$this->available()) { + $value = trim((string) $value); + if ($value === '' || $userId <= 0 || !$this->available()) { return 'skipped'; } - $typeId = $this->idFor($code); + $typeId = $this->idFor($value); if ($typeId === null) { return 'unknown'; } @@ -71,22 +79,46 @@ public function assign(int $userId, ?string $code): string return 'assigned'; } - private function idFor(string $code): ?int + private function idFor(string $value): ?int { - if ($this->codes === null) { - $this->codes = []; - // A deactivated type is not offered in the user form either, so - // its code does not resolve here: the reader keeps the default. - $active = $this->columnExists($this->typesTable, 'attiva') ? ' WHERE attiva = 1' : ''; - $result = $this->db->query("SELECT id, codice FROM `{$this->typesTable}`{$active}"); - if ($result instanceof \mysqli_result) { - while ($row = $result->fetch_assoc()) { - $this->codes[mb_strtolower((string) $row['codice'], 'UTF-8')] = (int) $row['id']; - } - $result->free(); + $this->load(); + $key = self::key($value); + return $this->codes[$key] ?? $this->names[$key] ?? null; + } + + /** Read the active types once: their codes, and their names where the table has them. */ + private function load(): void + { + if ($this->codes !== null) { + return; + } + $this->codes = []; + $this->names = []; + // A deactivated type is not offered in the user form either, so + // neither its code nor its name resolves here: the reader keeps the default. + $active = $this->columnExists($this->typesTable, 'attiva') ? ' WHERE attiva = 1' : ''; + $withName = $this->columnExists($this->typesTable, 'nome'); + $columns = $withName ? 'id, codice, nome' : 'id, codice'; + $result = $this->db->query("SELECT {$columns} FROM `{$this->typesTable}`{$active}"); + if (!($result instanceof \mysqli_result)) { + return; + } + while ($row = $result->fetch_assoc()) { + $id = (int) $row['id']; + $this->codes[self::key((string) $row['codice'])] = $id; + $name = $withName ? self::key((string) $row['nome']) : ''; + if ($name !== '') { + // Two active types with one name: neither is chosen. + $this->names[$name] = array_key_exists($name, $this->names) ? null : $id; } } - return $this->codes[mb_strtolower($code, 'UTF-8')] ?? null; + $result->free(); + } + + /** The lookup key of a code or a name: trimmed, lower case. */ + private static function key(string $value): string + { + return mb_strtolower(trim($value), 'UTF-8'); } private function columnExists(string $table, string $column): bool diff --git a/app/Support/UserImport.php b/app/Support/UserImport.php index cdd7a4081..0d3fbe142 100644 --- a/app/Support/UserImport.php +++ b/app/Support/UserImport.php @@ -57,7 +57,18 @@ final class UserImport 'data_scadenza_tessera' => ['datascadenzatessera', 'scadenzatessera', 'scadenza', 'expiry', 'expirydate', 'cardexpiry', 'expires', 'ablaufdatum', 'gultigbis', 'datedexpiration', 'udlobsdato'], 'note_utente' => ['noteutente', 'note', 'notes', 'notizen', 'noter', 'noteinterne'], 'locale' => ['locale', 'lingua', 'language', 'sprache', 'langue', 'sprog'], - 'tipologia' => ['tipologia', 'tipologialettore', 'patrontype', 'membertype', 'readertype', 'lesertyp', 'typedelecteur', 'lanertype'], + 'tipologia' => ['tipologia', 'tipologialettore', 'patrontype', 'membertype', 'readertype', 'lesertyp', 'leserkategorie', 'typedelecteur', 'categoriedelecteur', 'lanertype', 'lanerkategori'], + ]; + + /** + * Header spellings that name the patron type CODE column of the users CSV + * export ("Codice tipologia lettore" in the five languages). They map to + * `tipologia` like the name column, but guessMapping() prefers them: an + * export carries both columns and must re-import by code, which is exact + * even when two types share a name. + */ + private const PREFERRED_ALIASES = [ + 'tipologia' => ['codicetipologia', 'codicetipologialettore', 'tipologiacodice', 'tipologialettorecodice', 'patrontypecode', 'typecode', 'membertypecode', 'readertypecode', 'lesertypcode', 'codederleserkategorie', 'leserkategoriecode', 'codetypedelecteur', 'codedelacategoriedelecteur', 'lanertypekode', 'lanerkategorikode', 'kodeforlanerkategori'], ]; /** @@ -85,7 +96,7 @@ public static function targetFields(array $customDefinitions, bool $withPatronTy 'locale' => __('Lingua dell\'interfaccia'), ]; if ($withPatronType) { - $fields['tipologia'] = __('Tipologia lettore (codice)'); + $fields['tipologia'] = __('Tipologia lettore (codice o nome)'); } foreach ($customDefinitions as $def) { $fields['custom:' . $def['id']] = $def['etichetta']; @@ -137,7 +148,10 @@ public static function normalizeHeader(string $header): string /** * Suggested mapping: column index => field key ('' = ignore). Each field is - * proposed for one column at most (the first that matches). + * proposed for one column at most: the first that matches, except that a + * PREFERRED_ALIASES spelling wins over any other wherever it stands (the + * users export has the patron type name and then its code; the code is + * the column that re-imports exactly). * * @param list $headers * @param array $targetFields from targetFields() @@ -157,21 +171,38 @@ public static function guessMapping(array $headers, array $targetFields): array // installation carries localized headers). $byAlias[self::normalizeHeader($targetFields[$field])] ??= $field; } + $preferred = []; + foreach (self::PREFERRED_ALIASES as $field => $aliases) { + if (!isset($targetFields[$field])) { + continue; + } + foreach ($aliases as $alias) { + $byAlias[$alias] ??= $field; + if ($byAlias[$alias] === $field) { + $preferred[$alias] = true; + } + } + } foreach ($targetFields as $key => $label) { if (str_starts_with($key, 'custom:')) { $byAlias[self::normalizeHeader($label)] ??= $key; } } - $mapping = []; + $mapping = array_fill_keys(array_keys($headers), ''); $taken = []; - foreach ($headers as $i => $header) { - $norm = self::normalizeHeader((string) $header); - $field = $byAlias[$norm] ?? ''; - if ($field !== '' && !isset($taken[$field])) { - $mapping[$i] = $field; - $taken[$field] = true; - } else { - $mapping[$i] = ''; + // Two passes: the preferred spellings take their field first, then + // the other headers take what is left, in file order. + foreach ([true, false] as $preferredPass) { + foreach ($headers as $i => $header) { + $norm = self::normalizeHeader((string) $header); + if (isset($preferred[$norm]) !== $preferredPass) { + continue; + } + $field = $byAlias[$norm] ?? ''; + if ($field !== '' && !isset($taken[$field])) { + $mapping[$i] = $field; + $taken[$field] = true; + } } } return $mapping; diff --git a/app/Views/utenti/import.php b/app/Views/utenti/import.php index 3452b4e81..205d743aa 100644 --- a/app/Views/utenti/import.php +++ b/app/Views/utenti/import.php @@ -204,7 +204,7 @@
    • -
    • +
    • diff --git a/locale/da_DK.json b/locale/da_DK.json index 5ad96356e..6706a7887 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8719,7 +8719,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "Bruger importeret, men de brugerdefinerede felter blev ikke gemt.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "Bruger importeret, men lånertypen blev ikke tildelt.", "Tessere della biblioteca": "Bibliotekets lånerkort", - "Tipologia lettore (codice)": "Lånertype (kode)", + "Tipologia lettore (codice o nome)": "Lånertype (kode eller navn)", "Mappatura delle colonne non valida.": "Ugyldig kolonnetilknytning.", "Il campo \"%s\" è associato a più di una colonna.": "Feltet \"%s\" er knyttet til mere end én kolonne.", "Associa una colonna al campo obbligatorio \"%s\".": "Knyt en kolonne til det obligatoriske felt \"%s\".", @@ -8787,7 +8787,7 @@ "Senza codice tessera ne viene generato uno nuovo.": "Uden kortkode genereres en ny.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Datoer kan skrives som 2026-12-31, 31/12/2026 eller 31.12.2026.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "De brugerdefinerede registreringsfelter vises blandt de kolonner, der skal tilknyttes.", - "La tipologia lettore si indica con il suo codice.": "Lånertypen angives med sin kode.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "Lånertypen angives med sin kode eller sit navn.", "Le password non vengono mai importate.": "Adgangskoder importeres aldrig.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "Oversigten over hver import, med fejl til download, gemmes i", "storico import": "importhistorikken", @@ -8797,6 +8797,7 @@ "Il lettore ha raggiunto il numero massimo di prenotazioni.": "Låneren har nået det maksimale antal reserveringer.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Beregn udløbsdatoen ud fra lånerkategoriens kortvarighed (hvis angivet)", "Tipologia lettore": "Lånerkategori", + "Codice tipologia lettore": "Lånerkategorikode", "Solo consultazione": "Kun til brug på stedet", "Rendi prestabile": "Gør udlånbar", "Riserva alla consultazione": "Reserver til brug på stedet", diff --git a/locale/de_DE.json b/locale/de_DE.json index 893cddcba..ff788822f 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8719,7 +8719,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "Benutzer importiert, aber die benutzerdefinierten Felder wurden nicht gespeichert.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "Benutzer importiert, aber der Lesertyp wurde nicht zugewiesen.", "Tessere della biblioteca": "Leserausweise der Bibliothek", - "Tipologia lettore (codice)": "Lesertyp (Code)", + "Tipologia lettore (codice o nome)": "Lesertyp (Code oder Name)", "Mappatura delle colonne non valida.": "Ungültige Spaltenzuordnung.", "Il campo \"%s\" è associato a più di una colonna.": "Das Feld \"%s\" ist mehr als einer Spalte zugeordnet.", "Associa una colonna al campo obbligatorio \"%s\".": "Ordnen Sie dem Pflichtfeld \"%s\" eine Spalte zu.", @@ -8787,7 +8787,7 @@ "Senza codice tessera ne viene generato uno nuovo.": "Ohne Ausweisnummer wird eine neue erzeugt.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Datumsangaben können als 2026-12-31, 31/12/2026 oder 31.12.2026 geschrieben werden.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "Die benutzerdefinierten Registrierungsfelder erscheinen unter den zuzuordnenden Spalten.", - "La tipologia lettore si indica con il suo codice.": "Der Lesertyp wird über seinen Code angegeben.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "Der Lesertyp wird über seinen Code oder seinen Namen angegeben.", "Le password non vengono mai importate.": "Passwörter werden nie importiert.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "Die Zusammenfassung jedes Imports mit herunterladbaren Fehlern bleibt im", "storico import": "Importverlauf", @@ -8797,6 +8797,7 @@ "Il lettore ha raggiunto il numero massimo di prenotazioni.": "Die Person hat die Höchstzahl an Vormerkungen erreicht.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Ablaufdatum aus der Ausweisdauer der Leserkategorie berechnen (falls angegeben)", "Tipologia lettore": "Leserkategorie", + "Codice tipologia lettore": "Code der Leserkategorie", "Solo consultazione": "Nur Einsichtnahme", "Rendi prestabile": "Ausleihbar machen", "Riserva alla consultazione": "Nur zur Einsichtnahme vorbehalten", diff --git a/locale/en_US.json b/locale/en_US.json index 634309221..5213f3aaa 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8719,7 +8719,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "User imported, but the custom fields were not saved.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "User imported, but the patron type was not assigned.", "Tessere della biblioteca": "Library cards", - "Tipologia lettore (codice)": "Patron type (code)", + "Tipologia lettore (codice o nome)": "Patron type (code or name)", "Mappatura delle colonne non valida.": "Invalid column mapping.", "Il campo \"%s\" è associato a più di una colonna.": "The field \"%s\" is mapped to more than one column.", "Associa una colonna al campo obbligatorio \"%s\".": "Map a column to the required field \"%s\".", @@ -8787,7 +8787,7 @@ "Senza codice tessera ne viene generato uno nuovo.": "Without a card code a new one is generated.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Dates can be written as 2026-12-31, 31/12/2026 or 31.12.2026.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "The custom registration fields appear among the columns to map.", - "La tipologia lettore si indica con il suo codice.": "The patron type is given by its code.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "The patron type is given by its code or by its name.", "Le password non vengono mai importate.": "Passwords are never imported.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "The summary of every import, with downloadable errors, is kept in the", "storico import": "import history", @@ -8797,6 +8797,7 @@ "Il lettore ha raggiunto il numero massimo di prenotazioni.": "The patron has reached the maximum number of reservations.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Compute the expiry from the patron type's card duration (if set)", "Tipologia lettore": "Patron type", + "Codice tipologia lettore": "Patron type code", "Solo consultazione": "Consultation only", "Rendi prestabile": "Make lendable", "Riserva alla consultazione": "Keep for consultation only", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index dda1f8204..b2384eb61 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8719,7 +8719,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "Utilisateur importé, mais les champs personnalisés n'ont pas été enregistrés.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "Utilisateur importé, mais le type de lecteur n'a pas été attribué.", "Tessere della biblioteca": "Cartes de la bibliothèque", - "Tipologia lettore (codice)": "Type de lecteur (code)", + "Tipologia lettore (codice o nome)": "Type de lecteur (code ou nom)", "Mappatura delle colonne non valida.": "Correspondance des colonnes non valide.", "Il campo \"%s\" è associato a più di una colonna.": "Le champ \"%s\" est associé à plusieurs colonnes.", "Associa una colonna al campo obbligatorio \"%s\".": "Associez une colonne au champ obligatoire \"%s\".", @@ -8787,7 +8787,7 @@ "Senza codice tessera ne viene generato uno nuovo.": "Sans code de carte, un nouveau est généré.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Les dates peuvent s'écrire 2026-12-31, 31/12/2026 ou 31.12.2026.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "Les champs d'inscription personnalisés figurent parmi les colonnes à associer.", - "La tipologia lettore si indica con il suo codice.": "Le type de lecteur s'indique par son code.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "Le type de lecteur s'indique par son code ou par son nom.", "Le password non vengono mai importate.": "Les mots de passe ne sont jamais importés.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "Le récapitulatif de chaque import, avec les erreurs à télécharger, reste dans l'", "storico import": "historique des imports", @@ -8797,6 +8797,7 @@ "Il lettore ha raggiunto il numero massimo di prenotazioni.": "Le lecteur a atteint le nombre maximal de réservations.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Calculer l'échéance à partir de la durée de carte de la catégorie de lecteur (si indiquée)", "Tipologia lettore": "Catégorie de lecteur", + "Codice tipologia lettore": "Code de la catégorie de lecteur", "Solo consultazione": "Consultation sur place", "Rendi prestabile": "Rendre empruntable", "Riserva alla consultazione": "Réserver à la consultation sur place", diff --git a/locale/it_IT.json b/locale/it_IT.json index 70771010b..2375b30ac 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8719,7 +8719,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "Utente importato, ma i campi personalizzati non sono stati salvati.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "Utente importato, ma la tipologia lettore non è stata assegnata.", "Tessere della biblioteca": "Tessere della biblioteca", - "Tipologia lettore (codice)": "Tipologia lettore (codice)", + "Tipologia lettore (codice o nome)": "Tipologia lettore (codice o nome)", "Mappatura delle colonne non valida.": "Mappatura delle colonne non valida.", "Il campo \"%s\" è associato a più di una colonna.": "Il campo \"%s\" è associato a più di una colonna.", "Associa una colonna al campo obbligatorio \"%s\".": "Associa una colonna al campo obbligatorio \"%s\".", @@ -8787,7 +8787,7 @@ "Senza codice tessera ne viene generato uno nuovo.": "Senza codice tessera ne viene generato uno nuovo.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "I campi personalizzati della registrazione compaiono tra le colonne da associare.", - "La tipologia lettore si indica con il suo codice.": "La tipologia lettore si indica con il suo codice.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "La tipologia lettore si indica con il suo codice o con il suo nome.", "Le password non vengono mai importate.": "Le password non vengono mai importate.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "Il riepilogo di ogni import, con gli errori da scaricare, resta nello", "storico import": "storico import", @@ -8797,6 +8797,7 @@ "Il lettore ha raggiunto il numero massimo di prenotazioni.": "Il lettore ha raggiunto il numero massimo di prenotazioni.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)", "Tipologia lettore": "Tipologia lettore", + "Codice tipologia lettore": "Codice tipologia lettore", "Solo consultazione": "Solo consultazione", "Rendi prestabile": "Rendi prestabile", "Riserva alla consultazione": "Riserva alla consultazione", diff --git a/tests/circulation-patron-types.spec.js b/tests/circulation-patron-types.spec.js index 961d73eb6..efb546739 100644 --- a/tests/circulation-patron-types.spec.js +++ b/tests/circulation-patron-types.spec.js @@ -127,6 +127,13 @@ test.describe.serial('Patron types CRUD', () => { expect(text).toMatch(/Tipologia lettore|Patron type/); expect(text).toContain(patron.email); expect(text).toContain(`Studenti ${RUN}`); + // The type code stands right after the name, so the file re-imports by code. + const header = text.replace(/^\uFEFF/, '').split(/\r?\n/)[0].split(';').map((h) => h.replace(/^"|"$/g, '')); + const nameCol = header.findIndex((h) => /^(Tipologia lettore|Patron type)$/.test(h)); + expect(nameCol).toBeGreaterThan(0); + expect(header[nameCol + 1]).toMatch(/^(Codice tipologia lettore|Patron type code)$/); + const row = text.split(/\r?\n/).find((l) => l.includes(patron.email)) || ''; + expect(row.split(';')[nameCol + 1].replace(/^"|"$/g, '')).toBe(`${PREFIX}-stud`); }); test('deleting a type moves its patrons back to the default type', async () => { diff --git a/tests/patron-cards-import.unit.php b/tests/patron-cards-import.unit.php index acd8a4733..d203cc4a7 100644 --- a/tests/patron-cards-import.unit.php +++ b/tests/patron-cards-import.unit.php @@ -48,6 +48,13 @@ $check($guess === [0 => 'nome', 1 => 'cognome', 2 => 'email', 3 => 'data_nascita', 4 => 'sesso'], 'German headers are recognised'); $withTypes = UserImport::targetFields([], true); $check(UserImport::guessMapping(['Patron type'], $withTypes) === [0 => 'tipologia'], 'a patron type column is recognised once the schema exists'); +$check(UserImport::guessMapping(['Leserkategorie'], $withTypes) === [0 => 'tipologia'] && UserImport::guessMapping(['Catégorie de lecteur'], $withTypes) === [0 => 'tipologia'] && UserImport::guessMapping(['Lånerkategori'], $withTypes) === [0 => 'tipologia'], 'the translated name column of the users export is recognised'); +// The users export carries the type name and then its code: the code column +// is the one mapped, wherever it stands, since it re-imports exactly. +$check(UserImport::guessMapping(['Tipologia lettore', 'Codice tipologia lettore'], $withTypes) === [0 => '', 1 => 'tipologia'], 'the patron type code column wins over the name column that precedes it'); +$check(UserImport::guessMapping(['Patron type code', 'Patron type'], $withTypes) === [0 => 'tipologia', 1 => ''], 'the patron type code column wins when it comes first too'); +$check(UserImport::guessMapping(['Code der Leserkategorie'], $withTypes) === [0 => 'tipologia'] && UserImport::guessMapping(['Code de la catégorie de lecteur'], $withTypes) === [0 => 'tipologia'] && UserImport::guessMapping(['Lånerkategorikode'], $withTypes) === [0 => 'tipologia'], 'the translated code column of the users export is recognised'); +$check(UserImport::guessMapping(['Codice tipologia lettore'], UserImport::targetFields([], false)) === [0 => ''], 'the code column is not offered while the patron types schema is missing'); $v = UserImport::validateMapping(['0' => 'nome', '2' => 'email', '1' => ''], $fields, 3); $check($v['error'] === null && $v['mapping'] === [0 => 'nome', 2 => 'email'], 'a valid mapping is kept, ignored columns dropped'); @@ -144,6 +151,29 @@ $db->query("INSERT INTO {$types} (codice, attiva) VALUES ('ritirata', 0)"); $check((new PatronTypeImport($db, $types, $users))->assign(1, 'ritirata') === 'unknown' && (int) $db->query("SELECT tipologia_id FROM {$users} WHERE id = 1")->fetch_row()[0] === $studentId, 'a deactivated type is not assigned, the reader keeps its type'); + // With a name column the value resolves by code first, then by name. + $db->query("ALTER TABLE {$types} ADD COLUMN nome VARCHAR(100) NOT NULL DEFAULT ''"); + $db->query("UPDATE {$types} SET nome = CASE codice WHEN 'standard' THEN 'Standard' WHEN 'studente' THEN 'Studenti' ELSE 'Ritirata' END"); + $standardId = (int) $db->query("SELECT id FROM {$types} WHERE codice = 'standard'")->fetch_row()[0]; + $typeOfReader = static fn (): int => (int) $db->query("SELECT tipologia_id FROM {$users} WHERE id = 1")->fetch_row()[0]; + $byName = new PatronTypeImport($db, $types, $users); + $check($byName->assign(1, 'Standard') === 'assigned' && $typeOfReader() === $standardId, 'the type name is resolved when the code does not match'); + $check($byName->assign(1, ' STUDENTI ') === 'assigned' && $typeOfReader() === $studentId, 'the name is matched trimmed and case-insensitively'); + $check($byName->assign(1, 'standard') === 'assigned' && $typeOfReader() === $standardId && $byName->assign(1, 'STUDENTE') === 'assigned' && $typeOfReader() === $studentId, 'the code keeps resolving on the same instance'); + $check($byName->assign(1, 'Ritirata') === 'unknown' && $typeOfReader() === $studentId, 'the name of a deactivated type does not resolve either'); + $check($byName->assign(1, 'docenti') === 'unknown' && $typeOfReader() === $studentId, 'a value that is neither a code nor a name is still unknown'); + // A type named like another type's code: the code wins. + $db->query("INSERT INTO {$types} (codice, nome) VALUES ('prof', 'Studente')"); + $profId = (int) $db->query("SELECT id FROM {$types} WHERE codice = 'prof'")->fetch_row()[0]; + $codeFirst = new PatronTypeImport($db, $types, $users); + $check($codeFirst->assign(1, 'Studente') === 'assigned' && $typeOfReader() === $studentId, 'a value matching one type by code and another by name goes by the code'); + $check($codeFirst->assign(1, 'prof') === 'assigned' && $typeOfReader() === $profId, 'the type named like a code is still reachable by its own code'); + // Two active types with one name: the import does not guess between them. + $db->query("INSERT INTO {$types} (codice, nome) VALUES ('ext', 'Studenti')"); + $shared = new PatronTypeImport($db, $types, $users); + $check($shared->assign(1, 'Studenti') === 'unknown' && $typeOfReader() === $profId, 'a name shared by two active types resolves neither'); + $check($shared->assign(1, 'ext') === 'assigned' && $typeOfReader() !== $studentId && $typeOfReader() !== $profId, 'each of them is still reachable by code'); + // Patron photo against one scratch reader. $email = 'zz-pci-' . $suffix . '@example.invalid'; $code = 'ZZPCI' . strtoupper($suffix); diff --git a/tests/user-import.spec.js b/tests/user-import.spec.js index 633a7a9a8..b20b7f1b0 100644 --- a/tests/user-import.spec.js +++ b/tests/user-import.spec.js @@ -72,7 +72,8 @@ const MANUAL_EXPIRY = '2031-12-31'; /** * 20 data rows: 17 valid, a duplicate email (line 6), a duplicate card code (line 13), a bad email (line 17). - * Patron type: readers 1-15 carry TYPE_CODE (upper case for the even ones), 16 none, 17 (line 21) an unknown code. + * Patron type: readers 1-15 carry the type, as its name (every third one, TYPE_NAME) or as TYPE_CODE + * (upper case for the even ones, lower case otherwise); 16 none, 17 (line 21) an unknown value. * Card expiry: only reader 3 carries one (MANUAL_EXPIRY); every other row leaves the column empty. */ function buildCsv() { @@ -85,7 +86,7 @@ function buildCsv() { if (line === 17) { lines.push(['Sbagliata', 'Riga', 'not-an-email', '', '', '', SECRET, '', '', ''].join(';')); continue; } n++; const code = n % 2 === 0 ? `ZIM${RUN_UP}${n}` : ''; - const type = n === 17 ? UNKNOWN_TYPE : n === 16 ? '' : (n % 2 === 0 ? TYPE_CODE.toUpperCase() : TYPE_CODE); + const type = n === 17 ? UNKNOWN_TYPE : n === 16 ? '' : n % 3 === 0 ? TYPE_NAME : (n % 2 === 0 ? TYPE_CODE.toUpperCase() : TYPE_CODE); const expiry = n === 3 ? MANUAL_EXPIRY : ''; lines.push([`Imp${n}`, `Run${RUN}`, `imp${n}.${RUN}@${DOMAIN}`, `333000${String(n).padStart(4, '0')}`, `${String(n).padStart(2, '0')}/03/1990`, code, SECRET, `@imp${n}`, type, expiry].join(';')); } @@ -193,7 +194,7 @@ test.describe.serial('User import from CSV (phase 7)', () => { await expect(select(6)).toHaveValue(''); // Password: never mapped await expect(select(6).locator('option[value="password"]')).toHaveCount(0); await expect(select(7)).toHaveValue(`custom:${customId}`); // custom registration field - // Patron types exist: the "Tipologia" header is recognised as the patron type code. + // Patron types exist: the "Tipologia" header is recognised as the patron type (code or name). await expect(select(8).locator('option[value="tipologia"]')).toHaveCount(1); await expect(select(8)).toHaveValue('tipologia'); await expect(select(9)).toHaveValue('data_scadenza_tessera'); @@ -223,7 +224,7 @@ test.describe.serial('User import from CSV (phase 7)', () => { await expect(page.locator('#user-import-errors tr[data-import-error="duplicate"]')).toHaveCount(2); await expect(page.locator('#user-import-errors tr[data-import-error="validation"]')).toHaveCount(1); await expect(page.locator('#user-import-errors tr[data-import-error="validation"]')).toContainText('17'); - // The unknown patron type code is a warning: the reader is created with the default type. + // The unknown patron type (neither a code nor a name) is a warning: the reader is created with the default type. const warning = page.locator('#user-import-errors tr[data-import-error="warning"]'); await expect(warning).toHaveCount(1); await expect(warning).toContainText('21'); @@ -279,9 +280,12 @@ test.describe.serial('User import from CSV (phase 7)', () => { expect(csv).toContain(EXISTING_CODE); }); - test('the patron type column assigns the type by code, case-insensitively; empty and unknown codes keep the default', async () => { - // Readers 1-15: the code in lower (odd) and upper (even) case both resolve to the type. + test('the patron type column assigns the type by code or by name, case-insensitively; empty and unknown values keep the default', async () => { + // Readers 1-15: the code in lower (odd) and upper (even) case, and the name (every third), all resolve to the type. expect(dbQuery(`SELECT COUNT(*) FROM utenti WHERE email LIKE ${sqlStr(`imp%.${RUN}@${DOMAIN}`)} AND tipologia_id = ${typeId}`)).toBe('15'); + for (const n of [3, 6, 15]) { + expect(dbQuery(`SELECT COALESCE(tipologia_id, 'NULL') FROM utenti WHERE email = ${sqlStr(`imp${n}.${RUN}@${DOMAIN}`)}`), `reader ${n} by name`).toBe(String(typeId)); + } expect(dbQuery(`SELECT COALESCE(tipologia_id, 'NULL') FROM utenti WHERE email = ${sqlStr(`imp16.${RUN}@${DOMAIN}`)}`)).toBe('NULL'); expect(dbQuery(`SELECT COALESCE(tipologia_id, 'NULL') FROM utenti WHERE email = ${sqlStr(`imp17.${RUN}@${DOMAIN}`)}`)).toBe('NULL'); expect(dbQuery(`SELECT COALESCE(tipologia_id, 'NULL') FROM utenti WHERE id = ${existingId}`)).toBe('NULL'); From afe49d105ba3167821edb0da48a3386312f667cb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:09:26 +0000 Subject: [PATCH 172/398] Stocktake: freeze the report of a closed session at close summary(), reportRows() and the exports built on them read the current state of copies and loans through onLoanSql(), so the report of a closed session kept changing: a copy unscanned and lent at close counted as on loan, then as missing once the loan was returned, and a copy lent after the close went the other way. close() now writes each expected copy's bucket into inventario_attese.esito_chiusura right after it claims the session, in one UPDATE ... JOIN: the scan outcome for a scanned copy (one scan per copy by the UNIQUE key), else on loan or missing as of that instant. The copies a mark-lost close actually turns into 'perso' are then stamped 'persa'. Once a closed session has no NULL outcome, summary() and reportRows() read the frozen column; open sessions, cancelled ones and sessions closed before the column existed keep the live computation. 'persa' is counted among the missing, as the close message does, and summary() gains a 'perse' key with that part; the CSV and PDF columns are unchanged. The snapshot test checks the frozen buckets after both close paths, then returns a hold, lends a missing copy and finds a lost one and asserts the summary and report rows of the closed session do not move. --- CHANGELOG.md | 4 +- app/Services/StocktakeService.php | 201 ++++++++++++++++++++++++------ tests/stocktake-snapshot.unit.php | 25 +++- 3 files changed, 187 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa28bf45e..9ee9e63fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,13 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Stocktake sessions** (Admin → Revisione inventariale, `/admin/inventory`). A session is opened with a name, an optional branch and a filter (bookcase, shelf level, genre with its subgenres, media type, collocation range). Opening it freezes the list of expected copies in `inventario_attese`, books removed from the catalogue excluded, so moving a book, adding or deleting a copy while the stocktake runs does not change what the session looks for. Copies that are not on any shelf when the session opens (lost, damaged, being restored, in transit, in maintenance) are left out of the expected set, so they are not reported missing for the whole session; lent copies and copies waiting for pickup stay in, with a bucket of their own. - **Scanning** works with keyboard-wedge barcode readers (the code field keeps the focus, Enter records), with the self-hosted camera scanner already used at the loan desk, and with an uploaded `.txt` (one code per line) or `.csv` (first column) list. Each read is classified as found, misplaced (found on a bookcase other than the expected one, as declared by "Scaffale in cui stai lavorando"), on loan or awaiting pickup, unknown code, out of scope or repeated, with its own colour and sound. Several operators can scan the same session at once: the counters refresh every few seconds and "Solo le mie scansioni" narrows the list to one's own reads. -- **Live report**: expected copies always add up as found + misplaced + on loan (lent, or set aside on the pickup shelf for a reader) + missing; unknown, out-of-scope and repeated reads are counted on the side. PDF and CSV reports are available while the session is open and after it ends. +- **Live report**: expected copies always add up as found + misplaced + on loan (lent, or set aside on the pickup shelf for a reader) + missing; unknown, out-of-scope and repeated reads are counted on the side. PDF and CSV reports are available while the session is open and after it ends. The report of a closed session is frozen at close: the bucket of every expected copy is written to `inventario_attese.esito_chiusura` (found, misplaced, on loan, missing, or lost when the close marked it so), and a loan or a return after the close no longer moves a copy between buckets in the page, the counters or the exports. - **Closing** either leaves the missing copies as they are or marks them `perso`. Marking them lost is an administrator action, checked on the server: Staff and custom roles can scan and close a session, but not declare copies lost. Copies on loan or waiting to be picked up (by their state or by an active loan row), damaged, being restored, in transit, in maintenance or already lost are never touched; holds on a copy declared lost move to another copy as with a manual change of state; book availability is recalculated through `DataIntegrity`, and each copy gets a "Copia dichiarata persa in revisione inventariale" entry in the book's activity log. A session can also be cancelled without changing any copy. - **Permission** `inventory.manage` for custom roles, and a new starting profile "Revisore inventario" (backend access + stocktakes). Staff and administrators have it already. ### Migration -- `migrate_0.9.0.sql`, section "Inventory stocktake": creates `inventario_sessioni`, `inventario_attese` and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes. +- `migrate_0.9.0.sql`, section "Inventory stocktake": creates `inventario_sessioni`, `inventario_attese` (with `esito_chiusura`, the bucket frozen at close; added with a guard to a table an earlier build created) and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes. ## [0.8.9] diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index 8b5938a7e..076b377c8 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -21,10 +21,18 @@ * found, misplaced, on loan (lent, or waiting on the pickup shelf) or missing. * Unknown codes, copies outside the filter and repeated scans are counted on * the side. + * + * While the session is open the bucket of an unscanned copy follows the loan + * desk (a return makes it missing, a loan makes it on loan). Closing writes + * the bucket of every expected copy into inventario_attese.esito_chiusura, + * and from then on the report, its exports and the counters read that column: + * what the stocktake found is what it keeps saying. */ final class StocktakeService { public const OUTCOMES = ['trovata', 'fuori_posto', 'in_prestito', 'sconosciuta', 'duplicata', 'fuori_ambito']; + /** inventario_attese.esito_chiusura: the bucket of an expected copy, frozen at close. */ + public const CLOSE_OUTCOMES = ['trovata', 'fuori_posto', 'in_prestito', 'mancante', 'persa']; public const MEDIA_TYPES = ['libro', 'disco', 'audiolibro', 'dvd', 'altro']; public const MAX_CODE_LENGTH = 100; public const MAX_UPLOAD_CODES = 5000; @@ -607,10 +615,33 @@ public function shelfLabel(int $shelfId): string } /** - * Live counters. The four buckets of the expected copies always add up to - * the snapshot size: found + misplaced + on loan + missing = expected. + * A closed session reports the buckets frozen at close. A session closed + * before esito_chiusura existed still has NULL rows and keeps the live + * computation, as does an open or cancelled one. + */ + private function isFrozen(int $sessionId): bool + { + $stmt = $this->db->prepare( + "SELECT 1 FROM inventario_sessioni s WHERE s.id = ? AND s.stato <> 'aperta' " + . 'AND NOT EXISTS (SELECT 1 FROM inventario_attese a WHERE a.sessione_id = s.id AND a.esito_chiusura IS NULL)' + ); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('i', $sessionId); + $stmt->execute(); + $frozen = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $frozen; + } + + /** + * Counters. The four buckets of the expected copies always add up to the + * snapshot size: found + misplaced + on loan + missing = expected. They + * are live while the session is open and frozen once it is closed; `perse` + * is the part of `mancanti` the close marked lost (0 while open). * - * @return array{attese:int,trovate:int,fuori_posto:int,in_prestito:int,mancanti:int,sconosciute:int,fuori_ambito:int,duplicate:int,scansioni:int,mie:int} + * @return array{attese:int,trovate:int,fuori_posto:int,in_prestito:int,mancanti:int,perse:int,sconosciute:int,fuori_ambito:int,duplicate:int,scansioni:int,mie:int} */ public function summary(int $sessionId, ?int $operatorId = null): array { @@ -629,33 +660,60 @@ public function summary(int $sessionId, ?int $operatorId = null): array } $stmt->close(); - // Counted in SQL: the scan page asks for this on every scan and every - // poll, so it must not pull the unscanned rows into PHP. A deleted copy - // (c.stato NULL) is not on loan and stays among the missing ones. - $stmt = $this->db->prepare( - 'SELECT COUNT(*) AS n, COALESCE(SUM(t.in_prestito), 0) AS lent FROM (' - . 'SELECT COALESCE(' . self::onLoanSql() . ', 0) AS in_prestito ' - . 'FROM inventario_attese a ' - . 'LEFT JOIN inventario_scansioni s ON s.sessione_id = a.sessione_id AND s.copia_id = a.copia_id ' - . 'LEFT JOIN copie c ON c.id = a.copia_id ' - . 'WHERE a.sessione_id = ? AND s.id IS NULL) t' - ); - if ($stmt === false) { - throw new \RuntimeException($this->db->error); + if ($this->isFrozen($sessionId)) { + $frozen = array_fill_keys(self::CLOSE_OUTCOMES, 0); + $stmt = $this->db->prepare('SELECT esito_chiusura, COUNT(*) AS n FROM inventario_attese WHERE sessione_id = ? GROUP BY esito_chiusura'); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('i', $sessionId); + $stmt->execute(); + foreach ($stmt->get_result()->fetch_all(MYSQLI_ASSOC) as $row) { + $outcome = (string) $row['esito_chiusura']; + if (isset($frozen[$outcome])) { + $frozen[$outcome] = (int) $row['n']; + } + } + $stmt->close(); + $found = $frozen['trovata']; + $misplaced = $frozen['fuori_posto']; + $onLoan = $frozen['in_prestito']; + $lost = $frozen['persa']; + $missing = $frozen['mancante'] + $lost; + } else { + // Counted in SQL: the scan page asks for this on every scan and every + // poll, so it must not pull the unscanned rows into PHP. A deleted copy + // (c.stato NULL) is not on loan and stays among the missing ones. + $stmt = $this->db->prepare( + 'SELECT COUNT(*) AS n, COALESCE(SUM(t.in_prestito), 0) AS lent FROM (' + . 'SELECT COALESCE(' . self::onLoanSql() . ', 0) AS in_prestito ' + . 'FROM inventario_attese a ' + . 'LEFT JOIN inventario_scansioni s ON s.sessione_id = a.sessione_id AND s.copia_id = a.copia_id ' + . 'LEFT JOIN copie c ON c.id = a.copia_id ' + . 'WHERE a.sessione_id = ? AND s.id IS NULL) t' + ); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('i', $sessionId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + $unscannedOnLoan = (int) ($row['lent'] ?? 0); + $found = $counts['trovata']; + $misplaced = $counts['fuori_posto']; + $onLoan = $counts['in_prestito'] + $unscannedOnLoan; + $lost = 0; + $missing = (int) ($row['n'] ?? 0) - $unscannedOnLoan; } - $stmt->bind_param('i', $sessionId); - $stmt->execute(); - $row = $stmt->get_result()->fetch_assoc(); - $stmt->close(); - $unscannedOnLoan = (int) ($row['lent'] ?? 0); - $missing = (int) ($row['n'] ?? 0) - $unscannedOnLoan; return [ 'attese' => $this->expectedCount($sessionId), - 'trovate' => $counts['trovata'], - 'fuori_posto' => $counts['fuori_posto'], - 'in_prestito' => $counts['in_prestito'] + $unscannedOnLoan, + 'trovate' => $found, + 'fuori_posto' => $misplaced, + 'in_prestito' => $onLoan, 'mancanti' => $missing, + 'perse' => $lost, 'sconosciute' => $counts['sconosciuta'], 'fuori_ambito' => $counts['fuori_ambito'], 'duplicate' => $counts['duplicata'], @@ -665,16 +723,18 @@ public function summary(int $sessionId, ?int $operatorId = null): array } /** - * Expected copies nobody scanned, with their current loan status. + * Expected copies nobody scanned. `in_prestito` is their loan status right + * now, or with $frozen the one recorded at close (esito_chiusura); every + * other column is the copy as it is today. * * @return list> */ - private function unscannedExpected(int $sessionId): array + private function unscannedExpected(int $sessionId, bool $frozen = false): array { $stmt = $this->db->prepare( - 'SELECT a.copia_id, a.scaffale_atteso, a.stato_all_apertura, c.numero_inventario, c.stato AS stato_attuale, c.libro_id, ' + 'SELECT a.copia_id, a.scaffale_atteso, a.stato_all_apertura, a.esito_chiusura, c.numero_inventario, c.stato AS stato_attuale, c.libro_id, ' . 'l.titolo, l.collocazione, ' - . self::onLoanSql() . ' AS in_prestito ' + . ($frozen ? "(a.esito_chiusura = 'in_prestito')" : self::onLoanSql()) . ' AS in_prestito ' . 'FROM inventario_attese a ' . 'LEFT JOIN inventario_scansioni s ON s.sessione_id = a.sessione_id AND s.copia_id = a.copia_id ' . 'LEFT JOIN copie c ON c.id = a.copia_id ' @@ -696,13 +756,16 @@ private function unscannedExpected(int $sessionId): array /** * Full report rows: every expected copy with its bucket, plus the scans * that are not about an expected copy (unknown, out of scope, duplicate). + * On a closed session the bucket is the one frozen at close; a copy the + * close marked lost stays a `mancante` row, with `stato_copia` saying + * 'perso'. `stato_copia` is always the copy's state today. * * @return list */ public function reportRows(int $sessionId): array { $rows = []; - foreach ($this->unscannedExpected($sessionId) as $row) { + foreach ($this->unscannedExpected($sessionId, $this->isFrozen($sessionId)) as $row) { $rows[] = [ 'categoria' => (int) $row['in_prestito'] === 1 ? 'in_prestito' : 'mancante', 'codice' => (string) ($row['numero_inventario'] ?? ('#' . (int) $row['copia_id'])), @@ -765,11 +828,13 @@ public function scans(int $sessionId, ?int $operatorId = null, int $limit = 200) } /** - * Close the session. With $markLost every missing copy that is not already - * explained by its state (lent or waiting for pickup, damaged, lost, at the - * restorer's, in transit, in maintenance) becomes 'perso', one book per transaction, with the same - * hold reassignment and availability recalculation as a manual change of - * a copy's state, and an ActivityLog entry per copy. + * Close the session: claim it, freeze the bucket of every expected copy + * in inventario_attese.esito_chiusura, then with $markLost turn every + * missing copy that is not already explained by its state (lent or waiting + * for pickup, damaged, lost, at the restorer's, in transit, in maintenance) + * into 'perso', one book per transaction, with the same hold reassignment + * and availability recalculation as a manual change of a copy's state and + * an ActivityLog entry per copy. Those copies are frozen as 'persa'. * * @return array{marked:int,failed:int} */ @@ -779,12 +844,17 @@ public function close(int $sessionId, bool $markLost, ?int $operatorId): array // concurrent cancel or second close cannot also succeed. Marking copies // lost reads inventario_scansioni, not the session state. $this->claimTransition($sessionId, 'chiusa', $operatorId); + // The scans are final now, and no loan desk event after this instant + // may move a copy between buckets: the report of a closed session is + // what it was at close. + $this->freezeOutcomes($sessionId); $marked = 0; $failed = 0; if ($markLost) { $byBook = []; - foreach ($this->unscannedExpected($sessionId) as $row) { + $lost = []; + foreach ($this->unscannedExpected($sessionId, true) as $row) { if ((int) $row['in_prestito'] === 1 || $row['libro_id'] === null || $row['titolo'] === null) { continue; // lent or held for pickup, or the copy/book left the catalogue meanwhile } @@ -795,7 +865,9 @@ public function close(int $sessionId, bool $markLost, ?int $operatorId): array } foreach ($byBook as $bookId => $copies) { try { - $marked += $this->markBookCopiesLost($bookId, $copies, $sessionId, $operatorId); + $lostInBook = $this->markBookCopiesLost($bookId, $copies, $sessionId, $operatorId); + $marked += count($lostInBook); + $lost = array_merge($lost, $lostInBook); } catch (\Throwable $e) { $failed += count($copies); SecureLogger::error('[Stocktake] marking missing copies as lost failed', [ @@ -805,6 +877,7 @@ public function close(int $sessionId, bool $markLost, ?int $operatorId): array ]); } } + $this->markFrozenLost($sessionId, $lost); } $summary = $this->summary($sessionId); @@ -818,10 +891,58 @@ public function close(int $sessionId, bool $markLost, ?int $operatorId): array return ['marked' => $marked, 'failed' => $failed]; } + /** + * Write each expected copy's bucket into esito_chiusura in one statement: + * the scan outcome for a scanned copy (one scan per copy: UNIQUE + * sessione_id, copia_id), else on loan or missing as of this instant. A + * copy that no longer exists (c.stato NULL) is missing. close() marks the + * copies it declares lost afterwards. + */ + private function freezeOutcomes(int $sessionId): void + { + $stmt = $this->db->prepare( + 'UPDATE inventario_attese a ' + . 'LEFT JOIN inventario_scansioni s ON s.sessione_id = a.sessione_id AND s.copia_id = a.copia_id ' + . 'LEFT JOIN copie c ON c.id = a.copia_id ' + . 'SET a.esito_chiusura = CASE ' + . "WHEN s.esito IN ('trovata','fuori_posto','in_prestito') THEN s.esito " + . 'WHEN COALESCE(' . self::onLoanSql() . ", 0) = 1 THEN 'in_prestito' " + . "ELSE 'mancante' END " + . 'WHERE a.sessione_id = ?' + ); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('i', $sessionId); + $stmt->execute(); + $stmt->close(); + } + + /** + * The copies this close turned into 'perso' leave the frozen 'mancante' + * bucket for 'persa'; the report keeps counting them among the missing. + * + * @param list $copyIds + */ + private function markFrozenLost(int $sessionId, array $copyIds): void + { + foreach (array_chunk($copyIds, 500) as $chunk) { + $ids = implode(',', array_map('intval', $chunk)); + $stmt = $this->db->prepare("UPDATE inventario_attese SET esito_chiusura = 'persa' WHERE sessione_id = ? AND copia_id IN ({$ids})"); + if ($stmt === false) { + throw new \RuntimeException($this->db->error); + } + $stmt->bind_param('i', $sessionId); + $stmt->execute(); + $stmt->close(); + } + } + /** * @param list> $copies + * @return list ids of the copies now marked lost */ - private function markBookCopiesLost(int $bookId, array $copies, int $sessionId, ?int $operatorId): int + private function markBookCopiesLost(int $bookId, array $copies, int $sessionId, ?int $operatorId): array { $reassignment = new ReservationReassignmentService($this->db); $reassignment->setExternalTransaction(true); @@ -839,7 +960,7 @@ private function markBookCopiesLost(int $bookId, array $copies, int $sessionId, $lock->close(); if (!$bookLocked) { $this->db->rollback(); - return 0; + return []; } foreach ($copies as $copy) { @@ -926,7 +1047,7 @@ private function markBookCopiesLost(int $bookId, array $copies, int $sessionId, 'inventory' ); } - return count($changed); + return array_map(static fn (array $copy): int => (int) $copy['id'], $changed); } public function cancel(int $sessionId, ?int $operatorId): void diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index d3b8a6b61..7f00d6278 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -6,7 +6,8 @@ * at opening is independent of later catalogue edits, every scan lands in the * right bucket, the buckets add up, and closing with "mark as lost" changes * only the missing copies (never one lent, waiting for pickup or damaged), - * recalculates availability and writes the audit log. + * recalculates availability and writes the audit log. Once closed, the + * report is frozen: loans and returns after the close change nothing. * * Touches only rows it creates (titles/codes prefixed ZZST) and removes them. * Run: php tests/stocktake-snapshot.unit.php @@ -115,6 +116,7 @@ $check($summary['attese'] === 6 && $summary['trovate'] === 1 && $summary['fuori_posto'] === 0 && $summary['in_prestito'] === 2 && $summary['mancanti'] === 3, 'found 1, on loan 2 (C3 lent, C7 waiting for pickup), missing 3 (C2 deleted, C6 unscanned, C8 damaged)'); $check($summary['trovate'] + $summary['fuori_posto'] + $summary['in_prestito'] + $summary['mancanti'] === $summary['attese'], 'the four buckets add up to the expected copies'); $check($summary['sconosciute'] === 2 && $summary['fuori_ambito'] === 1 && $summary['duplicate'] === 1 && $summary['scansioni'] === 6, 'side counters: 2 unknown, 1 out of scope, 1 duplicate, 6 scans'); + $check($summary['perse'] === 0 && (int) $one("SELECT COUNT(*) FROM inventario_attese WHERE sessione_id = {$sessionId} AND esito_chiusura IS NULL") === 6, 'while the session is open no bucket is frozen and nothing is marked lost'); $categories = array_count_values(array_column($service->reportRows($sessionId), 'categoria')); $check(($categories['mancante'] ?? 0) === 3 && ($categories['trovata'] ?? 0) === 1 && ($categories['in_prestito'] ?? 0) === 2, 'the report rows list the same buckets'); @@ -137,6 +139,10 @@ $check((int) $one("SELECT copie_disponibili FROM libri WHERE id = {$b1}") === 1, 'book availability is recalculated (1 of the 3 remaining copies)'); $check((int) $one("SELECT COUNT(*) FROM log_modifiche WHERE tabella = 'libri' AND record_id = {$b1} AND JSON_UNQUOTE(JSON_EXTRACT(dati_nuovi, '$._activity.event')) = 'copy.inventory_lost'") === 1, 'an ActivityLog entry records the copy declared lost'); $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$sessionId}") === 'chiusa', 'the session is closed'); + $check($one("SELECT GROUP_CONCAT(CONCAT(copia_id, ':', esito_chiusura) ORDER BY copia_id) FROM inventario_attese WHERE sessione_id = {$sessionId}") === "{$c1}:trovata,{$c2}:mancante,{$c6}:persa,{$c3}:in_prestito,{$c7}:in_prestito,{$c8}:mancante", 'closing freezes each expected copy\'s bucket: the scan outcome, on loan, missing, or lost when the close marked it'); + $closedSummary = $service->summary($sessionId); + $check($closedSummary['trovate'] === 1 && $closedSummary['fuori_posto'] === 0 && $closedSummary['in_prestito'] === 2 && $closedSummary['mancanti'] === 3 && $closedSummary['perse'] === 1 && $closedSummary['attese'] === 6, 'the closed summary reads found 1, on loan 2, missing 3 of which 1 marked lost'); + $check($closedSummary['trovate'] + $closedSummary['fuori_posto'] + $closedSummary['in_prestito'] + $closedSummary['mancanti'] === $closedSummary['attese'], 'the frozen buckets add up to the expected copies'); try { $service->scan($sessionId, "{$p}-C6", null, null); $check(false, 'a closed session refuses scans'); @@ -199,6 +205,23 @@ (new App\Controllers\InventoryController())->close($request, new Slim\Psr7\Response(), $db, $fourth); $check(ignore_user_abort() === 1, 'a mark-lost close keeps running if the browser goes away'); $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$fourth}") === 'chiusa', 'and closes the session'); + $check($one("SELECT GROUP_CONCAT(CONCAT(copia_id, ':', esito_chiusura) ORDER BY copia_id) FROM inventario_attese WHERE sessione_id = {$fourth}") === "{$c3}:in_prestito,{$c7}:in_prestito,{$c5}:persa", 'the close from the page freezes the buckets too'); + + // The report of a closed session is frozen at close (review #504): the + // loan desk keeps working, the report keeps saying what the stocktake found. + $buckets = static fn (array $rows): array => array_map(static fn (array $r): array => [$r['categoria'], $r['codice']], $rows); + $closedRows = $buckets($service->reportRows($sessionId)); + $closedCategories = array_count_values(array_column($closedRows, 0)); + ksort($closedCategories); + $check($closedCategories === ['duplicata' => 1, 'fuori_ambito' => 1, 'in_prestito' => 2, 'mancante' => 3, 'sconosciuta' => 2, 'trovata' => 1], 'the closed report lists the frozen buckets, the copy marked lost among the missing'); + // C7's hold is picked up and returned, C6 turns up on a shelf, C8 is repaired and lent. + $db->query("UPDATE prestiti SET stato = 'restituito', attivo = 0 WHERE id = {$holdId}"); + $db->query("UPDATE copie SET stato = 'disponibile' WHERE id IN ({$c7}, {$c6})"); + $db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$c8}"); + $check($one("SELECT stato FROM copie WHERE id = {$c7}") === 'disponibile' && $one("SELECT stato FROM copie WHERE id = {$c8}") === 'prestato', 'the loan desk moved on after the close'); + $check($service->summary($sessionId) === $closedSummary, 'a return, a loan and a found copy after the close leave the summary as it was'); + $check($buckets($service->reportRows($sessionId)) === $closedRows, 'and every report row keeps its bucket'); + $check($one("SELECT esito_chiusura FROM inventario_attese WHERE sessione_id = {$sessionId} AND copia_id = {$c6}") === 'persa' && $service->summary($sessionId)['perse'] === 1, 'the copy the close marked lost is still reported as such'); echo "SUCCESS {$checks} stocktake snapshot checks\n"; } finally { From d4959afda92f8395d2a8178ad1033f8fd7a23f75 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 16:11:24 +0000 Subject: [PATCH 173/398] Changelog: drop the stale patron-type sentence from the user import bullet The merge of the parent branch re-appended the pre-#508 sentence about the patron-type column after the clause that now describes code-or-name resolution. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 691240c9a..dc80e0cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets. - **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card. - **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo, also when the account has a loan history and is therefore only suspended. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. -- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column ("Tipologia") assigns the type by its code or, failing that, by its name (both in any case; a name shared by two types is not guessed), so the users export of another installation with the same types re-imports cleanly; an empty value leaves the reader on the default type, and so does an unknown or deactivated one, reported as a warning with its line. The card expiry follows the user form: when the file gives none, it is computed from the card duration of the assigned patron type (the default type when the row has no type), and an expiry present in the CSV always wins. The separator (";", "," or TAB) is the one that splits the first lines into the same number of columns as the header, quoted fields respected, so a ";" file whose addresses hold commas is no longer read as a one-column "," file. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. +- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column ("Tipologia") assigns the type by its code or, failing that, by its name (both in any case; a name shared by two types is not guessed), so the users export of another installation with the same types re-imports cleanly; an empty value leaves the reader on the default type, and so does an unknown or deactivated one, reported as a warning with its line. The card expiry follows the user form: when the file gives none, it is computed from the card duration of the assigned patron type (the default type when the row has no type), and an expiry present in the CSV always wins. The separator (";", "," or TAB) is the one that splits the first lines into the same number of columns as the header, quoted fields respected, so a ";" file whose addresses hold commas is no longer read as a one-column "," file. ### Circulation policy - **One source for the circulation rules.** Loan duration, request window, renewals and the active-loan limit were read straight from the loans settings in about twenty places (staff desk, reader requests on the website and in the app, approval, queue promotion, renewals, NCIP). They now all go through `App\Services\CirculationPolicy`, which returns immutable `UserPolicy`/`LoanPolicy` objects, and every due date is computed by `CirculationPolicy::dueDateFor()`. With only the global settings the numbers are the same as before (a unit test compares every old read site over a grid of stored values); a CI step rejects any new direct read of those settings. From 8969ccf2e2ffd23e74d755f52ef8118ab851dd41 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 17:36:46 +0100 Subject: [PATCH 174/398] E2E: the NCIP debt renewal uses an NCIP loan; the ledger role check reloads the page --- tests/circulation-interop.spec.js | 2 +- tests/fines-ledger.spec.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/circulation-interop.spec.js b/tests/circulation-interop.spec.js index 0af625e90..9e13630e8 100644 --- a/tests/circulation-interop.spec.js +++ b/tests/circulation-interop.spec.js @@ -142,7 +142,7 @@ test.describe.serial('Patron-type rules on the Mobile API and NCIP', () => { expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${ctx.kid.id}`)).toBe(0); // An open loan of the child (made before the debt): the renewal is refused the same way. - F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo, origine) VALUES (${ctx.book1.id}, ${ctx.book1.copyIds[0]}, ${ctx.kid.id}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 10 DAY), 'in_corso', 1, 'diretto')`); + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo, origine) VALUES (${ctx.book1.id}, ${ctx.book1.copyIds[0]}, ${ctx.kid.id}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 10 DAY), 'in_corso', 1, 'ncip')`); F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${ctx.book1.copyIds[0]}`); const loanId = F.dbInt(`SELECT id FROM prestiti WHERE utente_id=${ctx.kid.id} AND libro_id=${ctx.book1.id} AND attivo=1`); const due = F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE id=${loanId}`); diff --git a/tests/fines-ledger.spec.js b/tests/fines-ledger.spec.js index ede6ea511..9abd4b8f5 100644 --- a/tests/fines-ledger.spec.js +++ b/tests/fines-ledger.spec.js @@ -558,7 +558,9 @@ test.describe.serial('Fines and patron ledger', () => { // loans.manage added (the role is re-read on every request): the desk forms appear and work. dbQuery(`UPDATE utenti SET ruolo_id=${deskRole} WHERE id=${operatorId}`); - await operator.goto(`${BASE}/admin/users/details/${state.patronId}#movimenti`); + // A fresh load: the same URL with only a #hash would not re-render the page. + await operator.goto(`${BASE}/admin/users/details/${state.patronId}`); + await operator.reload(); await expect(operator.locator('#ledger-payment-form')).toBeVisible(); await operator.fill('#ledger_pay_amount', '1,00'); await operator.selectOption('#ledger_pay_method', 'contanti'); From c41ad653c2bc8239d74d208fa9833928ef11a7e9 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 17:36:46 +0100 Subject: [PATCH 175/398] E2E: the consultation-only check signs the previous reader out first --- tests/circulation-consultation.spec.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/circulation-consultation.spec.js b/tests/circulation-consultation.spec.js index e17eb0d7e..85d7eb178 100644 --- a/tests/circulation-consultation.spec.js +++ b/tests/circulation-consultation.spec.js @@ -174,6 +174,8 @@ test.describe.serial('Consultation-only copy (single copy flag)', () => { F.dbQuery(`UPDATE copie SET stato='disponibile' WHERE libro_id=${title.id}`); await toggleCopy(title.copyIds[1]); expect(F.dbInt(`SELECT SUM(solo_consultazione) FROM copie WHERE libro_id=${title.id}`)).toBe(2); + // The reader page is still signed in as the previous test's viewer. + await reader.context().clearCookies(); await F.loginAs(reader, patron.email, patron.password); await reader.goto(`${F.BASE}/libro/${title.id}`); await expect(reader.locator('#consultation-only')).toBeVisible(); From 6c6436dfd92cb0e9e1fc6a628369cff0c64032d6 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 17:39:29 +0100 Subject: [PATCH 176/398] Fix atomic stocktake closure and lost outcomes --- app/Services/StocktakeService.php | 26 +++++++++++++----------- tests/stocktake-snapshot.unit.php | 33 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index 076b377c8..bcde12a12 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -840,20 +840,23 @@ public function scans(int $sessionId, ?int $operatorId = null, int $limit = 200) */ public function close(int $sessionId, bool $markLost, ?int $operatorId): array { - // Claim the session first: from here on scans are refused, and a - // concurrent cancel or second close cannot also succeed. Marking copies - // lost reads inventario_scansioni, not the session state. - $this->claimTransition($sessionId, 'chiusa', $operatorId); - // The scans are final now, and no loan desk event after this instant - // may move a copy between buckets: the report of a closed session is - // what it was at close. - $this->freezeOutcomes($sessionId); + // Publish the closed state and its frozen report together. Scans and + // competing transitions wait on the session lock until commit; if the + // snapshot fails, rollback leaves the session open for a retry. + $this->db->begin_transaction(); + try { + $this->claimTransition($sessionId, 'chiusa', $operatorId); + $this->freezeOutcomes($sessionId); + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollback(); + throw $e; + } $marked = 0; $failed = 0; if ($markLost) { $byBook = []; - $lost = []; foreach ($this->unscannedExpected($sessionId, true) as $row) { if ((int) $row['in_prestito'] === 1 || $row['libro_id'] === null || $row['titolo'] === null) { continue; // lent or held for pickup, or the copy/book left the catalogue meanwhile @@ -867,7 +870,6 @@ public function close(int $sessionId, bool $markLost, ?int $operatorId): array try { $lostInBook = $this->markBookCopiesLost($bookId, $copies, $sessionId, $operatorId); $marked += count($lostInBook); - $lost = array_merge($lost, $lostInBook); } catch (\Throwable $e) { $failed += count($copies); SecureLogger::error('[Stocktake] marking missing copies as lost failed', [ @@ -877,7 +879,6 @@ public function close(int $sessionId, bool $markLost, ?int $operatorId): array ]); } } - $this->markFrozenLost($sessionId, $lost); } $summary = $this->summary($sessionId); @@ -1020,6 +1021,9 @@ private function markBookCopiesLost(int $bookId, array $copies, int $sessionId, if ($changed !== [] && !(new DataIntegrity($this->db))->recalculateBookAvailability($bookId, insideTransaction: true)) { throw new \RuntimeException('Book availability recalculation failed'); } + // Keep the historical outcome in the same transaction as the copy + // state, hold reassignment and availability it describes. + $this->markFrozenLost($sessionId, array_map(static fn (array $copy): int => (int) $copy['id'], $changed)); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollback(); diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index 7f00d6278..875a245d4 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -223,6 +223,39 @@ $check($buckets($service->reportRows($sessionId)) === $closedRows, 'and every report row keeps its bucket'); $check($one("SELECT esito_chiusura FROM inventario_attese WHERE sessione_id = {$sessionId} AND copia_id = {$c6}") === 'persa' && $service->summary($sessionId)['perse'] === 1, 'the copy the close marked lost is still reported as such'); + // A failed snapshot write must leave the session open and retryable. + $atomicBook = $addBook('Atomic', $shelves['X']); + $atomicCopy = $addCopy($atomicBook, 'ATOMIC'); + $atomicSession = $service->open('ZZST atomic ' . $run, null, ['collocazione_da' => "{$p}.Atomic", 'collocazione_a' => "{$p}.Atomic"], null); + $sessionIds[] = $atomicSession; + $freezeFailure = 'zzst_freeze_' . strtolower($run); + $db->query("CREATE TRIGGER {$freezeFailure} BEFORE UPDATE ON inventario_attese FOR EACH ROW BEGIN IF NEW.sessione_id = {$atomicSession} AND NEW.esito_chiusura IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ZZST simulated snapshot failure'; END IF; END"); + $triggers[] = $freezeFailure; + $snapshotFailed = false; + try { + $service->close($atomicSession, false, null); + } catch (mysqli_sql_exception) { + $snapshotFailed = true; + } + $db->query("DROP TRIGGER {$freezeFailure}"); + $check($snapshotFailed, 'the snapshot write failure reaches the caller'); + $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$atomicSession}") === 'aperta', 'a failed snapshot rolls back the close so it can be retried'); + $check($one("SELECT esito_chiusura FROM inventario_attese WHERE sessione_id = {$atomicSession} AND copia_id = {$atomicCopy}") === null, 'a failed close leaves the expected copy unfrozen'); + + // Losing a copy and recording its frozen bucket are one per-book unit: + // neither the copy nor its availability may change if the bucket fails. + $lostFailure = 'zzst_lost_' . strtolower($run); + $db->query("CREATE TRIGGER {$lostFailure} BEFORE UPDATE ON inventario_attese FOR EACH ROW BEGIN IF NEW.sessione_id = {$atomicSession} AND NEW.esito_chiusura = 'persa' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'ZZST simulated lost bucket failure'; END IF; END"); + $triggers[] = $lostFailure; + $result = $service->close($atomicSession, true, null); + $db->query("DROP TRIGGER {$lostFailure}"); + $check($result === ['marked' => 0, 'failed' => 1], 'a lost bucket failure is reported as a failed book update'); + $check($one("SELECT stato FROM copie WHERE id = {$atomicCopy}") === 'disponibile', 'a failed lost bucket rolls back the copy state'); + $check($one("SELECT copie_disponibili FROM libri WHERE id = {$atomicBook}") === '1', 'a failed lost bucket rolls back book availability'); + $check($one("SELECT esito_chiusura FROM inventario_attese WHERE sessione_id = {$atomicSession} AND copia_id = {$atomicCopy}") === 'mancante', 'the frozen report stays missing when marking lost fails'); + $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$atomicSession}") === 'chiusa', 'the retried close succeeds even when marking a book lost fails'); + $check((int) $one("SELECT COUNT(*) FROM log_modifiche WHERE tabella = 'libri' AND record_id = {$atomicBook} AND JSON_UNQUOTE(JSON_EXTRACT(dati_nuovi, '$._activity.event')) = 'copy.inventory_lost'") === 0, 'the rolled-back loss writes no copy audit event'); + echo "SUCCESS {$checks} stocktake snapshot checks\n"; } finally { foreach ($triggers as $trigger) { From d3367044a5ae3ee61d6d4b1daa5a2f2ca9dffc8a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 17:42:23 +0100 Subject: [PATCH 177/398] Allow PHPStan workers enough memory for full analysis --- .github/workflows/ci-quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 501581cf0..e00433b95 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -148,7 +148,7 @@ jobs: - name: PHPStan (level from phpstan.neon) run: | - "$PHPSTAN_BIN" analyse --no-progress --memory-limit=512M + "$PHPSTAN_BIN" analyse --no-progress --memory-limit=1G # ── Route key integrity ─────────────────────────────────────────────── - name: Route key integrity (route_path() keys exist in routes_it_IT.json) From 92d5591c14e33c76b2f2840d4140c1ead12fae7e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 17:42:47 +0100 Subject: [PATCH 178/398] Normalize abbreviated French card headers consistently --- app/Support/UserImport.php | 5 ++++- tests/patron-cards-import.unit.php | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/Support/UserImport.php b/app/Support/UserImport.php index 70e9e1d03..0b25e6ac9 100644 --- a/app/Support/UserImport.php +++ b/app/Support/UserImport.php @@ -53,7 +53,7 @@ final class UserImport 'data_nascita' => ['datanascita', 'datadinascita', 'birthdate', 'dateofbirth', 'birthday', 'geburtsdatum', 'datedenaissance', 'fodselsdato'], 'sesso' => ['sesso', 'gender', 'sex', 'geschlecht', 'sexe', 'kon'], 'cod_fiscale' => ['codfiscale', 'codicefiscale', 'cf', 'taxcode', 'fiscalcode', 'steuernummer', 'codefiscal'], - 'codice_tessera' => ['codicetessera', 'tessera', 'card', 'cardnumber', 'cardcode', 'librarycard', 'barcode', 'ausweisnummer', 'ausweis', 'numerodecarte', 'kortnummer', 'lanerkort'], + 'codice_tessera' => ['codicetessera', 'tessera', 'card', 'cardnumber', 'cardcode', 'librarycard', 'barcode', 'ausweisnummer', 'ausweis', 'numerodecarte', 'ndecarte', 'kortnummer', 'lanerkort'], 'data_scadenza_tessera' => ['datascadenzatessera', 'scadenzatessera', 'scadenza', 'expiry', 'expirydate', 'cardexpiry', 'expires', 'ablaufdatum', 'gultigbis', 'datedexpiration', 'udlobsdato'], 'note_utente' => ['noteutente', 'note', 'notes', 'notizen', 'noter', 'noteinterne'], 'locale' => ['locale', 'lingua', 'language', 'sprache', 'langue', 'sprog'], @@ -109,6 +109,9 @@ public static function targetFields(array $customDefinitions, bool $withPatronTy 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ý' => 'y', 'ÿ' => 'y', 'ß' => 'ss', + // Abbreviated card headers ("Nº de carte") must not depend on whether + // iconv transliterates the ordinal indicator as "o" or drops it. + 'º' => '', 'À' => 'a', 'Á' => 'a', 'Â' => 'a', 'Ä' => 'a', 'Ã' => 'a', 'Å' => 'a', 'Æ' => 'ae', 'Ç' => 'c', 'È' => 'e', 'É' => 'e', 'Ê' => 'e', 'Ë' => 'e', diff --git a/tests/patron-cards-import.unit.php b/tests/patron-cards-import.unit.php index c361124bb..76741680e 100644 --- a/tests/patron-cards-import.unit.php +++ b/tests/patron-cards-import.unit.php @@ -35,7 +35,8 @@ setlocale(LC_CTYPE, 'C'); $check(UserImport::normalizeHeader('Prénom') === 'prenom' && UserImport::normalizeHeader('Téléphone') === 'telephone', 'French accents are removed whatever the locale'); $check(UserImport::normalizeHeader('Gültig bis') === 'gultigbis' && UserImport::normalizeHeader('GEBURTSDATUM ') === 'geburtsdatum', 'German umlauts and upper case are normalised whatever the locale'); -$check(UserImport::normalizeHeader('Nº de carte') === 'ndecarte' && UserImport::normalizeHeader('Straße') === 'strasse' && UserImport::normalizeHeader('Cœur') === 'coeur', 'º falls through to the fallback, ß and œ expand'); +$check(UserImport::normalizeHeader('Nº de carte') === 'ndecarte' && UserImport::normalizeHeader('Straße') === 'strasse' && UserImport::normalizeHeader('Cœur') === 'coeur', 'º is removed deterministically, ß and œ expand'); +$check(UserImport::guessMapping(['Nº de carte'], $fields) === [0 => 'codice_tessera'], 'the abbreviated French card header is recognised under the "C" locale'); $check(UserImport::normalizeGender('Männlich') === 'M' && UserImport::normalizeGender('Féminin') === 'F' && UserImport::normalizeGender('Autre') === 'Altro', 'gender words with accents are recognised whatever the locale'); $check(UserImport::guessMapping(['Prénom', 'Nom', 'Courriel', 'Téléphone', 'Date de naissance', 'Numéro de carte', "Date d'expiration"], $fields) === [0 => 'nome', 1 => 'cognome', 2 => 'email', 3 => 'telefono', 4 => 'data_nascita', 5 => 'codice_tessera', 6 => 'data_scadenza_tessera'], 'French headers are recognised under the "C" locale'); setlocale(LC_CTYPE, $savedLocale === false ? 'C' : $savedLocale); From b0d18c8a9c1c81a05968c2913763639873ffc348 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 18:04:49 +0100 Subject: [PATCH 179/398] Tests: load CirculationOutcome with ActionsController; the mobile request contract reads the stored state --- tests/issues-301-306.unit.php | 7 +++++-- tests/loan-window-surfaces.unit.php | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/issues-301-306.unit.php b/tests/issues-301-306.unit.php index cef8e909e..e3ce1be86 100644 --- a/tests/issues-301-306.unit.php +++ b/tests/issues-301-306.unit.php @@ -68,8 +68,11 @@ 'automatic approval sends the patron approval email' ); $check( - str_contains($mobileActions, "'auto_approved' => \$autoApproved") - && str_contains($mobileActions, "'status' => \$autoApproved ? 'da_ritirare' : 'pendente'") + // The state the request was stored with (CirculationOutcome::requestState(): + // pendente, da_ritirare, or prenotato when a closed day moved the start). + str_contains($mobileActions, 'CirculationOutcome::requestState($outcome)') + && str_contains($mobileActions, "'auto_approved' => \$status !== 'pendente'") + && str_contains($mobileActions, "'status' => \$status,") && str_contains($mobileHealth, "'loan_approval_required' => \$loanApprovalRequired") && str_contains($mobileDocs, 'data.auto_approved'), 'Mobile API advertises the setting and returns the actual request outcome' diff --git a/tests/loan-window-surfaces.unit.php b/tests/loan-window-surfaces.unit.php index 9d470fe20..10fe90337 100644 --- a/tests/loan-window-surfaces.unit.php +++ b/tests/loan-window-surfaces.unit.php @@ -43,6 +43,7 @@ require_once $root . '/storage/plugins/mobile-api/src/Support/JsonBody.php'; require_once $root . '/storage/plugins/mobile-api/src/Support/ResponseEnvelope.php'; require_once $root . '/storage/plugins/mobile-api/src/Support/Input.php'; +require_once $root . '/storage/plugins/mobile-api/src/Support/CirculationOutcome.php'; require_once $root . '/storage/plugins/mobile-api/src/Controllers/ActionsController.php'; require_once $root . '/storage/plugins/mobile-api/src/Controllers/CatalogController.php'; From 3fc5ec37f930512a73958fcfb0f989aa18148a50 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 18:09:26 +0100 Subject: [PATCH 180/398] Users export: one CirculationPolicy for the whole file, not two type queries per row --- app/Controllers/UsersController.php | 6 ++++-- app/Support/PatronTypeAssignment.php | 16 ++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index c8ea4d041..844e64d01 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -1163,6 +1163,8 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res // (nome, email, note) that start with = + - @. $writer = Csv::writerToStream($stream, ';', "'"); $writer->insertOne($headers); + // One policy for the whole file: the patron types are read once. + $exportPolicy = new \App\Services\CirculationPolicy($db); foreach ($utenti as $utente) { $row = [ @@ -1179,10 +1181,10 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res isset($utente['data_scadenza_tessera']) ? format_date($utente['data_scadenza_tessera'], false, '/') : '', $utente['stato'] ?? '', $utente['tipo_utente'] ?? '', - \App\Support\PatronTypeAssignment::nameOf($db, $utente['tipologia_id'] ?? null), + \App\Support\PatronTypeAssignment::nameOf($db, $utente['tipologia_id'] ?? null, $exportPolicy), // The code beside the name: the import resolves by code first, // so this file re-imports exactly where the same types exist. - \App\Support\PatronTypeAssignment::codeOf($db, $utente['tipologia_id'] ?? null), + \App\Support\PatronTypeAssignment::codeOf($db, $utente['tipologia_id'] ?? null, $exportPolicy), $utente['cod_fiscale'] ?? '', $utente['note_utente'] ?? '', ($utente['email_verificata'] ?? 0) == 1 ? 'Sì' : 'No', diff --git a/app/Support/PatronTypeAssignment.php b/app/Support/PatronTypeAssignment.php index 8d48c4ed6..d6b6b383a 100644 --- a/app/Support/PatronTypeAssignment.php +++ b/app/Support/PatronTypeAssignment.php @@ -103,9 +103,9 @@ public static function filterCondition(mysqli $db, mixed $value, string $column /** * Display name of a stored patron type (null/unknown = the default type). */ - public static function nameOf(mysqli $db, mixed $typeId): string + public static function nameOf(mysqli $db, mixed $typeId, ?CirculationPolicy $policy = null): string { - return self::fieldOf($db, $typeId, 'nome'); + return self::fieldOf($db, $typeId, 'nome', $policy); } /** @@ -114,14 +114,18 @@ public static function nameOf(mysqli $db, mixed $typeId): string * by its code first, so an export re-imports exactly on an installation * with the same types, even when two of them share a name. */ - public static function codeOf(mysqli $db, mixed $typeId): string + public static function codeOf(mysqli $db, mixed $typeId, ?CirculationPolicy $policy = null): string { - return self::fieldOf($db, $typeId, 'codice'); + return self::fieldOf($db, $typeId, 'codice', $policy); } - private static function fieldOf(mysqli $db, mixed $typeId, string $field): string + /** + * $policy: pass one instance when resolving many rows (the users export), + * so the patron types are read once instead of once per call. + */ + private static function fieldOf(mysqli $db, mixed $typeId, string $field, ?CirculationPolicy $policy = null): string { - $policy = new CirculationPolicy($db); + $policy ??= new CirculationPolicy($db); $types = $policy->types(); $id = is_numeric($typeId) ? (int) $typeId : null; $id = $id !== null && isset($types[$id]) ? $id : $policy->defaultTypeId(); From 43aa8faea3440acbe0268eb59976051e9f4139e8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 18:40:31 +0100 Subject: [PATCH 181/398] Stocktake: a lost copy that turns up is not reported as out of the filter Copies already lost, damaged or away are left out of the snapshot, so a scan of one is classified fuori_ambito and the operator read "La copia esiste ma non rientra nel filtro della revisione". The scan result now carries fuori_circolazione, and the scan page tells the operator that the copy is recorded as lost or out of circulation but has turned up, with its recorded state, so its state can be fixed. --- app/Services/StocktakeService.php | 10 ++++++++-- app/Views/inventario/show.php | 3 ++- 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 + tests/inventory-stocktake.spec.js | 16 ++++++++++++++++ tests/stocktake-snapshot.unit.php | 7 ++++++- 9 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/Services/StocktakeService.php b/app/Services/StocktakeService.php index bcde12a12..0f261cf98 100644 --- a/app/Services/StocktakeService.php +++ b/app/Services/StocktakeService.php @@ -387,7 +387,7 @@ private function assertOpen(int $sessionId): void /** * Record one scanned or typed code. * - * @return array{esito:string,codice:string,copia_id:int|null,libro_id:int|null,titolo:string,collocazione:string,scaffale_atteso:string,stato_copia:string} + * @return array{esito:string,codice:string,copia_id:int|null,libro_id:int|null,titolo:string,collocazione:string,scaffale_atteso:string,stato_copia:string,fuori_circolazione:bool} */ public function scan(int $sessionId, string $rawCode, ?int $operatorId, ?int $scanShelf = null): array { @@ -426,7 +426,12 @@ public function importCodes(int $sessionId, array $codes, ?int $operatorId, ?int } /** - * @return array{esito:string,codice:string,copia_id:int|null,libro_id:int|null,titolo:string,collocazione:string,scaffale_atteso:string,stato_copia:string} + * `fuori_circolazione` flags a copy recorded as lost, damaged or away + * (OFF_SHELF_STATES): such a copy is left out of the snapshot, so a scan of + * it is out of scope, but the operator must be told it turned up rather + * than that it is outside the filter. + * + * @return array{esito:string,codice:string,copia_id:int|null,libro_id:int|null,titolo:string,collocazione:string,scaffale_atteso:string,stato_copia:string,fuori_circolazione:bool} */ private function record(int $sessionId, string $code, ?int $operatorId, ?int $scanShelf): array { @@ -507,6 +512,7 @@ private function record(int $sessionId, string $code, ?int $operatorId, ?int $sc 'collocazione' => is_array($copy) ? (string) ($copy['collocazione'] ?? '') : '', 'scaffale_atteso' => $expectedShelf !== null ? $this->shelfLabel($expectedShelf) : '', 'stato_copia' => is_array($copy) ? (string) $copy['stato'] : '', + 'fuori_circolazione' => is_array($copy) && in_array((string) $copy['stato'], self::OFF_SHELF_STATES, true), ]; } diff --git a/app/Views/inventario/show.php b/app/Views/inventario/show.php index 554233835..8e9bd0a5b 100644 --- a/app/Views/inventario/show.php +++ b/app/Views/inventario/show.php @@ -380,6 +380,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te 'unknown' => __('Il codice non corrisponde a nessuna copia a catalogo.'), 'duplicate' => __('Questa copia è già stata registrata in questa revisione.'), 'outOfScope' => __('La copia esiste ma non rientra nel filtro della revisione.'), + 'offShelf' => __('La copia risulta persa o fuori circolazione, ma è stata ritrovata: aggiornane lo stato.'), 'onLoan' => __('La copia risulta in prestito o in attesa di ritiro: verifica il prestito.'), 'closed' => __('La revisione è stata chiusa da un altro operatore.'), ], @@ -444,7 +445,7 @@ function detailFor(result) { if (result.esito === 'fuori_posto' && result.scaffale_atteso) { parts.push(config.i18n.expectedShelf + ': ' + result.scaffale_atteso); } if (result.esito === 'sconosciuta') { parts.push(config.i18n.unknown); } if (result.esito === 'duplicata') { parts.push(config.i18n.duplicate); } - if (result.esito === 'fuori_ambito') { parts.push(config.i18n.outOfScope); } + if (result.esito === 'fuori_ambito') { parts.push(result.fuori_circolazione ? config.i18n.offShelf + ' (' + result.stato_copia + ')' : config.i18n.outOfScope); } if (result.esito === 'in_prestito') { parts.push(config.i18n.onLoan); } return parts.join(' · '); } diff --git a/locale/da_DK.json b/locale/da_DK.json index 072a4cade..c0452868e 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8489,6 +8489,7 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "Koden svarer ikke til noget eksemplar i kataloget.", "Questa copia è già stata registrata in questa revisione.": "Dette eksemplar er allerede registreret i denne optælling.", "La copia esiste ma non rientra nel filtro della revisione.": "Eksemplaret findes, men er uden for optællingens filter.", + "La copia risulta persa o fuori circolazione, ma è stata ritrovata: aggiornane lo stato.": "Eksemplaret er registreret som tabt eller ude af cirkulation, men er dukket op igen: opdater dets status.", "La revisione è stata chiusa da un altro operatore.": "Optællingen er lukket af en anden medarbejder.", "Revisore inventario": "Optællingsmedarbejder", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Startprofil til at deltage i lageroptællinger: åbne en session og scanne eksemplarer uden at ændre kataloget eller erklære eksemplarer tabt.", diff --git a/locale/de_DE.json b/locale/de_DE.json index 24b14f4ac..458fb59af 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8489,6 +8489,7 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "Der Code entspricht keinem Exemplar im Katalog.", "Questa copia è già stata registrata in questa revisione.": "Dieses Exemplar wurde in dieser Revision bereits erfasst.", "La copia esiste ma non rientra nel filtro della revisione.": "Das Exemplar existiert, liegt aber außerhalb des Filters der Revision.", + "La copia risulta persa o fuori circolazione, ma è stata ritrovata: aggiornane lo stato.": "Das Exemplar ist als verloren oder außer Umlauf erfasst, wurde aber wiedergefunden: Aktualisiere seinen Status.", "La revisione è stata chiusa da un altro operatore.": "Die Revision wurde von einem anderen Mitarbeiter geschlossen.", "Revisore inventario": "Bestandsprüfer", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Ausgangsprofil für die Teilnahme an Bestandsrevisionen: eine Sitzung eröffnen und Exemplare scannen, ohne den Katalog zu ändern oder Exemplare als verloren zu erklären.", diff --git a/locale/en_US.json b/locale/en_US.json index 00060f6a8..5e9ba5977 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8489,6 +8489,7 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "The code does not match any copy in the catalogue.", "Questa copia è già stata registrata in questa revisione.": "This copy was already recorded in this stocktake.", "La copia esiste ma non rientra nel filtro della revisione.": "The copy exists but is outside the stocktake's filter.", + "La copia risulta persa o fuori circolazione, ma è stata ritrovata: aggiornane lo stato.": "The copy is recorded as lost or out of circulation, but it has turned up: update its state.", "La revisione è stata chiusa da un altro operatore.": "The stocktake was closed by another operator.", "Revisore inventario": "Stocktake reviewer", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Starting profile for taking part in stocktakes: open a session and scan copies, without changing the catalogue or declaring copies lost.", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 5d0f97807..6fca51222 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8489,6 +8489,7 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "Le code ne correspond à aucun exemplaire du catalogue.", "Questa copia è già stata registrata in questa revisione.": "Cet exemplaire a déjà été enregistré dans ce récolement.", "La copia esiste ma non rientra nel filtro della revisione.": "L'exemplaire existe mais n'entre pas dans le filtre du récolement.", + "La copia risulta persa o fuori circolazione, ma è stata ritrovata: aggiornane lo stato.": "L'exemplaire est enregistré comme perdu ou hors circulation, mais il a été retrouvé : mettez à jour son état.", "La revisione è stata chiusa da un altro operatore.": "Le récolement a été clôturé par un autre opérateur.", "Revisore inventario": "Agent de récolement", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Profil de départ pour participer aux récolements : ouvrir une session et scanner les exemplaires, sans modifier le catalogue ni déclarer des exemplaires perdus.", diff --git a/locale/it_IT.json b/locale/it_IT.json index 8344d268b..bc30a1444 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8489,6 +8489,7 @@ "Il codice non corrisponde a nessuna copia a catalogo.": "Il codice non corrisponde a nessuna copia a catalogo.", "Questa copia è già stata registrata in questa revisione.": "Questa copia è già stata registrata in questa revisione.", "La copia esiste ma non rientra nel filtro della revisione.": "La copia esiste ma non rientra nel filtro della revisione.", + "La copia risulta persa o fuori circolazione, ma è stata ritrovata: aggiornane lo stato.": "La copia risulta persa o fuori circolazione, ma è stata ritrovata: aggiornane lo stato.", "La revisione è stata chiusa da un altro operatore.": "La revisione è stata chiusa da un altro operatore.", "Revisore inventario": "Revisore inventario", "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.": "Profilo iniziale per partecipare alle revisioni inventariali: aprire una sessione e scansionare le copie, senza modificare il catalogo né dichiarare perse le copie.", diff --git a/tests/inventory-stocktake.spec.js b/tests/inventory-stocktake.spec.js index bd18bb7bb..23c5d6c70 100644 --- a/tests/inventory-stocktake.spec.js +++ b/tests/inventory-stocktake.spec.js @@ -386,3 +386,19 @@ test('9. a session can be cancelled: it stays listed as cancelled and refuses sc expect(refused.status()).toBe(422); expect(Number(dbQuery(`SELECT COUNT(*) FROM inventario_scansioni WHERE sessione_id=${id}`))).toBe(0); }); + +test('10. a copy recorded as lost that turns up is reported as found again, not as outside the filter', async ({ page }) => { + // P1 is already lost: it is left out of every snapshot, so a scan of it is out of scope (review #504). + dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (${fx.books.B1}, ${S(code('P1'))}, 'perso')`); + fx.copies.P1 = Number(dbQuery(`SELECT id FROM copie WHERE numero_inventario=${S(code('P1'))}`)); + await login(page, ADMIN); + const id = await openSession(page, `${TAG} ritrovata`, fx.shelfX); + expect(Number(dbQuery(`SELECT COUNT(*) FROM inventario_attese WHERE sessione_id=${id} AND copia_id=${fx.copies.P1}`))).toBe(0); + expect(await scan(page, code('Y1'))).toBe('fuori_ambito'); + await expect(page.locator('#inventory-feedback')).toContainText('non rientra nel filtro della revisione'); + expect(await scan(page, code('P1'))).toBe('fuori_ambito'); + await expect(page.locator('#inventory-feedback')).toContainText('risulta persa o fuori circolazione, ma è stata ritrovata'); + await expect(page.locator('#inventory-feedback')).toContainText('(perso)'); + await expect(page.locator('#inventory-feedback')).not.toContainText('non rientra nel filtro della revisione'); + expect(copyState('P1')).toBe('perso'); +}); diff --git a/tests/stocktake-snapshot.unit.php b/tests/stocktake-snapshot.unit.php index 875a245d4..9db378e23 100644 --- a/tests/stocktake-snapshot.unit.php +++ b/tests/stocktake-snapshot.unit.php @@ -105,7 +105,8 @@ $x = $shelves['X']; $check($service->scan($sessionId, "{$p}-C1\r", null, $x)['esito'] === 'trovata', 'C1 scanned on shelf X is found although its book was moved since'); - $check($service->scan($sessionId, "{$p}-C5", null, $x)['esito'] === 'fuori_ambito', 'a copy added after opening is out of scope'); + $c5Scan = $service->scan($sessionId, "{$p}-C5", null, $x); + $check($c5Scan['esito'] === 'fuori_ambito' && $c5Scan['fuori_circolazione'] === false, 'a copy added after opening is out of scope, and not flagged as out of circulation'); $check($service->scan($sessionId, "{$p}-C4", null, $x)['esito'] === 'sconosciuta', 'a copy of a book removed from the catalogue is unknown'); $check($service->scan($sessionId, "{$p}-NOPE", null, $x)['esito'] === 'sconosciuta', 'an unknown code is unknown'); $check($service->scan($sessionId, strtolower("{$p}-c1"), null, $x)['esito'] === 'duplicata', 'scanning C1 again (any case) is a duplicate'); @@ -156,6 +157,10 @@ $check((int) $one("SELECT totale_attese FROM inventario_sessioni WHERE id = {$second}") === 3, 'a collocation range opens on B2 alone (C3, C5, C7)'); $import = $service->importCodes($second, StocktakeService::parseCodeList("codice\n{$p}-C5\n{$p}-C5\n{$p}-C1\n{$p}-C7\n"), null, null); $check($import['total'] === 4 && $import['outcomes']['trovata'] === 1 && $import['outcomes']['duplicata'] === 1 && $import['outcomes']['fuori_ambito'] === 1 && $import['outcomes']['in_prestito'] === 1, 'an uploaded list is classified line by line; a copy waiting for pickup is reported with the lent ones'); + // A copy recorded as lost is outside every snapshot; when it turns up the + // operator is told it was found, not that it is outside the filter (review #504). + $c9Scan = $service->scan($second, "{$p}-C9", null, null); + $check($c9Scan['esito'] === 'fuori_ambito' && $c9Scan['fuori_circolazione'] === true && $c9Scan['stato_copia'] === 'perso', 'a lost copy that turns up is flagged as out of circulation with its recorded state'); $service->cancel($second, null); $check($one("SELECT stato FROM inventario_sessioni WHERE id = {$second}") === 'annullata' && $one("SELECT stato FROM copie WHERE id = {$c5}") === 'disponibile', 'cancelling leaves every copy as it was'); From 90ecfb4d84571cd5746ae050d44033a7061df806 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 18:41:03 +0100 Subject: [PATCH 182/398] MARC: X11 subfields, export cut notes and fallback record, RDA overrides on a media type change - Marc21LibriMapper: in a meeting entry (111/711) the subordinate unit is $e, which is now part of the name, and the relator term is $j; 110/710 keep $b as the unit and $e as the relator. - MarcExportController::serializeIso(): full record, then long texts shortened, then a reduced record (texts at 200 characters, at most 20 authors and 5 publishers), so a book is no longer left out of an ISO 2709 export; only the field cuts of the record actually written are logged. - MarcBookImporter::extraColumnUpdates(): when an overwrite changes the media type, the RDA overrides the record does not carry are cleared instead of describing the old material; fill mode and same-type overwrites are unchanged. Tests: marc-library 51d, 74a, 74b; marc-import-robustness extraColumnUpdates cases. --- app/Controllers/MarcExportController.php | 79 +++++++++++++++++------- app/Support/Marc/Marc21LibriMapper.php | 29 +++++++-- app/Support/Marc/MarcBookImporter.php | 51 ++++++++++++--- tests/marc-import-robustness.unit.php | 10 +++ tests/marc-library.unit.php | 17 +++++ 5 files changed, 149 insertions(+), 37 deletions(-) diff --git a/app/Controllers/MarcExportController.php b/app/Controllers/MarcExportController.php index 2231229c9..9a57563ac 100644 --- a/app/Controllers/MarcExportController.php +++ b/app/Controllers/MarcExportController.php @@ -78,31 +78,19 @@ public function generate(\mysqli $db, string $format, ?array $ids): \Generator yield $xml->output(); } foreach ($this->books($db, $ids) as [$row, $authors, $publishers, $genre]) { - $record = $format === 'unimarc' - ? BookMarcBuilder::unimarc($row, $authors, $publishers, $genre, true) - : BookMarcBuilder::marc21($row, $authors, $publishers, $genre, true); if ($xml !== null) { - $xml->writeRecord($record); + $xml->writeRecord(BookMarcBuilder::marc21($row, $authors, $publishers, $genre, true)); yield $xml->output(); continue; } $cuts = []; - try { - $bytes = Iso2709Writer::serialize($record, $cuts); - } catch (\OverflowException) { - // Over 99,999 bytes: the long free texts (description, notes) - // are shortened so the book is still in the file, not dropped. - $short = self::shortenTexts($row); - $record = $format === 'unimarc' - ? BookMarcBuilder::unimarc($short, $authors, $publishers, $genre, true) - : BookMarcBuilder::marc21($short, $authors, $publishers, $genre, true); - try { - $bytes = Iso2709Writer::serialize($record, $cuts); - \App\Support\SecureLogger::warning('[MARC export] record over the ISO 2709 limit, long texts shortened', ['book_id' => $row['id'] ?? null]); - } catch (\OverflowException) { - \App\Support\SecureLogger::error('[MARC export] record too long for ISO 2709 even shortened, skipped', ['book_id' => $row['id'] ?? null]); - continue; - } + $bytes = self::serializeIso($format, $row, $authors, $publishers, $genre, $cuts, $level); + if ($bytes === null) { + \App\Support\SecureLogger::error('[MARC export] record too long for ISO 2709 even reduced, skipped', ['book_id' => $row['id'] ?? null]); + continue; + } + if ($level !== 'full') { + \App\Support\SecureLogger::warning('[MARC export] record over the ISO 2709 limit, exported ' . ($level === 'shortened' ? 'with long texts shortened' : 'as a reduced record'), ['book_id' => $row['id'] ?? null]); } // A field over the 9,999-byte ISO 2709 field limit is cut at a // subfield boundary: the file is still readable, the log says what @@ -141,17 +129,60 @@ public static function parseIds(string $raw): array } /** - * A copy of the book row with every text longer than 1000 characters + * One book as an ISO 2709 record that always fits the 99,999-byte limit, + * so an export never leaves a book out. The full record first; if it is + * too long, the same record with every text over 1000 characters + * shortened; if even that is too long (hundreds of contributors or + * subjects), a reduced record: texts cut to 200 characters and at most + * 20 authors and 5 publishers, which is bounded far below the limit. + * $level says which of the three was written ("full", "shortened", + * "reduced"); $cuts holds the field cuts of that record only. + * + * @param array $row + * @param list> $authors + * @param list> $publishers + * @param array|null $genre + * @param list $cuts + */ + public static function serializeIso(string $format, array $row, array $authors, array $publishers, ?array $genre, array &$cuts, ?string &$level = null): ?string + { + $attempts = [ + 'full' => [$row, $authors, $publishers], + 'shortened' => [self::shortenTexts($row), $authors, $publishers], + 'reduced' => [self::shortenTexts($row, 200), array_slice($authors, 0, 20), array_slice($publishers, 0, 5)], + ]; + foreach ($attempts as $name => [$r, $a, $p]) { + $record = $format === 'unimarc' + ? BookMarcBuilder::unimarc($r, $a, $p, $genre, true) + : BookMarcBuilder::marc21($r, $a, $p, $genre, true); + // Only the cuts of the record that is written are reported. + $notes = []; + try { + $bytes = Iso2709Writer::serialize($record, $notes); + $cuts = $notes ?? []; + $level = $name; + return $bytes; + } catch (\OverflowException) { + continue; + } + } + $cuts = []; + $level = null; + return null; + } + + /** + * A copy of the book row with every text longer than $max characters * cut there, for a record that would not fit ISO 2709 otherwise. * * @param array $row * @return array */ - public static function shortenTexts(array $row): array + public static function shortenTexts(array $row, int $max = 1000): array { foreach ($row as $key => $value) { - if (is_string($value) && mb_strlen($value) > 1000) { - $row[$key] = rtrim(mb_substr($value, 0, 1000)) . '…'; + if (is_string($value) && mb_strlen($value) > $max) { + $row[$key] = rtrim(mb_substr($value, 0, $max)) . '…'; } } return $row; diff --git a/app/Support/Marc/Marc21LibriMapper.php b/app/Support/Marc/Marc21LibriMapper.php index b8daac696..aaf7a1801 100644 --- a/app/Support/Marc/Marc21LibriMapper.php +++ b/app/Support/Marc/Marc21LibriMapper.php @@ -36,9 +36,27 @@ final class Marc21LibriMapper implements MarcLibriMapper 'colorist' => 'colorista', ]; + /** Meeting entries (X11) put the subordinate unit in $e and the relator term in $j. */ + private static function isMeeting(MarcField $field): bool + { + return $field->tag === '111' || $field->tag === '711'; + } + + /** + * The relator terms of a name entry: $e, except in a meeting entry + * (111/711), where $e is the subordinate unit and the term is $j. + * + * @return list + */ + private static function relatorTerms(MarcField $field): array + { + return $field->subfieldValues(self::isMeeting($field) ? 'j' : 'e'); + } + /** - * The name of a name entry: a person's $a; a corporate body's or - * meeting's $a with its subordinate units ($b), "United States. Congress". + * The name of a name entry: a person's $a; a corporate body's $a with + * its subordinate units ($b), "United States. Congress"; a meeting's $a + * with its subordinate units ($e). */ private static function entryName(MarcField $field): string { @@ -46,8 +64,9 @@ private static function entryName(MarcField $field): string if (!in_array($field->tag, ['110', '111', '710', '711'], true)) { return $name; } + $units = $field->subfieldValues(self::isMeeting($field) ? 'e' : 'b'); $parts = []; - foreach (array_merge([$name], array_map([MarcBookData::class, 'cleanName'], $field->subfieldValues('b'))) as $part) { + foreach (array_merge([$name], array_map([MarcBookData::class, 'cleanName'], $units)) as $part) { if ($part !== '') { $parts[] = $part; } @@ -102,7 +121,7 @@ public function map(MarcRecord $record): array foreach ($record->fieldsByTag($tag) as $field) { $name = self::entryName($field); $codes = $field->subfieldValues('4'); - $term = $field->subfieldValues('e'); + $term = self::relatorTerms($field); // The main entry is the author unless its own relator says otherwise. $role = ($codes === [] && $term === []) ? 'author' @@ -119,7 +138,7 @@ public function map(MarcRecord $record): array if ($name === '') { continue; } - $term = $field->subfieldValues('e'); + $term = self::relatorTerms($field); $role = RelatorRoles::resolve($field->subfieldValues('4'), $term === [] ? null : implode(' ', $term), $statement, $name, 'marc21'); $file($name, $role); } diff --git a/app/Support/Marc/MarcBookImporter.php b/app/Support/Marc/MarcBookImporter.php index e5d9b7dd5..8d31bc0dd 100644 --- a/app/Support/Marc/MarcBookImporter.php +++ b/app/Support/Marc/MarcBookImporter.php @@ -357,27 +357,62 @@ private function loadBook(int $id): ?array } /** + * The extra MARC columns to write for one record, column => value (NULL + * clears it). A record writes the columns it carries (in fill mode only + * where the book has them empty). When an overwrite changes the book's + * media type, the RDA overrides the record does not carry are cleared: + * they described the old material (a printed book's carrier "nb" on what + * is now a DVD), and without them the new media type implies its own + * RDA codes. + * * @param array> $data * @param array|null $existing + * @param callable(string): bool $hasColumn + * @return array */ - private function writeExtraColumns(int $bookId, array $data, ?array $existing): void + public static function extraColumnUpdates(string $mode, array $data, ?array $existing, callable $hasColumn): array { - $sets = []; - $params = []; + $newType = trim((string) ($data['tipo_media'] ?? '')); + $typeChanged = $existing !== null + && $mode === self::MODE_OVERWRITE + && $newType !== '' + && $newType !== trim((string) ($existing['tipo_media'] ?? '')); + $updates = []; foreach (self::EXTRA_COLUMNS as $column => $length) { + if (!$hasColumn($column)) { + continue; + } $value = trim((string) ($data[$column] ?? '')); - if ($value === '' || !$this->hasColumn($column)) { + if ($value === '') { + if ($typeChanged && str_starts_with($column, 'rda_') && trim((string) ($existing[$column] ?? '')) !== '') { + $updates[$column] = null; + } continue; } - if ($existing !== null && $this->mode === self::MODE_FILL && trim((string) ($existing[$column] ?? '')) !== '') { + if ($existing !== null && $mode === self::MODE_FILL && trim((string) ($existing[$column] ?? '')) !== '') { continue; } - $sets[] = "{$column} = ?"; - $params[] = mb_substr($value, 0, $length); + $updates[$column] = mb_substr($value, 0, $length); } - if ($sets === []) { + return $updates; + } + + /** + * @param array> $data + * @param array|null $existing + */ + private function writeExtraColumns(int $bookId, array $data, ?array $existing): void + { + $updates = self::extraColumnUpdates($this->mode, $data, $existing, fn (string $column): bool => $this->hasColumn($column)); + if ($updates === []) { return; } + $sets = []; + $params = []; + foreach ($updates as $column => $value) { + $sets[] = "{$column} = ?"; + $params[] = $value; + } $params[] = $bookId; $stmt = $this->db->prepare('UPDATE libri SET ' . implode(', ', $sets) . ' WHERE id = ? AND deleted_at IS NULL'); if ($stmt === false) { diff --git a/tests/marc-import-robustness.unit.php b/tests/marc-import-robustness.unit.php index ead25d234..7c14691ca 100644 --- a/tests/marc-import-robustness.unit.php +++ b/tests/marc-import-robustness.unit.php @@ -122,6 +122,16 @@ [$over, $row] = MarcBookImporter::plan(MarcBookImporter::MODE_OVERWRITE, $partial, $csv->normalizeImportRow(\App\Support\Marc\MarcBookData::toImportRow($partial)), ['isbn10' => '8845292614', 'traduttore' => 'Old Translator'] + $existing); $check($row['isbn10'] === '8845292614' && $row['traduttore'] === 'Old Translator' && $over['contributors'] === false, 'overwrite: a column the record leaves empty keeps the book\'s value'); $check($over['tipo_media'] === false, 'overwrite: a record without a media type leaves the book\'s'); +// An overwrite that changes the media type clears the RDA overrides the +// record does not carry (they described the old material). +$allColumns = static fn (string $column): bool => true; +$printed = ['tipo_media' => 'libro', 'rda_content_type' => '', 'rda_media_type' => 'n', 'rda_carrier_type' => 'nb', 'dimensioni' => '21 cm']; +$dvdRecord = ['tipo_media' => 'dvd', 'rda_content_type' => 'tdi'] + \App\Support\Marc\MarcBookData::empty(); +$up = MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_OVERWRITE, $dvdRecord, $printed, $allColumns); +$check($up === ['rda_content_type' => 'tdi', 'rda_media_type' => null, 'rda_carrier_type' => null], 'overwrite that changes the media type: the record\'s RDA override is written, the book\'s other overrides are cleared, other columns untouched'); +$check(MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_OVERWRITE, ['tipo_media' => 'libro'] + \App\Support\Marc\MarcBookData::empty(), $printed, $allColumns) === [], 'overwrite with the same media type keeps the book\'s RDA overrides'); +$check(MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_FILL, $dvdRecord, $printed, $allColumns) === ['rda_content_type' => 'tdi'], 'fill mode never clears an RDA override, only fills an empty one'); +$check(MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_OVERWRITE, $dvdRecord, $printed, static fn (string $c): bool => !str_starts_with($c, 'rda_')) === [], 'without the RDA columns nothing RDA is written or cleared'); // 4. Post-commit failures are warnings, not failed records. $envFile = $root . '/.env'; diff --git a/tests/marc-library.unit.php b/tests/marc-library.unit.php index da646336d..8a6525041 100644 --- a/tests/marc-library.unit.php +++ b/tests/marc-library.unit.php @@ -189,6 +189,12 @@ $c = (new Marc21LibriMapper())->map($corp); $check($c['autori'] === ['United States. Congress. Senate', 'Convegno di studi'], '51b 110 $a $b joined and a 711 meeting are authors; a publisher (710 $4 pbl) and a name-title entry are not'); $check($c['curatore'] === ['Istituto nazionale di statistica'], '51c a 710 with $4 edt is the editor'); +$meet = new MarcRecord(); +$meet->addDataField('111', '2', ' ', [['a', 'Congresso internazionale'], ['e', 'Comitato scientifico.'], ['d', '(2001 : Milano)']]); +$meet->addDataField('245', '1', '0', [['a', 'Atti']]); +$meet->addDataField('711', '2', ' ', [['a', 'Convegno di studi'], ['e', 'Sezione storica.'], ['j', 'editor.']]); +$mt = (new Marc21LibriMapper())->map($meet); +$check($mt['autori'] === ['Congresso internazionale. Comitato scientifico'] && $mt['curatore'] === ['Convegno di studi. Sezione storica'], '51d 111/711: $e is the subordinate unit (part of the name), $j the relator term'); echo "UNIMARC mapping\n"; $u = (new UnimarcLibriMapper())->map($uni[0]); @@ -373,6 +379,17 @@ $fits = false; } $check($fits, '74 with its long texts shortened the same book is exported instead of dropped'); +$hugeCut = $huge + []; +$hugeCut['descrizione'] = str_repeat('lunga ', 3500); // a 520 over the 9,999-byte field limit +$cutsSeen = ['stale']; +$bytesShort = \App\Controllers\MarcExportController::serializeIso('marc21', $hugeCut, [], [], null, $cutsSeen, $levelShort); +$check($bytesShort !== null && $levelShort === 'shortened' && $cutsSeen === [], '74a the field cuts of the rejected full record are not reported for the shortened one that is written'); +$crowd = array_map(static fn (int $i): array => ['nome' => 'Autore ' . str_repeat('n', 200) . " {$i}", 'ruolo' => 'principale'], range(1, 700)); +$cutsCrowd = []; +$bytesCrowd = \App\Controllers\MarcExportController::serializeIso('marc21', $huge, $crowd, [], null, $cutsCrowd, $levelCrowd); +$bytesCrowdUni = \App\Controllers\MarcExportController::serializeIso('unimarc', $huge, $crowd, [], null, $cutsCrowd, $levelCrowdUni); +$check($bytesCrowd !== null && $levelCrowd === 'reduced' && Iso2709Reader::parse(substr($bytesCrowd, 0, -1))->subfield('245', 'a') === 'T' + && $bytesCrowdUni !== null && $levelCrowdUni === 'reduced', '74b a book that overflows even shortened (700 authors) is exported as a reduced record, never skipped'); echo "Language names round trip\n"; $roundTrip = true; From 137589a3b23ef197944f1239009c7f13e1b29125 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:33:13 +0100 Subject: [PATCH 183/398] Tests: patron type, closures, fines and debt block end to end across website, app and NCIP --- tests/stack-090-integration.spec.js | 316 ++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 tests/stack-090-integration.spec.js diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js new file mode 100644 index 000000000..5f54f4061 --- /dev/null +++ b/tests/stack-090-integration.spec.js @@ -0,0 +1,316 @@ +// @ts-check +/** + * 0.9.0 cross-feature flow, end to end through the admin UI: a patron type + * with its own loan length, daily fine and debt threshold (#508/#510), the + * closures calendar (#509) moving the due date and keeping closed days out of + * the late days, the late return charging the type's fine, the debt blocking + * the reader on the website, the Mobile API and NCIP, and the payment at the + * reader record lifting the block on all three channels. + * + * The global fine settings stay EMPTY for the whole file: every fine and + * block comes from the patron type, and a reader of the default type + * returning just as late pays nothing. + * + * The DB is used only to create the patron/books, to age the loan (dates in + * the application timezone) and to verify what the UI wrote. + * + * Run: /tmp/run-e2e.sh tests/stack-090-integration.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); +const F = require('./helpers/circulation-fixtures'); +const M = require('./helpers/mobile-api-fixtures'); + +test.skip(!F.CONFIGURED, 'E2E credentials not configured'); + +const RUN = String(Date.now()).slice(-6); +const PREFIX = `e2eint${RUN}`; +const TAG = `E2E-INT-${RUN}`; +const PASS = 'Circ1234!'; +const TYPE_DAYS = 10; +const DEBT_MESSAGE = 'Saldo a debito oltre la soglia consentita'; +const LOAN_SETTINGS = ['daily_fine', 'fine_grace_days', 'fine_max_per_loan', 'debt_block_threshold', 'closed_weekdays', 'auto_approve_requests', 'fine_charged_email']; +const NCIP_NS = 'http://www.niso.org/2008/ncip'; + +const ncipXml = (service, userId, itemId) => ` + + <${service}> + ${userId} + ${itemId} + +`; +const ncipAuth = 'Basic ' + Buffer.from(`${F.ADMIN_EMAIL}:${F.ADMIN_PASS}`).toString('base64'); +const ncip = async (request, body) => (await request.post(`${F.BASE}/ncip`, { data: body, headers: { 'Content-Type': 'application/xml', Authorization: ncipAuth } })).text(); + +function readSetting(key) { + const exists = F.dbInt(`SELECT COUNT(*) FROM system_settings WHERE category='loans' AND setting_key=${F.sqlStr(key)}`) === 1; + return exists ? F.dbQuery(`SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key=${F.sqlStr(key)}`) : null; +} +function restoreSetting(key, value) { + if (value === null) F.dbQuery(`DELETE FROM system_settings WHERE category='loans' AND setting_key=${F.sqlStr(key)}`); + else F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', ${F.sqlStr(key)}, ${F.sqlStr(value)}) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); +} +function clearConfigCache() { + const cacheDir = path.join(__dirname, '..', 'storage', 'cache'); + if (!fs.existsSync(cacheDir)) return; + for (const name of fs.readdirSync(cacheDir)) { + if (name.startsWith('pinakes_config_settings_raw_')) { + try { fs.unlinkSync(path.join(cacheDir, name)); } catch { /* best effort */ } + } + } +} +function addDays(iso, days) { + const d = new Date(`${iso}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} +const balanceCents = (userId) => Math.round(Number(F.dbQuery(`SELECT COALESCE(SUM(importo),0) FROM movimenti_lettore WHERE utente_id=${userId}`)) * 100); + +async function addClosure(page, { start, end = '', description }) { + await page.goto(`${F.BASE}/admin/calendar`); + await page.fill('#closure_start', start); + if (end) await page.fill('#closure_end', end); + await page.fill('#closure_description', description); + await Promise.all([page.waitForURL(/\/admin\/calendar/, { timeout: 15000 }), page.locator('#save-closure').click()]); + expect(F.dbInt(`SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione=${F.sqlStr(description)}`), `closure ${description} saved`).toBe(1); +} + +/** Return screen: the fine it announces (cents, 0 when none), then confirm. */ +async function returnAtDesk(page, loanId) { + await page.goto(`${F.BASE}/admin/loans/returned/${loanId}`); + await expect(page.getByRole('button', { name: 'Conferma restituzione' })).toBeVisible(); + const box = page.locator('#fine-preview'); + const shown = (await box.isVisible().catch(() => false)) ? Number(await box.getAttribute('data-fine-cents')) : 0; + await page.getByRole('button', { name: 'Conferma restituzione' }).click(); + await page.waitForFunction(() => !window.location.pathname.includes('/admin/loans/returned/'), null, { timeout: 20000 }); + return shown; +} + +/** Age an active loan so that it was lent 20 days ago and due $late days ago. */ +function makeOverdue(loanId, late) { + F.dbQuery(`UPDATE prestiti SET data_prestito='${F.appDateOffsetISO(-20)}', data_scadenza='${F.appDateOffsetISO(-late)}', stato='in_ritardo' WHERE id=${loanId}`); +} + +test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block across channels', () => { + test.describe.configure({ timeout: 240000 }); + /** @type {import('@playwright/test').Page} */ let admin; + let adminCtx; + const saved = {}; + let savedClosures = []; + let gate = null; + let typeId = 0; + let reader; + let plain; + const books = {}; + const errors = []; + let today = ''; + let deskLoan = 0; + + test.beforeAll(async ({ browser }) => { + for (const k of LOAN_SETTINGS) saved[k] = readSetting(k); + savedClosures = F.dbQuery("SELECT CONCAT_WS(0x1f, id, data_inizio, data_fine, ricorrente_annuale, IFNULL(sede_id, ''), IFNULL(descrizione, '')) FROM calendario_chiusure").split('\n').filter(Boolean); + F.dbQuery('DELETE FROM calendario_chiusure'); + // Global fines and threshold empty: only the patron type charges and blocks. + for (const k of ['daily_fine', 'fine_max_per_loan', 'debt_block_threshold']) restoreSetting(k, ''); + restoreSetting('fine_grace_days', '0'); + restoreSetting('fine_charged_email', '0'); + restoreSetting('closed_weekdays', null); + restoreSetting('auto_approve_requests', '0'); + clearConfigCache(); + today = F.appTodayISO(); + + reader = F.createPatron(`INT${RUN}`, { password: PASS }); + plain = F.createPatron(`INP${RUN}`, { password: PASS }); + for (const key of ['desk', 'web', 'app', 'ncip', 'plain']) books[key] = F.createTitle(`${PREFIX}-${key}`); + + const helper = await browser.newPage(); + gate = await M.ensureMobileApiReady(helper); + await helper.close(); + const ncipId = F.dbInt("SELECT id FROM plugins WHERE name='ncip-server' LIMIT 1"); + expect(ncipId, 'ncip-server plugin registered').toBeGreaterThan(0); + + adminCtx = await browser.newContext(); + admin = await adminCtx.newPage(); + admin.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + if (F.dbQuery(`SELECT is_active FROM plugins WHERE id=${ncipId}`) !== '1') { + await admin.goto(`${F.BASE}/admin/plugins`); + await admin.evaluate(async (pid) => { + const fd = new FormData(); + fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); + await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/activate`, { method: 'POST', body: fd }); + }, ncipId); + } + }); + + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + + test.afterAll(async () => { + for (const k of LOAN_SETTINGS) restoreSetting(k, saved[k]); + clearConfigCache(); + F.dbQuery('DELETE FROM calendario_chiusure'); + for (const line of savedClosures) { + const [id, start, end, yearly, sede, description] = line.split('\x1f'); + F.dbQuery(`INSERT INTO calendario_chiusure (id, data_inizio, data_fine, ricorrente_annuale, sede_id, descrizione) VALUES (${Number(id)}, '${start}', '${end}', ${Number(yearly)}, ${sede === '' ? 'NULL' : Number(sede)}, ${F.sqlStr(description)})`); + } + for (const p of [reader, plain]) { + if (!p) continue; + try { F.dbQuery(`DELETE FROM mobile_app_tokens WHERE user_id=${p.id}`); } catch { /* no token table */ } + } + const ids = Object.values(books).map((b) => b.id); + if (ids.length) { + try { F.dbQuery(`DELETE FROM admin_notifications WHERE related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids.join(',')}))`); } catch { /* */ } + try { F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id IN (${ids.join(',')})`); } catch { /* */ } + } + F.deleteTitles(ids); + F.deletePatrons([reader?.id, plain?.id]); + F.deleteTypes(PREFIX); + M.restoreGate(gate); + await adminCtx?.close(); + }); + + test('1. the librarian creates a patron type with its own loan length, daily fine and debt threshold', async () => { + typeId = await F.createTypeViaUi(admin, { + nome: `Integrazione ${RUN}`, codice: `${PREFIX}-t`, durata_prestito_giorni: TYPE_DAYS, + multa_giornaliera: '1,00', giorni_tolleranza: 0, soglia_blocco_debito: '2', + }); + expect(F.dbQuery(`SELECT CONCAT_WS('|', durata_prestito_giorni, multa_giornaliera, IFNULL(giorni_tolleranza,'-'), soglia_blocco_debito) FROM tipologie_lettore WHERE id=${typeId}`)) + .toMatch(new RegExp(`^${TYPE_DAYS}\\|1\\.00\\|(0|-)\\|2\\.00$`)); + // The globals stay empty: the type is the only source of fines. + expect(readSetting('daily_fine')).toBe(''); + expect(readSetting('debt_block_threshold')).toBe(''); + }); + + test('2. the reader gets the type from the user edit form and the record shows its rules', async () => { + await admin.goto(`${F.BASE}/admin/users/edit/${reader.id}`); + await admin.locator('#tipologia_id').selectOption(String(typeId)); + await admin.locator('#user-form button[type="submit"]').click(); + await admin.waitForURL((u) => !u.pathname.includes('/edit/'), { timeout: 20000 }); + expect(admin.url()).not.toContain('error='); + expect(F.dbInt(`SELECT IFNULL(tipologia_id,0) FROM utenti WHERE id=${reader.id}`)).toBe(typeId); + await admin.goto(`${F.BASE}/admin/users/details/${reader.id}`); + await expect(admin.locator('[data-testid="patron-type-name"]')).toContainText(`Integrazione ${RUN}`); + await expect(admin.locator('[data-ledger-blocked]')).toHaveCount(0); + }); + + test('3. a closure on the type due date: the desk loan is proposed and stored on the next open day', async () => { + const raw = addDays(today, TYPE_DAYS); + await addClosure(admin, { start: raw, description: `${TAG} scadenza` }); + await admin.goto(`${F.BASE}/admin/loans/create`); + await F.fillAutocomplete(admin, '#utente_search', '#utente_suggest', reader.surname, '/api/search/utenti'); + await expect(admin.locator('#utente_id')).toHaveValue(String(reader.id)); + const availability = admin.waitForResponse((r) => r.url().includes('/availability') && r.url().includes(`for_user=${reader.id}`), { timeout: 15000 }); + const resolved = admin.waitForResponse((r) => r.url().includes('/admin/copies/by-code')); + await admin.locator('#copy_code').fill(F.copyCodeOf(books.desk.copyIds[0])); + await admin.locator('#copy_code').dispatchEvent('change'); + await resolved; + const data = await (await availability).json(); + expect(Number(data.availability.staff_loan_duration_days)).toBe(TYPE_DAYS); + await expect(admin.locator('#data_scadenza')).toHaveValue(addDays(raw, 1)); + await admin.evaluate(() => document.querySelectorAll('.flatpickr-calendar.open').forEach((c) => c.classList.remove('open'))); + await admin.uncheck('#scarica_pdf').catch(() => {}); + await Promise.all([ + admin.waitForURL((u) => u.toString().includes('created=1') || u.toString().includes('error='), { timeout: 20000 }), + admin.locator('form[action$="/admin/loans/create"] button[type="submit"]:not([name="save_and_new"])').click(), + ]); + expect(admin.url()).toContain('created=1'); + deskLoan = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.desk.id} AND utente_id=${reader.id} ORDER BY id DESC LIMIT 1`); + expect(F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE id=${deskLoan}`)).toBe(addDays(raw, 1)); + }); + + test('4. the late return is fined at the type rate on the open late days only', async () => { + // Due 6 days ago; the library was closed 4 and 3 days ago: 6 - 2 = 4 open late days. + makeOverdue(deskLoan, 6); + await addClosure(admin, { start: addDays(today, -4), end: addDays(today, -3), description: `${TAG} ponte` }); + await admin.goto(`${F.BASE}/admin/loans/details/${deskLoan}`); + await expect(admin.locator('[data-loan-fine-accrued]')).toHaveAttribute('data-loan-fine-accrued', '400'); + const shown = await returnAtDesk(admin, deskLoan); + expect(shown).toBe(400); + expect(F.dbQuery(`SELECT CONCAT(stato,'|',attivo) FROM prestiti WHERE id=${deskLoan}`)).toBe('restituito|0'); + expect(F.dbQuery(`SELECT GROUP_CONCAT(importo) FROM movimenti_lettore WHERE prestito_id=${deskLoan} AND tipo='multa_ritardo'`)).toBe('4.00'); + expect(balanceCents(reader.id)).toBe(400); + // 4,00 owed is over the type threshold of 2,00: the reader record says so. + await admin.goto(`${F.BASE}/admin/users/details/${reader.id}`); + await expect(admin.locator('[data-ledger-balance]')).toHaveAttribute('data-ledger-balance', '400'); + await expect(admin.locator('[data-ledger-blocked]')).toContainText('Prestiti bloccati'); + }); + + test('5. the website refuses the blocked reader with the debt message', async ({ browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + try { + await F.loginAs(page, reader.email, PASS); + const { ok, text } = await F.requestFromBookPage(page, books.web.id, today); + expect(ok).toBe(false); + expect(text).toContain(DEBT_MESSAGE); + } finally { + await ctx.close(); + } + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.web.id}`)).toBe(0); + }); + + test('6. the Mobile API refuses the same reader with the same message', async ({ request }) => { + const token = await M.login(request, reader.email, PASS, `int-${RUN}`); + const r = await M.api(request, 'POST', '/reservations', { token, body: { book_id: books.app.id } }); + expect(r.status, r.text.slice(0, 300)).toBe(403); + expect(r.json.error.code).toBe('not_eligible'); + expect(r.json.error.message).toContain(DEBT_MESSAGE); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.app.id}`)).toBe(0); + }); + + test('7. NCIP refuses the checkout to the same reader (User Ineligible)', async ({ request }) => { + const body = await ncip(request, ncipXml('CheckOutItem', reader.id, books.ncip.id)); + expect(body).toContain('User Ineligible To Check Out This Item'); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.ncip.id}`)).toBe(0); + }); + + test('8. the payment at the reader record lifts the block on website, app and NCIP', async ({ browser, request }) => { + await admin.goto(`${F.BASE}/admin/users/details/${reader.id}#movimenti`); + await admin.fill('#ledger_pay_amount', '4,00'); + await admin.selectOption('#ledger_pay_method', 'contanti'); + await admin.locator('#ledger-payment-form button[type="submit"]').click(); + await expect(admin.locator('[data-ledger-flash="paid"]')).toBeVisible(); + expect(balanceCents(reader.id)).toBe(0); + await expect(admin.locator('[data-ledger-blocked]')).toHaveCount(0); + + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + try { + await F.loginAs(page, reader.email, PASS); + const { ok, text } = await F.requestFromBookPage(page, books.web.id, today); + expect(ok, text).toBe(true); + } finally { + await ctx.close(); + } + expect(F.dbQuery(`SELECT stato FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.web.id} ORDER BY id DESC LIMIT 1`)).toBe('pendente'); + + const token = await M.login(request, reader.email, PASS, `int2-${RUN}`); + const r = await M.api(request, 'POST', '/reservations', { token, body: { book_id: books.app.id } }); + expect(r.status, r.text.slice(0, 300)).toBeLessThan(300); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.app.id}`)).toBe(1); + + const body = await ncip(request, ncipXml('CheckOutItem', reader.id, books.ncip.id)); + expect(body).toContain('CheckOutItemResponse'); + expect(body).not.toContain('User Ineligible'); + // The NCIP loan follows the type length, moved over a closure if one falls on it. + const ncipDue = F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.ncip.id} AND attivo=1`); + expect(ncipDue).toBe(addDays(today, TYPE_DAYS + 1)); + }); + + test('9. a reader of the default type, just as late over the same closures, pays nothing (globals empty)', async () => { + const copyId = books.plain.copyIds[0]; + F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${copyId}`); + F.dbQuery(`UPDATE libri SET copie_disponibili=0, stato='prestato' WHERE id=${books.plain.id}`); + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (${books.plain.id}, ${copyId}, ${plain.id}, '${F.appDateOffsetISO(-20)}', '${F.appDateOffsetISO(-6)}', 'in_ritardo', 'diretto', 1)`); + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.plain.id} AND utente_id=${plain.id} ORDER BY id DESC LIMIT 1`); + await admin.goto(`${F.BASE}/admin/loans/returned/${loanId}`); + await expect(admin.locator('#fine-preview')).toHaveCount(0); + const shown = await returnAtDesk(admin, loanId); + expect(shown).toBe(0); + expect(F.dbQuery(`SELECT stato FROM prestiti WHERE id=${loanId}`)).toBe('restituito'); + expect(F.dbInt(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${plain.id}`)).toBe(0); + await admin.goto(`${F.BASE}/admin/users/details/${plain.id}`); + await expect(admin.locator('[data-ledger-blocked]')).toHaveCount(0); + }); +}); From ac8588392ea27b86bd19ca88f57f94e09b78a616 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:36:29 +0100 Subject: [PATCH 184/398] Tests: an install with nothing configured keeps the 0.8.9 due dates, renewals, pickups and never fines or blocks --- tests/stack-090-zero-config.spec.js | 328 ++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 tests/stack-090-zero-config.spec.js diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js new file mode 100644 index 000000000..bde880b97 --- /dev/null +++ b/tests/stack-090-zero-config.spec.js @@ -0,0 +1,328 @@ +// @ts-check +/** + * Zero-config equivalence of the 0.9.0 stack: an install that never touches + * patron types, the rules matrix, the closures calendar, fines or the debt + * threshold must behave as 0.8.9 did. Due dates and renewals follow + * Settings → Loans (loan_duration_days), the pickup deadline follows + * pickup_expiry_days, a late return charges nothing and its email has no + * fine line, and a reader with money owed in the ledger is never blocked on + * the website, the Mobile API or NCIP. + * + * beforeAll puts the install in that state (empty calendar, no matrix rows, + * empty fine settings) and afterAll restores what was there. The DB is used + * only for that, for the fixtures (dates in the application timezone) and to + * verify what the UI wrote. + * + * Run: /tmp/run-e2e.sh tests/stack-090-zero-config.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); +const F = require('./helpers/circulation-fixtures'); +const M = require('./helpers/mobile-api-fixtures'); + +test.skip(!F.CONFIGURED, 'E2E credentials not configured'); + +const RUN = String(Date.now()).slice(-6); +const PREFIX = `e2ezc${RUN}`; +const PASS = 'Circ1234!'; +const MAILPIT_API = process.env.MAILPIT_API || 'http://localhost:8025/api/v1'; +const LOAN_SETTINGS = ['daily_fine', 'fine_grace_days', 'fine_max_per_loan', 'debt_block_threshold', 'closed_weekdays', 'auto_approve_requests', 'fine_charged_email']; +const NCIP_NS = 'http://www.niso.org/2008/ncip'; + +const ncipXml = (service, userId, itemId) => ` + + <${service}> + ${userId} + ${itemId} + +`; +const ncipAuth = 'Basic ' + Buffer.from(`${F.ADMIN_EMAIL}:${F.ADMIN_PASS}`).toString('base64'); +const ncip = async (request, body) => (await request.post(`${F.BASE}/ncip`, { data: body, headers: { 'Content-Type': 'application/xml', Authorization: ncipAuth } })).text(); + +function readSetting(category, key) { + const exists = F.dbInt(`SELECT COUNT(*) FROM system_settings WHERE category=${F.sqlStr(category)} AND setting_key=${F.sqlStr(key)}`) === 1; + return exists ? F.dbQuery(`SELECT setting_value FROM system_settings WHERE category=${F.sqlStr(category)} AND setting_key=${F.sqlStr(key)}`) : null; +} +function writeSetting(category, key, value) { + if (value === null) F.dbQuery(`DELETE FROM system_settings WHERE category=${F.sqlStr(category)} AND setting_key=${F.sqlStr(key)}`); + else F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES (${F.sqlStr(category)}, ${F.sqlStr(key)}, ${F.sqlStr(value)}) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); +} +function clearConfigCache() { + const cacheDir = path.join(__dirname, '..', 'storage', 'cache'); + if (!fs.existsSync(cacheDir)) return; + for (const name of fs.readdirSync(cacheDir)) { + if (name.startsWith('pinakes_config_settings_raw_')) { + try { fs.unlinkSync(path.join(cacheDir, name)); } catch { /* best effort */ } + } + } +} +function addDays(iso, days) { + const d = new Date(`${iso}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} +/** Rows of a table as tab-separated lines, to put back verbatim afterwards. */ +function stash(table) { + const cols = F.dbQuery(`SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='${table}'`); + const select = cols.split(',').map((c) => `QUOTE(\`${c}\`)`).join(", ',', "); + const rows = F.dbQuery(`SELECT CONCAT(${select}) FROM ${table}`).split('\n').filter(Boolean); + return { cols, rows }; +} +function unstash(table, s) { + F.dbQuery(`DELETE FROM ${table}`); + for (const values of s.rows) F.dbQuery(`INSERT INTO ${table} (${s.cols}) VALUES (${values})`); +} + +/** A title with one copy lent to $patron, lent 20 days ago and due $late days ago. */ +function overdueLoan(patron, book, late) { + const copyId = book.copyIds[0]; + F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${copyId}`); + F.dbQuery(`UPDATE libri SET copie_disponibili=0, stato='prestato' WHERE id=${book.id}`); + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (${book.id}, ${copyId}, ${patron.id}, '${F.appDateOffsetISO(-20)}', '${F.appDateOffsetISO(-late)}', 'in_ritardo', 'diretto', 1)`); + return F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${book.id} AND utente_id=${patron.id} ORDER BY id DESC LIMIT 1`); +} + +test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { + test.describe.configure({ timeout: 240000 }); + /** @type {import('@playwright/test').Page} */ let admin; + let adminCtx; + const saved = {}; + let closures; + let rules; + let emailSettings; + let gate = null; + let reader; + let debtor; + const books = {}; + const errors = []; + let today = ''; + let loanDays = 30; + let pickupDays = 3; + let deskLoan = 0; + + test.beforeAll(async ({ browser }) => { + for (const k of LOAN_SETTINGS) saved[k] = readSetting('loans', k); + closures = stash('calendario_chiusure'); + rules = stash('regole_prestito'); + emailSettings = F.dbQuery("SELECT CONCAT(QUOTE(setting_key), ',', QUOTE(setting_value)) FROM system_settings WHERE category='email'").split('\n').filter(Boolean); + F.dbQuery('DELETE FROM calendario_chiusure'); + F.dbQuery('DELETE FROM regole_prestito'); + for (const k of ['daily_fine', 'fine_max_per_loan', 'debt_block_threshold']) writeSetting('loans', k, ''); + writeSetting('loans', 'fine_grace_days', '0'); + writeSetting('loans', 'fine_charged_email', '0'); + writeSetting('loans', 'closed_weekdays', null); + writeSetting('loans', 'auto_approve_requests', '0'); + clearConfigCache(); + today = F.appTodayISO(); + loanDays = Number(readSetting('loans', 'loan_duration_days') || 30); + pickupDays = Number(readSetting('loans', 'pickup_expiry_days') || 3); + + reader = F.createPatron(`ZCR${RUN}`, { password: PASS, email: `zc-reader-${RUN}@circ.test` }); + debtor = F.createPatron(`ZCD${RUN}`, { password: PASS, email: `zc-debtor-${RUN}@circ.test` }); + // Money owed in the ledger, as an earlier manual charge would leave it. + F.dbQuery(`INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES (${debtor.id}, 'addebito_manuale', 50.00, 'Tessera smarrita', NOW())`); + for (const key of ['desk', 'renew', 'request', 'late', 'web', 'app', 'ncip']) books[key] = F.createTitle(`${PREFIX}-${key}`); + + const helper = await browser.newPage(); + gate = await M.ensureMobileApiReady(helper); + await helper.close(); + + adminCtx = await browser.newContext(); + admin = await adminCtx.newPage(); + admin.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + const ncipId = F.dbInt("SELECT id FROM plugins WHERE name='ncip-server' LIMIT 1"); + if (ncipId > 0 && F.dbQuery(`SELECT is_active FROM plugins WHERE id=${ncipId}`) !== '1') { + await admin.goto(`${F.BASE}/admin/plugins`); + await admin.evaluate(async (pid) => { + const fd = new FormData(); + fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); + await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/activate`, { method: 'POST', body: fd }); + }, ncipId); + } + }); + + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + + test.afterAll(async () => { + for (const k of LOAN_SETTINGS) writeSetting('loans', k, saved[k]); + unstash('calendario_chiusure', closures); + unstash('regole_prestito', rules); + if (emailSettings.length) { + F.dbQuery("DELETE FROM system_settings WHERE category='email'"); + for (const pair of emailSettings) F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('email', ${pair})`); + } + clearConfigCache(); + for (const p of [reader, debtor]) { + if (!p) continue; + try { F.dbQuery(`DELETE FROM mobile_app_tokens WHERE user_id=${p.id}`); } catch { /* no token table */ } + } + const ids = Object.values(books).map((b) => b.id); + if (ids.length) { + try { F.dbQuery(`DELETE FROM admin_notifications WHERE related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids.join(',')}))`); } catch { /* */ } + try { F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id IN (${ids.join(',')})`); } catch { /* */ } + } + F.deleteTitles(ids); + F.deletePatrons([reader?.id, debtor?.id]); + M.restoreGate(gate); + await adminCtx?.close(); + }); + + test('1. the settings, calendar and circulation pages show nothing configured', async () => { + await admin.goto(`${F.BASE}/admin/settings?tab=loans`); + for (const id of ['#daily_fine', '#fine_max_per_loan', '#debt_block_threshold']) await expect(admin.locator(id)).toHaveValue(''); + // Saving the tab unchanged (also refreshes the server's settings cache) enables nothing. + await admin.locator('form[action$="/admin/settings/loans"] button[type="submit"]').click(); + await admin.waitForURL(/tab=loans/, { timeout: 15000 }); + expect(readSetting('loans', 'daily_fine')).toBe(''); + expect(readSetting('loans', 'debt_block_threshold')).toBe(''); + await F.openCirculationTab(admin); + // Only the seeded default type, inheriting every rule; an empty matrix. + await expect(admin.locator('#circulation-types-table tbody tr[data-type-code]')).toHaveCount(F.dbInt('SELECT COUNT(*) FROM tipologie_lettore')); + expect(F.dbInt('SELECT COUNT(*) FROM regole_prestito')).toBe(0); + expect(F.dbInt(`SELECT COUNT(*) FROM tipologie_lettore WHERE predefinita=1 AND durata_prestito_giorni IS NULL AND max_rinnovi IS NULL AND multa_giornaliera IS NULL AND soglia_blocco_debito IS NULL`)).toBe(1); + await admin.goto(`${F.BASE}/admin/calendar`); + await expect(admin.locator('#save-closure')).toBeVisible(); + expect(F.dbInt('SELECT COUNT(*) FROM calendario_chiusure')).toBe(0); + }); + + test('2. the desk loan is due after loan_duration_days, as in 0.8.9', async () => { + await admin.goto(`${F.BASE}/admin/loans/create`); + await expect(admin.locator('#data_scadenza')).toHaveValue(addDays(today, loanDays)); + await F.fillAutocomplete(admin, '#utente_search', '#utente_suggest', reader.surname, '/api/search/utenti'); + await expect(admin.locator('#utente_id')).toHaveValue(String(reader.id)); + const availability = admin.waitForResponse((r) => r.url().includes('/availability') && r.url().includes(`for_user=${reader.id}`), { timeout: 15000 }); + const resolved = admin.waitForResponse((r) => r.url().includes('/admin/copies/by-code')); + await admin.locator('#copy_code').fill(F.copyCodeOf(books.desk.copyIds[0])); + await admin.locator('#copy_code').dispatchEvent('change'); + await resolved; + const data = await (await availability).json(); + expect(Number(data.availability.staff_loan_duration_days)).toBe(loanDays); + await expect(admin.locator('#data_scadenza')).toHaveValue(addDays(today, loanDays)); + await admin.evaluate(() => document.querySelectorAll('.flatpickr-calendar.open').forEach((c) => c.classList.remove('open'))); + await admin.uncheck('#scarica_pdf').catch(() => {}); + await Promise.all([ + admin.waitForURL((u) => u.toString().includes('created=1') || u.toString().includes('error='), { timeout: 20000 }), + admin.locator('form[action$="/admin/loans/create"] button[type="submit"]:not([name="save_and_new"])').click(), + ]); + expect(admin.url()).toContain('created=1'); + deskLoan = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.desk.id} AND utente_id=${reader.id} ORDER BY id DESC LIMIT 1`); + expect(F.dbQuery(`SELECT CONCAT(data_prestito,'|',data_scadenza) FROM prestiti WHERE id=${deskLoan}`)).toBe(`${today}|${addDays(today, loanDays)}`); + }); + + test('3. a renewal adds loan_duration_days to the due date and counts one renewal', async () => { + const copyId = books.renew.copyIds[0]; + const due = addDays(today, 5); + F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${copyId}`); + F.dbQuery(`UPDATE libri SET copie_disponibili=0, stato='prestato' WHERE id=${books.renew.id}`); + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (${books.renew.id}, ${copyId}, ${reader.id}, '${F.appDateOffsetISO(-3)}', '${due}', 'in_corso', 'diretto', 1)`); + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.renew.id} AND utente_id=${reader.id} AND attivo=1`); + await admin.goto(`${F.BASE}/admin/books/${books.renew.id}`); + await admin.getByRole('button', { name: /Rinnova prestito/ }).click(); + await admin.locator('.swal2-confirm').click(); + await admin.waitForFunction((id) => window.location.search.includes('renewed') || window.location.search.includes('error=') || !document.querySelector(`[data-loan-id="${id}"]`), loanId, { timeout: 20000 }).catch(() => {}); + await expect.poll(() => F.dbQuery(`SELECT CONCAT(data_scadenza,'|',renewals) FROM prestiti WHERE id=${loanId}`), { timeout: 15000 }).toBe(`${addDays(due, loanDays)}|1`); + expect(admin.url()).not.toContain('error='); + }); + + test('4. a reader request and its approval: due after loan_duration_days, pickup after pickup_expiry_days', async ({ browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + try { + await F.loginAs(page, reader.email, PASS); + const { ok, text } = await F.requestFromBookPage(page, books.request.id, today); + expect(ok, text).toBe(true); + } finally { + await ctx.close(); + } + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.request.id} AND utente_id=${reader.id} AND stato='pendente'`); + expect(loanId).toBeGreaterThan(0); + expect(F.dbQuery(`SELECT CONCAT(data_prestito,'|',data_scadenza) FROM prestiti WHERE id=${loanId}`)).toBe(`${today}|${addDays(today, loanDays)}`); + + await admin.goto(`${F.BASE}/admin/loans/pending`); + const approve = admin.locator(`.approve-btn[data-loan-id="${loanId}"]`); + await expect(approve).toBeVisible({ timeout: 15000 }); + await approve.click(); + await admin.waitForSelector('.swal2-popup', { timeout: 10000 }); + await admin.locator('.swal2-confirm').click(); + await expect.poll(() => F.dbQuery(`SELECT stato FROM prestiti WHERE id=${loanId}`), { timeout: 15000 }).toBe('da_ritirare'); + expect(F.dbQuery(`SELECT CONCAT(data_scadenza,'|',IFNULL(pickup_deadline,'-')) FROM prestiti WHERE id=${loanId}`)) + .toBe(`${addDays(today, loanDays)}|${addDays(today, Math.min(pickupDays, loanDays))}`); + }); + + test('5. a late return charges nothing, the loan page shows no fine and the return email has no fine line', async () => { + let mailpit = false; + try { mailpit = (await fetch(`${MAILPIT_API}/messages`, { signal: AbortSignal.timeout(3000) })).ok; } catch { mailpit = false; } + if (mailpit) { + F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES + ('email','type','smtp'),('email','driver_mode','smtp'),('email','smtp_host','localhost'),('email','smtp_port','1025'), + ('email','smtp_security',''),('email','smtp_username',''),('email','smtp_password',''), + ('email','from_email','test@biblioteca.local'),('email','from_name','Pinakes Test') + ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); + clearConfigCache(); + // Saving through the app invalidates the web server's settings cache too. + await admin.goto(`${F.BASE}/admin/settings?tab=loans`); + await admin.locator('form[action$="/admin/settings/loans"] button[type="submit"]').click(); + await admin.waitForURL(/tab=loans/, { timeout: 15000 }); + } + const loanId = overdueLoan(reader, books.late, 12); + await admin.goto(`${F.BASE}/admin/loans/details/${loanId}`); + await expect(admin.locator('[data-loan-fine-accrued]')).toHaveCount(0); + await admin.goto(`${F.BASE}/admin/loans/returned/${loanId}`); + await expect(admin.getByRole('button', { name: 'Conferma restituzione' })).toBeVisible(); + await expect(admin.locator('#fine-preview')).toHaveCount(0); + await expect(admin.locator('#waive_fine')).toHaveCount(0); + await admin.getByRole('button', { name: 'Conferma restituzione' }).click(); + await admin.waitForFunction(() => !window.location.pathname.includes('/admin/loans/returned/'), null, { timeout: 20000 }); + expect(F.dbQuery(`SELECT CONCAT(stato,'|',attivo,'|',restituito_in_ritardo) FROM prestiti WHERE id=${loanId}`)).toBe('restituito|0|1'); + expect(F.dbInt(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${reader.id}`)).toBe(0); + + if (!mailpit) return; + const search = async (query) => (await (await fetch(`${MAILPIT_API}/search?query=${encodeURIComponent(query)}`)).json()).messages || []; + let returned = null; + const deadline = Date.now() + 30000; + while (!returned && Date.now() < deadline) { + for (const m of await search(`to:${reader.email} subject:"Restituzione"`)) { + const full = await (await fetch(`${MAILPIT_API}/message/${m.ID}`)).json(); + if (full.HTML.includes(books.late.title)) { returned = full; break; } + } + if (!returned) await new Promise((r) => setTimeout(r, 500)); + } + expect(returned, 'loan_returned email in Mailpit').not.toBeNull(); + expect(returned.HTML).not.toContain('Multa'); + expect(returned.HTML).not.toContain('{{multa}}'); + expect(await search(`to:${reader.email} subject:"Multa per ritardo"`)).toEqual([]); + }); + + test('6. money owed in the ledger does not block the website request', async ({ browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + try { + await F.loginAs(page, debtor.email, PASS); + const { ok, text } = await F.requestFromBookPage(page, books.web.id, today); + expect(ok, text).toBe(true); + } finally { + await ctx.close(); + } + expect(F.dbQuery(`SELECT stato FROM prestiti WHERE utente_id=${debtor.id} AND libro_id=${books.web.id}`)).toBe('pendente'); + // The reader record shows the balance, without any block. + await admin.goto(`${F.BASE}/admin/users/details/${debtor.id}`); + await expect(admin.locator('[data-ledger-balance]')).toHaveAttribute('data-ledger-balance', '5000'); + await expect(admin.locator('[data-ledger-blocked]')).toHaveCount(0); + }); + + test('7. money owed does not block the Mobile API request', async ({ request }) => { + const token = await M.login(request, debtor.email, PASS, `zc-${RUN}`); + const r = await M.api(request, 'POST', '/reservations', { token, body: { book_id: books.app.id } }); + expect(r.status, r.text.slice(0, 300)).toBeLessThan(300); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${debtor.id} AND libro_id=${books.app.id}`)).toBe(1); + }); + + test('8. money owed does not block an NCIP checkout, due after loan_duration_days', async ({ request }) => { + const body = await ncip(request, ncipXml('CheckOutItem', debtor.id, books.ncip.id)); + expect(body).toContain('CheckOutItemResponse'); + expect(body).not.toContain('User Ineligible'); + expect(F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE utente_id=${debtor.id} AND libro_id=${books.ncip.id} AND attivo=1`)).toBe(addDays(today, loanDays)); + }); +}); From c79b24a6d6ba0fd034a95bead5271539f278ca2d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:46:25 +0100 Subject: [PATCH 185/398] Suppliers list: the search box follows the quoted-search rule (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supplier filter is the one search box of the 0.9.0 stack that runs in the browser, and it compared the typed text with includes(): a quoted word ("Müller") matched nothing because the quotes were searched as characters, and an unquoted "muller" did not find Müller. It now applies the ExactSearch rules: unquoted words ignore accents and case, a quoted group (straight or typographic quotes) keeps the exact spelling with case ignored, every term must match, and a box with only quotes filters nothing. The quote hint used under the other admin filters is shown under it too. --- app/Views/fornitori/index.php | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/app/Views/fornitori/index.php b/app/Views/fornitori/index.php index d39e6e461..777e1449c 100644 --- a/app/Views/fornitori/index.php +++ b/app/Views/fornitori/index.php @@ -54,8 +54,9 @@ - +

      @@ -130,10 +131,34 @@ class="form-input" /> document.addEventListener('DOMContentLoaded', function () { const search = document.getElementById('search_fornitore'); if (search) { + // Same rules as App\Support\ExactSearch (#501): a word matches whatever + // its accents ("muller" finds Müller), a quoted group matches the exact + // spelling ("Muller" does not find Müller), case is ignored, and every + // term must match. + const fold = function (s) { return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLocaleLowerCase(); }; + const exact = function (s) { return s.normalize('NFC').toLocaleLowerCase(); }; + const terms = function (query) { + const out = []; + const re = /["“”„«»]([^"“”„«»]*)(?:["“”„«»]|$)|([^\s"“”„«»]+)/g; + let m; + while ((m = re.exec(query)) !== null) { + if (m[1] !== undefined) { + const phrase = m[1].trim().replace(/\s+/g, ' '); + if (phrase !== '') { out.push({ term: exact(phrase), exact: true }); } + } else { + out.push({ term: fold(m[2]), exact: false }); + } + } + return out; + }; search.addEventListener('input', function () { - const q = search.value.trim().toLowerCase(); + const wanted = terms(search.value); document.querySelectorAll('[data-supplier-row]').forEach(function (row) { - row.classList.toggle('hidden', q !== '' && !(row.dataset.name || '').includes(q)); + const name = row.dataset.name || ''; + const exactName = exact(name); + const foldedName = fold(name); + const match = wanted.every(function (t) { return (t.exact ? exactName : foldedName).includes(t.term); }); + row.classList.toggle('hidden', !match); }); }); } From e70b89e6abf311a0b71594e6de940224c5cdff9f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:46:32 +0100 Subject: [PATCH 186/398] Tests: late 0.9.0 review fixes (admin-only calendar and retention, ledger replays, turned-up copies, MARC X11, media type and oversized export, quoted search on the new boxes) --- tests/stack-090-review-fixes.spec.js | 538 +++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 tests/stack-090-review-fixes.spec.js diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js new file mode 100644 index 000000000..c7b9d2a96 --- /dev/null +++ b/tests/stack-090-review-fixes.spec.js @@ -0,0 +1,538 @@ +// @ts-check +/** + * Review fixes made late in the 0.9.0 stack, driven like a librarian would: + * + * A. the closures calendar and the visitor-log retention are admin-only: + * staff gets neither the forms nor the actions (GET and every POST); + * B. the reader-ledger desk forms are recorded once (charge and waiver sent + * twice), and a replayed key with other values is refused; + * C. stocktake: a copy recorded as damaged that turns up is reported as + * found again, and a "mark missing as lost" close leaves it alone; + * D. MARC: 111/711 meeting entries ($e unit, $j relator), an overwrite that + * changes the media type clears stale RDA overrides (same type keeps + * them), an oversized record is exported reduced instead of skipped; + * E. #501 quoted search on the search boxes the stack added: the suppliers + * list (client-side filter), the users list with the patron type filter, + * the book list with the supplier filter. + * + * The DB is used only for fixtures and to verify the effects. + * + * Run: /tmp/run-e2e.sh tests/stack-090-review-fixes.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const F = require('./helpers/circulation-fixtures'); +const MARC = require('./helpers/marc-e2e'); + +test.skip(!F.CONFIGURED, 'E2E credentials not configured'); + +const RUN = String(Date.now()).slice(-7); +const ROOT = path.join(__dirname, '..'); +const PASS = 'Circ1234!'; +const S = F.sqlStr; + +function isbn13(twelve) { + const digits = String(twelve).padStart(12, '0').slice(-12); + const sum = digits.split('').reduce((acc, d, i) => acc + Number(d) * (i % 2 ? 3 : 1), 0); + return digits + String((10 - (sum % 10)) % 10); +} +const xmlEsc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); +/** A MARCXML collection file; records = [{leader, fields: [[tag, ind1, ind2, [[code, value]...]]...]}]. */ +function marcXmlFile(name, records) { + const body = records.map((r) => `${r.leader}` + + r.fields.map(([tag, i1, i2, subs]) => `${subs.map(([c, v]) => `${xmlEsc(v)}`).join('')}`).join('') + + '').join(''); + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pinakes-rf-')), name); + fs.writeFileSync(file, `${body}`); + return file; +} + +// ── A. Admin-only settings for staff ───────────────────────────────────── +test.describe.serial('A. closures calendar and visitor-log retention are reserved to administrators', () => { + /** @type {import('@playwright/test').Page} */ let staffPage; + /** @type {import('@playwright/test').Page} */ let adminPage; + let staff; + let closureId = 0; + let presenzaId = 0; + const TAG = `E2E-RFA-${RUN}`; + let savedWeekdays = null; + let savedRetention = null; + const calendarSnapshot = () => F.dbQuery("SELECT CONCAT(COUNT(*),'|',COALESCE(SUM(CRC32(CONCAT_WS('|',id,data_inizio,data_fine,ricorrente_annuale,IFNULL(sede_id,''),IFNULL(descrizione,'')))),0)) FROM calendario_chiusure") + + '#' + F.dbQuery("SELECT IFNULL(MAX(setting_value),'-') FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + + test.beforeAll(async ({ browser }) => { + savedWeekdays = F.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + savedRetention = F.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='attendance' AND setting_key='retention_days'"); + adminPage = await (await browser.newContext()).newPage(); + await F.loginAsAdmin(adminPage); + // A closure made by the administrator from the calendar page. + await adminPage.goto(`${F.BASE}/admin/calendar`); + await adminPage.fill('#closure_start', F.appDateOffsetISO(40)); + await adminPage.fill('#closure_description', TAG); + await Promise.all([adminPage.waitForURL(/\/admin\/calendar/), adminPage.locator('#save-closure').click()]); + closureId = F.dbInt(`SELECT id FROM calendario_chiusure WHERE descrizione=${S(TAG)}`); + expect(closureId).toBeGreaterThan(0); + // A visit older than any retention, which a purge would remove. + F.dbQuery(`INSERT INTO presenze (tipo, nome_ospite, motivo, created_at) VALUES ('ospite', ${S(TAG)}, 'E2E', DATE_SUB(NOW(), INTERVAL 4000 DAY))`); + presenzaId = F.dbInt(`SELECT id FROM presenze WHERE nome_ospite=${S(TAG)}`); + staff = F.createPatron(`RFA${RUN}`, { role: 'staff', password: PASS }); + staffPage = await (await browser.newContext()).newPage(); + await F.loginAs(staffPage, staff.email, PASS); + }); + + test.afterAll(async () => { + F.dbQuery(`DELETE FROM calendario_chiusure WHERE descrizione=${S(TAG)}`); + F.dbQuery(`DELETE FROM presenze WHERE nome_ospite=${S(TAG)}`); + if (savedWeekdays === '') F.dbQuery("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + else F.dbQuery(`UPDATE system_settings SET setting_value=${S(savedWeekdays)} WHERE category='loans' AND setting_key='closed_weekdays'`); + if (savedRetention === '') F.dbQuery("DELETE FROM system_settings WHERE category='attendance' AND setting_key='retention_days'"); + else F.dbQuery(`UPDATE system_settings SET setting_value=${S(savedRetention)} WHERE category='attendance' AND setting_key='retention_days'`); + F.deletePatrons([staff?.id]); + await staffPage?.context().close(); + await adminPage?.context().close(); + }); + + test('A1. staff opening the calendar page lands on the dashboard with the admin-only message', async () => { + await staffPage.goto(`${F.BASE}/admin/calendar`); + await expect(staffPage).toHaveURL(/\/admin\/dashboard/); + await expect(staffPage.locator('body')).toContainText(/riservata agli amministratori/i); + await expect(staffPage.locator('#save-closure')).toHaveCount(0); + }); + + test('A2. every calendar action sent by staff is refused and changes nothing', async () => { + const before = calendarSnapshot(); + await staffPage.goto(`${F.BASE}/admin/dashboard`); + const csrf = String(await staffPage.locator('meta[name="csrf-token"]').getAttribute('content')); + const day = F.appDateOffsetISO(41); + const posts = [ + ['/admin/calendar/weekdays', { 'closed_weekdays[]': '0' }], + ['/admin/calendar/closures', { data_inizio: day, data_fine: day, descrizione: `${TAG} intruso` }], + [`/admin/calendar/closures/${closureId}/update`, { data_inizio: day, data_fine: day, descrizione: `${TAG} cambiato` }], + [`/admin/calendar/closures/${closureId}/delete`, {}], + ['/admin/calendar/preset', { year: day.slice(0, 4) }], + ]; + for (const [route, fields] of posts) { + const r = await staffPage.request.post(`${F.BASE}${route}`, { form: { csrf_token: csrf, ...fields }, maxRedirects: 0 }); + expect(r.status(), route).toBe(302); + expect(r.headers()['location'] || '', route).toMatch(/\/admin\/dashboard/); + } + const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;VALUE=DATE:${day.replace(/-/g, '')}\r\nSUMMARY:${TAG} ics\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; + const r = await staffPage.request.post(`${F.BASE}/admin/calendar/import-ics`, { + multipart: { csrf_token: csrf, ics_file: { name: 'chiusure.ics', mimeType: 'text/calendar', buffer: Buffer.from(ics) } }, + maxRedirects: 0, + }); + expect(r.status()).toBe(302); + expect(r.headers()['location'] || '').toMatch(/\/admin\/dashboard/); + expect(calendarSnapshot()).toBe(before); + expect(F.dbQuery(`SELECT descrizione FROM calendario_chiusure WHERE id=${closureId}`)).toBe(TAG); + }); + + test('A3. the administrator edits the same closure from the calendar page', async () => { + await adminPage.goto(`${F.BASE}/admin/calendar?edit=${closureId}`); + await expect(adminPage.locator('#closure_description')).toHaveValue(TAG); + await adminPage.fill('#closure_description', `${TAG}`); + await adminPage.fill('#closure_end', F.appDateOffsetISO(42)); + await Promise.all([adminPage.waitForURL(/\/admin\/calendar/), adminPage.locator('#save-closure').click()]); + expect(F.dbQuery(`SELECT data_fine FROM calendario_chiusure WHERE id=${closureId}`)).toBe(F.appDateOffsetISO(42)); + }); + + test('A4. staff sees no retention forms on the visitor log, and posting them changes nothing', async () => { + await staffPage.goto(`${F.BASE}/admin/attendance`); + await expect(staffPage).toHaveURL(/\/admin\/attendance/); + await expect(staffPage.locator('#attendance-retention-form')).toHaveCount(0); + await expect(staffPage.locator('#attendance-purge-form')).toHaveCount(0); + const csrf = String(await staffPage.locator('meta[name="csrf-token"]').getAttribute('content')); + for (const [route, fields] of [['/admin/attendance/settings', { retention_days: '1' }], ['/admin/attendance/purge', {}]]) { + const r = await staffPage.request.post(`${F.BASE}${route}`, { form: { csrf_token: csrf, ...fields }, maxRedirects: 0 }); + expect(r.status(), route).toBe(302); + } + expect(F.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='attendance' AND setting_key='retention_days'")).toBe(savedRetention); + expect(F.dbInt(`SELECT COUNT(*) FROM presenze WHERE id=${presenzaId}`), 'the old visit is still there').toBe(1); + // The administrator's purge removes it. + await adminPage.goto(`${F.BASE}/admin/attendance`); + await adminPage.locator('#attendance-purge-form button[type="submit"]').click(); + const confirm = adminPage.locator('.swal2-confirm'); + if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click(); + await expect.poll(() => F.dbInt(`SELECT COUNT(*) FROM presenze WHERE id=${presenzaId}`), { timeout: 15000 }).toBe(0); + }); +}); + +// ── B. Ledger desk forms recorded once ─────────────────────────────────── +test.describe.serial('B. reader ledger desk forms', () => { + /** @type {import('@playwright/test').Page} */ let page; + let patron; + const movements = (tipo) => F.dbInt(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${patron.id} AND tipo=${S(tipo)}`); + const balance = () => Math.round(Number(F.dbQuery(`SELECT COALESCE(SUM(importo),0) FROM movimenti_lettore WHERE utente_id=${patron.id}`)) * 100); + + /** Fill a form, read it as the browser would send it, post it $times times. */ + async function sendTwice(formSelector, fill) { + await page.goto(`${F.BASE}/admin/users/details/${patron.id}`); + await fill(); + const form = page.locator(formSelector); + const fields = await form.evaluate((f) => Object.fromEntries(new FormData(/** @type {HTMLFormElement} */ (f)).entries())); + expect(String(fields.request_key)).toMatch(/^[a-f0-9]{32}$/); + const action = new URL(String(await form.getAttribute('action')), F.BASE).toString(); + const locations = []; + for (let i = 0; i < 2; i++) { + const r = await page.request.post(action, { form: /** @type {Record} */ (fields), maxRedirects: 0 }); + expect(r.status()).toBe(302); + locations.push(r.headers()['location'] || ''); + } + return { fields, action, locations }; + } + + test.beforeAll(async ({ browser }) => { + patron = F.createPatron(`RFB${RUN}`, { password: PASS }); + page = await (await browser.newContext()).newPage(); + await F.loginAsAdmin(page); + }); + test.afterAll(async () => { + F.deletePatrons([patron?.id]); + await page?.context().close(); + }); + + test('B1. a manual charge sent twice (double click) is recorded once', async () => { + const { locations } = await sendTwice('#ledger-charge-form', async () => { + await page.fill('#ledger_charge_amount', '6,00'); + await page.fill('#ledger_charge_description', `Tessera smarrita ${RUN}`); + }); + expect(locations.every((l) => !l.includes('ledger_error'))).toBe(true); + expect(movements('addebito_manuale')).toBe(1); + expect(balance()).toBe(600); + }); + + test('B2. a waiver sent twice is recorded once', async () => { + const { locations } = await sendTwice('#ledger-waive-form', async () => { + await page.fill('#ledger_waive_amount', '2,00'); + await page.fill('#ledger_waive_reason', 'Primo anno di iscrizione'); + }); + expect(locations.every((l) => !l.includes('ledger_error'))).toBe(true); + expect(movements('condono')).toBe(1); + expect(balance()).toBe(400); + }); + + test('B3. the same form key replayed with another amount is refused, not answered with the old movement', async () => { + const { fields, action } = await sendTwice('#ledger-payment-form', async () => { + await page.fill('#ledger_pay_amount', '1,00'); + }); + expect(movements('pagamento')).toBe(1); + expect(balance()).toBe(300); + const r = await page.request.post(action, { form: { ...fields, importo: '3,00' }, maxRedirects: 0 }); + expect(r.status()).toBe(302); + expect(r.headers()['location'] || '').toContain('ledger_error=duplicate_request'); + expect(movements('pagamento')).toBe(1); + expect(balance()).toBe(300); + // The page tells the operator what happened. + await page.goto(new URL(String(r.headers()['location']), F.BASE).toString()); + await expect(page.locator('body')).toContainText('Richiesta già registrata'); + }); +}); + +// ── C. Stocktake: a damaged copy turns up ──────────────────────────────── +test.describe.serial('C. stocktake and copies recorded out of circulation', () => { + /** @type {import('@playwright/test').Page} */ let page; + const TAG = `RFC${RUN}`; + const fx = { shelf: 0, book: 0, copies: {}, session: 0 }; + const code = (c) => `${TAG}-${c}`; + const state = (c) => F.dbQuery(`SELECT stato FROM copie WHERE id=${fx.copies[c]}`); + + async function scan(value) { + const answered = page.waitForResponse((r) => r.url().includes('/scan') && r.request().method() === 'POST'); + await page.locator('#inventory-code').pressSequentially(value, { delay: 10 }); + await page.locator('#inventory-code').press('Enter'); + const body = await (await answered).json(); + await expect(page.locator('#inventory-feedback')).toHaveAttribute('data-esito', body.result.esito); + return body.result; + } + + test.beforeAll(async ({ browser }) => { + F.dbQuery(`INSERT INTO scaffali (codice, nome, lettera) VALUES (${S(TAG)}, ${S('Revisione ' + TAG)}, 'R')`); + fx.shelf = F.dbInt(`SELECT id FROM scaffali WHERE codice=${S(TAG)}`); + F.dbQuery(`INSERT INTO libri (titolo, scaffale_id, collocazione, copie_totali, copie_disponibili) VALUES (${S(TAG + ' libro')}, ${fx.shelf}, ${S(TAG + '.1')}, 3, 2)`); + fx.book = F.dbInt(`SELECT id FROM libri WHERE titolo=${S(TAG + ' libro')} AND deleted_at IS NULL`); + for (const [c, st] of [['OK', 'disponibile'], ['MISS', 'disponibile'], ['DMG', 'danneggiato']]) { + F.dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (${fx.book}, ${S(code(c))}, ${S(st)})`); + fx.copies[c] = F.dbInt(`SELECT id FROM copie WHERE numero_inventario=${S(code(c))}`); + } + page = await (await browser.newContext()).newPage(); + await F.loginAsAdmin(page); + }); + test.afterAll(async () => { + const sessions = F.dbQuery(`SELECT id FROM inventario_sessioni WHERE nome LIKE ${S(TAG + '%')}`).split('\n').filter(Boolean); + if (sessions.length) { + F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='inventario_sessioni' AND record_id IN (${sessions.join(',')})`); + F.dbQuery(`DELETE FROM inventario_sessioni WHERE id IN (${sessions.join(',')})`); + } + if (fx.book) { + F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id=${fx.book}`); + F.dbQuery(`DELETE FROM copie WHERE libro_id=${fx.book}`); + F.dbQuery(`DELETE FROM libri WHERE id=${fx.book}`); + } + F.dbQuery(`DELETE FROM scaffali WHERE codice=${S(TAG)}`); + await page?.context().close(); + }); + + test('C1. a copy recorded as damaged that turns up is reported as found again, with its recorded state', async () => { + await page.goto(`${F.BASE}/admin/inventory`); + await page.fill('#inventory-name', `${TAG} danneggiata`); + await page.selectOption('#inventory-shelf', String(fx.shelf)); + await page.locator('#inventory-open-form button[type="submit"]').click(); + await page.waitForURL(/\/admin\/inventory\/\d+$/, { timeout: 15000 }); + fx.session = Number(new URL(page.url()).pathname.split('/').pop()); + // The damaged copy is not expected on the shelf: it is out of the snapshot. + expect(F.dbInt(`SELECT COUNT(*) FROM inventario_attese WHERE sessione_id=${fx.session}`)).toBe(2); + expect((await scan(code('OK'))).esito).toBe('trovata'); + const turnedUp = await scan(code('DMG')); + expect(turnedUp.esito).toBe('fuori_ambito'); + expect(turnedUp.fuori_circolazione).toBe(true); + await expect(page.locator('#inventory-feedback')).toContainText('risulta persa o fuori circolazione, ma è stata ritrovata'); + await expect(page.locator('#inventory-feedback')).toContainText('(danneggiato)'); + await expect(page.locator('#inventory-feedback')).not.toContainText('non rientra nel filtro della revisione'); + }); + + test('C2. closing with "mark missing as lost" marks only the missing copy and leaves the turned-up one as recorded', async () => { + await page.goto(`${F.BASE}/admin/inventory/${fx.session}`); + await page.locator('#inventory-close-form input[value="perso"]').check(); + await page.locator('#inventory-close-form button[type="submit"]').click(); + await expect(page.locator('.swal2-confirm')).toBeVisible({ timeout: 10000 }); + await page.locator('.swal2-confirm').click(); + await page.waitForURL(new RegExp(`/admin/inventory/${fx.session}$`)); + await expect(page.locator('#inventory-state')).toHaveText('Chiusa'); + await expect(page.locator('#inventory-report tr[data-categoria="mancante"]')).toContainText(code('MISS')); + expect(state('MISS')).toBe('perso'); + expect(state('OK')).toBe('disponibile'); + expect(state('DMG')).toBe('danneggiato'); + }); +}); + +// ── D. MARC review fixes ───────────────────────────────────────────────── +test.describe.serial('D. MARC import and export review fixes', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ let page; + const MRUN = `RFD${RUN}`; + const isbnMeeting = isbn13(`978${RUN}11`); + const isbnDvd = isbn13(`978${RUN}22`); + const isbnSame = isbn13(`978${RUN}33`); + + test.beforeAll(async ({ browser }) => { + page = await (await browser.newContext()).newPage(); + await MARC.loginAsAdmin(page); + }); + test.afterAll(async () => { + MARC.cleanupRun(MRUN); + await page?.context().close(); + }); + + test('D1. a meeting entry (111/711) keeps its subordinate unit ($e) in the name and reads $j as the relator', async () => { + const file = marcXmlFile(`meeting-${MRUN}.xml`, [{ + leader: '00000cam a2200000 a 4500', + fields: [ + ['020', ' ', ' ', [['a', isbnMeeting]]], + ['111', '2', ' ', [['a', `Convegno ${MRUN}`], ['e', 'Comitato scientifico.'], ['d', '(2001 : Milano)']]], + ['245', '1', '0', [['a', `Atti ${MRUN}`]]], + ['711', '2', ' ', [['a', `Seminario ${MRUN}`], ['e', 'Sezione storica.'], ['j', 'editor.']]], + ], + }]); + const upload = await MARC.uploadForPreview(page, file); + expect(upload.success).toBe(true); + const result = await MARC.confirmImport(page); + expect(result.imported).toBe(1); + const roles = F.dbQuery(`SELECT GROUP_CONCAT(CONCAT(la.ruolo,'=',a.nome) ORDER BY a.nome SEPARATOR ';') FROM libri l JOIN libri_autori la ON la.libro_id=l.id JOIN autori a ON a.id=la.autore_id WHERE l.titolo=${S(`Atti ${MRUN}`)} AND l.deleted_at IS NULL`); + expect(roles).toBe(`principale=Convegno ${MRUN}. Comitato scientifico;curatore=Seminario ${MRUN}. Sezione storica`); + }); + + test('D2. an overwrite that turns a book into a DVD clears the RDA overrides of the old material', async () => { + const title = `Film ${MRUN}`; + F.dbQuery(`INSERT INTO libri (titolo, isbn13, tipo_media, rda_media_type, rda_carrier_type, copie_totali, copie_disponibili) VALUES (${S(title)}, ${S(isbnDvd)}, 'libro', 'h', 'nb', 1, 1)`); + const file = marcXmlFile(`dvd-${MRUN}.xml`, [{ + leader: '00000cgm a2200000 a 4500', + fields: [ + ['020', ' ', ' ', [['a', isbnDvd]]], + ['245', '1', '0', [['a', title]]], + ['336', ' ', ' ', [['a', 'two-dimensional moving image'], ['b', 'tdi'], ['2', 'rdacontent']]], + ], + }]); + const upload = await MARC.uploadForPreview(page, file, { duplicates: 'overwrite' }); + expect(upload.preview[0].existing_id).toBeGreaterThan(0); + const result = await MARC.confirmImport(page); + expect(result.updated).toBe(1); + expect(F.dbQuery(`SELECT CONCAT_WS('|', tipo_media, IFNULL(rda_content_type,'-'), IFNULL(rda_media_type,'-'), IFNULL(rda_carrier_type,'-')) FROM libri WHERE isbn13=${S(isbnDvd)} AND deleted_at IS NULL`)) + .toBe('dvd|-|-|-'); + // The book page now describes a video, not a sheet of microform. + const id = F.dbInt(`SELECT id FROM libri WHERE isbn13=${S(isbnDvd)} AND deleted_at IS NULL`); + await page.goto(`${F.BASE}/admin/books/${id}`); + await expect(page.locator('body')).not.toContainText('microform'); + }); + + test('D3. an overwrite with the same media type keeps the book\'s RDA overrides', async () => { + const title = `Mappa ${MRUN}`; + F.dbQuery(`INSERT INTO libri (titolo, isbn13, tipo_media, rda_media_type, rda_carrier_type, copie_totali, copie_disponibili) VALUES (${S(title)}, ${S(isbnSame)}, 'libro', 'h', 'nb', 1, 1)`); + const file = marcXmlFile(`same-${MRUN}.xml`, [{ + leader: '00000cam a2200000 a 4500', + fields: [['020', ' ', ' ', [['a', isbnSame]]], ['245', '1', '0', [['a', `${title} (nuova edizione)`]]]], + }]); + await MARC.uploadForPreview(page, file, { duplicates: 'overwrite' }); + const result = await MARC.confirmImport(page); + expect(result.updated).toBe(1); + expect(F.dbQuery(`SELECT CONCAT_WS('|', titolo, tipo_media, IFNULL(rda_media_type,'-'), IFNULL(rda_carrier_type,'-')) FROM libri WHERE isbn13=${S(isbnSame)} AND deleted_at IS NULL`)) + .toBe(`${title} (nuova edizione)|libro|h|nb`); + }); + + test('D4. a book too large for ISO 2709 even shortened is exported as a reduced record, never skipped', async () => { + const title = `Enciclopedia ${MRUN}`; + const token = `zzbig${MRUN}`.toLowerCase(); + F.dbQuery(`INSERT INTO libri (titolo, search_index, descrizione, copie_totali, copie_disponibili) VALUES (${S(title)}, ${S(`${title} ${token}`)}, REPEAT('lunga descrizione ', 800), 1, 1)`); + const bookId = F.dbInt(`SELECT id FROM libri WHERE titolo=${S(title)} AND deleted_at IS NULL`); + // 450 contributors with long names: more than 99,999 bytes of 700 fields. + F.dbQuery(`INSERT INTO autori (nome) WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 450) + SELECT CONCAT('Autore ${MRUN} ', LPAD(i, 3, '0'), ' ', REPEAT('n', 220)) FROM n`); + F.dbQuery(`INSERT INTO libri_autori (libro_id, autore_id, ruolo) SELECT ${bookId}, id, IF(nome LIKE 'Autore ${MRUN} 001 %', 'principale', 'co-autore') FROM autori WHERE nome LIKE 'Autore ${MRUN} %'`); + expect(F.dbInt(`SELECT COUNT(*) FROM libri_autori WHERE libro_id=${bookId}`)).toBe(450); + + await page.goto(`${F.BASE}/admin/books?keywords=${encodeURIComponent(token)}`); + await expect(page.locator('.row-select')).toHaveCount(1, { timeout: 15000 }); + await page.locator('#select-all').check(); + await expect(page.locator('#selected-count')).toHaveText('1'); + await page.click('#bulk-export-marc'); + await page.locator('.swal2-select').selectOption('marc21'); + const [download] = await Promise.all([page.waitForEvent('download'), page.click('.swal2-confirm')]); + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pinakes-rf-export-')), download.suggestedFilename()); + await download.saveAs(file); + const read = JSON.parse(execFileSync('php', [path.join(ROOT, 'tests', 'helpers', 'marc-inspect.php'), file], { encoding: 'utf-8' })); + expect(read.errors).toBe(0); + expect(read.records).toHaveLength(1); + expect(read.records[0].titolo).toBe(title); + // Reduced: at most 20 contributors, the first one kept. + expect(read.records[0].autori.length).toBeGreaterThan(0); + expect(read.records[0].autori.length).toBeLessThanOrEqual(20); + expect(read.records[0].autori[0]).toContain(`Autore ${MRUN} 001`); + }); +}); + +// ── E. #501 quoted search on the boxes the stack added ─────────────────── +test.describe.serial('E. quoted search (#501) on the new search boxes', () => { + /** @type {import('@playwright/test').Page} */ let page; + const ERUN = `RFE${RUN}`; + const suppliers = {}; + const books = []; + const patrons = []; + let typeId = 0; + const visibleSuppliers = async () => (await page.locator('[data-supplier-row]:not(.hidden) td:first-child a').allInnerTexts()).map((t) => t.trim()).sort(); + + async function filterSuppliers(value) { + await page.locator('#search_fornitore').fill(value); + await page.locator('#search_fornitore').dispatchEvent('input'); + } + + test.beforeAll(async ({ browser }) => { + for (const [key, name] of [['umlaut', `Bücherei Müller ${ERUN}`], ['plain', `Bucherei Muller ${ERUN}`], ['other', `Libreria Rossi ${ERUN}`]]) { + F.dbQuery(`INSERT INTO fornitori (nome) VALUES (${S(name)})`); + suppliers[key] = { id: F.dbInt(`SELECT id FROM fornitori WHERE nome=${S(name)} COLLATE utf8mb4_bin`), name }; + } + F.dbQuery(`INSERT INTO tipologie_lettore (codice, nome, predefinita, attiva, ordinamento) VALUES (${S(ERUN.toLowerCase())}, ${S('Tipo ' + ERUN)}, 0, 1, 99)`); + typeId = F.dbInt(`SELECT id FROM tipologie_lettore WHERE codice=${S(ERUN.toLowerCase())}`); + // Own inserts: the card codes must differ by more than an accent (unique index, unicode_ci). + for (const [i, surname, type] of [['a', `Häffner${ERUN}`, typeId], ['b', `Haffner${ERUN}`, typeId], ['c', `Häffner${ERUN}`, null]]) { + const email = `rfe-${i}-${RUN}@circ.test`; + F.dbQuery(`INSERT INTO utenti (nome, cognome, email, password, tipo_utente, stato, email_verificata, codice_tessera, privacy_accettata, tipologia_id, created_at) + VALUES ('Circ', ${S(surname)}, ${S(email)}, ${S(F.phpHash(PASS))}, 'standard', 'attivo', 1, ${S(`RFE${i}${RUN}`)}, 1, ${type || 'NULL'}, NOW())`); + patrons.push({ id: F.dbInt(`SELECT id FROM utenti WHERE email=${S(email)}`), email }); + } + for (const [title, supplier] of [[`Brücke ${ERUN}`, suppliers.umlaut.id], [`Brucke ${ERUN}`, suppliers.umlaut.id], [`Brücke ${ERUN} senza`, null]]) { + F.dbQuery(`INSERT INTO libri (titolo, search_index, fornitore_id, copie_totali, copie_disponibili) VALUES (${S(title)}, ${S(title)}, ${supplier || 'NULL'}, 1, 1)`); + books.push(F.dbInt(`SELECT id FROM libri WHERE titolo=${S(title)} COLLATE utf8mb4_bin AND deleted_at IS NULL`)); + } + page = await (await browser.newContext()).newPage(); + await F.loginAsAdmin(page); + }); + test.afterAll(async () => { + F.deleteTitles(books); + F.deletePatrons(patrons.map((p) => p.id)); + F.dbQuery(`DELETE FROM tipologie_lettore WHERE id=${typeId}`); + F.dbQuery(`DELETE FROM fornitori WHERE nome LIKE ${S(`%${ERUN}`)}`); + await page?.context().close(); + }); + + test('E1. suppliers list: an unquoted word finds every spelling, accents or not', async () => { + await page.goto(`${F.BASE}/admin/suppliers`); + await expect(page.locator('#search_fornitore_hint')).toContainText('virgolette'); + await filterSuppliers(`muller ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.plain.name, suppliers.umlaut.name].sort()); + await filterSuppliers(`MÜLLER ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.plain.name, suppliers.umlaut.name].sort()); + }); + + test('E2. suppliers list: a quoted word keeps only the exact spelling, case ignored', async () => { + await page.goto(`${F.BASE}/admin/suppliers`); + await filterSuppliers(`"Müller" ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.umlaut.name]); + await filterSuppliers(`"muller" ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.plain.name]); + await filterSuppliers(`“bücherei müller” ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.umlaut.name]); + }); + + test('E3. suppliers list: each word keeps its own rule, and a box with only quotes filters nothing', async () => { + await page.goto(`${F.BASE}/admin/suppliers`); + await filterSuppliers(`bucherei "Muller" ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.plain.name]); + await filterSuppliers(`"Rossi" muller ${ERUN}`); + expect(await visibleSuppliers()).toEqual([]); + await filterSuppliers('""'); + expect((await visibleSuppliers()).filter((n) => n.endsWith(ERUN))).toEqual(Object.values(suppliers).map((s) => s.name).sort()); + }); + + /** The next list response whose query string has every given value. */ + const listResponse = (api, params) => page.waitForResponse((r) => { + if (!r.url().includes(api)) return false; + const q = new URL(r.url()).searchParams; + return Object.entries(params).every(([k, v]) => (q.get(k) || '') === String(v)); + }, { timeout: 15000 }); + + test('E4. users list: the patron type filter and a quoted surname combine', async () => { + await page.goto(`${F.BASE}/admin/users`); + let resp = listResponse('/api/utenti', { patron_type_filter: typeId }); + await page.locator('#patron_type_filter').selectOption(String(typeId)); + let json = await (await resp).json(); + const rowsOf = (j) => (j.data || []).map((r) => JSON.stringify(r)).join(' '); + resp = listResponse('/api/utenti', { patron_type_filter: typeId, search_text: `"Häffner${ERUN}"` }); + await page.locator('#search_text').fill(`"Häffner${ERUN}"`); + await page.locator('#search_text').dispatchEvent('keyup'); + json = await (await resp).json(); + expect(json.recordsFiltered).toBe(1); + expect(rowsOf(json)).toContain(patrons[0].email); + resp = listResponse('/api/utenti', { patron_type_filter: typeId, search_text: `haffner${ERUN}` }); + await page.locator('#search_text').fill(`haffner${ERUN}`); + await page.locator('#search_text').dispatchEvent('keyup'); + json = await (await resp).json(); + // Unquoted: both spellings of this type, never the reader of the default type. + expect(json.recordsFiltered).toBe(2); + expect(rowsOf(json)).not.toContain(patrons[2].email); + }); + + test('E5. book list: the supplier filter and a quoted title combine', async () => { + // The librarian follows "Libri di questo fornitore" from the suppliers list. + await page.goto(`${F.BASE}/admin/suppliers`); + let resp = listResponse('/api/libri', { fornitore_filter: suppliers.umlaut.id }); + await page.locator(`[data-supplier-row] a[href$="/admin/books?fornitore=${suppliers.umlaut.id}"]`).click(); + let json = await (await resp).json(); + const rowsOf = (j) => (j.data || []).map((r) => JSON.stringify(r)).join(' '); + expect(json.recordsFiltered).toBe(2); + resp = listResponse('/api/libri', { fornitore_filter: suppliers.umlaut.id, search_text: `"Brücke" ${ERUN}` }); + await page.locator('#search_text').fill(`"Brücke" ${ERUN}`); + await page.locator('#search_text').dispatchEvent('keyup'); + json = await (await resp).json(); + expect(json.recordsFiltered).toBe(1); + expect(rowsOf(json)).toContain(`Brücke ${ERUN}`); + expect(rowsOf(json)).not.toContain('senza'); + resp = listResponse('/api/libri', { fornitore_filter: suppliers.umlaut.id, search_text: `brucke ${ERUN}` }); + await page.locator('#search_text').fill(`brucke ${ERUN}`); + await page.locator('#search_text').dispatchEvent('keyup'); + json = await (await resp).json(); + expect(json.recordsFiltered).toBe(2); + expect(rowsOf(json)).not.toContain('senza'); + }); +}); From 05a2459c8b3b0d908ed493939c6a7635c9d68252 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:50:57 +0100 Subject: [PATCH 187/398] MARC 21 import: no double period after a name part that ends with an initial A corporate or meeting name whose $a keeps its final period because it ends with an initial ("Istituto X.") was joined to its subordinate unit as "Istituto X.. Ufficio studi". The parts are now joined with a single period. --- app/Support/Marc/Marc21LibriMapper.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/Support/Marc/Marc21LibriMapper.php b/app/Support/Marc/Marc21LibriMapper.php index aaf7a1801..5dfeb4fde 100644 --- a/app/Support/Marc/Marc21LibriMapper.php +++ b/app/Support/Marc/Marc21LibriMapper.php @@ -65,13 +65,16 @@ private static function entryName(MarcField $field): string return $name; } $units = $field->subfieldValues(self::isMeeting($field) ? 'e' : 'b'); - $parts = []; + $joined = ''; foreach (array_merge([$name], array_map([MarcBookData::class, 'cleanName'], $units)) as $part) { - if ($part !== '') { - $parts[] = $part; + if ($part === '') { + continue; } + // A part that keeps its final period (it ends with an initial, + // "Istituto X.") is not followed by a second one. + $joined = $joined === '' ? $part : $joined . (str_ends_with($joined, '.') ? ' ' : '. ') . $part; } - return implode('. ', $parts); + return $joined; } public function map(MarcRecord $record): array From 3fdee1fc4fbb3c0c241cd5e3d966a0b6e7036c65 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:51:00 +0100 Subject: [PATCH 188/398] Tests: unit checks for the 0.9.0 review fixes, zero-config fines and due dates, and a source guard for quoted search --- tests/stack-090-review-fixes.unit.php | 179 ++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 tests/stack-090-review-fixes.unit.php diff --git a/tests/stack-090-review-fixes.unit.php b/tests/stack-090-review-fixes.unit.php new file mode 100644 index 000000000..99f388387 --- /dev/null +++ b/tests/stack-090-review-fixes.unit.php @@ -0,0 +1,179 @@ +addDataField('245', '1', '0', [['a', 'Titolo']]); + foreach ($fields as [$tag, $ind1, $subfields]) { + $record->addDataField($tag, $ind1, ' ', $subfields); + } + return (new Marc21LibriMapper())->map($record); +}; +$m = $map([['110', '1', [['a', 'United States.'], ['b', 'Congress.'], ['b', 'Senate.']]], ['710', '2', [['a', 'Società Dante Alighieri.'], ['b', 'Comitato di Roma.'], ['e', 'editor.']]]]); +$check($m['autori'] === ['United States. Congress. Senate'] && $m['curatore'] === ['Società Dante Alighieri. Comitato di Roma'], + '1 corporate entries (110/710): $b units joined to the name with the final periods dropped, $e is the relator'); +$m = $map([['711', '2', [['a', 'International Congress of Mathematicians.'], ['e', 'Organizing Committee.'], ['j', 'editor.']]], ['711', '2', [['a', 'Giornate di studio.'], ['e', 'Sezione storica.'], ['4', 'edt']]]]); +$check($m['curatore'] === ['International Congress of Mathematicians. Organizing Committee', 'Giornate di studio. Sezione storica'] && $m['autori'] === [], + '2 meeting entries (711): $e is part of the name, the editor role comes from $j or from $4'); +$m = $map([['111', '2', [['a', 'Congresso nazionale.'], ['n', '3.'], ['d', '(2001 :'], ['c', 'Milano)']]]]); +$check($m['autori'] === ['Congresso nazionale'], '3 a 111 with only number, date and place is an author named by its $a'); +$m = $map([['110', '2', [['a', 'Istituto X.'], ['b', 'Ufficio studi.'], ['e', 'editor.']]]]); +$check($m['curatore'] === ['Istituto X. Ufficio studi'], '4 a name part that keeps the period of an initial is not followed by a second period'); + +echo "MARC ISO 2709 export fallbacks\n"; +$cuts = ['stale']; +$bytes = MarcExportController::serializeIso('marc21', ['id' => 7, 'titolo' => 'Piccolo libro', 'descrizione' => 'Breve.'], [['nome' => 'Mario Rossi', 'ruolo' => 'principale']], [['nome' => 'Editrice']], null, $cuts, $level); +$check($bytes !== null && $level === 'full' && $cuts === [] && Iso2709Reader::parse(substr($bytes, 0, -1))->subfield('245', 'a') === 'Piccolo libro', + '5 a normal book is written as the full record, with no cut reported'); +$long = ['id' => 8, 'titolo' => 'T', 'descrizione' => str_repeat('lunga ', 3500)]; +$cuts = []; +$bytes = MarcExportController::serializeIso('unimarc', $long, [], [], null, $cuts, $level); +$check($bytes !== null && $level === 'full' && count($cuts) === 1 && str_contains($cuts[0], 'field 330') && strlen($bytes) < 99999, + '6 UNIMARC: one description over the field limit is cut inside the full record, and that cut is the one reported'); +$crowd = array_map(static fn (int $i): array => ['nome' => 'Autore ' . str_repeat('n', 200) . " {$i}", 'ruolo' => $i === 1 ? 'principale' : 'co-autore'], range(1, 700)); +$pubs = array_map(static fn (int $i): array => ['nome' => "Editore {$i}"], range(1, 30)); +$cuts = []; +$bytes = MarcExportController::serializeIso('marc21', $long, $crowd, $pubs, null, $cuts, $level); +$reduced = $bytes !== null ? Iso2709Reader::parse(substr($bytes, 0, -1)) : null; +$names = []; +$publishers = 0; +foreach ($reduced !== null ? $reduced->fields() : [] as $field) { + if (in_array($field->tag, ['100', '700'], true)) { + $names[] = (string) $field->subfield('a'); + } + if (in_array($field->tag, ['260', '264'], true)) { + $publishers += count($field->subfieldValues('b')); + } +} +$check($level === 'reduced' && count($names) >= 1 && count($names) <= 20 && str_ends_with($names[0], ' 1') && $publishers >= 1 && $publishers <= 5, + '7 700 authors and 30 publishers: the reduced record keeps the first 20 authors and 5 publishers instead of dropping the book'); + +echo "MARC import: RDA overrides and the media type\n"; +$all = static fn (string $column): bool => true; +$dvd = ['tipo_media' => 'dvd', 'rda_media_type' => 'v', 'rda_carrier_type' => 'vf'] + MarcBookData::empty(); +$check(MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_OVERWRITE, $dvd, null, $all) === ['rda_media_type' => 'v', 'rda_carrier_type' => 'vf'], + '8 a new book takes the record\'s overrides and nothing is cleared'); +$plainBook = ['tipo_media' => 'libro', 'rda_content_type' => null, 'rda_media_type' => '', 'rda_carrier_type' => null]; +$check(MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_OVERWRITE, ['tipo_media' => 'dvd'] + MarcBookData::empty(), $plainBook, $all) === [], + '9 a media type change on a book with no override writes nothing (no pointless NULLs)'); +$printed = ['tipo_media' => 'libro', 'rda_content_type' => 'tct', 'rda_media_type' => 'h', 'rda_carrier_type' => 'nb']; +$check(MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_OVERWRITE, $dvd, $printed, $all) === ['rda_content_type' => null, 'rda_media_type' => 'v', 'rda_carrier_type' => 'vf'], + '10 a media type change: the record\'s overrides replace the old ones and the one it lacks is cleared'); + +echo "Zero configuration (0.8.9 behaviour)\n"; +$zero = FineParameters::fromSettings([]); +$calc = new FineCalculator(); +$check(!$zero->finesEnabled() && !$zero->debtBlockEnabled() && $calc->lateFineCents($zero, '2026-01-01', '2026-04-10') === 0, + '11 no fine settings: fines and the debt block are off, 99 days late cost nothing'); +$zeroes = FineParameters::fromSettings(['daily_fine' => '0', 'fine_grace_days' => '0', 'fine_max_per_loan' => '0', 'debt_block_threshold' => '0']); +$check(!$zeroes->finesEnabled() && !$zeroes->debtBlockEnabled() && $zeroes->maxFinePerLoanCents === null, + '12 settings saved as 0 mean "off", exactly as empty ones'); +$check(CirculationPolicy::dueDateFor('2026-01-30', 30) === '2026-03-01' && CirculationPolicy::dueDateFor('2026-03-28', 14) === '2026-04-11', + '13 without a calendar the due date is start + days, across month ends and the DST change'); +$check(CirculationPolicy::dueDateFor('2026-05-10', 0) === '2026-05-10' && CirculationPolicy::dueDateFor('2026-05-10', -3) === '2026-05-10', + '14 a typed date (0 days) is kept as it is, a negative length never moves it back'); + +echo "Fines with closed days\n"; +$weekendsClosed = new FineCalculator(static fn (string $ymd, ?int $branch): bool => (int) (new DateTimeImmutable($ymd))->format('N') < 6); +$daily = FineParameters::fromSettings(['daily_fine' => '0,50', 'fine_grace_days' => '3']); +// Due Friday 2026-10-02, returned Monday 2026-10-12: 10 calendar days, 6 open (Mon-Fri + Mon). +$check($weekendsClosed->daysLate('2026-10-02', '2026-10-12') === 6 && $weekendsClosed->lateFineCents($daily, '2026-10-02', '2026-10-12') === 300, + '15 closed weekends are not late days: 6 open late days at 0,50 over a grace of 3 = 3,00 from day one'); +$check($weekendsClosed->lateFineCents($daily, '2026-10-02', '2026-10-07') === 0, + '16 3 open late days (weekend skipped) stay within the grace of 3'); + +echo "Stocktake classification\n"; +$check(StocktakeService::classify(true, false, false, true, null, null) === 'fuori_ambito' + && StocktakeService::classify(true, true, false, false, null, null) === 'duplicata' + && StocktakeService::classify(false, true, true, true, 1, 2) === 'sconosciuta' + && StocktakeService::classify(true, false, true, true, 1, 2) === 'in_prestito' + && StocktakeService::classify(true, false, true, false, 1, 2) === 'fuori_posto' + && StocktakeService::classify(true, false, true, false, 1, null) === 'trovata', + '17 order: unknown > duplicate > out of snapshot (a turned-up lost copy) > on loan > misplaced > found'); + +echo "Source guard: new searches go through ExactSearch\n"; +// Every 0.9.0 controller/service with a user-typed search: a bare LIKE on +// user input would ignore the quote rule. The collocation range of the +// stocktake filter is a prefix range on a shelf mark, not a text search. +$files = [ + 'app/Controllers/SuppliersController.php', 'app/Controllers/InventoryController.php', 'app/Services/StocktakeService.php', + 'app/Controllers/AttendanceController.php', 'app/Controllers/PatronLedgerController.php', 'app/Controllers/CirculationSettingsController.php', + 'app/Controllers/Admin/LibraryCalendarController.php', 'app/Support/UserImport.php', 'app/Support/PatronTypeImport.php', + 'app/Support/PatronTypeAssignment.php', 'app/Controllers/MarcExportController.php', 'app/Support/Marc/MarcBookImporter.php', + 'app/Services/PatronLedger.php', 'app/Models/AttendanceRepository.php', +]; +$allowed = [ + 'app/Services/StocktakeService.php' => ['(l.collocazione <= ? OR l.collocazione LIKE ?)'], +]; +$offenders = []; +foreach ($files as $rel) { + $src = (string) @file_get_contents($root . '/' . $rel); + if ($src === '') { + $offenders[] = "{$rel} (missing)"; + continue; + } + if (preg_match_all('/[^\n]*\bLIKE\s+\?[^\n]*/i', $src, $hits) > 0) { + foreach ($hits[0] as $line) { + $ok = false; + foreach ($allowed[$rel] ?? [] as $fragment) { + $ok = $ok || str_contains($line, $fragment); + } + if (!$ok) { + $offenders[] = $rel . ': ' . trim($line); + } + } + } +} +$check($offenders === [], '18 no bare "LIKE ?" on typed input in the 0.9.0 search code' . ($offenders !== [] ? ' — ' . implode(' | ', $offenders) : '')); +$view = (string) file_get_contents($root . '/app/Views/fornitori/index.php'); +$check(!str_contains($view, ".includes(q)") && str_contains($view, '[^"“”„«»]') && str_contains($view, "normalize('NFD')") && str_contains($view, 'search_fornitore_hint'), + '19 the suppliers box tokenises quoted groups, folds accents for plain words and shows the quote hint'); + +echo "\n{$pass} PASS, {$fail} FAIL\n"; +exit($fail === 0 ? 0 : 1); From e1b3f050f4d263ffff1b9f693bb6261c13ca3bea Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 20:03:20 +0100 Subject: [PATCH 189/398] CI: run every 0.9.0 Playwright suite in the E2E workflow The E2E workflow listed its specs explicitly and none of the 36 suites added by the 0.9.0 stack ran there. A new feature-090 job runs them in two parallel groups (circulation, catalogue) on the deep-regression environment: Apache, MySQL, Mailpit with a real sendmail transport, the CI MySQL wrapper, poppler for the PDF assertions, a fresh install and the shared seed fixtures. The result audit gains an optional PLAYWRIGHT_MAX_SKIPPED ceiling; the new job sets it to 0, so a test that skips for any reason fails the gate. --- .github/workflows/ci-e2e.yml | 283 +++++++++++++++++++++++++ scripts/ci-audit-playwright-results.js | 16 ++ 2 files changed, 299 insertions(+) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 5ef21d5a3..2632a6f0f 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -389,3 +389,286 @@ jobs: name: apache-error-log-${{ github.run_id }} path: /var/log/apache2/pinakes-error.log retention-days: 7 + + # ── 0.9.0 feature suites ────────────────────────────────────────────────── + # Every Playwright suite added by the 0.9.0 stack (circulation rules, patron + # types, closures calendar, fines ledger, debt block, stocktake, MARC, cards, + # patron photo, user import, statistics, suppliers, RDA types, #501 quoted + # search). Same environment as the deep regression shards — Apache/mod_php, + # MySQL, Mailpit behind a real sendmail transport, the CI MySQL wrapper and + # poppler for the PDF assertions — on a fresh install bootstrapped by the + # full lifecycle suite. Two groups run in parallel; each list is explicit so + # a new spec has to be added here on purpose. + feature-090: + name: 0.9.0 feature suites (${{ matrix.group }}) + runs-on: ubuntu-24.04 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - group: circulation + specs: >- + tests/circulation-card.spec.js + tests/circulation-consultation.spec.js + tests/circulation-interop.spec.js + tests/circulation-patron-types.spec.js + tests/circulation-reservations.spec.js + tests/circulation-rules-matrix.spec.js + tests/circulation-settings-access.spec.js + tests/closures-calendar-admin.spec.js + tests/closures-calendar-circulation.spec.js + tests/closures-calendar-loans.spec.js + tests/debt-block.spec.js + tests/fines-closed-days.spec.js + tests/fines-ledger.spec.js + tests/fines-patron-types.spec.js + tests/stack-090-integration.spec.js + tests/stack-090-review-fixes.spec.js + tests/stack-090-zero-config.spec.js + - group: catalogue + specs: >- + tests/attendance-090.spec.js + tests/catalogue-cards-090.spec.js + tests/inventory-stocktake.spec.js + tests/issue-501-admin-lists.spec.js + tests/issue-501-exact-quoted-search.spec.js + tests/issue-501-name-pages.spec.js + tests/issue-501-quoted-hint.spec.js + tests/issue-501-quoted-new-author.spec.js + tests/issue-501-search-fields.spec.js + tests/issue-501-search-hint.spec.js + tests/marc-bulk-export.spec.js + tests/marc-bulk-import.spec.js + tests/marc-copy-cataloging.spec.js + tests/patron-cards.spec.js + tests/patron-photo.spec.js + tests/rda-types-090.spec.js + tests/statistics-090.spec.js + tests/suppliers-090.spec.js + tests/user-import.spec.js + + services: + mysql: + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: pinakes_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + mailpit: + image: axllent/mailpit:v1.21@sha256:81370195cd4a0eab9604d17c2617a7525b0486f9365555253b6c5376c6350f1a + ports: + - 1025:1025 + - 8025:8025 + + env: + E2E_BASE_URL: http://localhost:8081 + APP_URL: http://localhost:8081 + E2E_ADMIN_EMAIL: admin@pinakes.test + E2E_ADMIN_PASS: Test1234! + E2E_DB_HOST: 127.0.0.1 + E2E_DB_PORT: 3306 + E2E_DB_USER: root + E2E_DB_PASS: root + E2E_DB_NAME: pinakes_test + E2E_INSTALL_ROOT: ${{ github.workspace }} + MAILPIT_API: http://127.0.0.1:8025/api/v1 + E2E_PHP_UPLOAD_MAX_BYTES: 8388608 + E2E_PHP_POST_MAX_BYTES: 10485760 + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Install Apache, PHP module, MySQL client, mail transport and poppler + run: | + # This job never installs Google Chrome. Remove its hosted-runner + # apt source before the first refresh so mirror syncs cannot break CI. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update -q + # poppler-utils: patron-cards and patron-photo read the generated + # PDFs back with pdftotext/pdfinfo/pdfimages. + sudo apt-get install -y apache2 libapache2-mod-php php-apcu default-mysql-client msmtp-mta poppler-utils + sudo a2enmod rewrite headers env + php_module="$(find /etc/apache2/mods-available -maxdepth 1 -name 'php*.load' -printf '%f\n' | sed 's/\.load$//' | sort -V | tail -n1)" + [ -z "${php_module}" ] || sudo a2enmod "${php_module}" + sudo a2dissite 000-default || true + sudo tee /etc/msmtprc >/dev/null <<'EOF' + defaults + account default + host 127.0.0.1 + port 1025 + tls off + from pinakes-ci@localhost + EOF + sudo chmod 644 /etc/msmtprc + while IFS= read -r conf_dir; do + sudo tee "${conf_dir}/99-pinakes-ci.ini" >/dev/null <<'EOF' + sendmail_path = "/usr/bin/msmtp -t" + upload_max_filesize = 8M + post_max_size = 10M + apc.enable=1 + apc.enable_cli=1 + EOF + done < <(find /etc/php -type d -path '*/conf.d' | sort -u) + + - name: Setup PHP 8.2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.2' + extensions: mysqli, mbstring, json, curl, openssl, zip, gd, intl, xml, apcu + ini-values: apc.enable=1, apc.enable_cli=1 + coverage: none + + - name: Setup Node 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '22' + cache: npm + + - name: Install locked dependencies and Chromium + run: | + composer install --no-interaction --prefer-dist --no-dev --optimize-autoloader + npm ci --silent + npx playwright install chromium --with-deps + + - name: Route legacy MySQL test clients to the service container + run: echo "${GITHUB_WORKSPACE}/scripts/ci-bin" >> "${GITHUB_PATH}" + + - name: Verify test coverage policy + run: npm run test:ci-policy + + - name: Configure Apache on port 8081 + env: + WORKSPACE: ${{ github.workspace }} + run: | + sudo tee /etc/apache2/sites-available/pinakes.conf >/dev/null < + ServerName localhost + DocumentRoot ${WORKSPACE}/public + SetEnv PINAKES_E2E_BYPASS_RATE_LIMIT 1 + SetEnv PINAKES_E2E_SCRAPER_STUB 1 + # E2E only — arms GET /_e2e/flush-cache so specs that write to the + # DB directly can invalidate the page cache; 404s when unset. + SetEnv PINAKES_E2E_CACHE_FLUSH 1 + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + ErrorLog \${APACHE_LOG_DIR}/pinakes-error.log + CustomLog \${APACHE_LOG_DIR}/pinakes-access.log combined + + EOF + sudo a2ensite pinakes + sudo apachectl configtest + + - name: Grant runtime permissions and start Apache + env: + WORKSPACE: ${{ github.workspace }} + run: | + path="${WORKSPACE}" + while [ "${path}" != "/" ]; do + sudo chmod o+x "${path}" + path="$(dirname "${path}")" + done + sudo chmod 777 "${WORKSPACE}" + sudo chmod -R 777 "${WORKSPACE}/storage" + sudo mkdir -p "${WORKSPACE}/public/uploads" + sudo chmod -R 777 "${WORKSPACE}/public/uploads" + sudo systemctl start apache2 + for _ in $(seq 1 30); do + curl -sf -o /dev/null http://localhost:8081/installer/ && exit 0 + sleep 1 + done + sudo cat /var/log/apache2/pinakes-error.log || true + exit 1 + + - name: Bootstrap a complete fresh installation + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/bootstrap-${{ matrix.group }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-bootstrap-${{ matrix.group }} + run: npx playwright test tests/full-test.spec.js --config=tests/playwright.ci.config.js --workers=1 + + - name: Audit bootstrap failures, flakes and suspicious skips + if: always() + env: + RESULT_FILE: test-results/bootstrap-${{ matrix.group }}.json + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + + - name: Seed deterministic cross-suite fixtures + env: + E2E_RUN_SEED: '1' + E2E_OFFLINE_SEED: '1' + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/setup-${{ matrix.group }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-setup-${{ matrix.group }} + run: | + npx playwright test \ + tests/interop-00-activate-plugins.spec.js \ + tests/archives-seed-50.spec.js \ + tests/seed-catalog.spec.js \ + tests/persistent-seed-crud.spec.js \ + --config=tests/playwright.ci.config.js \ + --workers=1 + sudo chmod -R 777 "${GITHUB_WORKSPACE}/storage" + sudo chmod -R 777 "${GITHUB_WORKSPACE}/public/uploads" + # The CLI-level helpers (app clock, migrations) read the same + # throwaway CI credentials the installer protected from Apache users. + sudo chmod 640 "${GITHUB_WORKSPACE}/.env" + sudo chgrp "$(id -gn)" "${GITHUB_WORKSPACE}/.env" + + - name: Audit setup fixtures + if: always() + env: + RESULT_FILE: test-results/setup-${{ matrix.group }}.json + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + + - name: Run the 0.9.0 ${{ matrix.group }} suites + id: feature-suites + env: + SPECS: ${{ matrix.specs }} + GROUP: ${{ matrix.group }} + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/feature-090-${{ matrix.group }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-feature-090-${{ matrix.group }} + run: | + read -r -a spec_files <<< "${SPECS}" + for spec in "${spec_files[@]}"; do + [ -f "${spec}" ] || { echo "Listed spec does not exist: ${spec}"; exit 1; } + done + printf 'Running %s 0.9.0 specs (%s)\n' "${#spec_files[@]}" "${GROUP}" + npx playwright test "${spec_files[@]}" \ + --config=tests/playwright.ci.config.js \ + --workers=1 + + - name: Audit failures, flakes and skips (zero skips allowed) + if: always() && steps.feature-suites.outcome != 'skipped' + env: + RESULT_FILE: test-results/feature-090-${{ matrix.group }}.json + # The shared audit tolerates skips with an environmental reason. + # These suites were written for this environment: every test runs. + PLAYWRIGHT_MAX_SKIPPED: '0' + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + + - name: Upload browser evidence + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + if: always() + with: + name: feature-090-${{ matrix.group }}-${{ github.run_id }} + path: | + playwright-report-feature-090-${{ matrix.group }}/ + playwright-report-bootstrap-${{ matrix.group }}/ + playwright-report-setup-${{ matrix.group }}/ + test-results/ + storage/logs/app.log + /var/log/apache2/pinakes-error.log + /var/log/apache2/pinakes-access.log + retention-days: 14 + if-no-files-found: warn diff --git a/scripts/ci-audit-playwright-results.js b/scripts/ci-audit-playwright-results.js index 11512a786..08098935a 100755 --- a/scripts/ci-audit-playwright-results.js +++ b/scripts/ci-audit-playwright-results.js @@ -47,6 +47,22 @@ if (expected === 0) { console.error('✗ The suite executed zero passing tests'); failed = true; } +// Optional hard ceiling on skips, for suites written for the CI environment +// where any skip (whatever its reason) means a test silently did not run. +const maxSkippedRaw = process.env.PLAYWRIGHT_MAX_SKIPPED; +if (maxSkippedRaw !== undefined && maxSkippedRaw !== '') { + const maxSkipped = Number(maxSkippedRaw); + if (!Number.isInteger(maxSkipped) || maxSkipped < 0) { + console.error(`✗ PLAYWRIGHT_MAX_SKIPPED must be a non-negative integer, got "${maxSkippedRaw}"`); + failed = true; + } else if (skipped > maxSkipped) { + console.error(`✗ ${skipped} skipped test(s), at most ${maxSkipped} allowed:`); + for (const item of skipReasons.slice(0, 30)) { + console.error(` - ${item.file}: ${item.title} — ${item.reason || ''}`); + } + failed = true; + } +} if (suspicious.length > 0) { console.error(`✗ ${suspicious.length} suspicious skip(s) hide missing fixtures or infrastructure:`); for (const item of suspicious.slice(0, 30)) { From 570e846c287330a71514e3742182f86ac4df7a7f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 20:09:40 +0100 Subject: [PATCH 190/398] Tests: the 0.9.0 stack specs put back exactly what they changed - Table snapshots travel hex-encoded and are restored in one transaction, so a closure description with an apostrophe or a backslash comes back intact and a failed restore leaves the original rows in place. - The email settings are always reset: an install that had none (a fresh CI database) no longer keeps the Mailpit SMTP rows written by the return test. - ncip-server and mobile-api are deactivated again when the suite activated them, and the activation is checked instead of assumed. - Every cleanup step runs even when an earlier one throws. - The stocktake cleanup removes scans and expected copies before the session. --- tests/helpers/circulation-fixtures.js | 59 +++++++++++++++++++ tests/stack-090-integration.spec.js | 64 +++++++++++---------- tests/stack-090-review-fixes.spec.js | 3 + tests/stack-090-zero-config.spec.js | 81 ++++++++++++--------------- 4 files changed, 131 insertions(+), 76 deletions(-) diff --git a/tests/helpers/circulation-fixtures.js b/tests/helpers/circulation-fixtures.js index 17dc8853f..cae6fe1c6 100644 --- a/tests/helpers/circulation-fixtures.js +++ b/tests/helpers/circulation-fixtures.js @@ -226,6 +226,64 @@ async function requestFromBookPage(page, bookId, startISO) { return { ok, text }; } +/** Whether the named plugin is active (false when it is not registered). */ +function pluginActive(name) { + return dbQuery(`SELECT is_active FROM plugins WHERE name=${sqlStr(name)} LIMIT 1`) === '1'; +} + +/** + * Activate or deactivate a plugin through the admin endpoint, as the admin + * plugin page does. `page` must be logged in as an administrator. A no-op + * when the plugin is already in the requested state. + */ +async function setPluginActive(page, name, active) { + const id = dbInt(`SELECT id FROM plugins WHERE name=${sqlStr(name)} LIMIT 1`); + if (id <= 0) throw new Error(`${name} plugin is not registered`); + if (pluginActive(name) === active) return; + await page.goto(`${BASE}/admin/plugins`); + const status = await page.evaluate(async ({ pid, action }) => { + const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + const r = await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/${action}`, { method: 'POST', headers: { 'X-CSRF-Token': csrf } }); + return r.status; + }, { pid: id, action: active ? 'activate' : 'deactivate' }); + if (status >= 400) throw new Error(`${name} ${active ? 'activate' : 'deactivate'} failed: HTTP ${status}`); + if (pluginActive(name) !== active) throw new Error(`${name} is still ${active ? 'inactive' : 'active'}`); +} + +/** + * Snapshot the rows of a table (optionally filtered by `where`) so they can be + * put back verbatim. Every value travels hex-encoded, so quotes, backslashes + * and non-ASCII text survive the mysql batch output unchanged. + */ +function stashRows(table, where = '') { + const cols = dbQuery(`SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=${sqlStr(table)}`); + if (!cols) throw new Error(`table ${table} has no columns`); + const select = cols.split(',') + .map((c) => `IFNULL(CONCAT('CONVERT(X''', HEX(CAST(\`${c}\` AS CHAR)), ''' USING utf8mb4)'), 'NULL')`) + .join(", ',', "); + const filter = where ? ` WHERE ${where}` : ''; + const rows = dbQuery(`SELECT CONCAT(${select}) FROM \`${table}\`${filter}`).split('\n').filter(Boolean); + return { table, filter, cols: cols.split(',').map((c) => `\`${c}\``).join(','), rows }; +} + +/** Put back a stashRows() snapshot in one transaction: all of it or nothing. */ +function unstashRows(s) { + if (!s) return; + const sql = ['START TRANSACTION', `DELETE FROM \`${s.table}\`${s.filter}`]; + for (const values of s.rows) sql.push(`INSERT INTO \`${s.table}\` (${s.cols}) VALUES (${values})`); + sql.push('COMMIT'); + dbQuery(sql.join(';\n')); +} + +/** Run every cleanup step even when an earlier one throws; rethrow the first error. */ +async function runAll(steps) { + let first = null; + for (const step of steps) { + try { await step(); } catch (err) { if (!first) first = err; } + } + if (first) throw first; +} + module.exports = { BASE, ADMIN_EMAIL, ADMIN_PASS, CONFIGURED, dbQuery, dbInt, sqlStr, clearRateLimits, phpHash, @@ -233,4 +291,5 @@ module.exports = { loginAs, loginAsAdmin, openCirculationTab, createTypeViaUi, setMatrixCell, fillAutocomplete, staffLoan, requestFromBookPage, copyCodeOf, appTodayISO, appDateOffsetISO, + pluginActive, setPluginActive, stashRows, unstashRows, runAll, }; diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js index 5f54f4061..4a55ff423 100644 --- a/tests/stack-090-integration.spec.js +++ b/tests/stack-090-integration.spec.js @@ -97,7 +97,9 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a /** @type {import('@playwright/test').Page} */ let admin; let adminCtx; const saved = {}; - let savedClosures = []; + let savedClosures = null; + let ncipWasActive = true; + let mobileWasActive = true; let gate = null; let typeId = 0; let reader; @@ -109,7 +111,7 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a test.beforeAll(async ({ browser }) => { for (const k of LOAN_SETTINGS) saved[k] = readSetting(k); - savedClosures = F.dbQuery("SELECT CONCAT_WS(0x1f, id, data_inizio, data_fine, ricorrente_annuale, IFNULL(sede_id, ''), IFNULL(descrizione, '')) FROM calendario_chiusure").split('\n').filter(Boolean); + savedClosures = F.stashRows('calendario_chiusure'); F.dbQuery('DELETE FROM calendario_chiusure'); // Global fines and threshold empty: only the patron type charges and blocks. for (const k of ['daily_fine', 'fine_max_per_loan', 'debt_block_threshold']) restoreSetting(k, ''); @@ -124,6 +126,7 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a plain = F.createPatron(`INP${RUN}`, { password: PASS }); for (const key of ['desk', 'web', 'app', 'ncip', 'plain']) books[key] = F.createTitle(`${PREFIX}-${key}`); + mobileWasActive = F.pluginActive('mobile-api'); const helper = await browser.newPage(); gate = await M.ensureMobileApiReady(helper); await helper.close(); @@ -134,40 +137,39 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a admin = await adminCtx.newPage(); admin.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); await F.loginAsAdmin(admin); - if (F.dbQuery(`SELECT is_active FROM plugins WHERE id=${ncipId}`) !== '1') { - await admin.goto(`${F.BASE}/admin/plugins`); - await admin.evaluate(async (pid) => { - const fd = new FormData(); - fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); - await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/activate`, { method: 'POST', body: fd }); - }, ncipId); - } + ncipWasActive = F.pluginActive('ncip-server'); + await F.setPluginActive(admin, 'ncip-server', true); }); test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); test.afterAll(async () => { - for (const k of LOAN_SETTINGS) restoreSetting(k, saved[k]); - clearConfigCache(); - F.dbQuery('DELETE FROM calendario_chiusure'); - for (const line of savedClosures) { - const [id, start, end, yearly, sede, description] = line.split('\x1f'); - F.dbQuery(`INSERT INTO calendario_chiusure (id, data_inizio, data_fine, ricorrente_annuale, sede_id, descrizione) VALUES (${Number(id)}, '${start}', '${end}', ${Number(yearly)}, ${sede === '' ? 'NULL' : Number(sede)}, ${F.sqlStr(description)})`); - } - for (const p of [reader, plain]) { - if (!p) continue; - try { F.dbQuery(`DELETE FROM mobile_app_tokens WHERE user_id=${p.id}`); } catch { /* no token table */ } - } - const ids = Object.values(books).map((b) => b.id); - if (ids.length) { - try { F.dbQuery(`DELETE FROM admin_notifications WHERE related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids.join(',')}))`); } catch { /* */ } - try { F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id IN (${ids.join(',')})`); } catch { /* */ } - } - F.deleteTitles(ids); - F.deletePatrons([reader?.id, plain?.id]); - F.deleteTypes(PREFIX); - M.restoreGate(gate); - await adminCtx?.close(); + // Independent restore steps: one failing must not leave the others undone. + await F.runAll([ + () => { for (const k of LOAN_SETTINGS) restoreSetting(k, saved[k]); }, + () => clearConfigCache(), + () => F.unstashRows(savedClosures), + () => { + for (const p of [reader, plain]) { + if (!p) continue; + try { F.dbQuery(`DELETE FROM mobile_app_tokens WHERE user_id=${p.id}`); } catch { /* no token table */ } + } + }, + () => { + const ids = Object.values(books).map((b) => b.id); + if (ids.length) { + try { F.dbQuery(`DELETE FROM admin_notifications WHERE related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids.join(',')}))`); } catch { /* */ } + try { F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id IN (${ids.join(',')})`); } catch { /* */ } + } + F.deleteTitles(ids); + }, + () => F.deletePatrons([reader?.id, plain?.id]), + () => F.deleteTypes(PREFIX), + () => M.restoreGate(gate), + async () => { if (admin && !ncipWasActive) await F.setPluginActive(admin, 'ncip-server', false); }, + async () => { if (admin && !mobileWasActive) await F.setPluginActive(admin, 'mobile-api', false); }, + async () => { await adminCtx?.close(); }, + ]); }); test('1. the librarian creates a patron type with its own loan length, daily fine and debt threshold', async () => { diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js index c7b9d2a96..72dc760c5 100644 --- a/tests/stack-090-review-fixes.spec.js +++ b/tests/stack-090-review-fixes.spec.js @@ -264,6 +264,9 @@ test.describe.serial('C. stocktake and copies recorded out of circulation', () = const sessions = F.dbQuery(`SELECT id FROM inventario_sessioni WHERE nome LIKE ${S(TAG + '%')}`).split('\n').filter(Boolean); if (sessions.length) { F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='inventario_sessioni' AND record_id IN (${sessions.join(',')})`); + // Children first, in FK order, without relying on ON DELETE CASCADE. + F.dbQuery(`DELETE FROM inventario_scansioni WHERE sessione_id IN (${sessions.join(',')})`); + F.dbQuery(`DELETE FROM inventario_attese WHERE sessione_id IN (${sessions.join(',')})`); F.dbQuery(`DELETE FROM inventario_sessioni WHERE id IN (${sessions.join(',')})`); } if (fx.book) { diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js index bde880b97..f89ae6bf6 100644 --- a/tests/stack-090-zero-config.spec.js +++ b/tests/stack-090-zero-config.spec.js @@ -62,18 +62,6 @@ function addDays(iso, days) { d.setUTCDate(d.getUTCDate() + days); return d.toISOString().slice(0, 10); } -/** Rows of a table as tab-separated lines, to put back verbatim afterwards. */ -function stash(table) { - const cols = F.dbQuery(`SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='${table}'`); - const select = cols.split(',').map((c) => `QUOTE(\`${c}\`)`).join(", ',', "); - const rows = F.dbQuery(`SELECT CONCAT(${select}) FROM ${table}`).split('\n').filter(Boolean); - return { cols, rows }; -} -function unstash(table, s) { - F.dbQuery(`DELETE FROM ${table}`); - for (const values of s.rows) F.dbQuery(`INSERT INTO ${table} (${s.cols}) VALUES (${values})`); -} - /** A title with one copy lent to $patron, lent 20 days ago and due $late days ago. */ function overdueLoan(patron, book, late) { const copyId = book.copyIds[0]; @@ -91,6 +79,8 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { let closures; let rules; let emailSettings; + let ncipWasActive = true; + let mobileWasActive = true; let gate = null; let reader; let debtor; @@ -103,9 +93,9 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { test.beforeAll(async ({ browser }) => { for (const k of LOAN_SETTINGS) saved[k] = readSetting('loans', k); - closures = stash('calendario_chiusure'); - rules = stash('regole_prestito'); - emailSettings = F.dbQuery("SELECT CONCAT(QUOTE(setting_key), ',', QUOTE(setting_value)) FROM system_settings WHERE category='email'").split('\n').filter(Boolean); + closures = F.stashRows('calendario_chiusure'); + rules = F.stashRows('regole_prestito'); + emailSettings = F.stashRows('system_settings', "category='email'"); F.dbQuery('DELETE FROM calendario_chiusure'); F.dbQuery('DELETE FROM regole_prestito'); for (const k of ['daily_fine', 'fine_max_per_loan', 'debt_block_threshold']) writeSetting('loans', k, ''); @@ -124,6 +114,7 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { F.dbQuery(`INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES (${debtor.id}, 'addebito_manuale', 50.00, 'Tessera smarrita', NOW())`); for (const key of ['desk', 'renew', 'request', 'late', 'web', 'app', 'ncip']) books[key] = F.createTitle(`${PREFIX}-${key}`); + mobileWasActive = F.pluginActive('mobile-api'); const helper = await browser.newPage(); gate = await M.ensureMobileApiReady(helper); await helper.close(); @@ -132,41 +123,41 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { admin = await adminCtx.newPage(); admin.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); await F.loginAsAdmin(admin); - const ncipId = F.dbInt("SELECT id FROM plugins WHERE name='ncip-server' LIMIT 1"); - if (ncipId > 0 && F.dbQuery(`SELECT is_active FROM plugins WHERE id=${ncipId}`) !== '1') { - await admin.goto(`${F.BASE}/admin/plugins`); - await admin.evaluate(async (pid) => { - const fd = new FormData(); - fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); - await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/activate`, { method: 'POST', body: fd }); - }, ncipId); - } + ncipWasActive = F.pluginActive('ncip-server'); + await F.setPluginActive(admin, 'ncip-server', true); }); test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); test.afterAll(async () => { - for (const k of LOAN_SETTINGS) writeSetting('loans', k, saved[k]); - unstash('calendario_chiusure', closures); - unstash('regole_prestito', rules); - if (emailSettings.length) { - F.dbQuery("DELETE FROM system_settings WHERE category='email'"); - for (const pair of emailSettings) F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('email', ${pair})`); - } - clearConfigCache(); - for (const p of [reader, debtor]) { - if (!p) continue; - try { F.dbQuery(`DELETE FROM mobile_app_tokens WHERE user_id=${p.id}`); } catch { /* no token table */ } - } - const ids = Object.values(books).map((b) => b.id); - if (ids.length) { - try { F.dbQuery(`DELETE FROM admin_notifications WHERE related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids.join(',')}))`); } catch { /* */ } - try { F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id IN (${ids.join(',')})`); } catch { /* */ } - } - F.deleteTitles(ids); - F.deletePatrons([reader?.id, debtor?.id]); - M.restoreGate(gate); - await adminCtx?.close(); + // Independent restore steps: one failing must not leave the others undone. + await F.runAll([ + () => { for (const k of LOAN_SETTINGS) writeSetting('loans', k, saved[k]); }, + () => F.unstashRows(closures), + () => F.unstashRows(rules), + // Always reset the category: an install with no email rows gets none back. + () => F.unstashRows(emailSettings), + () => clearConfigCache(), + () => { + for (const p of [reader, debtor]) { + if (!p) continue; + try { F.dbQuery(`DELETE FROM mobile_app_tokens WHERE user_id=${p.id}`); } catch { /* no token table */ } + } + }, + () => { + const ids = Object.values(books).map((b) => b.id); + if (ids.length) { + try { F.dbQuery(`DELETE FROM admin_notifications WHERE related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids.join(',')}))`); } catch { /* */ } + try { F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id IN (${ids.join(',')})`); } catch { /* */ } + } + F.deleteTitles(ids); + }, + () => F.deletePatrons([reader?.id, debtor?.id]), + () => M.restoreGate(gate), + async () => { if (admin && !ncipWasActive) await F.setPluginActive(admin, 'ncip-server', false); }, + async () => { if (admin && !mobileWasActive) await F.setPluginActive(admin, 'mobile-api', false); }, + async () => { await adminCtx?.close(); }, + ]); }); test('1. the settings, calendar and circulation pages show nothing configured', async () => { From 92935ede0e5e909d100c570c5f4244e164cab925 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:17:14 +0100 Subject: [PATCH 191/398] Holidays: rule table and date calculator for 22 countries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each country lists its public holidays as rules with the native name: fixed dates, Easter offsets, nth weekday of a month, weekday on or before/after a date, and a fixed date on given weekdays with a fallback. Weekend rules cover the US, UK, Irish, Canadian and Australian substitute days, Koningsdag and the Argentine movable holidays; from/until years and one-year overrides (VE Day 2020, the Jubilees) are part of the rule. Regions: German Länder, UK nations, Canadian provinces, Australian states, Alsace-Moselle and six Swiss cantons. --- app/Support/Holidays/HolidayCalculator.php | 274 +++++++++++ app/Support/Holidays/HolidayRules.php | 510 +++++++++++++++++++++ tests/holiday-calculator.unit.php | 210 +++++++++ 3 files changed, 994 insertions(+) create mode 100644 app/Support/Holidays/HolidayCalculator.php create mode 100644 app/Support/Holidays/HolidayRules.php create mode 100644 tests/holiday-calculator.unit.php diff --git a/app/Support/Holidays/HolidayCalculator.php b/app/Support/Holidays/HolidayCalculator.php new file mode 100644 index 000000000..21a5f7a53 --- /dev/null +++ b/app/Support/Holidays/HolidayCalculator.php @@ -0,0 +1,274 @@ + 0, 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6]; + + /** @return array{name: string, regions?: array, region_required?: bool, rules: list>}|null */ + public static function country(string $code): ?array + { + return HolidayRules::countries()[strtoupper($code)] ?? null; + } + + /** + * Country code => display name, sorted by name. + * + * @return array + */ + public static function countryNames(): array + { + $names = []; + foreach (HolidayRules::countries() as $code => $country) { + $names[$code] = $country['name']; + } + uasort($names, static fn (string $a, string $b): int => strcmp(self::sortKey($a), self::sortKey($b))); + return $names; + } + + /** @return array region code => name (empty when the country has none) */ + public static function regions(string $country): array + { + return self::country($country)['regions'] ?? []; + } + + /** + * The region to use: $region when the country has it, the first region + * when the country needs one (United Kingdom), '' otherwise. + */ + public static function normaliseRegion(string $country, string $region): string + { + $regions = self::regions($country); + if ($region !== '' && isset($regions[$region])) { + return $region; + } + if (!empty(self::country($country)['region_required']) && $regions !== []) { + return (string) array_key_first($regions); + } + return ''; + } + + /** + * The rules that apply to $country / $region, in table order: the + * national ones plus, with a region, those of that region. + * + * @return list> + */ + public static function rulesFor(string $country, string $region = ''): array + { + $data = self::country($country); + if ($data === null) { + return []; + } + $out = []; + foreach ($data['rules'] as $rule) { + $regions = $rule['regions'] ?? null; + if (is_array($regions) && !in_array($region, $regions, true)) { + continue; + } + $out[] = $rule; + } + return $out; + } + + /** + * Each applicable rule with its date in $year (null when the holiday + * does not exist that year). + * + * @return list + */ + public static function forYear(string $country, string $region, int $year): array + { + $country = strtoupper($country); + $out = []; + foreach (self::rulesFor($country, $region) as $rule) { + $out[] = [ + 'id' => $country . ':' . (string) $rule['id'], + 'name' => (string) $rule['name'], + 'date' => self::dateOf($rule, $year), + 'yearly' => self::isYearly($rule), + 'regional' => isset($rule['regions']), + 'optional' => !empty($rule['optional']), + ]; + } + return $out; + } + + /** + * The same date every year: a fixed day that never moves and always + * exists. It is stored once as a yearly closure; any other rule is + * stored as one row per year. + * + * @param array $rule + */ + public static function isYearly(array $rule): bool + { + $kind = $rule['rule'][0] ?? null; + return $kind === 'fixed' + && empty($rule['observed']) + && !isset($rule['from']) + && !isset($rule['until']) + && empty($rule['overrides']); + } + + /** + * The date of one rule in $year, after the weekend rule and the yearly + * overrides; null when the year is outside from/until. + * + * @param array $rule + */ + public static function dateOf(array $rule, int $year): ?string + { + if (isset($rule['from']) && $year < (int) $rule['from']) { + return null; + } + if (isset($rule['until']) && $year > (int) $rule['until']) { + return null; + } + $overrides = $rule['overrides'] ?? []; + if (is_array($overrides) && isset($overrides[$year]) && is_string($overrides[$year])) { + return $overrides[$year]; + } + $spec = $rule['rule'] ?? null; + if (!is_array($spec)) { + return null; + } + $date = self::evaluate($spec, $year); + if ($date === null) { + return null; + } + $observed = $rule['observed'] ?? null; + return is_string($observed) && $observed !== '' ? self::observe($date, $observed) : $date; + } + + /** + * @param array $spec + */ + public static function evaluate(array $spec, int $year): ?string + { + $kind = $spec[0] ?? null; + switch ($kind) { + case 'fixed': + return self::ymd($year, (string) $spec[1]); + case 'easter': + return LibraryCalendar::shift(LibraryCalendar::easterSunday($year), (int) $spec[1]); + case 'nth_weekday': + $date = self::nthWeekday($year, (int) $spec[1], self::weekdayIndex((string) $spec[2]), (int) $spec[3]); + return $date === null ? null : LibraryCalendar::shift($date, (int) ($spec[4] ?? 0)); + case 'weekday_on_or_before': + $anchor = self::ymd($year, (string) $spec[1]); + if ($anchor === null) { + return null; + } + $back = (self::weekday($anchor) - self::weekdayIndex((string) $spec[2]) + 7) % 7; + return LibraryCalendar::shift($anchor, -$back); + case 'weekday_on_or_after': + $anchor = self::ymd($year, (string) $spec[1]); + if ($anchor === null) { + return null; + } + $forward = (self::weekdayIndex((string) $spec[2]) - self::weekday($anchor) + 7) % 7; + return LibraryCalendar::shift($anchor, $forward); + case 'fixed_if': + $date = self::ymd($year, (string) $spec[1]); + if ($date === null) { + return null; + } + $days = array_map(static fn ($d): int => self::weekdayIndex((string) $d), (array) $spec[2]); + if (in_array(self::weekday($date), $days, true)) { + return $date; + } + return is_array($spec[3] ?? null) ? self::evaluate($spec[3], $year) : null; + } + return null; + } + + /** + * The day off for a holiday that falls on a weekend: + * - us Saturday → Friday before, Sunday → Monday after; + * - next_monday Saturday or Sunday → the Monday after; + * - pair Saturday or Sunday → two days later: for two holidays + * in a row (Christmas and Boxing Day, 1 and 2 January in + * Scotland) the pair lands on Monday and Tuesday; + * - sunday_back Sunday → the Saturday before (Koningsdag); + * - ar_movable Tuesday/Wednesday → the Monday before, Thursday/Friday + * → the Monday after (Argentina, law 27.399). + */ + public static function observe(string $date, string $mode): string + { + $weekday = self::weekday($date); + switch ($mode) { + case 'us': + return $weekday === 6 ? LibraryCalendar::shift($date, -1) : ($weekday === 0 ? LibraryCalendar::shift($date, 1) : $date); + case 'next_monday': + return $weekday === 6 ? LibraryCalendar::shift($date, 2) : ($weekday === 0 ? LibraryCalendar::shift($date, 1) : $date); + case 'pair': + return ($weekday === 6 || $weekday === 0) ? LibraryCalendar::shift($date, 2) : $date; + case 'sunday_back': + return $weekday === 0 ? LibraryCalendar::shift($date, -1) : $date; + case 'ar_movable': + return match ($weekday) { + 2 => LibraryCalendar::shift($date, -1), + 3 => LibraryCalendar::shift($date, -2), + 4 => LibraryCalendar::shift($date, 4), + 5 => LibraryCalendar::shift($date, 3), + default => $date, + }; + } + return $date; + } + + /** The $n-th $weekday (0 = Sunday) of $month; $n = -1 is the last one. */ + public static function nthWeekday(int $year, int $month, int $weekday, int $n): ?string + { + if ($month < 1 || $month > 12 || $weekday < 0 || $n === 0 || $n > 5 || $n < -1) { + return null; + } + if ($n > 0) { + $first = sprintf('%04d-%02d-01', $year, $month); + $date = LibraryCalendar::shift($first, (($weekday - self::weekday($first) + 7) % 7) + 7 * ($n - 1)); + return substr($date, 0, 7) === substr($first, 0, 7) ? $date : null; + } + $last = (new \DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month), new \DateTimeZone('UTC')))->modify('last day of this month')->format('Y-m-d'); + return LibraryCalendar::shift($last, -((self::weekday($last) - $weekday + 7) % 7)); + } + + private static function ymd(int $year, string $monthDay): ?string + { + $date = sprintf('%04d-%s', $year, $monthDay); + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date, new \DateTimeZone('UTC')); + return $parsed !== false && $parsed->format('Y-m-d') === $date ? $date : null; + } + + private static function weekday(string $ymd): int + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $ymd, new \DateTimeZone('UTC')); + return $date === false ? -1 : (int) $date->format('w'); + } + + private static function weekdayIndex(string $name): int + { + return self::WEEKDAYS[strtolower($name)] ?? -1; + } + + /** Sort key without accents, so "Éire" sorts with the E. */ + private static function sortKey(string $name): string + { + if (class_exists(\Normalizer::class)) { + $decomposed = \Normalizer::normalize($name, \Normalizer::FORM_D); + if (is_string($decomposed)) { + $name = (string) preg_replace('/\p{Mn}+/u', '', $decomposed); + } + } + return mb_strtolower($name, 'UTF-8'); + } +} diff --git a/app/Support/Holidays/HolidayRules.php b/app/Support/Holidays/HolidayRules.php new file mode 100644 index 000000000..3feac0354 --- /dev/null +++ b/app/Support/Holidays/HolidayRules.php @@ -0,0 +1,510 @@ + Y-m-d, a one-year move decided by law (VE Day 2020). + * + * Rule kinds (all evaluated by HolidayCalculator::dateOf()): + * ['fixed', 'MM-DD'] + * ['easter', offsetDays] Gregorian Easter Sunday ± n + * ['nth_weekday', month, 'mon', n, offset = 0] n = 1..5, -1 = last + * ['weekday_on_or_before', 'MM-DD', 'mon'] + * ['weekday_on_or_after', 'MM-DD', 'sat'] + * ['fixed_if', 'MM-DD', ['fri'], ] that date on those weekdays, + * otherwise the fallback rule + * + * The names are stored as written here (calendario_chiusure.descrizione) and + * never go through __(): a German library wants "Fronleichnam" in German. + * The lists follow the official rules known when they were written; the + * library ticks the ones it closes for and can always add closures by hand. + * Regional lists are not exhaustive where noted (CH: six cantons; AU: the + * Western Australia King's Birthday is set by proclamation and is left out). + */ +final class HolidayRules +{ + /** + * @return array, region_required?: bool, rules: list>}> + */ + public static function countries(): array + { + return [ + 'IT' => [ + 'name' => 'Italia', + 'rules' => [ + ['id' => 'new_year', 'name' => 'Capodanno', 'rule' => ['fixed', '01-01']], + ['id' => 'epiphany', 'name' => 'Epifania', 'rule' => ['fixed', '01-06']], + ['id' => 'easter', 'name' => 'Pasqua', 'rule' => ['easter', 0]], + ['id' => 'easter_monday', 'name' => "Lunedì dell'Angelo (Pasquetta)", 'rule' => ['easter', 1]], + ['id' => 'liberation', 'name' => 'Festa della Liberazione', 'rule' => ['fixed', '04-25']], + ['id' => 'labour', 'name' => 'Festa del Lavoro', 'rule' => ['fixed', '05-01']], + ['id' => 'republic', 'name' => 'Festa della Repubblica', 'rule' => ['fixed', '06-02']], + ['id' => 'ferragosto', 'name' => 'Ferragosto', 'rule' => ['fixed', '08-15']], + // Restored as a national holiday by the 2025 law, from 2026. + ['id' => 'san_francesco', 'name' => "San Francesco d'Assisi, patrono d'Italia", 'rule' => ['fixed', '10-04'], 'from' => 2026], + ['id' => 'all_saints', 'name' => 'Ognissanti', 'rule' => ['fixed', '11-01']], + ['id' => 'immaculate', 'name' => 'Immacolata Concezione', 'rule' => ['fixed', '12-08']], + ['id' => 'christmas', 'name' => 'Natale', 'rule' => ['fixed', '12-25']], + ['id' => 'st_stephen', 'name' => 'Santo Stefano', 'rule' => ['fixed', '12-26']], + ], + ], + 'US' => [ + 'name' => 'United States', + 'rules' => [ + ['id' => 'new_year', 'name' => "New Year's Day", 'rule' => ['fixed', '01-01'], 'observed' => 'us'], + ['id' => 'mlk_day', 'name' => 'Birthday of Martin Luther King, Jr.', 'rule' => ['nth_weekday', 1, 'mon', 3]], + ['id' => 'presidents_day', 'name' => "Washington's Birthday (Presidents' Day)", 'rule' => ['nth_weekday', 2, 'mon', 3]], + ['id' => 'memorial_day', 'name' => 'Memorial Day', 'rule' => ['nth_weekday', 5, 'mon', -1]], + ['id' => 'juneteenth', 'name' => 'Juneteenth National Independence Day', 'rule' => ['fixed', '06-19'], 'observed' => 'us', 'from' => 2021], + ['id' => 'independence_day', 'name' => 'Independence Day', 'rule' => ['fixed', '07-04'], 'observed' => 'us'], + ['id' => 'labor_day', 'name' => 'Labor Day', 'rule' => ['nth_weekday', 9, 'mon', 1]], + ['id' => 'columbus_day', 'name' => 'Columbus Day', 'rule' => ['nth_weekday', 10, 'mon', 2]], + ['id' => 'veterans_day', 'name' => 'Veterans Day', 'rule' => ['fixed', '11-11'], 'observed' => 'us'], + ['id' => 'thanksgiving', 'name' => 'Thanksgiving Day', 'rule' => ['nth_weekday', 11, 'thu', 4]], + ['id' => 'christmas', 'name' => 'Christmas Day', 'rule' => ['fixed', '12-25'], 'observed' => 'us'], + ], + ], + 'GB' => [ + 'name' => 'United Kingdom', + 'regions' => ['ENG' => 'England and Wales', 'SCT' => 'Scotland', 'NIR' => 'Northern Ireland'], + 'region_required' => true, + 'rules' => [ + ['id' => 'new_year', 'name' => "New Year's Day", 'rule' => ['fixed', '01-01'], 'observed' => 'next_monday', 'regions' => ['ENG', 'NIR']], + ['id' => 'new_year_sct', 'name' => "New Year's Day", 'rule' => ['fixed', '01-01'], 'observed' => 'pair', 'regions' => ['SCT']], + ['id' => 'second_january', 'name' => '2nd January', 'rule' => ['fixed', '01-02'], 'observed' => 'pair', 'regions' => ['SCT']], + ['id' => 'st_patrick', 'name' => "St Patrick's Day", 'rule' => ['fixed', '03-17'], 'observed' => 'next_monday', 'regions' => ['NIR']], + ['id' => 'good_friday', 'name' => 'Good Friday', 'rule' => ['easter', -2]], + ['id' => 'easter_monday', 'name' => 'Easter Monday', 'rule' => ['easter', 1], 'regions' => ['ENG', 'NIR']], + // Moved to Friday 8 May for the VE Day anniversaries. + ['id' => 'early_may', 'name' => 'Early May bank holiday', 'rule' => ['nth_weekday', 5, 'mon', 1], 'overrides' => [1995 => '1995-05-08', 2020 => '2020-05-08']], + // Moved for the Golden, Diamond and Platinum Jubilees. + ['id' => 'spring', 'name' => 'Spring bank holiday', 'rule' => ['nth_weekday', 5, 'mon', -1], 'overrides' => [2002 => '2002-06-04', 2012 => '2012-06-04', 2022 => '2022-06-02']], + ['id' => 'battle_of_the_boyne', 'name' => "Battle of the Boyne (Orangemen's Day)", 'rule' => ['fixed', '07-12'], 'observed' => 'next_monday', 'regions' => ['NIR']], + ['id' => 'summer', 'name' => 'Summer bank holiday', 'rule' => ['nth_weekday', 8, 'mon', -1], 'regions' => ['ENG', 'NIR']], + ['id' => 'summer_sct', 'name' => 'Summer bank holiday', 'rule' => ['nth_weekday', 8, 'mon', 1], 'regions' => ['SCT']], + ['id' => 'st_andrew', 'name' => "St Andrew's Day", 'rule' => ['fixed', '11-30'], 'observed' => 'next_monday', 'regions' => ['SCT']], + ['id' => 'christmas', 'name' => 'Christmas Day', 'rule' => ['fixed', '12-25'], 'observed' => 'pair'], + ['id' => 'boxing_day', 'name' => 'Boxing Day', 'rule' => ['fixed', '12-26'], 'observed' => 'pair'], + ], + ], + 'IE' => [ + 'name' => 'Éire / Ireland', + 'rules' => [ + ['id' => 'new_year', 'name' => "New Year's Day", 'rule' => ['fixed', '01-01'], 'observed' => 'next_monday'], + // First Monday of February, or 1 February when that is a Friday. + ['id' => 'st_brigid', 'name' => "St Brigid's Day", 'rule' => ['fixed_if', '02-01', ['fri'], ['nth_weekday', 2, 'mon', 1]], 'from' => 2023], + ['id' => 'st_patrick', 'name' => "St Patrick's Day", 'rule' => ['fixed', '03-17'], 'observed' => 'next_monday'], + ['id' => 'easter_monday', 'name' => 'Easter Monday', 'rule' => ['easter', 1]], + ['id' => 'may_day', 'name' => 'May Day', 'rule' => ['nth_weekday', 5, 'mon', 1]], + ['id' => 'june_holiday', 'name' => 'June Holiday', 'rule' => ['nth_weekday', 6, 'mon', 1]], + ['id' => 'august_holiday', 'name' => 'August Holiday', 'rule' => ['nth_weekday', 8, 'mon', 1]], + ['id' => 'october_holiday', 'name' => 'October Holiday', 'rule' => ['nth_weekday', 10, 'mon', -1]], + ['id' => 'christmas', 'name' => 'Christmas Day', 'rule' => ['fixed', '12-25'], 'observed' => 'pair'], + ['id' => 'st_stephen', 'name' => "St Stephen's Day", 'rule' => ['fixed', '12-26'], 'observed' => 'pair'], + ], + ], + 'DE' => [ + 'name' => 'Deutschland', + 'regions' => [ + 'BW' => 'Baden-Württemberg', 'BY' => 'Bayern', 'BE' => 'Berlin', 'BB' => 'Brandenburg', + 'HB' => 'Bremen', 'HH' => 'Hamburg', 'HE' => 'Hessen', 'MV' => 'Mecklenburg-Vorpommern', + 'NI' => 'Niedersachsen', 'NW' => 'Nordrhein-Westfalen', 'RP' => 'Rheinland-Pfalz', 'SL' => 'Saarland', + 'SN' => 'Sachsen', 'ST' => 'Sachsen-Anhalt', 'SH' => 'Schleswig-Holstein', 'TH' => 'Thüringen', + ], + 'rules' => [ + ['id' => 'neujahr', 'name' => 'Neujahr', 'rule' => ['fixed', '01-01']], + ['id' => 'heilige_drei_koenige', 'name' => 'Heilige Drei Könige', 'rule' => ['fixed', '01-06'], 'regions' => ['BW', 'BY', 'ST']], + ['id' => 'frauentag', 'name' => 'Internationaler Frauentag', 'rule' => ['fixed', '03-08'], 'regions' => ['BE'], 'from' => 2019], + ['id' => 'frauentag_mv', 'name' => 'Internationaler Frauentag', 'rule' => ['fixed', '03-08'], 'regions' => ['MV'], 'from' => 2023], + ['id' => 'karfreitag', 'name' => 'Karfreitag', 'rule' => ['easter', -2]], + ['id' => 'ostersonntag', 'name' => 'Ostersonntag', 'rule' => ['easter', 0], 'regions' => ['BB']], + ['id' => 'ostermontag', 'name' => 'Ostermontag', 'rule' => ['easter', 1]], + ['id' => 'tag_der_arbeit', 'name' => 'Tag der Arbeit', 'rule' => ['fixed', '05-01']], + ['id' => 'christi_himmelfahrt', 'name' => 'Christi Himmelfahrt', 'rule' => ['easter', 39]], + ['id' => 'pfingstsonntag', 'name' => 'Pfingstsonntag', 'rule' => ['easter', 49], 'regions' => ['BB']], + ['id' => 'pfingstmontag', 'name' => 'Pfingstmontag', 'rule' => ['easter', 50]], + ['id' => 'fronleichnam', 'name' => 'Fronleichnam', 'rule' => ['easter', 60], 'regions' => ['BW', 'BY', 'HE', 'NW', 'RP', 'SL']], + ['id' => 'mariae_himmelfahrt', 'name' => 'Mariä Himmelfahrt', 'rule' => ['fixed', '08-15'], 'regions' => ['BY', 'SL']], + ['id' => 'weltkindertag', 'name' => 'Weltkindertag', 'rule' => ['fixed', '09-20'], 'regions' => ['TH'], 'from' => 2019], + ['id' => 'tag_der_deutschen_einheit', 'name' => 'Tag der Deutschen Einheit', 'rule' => ['fixed', '10-03']], + ['id' => 'reformationstag', 'name' => 'Reformationstag', 'rule' => ['fixed', '10-31'], 'regions' => ['BB', 'MV', 'SN', 'ST', 'TH']], + ['id' => 'reformationstag_nord', 'name' => 'Reformationstag', 'rule' => ['fixed', '10-31'], 'regions' => ['HB', 'HH', 'NI', 'SH'], 'from' => 2018], + ['id' => 'allerheiligen', 'name' => 'Allerheiligen', 'rule' => ['fixed', '11-01'], 'regions' => ['BW', 'BY', 'NW', 'RP', 'SL']], + // The Wednesday before 23 November. + ['id' => 'buss_und_bettag', 'name' => 'Buß- und Bettag', 'rule' => ['weekday_on_or_before', '11-22', 'wed'], 'regions' => ['SN']], + ['id' => 'erster_weihnachtstag', 'name' => '1. Weihnachtstag', 'rule' => ['fixed', '12-25']], + ['id' => 'zweiter_weihnachtstag', 'name' => '2. Weihnachtstag', 'rule' => ['fixed', '12-26']], + ], + ], + 'FR' => [ + 'name' => 'France', + 'regions' => ['ALM' => 'Alsace-Moselle'], + 'rules' => [ + ['id' => 'jour_de_l_an', 'name' => "Jour de l'an", 'rule' => ['fixed', '01-01']], + ['id' => 'vendredi_saint', 'name' => 'Vendredi saint', 'rule' => ['easter', -2], 'regions' => ['ALM']], + ['id' => 'lundi_de_paques', 'name' => 'Lundi de Pâques', 'rule' => ['easter', 1]], + ['id' => 'fete_du_travail', 'name' => 'Fête du Travail', 'rule' => ['fixed', '05-01']], + ['id' => 'victoire_1945', 'name' => 'Victoire 1945', 'rule' => ['fixed', '05-08']], + ['id' => 'ascension', 'name' => 'Ascension', 'rule' => ['easter', 39]], + ['id' => 'lundi_de_pentecote', 'name' => 'Lundi de Pentecôte', 'rule' => ['easter', 50]], + ['id' => 'fete_nationale', 'name' => 'Fête nationale', 'rule' => ['fixed', '07-14']], + ['id' => 'assomption', 'name' => 'Assomption', 'rule' => ['fixed', '08-15']], + ['id' => 'toussaint', 'name' => 'Toussaint', 'rule' => ['fixed', '11-01']], + ['id' => 'armistice', 'name' => 'Armistice 1918', 'rule' => ['fixed', '11-11']], + ['id' => 'noel', 'name' => 'Noël', 'rule' => ['fixed', '12-25']], + ['id' => 'saint_etienne', 'name' => 'Saint-Étienne', 'rule' => ['fixed', '12-26'], 'regions' => ['ALM']], + ], + ], + 'DK' => [ + 'name' => 'Danmark', + 'rules' => [ + ['id' => 'nytaarsdag', 'name' => 'Nytårsdag', 'rule' => ['fixed', '01-01']], + ['id' => 'skaertorsdag', 'name' => 'Skærtorsdag', 'rule' => ['easter', -3]], + ['id' => 'langfredag', 'name' => 'Langfredag', 'rule' => ['easter', -2]], + ['id' => 'paaskedag', 'name' => 'Påskedag', 'rule' => ['easter', 0]], + ['id' => 'anden_paaskedag', 'name' => '2. påskedag', 'rule' => ['easter', 1]], + // Abolished as a public holiday from 2024. + ['id' => 'store_bededag', 'name' => 'Store bededag', 'rule' => ['easter', 26], 'until' => 2023], + ['id' => 'kristi_himmelfartsdag', 'name' => 'Kristi himmelfartsdag', 'rule' => ['easter', 39]], + ['id' => 'pinsedag', 'name' => 'Pinsedag', 'rule' => ['easter', 49]], + ['id' => 'anden_pinsedag', 'name' => '2. pinsedag', 'rule' => ['easter', 50]], + ['id' => 'grundlovsdag', 'name' => 'Grundlovsdag', 'rule' => ['fixed', '06-05']], + ['id' => 'juleaftensdag', 'name' => 'Juleaftensdag', 'rule' => ['fixed', '12-24'], 'optional' => true], + ['id' => 'juledag', 'name' => 'Juledag', 'rule' => ['fixed', '12-25']], + ['id' => 'anden_juledag', 'name' => '2. juledag', 'rule' => ['fixed', '12-26']], + ['id' => 'nytaarsaftensdag', 'name' => 'Nytårsaftensdag', 'rule' => ['fixed', '12-31'], 'optional' => true], + ], + ], + 'ES' => [ + 'name' => 'España', + 'rules' => [ + ['id' => 'ano_nuevo', 'name' => 'Año Nuevo', 'rule' => ['fixed', '01-01']], + ['id' => 'epifania', 'name' => 'Epifanía del Señor', 'rule' => ['fixed', '01-06']], + // Regional in most autonomous communities: picked by the library. + ['id' => 'jueves_santo', 'name' => 'Jueves Santo', 'rule' => ['easter', -3], 'optional' => true], + ['id' => 'viernes_santo', 'name' => 'Viernes Santo', 'rule' => ['easter', -2]], + ['id' => 'lunes_de_pascua', 'name' => 'Lunes de Pascua', 'rule' => ['easter', 1], 'optional' => true], + ['id' => 'fiesta_del_trabajo', 'name' => 'Fiesta del Trabajo', 'rule' => ['fixed', '05-01']], + ['id' => 'asuncion', 'name' => 'Asunción de la Virgen', 'rule' => ['fixed', '08-15']], + ['id' => 'fiesta_nacional', 'name' => 'Fiesta Nacional de España', 'rule' => ['fixed', '10-12']], + ['id' => 'todos_los_santos', 'name' => 'Todos los Santos', 'rule' => ['fixed', '11-01']], + ['id' => 'constitucion', 'name' => 'Día de la Constitución Española', 'rule' => ['fixed', '12-06']], + ['id' => 'inmaculada', 'name' => 'Inmaculada Concepción', 'rule' => ['fixed', '12-08']], + ['id' => 'navidad', 'name' => 'Natividad del Señor', 'rule' => ['fixed', '12-25']], + ], + ], + 'PT' => [ + 'name' => 'Portugal', + 'rules' => [ + ['id' => 'ano_novo', 'name' => 'Ano Novo', 'rule' => ['fixed', '01-01']], + ['id' => 'carnaval', 'name' => 'Carnaval', 'rule' => ['easter', -47], 'optional' => true], + ['id' => 'sexta_feira_santa', 'name' => 'Sexta-feira Santa', 'rule' => ['easter', -2]], + ['id' => 'pascoa', 'name' => 'Páscoa', 'rule' => ['easter', 0]], + ['id' => 'dia_da_liberdade', 'name' => 'Dia da Liberdade', 'rule' => ['fixed', '04-25']], + ['id' => 'dia_do_trabalhador', 'name' => 'Dia do Trabalhador', 'rule' => ['fixed', '05-01']], + ['id' => 'corpo_de_deus', 'name' => 'Corpo de Deus', 'rule' => ['easter', 60]], + ['id' => 'dia_de_portugal', 'name' => 'Dia de Portugal, de Camões e das Comunidades Portuguesas', 'rule' => ['fixed', '06-10']], + ['id' => 'assuncao', 'name' => 'Assunção de Nossa Senhora', 'rule' => ['fixed', '08-15']], + ['id' => 'implantacao_da_republica', 'name' => 'Implantação da República', 'rule' => ['fixed', '10-05']], + ['id' => 'todos_os_santos', 'name' => 'Dia de Todos-os-Santos', 'rule' => ['fixed', '11-01']], + ['id' => 'restauracao', 'name' => 'Restauração da Independência', 'rule' => ['fixed', '12-01']], + ['id' => 'imaculada_conceicao', 'name' => 'Imaculada Conceição', 'rule' => ['fixed', '12-08']], + ['id' => 'natal', 'name' => 'Natal', 'rule' => ['fixed', '12-25']], + ], + ], + 'NL' => [ + 'name' => 'Nederland', + 'rules' => [ + ['id' => 'nieuwjaarsdag', 'name' => 'Nieuwjaarsdag', 'rule' => ['fixed', '01-01']], + ['id' => 'goede_vrijdag', 'name' => 'Goede Vrijdag', 'rule' => ['easter', -2], 'optional' => true], + ['id' => 'eerste_paasdag', 'name' => 'Eerste Paasdag', 'rule' => ['easter', 0]], + ['id' => 'tweede_paasdag', 'name' => 'Tweede Paasdag', 'rule' => ['easter', 1]], + // 27 April, the Saturday before when the 27th is a Sunday. + ['id' => 'koningsdag', 'name' => 'Koningsdag', 'rule' => ['fixed', '04-27'], 'observed' => 'sunday_back', 'from' => 2014], + ['id' => 'bevrijdingsdag', 'name' => 'Bevrijdingsdag', 'rule' => ['fixed', '05-05'], 'optional' => true], + ['id' => 'hemelvaartsdag', 'name' => 'Hemelvaartsdag', 'rule' => ['easter', 39]], + ['id' => 'eerste_pinksterdag', 'name' => 'Eerste Pinksterdag', 'rule' => ['easter', 49]], + ['id' => 'tweede_pinksterdag', 'name' => 'Tweede Pinksterdag', 'rule' => ['easter', 50]], + ['id' => 'eerste_kerstdag', 'name' => 'Eerste Kerstdag', 'rule' => ['fixed', '12-25']], + ['id' => 'tweede_kerstdag', 'name' => 'Tweede Kerstdag', 'rule' => ['fixed', '12-26']], + ], + ], + 'BE' => [ + 'name' => 'België / Belgique', + 'rules' => [ + ['id' => 'nieuwjaar', 'name' => "Nieuwjaar / Jour de l'An", 'rule' => ['fixed', '01-01']], + ['id' => 'pasen', 'name' => 'Pasen / Pâques', 'rule' => ['easter', 0]], + ['id' => 'paasmaandag', 'name' => 'Paasmaandag / Lundi de Pâques', 'rule' => ['easter', 1]], + ['id' => 'dag_van_de_arbeid', 'name' => 'Dag van de Arbeid / Fête du Travail', 'rule' => ['fixed', '05-01']], + ['id' => 'hemelvaart', 'name' => 'O.L.H. Hemelvaart / Ascension', 'rule' => ['easter', 39]], + ['id' => 'pinksteren', 'name' => 'Pinksteren / Pentecôte', 'rule' => ['easter', 49]], + ['id' => 'pinkstermaandag', 'name' => 'Pinkstermaandag / Lundi de Pentecôte', 'rule' => ['easter', 50]], + ['id' => 'nationale_feestdag', 'name' => 'Nationale feestdag / Fête nationale', 'rule' => ['fixed', '07-21']], + ['id' => 'olv_hemelvaart', 'name' => 'O.L.V. Hemelvaart / Assomption', 'rule' => ['fixed', '08-15']], + ['id' => 'allerheiligen', 'name' => 'Allerheiligen / Toussaint', 'rule' => ['fixed', '11-01']], + ['id' => 'wapenstilstand', 'name' => 'Wapenstilstand / Armistice', 'rule' => ['fixed', '11-11']], + ['id' => 'kerstmis', 'name' => 'Kerstmis / Noël', 'rule' => ['fixed', '12-25']], + ], + ], + 'AT' => [ + 'name' => 'Österreich', + 'rules' => [ + ['id' => 'neujahr', 'name' => 'Neujahr', 'rule' => ['fixed', '01-01']], + ['id' => 'heilige_drei_koenige', 'name' => 'Heilige Drei Könige', 'rule' => ['fixed', '01-06']], + ['id' => 'ostermontag', 'name' => 'Ostermontag', 'rule' => ['easter', 1]], + ['id' => 'staatsfeiertag', 'name' => 'Staatsfeiertag', 'rule' => ['fixed', '05-01']], + ['id' => 'christi_himmelfahrt', 'name' => 'Christi Himmelfahrt', 'rule' => ['easter', 39]], + ['id' => 'pfingstmontag', 'name' => 'Pfingstmontag', 'rule' => ['easter', 50]], + ['id' => 'fronleichnam', 'name' => 'Fronleichnam', 'rule' => ['easter', 60]], + ['id' => 'mariae_himmelfahrt', 'name' => 'Mariä Himmelfahrt', 'rule' => ['fixed', '08-15']], + ['id' => 'nationalfeiertag', 'name' => 'Nationalfeiertag', 'rule' => ['fixed', '10-26']], + ['id' => 'allerheiligen', 'name' => 'Allerheiligen', 'rule' => ['fixed', '11-01']], + ['id' => 'mariae_empfaengnis', 'name' => 'Mariä Empfängnis', 'rule' => ['fixed', '12-08']], + ['id' => 'christtag', 'name' => 'Christtag', 'rule' => ['fixed', '12-25']], + ['id' => 'stefanitag', 'name' => 'Stefanitag', 'rule' => ['fixed', '12-26']], + ], + ], + 'CH' => [ + 'name' => 'Schweiz / Suisse / Svizzera', + // Not exhaustive: six cantons in this first list. + 'regions' => ['BE' => 'Bern', 'BS' => 'Basel-Stadt', 'GE' => 'Genève', 'TI' => 'Ticino', 'VD' => 'Vaud', 'ZH' => 'Zürich'], + 'rules' => [ + ['id' => 'neujahr', 'name' => 'Neujahr / Nouvel An / Capodanno', 'rule' => ['fixed', '01-01']], + ['id' => 'berchtoldstag', 'name' => 'Berchtoldstag / Saint-Berthold', 'rule' => ['fixed', '01-02'], 'regions' => ['BE', 'VD', 'ZH']], + ['id' => 'epifania', 'name' => 'Epifania', 'rule' => ['fixed', '01-06'], 'regions' => ['TI']], + ['id' => 'san_giuseppe', 'name' => 'San Giuseppe', 'rule' => ['fixed', '03-19'], 'regions' => ['TI']], + ['id' => 'karfreitag', 'name' => 'Karfreitag / Vendredi saint', 'rule' => ['easter', -2], 'regions' => ['BE', 'BS', 'GE', 'VD', 'ZH']], + ['id' => 'ostermontag', 'name' => 'Ostermontag / Lundi de Pâques / Lunedì di Pasqua', 'rule' => ['easter', 1], 'regions' => ['BE', 'BS', 'GE', 'TI', 'VD', 'ZH']], + ['id' => 'tag_der_arbeit', 'name' => 'Tag der Arbeit / Festa del lavoro', 'rule' => ['fixed', '05-01'], 'regions' => ['BS', 'TI', 'ZH']], + ['id' => 'auffahrt', 'name' => 'Auffahrt / Ascension / Ascensione', 'rule' => ['easter', 39]], + ['id' => 'pfingstmontag', 'name' => 'Pfingstmontag / Lundi de Pentecôte / Lunedì di Pentecoste', 'rule' => ['easter', 50], 'regions' => ['BE', 'BS', 'GE', 'TI', 'VD', 'ZH']], + ['id' => 'corpus_domini', 'name' => 'Corpus Domini', 'rule' => ['easter', 60], 'regions' => ['TI']], + ['id' => 'santi_pietro_e_paolo', 'name' => 'Santi Pietro e Paolo', 'rule' => ['fixed', '06-29'], 'regions' => ['TI']], + ['id' => 'bundesfeier', 'name' => 'Bundesfeier / Fête nationale / Festa nazionale', 'rule' => ['fixed', '08-01']], + ['id' => 'assunzione', 'name' => 'Assunzione', 'rule' => ['fixed', '08-15'], 'regions' => ['TI']], + // The Thursday after the first Sunday of September. + ['id' => 'jeune_genevois', 'name' => 'Jeûne genevois', 'rule' => ['nth_weekday', 9, 'sun', 1, 4], 'regions' => ['GE']], + // The Monday after the third Sunday of September. + ['id' => 'lundi_du_jeune', 'name' => 'Lundi du Jeûne fédéral', 'rule' => ['nth_weekday', 9, 'sun', 3, 1], 'regions' => ['VD']], + ['id' => 'ognissanti', 'name' => 'Ognissanti', 'rule' => ['fixed', '11-01'], 'regions' => ['TI']], + ['id' => 'immacolata', 'name' => 'Immacolata', 'rule' => ['fixed', '12-08'], 'regions' => ['TI']], + ['id' => 'weihnachten', 'name' => 'Weihnachten / Noël / Natale', 'rule' => ['fixed', '12-25']], + ['id' => 'stephanstag', 'name' => 'Stephanstag / Santo Stefano', 'rule' => ['fixed', '12-26'], 'regions' => ['BE', 'BS', 'TI', 'ZH']], + ['id' => 'restauration', 'name' => 'Restauration de la République', 'rule' => ['fixed', '12-31'], 'regions' => ['GE']], + ], + ], + 'SE' => [ + 'name' => 'Sverige', + 'rules' => [ + ['id' => 'nyarsdagen', 'name' => 'Nyårsdagen', 'rule' => ['fixed', '01-01']], + ['id' => 'trettondedag_jul', 'name' => 'Trettondedag jul', 'rule' => ['fixed', '01-06']], + ['id' => 'langfredagen', 'name' => 'Långfredagen', 'rule' => ['easter', -2]], + ['id' => 'paskdagen', 'name' => 'Påskdagen', 'rule' => ['easter', 0]], + ['id' => 'annandag_pask', 'name' => 'Annandag påsk', 'rule' => ['easter', 1]], + ['id' => 'forsta_maj', 'name' => 'Första maj', 'rule' => ['fixed', '05-01']], + ['id' => 'kristi_himmelsfardsdag', 'name' => 'Kristi himmelsfärdsdag', 'rule' => ['easter', 39]], + ['id' => 'pingstdagen', 'name' => 'Pingstdagen', 'rule' => ['easter', 49]], + ['id' => 'nationaldagen', 'name' => 'Sveriges nationaldag', 'rule' => ['fixed', '06-06']], + ['id' => 'midsommarafton', 'name' => 'Midsommarafton', 'rule' => ['weekday_on_or_after', '06-19', 'fri'], 'optional' => true], + ['id' => 'midsommardagen', 'name' => 'Midsommardagen', 'rule' => ['weekday_on_or_after', '06-20', 'sat']], + ['id' => 'alla_helgons_dag', 'name' => 'Alla helgons dag', 'rule' => ['weekday_on_or_after', '10-31', 'sat']], + ['id' => 'julafton', 'name' => 'Julafton', 'rule' => ['fixed', '12-24'], 'optional' => true], + ['id' => 'juldagen', 'name' => 'Juldagen', 'rule' => ['fixed', '12-25']], + ['id' => 'annandag_jul', 'name' => 'Annandag jul', 'rule' => ['fixed', '12-26']], + ['id' => 'nyarsafton', 'name' => 'Nyårsafton', 'rule' => ['fixed', '12-31'], 'optional' => true], + ], + ], + 'NO' => [ + 'name' => 'Norge', + 'rules' => [ + ['id' => 'forste_nyttarsdag', 'name' => '1. nyttårsdag', 'rule' => ['fixed', '01-01']], + ['id' => 'skjaertorsdag', 'name' => 'Skjærtorsdag', 'rule' => ['easter', -3]], + ['id' => 'langfredag', 'name' => 'Langfredag', 'rule' => ['easter', -2]], + ['id' => 'forste_paskedag', 'name' => '1. påskedag', 'rule' => ['easter', 0]], + ['id' => 'andre_paskedag', 'name' => '2. påskedag', 'rule' => ['easter', 1]], + ['id' => 'arbeidernes_dag', 'name' => 'Arbeidernes dag', 'rule' => ['fixed', '05-01']], + ['id' => 'grunnlovsdag', 'name' => 'Grunnlovsdag', 'rule' => ['fixed', '05-17']], + ['id' => 'kristi_himmelfartsdag', 'name' => 'Kristi himmelfartsdag', 'rule' => ['easter', 39]], + ['id' => 'forste_pinsedag', 'name' => '1. pinsedag', 'rule' => ['easter', 49]], + ['id' => 'andre_pinsedag', 'name' => '2. pinsedag', 'rule' => ['easter', 50]], + ['id' => 'forste_juledag', 'name' => '1. juledag', 'rule' => ['fixed', '12-25']], + ['id' => 'andre_juledag', 'name' => '2. juledag', 'rule' => ['fixed', '12-26']], + ], + ], + 'FI' => [ + 'name' => 'Suomi', + 'rules' => [ + ['id' => 'uudenvuodenpaiva', 'name' => 'Uudenvuodenpäivä', 'rule' => ['fixed', '01-01']], + ['id' => 'loppiainen', 'name' => 'Loppiainen', 'rule' => ['fixed', '01-06']], + ['id' => 'pitkaperjantai', 'name' => 'Pitkäperjantai', 'rule' => ['easter', -2]], + ['id' => 'paasiaispaiva', 'name' => 'Pääsiäispäivä', 'rule' => ['easter', 0]], + ['id' => 'toinen_paasiaispaiva', 'name' => '2. pääsiäispäivä', 'rule' => ['easter', 1]], + ['id' => 'vappu', 'name' => 'Vappu', 'rule' => ['fixed', '05-01']], + ['id' => 'helatorstai', 'name' => 'Helatorstai', 'rule' => ['easter', 39]], + ['id' => 'helluntaipaiva', 'name' => 'Helluntaipäivä', 'rule' => ['easter', 49]], + ['id' => 'juhannusaatto', 'name' => 'Juhannusaatto', 'rule' => ['weekday_on_or_after', '06-19', 'fri'], 'optional' => true], + ['id' => 'juhannuspaiva', 'name' => 'Juhannuspäivä', 'rule' => ['weekday_on_or_after', '06-20', 'sat']], + ['id' => 'pyhainpaiva', 'name' => 'Pyhäinpäivä', 'rule' => ['weekday_on_or_after', '10-31', 'sat']], + ['id' => 'itsenaisyyspaiva', 'name' => 'Itsenäisyyspäivä', 'rule' => ['fixed', '12-06']], + ['id' => 'jouluaatto', 'name' => 'Jouluaatto', 'rule' => ['fixed', '12-24'], 'optional' => true], + ['id' => 'joulupaiva', 'name' => 'Joulupäivä', 'rule' => ['fixed', '12-25']], + ['id' => 'tapaninpaiva', 'name' => 'Tapaninpäivä', 'rule' => ['fixed', '12-26']], + ], + ], + 'PL' => [ + 'name' => 'Polska', + 'rules' => [ + ['id' => 'nowy_rok', 'name' => 'Nowy Rok', 'rule' => ['fixed', '01-01']], + ['id' => 'trzech_kroli', 'name' => 'Święto Trzech Króli', 'rule' => ['fixed', '01-06'], 'from' => 2011], + ['id' => 'wielkanoc', 'name' => 'Wielkanoc', 'rule' => ['easter', 0]], + ['id' => 'poniedzialek_wielkanocny', 'name' => 'Poniedziałek Wielkanocny', 'rule' => ['easter', 1]], + ['id' => 'swieto_pracy', 'name' => 'Święto Pracy', 'rule' => ['fixed', '05-01']], + ['id' => 'konstytucja_3_maja', 'name' => 'Święto Konstytucji 3 Maja', 'rule' => ['fixed', '05-03']], + ['id' => 'zielone_swiatki', 'name' => 'Zielone Świątki', 'rule' => ['easter', 49]], + ['id' => 'boze_cialo', 'name' => 'Boże Ciało', 'rule' => ['easter', 60]], + ['id' => 'wniebowziecie', 'name' => 'Wniebowzięcie Najświętszej Maryi Panny', 'rule' => ['fixed', '08-15']], + ['id' => 'wszystkich_swietych', 'name' => 'Wszystkich Świętych', 'rule' => ['fixed', '11-01']], + ['id' => 'niepodleglosci', 'name' => 'Narodowe Święto Niepodległości', 'rule' => ['fixed', '11-11']], + // A public holiday from 2025. + ['id' => 'wigilia', 'name' => 'Wigilia Bożego Narodzenia', 'rule' => ['fixed', '12-24'], 'from' => 2025], + ['id' => 'boze_narodzenie_1', 'name' => 'Boże Narodzenie (pierwszy dzień)', 'rule' => ['fixed', '12-25']], + ['id' => 'boze_narodzenie_2', 'name' => 'Boże Narodzenie (drugi dzień)', 'rule' => ['fixed', '12-26']], + ], + ], + 'CA' => [ + 'name' => 'Canada', + 'regions' => [ + 'AB' => 'Alberta', 'BC' => 'British Columbia', 'MB' => 'Manitoba', 'NB' => 'New Brunswick', + 'NL' => 'Newfoundland and Labrador', 'NS' => 'Nova Scotia', 'NT' => 'Northwest Territories', + 'NU' => 'Nunavut', 'ON' => 'Ontario', 'PE' => 'Prince Edward Island', 'QC' => 'Québec', + 'SK' => 'Saskatchewan', 'YT' => 'Yukon', + ], + 'rules' => [ + ['id' => 'new_year', 'name' => "New Year's Day / Jour de l'An", 'rule' => ['fixed', '01-01'], 'observed' => 'next_monday'], + ['id' => 'family_day', 'name' => 'Family Day', 'rule' => ['nth_weekday', 2, 'mon', 3], 'regions' => ['AB', 'BC', 'NB', 'ON', 'SK']], + ['id' => 'louis_riel_day', 'name' => 'Louis Riel Day', 'rule' => ['nth_weekday', 2, 'mon', 3], 'regions' => ['MB']], + ['id' => 'islander_day', 'name' => 'Islander Day', 'rule' => ['nth_weekday', 2, 'mon', 3], 'regions' => ['PE']], + ['id' => 'heritage_day_ns', 'name' => 'Heritage Day', 'rule' => ['nth_weekday', 2, 'mon', 3], 'regions' => ['NS']], + ['id' => 'good_friday', 'name' => 'Good Friday / Vendredi saint', 'rule' => ['easter', -2]], + ['id' => 'easter_monday', 'name' => 'Easter Monday / Lundi de Pâques', 'rule' => ['easter', 1], 'optional' => true], + // The Monday before 25 May (Journée nationale des patriotes in Québec). + ['id' => 'victoria_day', 'name' => 'Victoria Day / Fête de la Reine', 'rule' => ['weekday_on_or_before', '05-24', 'mon']], + ['id' => 'national_indigenous_peoples_day', 'name' => 'National Indigenous Peoples Day', 'rule' => ['fixed', '06-21'], 'regions' => ['NT', 'YT']], + ['id' => 'fete_nationale_quebec', 'name' => 'Fête nationale du Québec', 'rule' => ['fixed', '06-24'], 'regions' => ['QC']], + ['id' => 'canada_day', 'name' => 'Canada Day / Fête du Canada', 'rule' => ['fixed', '07-01'], 'observed' => 'next_monday'], + ['id' => 'nunavut_day', 'name' => 'Nunavut Day', 'rule' => ['fixed', '07-09'], 'regions' => ['NU']], + ['id' => 'civic_holiday', 'name' => 'Civic Holiday', 'rule' => ['nth_weekday', 8, 'mon', 1], 'regions' => ['AB', 'BC', 'NB', 'NT', 'NU', 'ON', 'SK']], + ['id' => 'discovery_day', 'name' => 'Discovery Day', 'rule' => ['nth_weekday', 8, 'mon', 3], 'regions' => ['YT']], + ['id' => 'labour_day', 'name' => 'Labour Day / Fête du Travail', 'rule' => ['nth_weekday', 9, 'mon', 1]], + ['id' => 'truth_and_reconciliation', 'name' => 'National Day for Truth and Reconciliation', 'rule' => ['fixed', '09-30'], 'from' => 2021], + ['id' => 'thanksgiving', 'name' => 'Thanksgiving / Action de grâce', 'rule' => ['nth_weekday', 10, 'mon', 2]], + ['id' => 'remembrance_day', 'name' => 'Remembrance Day / Jour du Souvenir', 'rule' => ['fixed', '11-11']], + ['id' => 'christmas', 'name' => 'Christmas Day / Noël', 'rule' => ['fixed', '12-25'], 'observed' => 'pair'], + ['id' => 'boxing_day', 'name' => 'Boxing Day / Lendemain de Noël', 'rule' => ['fixed', '12-26'], 'observed' => 'pair'], + ], + ], + 'AU' => [ + 'name' => 'Australia', + 'regions' => [ + 'ACT' => 'Australian Capital Territory', 'NSW' => 'New South Wales', 'NT' => 'Northern Territory', + 'QLD' => 'Queensland', 'SA' => 'South Australia', 'TAS' => 'Tasmania', 'VIC' => 'Victoria', 'WA' => 'Western Australia', + ], + 'rules' => [ + ['id' => 'new_year', 'name' => "New Year's Day", 'rule' => ['fixed', '01-01'], 'observed' => 'next_monday'], + ['id' => 'australia_day', 'name' => 'Australia Day', 'rule' => ['fixed', '01-26'], 'observed' => 'next_monday'], + ['id' => 'labour_day_wa', 'name' => 'Labour Day', 'rule' => ['nth_weekday', 3, 'mon', 1], 'regions' => ['WA']], + ['id' => 'labour_day_vic', 'name' => 'Labour Day', 'rule' => ['nth_weekday', 3, 'mon', 2], 'regions' => ['VIC']], + ['id' => 'eight_hours_day', 'name' => 'Eight Hours Day', 'rule' => ['nth_weekday', 3, 'mon', 2], 'regions' => ['TAS']], + ['id' => 'canberra_day', 'name' => 'Canberra Day', 'rule' => ['nth_weekday', 3, 'mon', 2], 'regions' => ['ACT']], + ['id' => 'adelaide_cup', 'name' => 'Adelaide Cup Day', 'rule' => ['nth_weekday', 3, 'mon', 2], 'regions' => ['SA']], + ['id' => 'good_friday', 'name' => 'Good Friday', 'rule' => ['easter', -2]], + ['id' => 'easter_saturday', 'name' => 'Easter Saturday', 'rule' => ['easter', -1], 'regions' => ['ACT', 'NSW', 'NT', 'QLD', 'SA', 'VIC']], + ['id' => 'easter_sunday', 'name' => 'Easter Sunday', 'rule' => ['easter', 0], 'regions' => ['ACT', 'NSW', 'QLD', 'VIC']], + ['id' => 'easter_monday', 'name' => 'Easter Monday', 'rule' => ['easter', 1]], + ['id' => 'anzac_day', 'name' => 'Anzac Day', 'rule' => ['fixed', '04-25']], + ['id' => 'labour_day_qld', 'name' => 'Labour Day', 'rule' => ['nth_weekday', 5, 'mon', 1], 'regions' => ['QLD']], + ['id' => 'may_day_nt', 'name' => 'May Day', 'rule' => ['nth_weekday', 5, 'mon', 1], 'regions' => ['NT']], + ['id' => 'reconciliation_day', 'name' => 'Reconciliation Day', 'rule' => ['weekday_on_or_after', '05-27', 'mon'], 'regions' => ['ACT'], 'from' => 2018], + ['id' => 'western_australia_day', 'name' => 'Western Australia Day', 'rule' => ['nth_weekday', 6, 'mon', 1], 'regions' => ['WA']], + ['id' => 'kings_birthday', 'name' => "King's Birthday", 'rule' => ['nth_weekday', 6, 'mon', 2], 'regions' => ['ACT', 'NSW', 'NT', 'SA', 'TAS', 'VIC']], + ['id' => 'picnic_day', 'name' => 'Picnic Day', 'rule' => ['nth_weekday', 8, 'mon', 1], 'regions' => ['NT']], + ['id' => 'labour_day_october', 'name' => 'Labour Day', 'rule' => ['nth_weekday', 10, 'mon', 1], 'regions' => ['ACT', 'NSW', 'SA']], + ['id' => 'kings_birthday_qld', 'name' => "King's Birthday", 'rule' => ['nth_weekday', 10, 'mon', 1], 'regions' => ['QLD']], + ['id' => 'melbourne_cup', 'name' => 'Melbourne Cup Day', 'rule' => ['nth_weekday', 11, 'tue', 1], 'regions' => ['VIC'], 'optional' => true], + ['id' => 'christmas', 'name' => 'Christmas Day', 'rule' => ['fixed', '12-25'], 'observed' => 'pair'], + ['id' => 'boxing_day', 'name' => 'Boxing Day', 'rule' => ['fixed', '12-26'], 'observed' => 'pair'], + ], + ], + 'BR' => [ + 'name' => 'Brasil', + 'rules' => [ + ['id' => 'confraternizacao', 'name' => 'Confraternização Universal', 'rule' => ['fixed', '01-01']], + // Ponto facultativo: optional days, picked by the library. + ['id' => 'carnaval_segunda', 'name' => 'Carnaval (segunda-feira)', 'rule' => ['easter', -48], 'optional' => true], + ['id' => 'carnaval_terca', 'name' => 'Carnaval (terça-feira)', 'rule' => ['easter', -47], 'optional' => true], + ['id' => 'sexta_feira_santa', 'name' => 'Sexta-feira Santa', 'rule' => ['easter', -2]], + ['id' => 'tiradentes', 'name' => 'Tiradentes', 'rule' => ['fixed', '04-21']], + ['id' => 'dia_do_trabalho', 'name' => 'Dia do Trabalho', 'rule' => ['fixed', '05-01']], + ['id' => 'corpus_christi', 'name' => 'Corpus Christi', 'rule' => ['easter', 60], 'optional' => true], + ['id' => 'independencia', 'name' => 'Independência do Brasil', 'rule' => ['fixed', '09-07']], + ['id' => 'nossa_senhora_aparecida', 'name' => 'Nossa Senhora Aparecida', 'rule' => ['fixed', '10-12']], + ['id' => 'finados', 'name' => 'Finados', 'rule' => ['fixed', '11-02']], + ['id' => 'proclamacao_da_republica', 'name' => 'Proclamação da República', 'rule' => ['fixed', '11-15']], + ['id' => 'consciencia_negra', 'name' => 'Dia Nacional de Zumbi e da Consciência Negra', 'rule' => ['fixed', '11-20'], 'from' => 2024], + ['id' => 'natal', 'name' => 'Natal', 'rule' => ['fixed', '12-25']], + ], + ], + 'AR' => [ + 'name' => 'Argentina', + 'rules' => [ + ['id' => 'ano_nuevo', 'name' => 'Año Nuevo', 'rule' => ['fixed', '01-01']], + ['id' => 'carnaval_lunes', 'name' => 'Carnaval (lunes)', 'rule' => ['easter', -48]], + ['id' => 'carnaval_martes', 'name' => 'Carnaval (martes)', 'rule' => ['easter', -47]], + ['id' => 'memoria', 'name' => 'Día Nacional de la Memoria por la Verdad y la Justicia', 'rule' => ['fixed', '03-24']], + ['id' => 'malvinas', 'name' => 'Día del Veterano y de los Caídos en la Guerra de Malvinas', 'rule' => ['fixed', '04-02']], + ['id' => 'jueves_santo', 'name' => 'Jueves Santo', 'rule' => ['easter', -3], 'optional' => true], + ['id' => 'viernes_santo', 'name' => 'Viernes Santo', 'rule' => ['easter', -2]], + ['id' => 'dia_del_trabajador', 'name' => 'Día del Trabajador', 'rule' => ['fixed', '05-01']], + ['id' => 'revolucion_de_mayo', 'name' => 'Día de la Revolución de Mayo', 'rule' => ['fixed', '05-25']], + // Trasladables (law 27.399): Tuesday/Wednesday to the Monday + // before, Thursday/Friday to the Monday after. + ['id' => 'guemes', 'name' => 'Paso a la Inmortalidad del General Martín Miguel de Güemes', 'rule' => ['fixed', '06-17'], 'observed' => 'ar_movable'], + ['id' => 'belgrano', 'name' => 'Paso a la Inmortalidad del General Manuel Belgrano', 'rule' => ['fixed', '06-20']], + ['id' => 'independencia', 'name' => 'Día de la Independencia', 'rule' => ['fixed', '07-09']], + ['id' => 'san_martin', 'name' => 'Paso a la Inmortalidad del General José de San Martín', 'rule' => ['fixed', '08-17'], 'observed' => 'ar_movable'], + ['id' => 'diversidad_cultural', 'name' => 'Día del Respeto a la Diversidad Cultural', 'rule' => ['fixed', '10-12'], 'observed' => 'ar_movable'], + ['id' => 'soberania_nacional', 'name' => 'Día de la Soberanía Nacional', 'rule' => ['fixed', '11-20'], 'observed' => 'ar_movable'], + ['id' => 'inmaculada', 'name' => 'Inmaculada Concepción de María', 'rule' => ['fixed', '12-08']], + ['id' => 'navidad', 'name' => 'Navidad', 'rule' => ['fixed', '12-25']], + ], + ], + 'MX' => [ + 'name' => 'México', + 'rules' => [ + ['id' => 'ano_nuevo', 'name' => 'Año Nuevo', 'rule' => ['fixed', '01-01']], + ['id' => 'dia_de_la_constitucion', 'name' => 'Día de la Constitución', 'rule' => ['nth_weekday', 2, 'mon', 1]], + ['id' => 'natalicio_benito_juarez', 'name' => 'Natalicio de Benito Juárez', 'rule' => ['nth_weekday', 3, 'mon', 3]], + ['id' => 'jueves_santo', 'name' => 'Jueves Santo', 'rule' => ['easter', -3], 'optional' => true], + ['id' => 'viernes_santo', 'name' => 'Viernes Santo', 'rule' => ['easter', -2], 'optional' => true], + ['id' => 'dia_del_trabajo', 'name' => 'Día del Trabajo', 'rule' => ['fixed', '05-01']], + ['id' => 'dia_de_la_independencia', 'name' => 'Día de la Independencia', 'rule' => ['fixed', '09-16']], + ['id' => 'dia_de_la_revolucion', 'name' => 'Día de la Revolución', 'rule' => ['nth_weekday', 11, 'mon', 3]], + ['id' => 'navidad', 'name' => 'Navidad', 'rule' => ['fixed', '12-25']], + ], + ], + ]; + } +} diff --git a/tests/holiday-calculator.unit.php b/tests/holiday-calculator.unit.php new file mode 100644 index 000000000..b2c730263 --- /dev/null +++ b/tests/holiday-calculator.unit.php @@ -0,0 +1,210 @@ += 22, true, 'at least 22 countries'); +foreach (['IT', 'US', 'GB', 'IE', 'DE', 'FR', 'DK', 'ES', 'PT', 'NL', 'BE', 'AT', 'CH', 'SE', 'NO', 'FI', 'PL', 'CA', 'AU', 'BR', 'AR', 'MX'] as $cc) { + $eq(isset($countries[$cc]), true, "{$cc} is a preset country"); +} +$kinds = []; +$allUnique = true; +$allPrefixed = true; +$allDated = true; +$regionsKnown = true; +foreach ($countries as $cc => $country) { + $seen = []; + foreach ($country['rules'] as $rule) { + $kinds[$rule['rule'][0]] = true; + if (isset($seen[$rule['id']])) { + $allUnique = false; + echo " duplicate id {$cc}:{$rule['id']}\n"; + } + $seen[$rule['id']] = true; + foreach ($rule['regions'] ?? [] as $region) { + if (!isset($country['regions'][$region])) { + $regionsKnown = false; + echo " unknown region {$region} in {$cc}:{$rule['id']}\n"; + } + } + // Every rule that exists in 2026 has a date in 2026. + if (!isset($rule['until']) && (($rule['from'] ?? 0) <= 2026) && H::dateOf($rule, 2026) === null) { + $allDated = false; + echo " no 2026 date for {$cc}:{$rule['id']}\n"; + } + } + foreach (H::forYear($cc, '', 2026) as $h) { + if (!str_starts_with($h['id'], $cc . ':')) { + $allPrefixed = false; + } + } + $eq(count(H::rulesFor($cc, H::normaliseRegion($cc, ''))) >= 4, true, "{$cc} has at least 4 holidays without a region"); +} +$eq($allUnique, true, 'rule ids are unique in each country'); +$eq($allPrefixed, true, 'every id is CC:-prefixed'); +$eq($regionsKnown, true, 'every rule region is a region of its country'); +$eq($allDated, true, 'every current rule has a date in 2026'); +ksort($kinds); +$eq(array_keys($kinds), ['easter', 'fixed', 'fixed_if', 'nth_weekday', 'weekday_on_or_after', 'weekday_on_or_before'], 'all six rule kinds are used'); + +// ── Easter-based, several years ─────────────────────────────────────────── +$eq($date('IT', '', 2024, 'easter_monday'), '2024-04-01', 'IT Pasquetta 2024'); +$eq($date('IT', '', 2025, 'easter'), '2025-04-20', 'IT Pasqua 2025'); +$eq($date('IT', '', 2026, 'easter'), '2026-04-05', 'IT Pasqua 2026'); +$eq($date('DE', '', 2026, 'karfreitag'), '2026-04-03', 'DE Karfreitag 2026'); +$eq($date('DE', '', 2027, 'christi_himmelfahrt'), '2027-05-06', 'DE Christi Himmelfahrt 2027'); +$eq($date('DE', '', 2025, 'pfingstmontag'), '2025-06-09', 'DE Pfingstmontag 2025'); +$eq($date('FR', '', 2028, 'lundi_de_pentecote'), '2028-06-05', 'FR Lundi de Pentecôte 2028'); +$eq($date('DK', '', 2026, 'skaertorsdag'), '2026-04-02', 'DK Skærtorsdag 2026'); +$eq($date('PT', '', 2026, 'corpo_de_deus'), '2026-06-04', 'PT Corpo de Deus 2026'); +$eq($date('BR', '', 2026, 'carnaval_terca'), '2026-02-17', 'BR Carnaval (terça) 2026'); +$eq($date('PL', '', 2026, 'zielone_swiatki'), '2026-05-24', 'PL Zielone Świątki 2026'); + +// ── Denmark: Store bededag abolished from 2024 ──────────────────────────── +$eq($date('DK', '', 2023, 'store_bededag'), '2023-05-05', 'DK Store bededag 2023 (4th Friday after Easter)'); +$eq($date('DK', '', 2024, 'store_bededag'), null, 'DK Store bededag gone in 2024'); +$eq($date('DK', '', 2026, 'store_bededag'), null, 'DK Store bededag gone in 2026'); +$eq($date('DK', '', 2026, 'grundlovsdag'), '2026-06-05', 'DK Grundlovsdag'); + +// ── United States: nth weekday and observed days ────────────────────────── +$eq($date('US', '', 2026, 'thanksgiving'), '2026-11-26', 'US Thanksgiving 2026'); +$eq($date('US', '', 2025, 'thanksgiving'), '2025-11-27', 'US Thanksgiving 2025'); +$eq($date('US', '', 2026, 'memorial_day'), '2026-05-25', 'US Memorial Day 2026 (last Monday of May)'); +$eq($date('US', '', 2026, 'mlk_day'), '2026-01-19', 'US MLK Day 2026'); +$eq($date('US', '', 2026, 'labor_day'), '2026-09-07', 'US Labor Day 2026'); +$eq($date('US', '', 2026, 'independence_day'), '2026-07-03', 'US Independence Day 2026 (Saturday) observed Friday 3'); +$eq($date('US', '', 2027, 'independence_day'), '2027-07-05', 'US Independence Day 2027 (Sunday) observed Monday 5'); +$eq($date('US', '', 2022, 'new_year'), '2021-12-31', 'US New Year 2022 (Saturday) observed Friday 31 Dec 2021'); +$eq($date('US', '', 2020, 'juneteenth'), null, 'US Juneteenth not before 2021'); +$eq($date('US', '', 2027, 'juneteenth'), '2027-06-18', 'US Juneteenth 2027 (Saturday) observed Friday'); + +// ── United Kingdom: substitute days, the VE Day and Jubilee moves ───────── +$eq($date('GB', 'ENG', 2026, 'early_may'), '2026-05-04', 'GB early May 2026'); +$eq($date('GB', 'ENG', 2020, 'early_may'), '2020-05-08', 'GB early May 2020 moved to VE Day (Friday 8)'); +$eq($date('GB', 'ENG', 2022, 'spring'), '2022-06-02', 'GB spring 2022 moved for the Platinum Jubilee'); +$eq($date('GB', 'ENG', 2026, 'spring'), '2026-05-25', 'GB spring 2026'); +$eq($date('GB', 'ENG', 2027, 'christmas'), '2027-12-27', 'GB Christmas 2027 (Saturday) → Monday 27'); +$eq($date('GB', 'ENG', 2027, 'boxing_day'), '2027-12-28', 'GB Boxing Day 2027 (Sunday) → Tuesday 28'); +$eq($date('GB', 'ENG', 2022, 'christmas'), '2022-12-27', 'GB Christmas 2022 (Sunday) → Tuesday 27'); +$eq($date('GB', 'ENG', 2022, 'boxing_day'), '2022-12-26', 'GB Boxing Day 2022 stays Monday 26'); +$eq($date('GB', 'ENG', 2023, 'new_year'), '2023-01-02', 'GB New Year 2023 (Sunday) → Monday 2'); +$eq($date('GB', 'SCT', 2022, 'new_year_sct'), '2022-01-03', 'SCT New Year 2022 (Saturday) → Monday 3'); +$eq($date('GB', 'SCT', 2022, 'second_january'), '2022-01-04', 'SCT 2 January 2022 (Sunday) → Tuesday 4'); +$eq($date('GB', 'SCT', 2026, 'summer_sct'), '2026-08-03', 'SCT summer bank holiday = first Monday of August'); +$eq($date('GB', 'ENG', 2026, 'summer'), '2026-08-31', 'ENG summer bank holiday = last Monday of August'); +$eq($has('GB', 'SCT', 'easter_monday'), false, 'no Easter Monday in Scotland'); +$eq($has('GB', 'NIR', 'st_patrick') && !$has('GB', 'ENG', 'st_patrick'), true, "St Patrick's Day only in Northern Ireland"); +$eq(H::normaliseRegion('GB', ''), 'ENG', 'the United Kingdom always has a nation'); + +// ── Germany: Länder ─────────────────────────────────────────────────────── +$eq($date('DE', 'SN', 2026, 'buss_und_bettag'), '2026-11-18', 'DE Buß- und Bettag 2026 (Sachsen)'); +$eq($date('DE', 'SN', 2025, 'buss_und_bettag'), '2025-11-19', 'DE Buß- und Bettag 2025 (Sachsen)'); +$eq($date('DE', 'SN', 2027, 'buss_und_bettag'), '2027-11-17', 'DE Buß- und Bettag 2027 (Sachsen)'); +$eq($has('DE', 'BY', 'buss_und_bettag'), false, 'no Buß- und Bettag in Bayern'); +$eq($date('DE', 'BY', 2026, 'fronleichnam'), '2026-06-04', 'DE Fronleichnam 2026 in Bayern'); +$eq($has('DE', 'BE', 'fronleichnam'), false, 'no Fronleichnam in Berlin'); +$eq($has('DE', '', 'fronleichnam'), false, 'no Fronleichnam without a Land'); +$eq($date('DE', 'BE', 2026, 'frauentag'), '2026-03-08', 'Frauentag in Berlin'); +$eq($date('DE', 'MV', 2022, 'frauentag_mv'), null, 'Frauentag in Mecklenburg-Vorpommern only from 2023'); +$eq($date('DE', 'NI', 2017, 'reformationstag_nord'), null, 'Reformationstag in Niedersachsen only from 2018'); + +// ── Canada, Australia, Ireland ──────────────────────────────────────────── +$eq($date('CA', '', 2026, 'victoria_day'), '2026-05-18', 'CA Victoria Day 2026 (Monday before 25 May)'); +$eq($date('CA', '', 2027, 'victoria_day'), '2027-05-24', 'CA Victoria Day 2027 (24 May is a Monday)'); +$eq($date('CA', '', 2026, 'thanksgiving'), '2026-10-12', 'CA Thanksgiving 2026 (2nd Monday of October)'); +$eq($date('CA', '', 2029, 'canada_day'), '2029-07-02', 'CA Canada Day 2029 (Sunday) → Monday 2'); +$eq($date('CA', '', 2020, 'truth_and_reconciliation'), null, 'CA Truth and Reconciliation from 2021'); +$eq($date('CA', 'QC', 2026, 'fete_nationale_quebec'), '2026-06-24', 'Fête nationale du Québec'); +$eq($has('CA', 'QC', 'family_day'), false, 'no Family Day in Québec'); +$eq($date('CA', 'ON', 2026, 'family_day'), '2026-02-16', 'Family Day 2026 in Ontario'); +$eq($date('AU', '', 2026, 'australia_day'), '2026-01-26', 'AU Australia Day 2026 (Monday)'); +$eq($date('AU', '', 2025, 'boxing_day'), '2025-12-26', 'AU Boxing Day 2025 (Friday)'); +$eq($date('AU', '', 2021, 'boxing_day'), '2021-12-28', 'AU Boxing Day 2021 (Sunday) → Tuesday 28'); +$eq($date('AU', 'NSW', 2026, 'kings_birthday'), '2026-06-08', "AU King's Birthday 2026 in NSW"); +$eq($date('AU', 'QLD', 2026, 'kings_birthday_qld'), '2026-10-05', "AU King's Birthday 2026 in Queensland (1st Monday of October)"); +$eq($date('AU', 'VIC', 2026, 'melbourne_cup'), '2026-11-03', 'AU Melbourne Cup 2026'); +$eq($date('IE', '', 2026, 'st_brigid'), '2026-02-02', "IE St Brigid's Day 2026 (first Monday of February)"); +$eq($date('IE', '', 2030, 'st_brigid'), '2030-02-01', "IE St Brigid's Day 2030 (1 February is a Friday)"); +$eq($date('IE', '', 2022, 'st_brigid'), null, "IE St Brigid's Day only from 2023"); +$eq($date('IE', '', 2026, 'october_holiday'), '2026-10-26', 'IE October Holiday 2026'); + +// ── Nordics, Netherlands, Switzerland, Argentina, Mexico, Italy ─────────── +$eq($date('SE', '', 2026, 'midsommardagen'), '2026-06-20', 'SE Midsommardagen 2026'); +$eq($date('SE', '', 2025, 'midsommardagen'), '2025-06-21', 'SE Midsommardagen 2025'); +$eq($date('SE', '', 2026, 'alla_helgons_dag'), '2026-10-31', 'SE Alla helgons dag 2026'); +$eq($date('FI', '', 2027, 'juhannuspaiva'), '2027-06-26', 'FI Juhannuspäivä 2027'); +$eq($date('FI', '', 2025, 'pyhainpaiva'), '2025-11-01', 'FI Pyhäinpäivä 2025'); +$eq($date('NL', '', 2025, 'koningsdag'), '2025-04-26', 'NL Koningsdag 2025 (27 April is a Sunday) → Saturday 26'); +$eq($date('NL', '', 2026, 'koningsdag'), '2026-04-27', 'NL Koningsdag 2026'); +$eq($date('CH', 'GE', 2026, 'jeune_genevois'), '2026-09-10', 'CH Jeûne genevois 2026'); +$eq($date('CH', 'VD', 2026, 'lundi_du_jeune'), '2026-09-21', 'CH Lundi du Jeûne fédéral 2026 (Vaud)'); +$eq($has('CH', '', 'karfreitag'), false, 'CH Good Friday only in the cantons that keep it'); +$eq($date('AR', '', 2026, 'soberania_nacional'), '2026-11-23', 'AR Soberanía Nacional 2026 (Friday 20) → Monday 23'); +$eq($date('AR', '', 2026, 'guemes'), '2026-06-15', 'AR Güemes 2026 (Wednesday 17) → Monday 15'); +$eq($date('AR', '', 2026, 'san_martin'), '2026-08-17', 'AR San Martín 2026 stays on Monday 17'); +$eq($date('MX', '', 2026, 'dia_de_la_constitucion'), '2026-02-02', 'MX Día de la Constitución 2026'); +$eq($date('MX', '', 2026, 'dia_de_la_revolucion'), '2026-11-16', 'MX Día de la Revolución 2026'); +$eq($date('IT', '', 2025, 'san_francesco'), null, 'IT San Francesco not in 2025'); +$eq($date('IT', '', 2026, 'san_francesco'), '2026-10-04', 'IT San Francesco from 2026'); +$eq($date('PL', '', 2024, 'wigilia'), null, 'PL Wigilia not a holiday in 2024'); +$eq($date('PL', '', 2025, 'wigilia'), '2025-12-24', 'PL Wigilia a holiday from 2025'); +$eq($date('BR', '', 2026, 'consciencia_negra'), '2026-11-20', 'BR Consciência Negra (from 2024)'); + +// ── Yearly or per year ──────────────────────────────────────────────────── +$rule = static function (string $cc, string $id): array { + foreach (HolidayRules::countries()[$cc]['rules'] as $r) { + if ($r['id'] === $id) { + return $r; + } + } + return []; +}; +$eq(H::isYearly($rule('IT', 'christmas')), true, 'a fixed date with no weekend rule is one yearly row'); +$eq(H::isYearly($rule('US', 'christmas')), false, 'a fixed date with a weekend rule is dated per year'); +$eq(H::isYearly($rule('IT', 'san_francesco')), false, 'a fixed date with a first year is dated per year'); +$eq(H::isYearly($rule('GB', 'early_may')), false, 'an nth weekday is dated per year'); +$eq(H::nthWeekday(2026, 2, 1, 5), null, 'there is no fifth Monday in February 2026'); +$eq(H::observe('2026-07-04', 'none'), '2026-07-04', 'an unknown weekend rule leaves the date'); + +echo ($fail === 0 ? 'SUCCESS' : 'FAILED') . " {$pass} checks, {$fail} failed\n"; +exit($fail === 0 ? 0 : 1); From 282996fddccc0c235e4843053a6de104ec07d187 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:17:16 +0100 Subject: [PATCH 192/398] Holidays: apply a selection of holidays to the closures calendar The library's choice (country, region, ticked rules) is saved in system_settings and materialised in calendario_chiusure, marked with regola_festivita: a fixed date as one yearly row, any other rule as one row per year up to two years ahead. Applying again adds nothing, unticking removes only that rule's rows, manual and ICS closures are never touched, and rows of the former Italian preset are adopted instead of duplicated. A closure with attiva = 0 stays stored and closes nothing. Migration section 'holiday presets' adds both columns and the index. --- app/Support/Holidays/HolidayPresets.php | 380 ++++++++++++++++++ app/Support/LibraryCalendar.php | 39 +- .../database/migrations/migrate_0.9.0.sql | 26 ++ installer/database/schema.sql | 3 + tests/holiday-presets-apply.unit.php | 173 ++++++++ tests/library-calendar.unit.php | 9 +- .../migration-0.9.0-holiday-presets.unit.php | 114 ++++++ 7 files changed, 718 insertions(+), 26 deletions(-) create mode 100644 app/Support/Holidays/HolidayPresets.php create mode 100644 tests/holiday-presets-apply.unit.php create mode 100644 tests/migration-0.9.0-holiday-presets.unit.php diff --git a/app/Support/Holidays/HolidayPresets.php b/app/Support/Holidays/HolidayPresets.php new file mode 100644 index 000000000..f5a131020 --- /dev/null +++ b/app/Support/Holidays/HolidayPresets.php @@ -0,0 +1,380 @@ + 'Capodanno', + 'IT:epiphany' => 'Epifania', + 'IT:easter' => 'Pasqua', + 'IT:easter_monday' => "Lunedì dell'Angelo (Pasquetta)", + 'IT:liberation' => 'Festa della Liberazione', + 'IT:labour' => 'Festa del Lavoro', + 'IT:republic' => 'Festa della Repubblica', + 'IT:ferragosto' => 'Ferragosto', + 'IT:all_saints' => 'Ognissanti', + 'IT:immaculate' => 'Immacolata Concezione', + 'IT:christmas' => 'Natale', + 'IT:st_stephen' => 'Santo Stefano', + ]; + + /** @var array>|null */ + private static ?array $legacyLabelCache = null; + + /** The country preselected before any choice: the one of the installation language. */ + public static function defaultCountry(): string + { + $locale = ''; + try { + $locale = I18n::getInstallationLocale(); + } catch (\Throwable $e) { + $locale = ''; + } + return match (substr($locale, 0, 2)) { + 'en' => 'US', + 'de' => 'DE', + 'fr' => 'FR', + 'da' => 'DK', + default => 'IT', + }; + } + + /** + * What the library chose. 'saved' is false while nothing was ever applied. + * + * @return array{country: string, region: string, rules: list, saved: bool} + */ + public static function selection(\mysqli $db): array + { + $repo = new SettingsRepository($db); + $country = strtoupper((string) $repo->get(self::SETTINGS_CATEGORY, 'holiday_country', '')); + $saved = $country !== '' && HolidayCalculator::country($country) !== null; + if (!$saved) { + $country = self::defaultCountry(); + } + $region = HolidayCalculator::normaliseRegion($country, (string) $repo->get(self::SETTINGS_CATEGORY, 'holiday_region', '')); + $rules = []; + $decoded = json_decode((string) $repo->get(self::SETTINGS_CATEGORY, 'holiday_rules', '[]'), true); + foreach (is_array($decoded) ? $decoded : [] as $id) { + if (is_string($id)) { + $rules[] = $id; + } + } + return ['country' => $country, 'region' => $region, 'rules' => $saved ? self::filterIds($country, $region, $rules) : [], 'saved' => $saved]; + } + + /** + * Keep the ids that belong to $country / $region, in table order. + * + * @param list $ids + * @return list + */ + public static function filterIds(string $country, string $region, array $ids): array + { + $wanted = array_flip($ids); + $out = []; + foreach (HolidayCalculator::rulesFor($country, $region) as $rule) { + $id = strtoupper($country) . ':' . (string) $rule['id']; + if (isset($wanted[$id])) { + $out[] = $id; + } + } + return $out; + } + + /** + * The closures the chosen rules stand for, from $firstYear on. + * + * @param list $ids + * @return array> rule id => rows + */ + public static function expectedRows(string $country, string $region, array $ids, int $firstYear): array + { + $country = strtoupper($country); + $wanted = array_flip($ids); + $out = []; + foreach (HolidayCalculator::rulesFor($country, $region) as $rule) { + $id = $country . ':' . (string) $rule['id']; + if (!isset($wanted[$id])) { + continue; + } + $rows = []; + if (HolidayCalculator::isYearly($rule)) { + $date = HolidayCalculator::dateOf($rule, $firstYear); + if ($date !== null) { + $rows[] = ['start' => $date, 'end' => $date, 'description' => (string) $rule['name'], 'yearly' => true]; + } + } else { + for ($year = $firstYear; $year <= $firstYear + self::YEARS_AHEAD; $year++) { + $date = HolidayCalculator::dateOf($rule, $year); + if ($date !== null) { + $rows[] = ['start' => $date, 'end' => $date, 'description' => (string) $rule['name'], 'yearly' => false]; + } + } + } + $out[$id] = $rows; + } + return $out; + } + + /** The migration that adds calendario_chiusure.regola_festivita has run. */ + public static function schemaReady(\mysqli $db): bool + { + try { + $result = $db->query("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure' AND COLUMN_NAME IN ('regola_festivita', 'attiva')"); + if (!$result instanceof \mysqli_result) { + return false; + } + $row = $result->fetch_row(); + $result->free(); + return (int) ($row[0] ?? 0) === 2; + } catch (\Throwable $e) { + return false; + } + } + + /** + * Save the choice and bring calendario_chiusure in line with it: rows of + * rules no longer chosen are removed, missing rows are added (or, for a + * former Italian preset row, adopted), rows already there stay. Applying + * the same choice twice changes nothing. + * + * @param list $ids + * @return array{added: int, removed: int, adopted: int} + */ + public static function apply(\mysqli $db, string $country, string $region, array $ids, ?string $today = null): array + { + $country = strtoupper($country); + if (HolidayCalculator::country($country) === null) { + throw new \InvalidArgumentException('Unknown country: ' . $country); + } + $region = HolidayCalculator::normaliseRegion($country, $region); + $ids = self::filterIds($country, $region, $ids); + $firstYear = (int) substr($today ?? DateHelper::today(), 0, 4); + $counts = ['added' => 0, 'removed' => 0, 'adopted' => 0]; + + DbAtomic::run($db, static function () use ($db, $country, $region, $ids, $firstYear, &$counts): void { + $repo = new SettingsRepository($db); + $repo->set(self::SETTINGS_CATEGORY, 'holiday_country', $country); + $repo->set(self::SETTINGS_CATEGORY, 'holiday_region', $region); + $repo->set(self::SETTINGS_CATEGORY, 'holiday_rules', (string) json_encode($ids, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + // Unticked (or of another country): every row of that rule goes. + if ($ids === []) { + $db->query('DELETE FROM calendario_chiusure WHERE regola_festivita IS NOT NULL'); + $counts['removed'] += max(0, $db->affected_rows); + } else { + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare("DELETE FROM calendario_chiusure WHERE regola_festivita IS NOT NULL AND regola_festivita NOT IN ({$placeholders})"); + $stmt->bind_param(str_repeat('s', count($ids)), ...$ids); + $stmt->execute(); + $counts['removed'] += max(0, $stmt->affected_rows); + $stmt->close(); + } + + foreach (self::expectedRows($country, $region, $ids, $firstYear) as $ruleId => $rows) { + $counts['removed'] += self::pruneStale($db, $ruleId, $rows, $firstYear); + foreach ($rows as $row) { + $outcome = self::ensureRow($db, $ruleId, $row); + if ($outcome !== null) { + $counts[$outcome]++; + } + } + } + }, 'HolidayPresets::apply'); + + return $counts; + } + + /** + * Yearly upkeep (maintenance): the chosen movable holidays get their rows + * up to YEARS_AHEAD years after the current one. Adds only; the rows of + * past years stay as history. Returns the number of rows added. + */ + public static function ensureYears(\mysqli $db, ?string $today = null): int + { + if (!self::schemaReady($db)) { + return 0; + } + $selection = self::selection($db); + if (!$selection['saved'] || $selection['rules'] === []) { + return 0; + } + $firstYear = (int) substr($today ?? DateHelper::today(), 0, 4); + $added = 0; + DbAtomic::run($db, static function () use ($db, $selection, $firstYear, &$added): void { + foreach (self::expectedRows($selection['country'], $selection['region'], $selection['rules'], $firstYear) as $ruleId => $rows) { + foreach ($rows as $row) { + if (self::ensureRow($db, $ruleId, $row) !== null) { + $added++; + } + } + } + }, 'HolidayPresets::ensureYears'); + return $added; + } + + /** + * Rows of a still-chosen rule that no longer match its dates from + * $firstYear on (a rule whose shape changed); earlier years stay. + * + * @param list $rows + */ + private static function pruneStale(\mysqli $db, string $ruleId, array $rows, int $firstYear): int + { + $keepYearly = []; + $keepDates = []; + foreach ($rows as $row) { + if ($row['yearly']) { + $keepYearly[substr($row['start'], 5)] = true; + } else { + $keepDates[$row['start']] = true; + } + } + $from = $keepDates !== [] ? min(array_keys($keepDates)) : sprintf('%04d-01-01', $firstYear); + $stmt = $db->prepare('SELECT id, data_inizio, ricorrente_annuale FROM calendario_chiusure WHERE regola_festivita = ?'); + $stmt->bind_param('s', $ruleId); + $stmt->execute(); + $existing = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $stmt->close(); + $removed = 0; + foreach ($existing as $row) { + $start = substr((string) $row['data_inizio'], 0, 10); + $stale = (int) $row['ricorrente_annuale'] === 1 + ? !isset($keepYearly[substr($start, 5)]) + : ($start >= $from && !isset($keepDates[$start])); + if ($stale) { + $id = (int) $row['id']; + $delete = $db->prepare('DELETE FROM calendario_chiusure WHERE id = ?'); + $delete->bind_param('i', $id); + $delete->execute(); + $removed += max(0, $delete->affected_rows); + $delete->close(); + } + } + return $removed; + } + + /** + * One expected row: already there (null), adopted from a former preset + * row ('adopted') or inserted ('added'). + * + * @param array{start: string, end: string, description: string, yearly: bool} $row + * @return 'added'|'adopted'|null + */ + private static function ensureRow(\mysqli $db, string $ruleId, array $row): ?string + { + $yearly = $row['yearly'] ? 1 : 0; + if ($yearly === 1) { + $md = substr($row['start'], 5); + $stmt = $db->prepare("SELECT 1 FROM calendario_chiusure WHERE regola_festivita = ? AND ricorrente_annuale = 1 AND DATE_FORMAT(data_inizio, '%m-%d') = ? LIMIT 1"); + $stmt->bind_param('ss', $ruleId, $md); + } else { + $stmt = $db->prepare('SELECT 1 FROM calendario_chiusure WHERE regola_festivita = ? AND ricorrente_annuale = 0 AND data_inizio = ? LIMIT 1'); + $stmt->bind_param('ss', $ruleId, $row['start']); + } + $stmt->execute(); + $found = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + if ($found) { + return null; + } + + $legacy = self::legacyLabels($ruleId); + if ($legacy !== []) { + $placeholders = implode(',', array_fill(0, count($legacy), '?')); + if ($yearly === 1) { + $md = substr($row['start'], 5); + $sql = "SELECT id FROM calendario_chiusure WHERE regola_festivita IS NULL AND sede_id IS NULL AND ricorrente_annuale = 1 + AND DATE_FORMAT(data_inizio, '%m-%d') = ? AND DATE_FORMAT(data_fine, '%m-%d') = ? AND descrizione IN ({$placeholders}) ORDER BY id LIMIT 1"; + $params = array_merge([$md, $md], $legacy); + } else { + $sql = "SELECT id FROM calendario_chiusure WHERE regola_festivita IS NULL AND sede_id IS NULL AND ricorrente_annuale = 0 + AND data_inizio = ? AND data_fine = ? AND descrizione IN ({$placeholders}) ORDER BY id LIMIT 1"; + $params = array_merge([$row['start'], $row['end']], $legacy); + } + $stmt = $db->prepare($sql); + $stmt->bind_param(str_repeat('s', count($params)), ...$params); + $stmt->execute(); + $match = $stmt->get_result()->fetch_row(); + $stmt->close(); + if ($match !== null) { + $id = (int) $match[0]; + $update = $db->prepare('UPDATE calendario_chiusure SET regola_festivita = ?, descrizione = ?, attiva = 1 WHERE id = ?'); + $update->bind_param('ssi', $ruleId, $row['description'], $id); + $update->execute(); + $update->close(); + return 'adopted'; + } + } + + $stmt = $db->prepare('INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale, sede_id, regola_festivita, attiva) VALUES (?, ?, ?, ?, NULL, ?, 1)'); + $stmt->bind_param('sssis', $row['start'], $row['end'], $row['description'], $yearly, $ruleId); + $stmt->execute(); + $stmt->close(); + return 'added'; + } + + /** + * The descriptions the former Italian preset could have stored for a + * rule: the Italian label and its translation in every locale file. + * + * @return list + */ + public static function legacyLabels(string $ruleId): array + { + if (!isset(self::LEGACY_IT_LABELS[$ruleId])) { + return []; + } + if (self::$legacyLabelCache === null) { + self::$legacyLabelCache = []; + $catalogues = []; + foreach (glob(dirname(__DIR__, 3) . '/locale/??_??.json') ?: [] as $file) { + $decoded = json_decode((string) file_get_contents($file), true); + if (is_array($decoded)) { + $catalogues[] = $decoded; + } + } + foreach (self::LEGACY_IT_LABELS as $id => $label) { + $labels = [$label => true]; + foreach ($catalogues as $catalogue) { + if (isset($catalogue[$label]) && is_string($catalogue[$label]) && $catalogue[$label] !== '') { + $labels[$catalogue[$label]] = true; + } + } + self::$legacyLabelCache[$id] = array_map('strval', array_keys($labels)); + } + } + return self::$legacyLabelCache[$ruleId] ?? []; + } +} diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index 14b3c1626..259a01c51 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -442,34 +442,22 @@ public static function easterSunday(int $year): string } /** - * Italian national holidays for $year. Fixed dates recur every year; - * Easter and Easter Monday move, so they are dated for that year only. + * Italian national holidays for $year, from the Italy preset + * (Holidays\HolidayRules): fixed dates recur every year, movable ones + * (Easter, Easter Monday) and those that did not always exist are dated + * for that year only. Names are the Italian ones the preset stores. * * @return list */ public static function italianHolidays(int $year): array { - $fixed = [ - '01-01' => __('Capodanno'), - '01-06' => __('Epifania'), - '04-25' => __('Festa della Liberazione'), - '05-01' => __('Festa del Lavoro'), - '06-02' => __('Festa della Repubblica'), - '08-15' => __('Ferragosto'), - '11-01' => __('Ognissanti'), - '12-08' => __('Immacolata Concezione'), - '12-25' => __('Natale'), - '12-26' => __('Santo Stefano'), - ]; $holidays = []; - foreach ($fixed as $monthDay => $label) { - $date = sprintf('%04d-%s', $year, $monthDay); - $holidays[] = ['start' => $date, 'end' => $date, 'description' => $label, 'yearly' => true]; - } - $easter = self::easterSunday($year); - $holidays[] = ['start' => $easter, 'end' => $easter, 'description' => __('Pasqua'), 'yearly' => false]; - $monday = self::shift($easter, 1); - $holidays[] = ['start' => $monday, 'end' => $monday, 'description' => __("Lunedì dell'Angelo (Pasquetta)"), 'yearly' => false]; + foreach (Holidays\HolidayCalculator::forYear('IT', '', $year) as $holiday) { + if ($holiday['date'] === null) { + continue; + } + $holidays[] = ['start' => $holiday['date'], 'end' => $holiday['date'], 'description' => $holiday['name'], 'yearly' => $holiday['yearly']]; + } usort($holidays, static fn (array $x, array $y): int => strcmp($x['start'], $y['start'])); return $holidays; } @@ -674,7 +662,12 @@ private static function loadClosures(\mysqli $db): array if ($exists === false || $exists->num_rows === 0) { return []; } - $result = $db->query('SELECT data_inizio, data_fine, ricorrente_annuale, sede_id FROM calendario_chiusure'); + // A closure switched off (attiva = 0) stays stored but closes + // nothing. Before the holiday-presets section of the migration + // the column is missing and every row counts, as before. + $hasActive = $db->query("SHOW COLUMNS FROM calendario_chiusure LIKE 'attiva'"); + $filter = ($hasActive instanceof \mysqli_result && $hasActive->num_rows > 0) ? ' WHERE attiva = 1' : ''; + $result = $db->query('SELECT data_inizio, data_fine, ricorrente_annuale, sede_id FROM calendario_chiusure' . $filter); return $result === false ? [] : $result->fetch_all(MYSQLI_ASSOC); } catch (\Throwable $e) { SecureLogger::warning('LibraryCalendar: closures not readable: ' . $e->getMessage()); diff --git a/installer/database/migrations/migrate_0.9.0.sql b/installer/database/migrations/migrate_0.9.0.sql index ad7699ba8..ec5984e6d 100644 --- a/installer/database/migrations/migrate_0.9.0.sql +++ b/installer/database/migrations/migrate_0.9.0.sql @@ -281,6 +281,32 @@ CREATE TABLE IF NOT EXISTS `calendario_chiusure` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ── end closures calendar ── +-- ── holiday presets ── +-- calendario_chiusure.regola_festivita: the holiday rule ("CC:rule", e.g. +-- "DE:fronleichnam") a preset row stands for; NULL = a closure added by hand +-- or from an ICS file, which the presets never change. attiva: 0 keeps a +-- closure stored but ignored by the calendar (a yearly closure switched off +-- without deleting it). The choice itself (country, region, ticked rules) is +-- in system_settings (calendar.holiday_*), written on the first apply: no +-- seed. Former Italian preset rows are adopted by the first apply, not here. +-- Guarded on information_schema, so the section is idempotent. +SET @hp_col = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure' AND COLUMN_NAME = 'regola_festivita'); +SET @hp_sql = IF(@hp_col = 0, 'ALTER TABLE `calendario_chiusure` ADD COLUMN `regola_festivita` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL AFTER `sede_id`', 'SELECT 1'); +PREPARE hp_stmt FROM @hp_sql; +EXECUTE hp_stmt; +DEALLOCATE PREPARE hp_stmt; +SET @hp_col = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure' AND COLUMN_NAME = 'attiva'); +SET @hp_sql = IF(@hp_col = 0, 'ALTER TABLE `calendario_chiusure` ADD COLUMN `attiva` tinyint(1) NOT NULL DEFAULT ''1'' AFTER `regola_festivita`', 'SELECT 1'); +PREPARE hp_stmt FROM @hp_sql; +EXECUTE hp_stmt; +DEALLOCATE PREPARE hp_stmt; +SET @hp_idx = (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure' AND INDEX_NAME = 'idx_calendario_chiusure_regola'); +SET @hp_sql = IF(@hp_idx = 0, 'ALTER TABLE `calendario_chiusure` ADD KEY `idx_calendario_chiusure_regola` (`regola_festivita`)', 'SELECT 1'); +PREPARE hp_stmt FROM @hp_sql; +EXECUTE hp_stmt; +DEALLOCATE PREPARE hp_stmt; +-- ── end holiday presets ── + -- ── fines-ledger (Phase 4: fines, patron ledger, debt block) ── -- movimenti_lettore is an append-only patron ledger: the balance is SUM(importo), -- positive = debt, negative = payment/waiver. chiave_idempotenza keeps a charge diff --git a/installer/database/schema.sql b/installer/database/schema.sql index 800b0c05d..1beacfae3 100755 --- a/installer/database/schema.sql +++ b/installer/database/schema.sql @@ -115,10 +115,13 @@ CREATE TABLE `calendario_chiusure` ( `descrizione` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `ricorrente_annuale` tinyint(1) NOT NULL DEFAULT '0', `sede_id` int DEFAULT NULL, + `regola_festivita` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `attiva` tinyint(1) NOT NULL DEFAULT '1', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_calendario_chiusure_range` (`data_inizio`,`data_fine`), KEY `idx_calendario_chiusure_sede` (`sede_id`), + KEY `idx_calendario_chiusure_regola` (`regola_festivita`), CONSTRAINT `fk_calendario_chiusure_sede` FOREIGN KEY (`sede_id`) REFERENCES `sedi` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/tests/holiday-presets-apply.unit.php b/tests/holiday-presets-apply.unit.php new file mode 100644 index 000000000..a9dbbdb9d --- /dev/null +++ b/tests/holiday-presets-apply.unit.php @@ -0,0 +1,173 @@ +_holtest"). Run: php tests/holiday-presets-apply.unit.php + */ + +require dirname(__DIR__) . '/vendor/autoload.php'; +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + +use App\Support\Holidays\HolidayPresets; +use App\Support\LibraryCalendar; + +$root = dirname(__DIR__); +$env = Dotenv\Dotenv::parse((string) file_get_contents($root . '/.env')); +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null); +$sandbox = getenv('E2E_HOLIDAY_DB') ?: preg_replace('/^fabiodal_biblioteca_/', 'fabiodal_', (string) $env['DB_NAME']) . '_holtest'; +if (preg_match('/^[A-Za-z0-9_]+$/', $sandbox) !== 1) { + fwrite(STDERR, "invalid sandbox name\n"); + exit(1); +} + +$checks = 0; +$check = static function (bool $ok, string $label) use (&$checks): void { + if (!$ok) { + throw new RuntimeException('FAIL ' . $label); + } + $checks++; + echo "OK {$label}\n"; +}; + +$schema = (string) file_get_contents($root . '/installer/database/schema.sql'); +$ddl = []; +foreach (['sedi', 'system_settings', 'calendario_chiusure'] as $table) { + if (preg_match('/CREATE TABLE `' . $table . '` \(.*?\) ENGINE=[^;]*;/s', $schema, $m) !== 1) { + fwrite(STDERR, "{$table} not found in schema.sql\n"); + exit(1); + } + $ddl[] = $m[0]; +} + +$admin = new mysqli($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', '', (int) ($env['DB_PORT'] ?? 3306), $socket); +$exit = 1; +try { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); + $admin->query("CREATE DATABASE `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $db = new mysqli($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $sandbox, (int) ($env['DB_PORT'] ?? 3306), $socket); + $db->set_charset('utf8mb4'); + foreach ($ddl as $statement) { + $db->query($statement); + } + $one = static fn (string $sql): string => (string) ($db->query($sql)->fetch_row()[0] ?? ''); + $count = static fn (string $where = '1=1'): int => (int) $one("SELECT COUNT(*) FROM calendario_chiusure WHERE {$where}"); + $today = '2026-10-29'; + + $check(HolidayPresets::schemaReady($db), 'schema.sql has regola_festivita and attiva'); + $check(HolidayPresets::selection($db)['saved'] === false, 'nothing is chosen on a fresh install'); + + // Closures that are not presets: by hand, and as an ICS import stores them. + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale) VALUES ('2026-08-16','2026-08-16','Patrono',1)"); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale) VALUES ('2026-04-06','2026-04-06','Inventario di primavera',0)"); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale) VALUES ('2026-11-02','2026-11-05','ICS inventario',0)"); + $manualSnapshot = static fn (): string => $one("SELECT GROUP_CONCAT(CONCAT_WS('|', id, data_inizio, data_fine, descrizione, ricorrente_annuale, attiva) ORDER BY id) FROM calendario_chiusure WHERE regola_festivita IS NULL"); + $manual = $manualSnapshot(); + + // 1. Three Italian holidays: two fixed (one yearly row each), one movable (three years). + $r = HolidayPresets::apply($db, 'IT', '', ['IT:new_year', 'IT:easter_monday', 'IT:christmas'], $today); + $check($r === ['added' => 5, 'removed' => 0, 'adopted' => 0], 'apply: 5 rows added (1 + 3 + 1)'); + $check($count("regola_festivita = 'IT:new_year' AND ricorrente_annuale = 1") === 1, 'a fixed holiday is ONE yearly row'); + $check($one("SELECT GROUP_CONCAT(data_inizio ORDER BY data_inizio) FROM calendario_chiusure WHERE regola_festivita = 'IT:easter_monday'") === '2026-04-06,2027-03-29,2028-04-17', 'Easter Monday is dated for 2026, 2027 and 2028'); + $check($one("SELECT descrizione FROM calendario_chiusure WHERE regola_festivita = 'IT:christmas'") === 'Natale', 'the native name is the description'); + $check($count("regola_festivita IS NOT NULL AND sede_id IS NOT NULL") === 0, 'preset rows are for every branch'); + $sel = HolidayPresets::selection($db); + $check($sel['saved'] && $sel['country'] === 'IT' && $sel['rules'] === ['IT:new_year', 'IT:easter_monday', 'IT:christmas'], 'the choice is saved'); + + // 2. Applying again changes nothing. + $before = $count(); + $r = HolidayPresets::apply($db, 'IT', '', ['IT:christmas', 'IT:new_year', 'IT:easter_monday'], $today); + $check($r === ['added' => 0, 'removed' => 0, 'adopted' => 0] && $count() === $before, 'reapplying adds and removes nothing'); + + // 3. Unticking removes only that rule. + $r = HolidayPresets::apply($db, 'IT', '', ['IT:new_year', 'IT:christmas'], $today); + $check($r['removed'] === 3 && $r['added'] === 0, 'unticking Easter Monday removes its 3 rows'); + $check($count("regola_festivita = 'IT:easter_monday'") === 0 && $count("regola_festivita IN ('IT:new_year','IT:christmas')") === 2, 'the other holidays stay'); + $check($manualSnapshot() === $manual, 'manual and ICS closures are untouched (also the one on Easter Monday 2026)'); + + // 4. Unknown and foreign ids are dropped; a regional rule needs its region. + $r = HolidayPresets::apply($db, 'IT', '', ['IT:new_year', 'IT:christmas', 'IT:nope', 'DE:neujahr'], $today); + $check($r['added'] === 0 && $count("regola_festivita LIKE 'DE:%'") === 0, 'ids of another country or unknown are ignored'); + + // 5. Another country: every Italian row goes, the German ones come. + $r = HolidayPresets::apply($db, 'DE', 'BE', ['DE:neujahr', 'DE:karfreitag', 'DE:fronleichnam', 'DE:frauentag'], $today); + $check($count("regola_festivita LIKE 'IT:%'") === 0, 'switching country removes the previous country'); + $check($count("regola_festivita = 'DE:fronleichnam'") === 0, 'Fronleichnam is not stored for Berlin'); + $check($count("regola_festivita = 'DE:frauentag'") === 3 && $count("regola_festivita = 'DE:karfreitag'") === 3, 'Frauentag (from 2019) and Karfreitag are dated per year'); + $check($r['removed'] === 2 && $r['added'] === 7, 'counts: 2 removed, 7 added (1 + 3 + 3)'); + $check(HolidayPresets::selection($db)['rules'] === ['DE:neujahr', 'DE:frauentag', 'DE:karfreitag'], 'the saved choice keeps the table order and only valid ids'); + $check($manualSnapshot() === $manual, 'manual and ICS closures still untouched'); + + // 6. Nothing ticked: every preset row goes, nothing else. + $r = HolidayPresets::apply($db, 'DE', 'BE', [], $today); + $check($r['removed'] === 7 && $count('regola_festivita IS NOT NULL') === 0 && $manualSnapshot() === $manual, 'select none removes all preset rows and only those'); + + // 7. Former Italian preset rows (the 0.9.0 importer, in the language of the + // install) are adopted, not duplicated; a manual closure on the same day + // with another name is not. + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale) VALUES ('2026-12-25','2026-12-25','Natale',1)"); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale) VALUES ('2025-12-26','2025-12-26','Santo Stefano',1)"); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale) VALUES ('2026-01-01','2026-01-01','Neujahr',1)"); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale) VALUES ('2027-03-29','2027-03-29','Ostermontag',0)"); + $legacyIds = $one("SELECT GROUP_CONCAT(id ORDER BY id) FROM calendario_chiusure WHERE descrizione IN ('Natale','Santo Stefano','Neujahr','Ostermontag')"); + $r = HolidayPresets::apply($db, 'IT', '', ['IT:new_year', 'IT:easter_monday', 'IT:christmas', 'IT:st_stephen'], $today); + $check($r['adopted'] === 4, 'four former preset rows adopted (Italian and German labels, yearly and dated)'); + $check($r['added'] === 2, 'only the missing Easter Mondays (2026, 2028) are added'); + $check($count("ricorrente_annuale = 1 AND DATE_FORMAT(data_inizio, '%m-%d') = '12-25'") === 1, 'Christmas is not duplicated'); + $check($one("SELECT GROUP_CONCAT(id ORDER BY id) FROM calendario_chiusure WHERE regola_festivita IN ('IT:new_year','IT:christmas','IT:st_stephen') OR (regola_festivita = 'IT:easter_monday' AND data_inizio = '2027-03-29')") === $legacyIds, 'the adopted rows keep their ids'); + $check($one("SELECT descrizione FROM calendario_chiusure WHERE regola_festivita = 'IT:new_year'") === 'Capodanno', 'an adopted row takes the native name'); + $check($one("SELECT regola_festivita IS NULL FROM calendario_chiusure WHERE descrizione = 'Inventario di primavera'") === '1', 'a manual closure on Easter Monday with its own name is not adopted'); + $check($manualSnapshot() === $manual, 'and the manual closures are still exactly as they were'); + $r = HolidayPresets::apply($db, 'IT', '', ['IT:new_year', 'IT:easter_monday', 'IT:christmas', 'IT:st_stephen'], $today); + $check($r === ['added' => 0, 'removed' => 0, 'adopted' => 0], 'after adoption a reapply is a no-op'); + + // 8. Yearly upkeep: a new year brings the year +2 of each movable holiday. + $check(HolidayPresets::ensureYears($db, $today) === 0, 'ensureYears in the same year adds nothing'); + $check(HolidayPresets::ensureYears($db, '2027-01-02') === 1, 'in 2027 Easter Monday 2029 is added'); + $check($count("regola_festivita = 'IT:easter_monday' AND data_inizio = '2029-04-02'") === 1, 'Easter Monday 2029 is 2 April'); + $check(HolidayPresets::ensureYears($db, '2027-01-02') === 0, 'ensureYears is idempotent'); + $check($count("regola_festivita = 'IT:easter_monday' AND data_inizio = '2026-04-06'") === 1, 'past years stay as history'); + + // 9. A rule with a first year: San Francesco only from 2026. + HolidayPresets::apply($db, 'IT', '', ['IT:san_francesco'], '2025-06-01'); + $check($one("SELECT GROUP_CONCAT(data_inizio ORDER BY data_inizio) FROM calendario_chiusure WHERE regola_festivita = 'IT:san_francesco'") === '2026-10-04,2027-10-04', 'San Francesco dated from 2026 only'); + + // 10. An observed rule of another country, and the region a nation needs. + HolidayPresets::apply($db, 'US', '', ['US:independence_day'], $today); + $check($one("SELECT GROUP_CONCAT(data_inizio ORDER BY data_inizio) FROM calendario_chiusure WHERE regola_festivita = 'US:independence_day'") === '2026-07-03,2027-07-05,2028-07-04', 'US Independence Day stored on the observed days'); + HolidayPresets::apply($db, 'GB', '', ['GB:new_year', 'GB:st_patrick'], $today); + $check(HolidayPresets::selection($db)['region'] === 'ENG' && $count("regola_festivita = 'GB:st_patrick'") === 0, 'the United Kingdom falls back to England and Wales'); + + $threw = false; + try { + HolidayPresets::apply($db, 'XX', '', [], $today); + } catch (\InvalidArgumentException $e) { + $threw = true; + } + $check($threw, 'an unknown country is refused'); + + // 11. A switched-off closure stays stored and closes nothing. + LibraryCalendar::reset(); + $check(!LibraryCalendar::fromDatabase($db)->isOpen('2027-08-16'), 'the yearly manual closure closes 16 August'); + $db->query("UPDATE calendario_chiusure SET attiva = 0 WHERE descrizione = 'Patrono'"); + $check(LibraryCalendar::fromDatabase($db)->isOpen('2027-08-16'), 'switched off, 16 August is open'); + $check($count("descrizione = 'Patrono'") === 1, 'and the closure is still stored'); + HolidayPresets::apply($db, 'US', '', ['US:independence_day'], $today); + LibraryCalendar::reset(); + $check(LibraryCalendar::dueDate($db, '2026-07-03') === '2026-07-04', 'a due date on a chosen holiday moves to the next open day'); + $check(LibraryCalendar::dueDate($db, '2026-09-07') === '2026-09-07', 'a due date on an unchosen holiday (Labor Day) stays'); + + $db->close(); + echo "SUCCESS {$checks} holiday preset checks\n"; + $exit = 0; +} catch (\Throwable $e) { + echo $e->getMessage() . "\n"; +} finally { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); +} +exit($exit); diff --git a/tests/library-calendar.unit.php b/tests/library-calendar.unit.php index b40d0f631..f87bf360b 100644 --- a/tests/library-calendar.unit.php +++ b/tests/library-calendar.unit.php @@ -154,11 +154,14 @@ } $h2026 = LibraryCalendar::italianHolidays(2026); $dates2026 = array_column($h2026, 'start'); -$eq(count($h2026), 12, 'Italian preset has 12 holidays'); +// 13 from 2026: San Francesco (4 October) is a national holiday again. +$eq(count($h2026), 13, 'Italian preset has 13 holidays in 2026'); +$eq(count(LibraryCalendar::italianHolidays(2025)), 12, 'and 12 in 2025, before San Francesco returned'); $eq(in_array('2026-04-06', $dates2026, true), true, 'Easter Monday 2026 is Apr 6'); $eq(in_array('2026-06-02', $dates2026, true), true, 'Festa della Repubblica included'); -$easterRows = array_values(array_filter($h2026, static fn (array $r): bool => !$r['yearly'])); -$eq(count($easterRows), 2, 'only Easter and Easter Monday are dated for the year'); +$eq(in_array('2026-10-04', $dates2026, true), true, 'San Francesco included from 2026'); +$datedRows = array_values(array_filter($h2026, static fn (array $r): bool => !$r['yearly'])); +$eq(array_column($datedRows, 'start'), ['2026-04-05', '2026-04-06', '2026-10-04'], 'Easter, Easter Monday and San Francesco (from 2026) are dated for the year'); $eq($dates2026, array_values(array_unique($dates2026)), 'preset dates are distinct'); $sorted = $dates2026; sort($sorted); diff --git a/tests/migration-0.9.0-holiday-presets.unit.php b/tests/migration-0.9.0-holiday-presets.unit.php new file mode 100644 index 000000000..82c5f15b1 --- /dev/null +++ b/tests/migration-0.9.0-holiday-presets.unit.php @@ -0,0 +1,114 @@ +_migtest_hol"). Run: php tests/migration-0.9.0-holiday-presets.unit.php + */ + +require dirname(__DIR__) . '/vendor/autoload.php'; +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + +$root = dirname(__DIR__); +$env = Dotenv\Dotenv::parse((string) file_get_contents($root . '/.env')); +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null); +$sandbox = getenv('E2E_MIG_DB') ?: preg_replace('/^fabiodal_biblioteca_/', 'fabiodal_', (string) $env['DB_NAME']) . '_migtest_hol'; +if (preg_match('/^[A-Za-z0-9_]+$/', $sandbox) !== 1) { + fwrite(STDERR, "invalid sandbox name\n"); + exit(1); +} + +$checks = 0; +$check = static function (bool $ok, string $label) use (&$checks): void { + if (!$ok) { + throw new RuntimeException('FAIL ' . $label); + } + $checks++; + echo "OK {$label}\n"; +}; + +$migration = (string) file_get_contents($root . '/installer/database/migrations/migrate_0.9.0.sql'); +$section = static function (string $name) use ($migration): string { + if (preg_match('/-- ── ' . preg_quote($name, '/') . ' ──(.*?)-- ── end ' . preg_quote($name, '/') . ' ──/s', $migration, $m) !== 1) { + fwrite(STDERR, "{$name} section not found in migrate_0.9.0.sql\n"); + exit(1); + } + return $m[1]; +}; +$closures = $section('closures calendar'); +$holidays = $section('holiday presets'); +$schema = (string) file_get_contents($root . '/installer/database/schema.sql'); +if (preg_match('/CREATE TABLE `sedi` \(.*?\) ENGINE=[^;]*;/s', $schema, $m) !== 1) { + fwrite(STDERR, "sedi not found in schema.sql\n"); + exit(1); +} +$sedi = $m[0]; + +$admin = new mysqli($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', '', (int) ($env['DB_PORT'] ?? 3306), $socket); +$exit = 1; +try { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); + $admin->query("CREATE DATABASE `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $db = new mysqli($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $sandbox, (int) ($env['DB_PORT'] ?? 3306), $socket); + $db->set_charset('utf8mb4'); + $db->query($sedi); + + // Executed the way the Updater does: comment lines dropped, split on ';'. + $run = static function (string $sql) use ($db): void { + $sql = implode("\n", array_filter(explode("\n", $sql), static fn (string $l): bool => !str_starts_with(trim($l), '--'))); + foreach (explode(';', $sql) as $statement) { + if (trim($statement) !== '') { + $db->query($statement); + } + } + }; + $columns = static function () use ($db): array { + $cols = []; + foreach ($db->query("SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure' ORDER BY ORDINAL_POSITION")->fetch_all(MYSQLI_ASSOC) as $c) { + $cols[$c['COLUMN_NAME']] = $c; + } + return $cols; + }; + $index = static fn (): int => (int) $db->query("SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure' AND INDEX_NAME = 'idx_calendario_chiusure_regola' AND COLUMN_NAME = 'regola_festivita'")->fetch_row()[0]; + + // The table as the closures section leaves it, with the rows the former + // Italian preset and a manual entry stored. + $run($closures); + $before = $columns(); + $check(!isset($before['regola_festivita']) && !isset($before['attiva']), 'the old table has neither regola_festivita nor attiva'); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale) VALUES ('2026-12-25','2026-12-25','Natale',1), ('2026-04-06','2026-04-06','Lunedì dell''Angelo (Pasquetta)',0), ('2026-08-16','2026-08-16','Patrono',1)"); + + $run($holidays); + $cols = $columns(); + $check(isset($cols['regola_festivita']) && $cols['regola_festivita']['COLUMN_TYPE'] === 'varchar(64)' && $cols['regola_festivita']['IS_NULLABLE'] === 'YES', 'regola_festivita varchar(64) NULL is added'); + $check(isset($cols['attiva']) && $cols['attiva']['COLUMN_TYPE'] === 'tinyint(1)' && $cols['attiva']['IS_NULLABLE'] === 'NO' && in_array($cols['attiva']['COLUMN_DEFAULT'], ['1', "'1'"], true), 'attiva tinyint(1) NOT NULL DEFAULT 1 is added'); + $order = array_keys($cols); + $check(array_search('regola_festivita', $order, true) === array_search('sede_id', $order, true) + 1 && array_search('attiva', $order, true) === array_search('regola_festivita', $order, true) + 1, 'the columns follow sede_id, as in schema.sql'); + $check($index() === 1, 'the regola_festivita index exists'); + $rows = $db->query('SELECT descrizione, regola_festivita, attiva FROM calendario_chiusure ORDER BY id')->fetch_all(MYSQLI_ASSOC); + $check(count($rows) === 3, 'existing closures are kept'); + $check(array_unique(array_map(static fn (array $r): string => var_export($r['regola_festivita'], true) . '|' . $r['attiva'], $rows)) === ['NULL|1'], 'they are manual (NULL) and active: the first apply adopts the preset ones'); + + $run($holidays); + $check(array_keys($columns()) === $order && $index() === 1, 'a second run changes nothing (idempotent)'); + $check((int) $db->query('SELECT COUNT(*) FROM calendario_chiusure')->fetch_row()[0] === 3, 'and keeps the rows'); + + // The fresh install has the same shape. + $check(str_contains($schema, "`regola_festivita` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `attiva` tinyint(1) NOT NULL DEFAULT '1',") && str_contains($schema, 'KEY `idx_calendario_chiusure_regola` (`regola_festivita`)'), 'schema.sql has both columns and the index'); + $check(strpos($migration, '-- ── holiday presets ──') > strpos($migration, '-- ── end closures calendar ──'), 'the section runs after the table is created'); + $version = json_decode((string) file_get_contents($root . '/version.json'), true)['version']; + $check(version_compare('0.9.0', $version, '<='), 'the release version includes the migration'); + $db->close(); + echo "SUCCESS {$checks} migration checks\n"; + $exit = 0; +} catch (\Throwable $e) { + echo $e->getMessage() . "\n"; +} finally { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); +} +exit($exit); From a8a77011a1c071c4c2d7146891fd18b65f424959 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:21:54 +0100 Subject: [PATCH 193/398] Feature toggles: one features.* switch per optional 0.9.0 module App\Support\Features holds the eleven keys with their upgrade default (also the code fallback) and their fresh-install default. ConfigStore maps the features category, so a saved switch is read back. Settings gains a Modules tab for administrators; the three circulation modules are switched from the Loans tab. FeatureGateMiddleware refuses the routes of a module that is off (302 to the dashboard, or 404 feature_disabled for JSON callers). Fresh defaults are seeded in the five data_*.sql files; the upgrade defaults go in a new feature toggles section of migrate_0.9.0.sql, which turns a module on when the library already has data for it. --- app/Controllers/SettingsController.php | 54 ++++- app/Middleware/FeatureGateMiddleware.php | 63 ++++++ app/Routes/web.php | 132 ++++++------ app/Support/ConfigStore.php | 21 ++ app/Support/Features.php | 197 ++++++++++++++++++ app/Views/partials/settings-sections.php | 13 +- app/Views/settings/index.php | 4 +- app/Views/settings/loans-tab.php | 83 +++++++- app/Views/settings/modules-tab.php | 79 +++++++ installer/database/data_da_DK.sql | 16 ++ installer/database/data_de_DE.sql | 16 ++ installer/database/data_en_US.sql | 16 ++ installer/database/data_fr_FR.sql | 16 ++ installer/database/data_it_IT.sql | 16 ++ .../database/migrations/migrate_0.9.0.sql | 76 +++++++ locale/da_DK.json | 31 ++- locale/de_DE.json | 31 ++- locale/en_US.json | 31 ++- locale/fr_FR.json | 31 ++- locale/it_IT.json | 31 ++- 20 files changed, 886 insertions(+), 71 deletions(-) create mode 100644 app/Middleware/FeatureGateMiddleware.php create mode 100644 app/Support/Features.php create mode 100644 app/Views/settings/modules-tab.php diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 1901a1ffb..65632e58e 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -84,7 +84,11 @@ public function index(Request $request, Response $response, mysqli $db): Respons // administrators like every CirculationSettingsController action, so // staff gets no data and the view renders no panel (the section is // absent from the settings catalogue for them too). - $circulationData = \App\Support\Permissions::mayConfigure() ? CirculationSettingsController::viewData($db) : null; + $circulationData = \App\Support\Permissions::mayConfigure() && \App\Support\Features::enabled('circulation_rules') + ? CirculationSettingsController::viewData($db) + : null; + // Optional modules (Settings → Modules): the saved switches. + $moduleSettings = \App\Support\Features::snapshot(); $contactMessages = $this->loadContactMessages($db); $cookieBannerTexts = $this->resolveCookieBannerTexts($repository); // Custom registration fields (issue #255) — edited from the email tab's @@ -120,6 +124,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons 'advancedSettings', 'loansSettings', 'circulationData', + 'moduleSettings', 'contactMessages', 'activeTab', 'db', @@ -1706,6 +1711,11 @@ private function resolveLoansSettings(SettingsRepository $repository): array 'debt_block_threshold' => (string) ($repository->get('loans', 'debt_block_threshold', '') ?? ''), 'fine_charged_email' => (string) ($repository->get('loans', 'fine_charged_email', '0') ?? '0') === '1', 'currency' => \App\Services\FineParameters::currency(), + // Optional circulation modules switched from this tab (the saved + // switch, not the catalogue-mode-adjusted one). + 'fines_enabled' => \App\Support\Features::stored('fines'), + 'closures_calendar_enabled' => \App\Support\Features::stored('closures_calendar'), + 'circulation_rules_enabled' => \App\Support\Features::stored('circulation_rules'), ]; } @@ -1798,6 +1808,9 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $fineEmail = isset($data['fine_charged_email']) && is_scalar($data['fine_charged_email']) && (string) $data['fine_charged_email'] === '1'; $repository->set('loans', 'fine_charged_email', $fineEmail ? '1' : '0'); + // The three circulation modules of this tab (patron type rules, + // closures calendar, fines): library policy, administrators only. + $this->saveFeatureSwitches($repository, $data, \App\Support\Features::LOANS_TAB); } // App timezone: DateHelper computes the loan clock ("today"/"now") from @@ -1815,6 +1828,45 @@ public function updateLoansSettings(Request $request, Response $response, mysqli return $response->withHeader('Location', url('/admin/settings?tab=loans'))->withStatus(302); } + /** + * Save Settings → Modules. Switching a module off hides it everywhere, so + * it is library policy: AdminAuthMiddleware admits staff, the check is + * here, as for the other policy settings. + */ + public function updateModules(Request $request, Response $response, mysqli $db): Response + { + if (!\App\Support\Permissions::mayConfigure()) { + $_SESSION['error_message'] = __('Accesso negato. Permessi insufficienti.'); + return $this->redirect($response, '/admin/settings'); + } + $data = (array) $request->getParsedBody(); + // CSRF validated by CsrfMiddleware + + $repository = new SettingsRepository($db); + $repository->ensureTables(); + $this->saveFeatureSwitches($repository, $data, \App\Support\Features::MODULES_TAB); + + $_SESSION['success_message'] = __('Moduli aggiornati correttamente.'); + return $this->redirect($response, '/admin/settings?tab=modules'); + } + + /** + * Write features. for each of $keys from the checkbox feature_ + * (absent = off). SettingsRepository::set() drops the ConfigStore cache, + * so the next request already reads the new value. + * + * @param array $data + * @param list $keys + */ + private function saveFeatureSwitches(SettingsRepository $repository, array $data, array $keys): void + { + foreach ($keys as $key) { + $field = 'feature_' . $key; + $on = isset($data[$field]) && is_scalar($data[$field]) && (string) $data[$field] === '1'; + $repository->set('features', $key, $on ? '1' : '0'); + } + } + /** A 302 back to an admin path inside the application. */ private function redirect(Response $response, string $location): Response { diff --git a/app/Middleware/FeatureGateMiddleware.php b/app/Middleware/FeatureGateMiddleware.php new file mode 100644 index 000000000..8390e5a36 --- /dev/null +++ b/app/Middleware/FeatureGateMiddleware.php @@ -0,0 +1,63 @@ +feature; + } + + public function process(Request $request, RequestHandler $handler): Response + { + if (Features::enabled($this->feature)) { + return $handler->handle($request); + } + + $uri = $request->getUri()->getPath(); + $wantsJson = str_starts_with($uri, '/api/') + || str_contains($uri, '/api/') + || strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest' + || str_contains($request->getHeaderLine('Accept'), 'application/json'); + + if ($wantsJson) { + $res = new SlimResponse(404); + $res->getBody()->write((string) json_encode([ + 'error' => true, + 'code' => 'feature_disabled', + 'message' => __('Funzione non disponibile su questa istanza.'), + ], JSON_UNESCAPED_UNICODE)); + return $res->withHeader('Content-Type', 'application/json'); + } + + $_SESSION['error_message'] = __('Funzione disattivata nelle impostazioni.'); + return (new SlimResponse(302))->withHeader('Location', url('/admin/dashboard')); + } +} diff --git a/app/Routes/web.php b/app/Routes/web.php index bcabd95c7..b5c7c8bbf 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -722,12 +722,12 @@ // the labels one; the GET form of the address opens that tab. $app->get('/admin/settings/cards', function ($request, $response) { return $response->withHeader('Location', url('/admin/settings?tab=cards'))->withStatus(302); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new AdminAuthMiddleware()); $app->post('/admin/settings/cards', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); $controller = new SettingsController(); return $controller->updateCards($request, $response, $db); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/sharing', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); @@ -771,6 +771,14 @@ return $controller->flushAllCaches($request, $response, $db); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Optional modules (Settings → Modules). Administrators only: the + // controller re-checks mayConfigure() inline, as for the policy settings. + $app->post('/admin/settings/modules', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + $controller = new SettingsController(); + return $controller->updateModules($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/settings/loans', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); $controller = new SettingsController(); @@ -780,42 +788,42 @@ // Circulation policy: patron types and rules matrix (Settings → Circulation) $app->get('/admin/settings/circulation', function ($request, $response) { return (new \App\Controllers\CirculationSettingsController())->index($request, $response); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->createType($request, $response, $db); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->get('/admin/settings/circulation/types/{id:\d+}', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->editForm($request, $response, $db, (int) $args['id']); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types/{id:\d+}', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->updateType($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types/{id:\d+}/delete', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->deleteType($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types/{id:\d+}/default', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->setDefault($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/types/{id:\d+}/move', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->moveType($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/settings/circulation/rules', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\CirculationSettingsController())->saveRules($request, $response, $db); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // API Settings Routes $app->post('/admin/settings/api/toggle', function ($request, $response) use ($app) { @@ -847,37 +855,37 @@ $app->get('/admin/calendar', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->index($request, $response, $db); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/weekdays', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->saveWeekdays($request, $response, $db); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/closures', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->store($request, $response, $db); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/closures/{id:\d+}/update', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->update($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/closures/{id:\d+}/delete', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->delete($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/preset', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->importPreset($request, $response, $db); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/calendar/import-ics', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->importIcs($request, $response, $db); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('closures_calendar'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // Language Management Routes (Admin Only) $app->get('/admin/languages', function ($request, $response) use ($app) { @@ -1286,7 +1294,7 @@ $db = $app->getContainer()->get('db'); $controller = new \App\Controllers\Admin\StatsController(); return $controller->finesCsv($request, $response, $db); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('fines'))->add(new AdminAuthMiddleware()); // Simple stats APIs used by header quick stats (require authentication) $app->get('/api/stats/books-count', function ($request, $response) use ($app) { @@ -1359,19 +1367,19 @@ $app->post('/admin/users/{id:\d+}/ledger/payment', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\PatronLedgerController())->payment($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('fines'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/users/{id:\d+}/ledger/charge', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\PatronLedgerController())->charge($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('fines'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/users/{id:\d+}/ledger/waive', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\PatronLedgerController())->waive($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('fines'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->get('/admin/users/{id:\d+}/ledger/receipt/{movement:\d+}', function ($request, $response, $args) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\PatronLedgerController())->receipt($request, $response, $db, (int) $args['id'], (int) $args['movement']); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('fines'))->add(new AdminAuthMiddleware()); $app->post('/admin/users/{id:\d+}/approve-and-send-activation', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\UsersController(); $db = $app->getContainer()->get('db'); @@ -1392,49 +1400,49 @@ $app->get('/admin/users/{id:\d+}/card-pdf', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\PatronCardsController(); return $controller->cardPdf($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->get('/admin/users/cards-pdf', function ($request, $response) use ($app) { $controller = new \App\Controllers\PatronCardsController(); return $controller->bulkPdf($request, $response, $app->getContainer()->get('db')); - })->add(new \App\Middleware\RateLimitMiddleware(15, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new \App\Middleware\RateLimitMiddleware(15, 60))->add(new AdminAuthMiddleware()); $app->get('/admin/users/{id:\d+}/photo', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\PatronCardsController(); return $controller->photo($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new AdminAuthMiddleware()); $app->post('/admin/users/{id:\d+}/photo', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\PatronCardsController(); return $controller->uploadPhoto($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->post('/admin/users/{id:\d+}/photo/delete', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\PatronCardsController(); return $controller->deletePhoto($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('patron_cards'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->get('/admin/users/import', function ($request, $response) use ($app) { $controller = new \App\Controllers\UserImportController(); return $controller->page($request, $response, $app->getContainer()->get('db')); - })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('user_import'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->get('/admin/users/import/example', function ($request, $response) use ($app) { $controller = new \App\Controllers\UserImportController(); return $controller->example($request, $response, $app->getContainer()->get('db')); - })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('user_import'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->get('/admin/users/import/errors', function ($request, $response) use ($app) { $controller = new \App\Controllers\UserImportController(); return $controller->downloadErrors($request, $response, $app->getContainer()->get('db')); - })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('user_import'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->post('/admin/users/import/upload', function ($request, $response) use ($app) { $controller = new \App\Controllers\UserImportController(); return $controller->upload($request, $response, $app->getContainer()->get('db')); - })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('user_import'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->post('/admin/users/import/start', function ($request, $response) use ($app) { $controller = new \App\Controllers\UserImportController(); return $controller->start($request, $response, $app->getContainer()->get('db')); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('user_import'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // No RateLimitMiddleware on /chunk: same rationale as the book import (#113), // one sequential request per chunk, bound to the session's import. $app->post('/admin/users/import/chunk', function ($request, $response) use ($app) { $controller = new \App\Controllers\UserImportController(); return $controller->chunk($request, $response, $app->getContainer()->get('db')); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('user_import'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // CSV Export route for users $app->get('/admin/users/export/csv', function ($request, $response) use ($app) { @@ -1664,26 +1672,26 @@ $controller = new \App\Controllers\MarcImportController(); $db = $app->getContainer()->get('db'); return $controller->showImportPage($request, $response, $db); - })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('marc'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->post('/admin/books/import/marc/upload', function ($request, $response) use ($app) { $controller = new \App\Controllers\MarcImportController(); $db = $app->getContainer()->get('db'); return $controller->upload($request, $response, $db); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('marc'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/books/import/marc/chunk', function ($request, $response) use ($app) { $controller = new \App\Controllers\MarcImportController(); $db = $app->getContainer()->get('db'); return $controller->chunk($request, $response, $db); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('marc'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // MARC bulk export: whole catalogue or ?ids= selection, streamed. $app->get('/admin/books/export/marc', function ($request, $response) use ($app) { $controller = new \App\Controllers\MarcExportController(); $db = $app->getContainer()->get('db'); return $controller->export($request, $response, $db); - })->add(new \App\Middleware\RateLimitMiddleware(15, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('marc'))->add(new \App\Middleware\RateLimitMiddleware(15, 60))->add(new AdminAuthMiddleware()); // LibraryThing Plugin Routes @@ -1844,7 +1852,7 @@ $controller = new \App\Controllers\CopyController(); $db = $app->getContainer()->get('db'); return $controller->toggleConsultation($request, $response, $db, (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('circulation_rules'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/books/copies/{id:\d+}/delete', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\CopyController(); @@ -1998,17 +2006,17 @@ : $controller->{$method}($request, $response, $db); }; }; - $app->get('/admin/inventory', $inventory('index'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); - $app->post('/admin/inventory', $inventory('store'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); - $app->get('/admin/inventory/{id:\d+}', $inventory('show'))->add(new AdminAuthMiddleware()); - $app->get('/admin/inventory/{id:\d+}/state', $inventory('state'))->add(new AdminAuthMiddleware()); + $app->get('/admin/inventory', $inventory('index'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/inventory', $inventory('store'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->get('/admin/inventory/{id:\d+}', $inventory('show'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new AdminAuthMiddleware()); + $app->get('/admin/inventory/{id:\d+}/state', $inventory('state'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new AdminAuthMiddleware()); // Scanning is a burst workload (wedge scanners read several codes a second). - $app->post('/admin/inventory/{id:\d+}/scan', $inventory('scan'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(600, 60))->add(new AdminAuthMiddleware()); - $app->post('/admin/inventory/{id:\d+}/upload', $inventory('upload'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); - $app->post('/admin/inventory/{id:\d+}/close', $inventory('close'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); - $app->post('/admin/inventory/{id:\d+}/cancel', $inventory('cancel'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); - $app->get('/admin/inventory/{id:\d+}/report.csv', $inventory('exportCsv'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); - $app->get('/admin/inventory/{id:\d+}/report.pdf', $inventory('exportPdf'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/inventory/{id:\d+}/scan', $inventory('scan'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(600, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/inventory/{id:\d+}/upload', $inventory('upload'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/inventory/{id:\d+}/close', $inventory('close'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/inventory/{id:\d+}/cancel', $inventory('cancel'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->get('/admin/inventory/{id:\d+}/report.csv', $inventory('exportCsv'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->get('/admin/inventory/{id:\d+}/report.pdf', $inventory('exportPdf'))->add(new \App\Middleware\FeatureGateMiddleware('stocktake'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); // Placement (protected admin) $app->get('/admin/placement', function ($request, $response) use ($app) { @@ -2789,50 +2797,50 @@ // Suppliers (0.9.0): the publishers' CRUD shape, catalogue permission. $app->get('/admin/suppliers', function ($request, $response) use ($app) { return (new \App\Controllers\SuppliersController())->index($request, $response, $app->getContainer()->get('db')); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('suppliers'))->add(new AdminAuthMiddleware()); $app->get('/admin/suppliers/create', function ($request, $response) { return (new \App\Controllers\SuppliersController())->createForm($request, $response); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('suppliers'))->add(new AdminAuthMiddleware()); $app->post('/admin/suppliers/create', function ($request, $response) use ($app) { return (new \App\Controllers\SuppliersController())->store($request, $response, $app->getContainer()->get('db')); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('suppliers'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->get('/admin/suppliers/edit/{id:\d+}', function ($request, $response, $args) use ($app) { return (new \App\Controllers\SuppliersController())->editForm($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('suppliers'))->add(new AdminAuthMiddleware()); $app->post('/admin/suppliers/update/{id:\d+}', function ($request, $response, $args) use ($app) { return (new \App\Controllers\SuppliersController())->update($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('suppliers'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/suppliers/delete/{id:\d+}', function ($request, $response, $args) use ($app) { return (new \App\Controllers\SuppliersController())->delete($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('suppliers'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // ISBD catalogue cards (0.9.0): one book, or the books selected in the list. $app->get('/admin/books/{id:\d+}/catalogue-card', function ($request, $response, $args) use ($app) { return (new \App\Controllers\CatalogueCardController())->single($request, $response, $app->getContainer()->get('db'), (int) $args['id']); - })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('catalogue_cards'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); $app->get('/admin/books/catalogue-cards', function ($request, $response) use ($app) { return (new \App\Controllers\CatalogueCardController())->bulk($request, $response, $app->getContainer()->get('db')); - })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('catalogue_cards'))->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); // Visitor log kiosk (0.9.0). Front-desk surface: loans permission. $app->get('/admin/attendance', function ($request, $response) use ($app) { return (new \App\Controllers\AttendanceController())->index($request, $response, $app->getContainer()->get('db')); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('visitor_log'))->add(new AdminAuthMiddleware()); $app->get('/admin/attendance/export.csv', function ($request, $response) use ($app) { return (new \App\Controllers\AttendanceController())->exportCsv($request, $response, $app->getContainer()->get('db')); - })->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('visitor_log'))->add(new AdminAuthMiddleware()); $app->post('/admin/attendance/scan', function ($request, $response) use ($app) { return (new \App\Controllers\AttendanceController())->scan($request, $response, $app->getContainer()->get('db')); - })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(120, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('visitor_log'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(120, 60))->add(new AdminAuthMiddleware()); $app->post('/admin/attendance/guest', function ($request, $response) use ($app) { return (new \App\Controllers\AttendanceController())->guest($request, $response, $app->getContainer()->get('db')); - })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(120, 60))->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('visitor_log'))->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(120, 60))->add(new AdminAuthMiddleware()); $app->post('/admin/attendance/settings', function ($request, $response) use ($app) { return (new \App\Controllers\AttendanceController())->saveSettings($request, $response, $app->getContainer()->get('db')); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('visitor_log'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); $app->post('/admin/attendance/purge', function ($request, $response) use ($app) { return (new \App\Controllers\AttendanceController())->purge($request, $response, $app->getContainer()->get('db')); - })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + })->add(new \App\Middleware\FeatureGateMiddleware('visitor_log'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // API Libri by Author/Publisher $app->get('/api/libri/author/{id:\d+}', function ($request, $response, $args) use ($app) { diff --git a/app/Support/ConfigStore.php b/app/Support/ConfigStore.php index e48099c8a..ef99b6f85 100644 --- a/app/Support/ConfigStore.php +++ b/app/Support/ConfigStore.php @@ -153,6 +153,10 @@ public static function all(): array 'litespeed_catalog_ttl' => 120, 'litespeed_book_ttl' => 300, ], + // Optional 0.9.0 modules (Settings → Modules / Loans). The default + // is the upgrade one: an install without the row behaves as an + // upgrade (Features::DEFAULTS is the single source). + 'features' => array_map(static fn (string $v): bool => $v === '1', Features::DEFAULTS), ]; $localizedDefaults = self::getLocaleDefaultTexts(); @@ -673,6 +677,23 @@ private static function loadDatabaseSettings(): array } } + // features.* (optional modules): without this mapping a saved + // switch would be inert (allowlist trap). Only the known keys, + // as booleans. + if (!empty($raw['features'])) { + self::$dbSettingsCache['features'] = []; + foreach (array_keys(Features::DEFAULTS) as $featureKey) { + if (!isset($raw['features'][$featureKey])) { + continue; + } + $value = filter_var($raw['features'][$featureKey], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($value === null) { + $value = in_array((string) $raw['features'][$featureKey], ['1', 'true', 'yes'], true); + } + self::$dbSettingsCache['features'][$featureKey] = $value; + } + } + if (!empty($raw['system'])) { self::$dbSettingsCache['system'] = []; foreach ($raw['system'] as $key => $value) { diff --git a/app/Support/Features.php b/app/Support/Features.php new file mode 100644 index 000000000..d6883a6e5 --- /dev/null +++ b/app/Support/Features.php @@ -0,0 +1,197 @@ +` row in system_settings. + * + * DEFAULTS is the value an install without the row behaves with: the upgrade + * default, also what the migration seeds and the code fallback. FRESH is what + * a new installation starts with (installer/database/data_*.sql). Both lists + * are checked against the seeds, the migration and ConfigStore by + * tests/feature-toggles-registry.unit.php. + * + * The four circulation modules are also off while catalogue mode is on: that + * switch stays the master switch of circulation. + */ +final class Features +{ + /** @var array upgrade default = code fallback */ + public const DEFAULTS = [ + 'stocktake' => '0', + 'marc' => '1', + 'visitor_log' => '0', + 'catalogue_cards' => '1', + 'suppliers' => '0', + 'rda_types' => '0', + 'patron_cards' => '1', + 'user_import' => '1', + 'circulation_rules' => '1', + 'closures_calendar' => '1', + 'fines' => '0', + ]; + + /** @var array fresh-install default (data_*.sql seeds) */ + public const FRESH = [ + 'stocktake' => '1', + 'marc' => '1', + 'visitor_log' => '0', + 'catalogue_cards' => '1', + 'suppliers' => '1', + 'rda_types' => '1', + 'patron_cards' => '1', + 'user_import' => '1', + 'circulation_rules' => '1', + 'closures_calendar' => '1', + 'fines' => '0', + ]; + + /** Modules that belong to circulation: off whenever catalogue mode is on. */ + public const CIRCULATION = ['circulation_rules', 'closures_calendar', 'fines', 'visitor_log']; + + /** Modules saved from Settings → Modules (the other three live in the Loans tab). */ + public const MODULES_TAB = ['stocktake', 'marc', 'catalogue_cards', 'suppliers', 'rda_types', 'patron_cards', 'user_import', 'visitor_log']; + + /** Modules saved from Settings → Loans. */ + public const LOANS_TAB = ['circulation_rules', 'closures_calendar', 'fines']; + + /** @var array test seam: forced values, ahead of the stored setting */ + private static array $overrides = []; + + /** @return list */ + public static function keys(): array + { + return array_keys(self::DEFAULTS); + } + + /** + * Whether $feature is on. An unknown key is a programming error, never a + * module that is silently always on or always off. + */ + public static function enabled(string $feature): bool + { + if (!array_key_exists($feature, self::DEFAULTS)) { + throw new \InvalidArgumentException('Unknown feature: ' . $feature); + } + if (array_key_exists($feature, self::$overrides)) { + return self::$overrides[$feature]; + } + if (in_array($feature, self::CIRCULATION, true) && ConfigStore::isCatalogueMode()) { + return false; + } + return self::stored($feature); + } + + /** The saved switch, ignoring catalogue mode (what the settings form shows). */ + public static function stored(string $feature): bool + { + if (!array_key_exists($feature, self::DEFAULTS)) { + throw new \InvalidArgumentException('Unknown feature: ' . $feature); + } + if (array_key_exists($feature, self::$overrides)) { + return self::$overrides[$feature]; + } + $value = ConfigStore::get('features.' . $feature, self::DEFAULTS[$feature] === '1'); + if (is_bool($value)) { + return $value; + } + return in_array(strtolower(trim((string) $value)), ['1', 'true', 'yes', 'on'], true); + } + + /** + * The saved switches keyed by feature (Settings → Modules, Loans tab). + * + * @return array + */ + public static function snapshot(): array + { + $out = []; + foreach (self::keys() as $key) { + $out[$key] = self::stored($key); + } + return $out; + } + + /** + * The standard refusal of an admin page whose module is off: a 302 to the + * dashboard with the message, or null when the module is on. + */ + public static function requireOrRedirect(string $feature, Response $response): ?Response + { + if (self::enabled($feature)) { + return null; + } + $_SESSION['error_message'] = __('Funzione disattivata nelle impostazioni.'); + return $response->withHeader('Location', url('/admin/dashboard'))->withStatus(302); + } + + /** Label shown in the settings (Italian base string, translated by __()). */ + public static function label(string $feature): string + { + return match ($feature) { + 'stocktake' => __('Revisione inventariale'), + 'marc' => __('Import ed export MARC'), + 'catalogue_cards' => __('Schede catalografiche'), + 'suppliers' => __('Fornitori'), + 'rda_types' => __('Tipi RDA'), + 'patron_cards' => __('Tessere e foto dei lettori'), + 'user_import' => __('Importazione utenti da CSV'), + 'visitor_log' => __('Registro presenze'), + 'circulation_rules' => __('Regole per tipologia di lettore'), + 'closures_calendar' => __('Calendario chiusure'), + 'fines' => __('Multe e registro movimenti'), + default => throw new \InvalidArgumentException('Unknown feature: ' . $feature), + }; + } + + /** Help text shown under the label. */ + public static function help(string $feature): string + { + return match ($feature) { + 'stocktake' => __('Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo "Revisore inventario" resta ma non apre nulla.'), + 'marc' => __('Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.'), + 'catalogue_cards' => __('Stampa della scheda catalografica di un libro o di una selezione.'), + 'suppliers' => __('Anagrafica fornitori e campo "Fornitore" nella scheda libro e nell\'import CSV.'), + 'rda_types' => __('Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.'), + 'patron_cards' => __('Stampa delle tessere in PDF, foto dalla webcam e scheda Tessere nelle impostazioni.'), + 'user_import' => __('Caricamento massivo dei lettori con invito via email.'), + 'visitor_log' => __('Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.'), + 'circulation_rules' => __('Tipologie di lettore, matrice delle regole per tipo di materiale, copie in sola consultazione, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.'), + 'closures_calendar' => __('Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.'), + 'fines' => __('Multe per ritardo, addebiti, pagamenti, ricevute e blocco per debito. Spente, nessun importo viene addebitato e i movimenti non sono visibili.'), + default => throw new \InvalidArgumentException('Unknown feature: ' . $feature), + }; + } + + /** + * Force $feature on/off for the rest of the process (unit tests), or drop + * the forced value with null. + */ + public static function override(string $feature, ?bool $value): void + { + if (!array_key_exists($feature, self::DEFAULTS)) { + throw new \InvalidArgumentException('Unknown feature: ' . $feature); + } + if ($value === null) { + unset(self::$overrides[$feature]); + return; + } + self::$overrides[$feature] = $value; + } + + /** Force every module on (unit tests of the modules themselves). */ + public static function overrideAll(bool $value): void + { + foreach (self::keys() as $key) { + self::$overrides[$key] = $value; + } + } + + public static function resetOverrides(): void + { + self::$overrides = []; + } +} diff --git a/app/Views/partials/settings-sections.php b/app/Views/partials/settings-sections.php index 7caba4f35..394fe9a66 100644 --- a/app/Views/partials/settings-sections.php +++ b/app/Views/partials/settings-sections.php @@ -20,7 +20,18 @@ // guards every page and form the same way). Staff reaches the settings page, // but neither sees the section in the navigation nor reaches its tab: // ?tab=circulation falls back to the default section like an unknown tab. -if (\App\Support\Permissions::mayConfigure()) { +// With the patron types module switched off (Settings → Loans) the section +// is absent for everyone, like an unknown tab. +if (\App\Support\Permissions::mayConfigure() && \App\Support\Features::enabled('circulation_rules')) { $settingsCatalogue['circulation'] = ['group' => __('Biblioteca'), 'label' => __('Circolazione'), 'description' => __('Tipologie di lettore e regole di prestito per tipo di materiale.'), 'icon' => 'fa-id-badge']; } +// Optional modules: switching one off is library policy, so the tab follows +// the same administrators-only rule (staff reaches the settings, not this). +if (\App\Support\Permissions::mayConfigure()) { + $settingsCatalogue['modules'] = ['group' => __('Sito e sistema'), 'label' => __('Moduli'), 'description' => __('Attiva o disattiva i moduli opzionali della biblioteca.'), 'icon' => 'fa-toggle-on']; +} +// The library cards tab belongs to the patron cards module. +if (!\App\Support\Features::enabled('patron_cards')) { + unset($settingsCatalogue['cards']); +} return $settingsCatalogue; diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index fbfde5f49..3d8d03201 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -893,7 +893,7 @@ class="mt-1 w-4 h-4 aspect-square shrink-0 text-gray-900 focus:ring-gray-500"> - +
      @@ -957,6 +957,8 @@ class="sharing-provider-checkbox w-4 h-4 rounded text-gray-900 focus:ring-gray-5 + + diff --git a/app/Views/settings/loans-tab.php b/app/Views/settings/loans-tab.php index 8e91c45ab..442defa1f 100644 --- a/app/Views/settings/loans-tab.php +++ b/app/Views/settings/loans-tab.php @@ -6,6 +6,7 @@
      +
      @@ -86,6 +87,46 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr
      + +
      +
      +

      + + +

      +

      + +
      +
      + +
      +
      +
      + +
      +
      +
      +
      + +

      +
      + +
      +
      +
      +
      @@ -463,7 +525,26 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr
      -
      +
      +
      + +

      +
      + +
      +
      diff --git a/app/Views/settings/modules-tab.php b/app/Views/settings/modules-tab.php new file mode 100644 index 000000000..51a5fa961 --- /dev/null +++ b/app/Views/settings/modules-tab.php @@ -0,0 +1,79 @@ + $moduleSettings */ +$moduleGroups = [ + 'catalogue' => [ + 'icon' => 'fa-book', + 'title' => __('Catalogazione'), + 'text' => __('Strumenti di catalogazione che si aggiungono alla scheda libro, ai menu di import ed export e alla barra laterale.'), + 'features' => ['stocktake', 'marc', 'catalogue_cards', 'suppliers', 'rda_types'], + ], + 'users' => [ + 'icon' => 'fa-users', + 'title' => __('Utenti'), + 'text' => __('Strumenti del modulo utenti: stampa delle tessere e caricamento massivo dei lettori.'), + 'features' => ['patron_cards', 'user_import'], + ], + 'public' => [ + 'icon' => 'fa-door-open', + 'title' => __('Servizi al pubblico'), + 'text' => __('Servizi rivolti a chi entra in biblioteca.'), + 'features' => ['visitor_log'], + ], +]; +?> +
      + + + + $group): ?> +
      +
      +

      + + +

      +

      +
      +
      + +
      + +
      +
      +
      +
      +
      + $featureKey): $fieldId = 'feature_' . $featureKey; ?> +
      +
      + +

      +
      + +
      + +
      +
      + + +
      + +
      + +
      diff --git a/installer/database/data_da_DK.sql b/installer/database/data_da_DK.sql index 2c2496c72..a8878d944 100644 --- a/installer/database/data_da_DK.sql +++ b/installer/database/data_da_DK.sql @@ -11,6 +11,22 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('system', 'catalogue_mode', '0', 'Kun katalog-tilstand – deaktiverer lån, reservationer og ønskeliste') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +-- Optional modules (Settings > Modules / Loans): fresh-install defaults +-- (App\Support\Features::FRESH; the upgrade defaults are in migrate_0.9.0.sql). +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('features', 'stocktake', '1', 'Valgfrit modul: Lageroptælling'), +('features', 'marc', '1', 'Valgfrit modul: MARC-import og -eksport'), +('features', 'visitor_log', '0', 'Valgfrit modul: Besøgsregister'), +('features', 'catalogue_cards', '1', 'Valgfrit modul: Katalogkort'), +('features', 'suppliers', '1', 'Valgfrit modul: Leverandører'), +('features', 'rda_types', '1', 'Valgfrit modul: RDA-typer'), +('features', 'patron_cards', '1', 'Valgfrit modul: Lånerkort og fotos'), +('features', 'user_import', '1', 'Valgfrit modul: Brugerimport fra CSV'), +('features', 'circulation_rules', '1', 'Valgfrit modul: Regler efter lånertype'), +('features', 'closures_calendar', '1', 'Valgfrit modul: Lukkekalender'), +('features', 'fines', '0', 'Valgfrit modul: Gebyrer og bevægelser') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Antal dage til at afhente et godkendt lån, før det udløber') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_de_DE.sql b/installer/database/data_de_DE.sql index 6bce81e44..446ae1a48 100644 --- a/installer/database/data_de_DE.sql +++ b/installer/database/data_de_DE.sql @@ -194,6 +194,22 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('system', 'catalogue_mode', '0', 'Nur-Katalog-Modus - deaktiviert Ausleihen, Vormerkungen und Wunschliste') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +-- Optional modules (Settings > Modules / Loans): fresh-install defaults +-- (App\Support\Features::FRESH; the upgrade defaults are in migrate_0.9.0.sql). +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('features', 'stocktake', '1', 'Optionales Modul: Bestandsrevision'), +('features', 'marc', '1', 'Optionales Modul: MARC-Import und -Export'), +('features', 'visitor_log', '0', 'Optionales Modul: Besucherregister'), +('features', 'catalogue_cards', '1', 'Optionales Modul: Katalogkarten'), +('features', 'suppliers', '1', 'Optionales Modul: Lieferanten'), +('features', 'rda_types', '1', 'Optionales Modul: RDA-Typen'), +('features', 'patron_cards', '1', 'Optionales Modul: Leseausweise und Fotos'), +('features', 'user_import', '1', 'Optionales Modul: Benutzerimport aus CSV'), +('features', 'circulation_rules', '1', 'Optionales Modul: Regeln nach Lesertyp'), +('features', 'closures_calendar', '1', 'Optionales Modul: Schließtage-Kalender'), +('features', 'fines', '0', 'Optionales Modul: Gebühren und Kontobewegungen') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Tage zum Abholen einer genehmigten Ausleihe bevor sie verfällt') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_en_US.sql b/installer/database/data_en_US.sql index 45fec471a..efb72b1da 100644 --- a/installer/database/data_en_US.sql +++ b/installer/database/data_en_US.sql @@ -194,6 +194,22 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('system', 'catalogue_mode', '0', 'Catalogue-only mode - disables loans, reservations and wishlist') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +-- Optional modules (Settings > Modules / Loans): fresh-install defaults +-- (App\Support\Features::FRESH; the upgrade defaults are in migrate_0.9.0.sql). +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('features', 'stocktake', '1', 'Optional module: Inventory stocktake'), +('features', 'marc', '1', 'Optional module: MARC import and export'), +('features', 'visitor_log', '0', 'Optional module: Visitor log'), +('features', 'catalogue_cards', '1', 'Optional module: Catalogue cards'), +('features', 'suppliers', '1', 'Optional module: Suppliers'), +('features', 'rda_types', '1', 'Optional module: RDA types'), +('features', 'patron_cards', '1', 'Optional module: Patron cards and photos'), +('features', 'user_import', '1', 'Optional module: User import from CSV'), +('features', 'circulation_rules', '1', 'Optional module: Rules by patron type'), +('features', 'closures_calendar', '1', 'Optional module: Closures calendar'), +('features', 'fines', '0', 'Optional module: Fines and patron ledger') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Days to pick up an approved loan before it expires') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_fr_FR.sql b/installer/database/data_fr_FR.sql index a69f930d4..4a2e6fbfa 100644 --- a/installer/database/data_fr_FR.sql +++ b/installer/database/data_fr_FR.sql @@ -194,6 +194,22 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('system', 'catalogue_mode', '0', 'Mode catalogue uniquement - désactive les emprunts, réservations et liste de souhaits') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +-- Optional modules (Settings > Modules / Loans): fresh-install defaults +-- (App\Support\Features::FRESH; the upgrade defaults are in migrate_0.9.0.sql). +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('features', 'stocktake', '1', 'Module facultatif: Récolement'), +('features', 'marc', '1', 'Module facultatif: Import et export MARC'), +('features', 'visitor_log', '0', 'Module facultatif: Registre des visites'), +('features', 'catalogue_cards', '1', 'Module facultatif: Fiches de catalogue'), +('features', 'suppliers', '1', 'Module facultatif: Fournisseurs'), +('features', 'rda_types', '1', 'Module facultatif: Types RDA'), +('features', 'patron_cards', '1', 'Module facultatif: Cartes et photos des lecteurs'), +('features', 'user_import', '1', 'Module facultatif: Import des utilisateurs depuis un CSV'), +('features', 'circulation_rules', '1', 'Module facultatif: Règles par type de lecteur'), +('features', 'closures_calendar', '1', 'Module facultatif: Calendrier des fermetures'), +('features', 'fines', '0', 'Module facultatif: Amendes et registre des mouvements') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Jours pour récupérer un emprunt approuvé avant expiration') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/data_it_IT.sql b/installer/database/data_it_IT.sql index 48a59ce0e..f517a7410 100644 --- a/installer/database/data_it_IT.sql +++ b/installer/database/data_it_IT.sql @@ -11,6 +11,22 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('system', 'catalogue_mode', '0', 'Modalità solo catalogo - disabilita prestiti, prenotazioni e wishlist') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); +-- Optional modules (Settings > Modules / Loans): fresh-install defaults +-- (App\Support\Features::FRESH; the upgrade defaults are in migrate_0.9.0.sql). +INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES +('features', 'stocktake', '1', 'Modulo opzionale: Revisione inventariale'), +('features', 'marc', '1', 'Modulo opzionale: Import ed export MARC'), +('features', 'visitor_log', '0', 'Modulo opzionale: Registro presenze'), +('features', 'catalogue_cards', '1', 'Modulo opzionale: Schede catalografiche'), +('features', 'suppliers', '1', 'Modulo opzionale: Fornitori'), +('features', 'rda_types', '1', 'Modulo opzionale: Tipi RDA'), +('features', 'patron_cards', '1', 'Modulo opzionale: Tessere e foto dei lettori'), +('features', 'user_import', '1', 'Modulo opzionale: Importazione utenti da CSV'), +('features', 'circulation_rules', '1', 'Modulo opzionale: Regole per tipologia di lettore'), +('features', 'closures_calendar', '1', 'Modulo opzionale: Calendario chiusure'), +('features', 'fines', '0', 'Modulo opzionale: Multe e registro movimenti') +ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); + INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES ('loans', 'pickup_expiry_days', '3', 'Giorni per ritirare un prestito approvato prima che scada') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); diff --git a/installer/database/migrations/migrate_0.9.0.sql b/installer/database/migrations/migrate_0.9.0.sql index ad7699ba8..f8b01231e 100644 --- a/installer/database/migrations/migrate_0.9.0.sql +++ b/installer/database/migrations/migrate_0.9.0.sql @@ -348,3 +348,79 @@ EXECUTE fl_stmt; EXECUTE fl_stmt; DEALLOCATE PREPARE fl_stmt; -- ── end fines-ledger ── + +-- ── feature toggles ── +-- One features. row per optional 0.9.0 module (App\Support\Features), with +-- its UPGRADE default, which is also the code fallback. Insert-if-missing (as +-- migrate_0.7.64-rc.1.sql): a value the operator already saved is never +-- overwritten, and a second run is a no-op. The values MUST match +-- Features::DEFAULTS (tests/feature-toggles-registry.unit.php checks it). +-- Modules that are on by default: reachable only from an explicit action inside +-- an existing module, they change nothing until used. +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'marc', '1', 'Optional module: MARC import and export' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'marc'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'catalogue_cards', '1', 'Optional module: Catalogue cards' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'catalogue_cards'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'patron_cards', '1', 'Optional module: Patron cards and photos' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'patron_cards'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'user_import', '1', 'Optional module: User import from CSV' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'user_import'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'circulation_rules', '1', 'Optional module: Rules by patron type' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'circulation_rules'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'closures_calendar', '1', 'Optional module: Closures calendar' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'closures_calendar'); +-- Off by default on an upgrade (new sidebar entries, new cataloguing fields, +-- money, personal data), EXCEPT where the library already has data for the +-- module (an upgrade from a 0.9.0 release candidate that used it): if you used +-- it, it stays on. Each probe is guarded on information_schema, so the section +-- also runs where an earlier section has not created the table. +SET @ft_has = (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'inventario_sessioni'); +SET @ft_sql = IF(@ft_has > 0, 'SELECT IF(EXISTS(SELECT 1 FROM inventario_sessioni), ''1'', ''0'') INTO @ft_val', 'SELECT ''0'' INTO @ft_val'); +PREPARE ft_stmt FROM @ft_sql; +EXECUTE ft_stmt; +DEALLOCATE PREPARE ft_stmt; +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'stocktake', @ft_val, 'Optional module: Inventory stocktake' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'stocktake'); +SET @ft_has = (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fornitori'); +SET @ft_sql = IF(@ft_has > 0, 'SELECT IF(EXISTS(SELECT 1 FROM fornitori), ''1'', ''0'') INTO @ft_val', 'SELECT ''0'' INTO @ft_val'); +PREPARE ft_stmt FROM @ft_sql; +EXECUTE ft_stmt; +DEALLOCATE PREPARE ft_stmt; +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'suppliers', @ft_val, 'Optional module: Suppliers' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'suppliers'); +SET @ft_has = (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'presenze'); +SET @ft_sql = IF(@ft_has > 0, 'SELECT IF(EXISTS(SELECT 1 FROM presenze), ''1'', ''0'') INTO @ft_val', 'SELECT ''0'' INTO @ft_val'); +PREPARE ft_stmt FROM @ft_sql; +EXECUTE ft_stmt; +DEALLOCATE PREPARE ft_stmt; +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'visitor_log', @ft_val, 'Optional module: Visitor log' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'visitor_log'); +SET @ft_has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND COLUMN_NAME IN ('rda_content_type', 'rda_media_type', 'rda_carrier_type')); +SET @ft_sql = IF(@ft_has = 3, 'SELECT IF(EXISTS(SELECT 1 FROM libri WHERE deleted_at IS NULL AND (rda_content_type IS NOT NULL OR rda_media_type IS NOT NULL OR rda_carrier_type IS NOT NULL)), ''1'', ''0'') INTO @ft_val', 'SELECT ''0'' INTO @ft_val'); +PREPARE ft_stmt FROM @ft_sql; +EXECUTE ft_stmt; +DEALLOCATE PREPARE ft_stmt; +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'rda_types', @ft_val, 'Optional module: RDA types' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'rda_types'); +-- Fines: a ledger row the legacy-penalty conversion did not write (key penalty:), +-- or a daily fine already configured. +SET @ft_has = (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'movimenti_lettore'); +SET @ft_sql = IF(@ft_has > 0, 'SELECT IF(EXISTS(SELECT 1 FROM movimenti_lettore WHERE chiave_idempotenza IS NULL OR chiave_idempotenza NOT LIKE ''penalty:%''), ''1'', ''0'') INTO @ft_val', 'SELECT ''0'' INTO @ft_val'); +PREPARE ft_stmt FROM @ft_sql; +EXECUTE ft_stmt; +DEALLOCATE PREPARE ft_stmt; +SET @ft_val = IF(@ft_val = '1' OR EXISTS(SELECT 1 FROM system_settings WHERE category = 'loans' AND setting_key = 'daily_fine' AND setting_value REGEXP '^[0-9]{1,8}([.,][0-9]{1,2})?$' AND CAST(REPLACE(setting_value, ',', '.') AS DECIMAL(10,2)) > 0), '1', '0'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'fines', @ft_val, 'Optional module: Fines and patron ledger' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'fines'); +-- ── end feature toggles ── diff --git a/locale/da_DK.json b/locale/da_DK.json index 2ddb5ca5c..98f140a92 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -9047,5 +9047,34 @@ "Fornitore non assegnato": "Leverandør ikke tildelt", "In prestito o in attesa di ritiro": "Udlånt eller klar til afhentning", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Eksemplaret står som udlånt eller klar til afhentning: tjek udlånet.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallet sammenlignes alfabetisk, ikke numerisk: A.10 kommer før A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallet sammenlignes alfabetisk, ikke numerisk: A.10 kommer før A.2.", + "Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.": "Leverandørregister og feltet \"Leverandør\" i bogformularen og i CSV-importen.", + "Attiva o disattiva i moduli opzionali della biblioteca.": "Slå bibliotekets valgfrie moduler til eller fra.", + "Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.": "Felter for RDA-indholds-, medie- og bærertype i bogformularen. Slået fra bevares værdierne, og eksporter udleder dem af materialetypen.", + "Caricamento massivo dei lettori con invito via email.": "Masseindlæsning af lånere med invitation via e-mail.", + "Funzione disattivata nelle impostazioni.": "Denne funktion er slået fra i indstillingerne.", + "Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.": "Lukkedage, helligdage og ICS-import: afleveringsdatoer springer lukkedage over. Slået fra anvendes kalenderen ikke.", + "Import ed export MARC": "MARC-import og -eksport", + "Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.": "Masseimport af MARC21/UNIMARC/danMARC2-poster, katalogeksport og kopikatalogisering via SRU.", + "Importazione utenti da CSV": "Brugerimport fra CSV", + "Moduli": "Moduler", + "Moduli aggiornati correttamente.": "Modulerne er opdateret.", + "Multe e registro movimenti": "Gebyrer og bevægelser", + "Multe per ritardo, addebiti, pagamenti, ricevute e blocco per debito. Spente, nessun importo viene addebitato e i movimenti non sono visibili.": "Gebyrer for forsinkelse, opkrævninger, betalinger, kvitteringer og spærring for gæld. Slået fra opkræves intet, og bevægelserne er ikke synlige.", + "Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.": "Log over besøg i biblioteket med tidsbegrænset opbevaring. Den indeholder personoplysninger: slå den kun til, hvis biblioteket faktisk bruger den.", + "Regole per tipologia di lettore": "Regler efter lånertype", + "Salva moduli": "Gem moduler", + "Servizi al pubblico": "Publikumsservice", + "Servizi rivolti a chi entra in biblioteca.": "Tjenester for dem, der kommer ind i biblioteket.", + "Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.": "Revisionssessioner med scanning af eksemplarer, resultater og rapporter. Slået fra: intet menupunkt og ingen sider; rollen \"Lagerrevisor\" består, men åbner intet.", + "Solo gli amministratori possono attivare o disattivare i moduli.": "Kun administratorer kan slå moduler til eller fra.", + "Stampa della scheda catalografica di un libro o di una selezione.": "Udskrivning af katalogkortet for en bog eller et udvalg.", + "Stampa delle tessere in PDF, foto dalla webcam e scheda Tessere nelle impostazioni.": "Lånerkort som PDF, fotos fra webkamera og fanen Kort i indstillingerne.", + "Strumenti del modulo utenti: stampa delle tessere e caricamento massivo dei lettori.": "Værktøjer i brugermodulet: udskrivning af kort og masseindlæsning af lånere.", + "Strumenti di catalogazione che si aggiungono alla scheda libro, ai menu di import ed export e alla barra laterale.": "Katalogiseringsværktøjer i bogposten, i import- og eksportmenuerne og i sidepanelet.", + "Tessere e foto dei lettori": "Lånerkort og fotos", + "Tipi RDA": "RDA-typer", + "Tipologie di lettore con regole proprie per tipo di materiale. Le regole si configurano nella sezione Circolazione delle impostazioni.": "Lånertyper med egne regler for hver materialetype. Reglerne konfigureres i afsnittet Cirkulation i indstillingerne.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, copie in sola consultazione, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Lånertyper, regelmatrix efter materialetype, eksemplarer kun til læsesal, grænser for reserveringer og automatisk kortvarighed. Slået fra gælder kun de globale regler på denne fane.", + "Un modulo spento scompare dai menu e le sue pagine non sono raggiungibili. I dati già salvati restano e tornano visibili alla riaccensione.": "Et modul, der er slået fra, forsvinder fra menuerne, og dets sider kan ikke nås. Data, der allerede er gemt, bevares og bliver synlige igen, når modulet slås til." } diff --git a/locale/de_DE.json b/locale/de_DE.json index d5c977198..be8357e72 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -9047,5 +9047,34 @@ "Fornitore non assegnato": "Lieferant nicht zugewiesen", "In prestito o in attesa di ritiro": "Ausgeliehen oder zur Abholung bereit", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "Das Exemplar ist als ausgeliehen oder zur Abholung bereit erfasst: Prüfe die Ausleihe.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Der Bereich wird alphabetisch verglichen, nicht numerisch: A.10 steht vor A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Der Bereich wird alphabetisch verglichen, nicht numerisch: A.10 steht vor A.2.", + "Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.": "Lieferantenverzeichnis und das Feld „Lieferant“ im Buchformular und im CSV-Import.", + "Attiva o disattiva i moduli opzionali della biblioteca.": "Optionale Module der Bibliothek ein- oder ausschalten.", + "Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.": "Felder für RDA-Inhalts-, Medien- und Datenträgertyp im Buchformular. Ausgeschaltet bleiben die Werte gespeichert und Exporte leiten sie aus dem Materialtyp ab.", + "Caricamento massivo dei lettori con invito via email.": "Massenimport von Lesern mit Einladung per E-Mail.", + "Funzione disattivata nelle impostazioni.": "Diese Funktion ist in den Einstellungen deaktiviert.", + "Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.": "Schließtage, Feiertage und ICS-Import: Fälligkeiten überspringen Schließtage. Ausgeschaltet wird der Kalender nicht angewendet.", + "Import ed export MARC": "MARC-Import und -Export", + "Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.": "Massenimport von MARC21/UNIMARC/danMARC2-Datensätzen, Katalogexport und Fremddatenübernahme über SRU.", + "Importazione utenti da CSV": "Benutzerimport aus CSV", + "Moduli": "Module", + "Moduli aggiornati correttamente.": "Module erfolgreich aktualisiert.", + "Multe e registro movimenti": "Gebühren und Kontobewegungen", + "Multe per ritardo, addebiti, pagamenti, ricevute e blocco per debito. Spente, nessun importo viene addebitato e i movimenti non sono visibili.": "Säumnisgebühren, Belastungen, Zahlungen, Quittungen und Sperre wegen Schulden. Ausgeschaltet wird nichts berechnet und die Bewegungen sind nicht sichtbar.", + "Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.": "Protokoll der Bibliotheksbesuche mit befristeter Aufbewahrung. Es enthält personenbezogene Daten: nur einschalten, wenn die Bibliothek es wirklich nutzt.", + "Regole per tipologia di lettore": "Regeln nach Lesertyp", + "Salva moduli": "Module speichern", + "Servizi al pubblico": "Publikumsdienste", + "Servizi rivolti a chi entra in biblioteca.": "Dienste für die Besucher der Bibliothek.", + "Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.": "Revisionssitzungen mit Scan der Exemplare, Ergebnissen und Berichten. Ausgeschaltet: kein Menüeintrag und keine Seiten; die Rolle „Inventurprüfer“ bleibt, öffnet aber nichts.", + "Solo gli amministratori possono attivare o disattivare i moduli.": "Nur Administratoren können Module ein- oder ausschalten.", + "Stampa della scheda catalografica di un libro o di una selezione.": "Druck der Katalogkarte eines Buches oder einer Auswahl.", + "Stampa delle tessere in PDF, foto dalla webcam e scheda Tessere nelle impostazioni.": "Leseausweise als PDF, Fotos per Webcam und der Reiter Ausweise in den Einstellungen.", + "Strumenti del modulo utenti: stampa delle tessere e caricamento massivo dei lettori.": "Werkzeuge des Benutzermoduls: Ausweisdruck und Massenimport von Lesern.", + "Strumenti di catalogazione che si aggiungono alla scheda libro, ai menu di import ed export e alla barra laterale.": "Katalogisierungswerkzeuge im Buchdatensatz, in den Import- und Exportmenüs und in der Seitenleiste.", + "Tessere e foto dei lettori": "Leseausweise und Fotos", + "Tipi RDA": "RDA-Typen", + "Tipologie di lettore con regole proprie per tipo di materiale. Le regole si configurano nella sezione Circolazione delle impostazioni.": "Lesertypen mit eigenen Regeln je Materialtyp. Die Regeln werden im Bereich Ausleihe der Einstellungen konfiguriert.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, copie in sola consultazione, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Lesertypen, Regelmatrix nach Materialtyp, Präsenzexemplare, Vormerkungslimits und automatische Ausweisdauer. Ausgeschaltet gelten nur die globalen Regeln dieses Reiters.", + "Un modulo spento scompare dai menu e le sue pagine non sono raggiungibili. I dati già salvati restano e tornano visibili alla riaccensione.": "Ein ausgeschaltetes Modul verschwindet aus den Menüs und seine Seiten sind nicht erreichbar. Bereits gespeicherte Daten bleiben erhalten und sind nach dem Wiedereinschalten wieder sichtbar." } diff --git a/locale/en_US.json b/locale/en_US.json index 599cba2e0..2e621119e 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -9047,5 +9047,34 @@ "Fornitore non assegnato": "Supplier not assigned", "In prestito o in attesa di ritiro": "On loan or awaiting pickup", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "The copy is recorded as on loan or awaiting pickup: check the loan.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "The range is compared alphabetically, not numerically: A.10 comes before A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "The range is compared alphabetically, not numerically: A.10 comes before A.2.", + "Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.": "Supplier records and the \"Supplier\" field in the book form and in the CSV import.", + "Attiva o disattiva i moduli opzionali della biblioteca.": "Turn the library's optional modules on or off.", + "Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.": "RDA content, media and carrier type fields in the book form. When off, the values stay stored and exports derive them from the material type.", + "Caricamento massivo dei lettori con invito via email.": "Bulk upload of patrons with an email invitation.", + "Funzione disattivata nelle impostazioni.": "This feature is turned off in the settings.", + "Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.": "Closed days, holidays and ICS import: due dates skip closed days. When off, the calendar is not applied.", + "Import ed export MARC": "MARC import and export", + "Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.": "Bulk import of MARC21/UNIMARC/danMARC2 records, catalogue export and copy cataloguing via SRU.", + "Importazione utenti da CSV": "User import from CSV", + "Moduli": "Modules", + "Moduli aggiornati correttamente.": "Modules updated successfully.", + "Multe e registro movimenti": "Fines and patron ledger", + "Multe per ritardo, addebiti, pagamenti, ricevute e blocco per debito. Spente, nessun importo viene addebitato e i movimenti non sono visibili.": "Late fines, charges, payments, receipts and the debt block. When off, nothing is charged and the movements are not visible.", + "Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.": "Log of library visits with time-limited retention. It holds personal data: turn it on only if the library really uses it.", + "Regole per tipologia di lettore": "Rules by patron type", + "Salva moduli": "Save modules", + "Servizi al pubblico": "Public services", + "Servizi rivolti a chi entra in biblioteca.": "Services for people who come into the library.", + "Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.": "Stocktake sessions with copy scanning, outcomes and reports. When off: no menu entry and no pages; the \"Stocktake auditor\" role remains but opens nothing.", + "Solo gli amministratori possono attivare o disattivare i moduli.": "Only administrators can turn modules on or off.", + "Stampa della scheda catalografica di un libro o di una selezione.": "Printing of the catalogue card of a book or of a selection.", + "Stampa delle tessere in PDF, foto dalla webcam e scheda Tessere nelle impostazioni.": "PDF library cards, webcam photos and the Cards tab in the settings.", + "Strumenti del modulo utenti: stampa delle tessere e caricamento massivo dei lettori.": "Tools of the users module: card printing and bulk patron upload.", + "Strumenti di catalogazione che si aggiungono alla scheda libro, ai menu di import ed export e alla barra laterale.": "Cataloguing tools added to the book record, to the import and export menus and to the sidebar.", + "Tessere e foto dei lettori": "Patron cards and photos", + "Tipi RDA": "RDA types", + "Tipologie di lettore con regole proprie per tipo di materiale. Le regole si configurano nella sezione Circolazione delle impostazioni.": "Patron types with their own rules per material type. The rules are configured in the Circulation section of the settings.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, copie in sola consultazione, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Patron types, the rules matrix by material type, reference-only copies, reservation limits and automatic card duration. When off, only the global rules of this tab apply.", + "Un modulo spento scompare dai menu e le sue pagine non sono raggiungibili. I dati già salvati restano e tornano visibili alla riaccensione.": "A module that is off disappears from the menus and its pages cannot be reached. The data already saved stays and is visible again once the module is back on." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index b1db74581..33df93705 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -9047,5 +9047,34 @@ "Fornitore non assegnato": "Fournisseur non attribué", "In prestito o in attesa di ritiro": "Emprunté ou en attente de retrait", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "L'exemplaire est enregistré comme prêté ou en attente de retrait : vérifiez le prêt.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "L'intervalle est comparé par ordre alphabétique, pas numérique : A.10 vient avant A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "L'intervalle est comparé par ordre alphabétique, pas numérique : A.10 vient avant A.2.", + "Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.": "Fiches fournisseurs et champ « Fournisseur » dans la fiche du livre et dans l'import CSV.", + "Attiva o disattiva i moduli opzionali della biblioteca.": "Activez ou désactivez les modules facultatifs de la bibliothèque.", + "Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.": "Champs type de contenu, de média et de support RDA dans la fiche du livre. Désactivés, les valeurs restent enregistrées et les exports les déduisent du type de document.", + "Caricamento massivo dei lettori con invito via email.": "Chargement en masse des lecteurs avec invitation par e-mail.", + "Funzione disattivata nelle impostazioni.": "Cette fonction est désactivée dans les paramètres.", + "Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.": "Jours de fermeture, jours fériés et import ICS : les échéances sautent les jours fermés. Désactivé, le calendrier n'est pas appliqué.", + "Import ed export MARC": "Import et export MARC", + "Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.": "Import en masse de notices MARC21/UNIMARC/danMARC2, export du catalogue et catalogage dérivé via SRU.", + "Importazione utenti da CSV": "Import des utilisateurs depuis un CSV", + "Moduli": "Modules", + "Moduli aggiornati correttamente.": "Modules mis à jour avec succès.", + "Multe e registro movimenti": "Amendes et registre des mouvements", + "Multe per ritardo, addebiti, pagamenti, ricevute e blocco per debito. Spente, nessun importo viene addebitato e i movimenti non sono visibili.": "Amendes de retard, débits, paiements, reçus et blocage pour dette. Désactivées, aucun montant n'est débité et les mouvements ne sont pas visibles.", + "Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.": "Registre des entrées à la bibliothèque avec conservation limitée dans le temps. Il contient des données personnelles : activez-le seulement si la bibliothèque l'utilise vraiment.", + "Regole per tipologia di lettore": "Règles par type de lecteur", + "Salva moduli": "Enregistrer les modules", + "Servizi al pubblico": "Services au public", + "Servizi rivolti a chi entra in biblioteca.": "Services destinés aux personnes qui entrent dans la bibliothèque.", + "Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.": "Sessions de récolement avec scan des exemplaires, résultats et rapports. Désactivée : ni entrée de menu ni pages ; le rôle « Réviseur d'inventaire » reste mais n'ouvre rien.", + "Solo gli amministratori possono attivare o disattivare i moduli.": "Seuls les administrateurs peuvent activer ou désactiver les modules.", + "Stampa della scheda catalografica di un libro o di una selezione.": "Impression de la fiche catalographique d'un livre ou d'une sélection.", + "Stampa delle tessere in PDF, foto dalla webcam e scheda Tessere nelle impostazioni.": "Cartes de lecteur en PDF, photos par webcam et onglet Cartes dans les paramètres.", + "Strumenti del modulo utenti: stampa delle tessere e caricamento massivo dei lettori.": "Outils du module utilisateurs : impression des cartes et chargement en masse des lecteurs.", + "Strumenti di catalogazione che si aggiungono alla scheda libro, ai menu di import ed export e alla barra laterale.": "Outils de catalogage ajoutés à la fiche du livre, aux menus d'import et d'export et à la barre latérale.", + "Tessere e foto dei lettori": "Cartes et photos des lecteurs", + "Tipi RDA": "Types RDA", + "Tipologie di lettore con regole proprie per tipo di materiale. Le regole si configurano nella sezione Circolazione delle impostazioni.": "Types de lecteur avec leurs propres règles par type de document. Les règles se configurent dans la section Circulation des paramètres.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, copie in sola consultazione, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Types de lecteur, matrice des règles par type de document, exemplaires en consultation sur place, limites de réservation et durée automatique de la carte. Désactivées, seules les règles globales de cet onglet s'appliquent.", + "Un modulo spento scompare dai menu e le sue pagine non sono raggiungibili. I dati già salvati restano e tornano visibili alla riaccensione.": "Un module désactivé disparaît des menus et ses pages ne sont plus accessibles. Les données déjà enregistrées restent et redeviennent visibles à la réactivation." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 53f18f70b..d8b27a9eb 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -9047,5 +9047,34 @@ "Fornitore non assegnato": "Fornitore non assegnato", "In prestito o in attesa di ritiro": "In prestito o in attesa di ritiro", "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.": "La copia risulta in prestito o in attesa di ritiro: verifica il prestito.", - "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2." + "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.": "Intervallo confrontato in ordine alfabetico, non numerico: A.10 viene prima di A.2.", + "Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.": "Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.", + "Attiva o disattiva i moduli opzionali della biblioteca.": "Attiva o disattiva i moduli opzionali della biblioteca.", + "Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.": "Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.", + "Caricamento massivo dei lettori con invito via email.": "Caricamento massivo dei lettori con invito via email.", + "Funzione disattivata nelle impostazioni.": "Funzione disattivata nelle impostazioni.", + "Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.": "Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.", + "Import ed export MARC": "Import ed export MARC", + "Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.": "Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.", + "Importazione utenti da CSV": "Importazione utenti da CSV", + "Moduli": "Moduli", + "Moduli aggiornati correttamente.": "Moduli aggiornati correttamente.", + "Multe e registro movimenti": "Multe e registro movimenti", + "Multe per ritardo, addebiti, pagamenti, ricevute e blocco per debito. Spente, nessun importo viene addebitato e i movimenti non sono visibili.": "Multe per ritardo, addebiti, pagamenti, ricevute e blocco per debito. Spente, nessun importo viene addebitato e i movimenti non sono visibili.", + "Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.": "Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.", + "Regole per tipologia di lettore": "Regole per tipologia di lettore", + "Salva moduli": "Salva moduli", + "Servizi al pubblico": "Servizi al pubblico", + "Servizi rivolti a chi entra in biblioteca.": "Servizi rivolti a chi entra in biblioteca.", + "Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.": "Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.", + "Solo gli amministratori possono attivare o disattivare i moduli.": "Solo gli amministratori possono attivare o disattivare i moduli.", + "Stampa della scheda catalografica di un libro o di una selezione.": "Stampa della scheda catalografica di un libro o di una selezione.", + "Stampa delle tessere in PDF, foto dalla webcam e scheda Tessere nelle impostazioni.": "Stampa delle tessere in PDF, foto dalla webcam e scheda Tessere nelle impostazioni.", + "Strumenti del modulo utenti: stampa delle tessere e caricamento massivo dei lettori.": "Strumenti del modulo utenti: stampa delle tessere e caricamento massivo dei lettori.", + "Strumenti di catalogazione che si aggiungono alla scheda libro, ai menu di import ed export e alla barra laterale.": "Strumenti di catalogazione che si aggiungono alla scheda libro, ai menu di import ed export e alla barra laterale.", + "Tessere e foto dei lettori": "Tessere e foto dei lettori", + "Tipi RDA": "Tipi RDA", + "Tipologie di lettore con regole proprie per tipo di materiale. Le regole si configurano nella sezione Circolazione delle impostazioni.": "Tipologie di lettore con regole proprie per tipo di materiale. Le regole si configurano nella sezione Circolazione delle impostazioni.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, copie in sola consultazione, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Tipologie di lettore, matrice delle regole per tipo di materiale, copie in sola consultazione, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.", + "Un modulo spento scompare dai menu e le sue pagine non sono raggiungibili. I dati già salvati restano e tornano visibili alla riaccensione.": "Un modulo spento scompare dai menu e le sue pagine non sono raggiungibili. I dati già salvati restano e tornano visibili alla riaccensione." } From 673101c5b5a6a62ab5441b8da1f207cd9d9a9b41 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:23:34 +0100 Subject: [PATCH 194/398] Feature toggles: every module off is gone from menus, pages, services and cron Sidebar entries, book list and book record actions, user list and record actions, form fields and settings tabs follow their module. The controllers re-check the switch after the route gate. Services carry the switch for every channel at once: CirculationPolicy::rulesActive() (patron types, rules, consultation copies), PatronLedger::active() (charges, debt block, previews, backfill, fine emails), LibraryCalendar::fromDatabase() (an empty calendar), RDA overrides and supplier writes. The visitor log purge waits while the module is off, the statistics sections follow their module, and the Presenze entry now also disappears in catalogue mode. Mobile API: /health.features gains fines, closures_calendar and circulation_rules; /me/movements answers 404 feature_disabled with fines off. The z39-server copy cataloguing routes follow the MARC module. --- .../Admin/LibraryCalendarController.php | 3 ++ app/Controllers/AttendanceController.php | 18 ++++++++++ app/Controllers/CatalogueCardController.php | 6 ++++ .../CirculationSettingsController.php | 3 ++ app/Controllers/CsvImportController.php | 3 +- app/Controllers/InventoryController.php | 30 +++++++++++++++++ app/Controllers/LibriController.php | 3 +- app/Controllers/MarcExportController.php | 3 ++ app/Controllers/MarcImportController.php | 12 ++++++- app/Controllers/PatronCardsController.php | 15 +++++++++ app/Controllers/PatronLedgerController.php | 6 ++++ app/Controllers/PrestitiController.php | 3 +- app/Controllers/SuppliersController.php | 18 ++++++++++ app/Controllers/UserDashboardController.php | 11 ++++--- app/Controllers/UserImportController.php | 18 ++++++++++ app/Controllers/UsersController.php | 2 ++ app/Services/CirculationPolicy.php | 29 +++++++++++----- app/Services/PatronLedger.php | 33 ++++++++++++++++++- app/Support/CataloguingExtras.php | 8 +++-- app/Support/LibraryCalendar.php | 7 ++++ app/Support/LoanEligibility.php | 7 ++-- app/Support/MaintenanceService.php | 4 ++- app/Support/Marc/MarcBookImporter.php | 4 ++- app/Support/Marc/MarcMediaType.php | 4 ++- app/Support/NotificationService.php | 3 ++ app/Support/RdaTypes.php | 6 ++++ app/Support/StatisticsSections.php | 15 ++++++++- app/Views/admin/stats.php | 2 ++ app/Views/dashboard/permissions.php | 2 +- app/Views/layout.php | 8 ++++- app/Views/libri/index.php | 16 +++++++-- app/Views/libri/partials/book_form.php | 4 +++ app/Views/libri/scheda_libro.php | 10 ++++-- app/Views/partials/role-navigation.php | 2 ++ app/Views/prenotazioni/crea_prenotazione.php | 2 ++ app/Views/utenti/dettagli_utente.php | 6 +++- app/Views/utenti/index.php | 13 ++++++-- app/Views/utenti/modifica_utente.php | 4 +++ .../plugins/mobile-api/MobileApiPlugin.php | 20 ++++++++++- storage/plugins/mobile-api/plugin.json | 2 +- .../src/Controllers/HealthController.php | 6 ++++ .../src/Controllers/OpenApiController.php | 4 +++ .../plugins/z39-server/Z39ServerPlugin.php | 6 ++-- 43 files changed, 338 insertions(+), 43 deletions(-) diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php index 211f4ec59..000989d8f 100644 --- a/app/Controllers/Admin/LibraryCalendarController.php +++ b/app/Controllers/Admin/LibraryCalendarController.php @@ -258,6 +258,9 @@ private function calendarChanged(\mysqli $db): void private function guard(Response $response): ?Response { + if (($off = \App\Support\Features::requireOrRedirect('closures_calendar', $response)) !== null) { + return $off; + } if (\App\Support\Permissions::mayConfigure()) { return null; } diff --git a/app/Controllers/AttendanceController.php b/app/Controllers/AttendanceController.php index 6cc92351b..0cf628e9a 100644 --- a/app/Controllers/AttendanceController.php +++ b/app/Controllers/AttendanceController.php @@ -19,6 +19,9 @@ class AttendanceController public function index(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('visitor_log', $response)) !== null) { + return $off; + } $repo = new AttendanceRepository($db); $days = $this->period($request); $data = [ @@ -45,6 +48,9 @@ public function index(Request $request, Response $response, mysqli $db): Respons public function scan(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('visitor_log', $response)) !== null) { + return $off; + } $body = (array) $request->getParsedBody(); $repo = new AttendanceRepository($db); $card = is_string($body['codice_tessera'] ?? null) ? $body['codice_tessera'] : ''; @@ -69,6 +75,9 @@ public function scan(Request $request, Response $response, mysqli $db): Response public function guest(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('visitor_log', $response)) !== null) { + return $off; + } $body = (array) $request->getParsedBody(); $repo = new AttendanceRepository($db); $name = is_string($body['nome_ospite'] ?? null) ? $body['nome_ospite'] : null; @@ -86,6 +95,9 @@ public function guest(Request $request, Response $response, mysqli $db): Respons /** Retention setting. AdminAuthMiddleware admits staff: this one is admin-only. */ public function saveSettings(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('visitor_log', $response)) !== null) { + return $off; + } if (!$this->isAdmin()) { $_SESSION['error_message'] = __('Operazione riservata agli amministratori.'); return $this->back($response); @@ -102,6 +114,9 @@ public function saveSettings(Request $request, Response $response, mysqli $db): /** Apply the retention now instead of waiting for the next maintenance run. */ public function purge(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('visitor_log', $response)) !== null) { + return $off; + } if (!$this->isAdmin()) { $_SESSION['error_message'] = __('Operazione riservata agli amministratori.'); return $this->back($response); @@ -115,6 +130,9 @@ public function purge(Request $request, Response $response, mysqli $db): Respons /** Daily counts for the selected period as CSV. */ public function exportCsv(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('visitor_log', $response)) !== null) { + return $off; + } $days = $this->period($request); $rows = (new AttendanceRepository($db))->dailyCounts($days); $out = fopen('php://temp', 'r+'); diff --git a/app/Controllers/CatalogueCardController.php b/app/Controllers/CatalogueCardController.php index 421f198a9..dd93e1e85 100644 --- a/app/Controllers/CatalogueCardController.php +++ b/app/Controllers/CatalogueCardController.php @@ -21,6 +21,9 @@ class CatalogueCardController public function single(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('catalogue_cards', $response)) !== null) { + return $off; + } $book = (new BookRepository($db))->getById($id); if ($book === null) { $_SESSION['error_message'] = __('Libro non trovato.'); @@ -31,6 +34,9 @@ public function single(Request $request, Response $response, mysqli $db, int $id public function bulk(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('catalogue_cards', $response)) !== null) { + return $off; + } $ids = self::parseIds((string) ($request->getQueryParams()['ids'] ?? '')); $repo = new BookRepository($db); $books = []; diff --git a/app/Controllers/CirculationSettingsController.php b/app/Controllers/CirculationSettingsController.php index 324bdb4ec..180607247 100644 --- a/app/Controllers/CirculationSettingsController.php +++ b/app/Controllers/CirculationSettingsController.php @@ -135,6 +135,9 @@ public static function mediaTypes(): array */ private function guard(Response $response): ?Response { + if (($off = \App\Support\Features::requireOrRedirect('circulation_rules', $response)) !== null) { + return $off; + } if (\App\Support\Permissions::mayConfigure()) { return null; } diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index ae04a16a9..519163ce1 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -170,7 +170,8 @@ public function showImportPage(Request $request, Response $response, \mysqli $db ob_start(); $title = "Import Libri da CSV"; $emerotecaAvailable = $this->isEmerotecaPluginActive($db); - $fornitori = (new \App\Models\SupplierRepository($db))->options(); + // Suppliers module off: no supplier select (and assignSupplier() writes nothing). + $fornitori = \App\Support\Features::enabled('suppliers') ? (new \App\Models\SupplierRepository($db))->options() : []; include __DIR__ . '/../Views/admin/csv_import.php'; $content = ob_get_clean(); diff --git a/app/Controllers/InventoryController.php b/app/Controllers/InventoryController.php index 413c8a720..1f025a890 100644 --- a/app/Controllers/InventoryController.php +++ b/app/Controllers/InventoryController.php @@ -105,12 +105,18 @@ private function lookups(mysqli $db): array public function index(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } $sessions = (new StocktakeService($db))->listSessions(); return $this->render($response, 'index', ['sessions' => $sessions] + $this->lookups($db)); } public function store(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } $data = (array) $request->getParsedBody(); $name = is_string($data['nome'] ?? null) ? $data['nome'] : ''; $note = is_string($data['note'] ?? null) ? $data['note'] : ''; @@ -131,6 +137,9 @@ public function store(Request $request, Response $response, mysqli $db): Respons public function show(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } $service = new StocktakeService($db); $session = $service->find($id); if ($session === null) { @@ -204,6 +213,9 @@ private function scanShelf(array $data): ?int public function scan(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } $data = (array) $request->getParsedBody(); if (str_contains($request->getHeaderLine('Content-Type'), 'application/json')) { $decoded = json_decode((string) $request->getBody(), true); @@ -229,6 +241,9 @@ public function scan(Request $request, Response $response, mysqli $db, int $id): public function state(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } $service = new StocktakeService($db); $session = $service->find($id); if ($session === null) { @@ -255,6 +270,9 @@ public function state(Request $request, Response $response, mysqli $db, int $id) public function upload(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } $files = $request->getUploadedFiles(); $file = $files['lista'] ?? null; $back = '/admin/inventory/' . $id; @@ -293,6 +311,9 @@ public function upload(Request $request, Response $response, mysqli $db, int $id public function close(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } $data = (array) $request->getParsedBody(); $markLost = ($data['azione_mancanti'] ?? '') === 'perso'; $back = '/admin/inventory/' . $id; @@ -330,6 +351,9 @@ public function close(Request $request, Response $response, mysqli $db, int $id) public function cancel(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } try { (new StocktakeService($db))->cancel($id, $this->operatorId()); } catch (\DomainException $e) { @@ -353,6 +377,9 @@ private function fileBase(array $session): string public function exportCsv(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } $service = new StocktakeService($db); $session = $service->find($id); if ($session === null) { @@ -383,6 +410,9 @@ public function exportCsv(Request $request, Response $response, mysqli $db, int public function exportPdf(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('stocktake', $response)) !== null) { + return $off; + } $service = new StocktakeService($db); $session = $service->find($id); if ($session === null) { diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 46b314297..584d1d508 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -404,7 +404,8 @@ public function index(Request $request, Response $response, mysqli $db): Respons $copyCatalogingAvailable = false; try { $pluginRow = $db->query("SELECT is_active FROM plugins WHERE name = 'z39-server' LIMIT 1"); - $copyCatalogingAvailable = $pluginRow instanceof \mysqli_result && (int) ($pluginRow->fetch_row()[0] ?? 0) === 1; + $copyCatalogingAvailable = \App\Support\Features::enabled('marc') + && $pluginRow instanceof \mysqli_result && (int) ($pluginRow->fetch_row()[0] ?? 0) === 1; } catch (\Throwable) { $copyCatalogingAvailable = false; } diff --git a/app/Controllers/MarcExportController.php b/app/Controllers/MarcExportController.php index 9a57563ac..8f32a09d6 100644 --- a/app/Controllers/MarcExportController.php +++ b/app/Controllers/MarcExportController.php @@ -26,6 +26,9 @@ class MarcExportController public function export(Request $request, Response $response, \mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('marc', $response)) !== null) { + return $off; + } $params = $request->getQueryParams(); $format = (string) ($params['format'] ?? 'marc21'); if (!in_array($format, self::FORMATS, true)) { diff --git a/app/Controllers/MarcImportController.php b/app/Controllers/MarcImportController.php index de34ba10f..fcf3f0435 100644 --- a/app/Controllers/MarcImportController.php +++ b/app/Controllers/MarcImportController.php @@ -42,9 +42,13 @@ class MarcImportController public function showImportPage(Request $request, Response $response, \mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('marc', $response)) !== null) { + return $off; + } ob_start(); $title = __('Import MARC'); - $fornitori = (new SupplierRepository($db))->options(); + // Suppliers module off: no supplier select (and assignSupplier() writes nothing). + $fornitori = \App\Support\Features::enabled('suppliers') ? (new SupplierRepository($db))->options() : []; include __DIR__ . '/../Views/admin/marc_import.php'; $content = (string) ob_get_clean(); $response->getBody()->write($content); @@ -56,6 +60,9 @@ public function showImportPage(Request $request, Response $response, \mysqli $db */ public function upload(Request $request, Response $response, \mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('marc', $response)) !== null) { + return $off; + } @ini_set('display_errors', '0'); @set_time_limit(300); $data = (array) $request->getParsedBody(); @@ -173,6 +180,9 @@ public function upload(Request $request, Response $response, \mysqli $db): Respo */ public function chunk(Request $request, Response $response, \mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('marc', $response)) !== null) { + return $off; + } @set_time_limit(600); @ini_set('display_errors', '0'); $payload = json_decode((string) $request->getBody(), true); diff --git a/app/Controllers/PatronCardsController.php b/app/Controllers/PatronCardsController.php index 3653c342f..00778c4c8 100644 --- a/app/Controllers/PatronCardsController.php +++ b/app/Controllers/PatronCardsController.php @@ -28,6 +28,9 @@ class PatronCardsController public function cardPdf(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('patron_cards', $response)) !== null) { + return $off; + } if (!\App\Support\Permissions::can('users.manage')) { return $response->withStatus(403); } @@ -44,6 +47,9 @@ public function cardPdf(Request $request, Response $response, mysqli $db, int $i public function bulkPdf(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('patron_cards', $response)) !== null) { + return $off; + } if (!\App\Support\Permissions::can('users.manage')) { return $response->withStatus(403); } @@ -71,6 +77,9 @@ public function bulkPdf(Request $request, Response $response, mysqli $db): Respo /** Stream the stored photo (never cached by shared caches). */ public function photo(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('patron_cards', $response)) !== null) { + return $off; + } if (!\App\Support\Permissions::can('users.manage')) { return $response->withStatus(403); } @@ -94,6 +103,9 @@ public function photo(Request $request, Response $response, mysqli $db, int $id) /** Save a photo sent as multipart field `photo` (webcam frame or picked file). JSON reply. */ public function uploadPhoto(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('patron_cards', $response)) !== null) { + return $off; + } $guard = $this->editGuard($db, $id); if ($guard !== null) { return $this->json($response, ['success' => false, 'error' => $guard[1]], $guard[0]); @@ -128,6 +140,9 @@ public function uploadPhoto(Request $request, Response $response, mysqli $db, in /** Remove the photo (the reader's right to erasure). JSON for fetch(), redirect for a plain form. */ public function deletePhoto(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('patron_cards', $response)) !== null) { + return $off; + } $wantsJson = str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json'); $guard = $this->editGuard($db, $id); if ($guard !== null) { diff --git a/app/Controllers/PatronLedgerController.php b/app/Controllers/PatronLedgerController.php index 38038f81e..0a6314ed9 100644 --- a/app/Controllers/PatronLedgerController.php +++ b/app/Controllers/PatronLedgerController.php @@ -44,6 +44,9 @@ public function waive(Request $request, Response $response, mysqli $db, int $use public function receipt(Request $request, Response $response, mysqli $db, int $userId, int $movementId): Response { + if (($off = \App\Support\Features::requireOrRedirect('fines', $response)) !== null) { + return $off; + } if (!\App\Support\Permissions::can('loans.manage')) { return $response->withStatus(403); } @@ -69,6 +72,9 @@ public function receipt(Request $request, Response $response, mysqli $db, int $u */ private function write(Request $request, Response $response, mysqli $db, int $userId, callable $operation, string $okFlag): Response { + if (($off = \App\Support\Features::requireOrRedirect('fines', $response)) !== null) { + return $off; + } if (!\App\Support\Permissions::can('loans.manage')) { return $response->withStatus(403); } diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 2fafffce8..972ac7168 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -1843,7 +1843,8 @@ public function details(Request $request, Response $response, mysqli $db, int $i $loanFineAccruedCents = ((int) ($prestito['attivo'] ?? 0) === 1 && in_array((string) ($prestito['stato'] ?? ''), ['in_corso', 'in_ritardo'], true)) ? (int) $ledger->previewLateFine($id)['cents'] : 0; - $loanFineChargedCents = $ledger->lateFineNetCents($id); + // Fines module off: the charged fine is not shown (the row stays). + $loanFineChargedCents = \App\Services\PatronLedger::active($db) ? $ledger->lateFineNetCents($id) : 0; ob_start(); require __DIR__ . '/../Views/prestiti/dettagli_prestito.php'; diff --git a/app/Controllers/SuppliersController.php b/app/Controllers/SuppliersController.php index afb04c5df..699705e87 100644 --- a/app/Controllers/SuppliersController.php +++ b/app/Controllers/SuppliersController.php @@ -15,17 +15,26 @@ class SuppliersController { public function index(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('suppliers', $response)) !== null) { + return $off; + } $fornitori = (new SupplierRepository($db))->listWithCounts(); return $this->render($response, 'index.php', ['fornitori' => $fornitori]); } public function createForm(Request $request, Response $response): Response { + if (($off = \App\Support\Features::requireOrRedirect('suppliers', $response)) !== null) { + return $off; + } return $this->render($response, 'form.php', ['fornitore' => null]); } public function store(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('suppliers', $response)) !== null) { + return $off; + } $input = (array) $request->getParsedBody(); $data = SupplierRepository::normalize($input); $error = $this->validationError($input, $data); @@ -45,6 +54,9 @@ public function store(Request $request, Response $response, mysqli $db): Respons public function editForm(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('suppliers', $response)) !== null) { + return $off; + } $fornitore = (new SupplierRepository($db))->getById($id); if ($fornitore === null) { return $response->withStatus(404); @@ -54,6 +66,9 @@ public function editForm(Request $request, Response $response, mysqli $db, int $ public function update(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('suppliers', $response)) !== null) { + return $off; + } $repo = new SupplierRepository($db); if ($repo->getById($id) === null) { return $response->withStatus(404); @@ -77,6 +92,9 @@ public function update(Request $request, Response $response, mysqli $db, int $id public function delete(Request $request, Response $response, mysqli $db, int $id): Response { + if (($off = \App\Support\Features::requireOrRedirect('suppliers', $response)) !== null) { + return $off; + } try { if ((new SupplierRepository($db))->delete($id)) { $_SESSION['success_message'] = __('Fornitore eliminato. I libri collegati non hanno più un fornitore.'); diff --git a/app/Controllers/UserDashboardController.php b/app/Controllers/UserDashboardController.php index 97d324448..522e49e37 100644 --- a/app/Controllers/UserDashboardController.php +++ b/app/Controllers/UserDashboardController.php @@ -233,10 +233,13 @@ public function prenotazioni(Request $request, Response $response, mysqli $db, m $ledgerMovements = []; $ledgerAccrued = []; try { - $ledger = \App\Services\PatronLedger::forDb($db); - $ledgerBalanceCents = $ledger->balanceCents($userId); - $ledgerMovements = $ledger->movements($userId, 50); - $ledgerAccrued = $ledger->accruedFines($userId); + // Fines module off: no balance section at all (the rows stay). + if (\App\Services\PatronLedger::active($db)) { + $ledger = \App\Services\PatronLedger::forDb($db); + $ledgerBalanceCents = $ledger->balanceCents($userId); + $ledgerMovements = $ledger->movements($userId, 50); + $ledgerAccrued = $ledger->accruedFines($userId); + } } catch (\Throwable $e) { \App\Support\SecureLogger::error('Patron ledger unavailable on the loans page', ['error' => $e->getMessage()]); } diff --git a/app/Controllers/UserImportController.php b/app/Controllers/UserImportController.php index 93556a8e6..0251bbc35 100644 --- a/app/Controllers/UserImportController.php +++ b/app/Controllers/UserImportController.php @@ -39,6 +39,9 @@ class UserImportController public function page(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('user_import', $response)) !== null) { + return $off; + } if (!\App\Support\Permissions::can('users.manage')) { return $response->withStatus(403); } @@ -64,6 +67,9 @@ public function page(Request $request, Response $response, mysqli $db): Response /** A sample file with every recognised column. */ public function example(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('user_import', $response)) !== null) { + return $off; + } if (!\App\Support\Permissions::can('users.manage')) { return $response->withStatus(403); } @@ -80,6 +86,9 @@ public function example(Request $request, Response $response, mysqli $db): Respo public function upload(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('user_import', $response)) !== null) { + return $off; + } @ini_set('display_errors', '0'); if (!\App\Support\Permissions::can('users.manage')) { return $this->json($response, ['success' => false, 'error' => __('Accesso negato')], 403); @@ -167,6 +176,9 @@ public function upload(Request $request, Response $response, mysqli $db): Respon public function start(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('user_import', $response)) !== null) { + return $off; + } @ini_set('display_errors', '0'); if (!\App\Support\Permissions::can('users.manage')) { return $this->json($response, ['success' => false, 'error' => __('Accesso negato')], 403); @@ -203,6 +215,9 @@ public function start(Request $request, Response $response, mysqli $db): Respons public function chunk(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('user_import', $response)) !== null) { + return $off; + } @set_time_limit(300); @ini_set('display_errors', '0'); if (!\App\Support\Permissions::can('users.manage')) { @@ -519,6 +534,9 @@ private function completeImportedUser(mysqli $db, array &$state, int $userId, in */ public function downloadErrors(Request $request, Response $response, mysqli $db): Response { + if (($off = \App\Support\Features::requireOrRedirect('user_import', $response)) !== null) { + return $off; + } if (!\App\Support\Permissions::can('users.manage')) { return $response->withStatus(403); } diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index f3fd2f1c5..a1c5a6bd2 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -828,6 +828,8 @@ public function details(Request $request, Response $response, mysqli $db, int $i $ledgerAccrued = $ledger->accruedFines($id); $ledgerParams = \App\Services\FineParameters::forUser($db, $id); $ledgerCanManage = \App\Support\Permissions::can('loans.manage'); + // Fines module off (Settings -> Loans): no Movimenti section. + $ledgerEnabled = \App\Services\PatronLedger::active($db); ob_start(); require __DIR__ . '/../Views/utenti/dettagli_utente.php'; diff --git a/app/Services/CirculationPolicy.php b/app/Services/CirculationPolicy.php index 4a9fff88f..457383822 100644 --- a/app/Services/CirculationPolicy.php +++ b/app/Services/CirculationPolicy.php @@ -368,7 +368,7 @@ public function materialRefusedReservationUserIds(int $libroId): array public function debtBlockError(int $userId): ?string { // No ledger table (code deployed before its migration): no balance, no block. - if (!PatronLedger::schemaReady($this->db)) { + if (!PatronLedger::active($this->db)) { return null; } $threshold = FineParameters::moneyToCents($this->forUser($userId)->debtBlockThreshold); @@ -425,7 +425,7 @@ public function reservationLimitError(UserPolicy $user, int $userId, bool $queue public function totalLoanLimitSql(string $userAlias): string { $global = (int) $this->globalLayer()['max_active_loans']; - if (!$this->schemaReady()) { + if (!$this->rulesActive()) { return (string) $global; } $default = $this->defaultTypeId() ?? 0; @@ -457,7 +457,7 @@ public function types(): array { if ($this->types === null) { $this->types = []; - if ($this->schemaReady()) { + if ($this->rulesActive()) { $result = $this->db->query('SELECT * FROM tipologie_lettore ORDER BY ordinamento ASC, nome ASC, id ASC'); if ($result instanceof \mysqli_result) { while ($row = $result->fetch_assoc()) { @@ -489,7 +489,7 @@ public function rules(): array { if ($this->rules === null) { $this->rules = []; - if ($this->schemaReady()) { + if ($this->rulesActive()) { $result = $this->db->query('SELECT * FROM regole_prestito'); if ($result instanceof \mysqli_result) { while ($row = $result->fetch_assoc()) { @@ -505,7 +505,7 @@ public function rules(): array /** The patron type governing $userId: theirs, else the default one. */ public function patronTypeOf(int $userId): ?int { - if (!$this->schemaReady()) { + if (!$this->rulesActive()) { return null; } if (!array_key_exists($userId, $this->userTypes)) { @@ -626,7 +626,7 @@ public function mediaOf(int $libroId): string /** Whether the copy is kept for in-house consultation only. */ public function isConsultationCopy(int $copiaId): bool { - if (!$this->schemaReady()) { + if (!$this->rulesActive()) { return false; } $stmt = $this->db->prepare('SELECT solo_consultazione FROM copie WHERE id = ?'); @@ -644,7 +644,7 @@ public function isConsultationCopy(int $copiaId): bool */ public function onlyConsultationCopies(int $libroId): bool { - if (!$this->schemaReady()) { + if (!$this->rulesActive()) { return false; } $stmt = $this->db->prepare( @@ -668,12 +668,25 @@ public function onlyConsultationCopies(int $libroId): bool */ public function lendableCopySql(string $alias = 'c'): string { - if (!$this->schemaReady()) { + if (!$this->rulesActive()) { return '1 = 1'; } return ($alias !== '' ? $alias . '.' : '') . 'solo_consultazione = 0'; } + /** + * Whether patron types, the rules matrix and consultation-only copies + * apply: their tables exist AND the module is on (Settings -> Loans, + * features.circulation_rules; also off in catalogue mode). Off, every + * rule is the global layer, exactly as before 0.9.0, on every channel + * at once (web, Mobile API, NCIP, maintenance), since they all resolve + * through this class. The data stays: switching back on restores it. + */ + public function rulesActive(): bool + { + return \App\Support\Features::enabled('circulation_rules') && $this->schemaReady(); + } + /** * Whether the 0.9.0 circulation tables and columns exist. Code ships * before its migration runs during an upgrade: until then every rule is diff --git a/app/Services/PatronLedger.php b/app/Services/PatronLedger.php index 341b433f6..ddb33f3d5 100644 --- a/app/Services/PatronLedger.php +++ b/app/Services/PatronLedger.php @@ -94,6 +94,18 @@ public static function schemaReady(mysqli $db): bool return self::$schemaReady[$key]; } + /** + * Whether fines and the ledger apply: the table exists AND the fines + * module is on (Settings -> Loans, features.fines; also off in catalogue + * mode). Off, nothing is charged, nobody is blocked for debt and the + * movements are not shown; the rows already written stay, and + * prestiti.sanzione keeps being written as before 0.9.0. + */ + public static function active(mysqli $db): bool + { + return \App\Support\Features::enabled('fines') && self::schemaReady($db); + } + /** Drop the per-request schema probe (tests that migrate a database mid-process). */ public static function resetSchemaCache(): void { @@ -205,6 +217,9 @@ public function movement(int $movementId): ?array */ public function chargeLateReturn(int $loanId, ?int $staffId = null): int { + if (!self::active($this->db)) { + return 0; + } $loan = $this->loanRow($loanId); if ($loan === null || (string) $loan['stato'] !== 'restituito' || empty($loan['data_restituzione'])) { return 0; @@ -235,6 +250,11 @@ public function chargeLateReturn(int $loanId, ?int $staffId = null): int */ public function chargeLossPenalty(int $loanId, ?int $staffId = null): int { + // Fines off: prestiti.sanzione alone, as before 0.9.0. The same key + // as the legacy backfill, which mirrors it once the module is back on. + if (!self::active($this->db)) { + return 0; + } $loan = $this->loanRow($loanId); if ($loan === null) { return 0; @@ -381,6 +401,9 @@ public function manualCharge(int $userId, int $cents, string $description, ?int */ public function previewLateFine(int $loanId, ?string $returnDate = null): array { + if (!self::active($this->db)) { + return ['days' => 0, 'cents' => 0, 'enabled' => false]; + } $loan = $this->loanRow($loanId); $params = FineParameters::forLoan($this->db, $loanId); if ($loan === null) { @@ -403,6 +426,9 @@ public function previewLateFine(int $loanId, ?string $returnDate = null): array */ public function accruedFines(int $userId): array { + if (!self::active($this->db)) { + return []; + } $today = DateHelper::today(); $stmt = $this->db->prepare( "SELECT p.id, p.data_scadenza, l.titolo, c.sede_id @@ -449,7 +475,9 @@ public function accruedFines(int $userId): array */ public function backfillLegacyPenalties(int $batch = 500, int $maxBatches = 1000): ?int { - if (!self::schemaReady($this->db)) { + // null also while the fines module is off: the scan is not recorded + // as done and runs when the module is switched back on. + if (!self::active($this->db)) { return null; } $written = 0; @@ -485,6 +513,9 @@ public function backfillLegacyPenalties(int $batch = 500, int $maxBatches = 1000 */ public function periodTotals(string $from, string $to): array { + if (!self::active($this->db)) { + return []; + } $fromTs = $from . ' 00:00:00'; $toTs = $to . ' 23:59:59'; $stmt = $this->db->prepare( diff --git a/app/Support/CataloguingExtras.php b/app/Support/CataloguingExtras.php index 671f3ca97..105170030 100644 --- a/app/Support/CataloguingExtras.php +++ b/app/Support/CataloguingExtras.php @@ -32,7 +32,9 @@ public static function save(mysqli $db, int $bookId, array $data): void $types = ''; $values = []; - if (array_key_exists('fornitore_id', $data) && self::hasColumn($db, 'fornitore_id')) { + // A module switched off (Settings → Modules) never writes its field: + // the stored value survives a save made while it is off. + if (array_key_exists('fornitore_id', $data) && Features::enabled('suppliers') && self::hasColumn($db, 'fornitore_id')) { $supplierId = self::existingSupplierId($db, $data['fornitore_id']); if ($supplierId === null) { $set[] = 'fornitore_id = NULL'; @@ -44,7 +46,7 @@ public static function save(mysqli $db, int $bookId, array $data): void } foreach (RdaTypes::COLUMNS as $kind => $column) { - if (!array_key_exists($column, $data) || !self::hasColumn($db, $column)) { + if (!array_key_exists($column, $data) || !Features::enabled('rda_types') || !self::hasColumn($db, $column)) { continue; } $code = RdaTypes::normalize($kind, $data[$column]); @@ -83,7 +85,7 @@ public static function save(mysqli $db, int $bookId, array $data): void */ public static function assignSupplier(mysqli $db, int $bookId, int $supplierId): void { - if (!self::hasColumn($db, 'fornitore_id') || self::existingSupplierId($db, $supplierId) === null) { + if (!Features::enabled('suppliers') || !self::hasColumn($db, 'fornitore_id') || self::existingSupplierId($db, $supplierId) === null) { return; } $stmt = $db->prepare('UPDATE libri SET fornitore_id = ? WHERE id = ? AND deleted_at IS NULL'); diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index 14b3c1626..12bde4bfc 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -100,6 +100,13 @@ public function __construct(array $closedWeekdays = [], array $closures = []) /** The calendar stored in the database (settings + closures table). */ public static function fromDatabase(\mysqli $db): self { + // Closures calendar module off (Settings -> Loans; also off in + // catalogue mode): an empty calendar, so due dates, pickup deadlines + // and fine days are those of a library open every day, as before + // 0.9.0. The stored closures stay for when it is switched back on. + if (!Features::enabled('closures_calendar')) { + return new self([], []); + } return new self(self::loadClosedWeekdays($db), self::loadClosures($db)); } diff --git a/app/Support/LoanEligibility.php b/app/Support/LoanEligibility.php index 9389826ee..8e28f53a2 100644 --- a/app/Support/LoanEligibility.php +++ b/app/Support/LoanEligibility.php @@ -171,11 +171,12 @@ public static function eligibleUserWhere(string $alias, \mysqli $db): string * (first predefinita = 1 in display order, as CirculationPolicy::types()), * the global loans.debt_block_threshold when it is a well-formed amount. * A threshold of NULL or 0 never blocks. With no movimenti_lettore table - * (PatronLedger::schemaReady()) nobody has a balance: "1 = 1". + * or with the fines module off (PatronLedger::active()) nobody is + * blocked: "1 = 1". */ public static function debtBlockSql(string $alias, \mysqli $db): string { - if (!\App\Services\PatronLedger::schemaReady($db)) { + if (!\App\Services\PatronLedger::active($db)) { return '1 = 1'; } $threshold = self::debtThresholdSql($alias, $db); @@ -196,7 +197,7 @@ public static function debtThresholdSql(string $alias, \mysqli $db): string WHERE dbt_g.category = 'loans' AND dbt_g.setting_key = 'debt_block_threshold' AND dbt_g.setting_value REGEXP '^[0-9]{1,8}([.,][0-9]{1,2}){0,1}$' LIMIT 1)"; - if (!(new \App\Services\CirculationPolicy($db))->schemaReady()) { + if (!(new \App\Services\CirculationPolicy($db))->rulesActive()) { return $global; } return "COALESCE( diff --git a/app/Support/MaintenanceService.php b/app/Support/MaintenanceService.php index d4baa37c0..dde0c84c3 100644 --- a/app/Support/MaintenanceService.php +++ b/app/Support/MaintenanceService.php @@ -348,7 +348,9 @@ private function runAllLocked(): array // no setting the default retention applies. try { $attendance = new \App\Models\AttendanceRepository($this->db); - $results['attendance_purged'] = $attendance->isAvailable() + // Visitor log module off (Settings -> Modules): nothing is purged + // either, the rows wait for the module to be switched back on. + $results['attendance_purged'] = Features::enabled('visitor_log') && $attendance->isAvailable() ? $attendance->purge(\App\Models\AttendanceRepository::retentionDays(new \App\Models\SettingsRepository($this->db))) : 0; } catch (\Throwable $e) { diff --git a/app/Support/Marc/MarcBookImporter.php b/app/Support/Marc/MarcBookImporter.php index 8d31bc0dd..821488954 100644 --- a/app/Support/Marc/MarcBookImporter.php +++ b/app/Support/Marc/MarcBookImporter.php @@ -378,8 +378,10 @@ public static function extraColumnUpdates(string $mode, array $data, ?array $exi && $newType !== '' && $newType !== trim((string) ($existing['tipo_media'] ?? '')); $updates = []; + // RDA types module off: imports neither set nor clear the overrides. + $rdaOn = \App\Support\Features::enabled('rda_types'); foreach (self::EXTRA_COLUMNS as $column => $length) { - if (!$hasColumn($column)) { + if (!$hasColumn($column) || (!$rdaOn && str_starts_with($column, 'rda_'))) { continue; } $value = trim((string) ($data[$column] ?? '')); diff --git a/app/Support/Marc/MarcMediaType.php b/app/Support/Marc/MarcMediaType.php index 97b93b90d..2040a1eb2 100644 --- a/app/Support/Marc/MarcMediaType.php +++ b/app/Support/Marc/MarcMediaType.php @@ -87,7 +87,9 @@ public static function field007(?string $tipoMedia): ?string public static function rdaCodes(array $row): array { [, , $content, $media, $carrier] = self::TYPES[self::normalise(isset($row['tipo_media']) ? (string) $row['tipo_media'] : null)]; - $own = static fn (string $key): string => strtolower(trim((string) ($row[$key] ?? ''))); + // RDA types module off: the stored overrides are not applied. + $overridesOn = \App\Support\Features::enabled('rda_types'); + $own = static fn (string $key): string => $overridesOn ? strtolower(trim((string) ($row[$key] ?? ''))) : ''; return [ 'content' => $own('rda_content_type') !== '' ? $own('rda_content_type') : $content, 'media' => $own('rda_media_type') !== '' ? $own('rda_media_type') : $media, diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index 8ae0f7c17..d100ec835 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -2449,6 +2449,9 @@ public function sendLoanReturnedNotification(int $loanId): bool { */ public function sendFineChargedNotification(int $loanId): bool { + if (!\App\Services\PatronLedger::active($this->db)) { + return false; + } try { $enabled = (string) ((new \App\Models\SettingsRepository($this->db))->get('loans', \App\Services\FineParameters::SETTING_FINE_EMAIL, '0') ?? '0'); if ($enabled !== '1') { diff --git a/app/Support/RdaTypes.php b/app/Support/RdaTypes.php index a0b6ff809..3370ea3c9 100644 --- a/app/Support/RdaTypes.php +++ b/app/Support/RdaTypes.php @@ -147,6 +147,12 @@ public static function normalize(string $kind, mixed $code): ?string public static function overrides(array $book): array { $out = []; + // RDA types module off (Settings → Modules): the stored overrides are + // kept but not applied, so every export derives from tipo_media as + // before 0.9.0. + if (!Features::enabled('rda_types')) { + return $out; + } foreach (self::COLUMNS as $kind => $column) { $code = self::normalize($kind, $book[$column] ?? null); if ($code !== null) { diff --git a/app/Support/StatisticsSections.php b/app/Support/StatisticsSections.php index 63e5793f1..111875f75 100644 --- a/app/Support/StatisticsSections.php +++ b/app/Support/StatisticsSections.php @@ -37,6 +37,10 @@ final class StatisticsSections */ public static function visitors(mysqli $db, int $days = 30): ?array { + // Each section follows the module it reports on (Settings -> Modules). + if (!Features::enabled('visitor_log')) { + return null; + } try { $repo = new AttendanceRepository($db); if (!$repo->isAvailable()) { @@ -102,12 +106,15 @@ public static function loansByMediaType(mysqli $db): array */ public static function loansByPatronType(mysqli $db): ?array { + if (!Features::enabled('circulation_rules')) { + return null; + } $hooked = self::tableSection(Hooks::apply('statistics.section.patron_types', null, [$db])); if ($hooked !== null) { return $hooked; } $policy = new \App\Services\CirculationPolicy($db); - if (!$policy->schemaReady()) { + if (!$policy->rulesActive()) { return null; } $types = $policy->types(); @@ -159,6 +166,9 @@ public static function loansByPatronType(mysqli $db): ?array */ public static function fines(mysqli $db): ?array { + if (!Features::enabled('fines')) { + return null; + } $hooked = self::tableSection(Hooks::apply('statistics.section.fines', null, [$db])); if ($hooked !== null) { return $hooked; @@ -231,6 +241,9 @@ private static function anyFineConfigured(mysqli $db): bool */ public static function stocktakeOutcomes(mysqli $db): ?array { + if (!Features::enabled('stocktake')) { + return null; + } $hooked = self::tableSection(Hooks::apply('statistics.section.stocktake', null, [$db])); if ($hooked !== null) { return $hooked; diff --git a/app/Views/admin/stats.php b/app/Views/admin/stats.php index 23a0f58da..de9714f5f 100644 --- a/app/Views/admin/stats.php +++ b/app/Views/admin/stats.php @@ -383,6 +383,7 @@ class="w-10 h-14 object-cover rounded shadow-sm"
      + Loans) ?> 0, 'incassato' => 0, 'condonato' => 0, 'stornato' => 0]; foreach (($finesByMonth ?? []) as $finesRow) { @@ -460,6 +461,7 @@ class="w-10 h-14 object-cover rounded shadow-sm"
      +
      diff --git a/app/Views/dashboard/permissions.php b/app/Views/dashboard/permissions.php index bfcc16d25..f08f79359 100644 --- a/app/Views/dashboard/permissions.php +++ b/app/Views/dashboard/permissions.php @@ -6,7 +6,7 @@

        ['/admin/books', __('Catalogazione')], 'loans.manage' => ['/admin/loans', __('Prestiti')], 'users.manage' => ['/admin/users', __('Utenti')], 'settings.manage' => ['/admin/settings', __('Impostazioni')], 'inventory.manage' => ['/admin/inventory', __('Revisione inventariale')]] as $permission => [$path, $label]): ?> -
      • +

      diff --git a/app/Views/layout.php b/app/Views/layout.php index 1224b642e..f30fd9e58 100644 --- a/app/Views/layout.php +++ b/app/Views/layout.php @@ -267,6 +267,7 @@ class="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 group-hov +
      + @@ -317,6 +319,7 @@ class="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 group-hov +
      + @@ -341,6 +345,7 @@ class="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 group-hov +
      + - +
      + @@ -65,6 +66,7 @@ +
      @@ -98,6 +100,7 @@ + @@ -107,6 +110,7 @@ + @@ -122,9 +126,11 @@ + + @@ -330,6 +336,7 @@ class="form-input" /> +
      + @@ -402,12 +410,16 @@ class="form-input" /> + + + + @@ -1509,7 +1521,7 @@ function updateBulkActionsBar() { }); // Bulk ISBD catalogue cards (PDF, one card per page) - document.getElementById('bulk-catalogue-cards').addEventListener('click', function() { + document.getElementById('bulk-catalogue-cards')?.addEventListener('click', function() { if (selectedBooks.size === 0) return; const maxCards = ; if (selectedBooks.size > maxCards) { @@ -1533,7 +1545,7 @@ function updateBulkActionsBar() { }); // Bulk MARC export of the selection: MARC 21, MARCXML or UNIMARC - document.getElementById('bulk-export-marc').addEventListener('click', async function() { + document.getElementById('bulk-export-marc')?.addEventListener('click', async function() { if (selectedBooks.size === 0) return; const choice = await Swal.fire({ title: __('Esporta in MARC'), diff --git a/app/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php index 2e0eae229..57dfa8676 100644 --- a/app/Views/libri/partials/book_form.php +++ b/app/Views/libri/partials/book_form.php @@ -413,6 +413,7 @@ + name, loaded by the controller. $supplierOptions = isset($fornitori) && is_array($fornitori) ? $fornitori : []; @@ -433,6 +434,7 @@

      + @@ -485,6 +487,7 @@ + +
      + + @@ -594,7 +596,7 @@ class="text-gray-700 hover:text-gray-900 hover:underline transition-colors"> getById((int) $libro['fornitore_id']) : null; ?> @@ -611,10 +613,12 @@ class="text-gray-700 hover:text-gray-900 hover:underline transition-colors"> $rdaParts[] = __(\App\Support\RdaTypes::label($rdaKind, $rdaCode)); } ?> +
      +
      - + @@ -1198,7 +1202,7 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm $canEdit = empty($copia['prestito_id']) || $bookAtLibrary; $canDelete = $canEdit && in_array($rawCopiaStatus, ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento'], true); ?> - +
      diff --git a/app/Views/partials/role-navigation.php b/app/Views/partials/role-navigation.php index 23e05ef49..be9e67a92 100644 --- a/app/Views/partials/role-navigation.php +++ b/app/Views/partials/role-navigation.php @@ -10,6 +10,8 @@ ]; foreach ($links as $permission => [$path, $label]): if (!Permissions::can($permission)) { continue; } + // The stocktake entry follows its module (Settings -> Modules). + if ($permission === 'inventory.manage' && !\App\Support\Features::enabled('stocktake')) { continue; } ?> diff --git a/app/Views/prenotazioni/crea_prenotazione.php b/app/Views/prenotazioni/crea_prenotazione.php index 109134f37..4887cc7e9 100644 --- a/app/Views/prenotazioni/crea_prenotazione.php +++ b/app/Views/prenotazioni/crea_prenotazione.php @@ -271,10 +271,12 @@ class="w-full px-6 py-5 text-xl border-2 border-gray-300 rounded-2xl focus:ring- + +
      diff --git a/app/Views/utenti/dettagli_utente.php b/app/Views/utenti/dettagli_utente.php index 7a1db0a30..ec8a8d442 100644 --- a/app/Views/utenti/dettagli_utente.php +++ b/app/Views/utenti/dettagli_utente.php @@ -125,10 +125,12 @@ ?> +

      @@ -443,7 +447,7 @@
      - +
      diff --git a/app/Views/utenti/index.php b/app/Views/utenti/index.php index 83b69f4e9..1dc87c553 100644 --- a/app/Views/utenti/index.php +++ b/app/Views/utenti/index.php @@ -27,10 +27,12 @@ + +
      + +
      @@ -573,7 +579,8 @@ class="btn-icon btn-danger-outline p-2 text-red-600 hover:bg-red-50 rounded-lg t // Bulk selection → library cards PDF (same interaction as the books list). function updateBulkActionsBar() { document.getElementById('selected-count').textContent = selectedUsers.size; - document.getElementById('bulk-actions-bar').classList.toggle('hidden', selectedUsers.size === 0); + // The bar only carries the card printing actions (patron cards module). + document.getElementById('bulk-actions-bar').classList.toggle('hidden', selectedUsers.size === 0 || !document.getElementById('bulk-cards-sheet')); } document.getElementById('utenti-table').addEventListener('change', function(e) { if (!e.target.classList || !e.target.classList.contains('row-select')) return; @@ -604,8 +611,8 @@ function openCards(format) { const ids = Array.from(selectedUsers).join(','); window.open((window.BASE_PATH || '') + '/admin/users/cards-pdf?format=' + format + '&ids=' + encodeURIComponent(ids), '_blank', 'noopener'); } - document.getElementById('bulk-cards-sheet').addEventListener('click', () => openCards('sheet')); - document.getElementById('bulk-cards-single').addEventListener('click', () => openCards('single')); + document.getElementById('bulk-cards-sheet')?.addEventListener('click', () => openCards('sheet')); + document.getElementById('bulk-cards-single')?.addEventListener('click', () => openCards('single')); // Scroll shadow effect for mobile function initializeScrollShadow() { diff --git a/app/Views/utenti/modifica_utente.php b/app/Views/utenti/modifica_utente.php index 64e512a03..538cd2482 100644 --- a/app/Views/utenti/modifica_utente.php +++ b/app/Views/utenti/modifica_utente.php @@ -167,6 +167,7 @@
      +

      @@ -193,6 +194,7 @@
      +

      @@ -272,6 +274,8 @@ const actions = document.getElementById('photo-actions'); const cameraActions = document.getElementById('photo-camera-actions'); const deleteBtn = document.getElementById('photo-delete'); + // Patron cards module off: no photo widget on the page. + if (!preview || !status) { return; } let stream = null; const say = (text, isError) => { diff --git a/storage/plugins/mobile-api/MobileApiPlugin.php b/storage/plugins/mobile-api/MobileApiPlugin.php index 6818b28db..8f8b92a25 100644 --- a/storage/plugins/mobile-api/MobileApiPlugin.php +++ b/storage/plugins/mobile-api/MobileApiPlugin.php @@ -693,6 +693,24 @@ public function registerRoutes(\Slim\App $app): void return $handler->handle($request); }; + // The fines module (Settings -> Loans) switched off: the reader's + // balance and movements are not available, in the same shape as + // the catalogue-mode refusal (the app hides the screen on it). + $finesGuard = function ( + ServerRequestInterface $request, + \Psr\Http\Server\RequestHandlerInterface $handler + ): ResponseInterface { + if (!\App\Support\Features::enabled('fines')) { + return \App\Plugins\MobileApi\Support\ResponseEnvelope::error( + new \Slim\Psr7\Response(), + 'feature_disabled', + __('Funzione non disponibile su questa istanza.'), + 404 + ); + } + return $handler->handle($request); + }; + // ── User actions (loans / reservations / wishlist / profile / msg) ── // Every handler is bearer-authenticated and strictly scoped to the // token-resolved user; loan/reservation overlap + availability reuse @@ -710,7 +728,7 @@ public function registerRoutes(\Slim\App $app): void ResponseInterface $response ) use ($db): ResponseInterface { return (new ActionsController($db))->myMovements($request, $response); - })->add($catalogueModeGuard)->add($quotaMw())->add($authMw()); + })->add($finesGuard)->add($catalogueModeGuard)->add($quotaMw())->add($authMw()); $group->get('/me/reservations', function ( ServerRequestInterface $request, diff --git a/storage/plugins/mobile-api/plugin.json b/storage/plugins/mobile-api/plugin.json index 0702230dd..a42e01118 100644 --- a/storage/plugins/mobile-api/plugin.json +++ b/storage/plugins/mobile-api/plugin.json @@ -22,5 +22,5 @@ "optional": true, "status": "stable" }, - "changelog": "v1.9.0: saldo e movimenti del lettore (/me/movements: addebiti, pagamenti, condoni, storni e multe in maturazione sui prestiti scaduti); il blocco per debito arriva come not_eligible con reason debt_block e come eligibility_reason negli elenchi prestiti dello staff. Regole di circolazione per tipologia lettore (Pinakes 0.9.0): il calendario di disponibilità (/catalog/books/{id}/availability) indica closed_dates (giorni di chiusura della biblioteca), loanable (falso se il titolo è solo in consultazione per il lettore) e reservations_enabled (falso se la tipologia non ammette prenotazioni); nuovi codici 409 media_limit_reached, not_loanable, reservations_disabled e reservation_limit_reached su richieste e prestiti; override_limits nella prenotazione da parte dello staff ignora i limiti di prenotazione della tipologia, come il modulo amministrativo. v1.8.1: il calendario di disponibilità (/catalog/books/{id}/availability) indica loan_duration_days, la durata del prestito che la richiesta aggiunge alla data scelta, così l'app mostra la data di restituzione prima dell'invio. v1.8.0: allegati digitali con visibilità (tutti, utenti registrati, solo staff, chi ha il prestito): il dettaglio libro indica per ogni file visibility e access, i file aperti hanno un link firmato valido 24 ore, quelli chiusi url null e il motivo. v1.7.0: circolazione completa nell'app (richieste, ritiri, prestiti attivi e scaduti, storico, prenotazioni con coda): creazione prestito con ricerca utente e scansione del codice copia, approvazione/rifiuto, conferma/annullamento ritiro, restituzione rapida o con esito della copia e sanzione, restituzione da codice a barre, rinnovo, proroga e solleciti multipli, modifica, ricevuta PDF e via email, calendario disponibilità per utente, contatori per la home staff; creazione di prestiti e prenotazioni ripetibile in sicurezza con chiave di idempotenza. Libri: gestione per singola copia (codice esplicito, stato, note, eliminazione), collane (albero, dettaglio, creazione, rinomina, unione, ordine, assegnazione multipla, opera madre), unione duplicati di autori/editori/generi, etichette PDF e relative impostazioni, importazione CSV e LibraryThing dal telefono con avanzamento ripristinabile ed esportazione LibraryThing, copertine mancanti e arricchimento massivo. Contenuti dei plugin: desiderata (proposte di donazione: accetta, rifiuta, ricevi, elimina, ricezione diretta) e archivi (schede, autorità, allegati, pubblicazione); se attivi, opere FRBR con le espressioni e il collegamento dei libri, allegati digitali (e-book e audiolibri), banco dell'emeroteca e gestione dei gruppi di lettura tramite i rispettivi plugin. In /health un flag per ogni area, vero solo quando l'area è disponibile. Le operazioni passano dagli stessi controller del sito: il registro attività riporta l'operatore e la sorgente del sito (manuale, approvazione, ritiro, restituzione). v1.6.0: area staff nell'app (riservata ad admin e staff): ricerca e scheda modificabile dei libri, importazione da ISBN/EAN con tutte le fonti di scraping, creazione e modifica tramite la stessa pipeline del modulo web, aggiunta copie, copertina da foto o da URL, eliminazione con controllo dei prestiti attivi; gestione di autori, editori, generi, scaffali e ripiani. Nuovo feed delta /catalog/sync per il catalogo offline e calendario personale di prestiti e prenotazioni (feed ICS con link segreto revocabile, eventi dall'inizio alla scadenza, senza dati personali). Sesso e codice fiscale seguono le impostazioni di registrazione: se nascosti non compaiono in /auth/registration-fields né in /me. v1.5.1: cifratura dei messaggi push e irrobustimento dell'API. v1.5.0: archivi e desiderata nell'app." + "changelog": "v1.9.0: saldo e movimenti del lettore (/me/movements: addebiti, pagamenti, condoni, storni e multe in maturazione sui prestiti scaduti); il blocco per debito arriva come not_eligible con reason debt_block e come eligibility_reason negli elenchi prestiti dello staff. Regole di circolazione per tipologia lettore (Pinakes 0.9.0): il calendario di disponibilità (/catalog/books/{id}/availability) indica closed_dates (giorni di chiusura della biblioteca), loanable (falso se il titolo è solo in consultazione per il lettore) e reservations_enabled (falso se la tipologia non ammette prenotazioni); nuovi codici 409 media_limit_reached, not_loanable, reservations_disabled e reservation_limit_reached su richieste e prestiti; override_limits nella prenotazione da parte dello staff ignora i limiti di prenotazione della tipologia, come il modulo amministrativo. In /health i flag fines, closures_calendar e circulation_rules seguono i moduli di Impostazioni → Prestiti; con le multe disattivate /me/movements risponde 404 feature_disabled. v1.8.1: il calendario di disponibilità (/catalog/books/{id}/availability) indica loan_duration_days, la durata del prestito che la richiesta aggiunge alla data scelta, così l'app mostra la data di restituzione prima dell'invio. v1.8.0: allegati digitali con visibilità (tutti, utenti registrati, solo staff, chi ha il prestito): il dettaglio libro indica per ogni file visibility e access, i file aperti hanno un link firmato valido 24 ore, quelli chiusi url null e il motivo. v1.7.0: circolazione completa nell'app (richieste, ritiri, prestiti attivi e scaduti, storico, prenotazioni con coda): creazione prestito con ricerca utente e scansione del codice copia, approvazione/rifiuto, conferma/annullamento ritiro, restituzione rapida o con esito della copia e sanzione, restituzione da codice a barre, rinnovo, proroga e solleciti multipli, modifica, ricevuta PDF e via email, calendario disponibilità per utente, contatori per la home staff; creazione di prestiti e prenotazioni ripetibile in sicurezza con chiave di idempotenza. Libri: gestione per singola copia (codice esplicito, stato, note, eliminazione), collane (albero, dettaglio, creazione, rinomina, unione, ordine, assegnazione multipla, opera madre), unione duplicati di autori/editori/generi, etichette PDF e relative impostazioni, importazione CSV e LibraryThing dal telefono con avanzamento ripristinabile ed esportazione LibraryThing, copertine mancanti e arricchimento massivo. Contenuti dei plugin: desiderata (proposte di donazione: accetta, rifiuta, ricevi, elimina, ricezione diretta) e archivi (schede, autorità, allegati, pubblicazione); se attivi, opere FRBR con le espressioni e il collegamento dei libri, allegati digitali (e-book e audiolibri), banco dell'emeroteca e gestione dei gruppi di lettura tramite i rispettivi plugin. In /health un flag per ogni area, vero solo quando l'area è disponibile. Le operazioni passano dagli stessi controller del sito: il registro attività riporta l'operatore e la sorgente del sito (manuale, approvazione, ritiro, restituzione). v1.6.0: area staff nell'app (riservata ad admin e staff): ricerca e scheda modificabile dei libri, importazione da ISBN/EAN con tutte le fonti di scraping, creazione e modifica tramite la stessa pipeline del modulo web, aggiunta copie, copertina da foto o da URL, eliminazione con controllo dei prestiti attivi; gestione di autori, editori, generi, scaffali e ripiani. Nuovo feed delta /catalog/sync per il catalogo offline e calendario personale di prestiti e prenotazioni (feed ICS con link segreto revocabile, eventi dall'inizio alla scadenza, senza dati personali). Sesso e codice fiscale seguono le impostazioni di registrazione: se nascosti non compaiono in /auth/registration-fields né in /me. v1.5.1: cifratura dei messaggi push e irrobustimento dell'API. v1.5.0: archivi e desiderata nell'app." } diff --git a/storage/plugins/mobile-api/src/Controllers/HealthController.php b/storage/plugins/mobile-api/src/Controllers/HealthController.php index c22c23f49..0fda25683 100644 --- a/storage/plugins/mobile-api/src/Controllers/HealthController.php +++ b/storage/plugins/mobile-api/src/Controllers/HealthController.php @@ -95,6 +95,12 @@ public function index( 'staff_catalog' => true, 'catalog_sync' => true, 'calendar_feed' => !$catalogueMode, + // Mobile API 1.9.0: the optional circulation modules of + // Settings -> Loans (already false in catalogue mode). + // Older servers omit these keys: the app treats them as off. + 'fines' => \App\Support\Features::enabled('fines'), + 'closures_calendar' => \App\Support\Features::enabled('closures_calendar'), + 'circulation_rules' => \App\Support\Features::enabled('circulation_rules'), // Mobile API 1.7.0 staff areas: true only when the area's // routes are registered (StaffAreas::IMPLEMENTED) AND its // prerequisite holds. Older servers omit these keys. diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 6523cb99c..47901d87d 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -723,6 +723,7 @@ private function paths(): array ]]], ]]]], '401' => ['$ref' => '#/components/responses/Unauthorized'], + '404' => ['description' => 'feature_disabled: fines are switched off on this instance (features.fines false in /health) or catalogue mode is on.'], '500' => ['$ref' => '#/components/responses/InternalError'], ], ], @@ -1677,6 +1678,9 @@ private function healthPayloadSchema(): array 'messages' => ['type' => 'boolean'], 'notifications' => ['type' => 'boolean'], 'push' => ['type' => 'boolean'], + 'fines' => ['type' => 'boolean', 'description' => 'Since 1.9.0: fines and the patron ledger are on (GET /me/movements answers). Absent on older servers: treat as false.'], + 'closures_calendar' => ['type' => 'boolean', 'description' => 'Since 1.9.0: the closures calendar is applied (availability closed_dates may be non-empty).'], + 'circulation_rules' => ['type' => 'boolean', 'description' => 'Since 1.9.0: patron types and the rules matrix apply (loanable / reservations_enabled may be false).'], ], ], 'catalogue_mode' => [ diff --git a/storage/plugins/z39-server/Z39ServerPlugin.php b/storage/plugins/z39-server/Z39ServerPlugin.php index d2a3e732e..64e17abd1 100644 --- a/storage/plugins/z39-server/Z39ServerPlugin.php +++ b/storage/plugins/z39-server/Z39ServerPlugin.php @@ -736,21 +736,21 @@ public function registerRoutes($app): void ResponseInterface $response ) use ($plugin): ResponseInterface { return $plugin->copyCatalogingPageAction($request, $response); - })->add($adminMiddleware); + })->add(new \App\Middleware\FeatureGateMiddleware('marc'))->add($adminMiddleware); $app->post('/admin/books/copy-cataloging/search', function ( ServerRequestInterface $request, ResponseInterface $response ) use ($plugin): ResponseInterface { return $plugin->copyCatalogingSearchAction($request, $response); - })->add($csrfMiddleware)->add($adminMiddleware); + })->add(new \App\Middleware\FeatureGateMiddleware('marc'))->add($csrfMiddleware)->add($adminMiddleware); $app->post('/admin/books/copy-cataloging/import', function ( ServerRequestInterface $request, ResponseInterface $response ) use ($plugin): ResponseInterface { return $plugin->copyCatalogingImportAction($request, $response); - })->add($csrfMiddleware)->add($adminMiddleware); + })->add(new \App\Middleware\FeatureGateMiddleware('marc'))->add($csrfMiddleware)->add($adminMiddleware); \App\Support\SecureLogger::debug('[Z39 Server Plugin] Routes registered', [ 'routes' => [ From 08ecb333cfca6dab16131307470047dab1ec6766 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 00:28:01 +0100 Subject: [PATCH 195/398] Calendar: choose the country, region and holidays to close for The holiday card of /admin/calendar lists the holidays of the chosen country and region with their native names and the dates of the preview year; the librarian ticks them (select all / none) and applies. Holiday rows carry a badge in the closures list and are changed from the selection only. A new card switches the yearly closures added by hand or from ICS on and off without deleting them. Maintenance keeps the movable holidays dated two years ahead and logs holiday_rows_added. Interface strings in the five locales; the old Italian-only import strings go. --- .../Admin/LibraryCalendarController.php | 176 ++++++++++++++++-- app/Routes/web.php | 7 +- app/Support/MaintenanceService.php | 34 +++- app/Views/admin/calendar.php | 154 +++++++++++++-- cron/full-maintenance.php | 1 + locale/da_DK.json | 33 +++- locale/de_DE.json | 33 +++- locale/en_US.json | 33 +++- locale/fr_FR.json | 33 +++- locale/it_IT.json | 33 +++- tests/calendar-permission.unit.php | 2 +- 11 files changed, 475 insertions(+), 64 deletions(-) diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php index 211f4ec59..25c98a160 100644 --- a/app/Controllers/Admin/LibraryCalendarController.php +++ b/app/Controllers/Admin/LibraryCalendarController.php @@ -6,6 +6,8 @@ use App\Models\SettingsRepository; use App\Support\DateHelper; +use App\Support\Holidays\HolidayCalculator; +use App\Support\Holidays\HolidayPresets; use App\Support\LibraryCalendar; use App\Support\SecureLogger; use Psr\Http\Message\ResponseInterface as Response; @@ -16,8 +18,9 @@ * Admin page /admin/calendar: the days the library is closed. * * Weekly closed days (loans.closed_weekdays), closure intervals with yearly - * recurrence and optional branch, the Italian national holidays preset, ICS - * import and a monthly preview. AdminAuthMiddleware also admits staff; like + * recurrence and optional branch, the national holiday presets (a country, + * optionally a region, and the holidays the library ticks), the yearly + * closures switched on and off, ICS import and a monthly preview. AdminAuthMiddleware also admits staff; like * the settings pages, every action is reserved to Permissions::mayConfigure(). */ class LibraryCalendarController @@ -45,10 +48,15 @@ public function index(Request $request, Response $response, \mysqli $db): Respon $editId = isset($query['edit']) && is_numeric($query['edit']) ? (int) $query['edit'] : 0; $editing = null; foreach ($closures as $row) { - if ((int) $row['id'] === $editId) { + // A preset row changes with the selection, never by hand. + if ((int) $row['id'] === $editId && ($row['regola_festivita'] ?? null) === null) { $editing = $row; } } + $yearlyClosures = array_values(array_filter( + $closures, + static fn (array $row): bool => (int) $row['ricorrente_annuale'] === 1 && ($row['regola_festivita'] ?? null) === null + )); $monthStart = $month . '-01'; $monthEnd = (new \DateTimeImmutable($monthStart, new \DateTimeZone('UTC')))->modify('last day of this month')->format('Y-m-d'); @@ -56,6 +64,7 @@ public function index(Request $request, Response $response, \mysqli $db): Respon $prevMonth = (new \DateTimeImmutable($monthStart, new \DateTimeZone('UTC')))->modify('-1 month')->format('Y-m'); $nextMonth = (new \DateTimeImmutable($monthStart, new \DateTimeZone('UTC')))->modify('+1 month')->format('Y-m'); $currentYear = (int) substr($today, 0, 4); + $holidays = $this->holidayPreview($db, $query, $currentYear); ob_start(); require __DIR__ . '/../../Views/admin/calendar.php'; @@ -116,6 +125,10 @@ public function update(Request $request, Response $response, \mysqli $db, int $i $_SESSION['error_message'] = __('Chiusura non trovata.'); return $this->back($response); } + if ($this->isPresetRow($db, $id)) { + $_SESSION['error_message'] = __('Le festività si modificano dalla selezione delle festività nazionali.'); + return $this->back($response); + } $row = $this->validatedClosure($db, (array) $request->getParsedBody()); if (is_string($row)) { $_SESSION['error_message'] = $row; @@ -137,6 +150,10 @@ public function delete(Request $request, Response $response, \mysqli $db, int $i if ($guard = $this->guard($response)) { return $guard; } + if ($this->isPresetRow($db, $id)) { + $_SESSION['error_message'] = __('Le festività si modificano dalla selezione delle festività nazionali.'); + return $this->back($response); + } $stmt = $db->prepare('DELETE FROM calendario_chiusure WHERE id = ?'); $stmt->bind_param('i', $id); $stmt->execute(); @@ -147,29 +164,85 @@ public function delete(Request $request, Response $response, \mysqli $db, int $i return $this->back($response); } + /** + * Apply the holidays ticked for a country (and region): the selection is + * saved, rows of unticked holidays are removed, missing ones added. + * Closures added by hand or from an ICS file are not touched. + */ public function importPreset(Request $request, Response $response, \mysqli $db): Response { if ($guard = $this->guard($response)) { return $guard; } $data = (array) $request->getParsedBody(); - $currentYear = (int) substr(DateHelper::today(), 0, 4); - $year = is_scalar($data['year'] ?? null) ? (int) $data['year'] : $currentYear; - if ($year < $currentYear - 1 || $year > $currentYear + 10) { - $_SESSION['error_message'] = __('Anno non valido.'); + $country = is_string($data['country'] ?? null) ? strtoupper(trim($data['country'])) : ''; + if (HolidayCalculator::country($country) === null) { + $_SESSION['error_message'] = __('Paese non valido.'); return $this->back($response); } - $added = 0; - foreach (LibraryCalendar::italianHolidays($year) as $holiday) { - $row = ['start' => $holiday['start'], 'end' => $holiday['end'], 'description' => $holiday['description'], 'yearly' => $holiday['yearly'], 'sede' => null]; - if (!$this->alreadyClosed($db, $row)) { - $this->insertClosure($db, $row); - $added++; + $region = HolidayCalculator::normaliseRegion($country, is_string($data['region'] ?? null) ? trim($data['region']) : ''); + if (!HolidayPresets::schemaReady($db)) { + $_SESSION['error_message'] = __("Esegui prima l'aggiornamento del database: manca la colonna delle festività."); + return $this->backToPreset($response, $country, $region); + } + $ids = []; + foreach (is_array($data['rules'] ?? null) ? $data['rules'] : [] as $id) { + if (is_string($id)) { + $ids[] = $id; } } + try { + $counts = HolidayPresets::apply($db, $country, $region, $ids); + } catch (\Throwable $e) { + SecureLogger::error('Calendar: holiday preset not applied: ' . $e->getMessage()); + $_SESSION['error_message'] = __('Festività non applicate: riprova.'); + return $this->backToPreset($response, $country, $region); + } $this->calendarChanged($db); - $_SESSION['success_message'] = __('Festività nazionali importate: %d nuove chiusure.', $added); - return $this->back($response); + $_SESSION['success_message'] = $counts['adopted'] > 0 + ? __('Festività applicate: %1$d aggiunte, %2$d rimosse, %3$d già presenti riconosciute.', $counts['added'], $counts['removed'], $counts['adopted']) + : __('Festività applicate: %1$d aggiunte, %2$d rimosse.', $counts['added'], $counts['removed']); + return $this->backToPreset($response, $country, $region); + } + + /** + * Switch the yearly closures added by hand (or from a file) on and off + * without deleting them: a switched-off closure closes no day. + */ + public function saveYearly(Request $request, Response $response, \mysqli $db): Response + { + if ($guard = $this->guard($response)) { + return $guard; + } + if (!HolidayPresets::schemaReady($db)) { + $_SESSION['error_message'] = __("Esegui prima l'aggiornamento del database: manca la colonna delle festività."); + return $this->back($response); + } + $data = (array) $request->getParsedBody(); + $ids = static function (mixed $raw): array { + $out = []; + foreach (is_array($raw) ? $raw : [] as $value) { + if (is_scalar($value) && ctype_digit((string) $value)) { + $out[(int) $value] = (int) $value; + } + } + return $out; + }; + $listed = $ids($data['listed'] ?? []); + $active = $ids($data['active'] ?? []); + $changed = 0; + $stmt = $db->prepare('UPDATE calendario_chiusure SET attiva = ? WHERE id = ? AND ricorrente_annuale = 1 AND regola_festivita IS NULL AND attiva <> ?'); + foreach ($listed as $id) { + $flag = isset($active[$id]) ? 1 : 0; + $stmt->bind_param('iii', $flag, $id, $flag); + $stmt->execute(); + $changed += max(0, $stmt->affected_rows); + } + $stmt->close(); + $this->calendarChanged($db); + $_SESSION['success_message'] = __('Chiusure annuali salvate: %d modificate.', $changed); + $target = url('/admin/calendar') . '#calendar-yearly'; + return $response->withHeader('Location', $target)->withStatus(302); } public function importIcs(Request $request, Response $response, \mysqli $db): Response @@ -389,12 +462,81 @@ private function alreadyClosed(\mysqli $db, array $row): bool /** @return list> */ private function closures(\mysqli $db): array { - $result = $db->query('SELECT c.id, c.data_inizio, c.data_fine, c.descrizione, c.ricorrente_annuale, c.sede_id, s.nome AS sede_nome + // Before the holiday-presets migration the two columns are missing: + // every row is a manual, active one. + $presetColumns = HolidayPresets::schemaReady($db) ? 'c.regola_festivita, c.attiva' : 'NULL AS regola_festivita, 1 AS attiva'; + $result = $db->query("SELECT c.id, c.data_inizio, c.data_fine, c.descrizione, c.ricorrente_annuale, c.sede_id, {$presetColumns}, s.nome AS sede_nome FROM calendario_chiusure c LEFT JOIN sedi s ON s.id = c.sede_id - ORDER BY c.ricorrente_annuale DESC, c.data_inizio, c.data_fine, c.id'); + ORDER BY c.ricorrente_annuale DESC, c.data_inizio, c.data_fine, c.id"); return $result === false ? [] : $result->fetch_all(MYSQLI_ASSOC); } + private function isPresetRow(\mysqli $db, int $id): bool + { + if (!HolidayPresets::schemaReady($db)) { + return false; + } + $stmt = $db->prepare('SELECT 1 FROM calendario_chiusure WHERE id = ? AND regola_festivita IS NOT NULL LIMIT 1'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $found = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $found; + } + + private function backToPreset(Response $response, string $country, string $region): Response + { + $params = ['holiday_country' => $country]; + if ($region !== '') { + $params['holiday_region'] = $region; + } + $target = url('/admin/calendar') . '?' . http_build_query($params) . '#calendar-preset'; + return $response->withHeader('Location', $target)->withStatus(302); + } + + /** + * What the holiday card shows: the country / region / preview year asked + * for in the query (the saved choice otherwise) and, for each holiday, + * its date in that year and whether it is ticked. Ticked = the saved + * choice for the saved country and region; for another one the + * statutory holidays (optional ones stay unticked). + * + * @param array $query + * @return array{countries: array, country: string, regions: array, region: string, years: list, year: int, list: list, saved: bool, schema: bool} + */ + private function holidayPreview(\mysqli $db, array $query, int $currentYear): array + { + $selection = HolidayPresets::selection($db); + $country = is_string($query['holiday_country'] ?? null) ? strtoupper($query['holiday_country']) : ''; + if (HolidayCalculator::country($country) === null) { + $country = $selection['country']; + } + $rawRegion = is_string($query['holiday_region'] ?? null) + ? $query['holiday_region'] + : ($country === $selection['country'] ? $selection['region'] : ''); + $region = HolidayCalculator::normaliseRegion($country, $rawRegion); + $years = range($currentYear, $currentYear + HolidayPresets::YEARS_AHEAD); + $year = is_scalar($query['holiday_year'] ?? null) && in_array((int) $query['holiday_year'], $years, true) ? (int) $query['holiday_year'] : $currentYear; + $savedHere = $selection['saved'] && $selection['country'] === $country && $selection['region'] === $region; + $ticked = array_flip($selection['rules']); + $list = []; + foreach (HolidayCalculator::forYear($country, $region, $year) as $holiday) { + $holiday['checked'] = $savedHere ? isset($ticked[$holiday['id']]) : !$holiday['optional']; + $list[] = $holiday; + } + return [ + 'countries' => HolidayCalculator::countryNames(), + 'country' => $country, + 'regions' => HolidayCalculator::regions($country), + 'region' => $region, + 'years' => $years, + 'year' => $year, + 'list' => $list, + 'saved' => $savedHere, + 'schema' => HolidayPresets::schemaReady($db), + ]; + } + /** @var array|null */ private ?array $branchCache = null; diff --git a/app/Routes/web.php b/app/Routes/web.php index bcabd95c7..937fe280c 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -843,7 +843,7 @@ })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // Closures calendar (Admin Only): weekly closed days, closure intervals, - // holiday preset, ICS import. Every action re-checks mayConfigure() inline. + // holiday presets, yearly closures on/off, ICS import. Every action re-checks mayConfigure() inline. $app->get('/admin/calendar', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->index($request, $response, $db); @@ -874,6 +874,11 @@ return (new \App\Controllers\Admin\LibraryCalendarController())->importPreset($request, $response, $db); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/calendar/yearly', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\Admin\LibraryCalendarController())->saveYearly($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/calendar/import-ics', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); return (new \App\Controllers\Admin\LibraryCalendarController())->importIcs($request, $response, $db); diff --git a/app/Support/MaintenanceService.php b/app/Support/MaintenanceService.php index d4baa37c0..68252636d 100644 --- a/app/Support/MaintenanceService.php +++ b/app/Support/MaintenanceService.php @@ -107,7 +107,7 @@ public function runIfNeeded(int $cooldownMinutes = 60): array * overdue loan updates, expired pickups, notifications, and ICS calendar generation. * Each task is wrapped in try-catch to prevent failures from blocking others. * - * @return array{skipped: true, reason: 'in_progress'}|array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, loan_recalls: int, pickup_reminders: int, card_expiry_warnings: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, errors: array} Results for each maintenance task, or the skip marker when another connection holds the maintenance lock + * @return array{skipped: true, reason: 'in_progress'}|array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, loan_recalls: int, pickup_reminders: int, card_expiry_warnings: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, holiday_rows_added?: int, errors: array} Results for each maintenance task, or the skip marker when another connection holds the maintenance lock */ public function runAll(): array { @@ -147,7 +147,7 @@ public function runAll(): array } } - /** @return array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, loan_recalls: int, pickup_reminders: int, card_expiry_warnings: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, errors: array} */ + /** @return array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, loan_recalls: int, pickup_reminders: int, card_expiry_warnings: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, holiday_rows_added?: int, errors: array} */ private function runAllLocked(): array { $results = [ @@ -199,6 +199,16 @@ private function runAllLocked(): array $results['errors'][] = 'legacyPenaltyBackfill: ' . $e->getMessage(); } + // Holiday presets (0.9.0): the movable holidays the library ticked get + // their rows up to two years ahead, so a new year never starts with + // Easter missing. Before the due dates and pickups of this pass. + try { + $results['holiday_rows_added'] = $this->extendHolidayRows(); + } catch (\Throwable $e) { + $results['errors'][] = 'extendHolidayRows: ' . $e->getMessage(); + SecureLogger::error(__('MaintenanceService errore festività'), ['error' => $e->getMessage()]); + } + // Overdue flip FIRST (#366 residual): flip date-overdue 'in_corso' loans // to 'in_ritardo' BEFORE expiries/activations/promotions, so every gate // in this same pass that special-cases 'in_ritardo' (capacity clamps, @@ -470,6 +480,26 @@ public static function icsHasPersonalData(string $ics): bool * * @return bool True if file was generated successfully, false otherwise */ + /** + * The rows of the chosen movable holidays up to two years ahead + * (HolidayPresets::ensureYears()). Skipped while the closures calendar + * is switched off (features.closures_calendar, when that toggle exists). + * Pending pickups are re-checked when rows were added. + */ + public function extendHolidayRows(): int + { + $toggle = ['App\\Support\\Features', 'enabled']; + if (is_callable($toggle) && !call_user_func($toggle, 'closures_calendar')) { + return 0; + } + $added = \App\Support\Holidays\HolidayPresets::ensureYears($this->db); + if ($added > 0) { + LibraryCalendar::reset(); + LibraryCalendar::extendPickupDeadlines($this->db); + } + return $added; + } + public function generateIcsCalendar(): bool { $icsGenerator = new IcsGenerator($this->db); diff --git a/app/Views/admin/calendar.php b/app/Views/admin/calendar.php index e62f7b58a..0f23af73b 100644 --- a/app/Views/admin/calendar.php +++ b/app/Views/admin/calendar.php @@ -13,6 +13,8 @@ * @var array $monthClosed * @var int $currentYear * @var string $today + * @var list> $yearlyClosures + * @var array{countries: array, country: string, regions: array, region: string, years: list, year: int, list: list, saved: bool, schema: bool} $holidays */ $csrf = \App\Support\Csrf::ensureToken(); @@ -170,31 +172,125 @@ + +
      +
      +

      + + +

      +
      +
      +

      +

      + +
      + + +
      + +
      + + +
      + +
      + + +
      + + + +
      + + + +
      + + +
      +
      + + + +
      +
      + +
      +
      +
      +
      +
      - -
      + +

      - - + +

      -
      + -

      -
      - - -
      +

      + +

      + +
      + + + +
      +
      -
      @@ -323,13 +419,23 @@
      – + + + + + + + + @@ -339,6 +445,9 @@ + + + +
      - + @@ -1202,7 +1202,7 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm $canEdit = empty($copia['prestito_id']) || $bookAtLibrary; $canDelete = $canEdit && in_array($rawCopiaStatus, ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento'], true); ?> - +
      diff --git a/app/Views/partials/settings-sections.php b/app/Views/partials/settings-sections.php index 394fe9a66..5441906bd 100644 --- a/app/Views/partials/settings-sections.php +++ b/app/Views/partials/settings-sections.php @@ -20,9 +20,8 @@ // guards every page and form the same way). Staff reaches the settings page, // but neither sees the section in the navigation nor reaches its tab: // ?tab=circulation falls back to the default section like an unknown tab. -// With the patron types module switched off (Settings → Loans) the section -// is absent for everyone, like an unknown tab. -if (\App\Support\Permissions::mayConfigure() && \App\Support\Features::enabled('circulation_rules')) { +// Administrators can prepare the configuration while the module is off. +if (\App\Support\Permissions::mayConfigure()) { $settingsCatalogue['circulation'] = ['group' => __('Biblioteca'), 'label' => __('Circolazione'), 'description' => __('Tipologie di lettore e regole di prestito per tipo di materiale.'), 'icon' => 'fa-id-badge']; } // Optional modules: switching one off is library policy, so the tab follows @@ -31,7 +30,7 @@ $settingsCatalogue['modules'] = ['group' => __('Sito e sistema'), 'label' => __('Moduli'), 'description' => __('Attiva o disattiva i moduli opzionali della biblioteca.'), 'icon' => 'fa-toggle-on']; } // The library cards tab belongs to the patron cards module. -if (!\App\Support\Features::enabled('patron_cards')) { +if (!\App\Support\Features::enabled('patron_cards') && !\App\Support\Permissions::mayConfigure()) { unset($settingsCatalogue['cards']); } return $settingsCatalogue; diff --git a/app/Views/settings/cards-tab.php b/app/Views/settings/cards-tab.php index 85ea50c4d..af4afa22e 100644 --- a/app/Views/settings/cards-tab.php +++ b/app/Views/settings/cards-tab.php @@ -18,6 +18,9 @@ ?>
      + +

      + diff --git a/app/Views/settings/circulation-tab.php b/app/Views/settings/circulation-tab.php index 110485c2c..9b59774e1 100644 --- a/app/Views/settings/circulation-tab.php +++ b/app/Views/settings/circulation-tab.php @@ -14,6 +14,9 @@ $cMoneyPlaceholder = static fn (?float $v): string => $v !== null && $v > 0 ? number_format($v, 2, '.', '') : '—'; ?>
      + +

      +
      diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index 3d8d03201..89469491b 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -893,7 +893,7 @@ class="mt-1 w-4 h-4 aspect-square shrink-0 text-gray-900 focus:ring-gray-500">
      - +
      diff --git a/app/Views/settings/loans-tab.php b/app/Views/settings/loans-tab.php index 442defa1f..c16662847 100644 --- a/app/Views/settings/loans-tab.php +++ b/app/Views/settings/loans-tab.php @@ -124,6 +124,15 @@ class="toggle-checkbox sr-only">
      + + +
      + + +
      @@ -156,7 +165,7 @@ class="toggle-checkbox sr-only">
      - + @@ -506,12 +515,12 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr -

      +

      - +
      @@ -544,6 +553,18 @@ class="toggle-checkbox sr-only">
      + + 0): ?> +

      + +

      +
      + + +

      - +

      @@ -624,7 +645,7 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr

      - +

      diff --git a/app/Views/settings/modules-tab.php b/app/Views/settings/modules-tab.php index 51a5fa961..86014aa6c 100644 --- a/app/Views/settings/modules-tab.php +++ b/app/Views/settings/modules-tab.php @@ -13,13 +13,13 @@ 'icon' => 'fa-users', 'title' => __('Utenti'), 'text' => __('Strumenti del modulo utenti: stampa delle tessere e caricamento massivo dei lettori.'), - 'features' => ['patron_cards', 'user_import'], + 'features' => ['patron_cards', 'patron_photos', 'user_import'], ], 'public' => [ 'icon' => 'fa-door-open', 'title' => __('Servizi al pubblico'), 'text' => __('Servizi rivolti a chi entra in biblioteca.'), - 'features' => ['visitor_log'], + 'features' => ['visitor_log', 'attendance_retention'], ], ]; ?> @@ -52,6 +52,9 @@

      + +
      + + +

      +
      + +
      + diff --git a/app/Views/settings/partials/circulation-type-fields.php b/app/Views/settings/partials/circulation-type-fields.php index 7bf83dd13..f458d0c05 100644 --- a/app/Views/settings/partials/circulation-type-fields.php +++ b/app/Views/settings/partials/circulation-type-fields.php @@ -46,12 +46,14 @@
      +

      +

      diff --git a/app/Views/settings/partials/feature-switch.php b/app/Views/settings/partials/feature-switch.php new file mode 100644 index 000000000..355094a96 --- /dev/null +++ b/app/Views/settings/partials/feature-switch.php @@ -0,0 +1,20 @@ + +
      +
      + +

      +
      + +
      diff --git a/app/Views/utenti/crea_utente.php b/app/Views/utenti/crea_utente.php index b5236ee5b..4f266cb7e 100644 --- a/app/Views/utenti/crea_utente.php +++ b/app/Views/utenti/crea_utente.php @@ -173,12 +173,14 @@ +
      +
      diff --git a/app/Views/utenti/dettagli_utente.php b/app/Views/utenti/dettagli_utente.php index 2a9db788a..de554a436 100644 --- a/app/Views/utenti/dettagli_utente.php +++ b/app/Views/utenti/dettagli_utente.php @@ -158,7 +158,7 @@ unset($workspaceHeader); ?> - +

      @@ -175,12 +175,14 @@

      + + @@ -337,7 +339,7 @@

      diff --git a/app/Views/utenti/modifica_utente.php b/app/Views/utenti/modifica_utente.php index 538cd2482..9de9f28f5 100644 --- a/app/Views/utenti/modifica_utente.php +++ b/app/Views/utenti/modifica_utente.php @@ -167,7 +167,7 @@
      - +

      @@ -237,12 +237,14 @@
      +
      +
      diff --git a/app/Views/utenti/tipologia-lettore.php b/app/Views/utenti/tipologia-lettore.php index d04f7abeb..de9f27119 100644 --- a/app/Views/utenti/tipologia-lettore.php +++ b/app/Views/utenti/tipologia-lettore.php @@ -17,7 +17,7 @@

      diff --git a/docs/reviews/pr-512-settings-flexibility-2026-10-29.md b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md index dd24a7195..88b9cc33c 100644 --- a/docs/reviews/pr-512-settings-flexibility-2026-10-29.md +++ b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md @@ -1,125 +1,76 @@ -# PR #512: workflow, 26 test e flessibilità delle impostazioni +# PR #512: interruttori, aggiornamenti e verifiche -Verifica del 29 ottobre 2026 sulla catena #503–#512, con #502 esclusa dalla review come richiesto. Le correzioni funzionali della #512 sono nel commit `3960c194`; la suite nel commit `d72fa1d4` comprende i 25 scenari richiesti e la regressione aggiuntiva sulla conservazione del comportamento durante gli aggiornamenti. +Aggiornamento del 29 ottobre 2026 sulla catena #503–#512, con #502 esclusa dalla review. Gli undici moduli hanno ora **18 interruttori persistenti**: dieci in **Impostazioni → Moduli**, otto in **Impostazioni → Prestiti**. I controlli nuovi riprendono etichette, descrizioni, accessibilità e stile degli interruttori esistenti. -## Esito dei workflow +## Controlli applicati -La tabella distingue la batteria avviata sulle correzioni funzionali dalla successiva verifica della nuova suite. Gli esiti si riferiscono al commit indicato, non a run precedenti della stessa PR. - -Tutti i dieci workflow delle correzioni sono terminati con successo. Anche Code Quality e la matrice database sulla suite aggiornata di 26 scenari sono verdi: non sono emersi errori CI da correggere. - -### Correzioni funzionali: `3960c194` - -| Workflow | Esito | Run | +| Dove | Controllo | Comportamento | +| --- | --- | --- | +| Moduli → Utenti | Stampa delle tessere | Abilita PDF singoli e multipli. Le impostazioni di stampa si possono preparare anche a modulo spento. | +| Moduli → Utenti | Foto dei lettori | Acquisizione, visualizzazione, modifica e cancellazione indipendenti dalla stampa. Spento, blocca anche gli URL diretti ed esclude le foto dai PDF; conserva i file esistenti. | +| Moduli → Servizi al pubblico | Registro presenze | Abilita la raccolta di nuovi ingressi. | +| Moduli → Servizi al pubblico | Conservazione a registro sospeso | Consente di applicare la conservazione mentre la raccolta è spenta, anche in modalità catalogo. Parte spento; non avvia cancellazioni durante l’aggiornamento. | +| Moduli → Servizi al pubblico | Giorni di conservazione | Modificabili anche a registro spento. `0` impedisce sempre la cancellazione automatica. | +| Prestiti → Regole lettore | Regole per tipologia | Attiva tipologie, matrice, durata e limiti per lettore e materiale. Gli amministratori possono preparare la configurazione a modulo spento. | +| Prestiti → Regole lettore | Copie in sola consultazione | Applica il vincolo fisico anche con le regole per tipologia spente. Controllo condiviso da banco, sito, app, allocazione SQL e integrazioni. | +| Prestiti → Regole lettore | Validità automatica delle tessere | Applica la durata della tipologia a nuove iscrizioni e approvazioni. Spento, conserva la scadenza già salvata; il rinnovo manuale resta disponibile. | +| Prestiti → Regole lettore | Base del rinnovo tessera | Scelta tra oggi, comportamento precedente, e scadenza futura esistente. Una scadenza già passata riparte da oggi. | +| Prestiti → Calendario | Calendario chiusure | Sospende l’applicazione del calendario senza cancellarlo. Gli amministratori possono preparare chiusure e festività e vederne l’anteprima anche a modulo spento. Anno delle festività selezionabile dal 1900 al 2199. | +| Prestiti → Multe | Registro movimenti | Addebiti manuali, pagamenti, ricevute, condoni e saldo. Può funzionare con tutti e tre gli automatismi successivi spenti. | +| Prestiti → Multe | Multe automatiche per ritardo | Richiede registro attivo, interruttore dedicato e tariffa positiva. Le tariffe per tipologia e materiale non aggirano il controllo. | +| Prestiti → Multe | Blocco per debito | Richiede registro attivo, interruttore dedicato e soglia positiva. Funziona senza nuove multe automatiche. | +| Prestiti → Multe | Riporto penalità per perdita e danno | Riporta soltanto importi già assegnati, compresi quelli precedenti. Il form mostra quantità e totale degli importi da riportare. La riattivazione riapre la scansione idempotente. | +| Prestiti → Multe | Modalità di tolleranza | Soglia, come prima: oltre la tolleranza si conta dal primo giorno. Oppure abbuono: si sottraggono sempre i giorni di tolleranza. | + +Gli altri controlli restano nelle sezioni esistenti: inventario, MARC, schede catalografiche, fornitori, tipi RDA e importazione CSV. Inventario senza dichiarazione di perdita, scelta dei duplicati MARC, inviti email, campi facoltativi e contenuti della tessera continuano a richiedere le rispettive scelte esplicite. + +Accanto ai campi ereditabili sono indicati i significati di vuoto e zero: rinnovi vietati, nessun limite, nessun tetto, nessuna tolleranza oppure disattivazione per tipologia. Il riepilogo delle regole e i valori ereditati della matrice restano disponibili. + +## Garanzia sugli aggiornamenti + +**Se le multe non erano previste, l’aggiornamento non le introduce.** + +- Ogni nuovo controllo separato eredita lo stato del precedente controllo soltanto quando la preferenza è assente. Un valore salvato, compreso `0`, viene conservato. +- Nuovi controlli assenti e registro precedentemente spento: multe automatiche, blocco e riporto penalità vengono salvati spenti, anche in presenza di tariffe positive, tipologie configurate o penalità pregresse. +- Attivare successivamente il solo registro lascia spenti gli automatismi. Tariffe e soglie restano memorizzate per una scelta futura esplicita. +- La migrazione non riporta penalità quando il registro o il relativo controllo sono spenti. Gli importi precedenti rimangono in `prestiti.sanzione`; i movimenti già esistenti rimangono conservati. +- I vecchi moduli di salvataggio, che non inviano i nuovi campi, conservano le preferenze. Prima di cambiare il vecchio controllo vengono fissate le scelte ereditate, così non seguono accidentalmente la successiva riattivazione. +- La modalità catalogo sospende la circolazione mantenendo gli interruttori salvati. La cancellazione delle presenze a registro sospeso richiede la propria attivazione esplicita. +- Le nuove scelte di tolleranza e rinnovo mantengono i comportamenti precedenti come default. I prestiti esistenti mantengono la propria scadenza. + +Gli interruttori e le nuove regole di servizio sono riservati agli amministratori e ai ruoli con capacità di configurazione. L’accesso alla configurazione di un modulo spento conserva autenticazione, CSRF e controllo del ruolo; le rotte operative restano protette. + +## Verifiche dell’implementazione + +- **325 suite PHP superate, 0 saltate, 0 fallite**, su database MySQL temporaneo isolato. +- **37 scenari di integrazione** in [`tests/stack-090-settings-flexibility.unit.php`](../../tests/stack-090-settings-flexibility.unit.php): i 25 richiesti, la regressione sugli aggiornamenti e 11 verifiche aggiuntive sui nuovi controlli. +- Migrazione completa ripetuta con moduli accesi, spenti, combinazioni miste, modalità catalogo e schema reale 0.8.9. Fixture inclusa nel repository per rendere la verifica obbligatoria anche nei checkout senza cronologia. +- Test specifico con registro spento, tariffe globali e di tipologia positive e penalità precedenti: due migrazioni non producono addebiti né riattivazioni. Accendere poi il solo registro lascia spenti gli automatismi. +- PHPStan 2.1.56, configurazione completa del repository: nessun errore. +- Gate delle migrazioni, schema e auto-riparazione dei plugin: superato. +- Registro dei 18 controlli, distribuzione nei form, protezioni delle rotte, seed delle cinque lingue e traduzioni: verificati. +- **25 test browser superati**: salvataggio dei controlli, tastiera, permessi, configurazione a modulo spento, calendario, foto e consultazione indipendenti. +- Controllo visivo nel browser a 1280 px e 390 px: nessun overflow orizzontale nei nuovi gruppi di impostazioni. + +## Workflow già verificati prima di questa estensione + +Gli esiti seguenti riguardano esplicitamente i commit indicati; non vengono attribuiti alle modifiche successive. + +| Workflow sulle correzioni `3960c194` | Esito | Run | | --- | --- | --- | | Code Quality | Superato | [40830554173](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830554173) | -| E2E Test Suite | Superato: generale, catalogazione e circolazione 0.9.0 | [40830557203](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830557203) | +| E2E Test Suite | Superato | [40830557203](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830557203) | | Browser Compatibility and DAST | Superato | [40830559955](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830559955) | -| Deep Regression Gate | Superato: quattro shard browser e cinque installazioni localizzate | [40830562699](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830562699) | +| Deep Regression Gate | Superato | [40830562699](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830562699) | | Database Compatibility | Superato | [40830565681](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830565681) | | Security and Supply Chain | Superato | [40830568665](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830568665) | -| Real Upgrade, interfaccia admin | Superato | [40830571304](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830571304) | +| Real Upgrade | Superato | [40830571304](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830571304) | | Upgrade Smoke Test | Superato | [40830574075](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830574075) | | Test Database Migrations | Superato | [40830576583](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830576583) | -| CodeQL Security Analysis | Superato | [40830579689](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830579689) | +| CodeQL | Superato | [40830579689](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40830579689) | -### Nuova suite: `d72fa1d4`, 26 scenari +La precedente suite di 26 scenari, commit `d72fa1d4`, ha superato [Code Quality](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40844504696) e la [matrice MySQL 8.0/8.4 e MariaDB 10.11/11.4](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40844508132). -| Verifica | Esito | Run | -| --- | --- | --- | -| Esecuzione locale, MySQL isolato | 26 superati, 0 falliti | `php tests/stack-090-settings-flexibility.unit.php` | -| Code Quality | Superato | [40844504696](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40844504696) | -| Database Compatibility | Superato: 26/26 scenari su MySQL 8.0/8.4 e MariaDB 10.11/11.4 | [40844508132](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40844508132) | +## Ulteriori opzioni di prodotto già proposte -## Cosa è già configurabile - -Tutti gli undici moduli opzionali introdotti hanno un interruttore persistente. Otto sono in **Impostazioni → Moduli**; regole lettore, calendario e multe sono in **Impostazioni → Prestiti**. Gli interruttori sono scelte di configurazione: lo staff non può modificarli; valgono i controlli amministrativi e la capacità di configurazione previsti dall'applicazione. - -Spegnere un modulo conserva i suoi dati. La modalità catalogo sospende regole lettore, calendario, multe e presenze senza cancellare le scelte salvate: uscendone, i moduli riprendono lo stato scelto. - -| Ambito | Dove si spegne | Scelte già disponibili | -| --- | --- | --- | -| Revisione inventariale | Moduli → Revisione inventariale | Sede e filtri per sessione; chiusura con o senza dichiarazione di perdita delle copie mancanti; annullamento; report congelato. | -| MARC | Moduli → Import ed export MARC | Duplicati: salta, completa, sovrascrivi; copertine facoltative; export per formato e selezione di libri. | -| Schede catalografiche | Moduli → Schede catalografiche | Stampa di un libro o di una selezione; l'esportazione richiede un'azione esplicita. | -| Fornitori | Moduli → Fornitori | Fornitore facoltativo per libro o importazione; eliminare il fornitore scollega i libri senza eliminarli. | -| Tipi RDA | Moduli → Tipi RDA | Valori derivati dal materiale oppure override per record; gli override rimangono conservati quando il modulo è spento. | -| Tessere e foto | Moduli → Tessere e foto dei lettori | Impostazioni → Tessere: layout, codice a barre/QR, foglio/singola, titolo e retro; foto, logo e scadenza si possono omettere dalla stampa. | -| Import utenti CSV | Moduli → Importazione utenti da CSV | Mappatura dei campi e inviti email facoltativi; tipologia esclusa dalla mappatura e dall'assegnazione quando le regole lettore sono spente. | -| Presenze | Moduli → Registro presenze | Conservazione configurabile nel registro; `0` disattiva la cancellazione automatica. La gestione della conservazione segue il controllo amministrativo del registro. | -| Regole lettore | Prestiti → Regole per tipologia di lettore | Regole globali, tipologie e matrice per materiale; durata, rinnovi, limiti, prenotazioni, validità tessera e consultazione. | -| Calendario | Prestiti → Calendario chiusure | Giorni settimanali, paese/regione e festività selezionate, chiusure per sede, intervalli, ricorrenze, import ICS; chiusure annuali manuali disattivabili singolarmente. | -| Multe e registro | Prestiti → Multe e registro movimenti | Tariffa, tolleranza, tetto e soglia debito; override per tipologia e materiale; avviso email facoltativo. Tariffa e blocco sono indipendenti. | - -I valori numerici hanno significati diversi che vanno conservati: `0` rinnovi vieta i rinnovi; `0` per i limiti quantitativi significa nessun limite; tariffa o soglia debito a `0` disattivano quel singolo automatismo. Il valore vuoto nei campi di ereditarietà lascia applicare il livello sottostante. Queste combinazioni sono verificate dai test, non soltanto dalla presenza dei campi nel form. - -Per usare solo il registro contabile mantenere il modulo multe acceso e impostare tariffa e soglia debito a zero. Per addebitare ritardi senza bloccare i prestiti, scegliere una tariffa positiva e una soglia a zero. Per bloccare il debito senza nuove multe automatiche, usare tariffa zero e soglia positiva. - -## I 26 nuovi scenari - -File: [`tests/stack-090-settings-flexibility.unit.php`](../../tests/stack-090-settings-flexibility.unit.php). Esecuzione locale aggiornata: **26/26 superati, nessun fallimento**. - -La suite usa controller reali, impostazioni salvate e servizi applicativi. Non forza i moduli con `Features::override()`. Crea un database casuale con schema, dati iniziali e trigger reali, e lo elimina alla fine; non modifica i dati della biblioteca. La CI la raccoglie automaticamente come suite `*.unit.php`. - -| N. | Scenario | -| --- | --- | -| 1 | Salvataggio di una selezione mista dei moduli e lettura effettiva da parte del runtime. | -| 2 | Un salvataggio della scheda Moduli non altera gli interruttori della scheda Prestiti. | -| 3 | Lo staff non può modificare i moduli opzionali. | -| 4 | Gli interruttori della circolazione sono indipendenti e gli zeri espliciti vengono conservati. | -| 5 | Lo staff non può modificare interruttori della circolazione o importi delle multe. | -| 6 | La modalità catalogo sospende e ripristina le scelte di circolazione senza cancellarle. | -| 7 | Un modulo spento restituisce `feature_disabled` ai client JSON senza eseguire l'endpoint. | -| 8 | La chiusura di tutti e sette i giorni viene rifiutata senza sovrascrivere il calendario precedente. | -| 9 | Festività globali e chiusure di sede producono scadenze diverse per le copie di due sedi. | -| 10 | Riaccendere il calendario applica subito una festività lontana senza attendere la manutenzione. | -| 11 | Rimuovere una regione elimina la sua festività senza alterare una chiusura manuale disattivata. | -| 12 | L'applicazione delle festività partecipa alla transazione del chiamante e ne segue il rollback. | -| 13 | Una cella della matrice vieta i rinnovi con `0` e continua a ereditare la durata lasciata vuota. | -| 14 | Spegnere le regole ripristina durata globale e prestabilità; riaccenderle recupera tipologia e consultazione. | -| 15 | Il blocco per debito funziona con le nuove multe automatiche disattivate. | -| 16 | Le multe automatiche funzionano con il blocco per debito disattivato. | -| 17 | Una tipologia può escludere tariffa e blocco globali con zeri espliciti. | -| 18 | Un importer CSV già creato smette di assegnare tipologie quando il modulo viene spento e riprende alla riaccensione. | -| 19 | Il codice CSV identifica la tipologia esatta anche quando due tipologie hanno lo stesso nome. | -| 20 | MARC completa/sovrascrivi rispettano scelte diverse sul materiale e preservano prezzo e formato del libro esistente. | -| 21 | Disattivare e riattivare RDA conserva gli override manuali. | -| 22 | La sospensione dei fornitori conserva l'associazione; eliminarne uno conserva il libro. | -| 23 | Layout tessera, QR, formato e omissione di foto/logo/scadenza rimangono indipendenti dalla visibilità del modulo. | -| 24 | La conservazione delle presenze supporta sia nessuna cancellazione automatica sia una finestra personalizzata. | -| 25 | L'inventario si chiude senza marcare perdite e conserva i report quando il modulo viene spento. | -| 26 | La migrazione completa, ripetuta, conserva preferenze salvate, dati e comportamento effettivo: anche con tutti i moduli spenti, tutti accesi, scelte miste e aggiornamento dallo schema 0.8.9. | - -## Vincolo permanente per gli aggiornamenti - -Gli utenti devono mantenere il comportamento già scelto a ogni aggiornamento. Un default nuovo si applica a una preferenza assente; non sostituisce un valore salvato, compresi `0`, stringa vuota o modulo esplicitamente spento. La presenza di dati del modulo non autorizza a riaccenderlo se l'utente lo ha disattivato. - -Il test 26 esegue l'intera `migrate_0.9.0.sql` due volte per cinque configurazioni: moduli tutti spenti, tutti accesi, scelte miste in modalità catalogo e schema 0.8.9 nelle due modalità operative. Controlla i valori persistiti e ricostruisce i servizi dopo la migrazione, verificando durata/rinnovi/prestabilità, debito, scadenze, tessere, conservazione, dati del lettore e del catalogo. Copre anche preferenze sconosciute al core e scelte di spegnimento in presenza di dati esistenti. - -La fixture dello schema 0.8.9 è inclusa nel repository: la verifica del vero aggiornamento resta obbligatoria anche nei checkout CI senza cronologia o tag. - -## Cosa cambierei - -Le proposte seguenti ampliano le scelte dell'utente. Sono proposte di prodotto, separate dalle verifiche e dalle correzioni già pubblicate. - -I nuovi controlli devono partire dall'assetto effettivo della biblioteca, conservando il comportamento esistente fino a una scelta esplicita. L'aggiornamento non deve attivare tariffe, cambiare la prestabilità di copie o avviare cancellazioni di dati come effetto collaterale dell'aggiunta di un'opzione. - -Consentirei inoltre agli amministratori di preparare calendari e regole mentre il relativo modulo è spento, con un riepilogo prima dell'attivazione. Oggi alcuni pannelli seguono lo stesso blocco delle pagine operative: configurare in anticipo renderebbe più controllabile il passaggio all'uso effettivo. - -### Priorità alta - -1. **Aperture straordinarie per data e sede.** Oggi il calendario descrive chiusure: non si può aprire una singola domenica mantenendo tutte le altre domeniche chiuse, oppure aprire una sola festività senza deselezionarla per tutti gli anni. Aggiungerei un'eccezione di apertura esplicita per quella data e sede, prioritaria rispetto a ricorrenze e preset, visibile nell'anteprima e applicata su tutti i canali. -2. **Separare le foto dalla stampa delle tessere.** Oggi si può togliere la foto dal PDF, ma acquisizione fotografica e stampa condividono lo stesso modulo. Aggiungerei un controllo dedicato all'acquisizione/visualizzazione delle foto, lasciando utilizzabili tessere e codici a barre. La riattivazione conserverebbe le foto già registrate. -3. **Separare consultazione e regole per tipologia.** Spegnere le regole oggi rende prestabili anche le copie segnate in sola consultazione. Consentirei di mantenere quel vincolo fisico senza usare tipologie e matrice. La scelta dovrebbe valere su banco, sito, app e NCIP. -4. **Mostrare l'effetto delle modifiche sul lavoro già aperto.** Prima di applicare un calendario o riattivare il registro, offrirei un riepilogo dei ritiri interessati e delle penalità già registrate che verranno riportate nel registro. Le scadenze dei prestiti in corso resterebbero modificabili con un'azione esplicita e tracciata. - -### Priorità successiva - -5. **Festività selezionabili per sede.** I preset sono globali; le eccezioni di sede richiedono chiusure manuali/ICS. Aggiungerei una selezione ereditata dal calendario generale, con eccezioni di sede chiaramente visibili nell'anteprima. -6. **Separare raccolta e conservazione delle presenze.** Spegnere il registro oggi sospende anche la cancellazione automatica. Offrirei la scelta di fermare i nuovi ingressi continuando ad applicare la finestra di conservazione ai dati esistenti. -7. **Ampliare la scelta dell'anno nell'anteprima delle festività.** Il motore ora calcola anche anni lontani; il selettore dell'anteprima mostra ancora anno corrente e due successivi. Consentirei di scegliere direttamente un altro anno senza trasformare il buffer tecnico in una preferenza dell'utente. -8. **Rendere esplicita la semantica di vuoto e zero.** Mostrerei vicino a ogni campo «eredita», «senza limite» o «disattiva», secondo il suo significato, e un riepilogo della regola effettiva per lettore e materiale. -9. **Profili iniziali modificabili.** Proporrei configurazioni «catalogo», «prestiti essenziali» e «circolazione completa», accompagnate dal riepilogo degli interruttori modificati. Ogni modulo resterebbe poi configurabile singolarmente. -10. **Opzioni aggiuntive per multe e tessere.** Valuterei una scelta esplicita tra tolleranza come soglia e giorni effettivamente abbuonati, e tra validità tessera calcolata da oggi o dalla scadenza esistente. Manterrei il comportamento attuale come default e verificherei le nuove opzioni su tutti i canali. - -Terrei interni dimensione dei batch, timeout dei lock e finestra di materializzazione delle festività: sono parametri operativi. Le impostazioni rivolte al bibliotecario dovrebbero descrivere regole di servizio, conservazione dei dati e presentazione dei documenti. +Restano le proposte che ampliano il modello del calendario e i profili iniziali: aperture straordinarie per data e sede, preset di festività con ereditarietà per sede, riepilogo dei ritiri prima di applicare nuove chiusure e profili iniziali modificabili. L’estensione attuale applica le proposte sugli interruttori e sulle opzioni di calcolo; questi sviluppi del modello rimangono distinti e richiedono ulteriori implementazioni e verifiche. diff --git a/installer/database/data_da_DK.sql b/installer/database/data_da_DK.sql index a8878d944..f891551c5 100644 --- a/installer/database/data_da_DK.sql +++ b/installer/database/data_da_DK.sql @@ -24,7 +24,14 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('features', 'user_import', '1', 'Valgfrit modul: Brugerimport fra CSV'), ('features', 'circulation_rules', '1', 'Valgfrit modul: Regler efter lånertype'), ('features', 'closures_calendar', '1', 'Valgfrit modul: Lukkekalender'), -('features', 'fines', '0', 'Valgfrit modul: Gebyrer og bevægelser') +('features', 'fines', '0', 'Valgfrit modul: Gebyrer og bevægelser'), +('features', 'patron_photos', '1', 'Optional control: patron_photos'), +('features', 'consultation_only', '1', 'Optional control: consultation_only'), +('features', 'automatic_card_expiry', '1', 'Optional control: automatic_card_expiry'), +('features', 'automatic_fines', '0', 'Optional control: automatic_fines'), +('features', 'debt_block', '0', 'Optional control: debt_block'), +('features', 'loss_penalties', '0', 'Optional control: loss_penalties'), +('features', 'attendance_retention', '0', 'Optional control: attendance_retention') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES diff --git a/installer/database/data_de_DE.sql b/installer/database/data_de_DE.sql index 446ae1a48..ec1de6520 100644 --- a/installer/database/data_de_DE.sql +++ b/installer/database/data_de_DE.sql @@ -207,7 +207,14 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('features', 'user_import', '1', 'Optionales Modul: Benutzerimport aus CSV'), ('features', 'circulation_rules', '1', 'Optionales Modul: Regeln nach Lesertyp'), ('features', 'closures_calendar', '1', 'Optionales Modul: Schließtage-Kalender'), -('features', 'fines', '0', 'Optionales Modul: Gebühren und Kontobewegungen') +('features', 'fines', '0', 'Optionales Modul: Gebühren und Kontobewegungen'), +('features', 'patron_photos', '1', 'Optional control: patron_photos'), +('features', 'consultation_only', '1', 'Optional control: consultation_only'), +('features', 'automatic_card_expiry', '1', 'Optional control: automatic_card_expiry'), +('features', 'automatic_fines', '0', 'Optional control: automatic_fines'), +('features', 'debt_block', '0', 'Optional control: debt_block'), +('features', 'loss_penalties', '0', 'Optional control: loss_penalties'), +('features', 'attendance_retention', '0', 'Optional control: attendance_retention') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES diff --git a/installer/database/data_en_US.sql b/installer/database/data_en_US.sql index efb72b1da..9c7d51e04 100644 --- a/installer/database/data_en_US.sql +++ b/installer/database/data_en_US.sql @@ -207,7 +207,14 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('features', 'user_import', '1', 'Optional module: User import from CSV'), ('features', 'circulation_rules', '1', 'Optional module: Rules by patron type'), ('features', 'closures_calendar', '1', 'Optional module: Closures calendar'), -('features', 'fines', '0', 'Optional module: Fines and patron ledger') +('features', 'fines', '0', 'Optional module: Fines and patron ledger'), +('features', 'patron_photos', '1', 'Optional control: patron_photos'), +('features', 'consultation_only', '1', 'Optional control: consultation_only'), +('features', 'automatic_card_expiry', '1', 'Optional control: automatic_card_expiry'), +('features', 'automatic_fines', '0', 'Optional control: automatic_fines'), +('features', 'debt_block', '0', 'Optional control: debt_block'), +('features', 'loss_penalties', '0', 'Optional control: loss_penalties'), +('features', 'attendance_retention', '0', 'Optional control: attendance_retention') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES diff --git a/installer/database/data_fr_FR.sql b/installer/database/data_fr_FR.sql index 4a2e6fbfa..9722bf49f 100644 --- a/installer/database/data_fr_FR.sql +++ b/installer/database/data_fr_FR.sql @@ -207,7 +207,14 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('features', 'user_import', '1', 'Module facultatif: Import des utilisateurs depuis un CSV'), ('features', 'circulation_rules', '1', 'Module facultatif: Règles par type de lecteur'), ('features', 'closures_calendar', '1', 'Module facultatif: Calendrier des fermetures'), -('features', 'fines', '0', 'Module facultatif: Amendes et registre des mouvements') +('features', 'fines', '0', 'Module facultatif: Amendes et registre des mouvements'), +('features', 'patron_photos', '1', 'Optional control: patron_photos'), +('features', 'consultation_only', '1', 'Optional control: consultation_only'), +('features', 'automatic_card_expiry', '1', 'Optional control: automatic_card_expiry'), +('features', 'automatic_fines', '0', 'Optional control: automatic_fines'), +('features', 'debt_block', '0', 'Optional control: debt_block'), +('features', 'loss_penalties', '0', 'Optional control: loss_penalties'), +('features', 'attendance_retention', '0', 'Optional control: attendance_retention') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES diff --git a/installer/database/data_it_IT.sql b/installer/database/data_it_IT.sql index f517a7410..7184a8179 100644 --- a/installer/database/data_it_IT.sql +++ b/installer/database/data_it_IT.sql @@ -24,7 +24,14 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc ('features', 'user_import', '1', 'Modulo opzionale: Importazione utenti da CSV'), ('features', 'circulation_rules', '1', 'Modulo opzionale: Regole per tipologia di lettore'), ('features', 'closures_calendar', '1', 'Modulo opzionale: Calendario chiusure'), -('features', 'fines', '0', 'Modulo opzionale: Multe e registro movimenti') +('features', 'fines', '0', 'Modulo opzionale: Multe e registro movimenti'), +('features', 'patron_photos', '1', 'Optional control: patron_photos'), +('features', 'consultation_only', '1', 'Optional control: consultation_only'), +('features', 'automatic_card_expiry', '1', 'Optional control: automatic_card_expiry'), +('features', 'automatic_fines', '0', 'Optional control: automatic_fines'), +('features', 'debt_block', '0', 'Optional control: debt_block'), +('features', 'loss_penalties', '0', 'Optional control: loss_penalties'), +('features', 'attendance_retention', '0', 'Optional control: attendance_retention') ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW(); INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES diff --git a/installer/database/migrations/migrate_0.9.0.sql b/installer/database/migrations/migrate_0.9.0.sql index d99e2678e..9a575deea 100644 --- a/installer/database/migrations/migrate_0.9.0.sql +++ b/installer/database/migrations/migrate_0.9.0.sql @@ -334,7 +334,8 @@ CREATE TABLE IF NOT EXISTS `movimenti_lettore` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Existing prestiti.sanzione amounts (assessed by hand when a copy was closed lost --- or damaged) become ledger charges. prestiti.sanzione stays and is still written +-- or damaged) become ledger charges only when the existing ledger and loss +-- mirroring are enabled. Otherwise they remain solely in prestiti.sanzione. prestiti.sanzione stays and is still written -- as a mirror. The key penalty: is the one the live return path uses, so -- the conversion happens exactly once, however many times the upgrade runs. -- Batches of 5000 loans in id order; each statement is its own short transaction @@ -347,6 +348,8 @@ SET @fl_sql = 'INSERT INTO movimenti_lettore (utente_id, prestito_id, tipo, impo COALESCE(CAST(p.data_restituzione AS DATETIME), p.updated_at, p.created_at, NOW()), CONCAT(''penalty:'', p.id) FROM prestiti p WHERE p.sanzione > 0 + AND EXISTS (SELECT 1 FROM system_settings WHERE category = ''features'' AND setting_key = ''fines'' AND setting_value = ''1'') + AND COALESCE((SELECT setting_value FROM system_settings WHERE category = ''features'' AND setting_key = ''loss_penalties''), ''1'') = ''1'' AND NOT EXISTS (SELECT 1 FROM movimenti_lettore m WHERE m.chiave_idempotenza = CONCAT(''penalty:'', p.id)) ORDER BY p.id LIMIT 5000 @@ -460,4 +463,33 @@ DEALLOCATE PREPARE ft_stmt; INSERT INTO system_settings (category, setting_key, setting_value, description) SELECT 'features', 'fines', @ft_val, 'Optional module: Fines and patron ledger' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'fines'); +-- Split controls inherit the former switch; explicit choices are never overwritten. +SET @ft_val = COALESCE((SELECT setting_value FROM system_settings WHERE category = 'features' AND setting_key = 'patron_cards'), '0'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'patron_photos', @ft_val, 'Optional control: patron_photos' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'patron_photos'); +SET @ft_val = COALESCE((SELECT setting_value FROM system_settings WHERE category = 'features' AND setting_key = 'circulation_rules'), '0'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'consultation_only', @ft_val, 'Optional control: consultation_only' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'consultation_only'); +SET @ft_val = COALESCE((SELECT setting_value FROM system_settings WHERE category = 'features' AND setting_key = 'circulation_rules'), '0'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'automatic_card_expiry', @ft_val, 'Optional control: automatic_card_expiry' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'automatic_card_expiry'); +SET @ft_val = COALESCE((SELECT setting_value FROM system_settings WHERE category = 'features' AND setting_key = 'fines'), '0'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'automatic_fines', @ft_val, 'Optional control: automatic_fines' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'automatic_fines'); +SET @ft_val = COALESCE((SELECT setting_value FROM system_settings WHERE category = 'features' AND setting_key = 'fines'), '0'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'debt_block', @ft_val, 'Optional control: debt_block' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'debt_block'); +SET @ft_val = COALESCE((SELECT setting_value FROM system_settings WHERE category = 'features' AND setting_key = 'fines'), '0'); +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'loss_penalties', @ft_val, 'Optional control: loss_penalties' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'loss_penalties'); +SET @ft_val = '0'; +INSERT INTO system_settings (category, setting_key, setting_value, description) +SELECT 'features', 'attendance_retention', @ft_val, 'Optional control: attendance_retention' FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE category = 'features' AND setting_key = 'attendance_retention'); -- ── end feature toggles ── diff --git a/locale/da_DK.json b/locale/da_DK.json index e642aa284..8e2b555a5 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -9099,5 +9099,46 @@ "non prevista nel %d": "ikke i %d", "ogni anno": "hvert år", "regionale": "regional", - "MaintenanceService errore festività": "MaintenanceService fejl ved helligdage" + "MaintenanceService errore festività": "MaintenanceService fejl ved helligdage", + "Stampa delle tessere": "Udskrivning af lånerkort", + "Registro movimenti": "Kontobevægelser", + "Foto dei lettori": "Lånerfotos", + "Copie in sola consultazione": "Eksemplarer til brug på stedet", + "Multe automatiche per ritardo": "Automatiske forsinkelsesgebyrer", + "Blocco dei prestiti per debito": "Bloker udlån ved gæld", + "Validità automatica delle tessere": "Automatisk kortgyldighed", + "Riporta penalità per perdita e danno nel registro": "Overfør erstatning for tab og skade til kontoen", + "Conservazione a registro presenze sospeso": "Opbevaring mens besøgsregistrering er slået fra", + "Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.": "Udskriv lånerkort som PDF. Fotos og automatisk gyldighed har separate indstillinger.", + "Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.": "Tag og vis fotos uafhængigt af kortudskrivning. Gemte fotos bevares, når funktionen slås fra.", + "Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.": "Håndhæv brug på stedet uden regler for lånertyper ved skranken, på hjemmesiden, i appen og integrationer.", + "Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.": "Anvend lånertypens gyldighed ved nye tilmeldinger og godkendelser. Eksisterende udløbsdatoer bevares; manuel fornyelse er stadig mulig.", + "Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.": "Kræver en aktiv konto og en positiv takst. Når slået fra beregnes eller opkræves ingen nye forsinkelsesgebyrer; takster og bevægelser bevares.", + "Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.": "Kræver en aktiv konto og en positiv grænse. Kan blokere udlån uden at oprette nye automatiske gebyrer.", + "Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.": "Overfør beløb, der allerede er fastsat på lån, inklusive tidligere erstatninger. Fastsætter ikke nye beløb.", + "Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.": "Fortsæt sletning efter opbevaringsperioden, selv når registrering er slået fra eller i katalogtilstand. Ved 0 dage slettes intet.", + "Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.": "Manuelle opkrævninger, betalinger, kvitteringer og eftergivelser. Automatiske gebyrer, erstatninger og gældsblokering har separate indstillinger. Bevægelser bevares ved deaktivering.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Lånertyper, materialeregler, reservationsgrænser og automatisk kortgyldighed. Når slået fra gælder kun de globale regler på denne fane.", + "Giorni di conservazione delle presenze": "Dage til opbevaring af besøgsdata", + "0 = nessuna cancellazione automatica. La durata si applica ai dati esistenti; non riattiva la raccolta.": "0 = ingen automatisk sletning. Perioden gælder eksisterende data; den genstarter ikke registrering.", + "Configura le tessere anche a modulo spento": "Konfigurer kort, selv når modulet er slået fra", + "Configura le regole anche a modulo spento": "Konfigurer regler, selv når modulet er slået fra", + "Calcola il rinnovo della tessera da": "Beregn kortfornyelse fra", + "Scadenza esistente, se futura; altrimenti oggi": "Eksisterende udløb, hvis det er i fremtiden; ellers i dag", + "Modalità di tolleranza della multa": "Tilstand for gebyrfri frist", + "Soglia: superata la tolleranza, conta dal primo giorno": "Grænse: opkræv fra første dag, når fristen overskrides", + "Abbuono: sottrai sempre i giorni di tolleranza": "Fradrag: træk altid de gebyrfri dage fra", + "Il registro può restare attivo con tutti gli automatismi spenti. Le tariffe per tipologia e materiale rispettano questi interruttori.": "Kontoen kan være aktiv med al automatik slået fra. Takster for lånertyper og materialer følger disse indstillinger.", + "Modulo spento: puoi preparare la configurazione. Le scelte diventeranno operative soltanto dopo l’attivazione nelle impostazioni.": "Modulet er slået fra: du kan forberede konfigurationen. Valg træder først i kraft efter aktivering i indstillingerne.", + "Attivando il riporto delle penalità, saranno registrati %s importi precedenti, per un totale di %s. Le multe per ritardo restano una scelta separata.": "Aktivering overfører %s tidligere beløb på i alt %s. Forsinkelsesgebyrer er fortsat et separat valg.", + "Addebita automaticamente una multa per ogni giorno di ritardo quando un prestito viene restituito dopo la scadenza. Le multe richiedono registro, interruttore dedicato e tariffa positiva.": "Opkræv automatisk gebyr ved forsinket aflevering. Kræver kontoen, en særskilt aktivering og en positiv takst.", + "La modalità di tolleranza determina quali giorni vengono addebitati. Saldo, pagamenti e condoni si gestiscono dalla scheda del lettore, sezione Movimenti.": "Fristtilstanden bestemmer, hvilke dage der opkræves for. Saldo, betalinger og eftergivelser administreres under bevægelser på låneren.", + "Vuoto = eredita; 0 = nessun limite.": "Tom = nedarv; 0 = ubegrænset.", + "Vuoto = eredita; 0 = rinnovi vietati.": "Tom = nedarv; 0 = ingen fornyelser.", + "Vuoto = eredita; 0 = nessuna tolleranza.": "Tom = nedarv; 0 = ingen gebyrfri dage.", + "Vuoto = eredita; inserisci un numero positivo.": "Tom = nedarv; indtast et positivt tal.", + "Vuoto = eredita; 0 = nessun tetto.": "Tom = nedarv; 0 = intet loft.", + "Vuoto = eredita; 0 = disattiva per questa tipologia.": "Tom = nedarv; 0 = slå fra for denne lånertype.", + "Tariffa globale per giorno. Vuoto o 0 = nessuna tariffa globale; le tipologie possono definirne una propria. Per sospendere tutte le multe usa l’interruttore dedicato.": "Global dagstakst. Tom eller 0 = ingen global takst; lånertyper kan have deres egen. Brug den særskilte indstilling til at stoppe alle forsinkelsesgebyrer.", + "Soglia globale del debito. Vuoto o 0 = nessun blocco globale; le tipologie possono definire una soglia propria. Per sospendere tutti i blocchi usa l’interruttore dedicato.": "Global gældsgrænse. Tom eller 0 = ingen global blokering; lånertyper kan have deres egen grænse. Brug den særskilte indstilling til at stoppe alle gældsblokeringer." } diff --git a/locale/de_DE.json b/locale/de_DE.json index a1bcb3b0e..09a86d4b9 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -9099,5 +9099,46 @@ "non prevista nel %d": "%d nicht vorgesehen", "ogni anno": "jedes Jahr", "regionale": "regional", - "MaintenanceService errore festività": "MaintenanceService Fehler bei den Feiertagen" + "MaintenanceService errore festività": "MaintenanceService Fehler bei den Feiertagen", + "Stampa delle tessere": "Kartendruck", + "Registro movimenti": "Leserkonto", + "Foto dei lettori": "Leserfotos", + "Copie in sola consultazione": "Präsenzexemplare", + "Multe automatiche per ritardo": "Automatische Säumnisgebühren", + "Blocco dei prestiti per debito": "Ausleihsperre bei Schulden", + "Validità automatica delle tessere": "Automatische Kartengültigkeit", + "Riporta penalità per perdita e danno nel registro": "Verlust- und Schadensgebühren ins Konto übernehmen", + "Conservazione a registro presenze sospeso": "Aufbewahrung bei ausgesetztem Besucherprotokoll", + "Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.": "Bibliothekskarten als PDF drucken. Fotos und automatische Gültigkeit haben eigene Schalter.", + "Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.": "Fotos unabhängig vom Kartendruck aufnehmen und anzeigen. Beim Ausschalten bleiben gespeicherte Fotos erhalten.", + "Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.": "Präsenzexemplare auch ohne Lesertypregeln berücksichtigen: am Schalter, auf der Website, in der App und in Integrationen.", + "Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.": "Gültigkeitsdauer des Lesertyps bei Neuanmeldungen und Freigaben anwenden. Bestehende Ablaufdaten bleiben erhalten; manuelle Verlängerung bleibt möglich.", + "Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.": "Erfordert ein aktives Konto und einen positiven Gebührensatz. Ausgeschaltet werden keine neuen Säumnisgebühren berechnet; Sätze und Buchungen bleiben erhalten.", + "Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.": "Erfordert ein aktives Konto und einen positiven Schwellenwert. Kann Ausleihen ohne neue automatische Gebühren sperren.", + "Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.": "Bereits festgesetzte Beträge einschließlich früherer Gebühren ins Konto übernehmen. Es werden keine neuen Beträge festgelegt.", + "Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.": "Besuche gemäß Aufbewahrungsfrist auch bei ausgeschalteter Erfassung oder im Katalogmodus löschen. Bei 0 Tagen wird nichts gelöscht.", + "Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.": "Manuelle Belastungen, Zahlungen, Belege und Erlasse. Automatische Gebühren, Ersatzforderungen und Schuldensperre haben eigene Schalter. Buchungen bleiben beim Ausschalten erhalten.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Lesertypen, Materialregeln, Vormerklimits und automatische Kartengültigkeit. Ausgeschaltet gelten nur die globalen Regeln dieses Bereichs.", + "Giorni di conservazione delle presenze": "Aufbewahrung der Besuche in Tagen", + "0 = nessuna cancellazione automatica. La durata si applica ai dati esistenti; non riattiva la raccolta.": "0 = keine automatische Löschung. Die Frist gilt für bestehende Daten; die Erfassung wird nicht wieder aktiviert.", + "Configura le tessere anche a modulo spento": "Karten auch bei ausgeschaltetem Modul konfigurieren", + "Configura le regole anche a modulo spento": "Regeln auch bei ausgeschaltetem Modul konfigurieren", + "Calcola il rinnovo della tessera da": "Kartenverlängerung berechnen ab", + "Scadenza esistente, se futura; altrimenti oggi": "Bestehendem Ablaufdatum, falls zukünftig; sonst heute", + "Modalità di tolleranza della multa": "Art der Gebührenkulanz", + "Soglia: superata la tolleranza, conta dal primo giorno": "Schwelle: nach Überschreitung ab dem ersten Tag berechnen", + "Abbuono: sottrai sempre i giorni di tolleranza": "Erlass: Kulanztage immer abziehen", + "Il registro può restare attivo con tutti gli automatismi spenti. Le tariffe per tipologia e materiale rispettano questi interruttori.": "Das Konto kann bei ausgeschalteten Automatismen aktiv bleiben. Sätze nach Lesertyp und Material beachten diese Schalter.", + "Modulo spento: puoi preparare la configurazione. Le scelte diventeranno operative soltanto dopo l’attivazione nelle impostazioni.": "Modul ausgeschaltet: Sie können die Konfiguration vorbereiten. Sie wird erst nach der Aktivierung in den Einstellungen wirksam.", + "Attivando il riporto delle penalità, saranno registrati %s importi precedenti, per un totale di %s. Le multe per ritardo restano una scelta separata.": "Beim Aktivieren werden %s frühere Beträge mit insgesamt %s übernommen. Säumnisgebühren bleiben eine separate Wahl.", + "Addebita automaticamente una multa per ogni giorno di ritardo quando un prestito viene restituito dopo la scadenza. Le multe richiedono registro, interruttore dedicato e tariffa positiva.": "Bei verspäteter Rückgabe automatisch Säumnisgebühren berechnen. Erfordert das Konto, den eigenen Schalter und einen positiven Satz.", + "La modalità di tolleranza determina quali giorni vengono addebitati. Saldo, pagamenti e condoni si gestiscono dalla scheda del lettore, sezione Movimenti.": "Die Kulanzart bestimmt die berechneten Tage. Saldo, Zahlungen und Erlasse werden im Leserkonto unter Buchungen verwaltet.", + "Vuoto = eredita; 0 = nessun limite.": "Leer = erben; 0 = unbegrenzt.", + "Vuoto = eredita; 0 = rinnovi vietati.": "Leer = erben; 0 = keine Verlängerungen.", + "Vuoto = eredita; 0 = nessuna tolleranza.": "Leer = erben; 0 = keine Kulanztage.", + "Vuoto = eredita; inserisci un numero positivo.": "Leer = erben; positive Zahl eingeben.", + "Vuoto = eredita; 0 = nessun tetto.": "Leer = erben; 0 = keine Obergrenze.", + "Vuoto = eredita; 0 = disattiva per questa tipologia.": "Leer = erben; 0 = für diesen Lesertyp deaktivieren.", + "Tariffa globale per giorno. Vuoto o 0 = nessuna tariffa globale; le tipologie possono definirne una propria. Per sospendere tutte le multe usa l’interruttore dedicato.": "Globaler Tagessatz. Leer oder 0 = kein globaler Satz; Lesertypen können eigene Sätze haben. Alle Säumnisgebühren über den eigenen Schalter aussetzen.", + "Soglia globale del debito. Vuoto o 0 = nessun blocco globale; le tipologie possono definire una soglia propria. Per sospendere tutti i blocchi usa l’interruttore dedicato.": "Globale Schuldenschwelle. Leer oder 0 = keine globale Sperre; Lesertypen können eigene Schwellen festlegen. Alle Schuldensperren über den eigenen Schalter aussetzen." } diff --git a/locale/en_US.json b/locale/en_US.json index 4b40f7a26..1452feaec 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -9099,5 +9099,46 @@ "non prevista nel %d": "not held in %d", "ogni anno": "every year", "regionale": "regional", - "MaintenanceService errore festività": "MaintenanceService holiday rows error" + "MaintenanceService errore festività": "MaintenanceService holiday rows error", + "Stampa delle tessere": "Card printing", + "Registro movimenti": "Patron ledger", + "Foto dei lettori": "Patron photos", + "Copie in sola consultazione": "Reference-only copies", + "Multe automatiche per ritardo": "Automatic overdue fines", + "Blocco dei prestiti per debito": "Block borrowing for debt", + "Validità automatica delle tessere": "Automatic card validity", + "Riporta penalità per perdita e danno nel registro": "Post loss and damage penalties to the ledger", + "Conservazione a registro presenze sospeso": "Retention while the visitor log is suspended", + "Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.": "Print library cards as PDF. Photos and automatic validity have separate controls.", + "Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.": "Capture and display photos independently of card printing. Turning this off preserves stored photos.", + "Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.": "Enforce reference-only copies even without patron type rules, at the desk, on the website, in the app and integrations.", + "Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.": "Apply the patron type duration to new registrations and approvals. Existing expiry dates stay unchanged; manual renewal remains available.", + "Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.": "Requires an active ledger and a positive rate. When off, no new overdue fine is calculated or charged; rates and transactions are preserved.", + "Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.": "Requires an active ledger and a positive threshold. Can block borrowing without generating new automatic fines.", + "Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.": "Post amounts already assessed on loans to the ledger, including past penalties. Does not determine new amounts.", + "Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.": "Continue deleting visits according to the retention period even when collection is off or in catalogue mode. With 0 days, nothing is deleted.", + "Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.": "Manual charges, payments, receipts and waivers. Automatic fines, penalties and debt blocking have separate controls. Turning this off preserves transactions.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Patron types, rules by material, reservation limits and automatic card duration. When off, only the global rules on this tab apply.", + "Giorni di conservazione delle presenze": "Visitor log retention days", + "0 = nessuna cancellazione automatica. La durata si applica ai dati esistenti; non riattiva la raccolta.": "0 = no automatic deletion. The period applies to existing data; it does not restart collection.", + "Configura le tessere anche a modulo spento": "Configure cards while the module is off", + "Configura le regole anche a modulo spento": "Configure rules while the module is off", + "Calcola il rinnovo della tessera da": "Calculate card renewal from", + "Scadenza esistente, se futura; altrimenti oggi": "Existing expiry, if in the future; otherwise today", + "Modalità di tolleranza della multa": "Fine grace period mode", + "Soglia: superata la tolleranza, conta dal primo giorno": "Threshold: charge from day one once grace is exceeded", + "Abbuono: sottrai sempre i giorni di tolleranza": "Waiver: always deduct the grace days", + "Il registro può restare attivo con tutti gli automatismi spenti. Le tariffe per tipologia e materiale rispettano questi interruttori.": "The ledger can stay active with all automations off. Patron type and material rates respect these switches.", + "Modulo spento: puoi preparare la configurazione. Le scelte diventeranno operative soltanto dopo l’attivazione nelle impostazioni.": "Module off: you can prepare its configuration. Choices only take effect after activation in settings.", + "Attivando il riporto delle penalità, saranno registrati %s importi precedenti, per un totale di %s. Le multe per ritardo restano una scelta separata.": "Enabling penalty posting will record %s previous amounts, totalling %s. Overdue fines remain a separate choice.", + "Addebita automaticamente una multa per ogni giorno di ritardo quando un prestito viene restituito dopo la scadenza. Le multe richiedono registro, interruttore dedicato e tariffa positiva.": "Automatically charge an overdue fine on late returns. Fines require the ledger, their own switch and a positive rate.", + "La modalità di tolleranza determina quali giorni vengono addebitati. Saldo, pagamenti e condoni si gestiscono dalla scheda del lettore, sezione Movimenti.": "The grace mode determines which days are charged. Manage balances, payments and waivers in the patron record under Transactions.", + "Vuoto = eredita; 0 = nessun limite.": "Blank = inherit; 0 = unlimited.", + "Vuoto = eredita; 0 = rinnovi vietati.": "Blank = inherit; 0 = no renewals.", + "Vuoto = eredita; 0 = nessuna tolleranza.": "Blank = inherit; 0 = no grace days.", + "Vuoto = eredita; inserisci un numero positivo.": "Blank = inherit; enter a positive number.", + "Vuoto = eredita; 0 = nessun tetto.": "Blank = inherit; 0 = no cap.", + "Vuoto = eredita; 0 = disattiva per questa tipologia.": "Blank = inherit; 0 = disable for this patron type.", + "Tariffa globale per giorno. Vuoto o 0 = nessuna tariffa globale; le tipologie possono definirne una propria. Per sospendere tutte le multe usa l’interruttore dedicato.": "Global daily rate. Blank or 0 = no global rate; patron types may set their own. Use the dedicated switch to suspend all overdue fines.", + "Soglia globale del debito. Vuoto o 0 = nessun blocco globale; le tipologie possono definire una soglia propria. Per sospendere tutti i blocchi usa l’interruttore dedicato.": "Global debt threshold. Blank or 0 = no global block; patron types may set their own threshold. Use the dedicated switch to suspend all debt blocks." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index ee29a745b..78051191f 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -9099,5 +9099,46 @@ "non prevista nel %d": "non prévu en %d", "ogni anno": "chaque année", "regionale": "régional", - "MaintenanceService errore festività": "MaintenanceService erreur jours fériés" + "MaintenanceService errore festività": "MaintenanceService erreur jours fériés", + "Stampa delle tessere": "Impression des cartes", + "Registro movimenti": "Registre des mouvements", + "Foto dei lettori": "Photos des lecteurs", + "Copie in sola consultazione": "Exemplaires en consultation sur place", + "Multe automatiche per ritardo": "Amendes automatiques de retard", + "Blocco dei prestiti per debito": "Blocage des prêts pour dette", + "Validità automatica delle tessere": "Validité automatique des cartes", + "Riporta penalità per perdita e danno nel registro": "Reporter les pénalités de perte et de dommage dans le registre", + "Conservazione a registro presenze sospeso": "Conservation lorsque le registre des visites est suspendu", + "Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.": "Imprime les cartes en PDF. Les photos et la validité automatique ont des commandes distinctes.", + "Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.": "Prise et affichage des photos indépendamment de l’impression des cartes. La désactivation conserve les photos enregistrées.", + "Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.": "Respecte la consultation sur place même sans règles par catégorie de lecteur, au guichet, sur le site, dans l’application et les intégrations.", + "Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.": "Applique la durée de la catégorie aux inscriptions et validations. Les échéances existantes sont conservées ; le renouvellement manuel reste disponible.", + "Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.": "Nécessite un registre actif et un tarif positif. Désactivé, aucune nouvelle amende de retard n’est calculée ni débitée ; tarifs et mouvements sont conservés.", + "Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.": "Nécessite un registre actif et un seuil positif. Peut bloquer les prêts sans générer de nouvelles amendes automatiques.", + "Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.": "Reporte au registre les montants déjà attribués aux prêts, y compris les anciennes pénalités. Ne fixe aucun nouveau montant.", + "Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.": "Continue la suppression selon la durée de conservation même lorsque la collecte est désactivée ou en mode catalogue. Avec 0 jour, aucune suppression.", + "Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.": "Débits manuels, paiements, reçus et remises. Les amendes automatiques, pénalités et blocages ont des commandes distinctes. La désactivation conserve les mouvements.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Catégories de lecteurs, règles par support, limites de réservation et durée automatique des cartes. Désactivées, seules les règles globales de cet onglet s’appliquent.", + "Giorni di conservazione delle presenze": "Durée de conservation des visites en jours", + "0 = nessuna cancellazione automatica. La durata si applica ai dati esistenti; non riattiva la raccolta.": "0 = aucune suppression automatique. La durée s’applique aux données existantes ; elle ne réactive pas la collecte.", + "Configura le tessere anche a modulo spento": "Configurer les cartes même si le module est désactivé", + "Configura le regole anche a modulo spento": "Configurer les règles même si le module est désactivé", + "Calcola il rinnovo della tessera da": "Calculer le renouvellement de la carte à partir de", + "Scadenza esistente, se futura; altrimenti oggi": "L’échéance existante si elle est future ; sinon aujourd’hui", + "Modalità di tolleranza della multa": "Mode de tolérance des amendes", + "Soglia: superata la tolleranza, conta dal primo giorno": "Seuil : au-delà de la tolérance, compter dès le premier jour", + "Abbuono: sottrai sempre i giorni di tolleranza": "Franchise : toujours déduire les jours de tolérance", + "Il registro può restare attivo con tutti gli automatismi spenti. Le tariffe per tipologia e materiale rispettano questi interruttori.": "Le registre peut rester actif sans automatisme. Les tarifs par catégorie et support respectent ces commandes.", + "Modulo spento: puoi preparare la configurazione. Le scelte diventeranno operative soltanto dopo l’attivazione nelle impostazioni.": "Module désactivé : vous pouvez préparer sa configuration. Les choix prendront effet après activation dans les paramètres.", + "Attivando il riporto delle penalità, saranno registrati %s importi precedenti, per un totale di %s. Le multe per ritardo restano una scelta separata.": "L’activation reportera %s montants antérieurs pour un total de %s. Les amendes de retard restent un choix distinct.", + "Addebita automaticamente una multa per ogni giorno di ritardo quando un prestito viene restituito dopo la scadenza. Le multe richiedono registro, interruttore dedicato e tariffa positiva.": "Débite automatiquement une amende lors d’un retour tardif. Nécessite le registre, la commande dédiée et un tarif positif.", + "La modalità di tolleranza determina quali giorni vengono addebitati. Saldo, pagamenti e condoni si gestiscono dalla scheda del lettore, sezione Movimenti.": "Le mode de tolérance détermine les jours facturés. Gérez solde, paiements et remises dans la fiche du lecteur, section Mouvements.", + "Vuoto = eredita; 0 = nessun limite.": "Vide = hériter ; 0 = illimité.", + "Vuoto = eredita; 0 = rinnovi vietati.": "Vide = hériter ; 0 = aucun renouvellement.", + "Vuoto = eredita; 0 = nessuna tolleranza.": "Vide = hériter ; 0 = aucune tolérance.", + "Vuoto = eredita; inserisci un numero positivo.": "Vide = hériter ; saisir un nombre positif.", + "Vuoto = eredita; 0 = nessun tetto.": "Vide = hériter ; 0 = aucun plafond.", + "Vuoto = eredita; 0 = disattiva per questa tipologia.": "Vide = hériter ; 0 = désactiver pour cette catégorie.", + "Tariffa globale per giorno. Vuoto o 0 = nessuna tariffa globale; le tipologie possono definirne una propria. Per sospendere tutte le multe usa l’interruttore dedicato.": "Tarif journalier global. Vide ou 0 = aucun tarif global ; les catégories peuvent définir leur tarif. Utilisez la commande dédiée pour suspendre toutes les amendes.", + "Soglia globale del debito. Vuoto o 0 = nessun blocco globale; le tipologie possono definire una soglia propria. Per sospendere tutti i blocchi usa l’interruttore dedicato.": "Seuil global de dette. Vide ou 0 = aucun blocage global ; les catégories peuvent définir leur seuil. Utilisez la commande dédiée pour suspendre tous les blocages." } diff --git a/locale/it_IT.json b/locale/it_IT.json index cfe569db0..58e50e6af 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -9099,5 +9099,46 @@ "non prevista nel %d": "non prevista nel %d", "ogni anno": "ogni anno", "regionale": "regionale", - "MaintenanceService errore festività": "MaintenanceService errore festività" + "MaintenanceService errore festività": "MaintenanceService errore festività", + "Stampa delle tessere": "Stampa delle tessere", + "Registro movimenti": "Registro movimenti", + "Foto dei lettori": "Foto dei lettori", + "Copie in sola consultazione": "Copie in sola consultazione", + "Multe automatiche per ritardo": "Multe automatiche per ritardo", + "Blocco dei prestiti per debito": "Blocco dei prestiti per debito", + "Validità automatica delle tessere": "Validità automatica delle tessere", + "Riporta penalità per perdita e danno nel registro": "Riporta penalità per perdita e danno nel registro", + "Conservazione a registro presenze sospeso": "Conservazione a registro presenze sospeso", + "Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.": "Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.", + "Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.": "Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.", + "Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.": "Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.", + "Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.": "Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.", + "Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.": "Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.", + "Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.": "Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.", + "Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.": "Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.", + "Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.": "Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.", + "Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.": "Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.", + "Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.": "Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.", + "Giorni di conservazione delle presenze": "Giorni di conservazione delle presenze", + "0 = nessuna cancellazione automatica. La durata si applica ai dati esistenti; non riattiva la raccolta.": "0 = nessuna cancellazione automatica. La durata si applica ai dati esistenti; non riattiva la raccolta.", + "Configura le tessere anche a modulo spento": "Configura le tessere anche a modulo spento", + "Configura le regole anche a modulo spento": "Configura le regole anche a modulo spento", + "Calcola il rinnovo della tessera da": "Calcola il rinnovo della tessera da", + "Scadenza esistente, se futura; altrimenti oggi": "Scadenza esistente, se futura; altrimenti oggi", + "Modalità di tolleranza della multa": "Modalità di tolleranza della multa", + "Soglia: superata la tolleranza, conta dal primo giorno": "Soglia: superata la tolleranza, conta dal primo giorno", + "Abbuono: sottrai sempre i giorni di tolleranza": "Abbuono: sottrai sempre i giorni di tolleranza", + "Il registro può restare attivo con tutti gli automatismi spenti. Le tariffe per tipologia e materiale rispettano questi interruttori.": "Il registro può restare attivo con tutti gli automatismi spenti. Le tariffe per tipologia e materiale rispettano questi interruttori.", + "Modulo spento: puoi preparare la configurazione. Le scelte diventeranno operative soltanto dopo l’attivazione nelle impostazioni.": "Modulo spento: puoi preparare la configurazione. Le scelte diventeranno operative soltanto dopo l’attivazione nelle impostazioni.", + "Attivando il riporto delle penalità, saranno registrati %s importi precedenti, per un totale di %s. Le multe per ritardo restano una scelta separata.": "Attivando il riporto delle penalità, saranno registrati %s importi precedenti, per un totale di %s. Le multe per ritardo restano una scelta separata.", + "Addebita automaticamente una multa per ogni giorno di ritardo quando un prestito viene restituito dopo la scadenza. Le multe richiedono registro, interruttore dedicato e tariffa positiva.": "Addebita automaticamente una multa per ogni giorno di ritardo quando un prestito viene restituito dopo la scadenza. Le multe richiedono registro, interruttore dedicato e tariffa positiva.", + "La modalità di tolleranza determina quali giorni vengono addebitati. Saldo, pagamenti e condoni si gestiscono dalla scheda del lettore, sezione Movimenti.": "La modalità di tolleranza determina quali giorni vengono addebitati. Saldo, pagamenti e condoni si gestiscono dalla scheda del lettore, sezione Movimenti.", + "Vuoto = eredita; 0 = nessun limite.": "Vuoto = eredita; 0 = nessun limite.", + "Vuoto = eredita; 0 = rinnovi vietati.": "Vuoto = eredita; 0 = rinnovi vietati.", + "Vuoto = eredita; 0 = nessuna tolleranza.": "Vuoto = eredita; 0 = nessuna tolleranza.", + "Vuoto = eredita; inserisci un numero positivo.": "Vuoto = eredita; inserisci un numero positivo.", + "Vuoto = eredita; 0 = nessun tetto.": "Vuoto = eredita; 0 = nessun tetto.", + "Vuoto = eredita; 0 = disattiva per questa tipologia.": "Vuoto = eredita; 0 = disattiva per questa tipologia.", + "Tariffa globale per giorno. Vuoto o 0 = nessuna tariffa globale; le tipologie possono definirne una propria. Per sospendere tutte le multe usa l’interruttore dedicato.": "Tariffa globale per giorno. Vuoto o 0 = nessuna tariffa globale; le tipologie possono definirne una propria. Per sospendere tutte le multe usa l’interruttore dedicato.", + "Soglia globale del debito. Vuoto o 0 = nessun blocco globale; le tipologie possono definire una soglia propria. Per sospendere tutti i blocchi usa l’interruttore dedicato.": "Soglia globale del debito. Vuoto o 0 = nessun blocco globale; le tipologie possono definire una soglia propria. Per sospendere tutti i blocchi usa l’interruttore dedicato." } diff --git a/storage/plugins/mobile-api/src/Controllers/HealthController.php b/storage/plugins/mobile-api/src/Controllers/HealthController.php index 0fda25683..72efdf0ae 100644 --- a/storage/plugins/mobile-api/src/Controllers/HealthController.php +++ b/storage/plugins/mobile-api/src/Controllers/HealthController.php @@ -98,6 +98,9 @@ public function index( // Mobile API 1.9.0: the optional circulation modules of // Settings -> Loans (already false in catalogue mode). // Older servers omit these keys: the app treats them as off. + 'automatic_fines' => \App\Support\Features::enabled('automatic_fines'), + 'debt_block' => \App\Support\Features::enabled('debt_block'), + 'consultation_only' => \App\Support\Features::enabled('consultation_only'), 'fines' => \App\Support\Features::enabled('fines'), 'closures_calendar' => \App\Support\Features::enabled('closures_calendar'), 'circulation_rules' => \App\Support\Features::enabled('circulation_rules'), diff --git a/tests/feature-toggle-circulation-rules.spec.js b/tests/feature-toggle-circulation-rules.spec.js index f59bee968..37880c006 100644 --- a/tests/feature-toggle-circulation-rules.spec.js +++ b/tests/feature-toggle-circulation-rules.spec.js @@ -29,8 +29,8 @@ test.describe.serial('Feature toggle: rules by patron type', () => { let typeId = 0; test.beforeAll(async ({ browser }) => { - snapshot = ft.readFeatures(['circulation_rules']); - await ft.writeFeatures({ circulation_rules: '1' }); + snapshot = ft.readFeatures(['circulation_rules', 'consultation_only', 'automatic_card_expiry']); + await ft.writeFeatures({ circulation_rules: '1', consultation_only: '1' }); ft.removeUsers('zz-ft-rules-%@test.local'); bookId = mf.seedBook({ titolo: TITLE, copie_totali: 1, copie_disponibili: 1 }); ft.dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato, solo_consultazione) VALUES (${bookId}, ${ft.sqlString(`ZZFTC-${RUN}`)}, 'disponibile', 1)`); @@ -51,16 +51,16 @@ test.describe.serial('Feature toggle: rules by patron type', () => { await page?.close(); }); - test('1. switched off from Settings → Loans: the Circulation section and its pages are gone', async () => { + test('1. rules off: administrators can still configure circulation', async () => { await page.goto(`${ft.BASE}/admin/settings?tab=loans`); await expect(page.locator('#loans-circulation-rules')).toBeVisible(); await ft.setModuleInUi(page, 'circulation_rules', false); await expect(page.locator('#feature_circulation_rules')).not.toBeChecked(); - await expect(page.locator('a[data-settings-section="circulation"]')).toHaveCount(0); + await expect(page.locator('a[data-settings-section="circulation"]').first()).toBeAttached(); await page.goto(`${ft.BASE}/admin/settings?tab=circulation`); - await expect(page.locator('section[data-settings-panel="general"]')).toBeVisible(); + await expect(page.locator('section[data-settings-panel="circulation"]')).toBeVisible(); await page.goto(`${ft.BASE}/admin/settings/circulation`); - await expect(page).toHaveURL(/\/admin\/dashboard/); + await expect(page).toHaveURL(/tab=circulation/); }); test('2. switched off: no patron type in the user forms, the record or the users filter', async () => { @@ -73,7 +73,12 @@ test.describe.serial('Feature toggle: rules by patron type', () => { await expect(page.locator('#patron_type_filter')).toHaveCount(0); }); - test('3. switched off: a consultation-only copy is an ordinary lendable copy', async () => { + test('3. consultation survives rules off and has its own switch', async () => { + await page.goto(`${ft.BASE}/admin/books/${bookId}`); + await expect(page.locator('[data-testid="copy-consultation-badge"]')).toBeVisible(); + await page.goto(`${ft.BASE}/libro/${bookId}`); + await expect(page.locator('#consultation-only')).toBeVisible(); + await ft.setModuleInUi(page, 'consultation_only', false); await page.goto(`${ft.BASE}/admin/books/${bookId}`); await expect(page.locator('h1').first()).toContainText(TITLE); await expect(page.locator('[data-testid="copy-consultation-badge"]')).toHaveCount(0); @@ -84,6 +89,7 @@ test.describe.serial('Feature toggle: rules by patron type', () => { const res = await page.request.post(`${ft.BASE}/admin/books/copies/${copyId}/consultation`, { form: { csrf_token: csrf, solo_consultazione: '0' }, maxRedirects: 0 }); expect(res.status()).toBe(302); expect(ft.dbQuery(`SELECT solo_consultazione FROM copie WHERE id=${copyId}`)).toBe('1'); + await ft.setModuleInUi(page, 'consultation_only', true); }); test('4. switched off: saving the reader from the edit form keeps the patron type', async () => { @@ -103,4 +109,33 @@ test.describe.serial('Feature toggle: rules by patron type', () => { await page.goto(`${ft.BASE}/libro/${bookId}`); await expect(page.locator('#consultation-only')).toBeVisible(); }); + test('6. card automation can be suspended and renewal confirmation matches a future expiry', async () => { + const previousBase = ft.dbQuery("SELECT COALESCE(MAX(setting_value), '') FROM system_settings WHERE category='loans' AND setting_key='card_renewal_base'"); + try { + await ft.setModuleInUi(page, 'automatic_card_expiry', false); + await page.goto(`${ft.BASE}/admin/users/edit/${readerId}`); + await expect(page.locator('#card_auto')).toHaveCount(0); + await ft.setModuleInUi(page, 'automatic_card_expiry', true); + await page.goto(`${ft.BASE}/admin/users/edit/${readerId}`); + await expect(page.locator('#card_auto')).toBeVisible(); + ft.dbQuery(`UPDATE tipologie_lettore SET durata_tessera_giorni=30 WHERE id=${typeId}`); + ft.dbQuery(`UPDATE utenti SET data_scadenza_tessera='2090-01-01' WHERE id=${readerId}`); + ft.dbQuery("INSERT INTO system_settings (category,setting_key,setting_value) VALUES ('loans','card_renewal_base','expiry') ON DUPLICATE KEY UPDATE setting_value='expiry'"); + await require('./helpers/flush-cache').flushCache(); + const expected = '2090-01-31'; + const displayed = '31/01/2090'; + await page.goto(`${ft.BASE}/admin/users/details/${readerId}`); + const form = page.locator('form[action$="/renew-card"]'); + await expect(form).toHaveAttribute('data-swal-confirm', new RegExp(displayed)); + await form.locator('button[type="submit"]').click(); + await page.locator('.swal2-confirm').click(); + await page.waitForLoadState('networkidle'); + expect(ft.dbQuery(`SELECT data_scadenza_tessera FROM utenti WHERE id=${readerId}`)).toBe(expected); + } finally { + if (previousBase === '') ft.dbQuery("DELETE FROM system_settings WHERE category='loans' AND setting_key='card_renewal_base'"); + else ft.dbQuery(`UPDATE system_settings SET setting_value=${ft.sqlString(previousBase)} WHERE category='loans' AND setting_key='card_renewal_base'`); + await require('./helpers/flush-cache').flushCache(); + } + }); + }); diff --git a/tests/feature-toggle-closures-calendar.spec.js b/tests/feature-toggle-closures-calendar.spec.js index 7377f5192..96087e224 100644 --- a/tests/feature-toggle-closures-calendar.spec.js +++ b/tests/feature-toggle-closures-calendar.spec.js @@ -60,51 +60,52 @@ test.describe.serial('Feature toggle: closures calendar', () => { expect(await closedDates()).toContain(closed); }); - test('2. switched off from Settings → Loans: no sidebar entry, no calendar page, no button', async () => { + test('2. switched off: operational entry disappears but administrators can configure', async () => { await ft.setModuleInUi(page, 'closures_calendar', false); await expect(page.locator('#feature_closures_calendar')).not.toBeChecked(); - await expect(page.locator('#open-closures-calendar')).toHaveCount(0); + await expect(page.locator('#open-closures-calendar')).toBeVisible(); await expect(page.locator('a.nav-link[href$="/admin/calendar"]')).toHaveCount(0); await page.goto(`${ft.BASE}/admin/calendar`); - await expect(page).toHaveURL(/\/admin\/dashboard/); + await expect(page).toHaveURL(/\/admin\/calendar/); }); test('3. switched off: the request calendar has no closed day', async () => { expect(await closedDates()).not.toContain(closed); }); - test('4. switched off: a closure posted to the calendar is refused', async () => { + test('4. switched off: a closure can be prepared without affecting circulation', async () => { const csrf = await ft.csrfToken(page); const res = await page.request.post(`${ft.BASE}/admin/calendar/closures`, { form: { csrf_token: csrf, data_inizio: closed, data_fine: closed, descrizione: `${NOTE} refused` }, maxRedirects: 0, }); expect(res.status()).toBe(302); - expect(ft.dbQuery(`SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione=${ft.sqlString(NOTE + ' refused')}`)).toBe('0'); + expect(ft.dbQuery(`SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione=${ft.sqlString(NOTE + ' refused')}`)).toBe('1'); + expect(await closedDates()).not.toContain(closed); }); - test('5. switched off: no holiday card, and the holiday and yearly-closure forms are refused', async () => { + test('5. switched off: holiday validation and yearly configuration remain available', async () => { const holidayRows = () => ft.dbQuery('SELECT COUNT(*) FROM calendario_chiusure WHERE regola_festivita IS NOT NULL'); const holidayChoice = () => ft.dbQuery("SELECT COALESCE(GROUP_CONCAT(CONCAT(setting_key, '=', setting_value) ORDER BY setting_key), '') FROM system_settings WHERE category = 'calendar' AND setting_key LIKE 'holiday\\_%'"); const rowsBefore = holidayRows(); const choiceBefore = holidayChoice(); await page.goto(`${ft.BASE}/admin/calendar#calendar-preset`); - await expect(page).toHaveURL(/\/admin\/dashboard/); - await expect(page.locator('#calendar-preset')).toHaveCount(0); - await expect(page.locator('#calendar-yearly')).toHaveCount(0); + await expect(page).toHaveURL(/\/admin\/calendar/); + await expect(page.locator('#calendar-preset')).toBeVisible(); + await expect(page.locator('#calendar-yearly')).toBeVisible(); const csrf = await ft.csrfToken(page); const post = (path, body) => page.request.post(`${ft.BASE}${path}`, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: new URLSearchParams(body).toString(), maxRedirects: 0, }); - const preset = await post('/admin/calendar/preset', [['csrf_token', csrf], ['country', 'IT'], ['region', ''], ['rules[]', 'IT:easter_monday'], ['rules[]', 'IT:christmas']]); + const preset = await post('/admin/calendar/preset', [['csrf_token', csrf], ['country', 'INVALID'], ['region', ''], ['rules[]', 'IT:easter_monday'], ['rules[]', 'IT:christmas']]); expect(preset.status()).toBe(302); - expect(preset.headers()['location'] || '').toMatch(/\/admin\/dashboard/); + expect(preset.headers()['location'] || '').toMatch(/\/admin\/calendar/); expect(holidayRows()).toBe(rowsBefore); expect(holidayChoice()).toBe(choiceBefore); // The yearly closure is listed and not ticked: on, it would be switched off. const yearly = await post('/admin/calendar/yearly', [['csrf_token', csrf], ['listed[]', String(yearlyId)]]); expect(yearly.status()).toBe(302); - expect(yearly.headers()['location'] || '').toMatch(/\/admin\/dashboard/); - expect(ft.dbQuery(`SELECT attiva FROM calendario_chiusure WHERE id=${yearlyId}`)).toBe('1'); + expect(yearly.headers()['location'] || '').toMatch(/\/admin\/calendar/); + expect(ft.dbQuery(`SELECT attiva FROM calendario_chiusure WHERE id=${yearlyId}`)).toBe('0'); }); test('6. switched back on: entry, page and the closure stored before are back', async () => { @@ -121,7 +122,7 @@ test.describe.serial('Feature toggle: closures calendar', () => { await expect(page).toHaveURL(/\/admin\/calendar/); await expect(page.locator('body')).toContainText(NOTE); await expect(page.locator('#calendar-preset')).toBeVisible(); - await expect(page.locator(`#yearly_closure_${yearlyId}`)).toBeChecked(); + await expect(page.locator(`#yearly_closure_${yearlyId}`)).not.toBeChecked(); expect(await closedDates()).toContain(closed); }); }); diff --git a/tests/feature-toggle-patron-cards.spec.js b/tests/feature-toggle-patron-cards.spec.js index 0f196fd38..89d72ea69 100644 --- a/tests/feature-toggle-patron-cards.spec.js +++ b/tests/feature-toggle-patron-cards.spec.js @@ -23,7 +23,8 @@ test.describe.serial('Feature toggle: patron cards and photos', () => { let readerId = 0; test.beforeAll(async ({ browser }) => { - snapshot = ft.readFeatures(['patron_cards']); + snapshot = ft.readFeatures(['patron_cards', 'patron_photos']); + await ft.writeFeatures({ patron_photos: '1' }); ft.removeUsers('zz-ft-card-%@test.local'); readerId = ft.createUser(READER, 'Test1234!', 'standard'); page = await browser.newPage(); @@ -36,22 +37,22 @@ test.describe.serial('Feature toggle: patron cards and photos', () => { await page?.close(); }); - test('1. switched off: the Cards settings section is gone and ?tab=cards falls back', async () => { + test('1. printing off: administrators can still prepare card settings', async () => { await ft.setModuleInUi(page, 'patron_cards', false); await page.goto(`${ft.BASE}/admin/settings?tab=cards`); - await expect(page.locator('a[data-settings-section="cards"]')).toHaveCount(0); - await expect(page.locator('section[data-settings-panel="cards"]')).toHaveCount(0); - await expect(page.locator('section[data-settings-panel="general"]')).toBeVisible(); + await expect(page.locator('a[data-settings-section="cards"]').first()).toBeAttached(); + await expect(page.locator('section[data-settings-panel="cards"]')).toBeVisible(); + }); - test('2. switched off: the reader record has neither the print button nor the photo panel', async () => { + test('2. printing off: photo capture and display remain available', async () => { await page.goto(`${ft.BASE}/admin/users/details/${readerId}`); await expect(page.locator('h1').first()).toBeVisible(); await expect(page.locator('#print-card')).toHaveCount(0); - await expect(page.locator('#patron-card-section')).toHaveCount(0); + await expect(page.locator('#patron-card-section')).toBeVisible(); await page.goto(`${ft.BASE}/admin/users/edit/${readerId}`); await expect(page.locator('input[name="email"]')).toBeVisible(); - await expect(page.locator('#patron-photo-widget')).toHaveCount(0); + await expect(page.locator('#patron-photo-widget')).toBeVisible(); await page.goto(`${ft.BASE}/admin/users`); await expect(page.locator('#bulk-cards-sheet')).toHaveCount(0); }); @@ -64,7 +65,8 @@ test.describe.serial('Feature toggle: patron cards and photos', () => { } }); - test('4. switched off: a photo upload is refused and nothing is stored', async () => { + test('4. photos switched off independently: upload is refused and nothing is stored', async () => { + await ft.setModuleInUi(page, 'patron_photos', false); const csrf = await ft.csrfToken(page); const res = await page.request.post(`${ft.BASE}/admin/users/${readerId}/photo`, { headers: { Accept: 'application/json' }, @@ -82,11 +84,14 @@ test.describe.serial('Feature toggle: patron cards and photos', () => { await expect(page.locator('section[data-settings-panel="cards"]')).toBeVisible(); await page.goto(`${ft.BASE}/admin/users/details/${readerId}`); await expect(page.locator('#print-card')).toBeVisible(); - await expect(page.locator('#patron-card-section')).toBeVisible(); + await expect(page.locator('#patron-card-section')).toHaveCount(0); const res = await page.request.get(`${ft.BASE}/admin/users/${readerId}/card-pdf`); expect(res.status()).toBe(200); expect(String(res.headers()['content-type'] || '')).toContain('application/pdf'); await page.goto(`${ft.BASE}/admin/users/edit/${readerId}`); + await expect(page.locator('#patron-photo-widget')).toHaveCount(0); + await ft.setModuleInUi(page, 'patron_photos', true); + await page.goto(`${ft.BASE}/admin/users/edit/${readerId}`); await expect(page.locator('#patron-photo-widget')).toBeVisible(); }); }); diff --git a/tests/feature-toggles-registry.unit.php b/tests/feature-toggles-registry.unit.php index 795856b72..6a120c9a6 100644 --- a/tests/feature-toggles-registry.unit.php +++ b/tests/feature-toggles-registry.unit.php @@ -28,7 +28,7 @@ }; $keys = Features::keys(); -$check(count($keys) === 11, '11 optional modules (got ' . count($keys) . ')'); +$check(count($keys) === 18, '18 optional controls (got ' . count($keys) . ')'); $check(array_keys(Features::FRESH) === $keys, 'FRESH lists the same keys as DEFAULTS, in the same order'); $check(array_diff(array_merge(Features::MODULES_TAB, Features::LOANS_TAB), $keys) === [] && count(Features::MODULES_TAB) + count(Features::LOANS_TAB) === count($keys) && array_intersect(Features::MODULES_TAB, Features::LOANS_TAB) === [], 'every module is saved by exactly one settings tab'); @@ -92,7 +92,7 @@ $check(false, "migration seeds {$k}"); continue; } - $value = $mm[1] === '@ft_val' ? '0' : $mm[2]; + $value = $mm[1] === '@ft_val' ? (isset(Features::INHERITED[$k]) ? Features::DEFAULTS[Features::INHERITED[$k]] : '0') : $mm[2]; $check($value === Features::DEFAULTS[$k], "migration seeds {$k} with the upgrade default " . Features::DEFAULTS[$k] . ($mm[1] === '@ft_val' ? ' (unless the library has data for it)' : '')); } @@ -103,7 +103,7 @@ } $loansTab = (string) file_get_contents($root . '/app/Views/settings/loans-tab.php'); foreach (Features::LOANS_TAB as $k) { - $check(str_contains($loansTab, "id=\"feature_{$k}\""), "Settings → Loans renders feature_{$k}"); + $check(str_contains($loansTab, "id=\"feature_{$k}\"") || str_contains($loansTab, "'{$k}'"), "Settings → Loans renders feature_{$k}"); } // Labels and help: a translation in every locale. diff --git a/tests/feature-toggles-routes.unit.php b/tests/feature-toggles-routes.unit.php index d6f34b92b..e12b700fc 100644 --- a/tests/feature-toggles-routes.unit.php +++ b/tests/feature-toggles-routes.unit.php @@ -38,9 +38,11 @@ in_array($path, ['/admin/books/{id:\d+}/catalogue-card', '/admin/books/catalogue-cards'], true) => 'catalogue_cards', str_starts_with($path, '/admin/suppliers') => 'suppliers', str_starts_with($path, '/admin/attendance') => 'visitor_log', - in_array($path, ['/admin/settings/cards', '/admin/users/{id:\d+}/card-pdf', '/admin/users/cards-pdf', '/admin/users/{id:\d+}/photo', '/admin/users/{id:\d+}/photo/delete'], true) => 'patron_cards', + in_array($path, ['/admin/settings/cards', '/admin/users/{id:\d+}/card-pdf', '/admin/users/cards-pdf', ], true) => 'patron_cards', + in_array($path, ['/admin/users/{id:\d+}/photo', '/admin/users/{id:\d+}/photo/delete'], true) => 'patron_photos', + $path === '/admin/books/copies/{id:\d+}/consultation' => 'consultation_only', str_starts_with($path, '/admin/users/import') => 'user_import', - str_starts_with($path, '/admin/settings/circulation'), $path === '/admin/books/copies/{id:\d+}/consultation' => 'circulation_rules', + str_starts_with($path, '/admin/settings/circulation') => 'circulation_rules', ($path === '/admin/calendar' || str_starts_with($path, '/admin/calendar/')) && $path !== '/admin/calendar/rotate-link' => 'closures_calendar', $path === '/admin/statistics/fines.csv', str_starts_with($path, '/admin/users/{id:\d+}/ledger/') => 'fines', default => null, @@ -59,9 +61,9 @@ foreach ($m[1] as $i => [$path, $offset]) { $next = $m[0][$i + 1][1] ?? strlen($source); $chunk = substr($source, $offset, $next - $offset); - $gate = preg_match("/FeatureGateMiddleware\\('([a-z_]+)'\\)/", $chunk, $g) === 1 ? $g[1] : null; + $gate = preg_match("/FeatureGateMiddleware\\('([a-z_]+)'(?:, configuration: true)?\\)/", $chunk, $g) === 1 ? $g[1] : null; // First ->add() after the handler closes (`})` or `$inventory('x'))`). - $first = $gate !== null && preg_match("/(?:\\}\\)|\\('[a-zA-Z]+'\\)\\))->add\\(new \\\\App\\\\Middleware\\\\FeatureGateMiddleware\\('{$gate}'\\)\\)/", $chunk) === 1; + $first = $gate !== null && preg_match("/(?:\\}\\)|\\('[a-zA-Z]+'\\)\\))->add\\(new \\\\App\\\\Middleware\\\\FeatureGateMiddleware\\('{$gate}'(?:, configuration: true)?\\)\\)/", $chunk) === 1; $out[] = ['path' => $path, 'gate' => $gate, 'first' => $first]; } return $out; diff --git a/tests/feature-toggles-services.unit.php b/tests/feature-toggles-services.unit.php index 7d5cf82eb..debc387cc 100644 --- a/tests/feature-toggles-services.unit.php +++ b/tests/feature-toggles-services.unit.php @@ -134,6 +134,7 @@ // ── B. circulation rules ──────────────────────────────────────────────── Features::override('circulation_rules', false); + Features::override('consultation_only', false); $policy = new CirculationPolicy($db); $check($policy->schemaReady() && !$policy->rulesActive(), 'B off: the tables exist, the rules do not apply'); $check($policy->types() === [] && $policy->rules() === [], 'B off: no patron type and no rule'); @@ -145,6 +146,7 @@ $check(!$policy->isConsultationCopy($copyId) && !$policy->onlyConsultationCopies($bookId), 'B off: a consultation-only copy is lendable'); $check($policy->forViewer(null, $bookId)->loanable === true, 'B off: the title is loanable for everybody'); Features::override('circulation_rules', true); + Features::override('consultation_only', true); $policy = new CirculationPolicy($db); $check($policy->rulesActive() && $policy->types() !== [], 'B on: the seeded patron types apply'); $check($policy->isConsultationCopy($copyId) && $policy->onlyConsultationCopies($bookId), 'B on: the consultation flag applies again'); @@ -159,12 +161,14 @@ $check($typeImporter->available(), 'B on: user import offers patron types'); $defaultType = $policy->types()[$policy->defaultTypeId()]; Features::override('circulation_rules', false); + Features::override('consultation_only', false); $check(!$typeImporter->available(), 'B off: an existing importer stops offering patron types'); $check($typeImporter->assign($patron, (string) $defaultType['codice']) === 'skipped', 'B off: user import assigns no patron type'); $check($db->query("SELECT tipologia_id FROM utenti WHERE id = {$patron}")->fetch_row()[0] === null, 'B off: the imported reader keeps their stored type'); $fields = (new \ReflectionMethod(\App\Controllers\UserImportController::class, 'targetFields'))->invoke(new \App\Controllers\UserImportController(), $db); $check(!isset($fields['tipologia']), 'B off: the CSV mapping omits the patron type'); Features::override('circulation_rules', true); + Features::override('consultation_only', true); $check($typeImporter->available(), 'B on: the same importer offers patron types again'); $db->query("INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES ({$patron}, 'addebito_manuale', 100.00, 'ZZ_FTSVC debt', NOW())"); $db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', 'debt_block_threshold', '1.00') ON DUPLICATE KEY UPDATE setting_value = '1.00'"); @@ -182,6 +186,7 @@ $check((new \App\Support\NotificationService($db))->sendFineChargedNotification(PHP_INT_MAX) === false, 'C off: no fine_charged email'); $check(PatronLedger::forDb($db)->balanceCents($patron) === 10000, 'C off: the rows already written stay'); Features::override('fines', true); + foreach (['automatic_fines', 'debt_block', 'loss_penalties'] as $control) { Features::override($control, true); } $check((new CirculationPolicy($db))->debtBlockError($patron) === 'debt_block', 'C on: the same patron is blocked'); $check(LoanEligibility::debtBlockSql('u', $db) !== '1 = 1', 'C on: the queue predicate checks the balance'); @@ -199,6 +204,7 @@ $check(PatronLedger::forDb($db)->chargeLossPenalty($lostLoan) === 0 && $penaltyRows() === 0, 'C2 off: the loss penalty stays in prestiti.sanzione only'); $check($marker() === '0', 'C2 off: the legacy backfill is re-armed'); Features::override('fines', true); + foreach (['automatic_fines', 'debt_block', 'loss_penalties'] as $control) { Features::override($control, true); } $db->query("DELETE FROM system_settings WHERE category = 'maintenance' AND setting_key = 'last_run'"); (new \App\Support\MaintenanceService($db))->runAll(); $check($penaltyRows() === 1 && $marker() === '1', 'C2 on: maintenance mirrors it into the ledger and records the scan again'); diff --git a/tests/feature-toggles-settings.spec.js b/tests/feature-toggles-settings.spec.js index 77543b4ba..275140af2 100644 --- a/tests/feature-toggles-settings.spec.js +++ b/tests/feature-toggles-settings.spec.js @@ -177,4 +177,71 @@ test.describe.serial('Feature toggles: settings', () => { await en.close(); } }); + test('7. the ledger, automatic fines, debt block and loss mirroring save independently', async () => { + await ft.setModuleInUi(page, 'fines', true); + await ft.setModuleInUi(page, 'automatic_fines', false); + await ft.setModuleInUi(page, 'debt_block', true); + await ft.setModuleInUi(page, 'loss_penalties', false); + await page.reload(); + await expect(page.locator('#feature_fines')).toBeChecked(); + await expect(page.locator('#feature_automatic_fines')).not.toBeChecked(); + await expect(page.locator('#feature_debt_block')).toBeChecked(); + await expect(page.locator('#feature_loss_penalties')).not.toBeChecked(); + const stored = ft.readFeatures(['fines', 'automatic_fines', 'debt_block', 'loss_penalties']); + expect(stored).toEqual({ fines: '1', automatic_fines: '0', debt_block: '1', loss_penalties: '0' }); + }); + + test('8. disabled card and circulation modules can be configured without being activated', async () => { + const saved = ft.readFeatures(['patron_cards', 'circulation_rules']); + const code = `zz-staged-${RUN}`; + let oldTitle; + try { + await ft.writeFeatures({ patron_cards: '0', circulation_rules: '0' }); + await page.goto(`${ft.BASE}/admin/settings?tab=cards`); + const cards = page.locator('section[data-settings-panel="cards"]'); + await expect(cards).toBeVisible(); + oldTitle = await cards.locator('[name="card_title"]').inputValue(); + await cards.locator('[name="card_title"]').fill(`ZZ staged ${RUN}`); + await cards.locator('button[type="submit"]').click(); + await page.waitForURL(/tab=cards/); + await expect(cards.locator('[name="card_title"]')).toHaveValue(`ZZ staged ${RUN}`); + await page.goto(`${ft.BASE}/admin/settings?tab=circulation`); + await page.locator('#circulation-new-type summary').click(); + const form = page.locator('[data-testid="circulation-type-create"]'); + await form.locator('[name="nome"]').fill(`ZZ staged ${RUN}`); + await form.locator('[name="codice"]').fill(code); + await form.locator('button[type="submit"]').click(); + await page.waitForURL(/tab=circulation/); + expect(ft.dbQuery(`SELECT COUNT(*) FROM tipologie_lettore WHERE codice=${ft.sqlString(code)}`)).toBe('1'); + expect(ft.readFeatures(['patron_cards', 'circulation_rules'])).toEqual({ patron_cards: '0', circulation_rules: '0' }); + } finally { + if (oldTitle !== undefined) { + await page.goto(`${ft.BASE}/admin/settings?tab=cards`); + const cards = page.locator('section[data-settings-panel="cards"]'); + await cards.locator('[name="card_title"]').fill(oldTitle); + await cards.locator('button[type="submit"]').click(); + await page.waitForURL(/tab=cards/); + } + ft.dbQuery(`DELETE FROM tipologie_lettore WHERE codice=${ft.sqlString(code)}`); + await ft.restoreFeatures(saved); + } + }); + + test('9. configuration access while disabled remains reserved to administrators', async ({ browser }) => { + const saved = ft.readFeatures(['patron_cards', 'circulation_rules', 'closures_calendar']); + const staff = await browser.newPage(); + try { + await ft.writeFeatures({ patron_cards: '0', circulation_rules: '0', closures_calendar: '0' }); + await ft.loginAs(staff, STAFF_EMAIL, PASS); + for (const path of ['/admin/settings/circulation', '/admin/settings/cards', '/admin/calendar']) { + const res = await staff.request.get(`${ft.BASE}${path}`, { headers: { Accept: 'application/json' }, maxRedirects: 0 }); + expect(res.status()).toBe(404); + expect((await res.json()).code).toBe('feature_disabled'); + } + } finally { + await staff.close(); + await ft.restoreFeatures(saved); + } + }); + }); diff --git a/tests/helpers/feature-toggles.js b/tests/helpers/feature-toggles.js index 93b6dc2d5..a59988a38 100644 --- a/tests/helpers/feature-toggles.js +++ b/tests/helpers/feature-toggles.js @@ -22,9 +22,9 @@ const DB_HOST = process.env.E2E_DB_HOST || ''; const DB_NAME = process.env.E2E_DB_NAME || ''; /** Same order and keys as Features::DEFAULTS. */ -const FEATURE_KEYS = ['stocktake', 'marc', 'visitor_log', 'catalogue_cards', 'suppliers', 'rda_types', 'patron_cards', 'user_import', 'circulation_rules', 'closures_calendar', 'fines']; +const FEATURE_KEYS = ['stocktake', 'marc', 'visitor_log', 'catalogue_cards', 'suppliers', 'rda_types', 'patron_cards', 'user_import', 'circulation_rules', 'closures_calendar', 'fines', 'patron_photos', 'consultation_only', 'automatic_card_expiry', 'automatic_fines', 'debt_block', 'loss_penalties', 'attendance_retention']; /** Saved from Settings → Loans; every other key from Settings → Modules. */ -const LOANS_TAB_KEYS = ['circulation_rules', 'closures_calendar', 'fines']; +const LOANS_TAB_KEYS = ['circulation_rules', 'closures_calendar', 'fines', 'consultation_only', 'automatic_card_expiry', 'automatic_fines', 'debt_block', 'loss_penalties']; function dbQuery(sql) { const args = []; @@ -67,6 +67,8 @@ async function writeFeatures(values) { /** Switch $keys on for a spec's setup; returns the snapshot restoreFeatures() puts back. */ async function enableFeatures(keys) { + // Module suites exercise their full domain; opt into the separate controls explicitly. + keys = [...new Set([...keys, ...(keys.includes('fines') ? ['automatic_fines', 'debt_block', 'loss_penalties'] : []), ...(keys.includes('patron_cards') ? ['patron_photos'] : [])])]; const snapshot = readFeatures(keys); await writeFeatures(Object.fromEntries(keys.map((k) => [k, '1']))); return snapshot; diff --git a/tests/migration-0.9.0-fines-ledger.unit.php b/tests/migration-0.9.0-fines-ledger.unit.php index a539d814d..e2c4018df 100644 --- a/tests/migration-0.9.0-fines-ledger.unit.php +++ b/tests/migration-0.9.0-fines-ledger.unit.php @@ -28,6 +28,7 @@ // The module under test is on, whatever features.* says in the database // this runs against (Settings → Modules / Loans). \App\Support\Features::override('fines', true); +foreach (['automatic_fines', 'debt_block', 'loss_penalties'] as $control) { \App\Support\Features::override($control, true); } mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); $passed = 0; @@ -105,6 +106,8 @@ $check(!PatronLedger::schemaReady($db) && (new PatronLedger($db))->backfillLegacyPenalties() === null, 'pre-upgrade: the backfill reports the missing table (null), so maintenance keeps its marker unset'); $fragment = \App\Support\LoanEligibility::eligibleUserWhere('u', $db); $check(!str_contains($fragment, 'movimenti_lettore') && str_contains($fragment, '1 = 1') && substr_count($fragment, '?') === 1, 'pre-upgrade: eligibleUserWhere() carries no debt predicate and still needs ONE placeholder'); + $db->query("CREATE TABLE IF NOT EXISTS system_settings (id INT AUTO_INCREMENT PRIMARY KEY, category VARCHAR(50), setting_key VARCHAR(100), setting_value TEXT, description TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY(category,setting_key))"); + $db->query("INSERT IGNORE INTO system_settings (category, setting_key, setting_value) VALUES ('features','fines','1'),('features','loss_penalties','1')"); $runMigration(); PatronLedger::resetSchemaCache(); \App\Services\CirculationPolicy::resetSchemaCache(); diff --git a/tests/patron-ledger.unit.php b/tests/patron-ledger.unit.php index 0ddf1f4b7..d0bb9913e 100644 --- a/tests/patron-ledger.unit.php +++ b/tests/patron-ledger.unit.php @@ -28,6 +28,7 @@ // The module under test is on, whatever features.* says in the database // this runs against (Settings → Modules / Loans). \App\Support\Features::override('fines', true); +foreach (['automatic_fines', 'debt_block', 'loss_penalties'] as $control) { \App\Support\Features::override($control, true); } mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); $passed = 0; diff --git a/tests/stack-090-settings-flexibility.unit.php b/tests/stack-090-settings-flexibility.unit.php index 7eead48d9..4b96af427 100644 --- a/tests/stack-090-settings-flexibility.unit.php +++ b/tests/stack-090-settings-flexibility.unit.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * 26 independently named integration scenarios for the 0.9.0 settings: + * 37 independently named integration scenarios for the 0.9.0 settings: * 25 configuration scenarios and upgrade preservation of saved choices. * Real controllers, persisted settings, feature gates and domain services; * no Features::override(), no source-text assertions, no network requests. @@ -141,13 +141,14 @@ 'loan_duration_days' => '30', 'pickup_expiry_days' => '3', 'max_renewals' => '3', 'max_loan_duration_days' => '3650', 'feature_circulation_rules' => '1', 'feature_closures_calendar' => '1', 'feature_fines' => '1', + 'feature_automatic_fines' => '1', 'feature_debt_block' => '1', 'feature_loss_penalties' => '1', ], $extra); $saveLoans = static function (array $extra = []) use ($request, $db, $loansBody): void { (new SettingsController())->updateLoansSettings($request('/admin/settings/loans', $loansBody($extra)), new Response(), $db); }; $test('01 Modules: a mixed selection persists and is read by the runtime', static function () use ($request, $db, $assert): void { - (new SettingsController())->updateModules($request('/admin/settings/modules', ['feature_marc' => '1', 'feature_user_import' => '1']), new Response(), $db); + (new SettingsController())->updateModules($request('/admin/settings/modules', ['feature_marc' => '1', 'feature_user_import' => '1', 'feature_patron_photos' => '0']), new Response(), $db); foreach (Features::MODULES_TAB as $key) { $assert(Features::enabled($key) === in_array($key, ['marc', 'user_import'], true), 'Saved switch is inert or wrong: ' . $key); } @@ -244,13 +245,13 @@ public function handle(ServerRequestInterface $request): ResponseInterface { thr $policy = (new CirculationPolicy($db))->forLoan($p, $b['id']); $assert($policy->maxRenewals === 0 && $policy->durationDays === 17, 'Zero and inheritance were conflated'); }); - $test('14 Disabling type rules uses global duration and ignores consultation, then restores both', static function () use ($db, $type, $book, $patron, $flag, $set, $assert): void { + $test('14 Disabling type rules preserves the independent consultation restriction', static function () use ($db, $type, $book, $patron, $flag, $set, $assert): void { $t = $type(['durata_prestito_giorni' => '9']); $p = $patron(); $b = $book(); $db->query("UPDATE utenti SET tipologia_id={$t} WHERE id={$p}"); $db->query("UPDATE copie SET solo_consultazione=1 WHERE id={$b['copy']}"); $set('loans', 'loan_duration_days', '23'); $flag('circulation_rules', false); $off = (new CirculationPolicy($db))->forLoan($p, $b['id'], $b['copy']); - $assert($off->durationDays === 23 && $off->loanable, 'Off does not restore the global policy'); + $assert($off->durationDays === 23 && !$off->loanable, 'Off does not restore the global policy'); $flag('circulation_rules', true); $on = (new CirculationPolicy($db))->forLoan($p, $b['id'], $b['copy']); $assert($on->durationDays === 9 && !$on->loanable, 'The saved type or consultation flag was lost'); @@ -353,6 +354,132 @@ public function handle(ServerRequestInterface $request): ResponseInterface { thr $assert($one("SELECT stato FROM copie WHERE id={$b['copy']}") === 'disponibile', 'Optional loss action was applied implicitly'); }); + + $test('27 Ledger-only mode ignores positive global and typed fine rates and debt thresholds', static function () use ($saveLoans, $db, $patron, $book, $loan, $type, $assert): void { + $saveLoans(['daily_fine' => '2', 'debt_block_threshold' => '1', 'feature_automatic_fines' => '0', 'feature_debt_block' => '0']); + $t = $type(['multa_giornaliera' => '3.00', 'soglia_blocco_debito' => '0.50']); $p = $patron(); + $db->query("UPDATE utenti SET tipologia_id={$t} WHERE id={$p}"); + $id = $loan($p, $book(), '2026-10-01', '2026-10-06'); $ledger = new PatronLedger($db); + $ledger->manualCharge($p, 1000, 'ZZ manual only', null); + $assert($ledger->chargeLateReturn($id) === 0 && !$ledger->previewLateFine($id)['enabled'], 'Disabled automatic fines still charge or preview'); + $assert(!PatronLedger::isDebtBlocked($db, $p) && \App\Support\LoanEligibility::debtBlockSql('u', $db) === '1 = 1', 'Debt still blocks a channel'); + $assert($ledger->balanceCents($p) === 1000 && Features::enabled('fines'), 'Manual ledger no longer works'); + }); + $test('28 Fine and debt switches work independently with positive retained amounts', static function () use ($saveLoans, $db, $patron, $book, $loan, $assert): void { + $saveLoans(['daily_fine' => '0.50', 'debt_block_threshold' => '1', 'feature_automatic_fines' => '0', 'feature_debt_block' => '1']); + $p = $patron(); $ledger = new PatronLedger($db); $ledger->manualCharge($p, 200, 'ZZ existing debt', null); + $id = $loan($p, $book(), '2026-10-01', '2026-10-04'); + $assert($ledger->chargeLateReturn($id) === 0 && PatronLedger::isDebtBlocked($db, $p), 'Debt requires automatic fines'); + $saveLoans(['daily_fine' => '0.50', 'debt_block_threshold' => '1', 'feature_automatic_fines' => '1', 'feature_debt_block' => '0']); + $assert($ledger->chargeLateReturn($id) === 150 && !PatronLedger::isDebtBlocked($db, $p), 'Fine generation requires a debt block'); + }); + $test('29 Loss mirroring and legacy backfill require their own switch and remain idempotent', static function () use ($saveLoans, $db, $patron, $book, $loan, $flag, $assert): void { + $saveLoans(['feature_loss_penalties' => '0']); $p = $patron(); $id = $loan($p, $book(), '2026-10-01', '2026-10-04'); + $db->query("UPDATE prestiti SET stato='perso', sanzione=12.50 WHERE id={$id}"); $ledger = new PatronLedger($db); + $assert($ledger->chargeLossPenalty($id) === 0 && $ledger->backfillLegacyPenalties() === null, 'Loss opt-out created ledger charges'); + $assert($ledger->balanceCents($p) === 0, 'A disabled loss mirror changed the balance'); + $flag('loss_penalties', true); $ledger->backfillLegacyPenalties(); + $assert($ledger->balanceCents($p) === 1250 && $ledger->chargeLossPenalty($id) === 0, 'Reactivation lost or doubled the assessed amount'); + }); + $test('30 Consultation restrictions survive type rules being off and can be independently suspended', static function () use ($db, $book, $patron, $flag, $assert): void { + $b = $book(); $p = $patron(); $db->query("UPDATE copie SET solo_consultazione=1 WHERE id={$b['copy']}"); + $flag('circulation_rules', false); $flag('consultation_only', true); $policy = new CirculationPolicy($db); + $assert(!$policy->forLoan($p, $b['id'], $b['copy'])->loanable && $policy->onlyConsultationCopies($b['id']), 'Consultation depends on type rules'); + $assert(str_contains($policy->lendableCopySql('c'), 'solo_consultazione'), 'SQL allocation ignores consultation'); + $flag('consultation_only', false); $policy = new CirculationPolicy($db); + $assert($policy->forLoan($p, $b['id'], $b['copy'])->loanable && $policy->lendableCopySql() === '1 = 1', 'Consultation cannot be suspended'); + }); + $test('31 Photos are independent of card printing and disabled photos never enter a PDF', static function () use ($flag, $assert): void { + $flag('patron_cards', false); $flag('patron_photos', true); + $assert(Features::enabled('patron_photos') && !Features::enabled('patron_cards'), 'Photos require printing'); + $file = tempnam(sys_get_temp_dir(), 'pinakes-photo-'); + try { + $im = imagecreatetruecolor(12, 12); imagejpeg($im, $file); imagedestroy($im); + $card = ['name' => 'Photo test', 'code' => 'ZZPHOTO', 'expiry' => '', 'photo' => $file]; + $settings = PatronCardSettings::defaults(); + $visible = (new \App\Support\PatronCardPdf($settings, 'Library', null))->render([$card], 'single'); + $flag('patron_photos', false); $flag('patron_cards', true); + $hidden = (new \App\Support\PatronCardPdf($settings, 'Library', null))->render([$card], 'single'); + $assert(str_contains($visible, '/Subtype /Image') && !str_contains($hidden, '/Subtype /Image'), 'Photo switch does not control PDF embedding'); + } finally { @unlink($file); } + }); + $test('32 Retention can continue while collection is off, and zero days always prevents deletion', static function () use ($db, $flag, $set, $assert): void { + $db->query("INSERT INTO presenze (tipo,nome_ospite,created_at) VALUES ('ospite','ZZ old visit','2020-01-01 10:00:00')"); + $id = (int) $db->insert_id; $flag('visitor_log', false); $flag('attendance_retention', false); $set('attendance', 'retention_days', '30'); + $maintenance = new \App\Support\MaintenanceService($db); + $assert($maintenance->purgeAttendance() === 0, 'Suspended collection purges without consent'); + $flag('attendance_retention', true); $set('attendance', 'retention_days', '0'); + $assert($maintenance->purgeAttendance() === 0, 'Zero retention unexpectedly deletes'); + $set('attendance', 'retention_days', '30'); + $assert($maintenance->purgeAttendance() === 1, 'Retention cannot run with collection off'); + $assert((int) $db->query("SELECT COUNT(*) FROM presenze WHERE id={$id}")->fetch_row()[0] === 0, 'Expired attendance survived purge'); + }); + $test('33 Fine grace can be a threshold or waived days, using the same typed daily rate', static function () use ($saveLoans, $db, $type, $patron, $book, $loan, $assert): void { + $saveLoans(['daily_fine' => '1', 'fine_grace_days' => '2', 'fine_grace_mode' => 'threshold']); + $t = $type(['multa_giornaliera' => '0.50']); $p = $patron(); $db->query("UPDATE utenti SET tipologia_id={$t} WHERE id={$p}"); + $id = $loan($p, $book(), '2026-10-01', '2026-10-06'); $ledger = new PatronLedger($db); + $assert($ledger->previewLateFine($id, '2026-10-06')['cents'] === 250, 'Historical threshold changed'); + $saveLoans(['daily_fine' => '1', 'fine_grace_days' => '2', 'fine_grace_mode' => 'deduct']); + $assert($ledger->chargeLateReturn($id) === 150, 'Waived days were not deducted from the typed rate'); + }); + $test('34 Automatic card expiry can be disabled while manual renewal retains today or future-expiry choices', static function () use ($db, $type, $patron, $flag, $set, $assert): void { + $t = $type(['durata_tessera_giorni' => '30']); $p = $patron(); + $future = (new DateTimeImmutable(DateHelper::today()))->modify('+100 days')->format('Y-m-d'); + $db->query("UPDATE utenti SET tipologia_id={$t}, data_scadenza_tessera='{$future}' WHERE id={$p}"); + $flag('automatic_card_expiry', false); + $assert(\App\Support\PatronTypeAssignment::automaticCardExpiry($db, $t) === null, 'Automatic expiry ignores its switch'); + \App\Support\PatronTypeAssignment::applyCardDurationOnApproval($db, $p); + $assert($db->query("SELECT data_scadenza_tessera FROM utenti WHERE id={$p}")->fetch_row()[0] === $future, 'Approval reset an existing expiry'); + $set('loans', 'card_renewal_base', 'expiry'); + $assert(\App\Support\PatronTypeAssignment::renewCard($db, $p) === (new DateTimeImmutable($future))->modify('+30 days')->format('Y-m-d'), 'Renewal erased remaining validity'); + $set('loans', 'card_renewal_base', 'today'); + $assert(\App\Support\PatronTypeAssignment::renewCard($db, $p) === \App\Support\PatronTypeAssignment::todayPlus(30), 'Today default changed'); + }); + $test('35 Administrators can prepare disabled rules and calendars without activating runtime behaviour', static function () use ($db, $type, $set, $flag, $assert): void { + $t = $type(['durata_prestito_giorni' => '11']); $flag('circulation_rules', false); $flag('closures_calendar', false); + $data = CirculationSettingsController::viewData($db); + $assert(isset($data['types'][$t]) && $data['effective'][$t]->durationDays === 11 && (new CirculationPolicy($db))->types() === [], 'Configuration activated or hid disabled type rules'); + $set('loans', 'closed_weekdays', '0'); + $assert(!LibraryCalendar::fromDatabase($db)->hasRules() && LibraryCalendar::fromDatabase($db, configuration: true)->hasRules(), 'Draft calendar leaks into circulation'); + }); + $test('36 Old forms freeze absent granular preferences before enabling their former parent', static function () use ($request, $db, $settings, $flag, $assert): void { + $db->query("DELETE FROM system_settings WHERE category='features' AND setting_key IN ('automatic_fines','debt_block','loss_penalties','patron_photos','consultation_only','automatic_card_expiry')"); + $flag('fines', false); $flag('patron_cards', false); $flag('circulation_rules', false); + $body = ['feature_fines' => '1', 'feature_circulation_rules' => '1', 'feature_closures_calendar' => '1', 'daily_fine' => '2', 'debt_block_threshold' => '1']; + (new SettingsController())->updateLoansSettings($request('/admin/settings/loans', $body), new Response(), $db); + foreach (['automatic_fines', 'debt_block', 'loss_penalties', 'consultation_only', 'automatic_card_expiry'] as $key) { + $assert($settings->get('features', $key) === '0' && !Features::enabled($key), 'A former parent silently enabled ' . $key); + } + (new SettingsController())->updateModules($request('/admin/settings/modules', ['feature_patron_cards' => '1']), new Response(), $db); + $assert(Features::enabled('patron_cards') && !Features::enabled('patron_photos'), 'Printing unexpectedly resumed photo collection'); + }); + + $test('37 Repeated upgrades never enable fines or convert penalties when the old ledger was off', static function () use ($root, $db, $flag, $set, $type, $patron, $book, $loan, $assert): void { + $db->query("DELETE FROM system_settings WHERE category='features' AND setting_key IN ('automatic_fines','debt_block','loss_penalties','patron_photos','consultation_only','automatic_card_expiry')"); + $flag('fines', false); $flag('patron_cards', false); $flag('circulation_rules', false); + $set('loans', 'daily_fine', '2.00'); $set('loans', 'debt_block_threshold', '1.00'); + $t = $type(['multa_giornaliera' => '3.00', 'soglia_blocco_debito' => '0.50']); $p = $patron(); + $db->query("UPDATE utenti SET tipologia_id={$t} WHERE id={$p}"); + $id = $loan($p, $book(), '2026-10-01', '2026-10-06'); + $db->query("UPDATE prestiti SET sanzione=12.50 WHERE id={$id}"); + $sql = (string) file_get_contents($root . '/installer/database/migrations/migrate_0.9.0.sql'); + $sql = implode("\n", array_filter(explode("\n", $sql), static fn (string $line): bool => !str_starts_with(trim($line), '--'))); + for ($pass = 0; $pass < 2; $pass++) { + foreach (explode(';', $sql) as $statement) { + if (trim($statement) === '') { continue; } + $result = $db->query($statement); if ($result instanceof mysqli_result) { $result->free(); } + } + ConfigStore::clearCache(); PatronLedger::resetSchemaCache(); + foreach (['fines','automatic_fines','debt_block','loss_penalties','patron_photos','consultation_only','automatic_card_expiry'] as $key) { + $assert(!Features::stored($key), 'Upgrade silently enabled ' . $key); + } + $ledger = new PatronLedger($db); + $assert($ledger->chargeLateReturn($id) === 0 && $ledger->balanceCents($p) === 0, 'Upgrade created an unplanned financial charge'); + } + $flag('fines', true); + $assert(!Features::enabled('automatic_fines') && !Features::enabled('debt_block') && !Features::enabled('loss_penalties'), 'Enabling the ledger implicitly enabled its automations'); + }); + $test('26 Repeated upgrades preserve saved settings and effective behaviour in every switch state', static function () use ($root, $db, $admin, $sandbox, $settings, $set, $flag, $book, $patron, $assert): void { $p = $patron(); $b = $book(); $db->query("UPDATE libri SET rda_content_type='prm' WHERE id={$b['id']}"); @@ -469,7 +596,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface { thr } }); - $assert(count($cases) === 26, 'This suite must register exactly 26 scenarios'); + $assert(count($cases) === 37, 'This suite must register exactly 37 scenarios'); foreach ($cases as [$name, $body]) { try { $db->rollback(); @@ -505,5 +632,5 @@ public function handle(ServerRequestInterface $request): ResponseInterface { thr if ($db instanceof mysqli) { $db->rollback(); $db->close(); } if ($admin instanceof mysqli) { $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); $admin->close(); } } -echo "Settings flexibility: {$passed}/26 passed, {$failed} failed\n"; -exit($passed === 26 && $failed === 0 ? 0 : 1); +echo "Settings flexibility: {$passed}/37 passed, {$failed} failed\n"; +exit($passed === 37 && $failed === 0 ? 0 : 1); From d9f36316b1ef39f409b5c3eff69e4c5b49613147 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 10:49:36 +0100 Subject: [PATCH 211/398] Respect the debt switch in web and mobile balance indicators --- app/Support/PatronTypeAssignment.php | 4 +++- app/Views/utenti/_movimenti.php | 2 +- .../pr-512-settings-flexibility-2026-10-29.md | 3 ++- .../src/Controllers/ActionsController.php | 2 +- tests/stack-090-settings-flexibility.unit.php | 19 ++++++++++++++++++- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/Support/PatronTypeAssignment.php b/app/Support/PatronTypeAssignment.php index 1daa5e11d..51bc1fcec 100644 --- a/app/Support/PatronTypeAssignment.php +++ b/app/Support/PatronTypeAssignment.php @@ -185,7 +185,9 @@ public static function renewalDate(int $days, ?string $currentExpiry, string $ba && DateHelper::isISODateFormat($currentExpiry) && $currentExpiry > $base) { $base = $currentExpiry; } - return (new \DateTimeImmutable($base))->modify('+' . max(0, $days) . ' days')->format('Y-m-d'); + $expiry = (new \DateTimeImmutable($base))->modify('+' . max(0, $days) . ' days'); + // MySQL DATE ends at 9999-12-31; lifetime cards must remain renewable. + return (int) $expiry->format('Y') > 9999 ? '9999-12-31' : $expiry->format('Y-m-d'); } /** diff --git a/app/Views/utenti/_movimenti.php b/app/Views/utenti/_movimenti.php index 6a49c1cc5..e4485bdb6 100644 --- a/app/Views/utenti/_movimenti.php +++ b/app/Views/utenti/_movimenti.php @@ -21,7 +21,7 @@ $ledgerE = static fn ($v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); $ledgerFmt = static fn (int $cents): string => FineParameters::format($cents); $ledgerAccruedTotal = array_sum(array_map(static fn (array $a): int => $a['cents'], $ledgerAccrued)); -$ledgerBlocked = $ledgerParams->debtBlockEnabled() && $ledgerBalanceCents > (int) $ledgerParams->debtBlockThresholdCents; +$ledgerBlocked = \App\Support\Features::enabled('debt_block') && $ledgerParams->debtBlockEnabled() && $ledgerBalanceCents > (int) $ledgerParams->debtBlockThresholdCents; $ledgerFlash = is_scalar($_GET['ledger'] ?? null) ? (string) $_GET['ledger'] : ''; $ledgerError = is_scalar($_GET['ledger_error'] ?? null) ? (string) $_GET['ledger_error'] : ''; $ledgerMovementId = (int) (is_scalar($_GET['movement'] ?? null) ? $_GET['movement'] : 0); diff --git a/docs/reviews/pr-512-settings-flexibility-2026-10-29.md b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md index 88b9cc33c..9aff259d3 100644 --- a/docs/reviews/pr-512-settings-flexibility-2026-10-29.md +++ b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md @@ -18,7 +18,7 @@ Aggiornamento del 29 ottobre 2026 sulla catena #503–#512, con #502 esclusa dal | Prestiti → Calendario | Calendario chiusure | Sospende l’applicazione del calendario senza cancellarlo. Gli amministratori possono preparare chiusure e festività e vederne l’anteprima anche a modulo spento. Anno delle festività selezionabile dal 1900 al 2199. | | Prestiti → Multe | Registro movimenti | Addebiti manuali, pagamenti, ricevute, condoni e saldo. Può funzionare con tutti e tre gli automatismi successivi spenti. | | Prestiti → Multe | Multe automatiche per ritardo | Richiede registro attivo, interruttore dedicato e tariffa positiva. Le tariffe per tipologia e materiale non aggirano il controllo. | -| Prestiti → Multe | Blocco per debito | Richiede registro attivo, interruttore dedicato e soglia positiva. Funziona senza nuove multe automatiche. | +| Prestiti → Multe | Blocco per debito | Richiede registro attivo, interruttore dedicato e soglia positiva. Funziona senza nuove multe automatiche. Scheda web e API mobile mostrano il blocco soltanto quando è effettivamente attivo. | | Prestiti → Multe | Riporto penalità per perdita e danno | Riporta soltanto importi già assegnati, compresi quelli precedenti. Il form mostra quantità e totale degli importi da riportare. La riattivazione riapre la scansione idempotente. | | Prestiti → Multe | Modalità di tolleranza | Soglia, come prima: oltre la tolleranza si conta dal primo giorno. Oppure abbuono: si sottraggono sempre i giorni di tolleranza. | @@ -50,6 +50,7 @@ Gli interruttori e le nuove regole di servizio sono riservati agli amministrator - Gate delle migrazioni, schema e auto-riparazione dei plugin: superato. - Registro dei 18 controlli, distribuzione nei form, protezioni delle rotte, seed delle cinque lingue e traduzioni: verificati. - **25 test browser superati**: salvataggio dei controlli, tastiera, permessi, configurazione a modulo spento, calendario, foto e consultazione indipendenti. +- Superati inoltre **40 test browser** su multe, tariffe per tipologia/materiale e foto, e **22 test** su tessere e regole lettore, compresa la corrispondenza tra data di conferma e rinnovo dalla scadenza futura. - Controllo visivo nel browser a 1280 px e 390 px: nessun overflow orizzontale nei nuovi gruppi di impostazioni. ## Workflow già verificati prima di questa estensione diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index 8bc3c94f3..5f3ff37e4 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -182,7 +182,7 @@ public function myMovements(Request $request, ResponseInterface $response): Resp 'days_late' => $a['days'], 'amount_cents' => $a['cents'], ], $ledger->accruedFines($userId)); - $threshold = $params->debtBlockThresholdCents; + $threshold = \App\Support\Features::enabled('debt_block') ? $params->debtBlockThresholdCents : null; return ResponseEnvelope::success($response, [ 'currency' => $params->currency, diff --git a/tests/stack-090-settings-flexibility.unit.php b/tests/stack-090-settings-flexibility.unit.php index 4b96af427..f41045193 100644 --- a/tests/stack-090-settings-flexibility.unit.php +++ b/tests/stack-090-settings-flexibility.unit.php @@ -355,7 +355,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface { thr }); - $test('27 Ledger-only mode ignores positive global and typed fine rates and debt thresholds', static function () use ($saveLoans, $db, $patron, $book, $loan, $type, $assert): void { + $test('27 Ledger-only mode ignores positive global and typed fine rates and debt thresholds', static function () use ($root, $saveLoans, $db, $patron, $book, $loan, $type, $flag, $request, $assert): void { $saveLoans(['daily_fine' => '2', 'debt_block_threshold' => '1', 'feature_automatic_fines' => '0', 'feature_debt_block' => '0']); $t = $type(['multa_giornaliera' => '3.00', 'soglia_blocco_debito' => '0.50']); $p = $patron(); $db->query("UPDATE utenti SET tipologia_id={$t} WHERE id={$p}"); @@ -364,6 +364,20 @@ public function handle(ServerRequestInterface $request): ResponseInterface { thr $assert($ledger->chargeLateReturn($id) === 0 && !$ledger->previewLateFine($id)['enabled'], 'Disabled automatic fines still charge or preview'); $assert(!PatronLedger::isDebtBlocked($db, $p) && \App\Support\LoanEligibility::debtBlockSql('u', $db) === '1 = 1', 'Debt still blocks a channel'); $assert($ledger->balanceCents($p) === 1000 && Features::enabled('fines'), 'Manual ledger no longer works'); + require_once $root . '/storage/plugins/mobile-api/MobileApiPlugin.php'; + $mobileRequest = (new ServerRequestFactory())->createServerRequest('GET', '/api/v1/me/movements')->withAttribute('mobile_user', ['id' => $p]); + $mobile = new \App\Plugins\MobileApi\Controllers\ActionsController($db); + $read = static function () use ($mobile, $mobileRequest): array { + $response = $mobile->myMovements($mobileRequest, new Response()); + return (array) (json_decode((string) $response->getBody(), true)['data'] ?? []); + }; + $api = $read(); + $assert(($api['debt_blocked'] ?? null) === false && array_key_exists('debt_block_threshold_cents', $api) && $api['debt_block_threshold_cents'] === null, 'Mobile API reports disabled debt blocking'); + $page = (new \App\Controllers\UsersController())->details($request('/admin/users/details/' . $p), new Response(), $db, $p); + $assert($page->getStatusCode() === 200 && str_contains((string) $page->getBody(), 'data-ledger-balance="1000"') && !str_contains((string) $page->getBody(), __('Prestiti bloccati: il saldo supera la soglia di %s.', FineParameters::format(50))), 'Reader record reports disabled debt blocking'); + $flag('debt_block', true); $api = $read(); + $assert(($api['debt_blocked'] ?? null) === true && $api['debt_block_threshold_cents'] === 50, 'Mobile API does not restore the saved typed threshold'); + }); $test('28 Fine and debt switches work independently with positive retained amounts', static function () use ($saveLoans, $db, $patron, $book, $loan, $assert): void { $saveLoans(['daily_fine' => '0.50', 'debt_block_threshold' => '1', 'feature_automatic_fines' => '0', 'feature_debt_block' => '1']); @@ -434,6 +448,9 @@ public function handle(ServerRequestInterface $request): ResponseInterface { thr $assert(\App\Support\PatronTypeAssignment::renewCard($db, $p) === (new DateTimeImmutable($future))->modify('+30 days')->format('Y-m-d'), 'Renewal erased remaining validity'); $set('loans', 'card_renewal_base', 'today'); $assert(\App\Support\PatronTypeAssignment::renewCard($db, $p) === \App\Support\PatronTypeAssignment::todayPlus(30), 'Today default changed'); + $set('loans', 'card_renewal_base', 'expiry'); + $db->query("UPDATE utenti SET data_scadenza_tessera='9999-12-31' WHERE id={$p}"); + $assert(\App\Support\PatronTypeAssignment::renewCard($db, $p) === '9999-12-31', 'A lifetime card overflowed the database date range'); }); $test('35 Administrators can prepare disabled rules and calendars without activating runtime behaviour', static function () use ($db, $type, $set, $flag, $assert): void { $t = $type(['durata_prestito_giorni' => '11']); $flag('circulation_rules', false); $flag('closures_calendar', false); From a34d8592a350d476e6f34a5eef2507ee31315b5c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 11:27:49 +0100 Subject: [PATCH 212/398] Document granular API capabilities and verified holiday effective date --- docs/reviews/pr-512-settings-flexibility-2026-10-29.md | 4 ++++ .../mobile-api/src/Controllers/OpenApiController.php | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/reviews/pr-512-settings-flexibility-2026-10-29.md b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md index 9aff259d3..410d22d33 100644 --- a/docs/reviews/pr-512-settings-flexibility-2026-10-29.md +++ b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md @@ -53,6 +53,10 @@ Gli interruttori e le nuove regole di servizio sono riservati agli amministrator - Superati inoltre **40 test browser** su multe, tariffe per tipologia/materiale e foto, e **22 test** su tessere e regole lettore, compresa la corrispondenza tra data di conferma e rinnovo dalla scadenza futura. - Controllo visivo nel browser a 1280 px e 390 px: nessun overflow orizzontale nei nuovi gruppi di impostazioni. +La precedente descrizione della PR lasciava aperta la decorrenza di San Francesco: la regola del 4 ottobre dal 2026 è confermata dall’entrata in vigore del 1° gennaio 2026 nella [legge 151/2025, Gazzetta Ufficiale](https://www.gazzettaufficiale.it/eli/id/2025/10/10/25G00153/sg). I test distinguono già il 2025 dal 2026. + +La documentazione OpenAPI descrive anche i tre nuovi flag di capacità (`automatic_fines`, `debt_block`, `consultation_only`) e chiarisce che la soglia di debito effettiva è nulla quando il blocco è spento. + ## Workflow già verificati prima di questa estensione Gli esiti seguenti riguardano esplicitamente i commit indicati; non vengono attribuiti alle modifiche successive. diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 47901d87d..99292b0c4 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -710,7 +710,7 @@ private function paths(): array 'loan_id' => ['type' => 'integer'], 'title' => ['type' => 'string'], 'due_at' => ['type' => 'string', 'format' => 'date'], 'days_late' => ['type' => 'integer'], 'amount_cents' => ['type' => 'integer'], ]]], - 'debt_block_threshold_cents' => ['type' => ['integer', 'null']], + 'debt_block_threshold_cents' => ['type' => ['integer', 'null'], 'description' => 'Effective debt threshold; null when debt blocking is switched off or no positive threshold applies.'], 'debt_blocked' => ['type' => 'boolean'], 'items' => ['type' => 'array', 'items' => ['type' => 'object', 'properties' => [ 'id' => ['type' => 'integer'], @@ -1678,7 +1678,10 @@ private function healthPayloadSchema(): array 'messages' => ['type' => 'boolean'], 'notifications' => ['type' => 'boolean'], 'push' => ['type' => 'boolean'], - 'fines' => ['type' => 'boolean', 'description' => 'Since 1.9.0: fines and the patron ledger are on (GET /me/movements answers). Absent on older servers: treat as false.'], + 'fines' => ['type' => 'boolean', 'description' => 'Since 1.9.0: the patron ledger is available (GET /me/movements answers). Automatic fines and debt blocking have independent switches. Absent on older servers: treat as false.'], + 'automatic_fines' => ['type' => 'boolean', 'description' => 'The ledger and automatic overdue fines are enabled. A positive resolved daily rate is also required.'], + 'debt_block' => ['type' => 'boolean', 'description' => 'The ledger and debt blocking are enabled. The effective threshold and blocked state are returned by GET /me/movements.'], + 'consultation_only' => ['type' => 'boolean', 'description' => 'Reference-only copy restrictions apply independently of patron-type rules.'], 'closures_calendar' => ['type' => 'boolean', 'description' => 'Since 1.9.0: the closures calendar is applied (availability closed_dates may be non-empty).'], 'circulation_rules' => ['type' => 'boolean', 'description' => 'Since 1.9.0: patron types and the rules matrix apply (loanable / reservations_enabled may be false).'], ], From 103da8657eef9e5f079ea44fb58171a071cc94fc Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 12:01:30 +0100 Subject: [PATCH 213/398] Record successful CI results for granular settings controls --- .../pr-512-settings-flexibility-2026-10-29.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/reviews/pr-512-settings-flexibility-2026-10-29.md b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md index 410d22d33..9e458ede6 100644 --- a/docs/reviews/pr-512-settings-flexibility-2026-10-29.md +++ b/docs/reviews/pr-512-settings-flexibility-2026-10-29.md @@ -57,6 +57,25 @@ La precedente descrizione della PR lasciava aperta la decorrenza di San Francesc La documentazione OpenAPI descrive anche i tre nuovi flag di capacità (`automatic_fines`, `debt_block`, `consultation_only`) e chiarisce che la soglia di debito effettiva è nulla quando il blocco è spento. +## CI sulle modifiche agli interruttori: tutti i workflow superati + +Commit funzionale verificato: `d9f36316b1ef39f409b5c3eff69e4c5b49613147`. Tutti i dieci run seguenti sono terminati con successo. I commit successivi aggiornano la relazione e la descrizione OpenAPI dei flag; il documento API completo è stato verificato come JSON valido. + +| Workflow | Esito | Run | +| --- | --- | --- | +| Code Quality | Superato | [40852924644](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852924644) | +| Database Compatibility | Superato | [40852926691](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852926691) | +| E2E Test Suite | Superato | [40852928999](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852928999) | +| Test Database Migrations | Superato | [40852931140](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852931140) | +| Real Upgrade (admin UI) | Superato | [40852933844](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852933844) | +| Upgrade Smoke Test | Superato | [40852936293](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852936293) | +| Deep Regression Gate | Superato | [40852939135](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852939135) | +| Browser Compatibility and DAST | Superato | [40852941339](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852941339) | +| Security and Supply Chain | Superato | [40852943720](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852943720) | +| CodeQL Security Analysis | Superato | [40852947181](https://github.com/fabiodalez-dev/Pinakes/actions/runs/40852947181) | + +La matrice database ha superato tutte le 325 suite su MySQL 8.0/8.4 e MariaDB 10.11/11.4. Gli E2E della 0.9.0 hanno superato **230 test di circolazione** e **253 di catalogazione**, oltre alla batteria generale dell’applicazione. La regressione approfondita ha superato **4/4 gruppi browser** e **5/5 installazioni localizzate**. + ## Workflow già verificati prima di questa estensione Gli esiti seguenti riguardano esplicitamente i commit indicati; non vengono attribuiti alle modifiche successive. From 94c3237072d6449bf3b10bd133c5081017e7c1f8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 12:05:00 +0100 Subject: [PATCH 214/398] Correct Danish permissions message spelling --- locale/da_DK.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locale/da_DK.json b/locale/da_DK.json index 8e2b555a5..b66f1622a 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -318,7 +318,7 @@ "Accesso Richiesto": "Log ind påkrævet", "Accesso in corso...": "Logger ind...", "Accesso negato.": "Adgang nægtet.", - "Accesso negato. Permessi insufficienti.": "Adgang nægtet. Utilstækkelige tilladelser.", + "Accesso negato. Permessi insufficienti.": "Adgang nægtet. Utilstrækkelige tilladelser.", "Accesso non autorizzato": "Uautoriseret adgang", "Accetta selezionati": "Acceptér valgte", "Accetta tutti": "Acceptér alle", From 3d21a410006a566e3f2b52baf81560d4431899ce Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 29 Oct 2026 13:06:21 +0100 Subject: [PATCH 215/398] Holidays: a day in lieu closes the holiday itself too; Australian state rules When a weekend holiday gets a day in lieu (us, next_monday, pair), the library is now closed on both days: Christmas on a Saturday closes the Saturday and the substitute Monday. Holidays that are moved rather than substituted (Koningsdag, the Argentine movable holidays) keep only the new date. The admin preview names the extra day. Easter Sunday is a public holiday in Western Australia from 2022, in the Northern Territory from 2022 and in South Australia from 2024; in Western Australia an Anzac Day on a weekend adds the following Monday. Before the first selection, a former Italian closure the library had suspended starts unticked, so applying the defaults does not switch it back on. The preview year is a number field (1900-2199) instead of a 300-item list. --- .../Admin/LibraryCalendarController.php | 12 ++++--- app/Support/Holidays/HolidayCalculator.php | 32 +++++++++++++++++- app/Support/Holidays/HolidayPresets.php | 33 +++++++++++++++++-- app/Support/Holidays/HolidayRules.php | 8 +++++ app/Support/LibraryCalendar.php | 7 ++-- app/Views/admin/calendar.php | 11 +++---- tests/holiday-calculator.unit.php | 21 ++++++++++++ tests/holiday-presets-apply.unit.php | 10 +++--- tests/holidays-preset.spec.js | 24 ++++++++++---- tests/library-calendar.unit.php | 3 +- 10 files changed, 134 insertions(+), 27 deletions(-) diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php index 1cfddf081..7b9c14339 100644 --- a/app/Controllers/Admin/LibraryCalendarController.php +++ b/app/Controllers/Admin/LibraryCalendarController.php @@ -505,7 +505,7 @@ private function backToPreset(Response $response, string $country, string $regio * statutory holidays (optional ones stay unticked). * * @param array $query - * @return array{countries: array, country: string, regions: array, region: string, years: list, year: int, list: list, saved: bool, schema: bool} + * @return array{countries: array, country: string, regions: array, region: string, years: array{min: int, max: int}, year: int, list: list, yearly: bool, regional: bool, optional: bool, checked: bool}>, saved: bool, schema: bool} */ private function holidayPreview(\mysqli $db, array $query, int $currentYear): array { @@ -518,13 +518,17 @@ private function holidayPreview(\mysqli $db, array $query, int $currentYear): ar ? $query['holiday_region'] : ($country === $selection['country'] ? $selection['region'] : ''); $region = HolidayCalculator::normaliseRegion($country, $rawRegion); - $years = range(1900, 2199); - $year = is_scalar($query['holiday_year'] ?? null) && in_array((int) $query['holiday_year'], $years, true) ? (int) $query['holiday_year'] : $currentYear; + $years = ['min' => 1900, 'max' => 2199]; + $requestedYear = is_scalar($query['holiday_year'] ?? null) ? filter_var($query['holiday_year'], FILTER_VALIDATE_INT) : false; + $year = $requestedYear !== false && $requestedYear >= $years['min'] && $requestedYear <= $years['max'] ? $requestedYear : $currentYear; $savedHere = $selection['saved'] && $selection['country'] === $country && $selection['region'] === $region; $ticked = array_flip($selection['rules']); + // Before the first selection, a former Italian closure the library + // suspended starts unticked: applying the defaults keeps it off. + $suspended = !$selection['saved'] && $country === 'IT' ? array_flip(HolidayPresets::suspendedLegacyRules($db)) : []; $list = []; foreach (HolidayCalculator::forYear($country, $region, $year) as $holiday) { - $holiday['checked'] = $savedHere ? isset($ticked[$holiday['id']]) : !$holiday['optional']; + $holiday['checked'] = $savedHere ? isset($ticked[$holiday['id']]) : !$holiday['optional'] && !isset($suspended[$holiday['id']]); $list[] = $holiday; } return [ diff --git a/app/Support/Holidays/HolidayCalculator.php b/app/Support/Holidays/HolidayCalculator.php index 21a5f7a53..9d52b172a 100644 --- a/app/Support/Holidays/HolidayCalculator.php +++ b/app/Support/Holidays/HolidayCalculator.php @@ -84,7 +84,7 @@ public static function rulesFor(string $country, string $region = ''): array * Each applicable rule with its date in $year (null when the holiday * does not exist that year). * - * @return list + * @return list, yearly: bool, regional: bool, optional: bool}> */ public static function forYear(string $country, string $region, int $year): array { @@ -95,6 +95,7 @@ public static function forYear(string $country, string $region, int $year): arra 'id' => $country . ':' . (string) $rule['id'], 'name' => (string) $rule['name'], 'date' => self::dateOf($rule, $year), + 'dates' => self::datesOf($rule, $year), 'yearly' => self::isYearly($rule), 'regional' => isset($rule['regions']), 'optional' => !empty($rule['optional']), @@ -150,6 +151,35 @@ public static function dateOf(array $rule, int $year): ?string return is_string($observed) && $observed !== '' ? self::observe($date, $observed) : $date; } + /** Weekend rules that add a day in lieu: the holiday itself keeps its date. */ + private const IN_LIEU_MODES = ['us', 'next_monday', 'pair']; + + /** + * Every closed day of one rule in $year: the observed date, plus the + * actual date when a weekend rule adds a day in lieu (us, next_monday, + * pair) instead of moving the holiday. Koningsdag (sunday_back) and the + * Argentine movable holidays (ar_movable) move: only the new date is a + * holiday. A yearly override is the one date of that year. + * + * @param array $rule + * @return list + */ + public static function datesOf(array $rule, int $year): array + { + $observed = self::dateOf($rule, $year); + if ($observed === null) { + return []; + } + $mode = $rule['observed'] ?? null; + $overrides = $rule['overrides'] ?? []; + if (!is_string($mode) || !in_array($mode, self::IN_LIEU_MODES, true) + || (is_array($overrides) && isset($overrides[$year])) || !is_array($rule['rule'] ?? null)) { + return [$observed]; + } + $actual = self::evaluate($rule['rule'], $year); + return $actual !== null && $actual !== $observed ? [$actual, $observed] : [$observed]; + } + /** * @param array $spec */ diff --git a/app/Support/Holidays/HolidayPresets.php b/app/Support/Holidays/HolidayPresets.php index 5144cace4..54f0c1146 100644 --- a/app/Support/Holidays/HolidayPresets.php +++ b/app/Support/Holidays/HolidayPresets.php @@ -194,8 +194,8 @@ public static function expectedRows(string $country, string $region, array $ids, } } else { for ($year = $firstYear; $year <= $firstYear + self::YEARS_AHEAD; $year++) { - $date = HolidayCalculator::dateOf($rule, $year); - if ($date !== null) { + // A day in lieu closes both days: the holiday and its substitute. + foreach (HolidayCalculator::datesOf($rule, $year) as $date) { $rows[] = ['start' => $date, 'end' => $date, 'description' => (string) $rule['name'], 'yearly' => false]; } } @@ -409,6 +409,35 @@ private static function ensureRow(\mysqli $db, string $ruleId, array $row): ?str return 'added'; } + /** + * Former Italian preset rules the librarian suspended: every legacy row + * of the rule is switched off (attiva = 0). Before the first selection + * they start unticked, so applying the defaults does not bring them back. + * + * @return list rule ids + */ + public static function suspendedLegacyRules(\mysqli $db): array + { + if (!self::schemaReady($db)) { + return []; + } + $out = []; + foreach (array_keys(self::LEGACY_IT_LABELS) as $ruleId) { + $labels = self::legacyLabels($ruleId); + $placeholders = implode(',', array_fill(0, count($labels), '?')); + $stmt = $db->prepare("SELECT COUNT(*), COALESCE(SUM(attiva), 0) FROM calendario_chiusure + WHERE regola_festivita IS NULL AND sede_id IS NULL AND descrizione IN ({$placeholders})"); + $stmt->bind_param(str_repeat('s', count($labels)), ...$labels); + $stmt->execute(); + [$rows, $active] = $stmt->get_result()->fetch_row() ?? [0, 0]; + $stmt->close(); + if ((int) $rows > 0 && (int) $active === 0) { + $out[] = $ruleId; + } + } + return $out; + } + /** * The descriptions the former Italian preset could have stored for a * rule: the Italian label and its translation in every locale file. diff --git a/app/Support/Holidays/HolidayRules.php b/app/Support/Holidays/HolidayRules.php index 3feac0354..669589ba5 100644 --- a/app/Support/Holidays/HolidayRules.php +++ b/app/Support/Holidays/HolidayRules.php @@ -433,8 +433,16 @@ public static function countries(): array ['id' => 'good_friday', 'name' => 'Good Friday', 'rule' => ['easter', -2]], ['id' => 'easter_saturday', 'name' => 'Easter Saturday', 'rule' => ['easter', -1], 'regions' => ['ACT', 'NSW', 'NT', 'QLD', 'SA', 'VIC']], ['id' => 'easter_sunday', 'name' => 'Easter Sunday', 'rule' => ['easter', 0], 'regions' => ['ACT', 'NSW', 'QLD', 'VIC']], + // Public and Bank Holidays Act 1972 as amended in 2021: from Easter 2022. + ['id' => 'easter_sunday_wa', 'name' => 'Easter Sunday', 'rule' => ['easter', 0], 'regions' => ['WA'], 'from' => 2022], + // Gazetted every year since 2022. + ['id' => 'easter_sunday_nt', 'name' => 'Easter Sunday', 'rule' => ['easter', 0], 'regions' => ['NT'], 'from' => 2022], + // Declared by the 2023 amendment, from Easter 2024. + ['id' => 'easter_sunday_sa', 'name' => 'Easter Sunday', 'rule' => ['easter', 0], 'regions' => ['SA'], 'from' => 2024], ['id' => 'easter_monday', 'name' => 'Easter Monday', 'rule' => ['easter', 1]], ['id' => 'anzac_day', 'name' => 'Anzac Day', 'rule' => ['fixed', '04-25']], + // WA: on a Saturday or Sunday the following Monday is also a public holiday. + ['id' => 'anzac_day_monday_wa', 'name' => 'Anzac Day (additional Monday)', 'rule' => ['fixed_if', '04-25', ['sat', 'sun']], 'observed' => 'next_monday', 'regions' => ['WA']], ['id' => 'labour_day_qld', 'name' => 'Labour Day', 'rule' => ['nth_weekday', 5, 'mon', 1], 'regions' => ['QLD']], ['id' => 'may_day_nt', 'name' => 'May Day', 'rule' => ['nth_weekday', 5, 'mon', 1], 'regions' => ['NT']], ['id' => 'reconciliation_day', 'name' => 'Reconciliation Day', 'rule' => ['weekday_on_or_after', '05-27', 'mon'], 'regions' => ['ACT'], 'from' => 2018], diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index bbf89c713..69e39e1c2 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -281,9 +281,10 @@ private function isHoliday(string $ymd): bool // preceding year. Include adjacent source years before filtering. for ($sourceYear = max(1, $year - 1); $sourceYear <= min(9999, $year + 1); $sourceYear++) { foreach ($this->holidayRules as $rule) { - $date = Holidays\HolidayCalculator::dateOf($rule, $sourceYear); - if ($date !== null && (int) substr($date, 0, 4) === $year) { - $dates[$date] = true; + foreach (Holidays\HolidayCalculator::datesOf($rule, $sourceYear) as $date) { + if ((int) substr($date, 0, 4) === $year) { + $dates[$date] = true; + } } } } diff --git a/app/Views/admin/calendar.php b/app/Views/admin/calendar.php index 0f23af73b..c17bc9065 100644 --- a/app/Views/admin/calendar.php +++ b/app/Views/admin/calendar.php @@ -14,7 +14,7 @@ * @var int $currentYear * @var string $today * @var list> $yearlyClosures - * @var array{countries: array, country: string, regions: array, region: string, years: list, year: int, list: list, saved: bool, schema: bool} $holidays + * @var array{countries: array, country: string, regions: array, region: string, years: array{min: int, max: int}, year: int, list: list, yearly: bool, regional: bool, optional: bool, checked: bool}>, saved: bool, schema: bool} $holidays */ $csrf = \App\Support\Csrf::ensureToken(); @@ -207,11 +207,7 @@
      - +