From 4cc075d799402efba1767368183ea398dbe378d7 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 28 Jul 2026 18:39:25 +0200 Subject: [PATCH 1/9] fix: catalogue "on loan" filter counted books with no copies at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The availability filter derived "On loan" from copie_disponibili <= 0. A book with copie_totali = 0 (a record with no copies registered) also has copie_disponibili = 0, so it was counted — and filtered — as "on loan" even though nothing is borrowed. On one library this showed 7 "on loan" when only 2 books were actually lent (the other 5 had zero copies). - "On loan" (count + filter condition) now requires copie_totali > 0: every copy is out, as opposed to there being no copies. - "All books" is now a real COUNT(DISTINCT l.id) instead of available + borrowed — a no-copies book is neither available nor on loan, so the sum under-counted the catalogue by exactly those records. tests/catalog-borrowed-count-empty-copies.spec.js seeds one available, one truly on-loan and two no-copies books and checks the badges by delta: "On loan" rises by 1 (not 3), "All books" by 4. --- app/Controllers/FrontendController.php | 25 ++++++-- ...atalog-borrowed-count-empty-copies.spec.js | 62 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/catalog-borrowed-count-empty-copies.spec.js diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index 80cac2c4..2675d61c 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -946,7 +946,10 @@ private function buildWhereConditions(array $filters, mysqli $db): array if ($filters['disponibilita'] === 'disponibile') { $conditions[] = "l.copie_disponibili > 0"; } elseif ($filters['disponibilita'] === 'prestato') { - $conditions[] = "l.copie_disponibili <= 0"; + // "On loan" means every copy is out — NOT a book with no copies at all + // (copie_totali = 0), which also has copie_disponibili = 0 but isn't + // borrowed. Require copie_totali > 0 so empty records don't count. + $conditions[] = "l.copie_disponibili <= 0 AND l.copie_totali > 0"; } if (!empty($filters['anno_min'])) { @@ -1167,8 +1170,10 @@ private function getFilterOptions(mysqli $db, array $filters = []): array $row = $stmt->get_result()->fetch_assoc(); $availableCount = $row['cnt'] ?? 0; - // Count borrowed/unavailable books (coerente col filtro: copie_disponibili <= 0) - $queryBorrowed = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.copie_disponibili <= 0"; + // Count borrowed books: every copy out. copie_totali > 0 excludes records with + // no copies at all (copie_totali = 0 → copie_disponibili = 0), which are not + // on loan — otherwise they inflate the "on loan" filter count. + $queryBorrowed = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.copie_disponibili <= 0 AND l.copie_totali > 0"; $stmt = $db->prepare($queryBorrowed); if (!empty($paramsAvail)) { $stmt->bind_param($typesAvail, ...$paramsAvail); @@ -1177,10 +1182,22 @@ private function getFilterOptions(mysqli $db, array $filters = []): array $row = $stmt->get_result()->fetch_assoc(); $borrowedCount = $row['cnt'] ?? 0; + // Real total: all catalogue books matching the other filters. Since a book + // with no copies is neither available nor borrowed, available + borrowed + // would under-count it — count the total directly instead. + $queryTotal = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery; + $stmt = $db->prepare($queryTotal); + if (!empty($paramsAvail)) { + $stmt->bind_param($typesAvail, ...$paramsAvail); + } + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $totalCount = $row['cnt'] ?? 0; + $options['availability_stats'] = [ 'available' => $availableCount, 'borrowed' => $borrowedCount, - 'total' => $availableCount + $borrowedCount + 'total' => $totalCount ]; // Shared LEFT JOIN block so the remove-self WHERE conditions (which reference diff --git a/tests/catalog-borrowed-count-empty-copies.spec.js b/tests/catalog-borrowed-count-empty-copies.spec.js new file mode 100644 index 00000000..99fc50da --- /dev/null +++ b/tests/catalog-borrowed-count-empty-copies.spec.js @@ -0,0 +1,62 @@ +// @ts-check +// The catalogue availability filter derived "On loan" from copie_disponibili <= 0, +// which also matches books with NO copies at all (copie_totali = 0 → copie_disponibili +// = 0), so records with zero copies inflated the "On loan" count. The fix requires +// copie_totali > 0 for "On loan", and counts "All books" directly (a no-copies book +// is neither available nor on loan, so available + borrowed would under-count it). +// +// This seeds books of each kind and checks the filter badges by DELTA: adding one +// on-loan book and two no-copies books raises "On loan" by 1 (not 3) and "All" by 3. +// +// Run: /tmp/run-e2e.sh tests/catalog-borrowed-count-empty-copies.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 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 || ''; + +test.skip(!DB_USER || !DB_NAME, 'DB credentials not configured (this spec seeds the catalogue)'); + +function db(sql) { + const args = []; + 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(); +} +function sqlq(s) { return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; } + +const TAG = 'ZZBORROW298'; +async function counts(page) { + await page.goto(`${BASE}/catalogo`); + await page.waitForSelector('#borrowed-books-count', { timeout: 15000 }); + const read = async (id) => { + const txt = (await page.locator('#' + id).first().textContent()) || '0'; + return parseInt(txt.replace(/[^0-9]/g, ''), 10) || 0; + }; + return { all: await read('total-books-count'), available: await read('available-books-count'), borrowed: await read('borrowed-books-count') }; +} + +test.afterAll(() => { db(`DELETE FROM libri WHERE titolo LIKE ${sqlq(TAG + '%')}`); }); + +test('#298-borrowed: no-copies books are not counted as "on loan"', async ({ page }) => { + db(`DELETE FROM libri WHERE titolo LIKE ${sqlq(TAG + '%')}`); + const before = await counts(page); + + // 1 available, 1 truly on loan (has a copy, none available), 2 with no copies at all + db(`INSERT INTO libri (titolo, stato, copie_totali, copie_disponibili) VALUES (${sqlq(TAG + ' available')}, 'disponibile', 1, 1)`); + db(`INSERT INTO libri (titolo, stato, copie_totali, copie_disponibili) VALUES (${sqlq(TAG + ' on loan')}, 'prestato', 1, 0)`); + db(`INSERT INTO libri (titolo, stato, copie_totali, copie_disponibili) VALUES (${sqlq(TAG + ' no copies 1')},'disponibile', 0, 0)`); + db(`INSERT INTO libri (titolo, stato, copie_totali, copie_disponibili) VALUES (${sqlq(TAG + ' no copies 2')},'disponibile', 0, 0)`); + + const after = await counts(page); + + // "On loan" rises by exactly 1 — the two no-copies books must NOT count. + expect(after.borrowed - before.borrowed, '"On loan" counts only the real loan, not the no-copies records').toBe(1); + // "Available" rises by 1. + expect(after.available - before.available, '"Available" counts the one with a free copy').toBe(1); + // "All books" rises by 4 (all of them, including the two no-copies records). + expect(after.all - before.all, '"All books" counts every record, including no-copies ones').toBe(4); +}); From 33f45c1282c36af1d327e82df9fe512d1a15c299 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 28 Jul 2026 19:21:16 +0200 Subject: [PATCH 2/9] test(#303): fix the delta stated in the header comment (All rises by 4, not 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit: four records are seeded (available + on-loan + two no-copies), so "All books" rises by 4 — the assertion was already correct, only the comment said 3. --- tests/catalog-borrowed-count-empty-copies.spec.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/catalog-borrowed-count-empty-copies.spec.js b/tests/catalog-borrowed-count-empty-copies.spec.js index 99fc50da..b591ff56 100644 --- a/tests/catalog-borrowed-count-empty-copies.spec.js +++ b/tests/catalog-borrowed-count-empty-copies.spec.js @@ -6,7 +6,8 @@ // is neither available nor on loan, so available + borrowed would under-count it). // // This seeds books of each kind and checks the filter badges by DELTA: adding one -// on-loan book and two no-copies books raises "On loan" by 1 (not 3) and "All" by 3. +// available, one on-loan and two no-copies books raises "On loan" by 1 (not 3), +// "Available" by 1 and "All books" by 4. // // Run: /tmp/run-e2e.sh tests/catalog-borrowed-count-empty-copies.spec.js --config=tests/playwright.config.js --workers=1 const { test, expect } = require('@playwright/test'); From 21ad8256ab98227a6a7c447bf22955442ce63cd6 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 28 Jul 2026 20:46:15 +0200 Subject: [PATCH 3/9] feat: add "Reserved" catalogue availability facet; derive i18n stats from source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The availability filter derived "on loan" from copie_disponibili <= 0, which also matched reserved books (held by a pending request or slot reservation) and records with no copies. The filter and counts now mirror the recomputed libri.stato so the filter, the card badge and the book page agree by construction: - Available → copie_disponibili > 0 - Reserved → l.stato = 'prenotato' (new facet + count + card badge) - On loan → l.stato = 'prestato' (a copy actually checked out) The catalogue card gains a distinct "Not available" badge for books whose copies are all out of circulation (l.stato = 'non_disponibile'), instead of mislabelling them "on loan". i18n completion stats are now derived live from the Italian source (Language::getAllWithDerivedStats): every __() key IS the Italian string, so the source key count is the canonical denominator for every locale. total_keys can no longer drift and needs no migration when keys are added — the historical da_DK migration tests now assert their shipped literal instead of the live file count. Also clarifies the "All books" facet description (was "Available and on loan"). Tests: 12 E2E (catalog-reserved-category) + 17 unit (language-derived-stats). --- app/Controllers/Admin/LanguagesController.php | 4 +- app/Controllers/FrontendController.php | 39 +++-- app/Models/Language.php | 76 +++++++++ app/Views/frontend/catalog-grid.php | 15 +- app/Views/frontend/catalog.php | 19 ++- 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/catalog-reserved-category.spec.js | 149 ++++++++++++++++++ tests/language-derived-stats.unit.php | 131 +++++++++++++++ tests/migration-0.7.41.unit.php | 8 +- tests/migration-0.7.42.unit.php | 8 +- 14 files changed, 449 insertions(+), 25 deletions(-) create mode 100644 tests/catalog-reserved-category.spec.js create mode 100644 tests/language-derived-stats.unit.php diff --git a/app/Controllers/Admin/LanguagesController.php b/app/Controllers/Admin/LanguagesController.php index 0f5937fc..2e9f5534 100644 --- a/app/Controllers/Admin/LanguagesController.php +++ b/app/Controllers/Admin/LanguagesController.php @@ -33,7 +33,9 @@ class LanguagesController public function index(Request $request, Response $response, \mysqli $db, array $args): Response { $languageModel = new Language($db); - $languages = $languageModel->getAll(); + // Stats derived live from the Italian source (see Language::getAllWithDerivedStats) + // so total_keys is the same 6613-key denominator for every locale and can't drift. + $languages = $languageModel->getAllWithDerivedStats(); // Render view content ob_start(); diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index 2675d61c..dce89282 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -940,16 +940,20 @@ private function buildWhereConditions(array $filters, mysqli $db): array } } - // Filtra sulla disponibilità reale (copie_disponibili), coerente con badge e - // bottone prestito. Usare l.stato escludeva i libri 'prenotato' (copie a 0) - // da entrambi i filtri. + // Availability facets mirror the recomputed l.stato so the filter agrees + // with the card badge and the book page by construction: + // - "available" → copie_disponibili > 0 (canonical, drives the loan button) + // - "prenotato" → reserved: physically present but held by a scheduled + // loan / pending request / slot reservation (l.stato) + // - "prestato" → on loan: a copy is actually checked out (l.stato) + // Books with copies all out of circulation (l.stato = 'non_disponibile') + // and empty records belong to none of the three — they show only under "All". if ($filters['disponibilita'] === 'disponibile') { $conditions[] = "l.copie_disponibili > 0"; + } elseif ($filters['disponibilita'] === 'prenotato') { + $conditions[] = "l.stato = 'prenotato'"; } elseif ($filters['disponibilita'] === 'prestato') { - // "On loan" means every copy is out — NOT a book with no copies at all - // (copie_totali = 0), which also has copie_disponibili = 0 but isn't - // borrowed. Require copie_totali > 0 so empty records don't count. - $conditions[] = "l.copie_disponibili <= 0 AND l.copie_totali > 0"; + $conditions[] = "l.stato = 'prestato'"; } if (!empty($filters['anno_min'])) { @@ -1170,10 +1174,11 @@ private function getFilterOptions(mysqli $db, array $filters = []): array $row = $stmt->get_result()->fetch_assoc(); $availableCount = $row['cnt'] ?? 0; - // Count borrowed books: every copy out. copie_totali > 0 excludes records with - // no copies at all (copie_totali = 0 → copie_disponibili = 0), which are not - // on loan — otherwise they inflate the "on loan" filter count. - $queryBorrowed = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.copie_disponibili <= 0 AND l.copie_totali > 0"; + // Count borrowed books: a copy is actually checked out. Mirrors l.stato (the + // recomputed copy states) so it agrees with the card/book-page and does NOT + // count reserved books (stato='prenotato') or empty records (stato stays + // 'non_disponibile'/'disponibile'), which the old copie_disponibili<=0 proxy did. + $queryBorrowed = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.stato = 'prestato'"; $stmt = $db->prepare($queryBorrowed); if (!empty($paramsAvail)) { $stmt->bind_param($typesAvail, ...$paramsAvail); @@ -1182,6 +1187,17 @@ private function getFilterOptions(mysqli $db, array $filters = []): array $row = $stmt->get_result()->fetch_assoc(); $borrowedCount = $row['cnt'] ?? 0; + // Count reserved books: physically present but held by a scheduled loan, + // pending request or slot reservation — not lendable now, yet not on loan. + $queryReserved = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.stato = 'prenotato'"; + $stmt = $db->prepare($queryReserved); + if (!empty($paramsAvail)) { + $stmt->bind_param($typesAvail, ...$paramsAvail); + } + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $reservedCount = $row['cnt'] ?? 0; + // Real total: all catalogue books matching the other filters. Since a book // with no copies is neither available nor borrowed, available + borrowed // would under-count it — count the total directly instead. @@ -1196,6 +1212,7 @@ private function getFilterOptions(mysqli $db, array $filters = []): array $options['availability_stats'] = [ 'available' => $availableCount, + 'reserved' => $reservedCount, 'borrowed' => $borrowedCount, 'total' => $totalCount ]; diff --git a/app/Models/Language.php b/app/Models/Language.php index 0614c8c7..766f5946 100644 --- a/app/Models/Language.php +++ b/app/Models/Language.php @@ -15,6 +15,13 @@ */ class Language { + /** + * Source language. Every __() key IS the Italian string, so Italian is the + * translation source: it has no lookup file and is, by definition, complete. + * Its key count is the canonical denominator for every locale's completion. + */ + public const SOURCE_LOCALE = 'it_IT'; + private mysqli $db; public function __construct(mysqli $db) @@ -22,6 +29,75 @@ public function __construct(mysqli $db) $this->db = $db; } + /** + * Number of translatable keys defined by the source language (Italian). + * Derived live from locale/it_IT.json so it can never drift from the actual + * string set — no migration is needed when keys are added or removed. + * + * @return int + */ + public function sourceKeyCount(): int + { + static $count = null; + if ($count !== null) { + return $count; + } + $path = __DIR__ . '/../../locale/' . self::SOURCE_LOCALE . '.json'; + $decoded = is_file($path) ? json_decode((string) file_get_contents($path), true) : null; + $count = is_array($decoded) ? count($decoded) : 0; + return $count; + } + + /** + * Count of non-empty translations actually present in a locale's file, + * capped at the source total (orphan keys never push completion past 100%). + * The source language is complete by definition. + * + * @param string $code Locale code + * @param int $sourceTotal Source key count (denominator) + * @return int + */ + private function translatedKeyCount(string $code, int $sourceTotal): int + { + if ($code === self::SOURCE_LOCALE) { + return $sourceTotal; + } + $path = __DIR__ . '/../../locale/' . $code . '.json'; + if (!is_file($path)) { + return 0; + } + $decoded = json_decode((string) file_get_contents($path), true); + if (!is_array($decoded)) { + return 0; + } + $translated = count(array_filter($decoded, static fn($value) => is_string($value) && $value !== '')); + return min($translated, $sourceTotal); + } + + /** + * Same as getAll(), but with translation stats DERIVED from the source + * language instead of the stored (often stale) columns: total_keys is the + * source key count for every locale, translated_keys is what that locale's + * file actually covers, and completion_percentage is recomputed. This keeps + * the admin figures linked to Italian without a migration per key change. + * + * @param bool $activeOnly + * @return array> + */ + public function getAllWithDerivedStats(bool $activeOnly = false): array + { + $total = $this->sourceKeyCount(); + $languages = $this->getAll($activeOnly); + foreach ($languages as &$lang) { + $translated = $this->translatedKeyCount((string) ($lang['code'] ?? ''), $total); + $lang['total_keys'] = $total; + $lang['translated_keys'] = $translated; + $lang['completion_percentage'] = $total > 0 ? round($translated / $total * 100, 2) : 0.00; + } + unset($lang); + return $languages; + } + /** * Get all languages * diff --git a/app/Views/frontend/catalog-grid.php b/app/Views/frontend/catalog-grid.php index c5c9a3d6..419785ea 100644 --- a/app/Views/frontend/catalog-grid.php +++ b/app/Views/frontend/catalog-grid.php @@ -8,11 +8,18 @@ $getBookStatusBadge = static function ($book) { ob_start(); $available = ($book['copie_disponibili'] ?? 0) > 0; - $reserved = !$available && ($book['stato'] ?? '') === 'prenotato'; + $stato = $book['stato'] ?? ''; + $reserved = !$available && $stato === 'prenotato'; + // A book whose copies are all out of circulation (maintenance, lost, damaged…) + // is 'non_disponibile' — not on loan. Without this branch it fell through to + // the "In prestito" badge, mislabelling it. + $unavailable = !$available && $stato === 'non_disponibile'; if ($available) { echo '' . __("Disponibile"); } elseif ($reserved) { echo '' . __("Prenotato"); + } elseif ($unavailable) { + echo '' . __("Non disponibile"); } else { echo '' . __("In prestito"); } @@ -252,6 +259,12 @@ color: white; } +.status-unavailable { + /* Neutral grey — theme-agnostic, signals "not circulating" without alarm */ + background: #6b7280; + color: white; +} + /* Ensure consistent grid layout */ .books-grid { display: grid; diff --git a/app/Views/frontend/catalog.php b/app/Views/frontend/catalog.php index a463b8dc..530d2389 100644 --- a/app/Views/frontend/catalog.php +++ b/app/Views/frontend/catalog.php @@ -1409,7 +1409,7 @@ class="filter-option count
-
+
@@ -1431,6 +1431,21 @@ class="filter-option count
+
+
+ +
+
+
+
+
+
+ +
+
+
@@ -2111,10 +2126,12 @@ function updateFilterOptions(filterOptions, genreDisplay) { if (filterOptions.availability_stats) { const totalCount = document.getElementById('total-books-count'); const availableCount = document.getElementById('available-books-count'); + const reservedCount = document.getElementById('reserved-books-count'); const borrowedCount = document.getElementById('borrowed-books-count'); if (totalCount) totalCount.textContent = filterOptions.availability_stats.total.toLocaleString(); if (availableCount) availableCount.textContent = filterOptions.availability_stats.available.toLocaleString(); + if (reservedCount && filterOptions.availability_stats.reserved != null) reservedCount.textContent = filterOptions.availability_stats.reserved.toLocaleString(); if (borrowedCount) borrowedCount.textContent = filterOptions.availability_stats.borrowed.toLocaleString(); syncAvailabilityActiveState(); } diff --git a/locale/da_DK.json b/locale/da_DK.json index d22bdcfd..6b4ecafd 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -6609,5 +6609,8 @@ "Authorization header:": "Authorization-header:", "Percorso file non valido": "Ugyldig filsti", "Solo gli amministratori possono modificare questa chiave.": "Kun administratorer kan redigere denne nøgle.", - "Solo gli amministratori possono visualizzare e gestire le API key.": "Kun administratorer kan se og administrere API-nøgler." + "Solo gli amministratori possono visualizzare e gestire le API key.": "Kun administratorer kan se og administrere API-nøgler.", + "Prenotati": "Reserverede", + "Attualmente riservati": "I øjeblikket reserveret", + "Tutto il catalogo": "Hele kataloget" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 0467fea0..b4ae0c8a 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6609,5 +6609,8 @@ "Authorization header:": "Authorization-Header:", "Percorso file non valido": "Ungültiger Dateipfad", "Solo gli amministratori possono modificare questa chiave.": "Nur Administratoren können diesen Schlüssel bearbeiten.", - "Solo gli amministratori possono visualizzare e gestire le API key.": "Nur Administratoren können API-Schlüssel anzeigen und verwalten." + "Solo gli amministratori possono visualizzare e gestire le API key.": "Nur Administratoren können API-Schlüssel anzeigen und verwalten.", + "Prenotati": "Vorgemerkt", + "Attualmente riservati": "Derzeit vorgemerkt", + "Tutto il catalogo": "Gesamter Katalog" } diff --git a/locale/en_US.json b/locale/en_US.json index 7c092157..a1f43a2b 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6609,5 +6609,8 @@ "Authorization header:": "Authorization header:", "Percorso file non valido": "Invalid file path", "Solo gli amministratori possono modificare questa chiave.": "Only administrators can edit this key.", - "Solo gli amministratori possono visualizzare e gestire le API key.": "Only administrators can view and manage API keys." + "Solo gli amministratori possono visualizzare e gestire le API key.": "Only administrators can view and manage API keys.", + "Prenotati": "Reserved", + "Attualmente riservati": "Currently reserved", + "Tutto il catalogo": "Whole catalogue" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 641c120e..96673e4f 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6609,5 +6609,8 @@ "Authorization header:": "En-tête Authorization :", "Percorso file non valido": "Chemin de fichier invalide", "Solo gli amministratori possono modificare questa chiave.": "Seuls les administrateurs peuvent modifier cette clé.", - "Solo gli amministratori possono visualizzare e gestire le API key.": "Seuls les administrateurs peuvent voir et gérer les clés API." + "Solo gli amministratori possono visualizzare e gestire le API key.": "Seuls les administrateurs peuvent voir et gérer les clés API.", + "Prenotati": "Réservés", + "Attualmente riservati": "Actuellement réservés", + "Tutto il catalogo": "Tout le catalogue" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 125e73b5..57f82ef1 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6609,5 +6609,8 @@ "Authorization header:": "Authorization header:", "Percorso file non valido": "Percorso file non valido", "Solo gli amministratori possono modificare questa chiave.": "Solo gli amministratori possono modificare questa chiave.", - "Solo gli amministratori possono visualizzare e gestire le API key.": "Solo gli amministratori possono visualizzare e gestire le API key." + "Solo gli amministratori possono visualizzare e gestire le API key.": "Solo gli amministratori possono visualizzare e gestire le API key.", + "Prenotati": "Prenotati", + "Attualmente riservati": "Attualmente riservati", + "Tutto il catalogo": "Tutto il catalogo" } diff --git a/tests/catalog-reserved-category.spec.js b/tests/catalog-reserved-category.spec.js new file mode 100644 index 00000000..202526e7 --- /dev/null +++ b/tests/catalog-reserved-category.spec.js @@ -0,0 +1,149 @@ +// @ts-check +// The catalogue availability facets now mirror the recomputed libri.stato so the +// filter, the card badge and the book page agree by construction: +// - "Available" → copie_disponibili > 0 +// - "Reserved" → l.stato = 'prenotato' (held by a scheduled loan / pending +// request / slot reservation — present but not lendable now) +// - "On loan" → l.stato = 'prestato' (a copy is actually checked out) +// A book whose copies are all out of circulation (l.stato = 'non_disponibile') +// belongs to none of the three and shows a distinct "Not available" card badge. +// +// The old proxy (copie_disponibili <= 0 AND copie_totali > 0) lumped reserved, +// pending and on-loan together under "On loan". These tests pin the split. +// +// Run: /tmp/run-e2e.sh tests/catalog-reserved-category.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 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 || ''; + +test.skip(!DB_USER || !DB_NAME, 'DB credentials not configured (this spec seeds the catalogue)'); + +function db(sql) { + const args = []; + 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(); +} +function sqlq(s) { return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; } + +const TAG = 'ZZRESV310'; +const T_AVAIL = `${TAG} disponibile`; +const T_RESV = `${TAG} prenotato`; +const T_LOAN = `${TAG} prestato`; +const T_UNAV = `${TAG} non disponibile`; +const T_EMPTY = `${TAG} senza copie`; + +async function counts(page) { + await page.goto(`${BASE}/catalogo`); + await page.waitForSelector('#borrowed-books-count', { timeout: 15000 }); + const read = async (id) => { + const el = page.locator('#' + id).first(); + if (await el.count() === 0) return 0; + const txt = (await el.textContent()) || '0'; + return parseInt(txt.replace(/[^0-9]/g, ''), 10) || 0; + }; + return { + all: await read('total-books-count'), + available: await read('available-books-count'), + reserved: await read('reserved-books-count'), + borrowed: await read('borrowed-books-count'), + }; +} + +function seed() { + db(`DELETE FROM libri WHERE titolo LIKE ${sqlq(TAG + '%')}`); + // (stato, copie_totali, copie_disponibili) mirror the recomputed post-loan state. + db(`INSERT INTO libri (titolo, stato, copie_totali, copie_disponibili) VALUES (${sqlq(T_AVAIL)}, 'disponibile', 1, 1)`); + db(`INSERT INTO libri (titolo, stato, copie_totali, copie_disponibili) VALUES (${sqlq(T_RESV)}, 'prenotato', 1, 0)`); + db(`INSERT INTO libri (titolo, stato, copie_totali, copie_disponibili) VALUES (${sqlq(T_LOAN)}, 'prestato', 1, 0)`); + db(`INSERT INTO libri (titolo, stato, copie_totali, copie_disponibili) VALUES (${sqlq(T_UNAV)}, 'non_disponibile', 1, 0)`); + db(`INSERT INTO libri (titolo, stato, copie_totali, copie_disponibili) VALUES (${sqlq(T_EMPTY)}, 'disponibile', 0, 0)`); +} + +test.describe.serial('catalogue reserved category', () => { + let before; + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + db(`DELETE FROM libri WHERE titolo LIKE ${sqlq(TAG + '%')}`); + before = await counts(page); + seed(); + await page.close(); + }); + + test.afterAll(() => { db(`DELETE FROM libri WHERE titolo LIKE ${sqlq(TAG + '%')}`); }); + + test('Reserved count rises by exactly 1 (the prenotato book)', async ({ page }) => { + const after = await counts(page); + expect(after.reserved - before.reserved).toBe(1); + }); + + test('On-loan count rises by exactly 1 (the prestato book only)', async ({ page }) => { + const after = await counts(page); + expect(after.borrowed - before.borrowed).toBe(1); + }); + + test('Available count rises by exactly 1 (the disponibile book)', async ({ page }) => { + const after = await counts(page); + expect(after.available - before.available).toBe(1); + }); + + test('All-books count rises by 5 (every seeded record, incl. no-copies & non_disponibile)', async ({ page }) => { + const after = await counts(page); + expect(after.all - before.all).toBe(5); + }); + + test('reserved and on-loan are NOT merged: borrowed delta stays 1 with a reserved book present', async ({ page }) => { + const after = await counts(page); + // The old proxy would have counted BOTH prenotato and prestato → delta 2. + expect(after.borrowed - before.borrowed).toBe(1); + expect(after.reserved - before.reserved).toBe(1); + }); + + test('?disponibilita=prenotato shows the reserved book', async ({ page }) => { + await page.goto(`${BASE}/catalogo?disponibilita=prenotato`); + await expect(page.locator('.book-card', { hasText: T_RESV })).toHaveCount(1); + }); + + test('?disponibilita=prenotato does NOT show the on-loan book', async ({ page }) => { + await page.goto(`${BASE}/catalogo?disponibilita=prenotato`); + await expect(page.locator('.book-card', { hasText: T_LOAN })).toHaveCount(0); + }); + + test('?disponibilita=prestato shows the on-loan book but not the reserved one', async ({ page }) => { + await page.goto(`${BASE}/catalogo?disponibilita=prestato`); + await expect(page.locator('.book-card', { hasText: T_LOAN })).toHaveCount(1); + await expect(page.locator('.book-card', { hasText: T_RESV })).toHaveCount(0); + }); + + test('?disponibilita=disponibile shows the available book but not the reserved one', async ({ page }) => { + await page.goto(`${BASE}/catalogo?disponibilita=disponibile`); + await expect(page.locator('.book-card', { hasText: T_AVAIL })).toHaveCount(1); + await expect(page.locator('.book-card', { hasText: T_RESV })).toHaveCount(0); + }); + + test('reserved book card shows the "Prenotato" badge (not "In prestito")', async ({ page }) => { + await page.goto(`${BASE}/catalogo?disponibilita=prenotato`); + const badge = page.locator('.book-card', { hasText: T_RESV }).locator('.book-status-badge'); + await expect(badge).toHaveText(/Prenotato/i); + }); + + test('on-loan book card shows the "In prestito" badge', async ({ page }) => { + await page.goto(`${BASE}/catalogo?disponibilita=prestato`); + const badge = page.locator('.book-card', { hasText: T_LOAN }).locator('.book-status-badge'); + await expect(badge).toHaveText(/In prestito/i); + }); + + test('non_disponibile book shows a distinct "Non disponibile" badge under "All"', async ({ page }) => { + await page.goto(`${BASE}/catalogo`); + const card = page.locator('.book-card', { hasText: T_UNAV }); + // Present in the unfiltered catalogue… + await expect(card).toHaveCount(1); + await expect(card.locator('.book-status-badge')).toHaveText(/Non disponibile/i); + }); +}); diff --git a/tests/language-derived-stats.unit.php b/tests/language-derived-stats.unit.php new file mode 100644 index 00000000..0530b7cd --- /dev/null +++ b/tests/language-derived-stats.unit.php @@ -0,0 +1,131 @@ +set_charset('utf8mb4'); +} catch (\Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable: {$e->getMessage()}\n"); + exit(1); +} + +$lang = new Language($db); +$srcPath = $root . '/locale/' . Language::SOURCE_LOCALE . '.json'; +$srcCount = count(json_decode((string) file_get_contents($srcPath), true, 512, JSON_THROW_ON_ERROR)); + +// Reflection handle for the private translatedKeyCount(). +$ref = new ReflectionMethod(Language::class, 'translatedKeyCount'); +$ref->setAccessible(true); +$translated = static fn (string $code, int $total): int => $ref->invoke($lang, $code, $total); + +echo "A. sourceKeyCount()\n"; +$src = $lang->sourceKeyCount(); +$check($src === $srcCount, "sourceKeyCount() == it_IT.json key count ({$srcCount})"); +$check($src > 6000, "source key count is in the expected range (>6000): {$src}"); +$check($lang->sourceKeyCount() === $src, 'sourceKeyCount() is stable across calls (static cache)'); + +echo "B. getAllWithDerivedStats() — total_keys linked to Italian for every locale\n"; +$rows = $lang->getAllWithDerivedStats(); +$check(count($rows) >= 5, 'returns all seeded locales (>=5)'); + +$allSameTotal = true; +$completionOk = true; +$translatedCapped = true; +$byCode = []; +foreach ($rows as $r) { + $byCode[(string) $r['code']] = $r; + if ((int) $r['total_keys'] !== $srcCount) { $allSameTotal = false; } + if ((int) $r['translated_keys'] > (int) $r['total_keys']) { $translatedCapped = false; } + $expectedPct = $srcCount > 0 ? round((int) $r['translated_keys'] / $srcCount * 100, 2) : 0.00; + if (abs((float) $r['completion_percentage'] - $expectedPct) > 0.01) { $completionOk = false; } +} +$check($allSameTotal, "every locale's total_keys == source count ({$srcCount}) — no per-locale denominator"); +$check($translatedCapped, 'translated_keys never exceeds total_keys for any locale'); +$check($completionOk, 'completion_percentage == round(translated/total*100) for every locale'); + +echo "C. Source locale (Italian) is complete by definition\n"; +$check(isset($byCode[Language::SOURCE_LOCALE]), 'source locale present in results'); +if (isset($byCode[Language::SOURCE_LOCALE])) { + $it = $byCode[Language::SOURCE_LOCALE]; + $check((int) $it['translated_keys'] === (int) $it['total_keys'], 'it_IT translated_keys == total_keys'); + $check((float) $it['completion_percentage'] === 100.00, 'it_IT completion is 100.00'); +} + +echo "D. translatedKeyCount() unit behaviour\n"; +$check($translated(Language::SOURCE_LOCALE, $srcCount) === $srcCount, 'source locale counts as fully translated'); +$check($translated('en_US', $srcCount) <= $srcCount, 'en_US translated <= source total (capped)'); +$check($translated('en_US', $srcCount) > 6000, 'en_US is a well-covered locale (>6000)'); +$check($translated('zz_NONEXISTENT', $srcCount) === 0, 'missing locale file → 0 translated'); + +echo "E. Temp locale: incomplete, empty values, and orphan-key cap\n"; +$tmpCode = 'zz_ZZ'; +$tmpPath = $root . '/locale/' . $tmpCode . '.json'; +try { + // 3 real translations + 2 empty (must not count) → 3 translated. + file_put_contents($tmpPath, json_encode( + ['a' => 'x', 'b' => 'y', 'c' => 'z', 'd' => '', 'e' => ''], + JSON_UNESCAPED_UNICODE + )); + $check($translated($tmpCode, $srcCount) === 3, 'empty values are not counted (3 of 5)'); + + // A partial translation is below 100%. + $partial = $translated($tmpCode, $srcCount); + $pct = round($partial / $srcCount * 100, 2); + $check($pct < 100.00, "partial locale completion is below 100% ({$pct}%)"); + + // Orphan keys beyond the source total must not push the count past the cap. + $big = []; + for ($i = 0; $i < $srcCount + 50; $i++) { $big["k{$i}"] = 'v'; } + file_put_contents($tmpPath, json_encode($big, JSON_UNESCAPED_UNICODE)); + $check($translated($tmpCode, $srcCount) === $srcCount, 'orphan keys cannot exceed the source total (cap at 100%)'); +} finally { + if (is_file($tmpPath)) { @unlink($tmpPath); } +} + +$db->close(); +echo "\n{$pass} PASS, {$fail} FAIL\n"; +exit($fail === 0 ? 0 : 1); diff --git a/tests/migration-0.7.41.unit.php b/tests/migration-0.7.41.unit.php index 1c55e229..1548a13e 100644 --- a/tests/migration-0.7.41.unit.php +++ b/tests/migration-0.7.41.unit.php @@ -112,13 +112,15 @@ $check($row !== null, 'da_DK row inserted by migration'); $check($row !== null && $row['native_name'] === 'Dansk', 'native_name = Dansk'); $check($row !== null && (int) $row['is_active'] === 1, 'da_DK is active'); - $locale = json_decode((string) file_get_contents($root . '/locale/da_DK.json'), true, 512, JSON_THROW_ON_ERROR); - $expectedKeys = count($locale); + // migrate_0.7.41.sql inserts da_DK with the fixed literal 6611 it shipped with. + // (Live completion is now derived dynamically from the Italian source, so the + // da_DK.json file may hold more keys than this historical migration wrote.) + $expectedKeys = 6611; $check( $row !== null && (int) $row['total_keys'] === $expectedKeys && (int) $row['translated_keys'] === $expectedKeys, - "key counts match locale/da_DK.json ({$expectedKeys}/{$expectedKeys})" + "key counts match migrate_0.7.41.sql literal ({$expectedKeys}/{$expectedKeys})" ); $check($row !== null && (float) $row['completion_percentage'] === 100.00, 'completion_percentage = 100.00'); $check($row !== null && $row['translation_file'] === 'locale/da_DK.json', 'translation_file = locale/da_DK.json'); diff --git a/tests/migration-0.7.42.unit.php b/tests/migration-0.7.42.unit.php index 831a4940..7087bc03 100644 --- a/tests/migration-0.7.42.unit.php +++ b/tests/migration-0.7.42.unit.php @@ -72,9 +72,11 @@ $db->query("DROP TABLE IF EXISTS {$SB}"); }; -// The count the migration targets — read from the shipped locale file so the -// test tracks the real key count rather than a stale literal. -$expectedKeys = count(json_decode((string) file_get_contents($root . '/locale/da_DK.json'), true, 512, JSON_THROW_ON_ERROR)); +// The count migrate_0.7.42.sql targets is the fixed literal 6611 it shipped with. +// (Live completion stats are now derived dynamically from the Italian source in +// Language::getAllWithDerivedStats, so the da_DK.json file may hold more keys than +// this historical migration wrote — this test only checks that migration's own effect.) +$expectedKeys = 6611; try { $cleanup(); From 65ce672356588e0f3e6db0780f8d09def1632c05 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 28 Jul 2026 20:59:19 +0200 Subject: [PATCH 4/9] fix(#303): make catalogue and locale stats consistent --- app/Controllers/FrontendController.php | 55 +++++++------------------ app/Models/Language.php | 26 +++++++++--- app/Views/frontend/catalog.php | 8 +++- tests/catalog-reserved-category.spec.js | 7 ++++ tests/language-derived-stats.unit.php | 43 +++++++++++++++---- 5 files changed, 83 insertions(+), 56 deletions(-) diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index dce89282..6cdf1995 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -1163,52 +1163,25 @@ private function getFilterOptions(mysqli $db, array $filters = []): array $availabilityBaseQuery .= " AND " . implode(' AND ', $conditionsAvail); } - // Count available books (base query always has WHERE l.deleted_at IS NULL). - // Conteggio coerente col filtro: basato su copie_disponibili, non su l.stato. - $queryAvailable = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.copie_disponibili > 0"; - $stmt = $db->prepare($queryAvailable); + // Compute every mutually-exclusive availability facet and the real catalogue + // total in one scan. The direct total includes records with no copies, while + // the conditional counts mirror the corresponding filter predicates. + $queryStats = "SELECT + COUNT(DISTINCT l.id) AS total_cnt, + COUNT(DISTINCT CASE WHEN l.copie_disponibili > 0 THEN l.id END) AS available_cnt, + COUNT(DISTINCT CASE WHEN l.stato = 'prenotato' THEN l.id END) AS reserved_cnt, + COUNT(DISTINCT CASE WHEN l.stato = 'prestato' THEN l.id END) AS borrowed_cnt + " . $availabilityBaseQuery; + $stmt = $db->prepare($queryStats); if (!empty($paramsAvail)) { $stmt->bind_param($typesAvail, ...$paramsAvail); } $stmt->execute(); $row = $stmt->get_result()->fetch_assoc(); - $availableCount = $row['cnt'] ?? 0; - - // Count borrowed books: a copy is actually checked out. Mirrors l.stato (the - // recomputed copy states) so it agrees with the card/book-page and does NOT - // count reserved books (stato='prenotato') or empty records (stato stays - // 'non_disponibile'/'disponibile'), which the old copie_disponibili<=0 proxy did. - $queryBorrowed = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.stato = 'prestato'"; - $stmt = $db->prepare($queryBorrowed); - if (!empty($paramsAvail)) { - $stmt->bind_param($typesAvail, ...$paramsAvail); - } - $stmt->execute(); - $row = $stmt->get_result()->fetch_assoc(); - $borrowedCount = $row['cnt'] ?? 0; - - // Count reserved books: physically present but held by a scheduled loan, - // pending request or slot reservation — not lendable now, yet not on loan. - $queryReserved = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.stato = 'prenotato'"; - $stmt = $db->prepare($queryReserved); - if (!empty($paramsAvail)) { - $stmt->bind_param($typesAvail, ...$paramsAvail); - } - $stmt->execute(); - $row = $stmt->get_result()->fetch_assoc(); - $reservedCount = $row['cnt'] ?? 0; - - // Real total: all catalogue books matching the other filters. Since a book - // with no copies is neither available nor borrowed, available + borrowed - // would under-count it — count the total directly instead. - $queryTotal = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery; - $stmt = $db->prepare($queryTotal); - if (!empty($paramsAvail)) { - $stmt->bind_param($typesAvail, ...$paramsAvail); - } - $stmt->execute(); - $row = $stmt->get_result()->fetch_assoc(); - $totalCount = $row['cnt'] ?? 0; + $totalCount = (int) ($row['total_cnt'] ?? 0); + $availableCount = (int) ($row['available_cnt'] ?? 0); + $reservedCount = (int) ($row['reserved_cnt'] ?? 0); + $borrowedCount = (int) ($row['borrowed_cnt'] ?? 0); $options['availability_stats'] = [ 'available' => $availableCount, diff --git a/app/Models/Language.php b/app/Models/Language.php index 766f5946..4a09714a 100644 --- a/app/Models/Language.php +++ b/app/Models/Language.php @@ -38,14 +38,24 @@ public function __construct(mysqli $db) */ public function sourceKeyCount(): int { - static $count = null; - if ($count !== null) { - return $count; + return count($this->sourceKeys()); + } + + /** + * Canonical translation keys defined by the Italian source catalogue. + * + * @return array + */ + private function sourceKeys(): array + { + static $keys = null; + if ($keys !== null) { + return $keys; } $path = __DIR__ . '/../../locale/' . self::SOURCE_LOCALE . '.json'; $decoded = is_file($path) ? json_decode((string) file_get_contents($path), true) : null; - $count = is_array($decoded) ? count($decoded) : 0; - return $count; + $keys = is_array($decoded) ? array_fill_keys(array_keys($decoded), true) : []; + return $keys; } /** @@ -70,7 +80,11 @@ private function translatedKeyCount(string $code, int $sourceTotal): int if (!is_array($decoded)) { return 0; } - $translated = count(array_filter($decoded, static fn($value) => is_string($value) && $value !== '')); + // Only canonical source keys contribute to completion. Merely capping the + // number of locale entries would let orphan keys hide missing translations + // and could incorrectly report 100% completion. + $canonicalEntries = array_intersect_key($decoded, $this->sourceKeys()); + $translated = count(array_filter($canonicalEntries, static fn($value) => is_string($value) && $value !== '')); return min($translated, $sourceTotal); } diff --git a/app/Views/frontend/catalog.php b/app/Views/frontend/catalog.php index 530d2389..0aeb67db 100644 --- a/app/Views/frontend/catalog.php +++ b/app/Views/frontend/catalog.php @@ -1671,6 +1671,7 @@ class="year-slider" // Status labels 'disponibile' => __('Disponibile'), + 'prenotato' => __('Prenotato'), 'in_prestito' => __('In prestito'), // Actions @@ -1878,7 +1879,12 @@ function updateActiveFiltersDisplay() { if (filterKey === 'sort') { displayValue = sortLabels[value] || value; } else if (filterKey === 'disponibilita') { - displayValue = value === 'disponibile' ? i18n.disponibile : i18n.in_prestito; + const availabilityLabels = { + disponibile: i18n.disponibile, + prenotato: i18n.prenotato, + prestato: i18n.in_prestito, + }; + displayValue = availabilityLabels[value] || value; } else if (filterKey === 'genere_id') { displayValue = currentGenreName || value; } else if (filterKey === 'autore_id') { diff --git a/tests/catalog-reserved-category.spec.js b/tests/catalog-reserved-category.spec.js index 202526e7..b64596c3 100644 --- a/tests/catalog-reserved-category.spec.js +++ b/tests/catalog-reserved-category.spec.js @@ -110,6 +110,13 @@ test.describe.serial('catalogue reserved category', () => { await expect(page.locator('.book-card', { hasText: T_RESV })).toHaveCount(1); }); + test('?disponibilita=prenotato labels the active filter as reserved, not on loan', async ({ page }) => { + await page.goto(`${BASE}/catalogo?disponibilita=prenotato`); + const activeFilter = page.locator('#active-filters-list .filter-tag'); + await expect(activeFilter).toContainText(/Prenotato/i); + await expect(activeFilter).not.toContainText(/In prestito/i); + }); + test('?disponibilita=prenotato does NOT show the on-loan book', async ({ page }) => { await page.goto(`${BASE}/catalogo?disponibilita=prenotato`); await expect(page.locator('.book-card', { hasText: T_LOAN })).toHaveCount(0); diff --git a/tests/language-derived-stats.unit.php b/tests/language-derived-stats.unit.php index 0530b7cd..8a9ac367 100644 --- a/tests/language-derived-stats.unit.php +++ b/tests/language-derived-stats.unit.php @@ -53,9 +53,28 @@ exit(1); } -$lang = new Language($db); $srcPath = $root . '/locale/' . Language::SOURCE_LOCALE . '.json'; -$srcCount = count(json_decode((string) file_get_contents($srcPath), true, 512, JSON_THROW_ON_ERROR)); +$sourceEntries = json_decode((string) file_get_contents($srcPath), true, 512, JSON_THROW_ON_ERROR); +$srcCount = count($sourceEntries); + +// The suite database is intentionally not assumed to contain application seed +// data. Build the minimum language catalogue in a transaction and roll it back +// when the test finishes, preserving any rows that were already present. +$db->begin_transaction(); +foreach ([ + ['it_IT', 'Italian', 'Italiano'], + ['zz_AA', 'Fixture A', 'Fixture A'], + ['zz_BB', 'Fixture B', 'Fixture B'], + ['zz_CC', 'Fixture C', 'Fixture C'], + ['zz_DD', 'Fixture D', 'Fixture D'], +] as [$code, $name, $nativeName]) { + $stmt = $db->prepare('INSERT IGNORE INTO languages (code, name, native_name, is_default, is_active) VALUES (?, ?, ?, 0, 1)'); + $stmt->bind_param('sss', $code, $name, $nativeName); + $stmt->execute(); + $stmt->close(); +} + +$lang = new Language($db); // Reflection handle for the private translatedKeyCount(). $ref = new ReflectionMethod(Language::class, 'translatedKeyCount'); @@ -101,31 +120,39 @@ $check($translated('en_US', $srcCount) > 6000, 'en_US is a well-covered locale (>6000)'); $check($translated('zz_NONEXISTENT', $srcCount) === 0, 'missing locale file → 0 translated'); -echo "E. Temp locale: incomplete, empty values, and orphan-key cap\n"; +echo "E. Temp locale: canonical coverage, empty values, and orphan keys\n"; $tmpCode = 'zz_ZZ'; $tmpPath = $root . '/locale/' . $tmpCode . '.json'; try { - // 3 real translations + 2 empty (must not count) → 3 translated. + $canonicalKeys = array_slice(array_keys($sourceEntries), 0, 5); + // 3 canonical translations + 2 empty canonical values → 3 translated. file_put_contents($tmpPath, json_encode( - ['a' => 'x', 'b' => 'y', 'c' => 'z', 'd' => '', 'e' => ''], + [ + $canonicalKeys[0] => 'x', + $canonicalKeys[1] => 'y', + $canonicalKeys[2] => 'z', + $canonicalKeys[3] => '', + $canonicalKeys[4] => '', + ], JSON_UNESCAPED_UNICODE )); - $check($translated($tmpCode, $srcCount) === 3, 'empty values are not counted (3 of 5)'); + $check($translated($tmpCode, $srcCount) === 3, 'only non-empty canonical values are counted (3 of 5)'); // A partial translation is below 100%. $partial = $translated($tmpCode, $srcCount); $pct = round($partial / $srcCount * 100, 2); $check($pct < 100.00, "partial locale completion is below 100% ({$pct}%)"); - // Orphan keys beyond the source total must not push the count past the cap. + // Even more orphan entries than the source total contribute no coverage. $big = []; for ($i = 0; $i < $srcCount + 50; $i++) { $big["k{$i}"] = 'v'; } file_put_contents($tmpPath, json_encode($big, JSON_UNESCAPED_UNICODE)); - $check($translated($tmpCode, $srcCount) === $srcCount, 'orphan keys cannot exceed the source total (cap at 100%)'); + $check($translated($tmpCode, $srcCount) === 0, 'orphan-only keys do not count as translated'); } finally { if (is_file($tmpPath)) { @unlink($tmpPath); } } +$db->rollback(); $db->close(); echo "\n{$pass} PASS, {$fail} FAIL\n"; exit($fail === 0 ? 0 : 1); From 2dc0f7e6beefc6c4d569e004635c3a3da568af6b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 29 Jul 2026 08:46:04 +0200 Subject: [PATCH 5/9] fix: ship Open Library cover fallback in v0.7.45 --- README.md | 22 +- app/Controllers/ScrapeController.php | 58 +---- .../open-library/OpenLibraryPlugin.php | 210 ++++++------------ storage/plugins/open-library/README.md | 4 + storage/plugins/open-library/plugin.json | 16 +- tests/full-test.spec.js | 22 +- tests/plugin-goodreads-cover.unit.php | 80 +++++++ version.json | 2 +- 8 files changed, 201 insertions(+), 213 deletions(-) create mode 100644 tests/plugin-goodreads-cover.unit.php diff --git a/README.md b/README.md index 908ffe61..477c80b3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,24 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri --- +## What's New in v0.7.45 + +A catalogue-consistency and scraper-maintenance release. + +### Improvements +- **Catalogue availability facets now reflect real circulation state** — reserved, on-loan, available and non-circulating records are shown and counted separately; records without physical copies remain visible under the complete catalogue without being mislabelled as loans. +- **Language completion statistics are derived from the Italian source catalogue** — every locale now uses the same live denominator, so translation percentages cannot drift from the shipped keys. +- **Open Library plugin 1.0.2** — replaces the retired third-party Goodreads-cover service with a direct, timeout-bounded lookup of the public Goodreads ISBN page. Only HTTPS cover URLs from exact Goodreads/Amazon CDN domains or their subdomains are accepted; its manifest now correctly requires Pinakes 0.7.16+ and PHP 8.2+. + +### Database Changes +- None — catalogue availability is derived from existing circulation state and language statistics are computed from the shipped locale files. + +### Upgrade Notes +- The bundled Open Library plugin is replaced atomically by the updater and its database metadata advances from 1.0.1 to 1.0.2. +- Back up your database before updating (the in-app updater does this automatically). + +--- + ## What's New in v0.7.44 A bug-fix release for email-template links. @@ -1433,10 +1451,10 @@ Plugins support encrypted secrets and isolated configuration. Install via ZIP up All plugins are located in `storage/plugins/` and can be managed from **Admin → Plugins**. -### 1. Open Library (`open-library-v1.0.1.zip`) +### 1. Open Library (`open-library-v1.0.2.zip`) - **Metadata scraping** from Open Library API - **Fallback to Google Books** when Open Library lacks data -- **Automatic cover download** with validation +- **Automatic cover lookup** from Open Library with a secure Goodreads/Amazon CDN fallback - **Subject mapping** and language normalization - **Configurable priority** and caching options diff --git a/app/Controllers/ScrapeController.php b/app/Controllers/ScrapeController.php index 9733d4ae..21022acc 100644 --- a/app/Controllers/ScrapeController.php +++ b/app/Controllers/ScrapeController.php @@ -245,16 +245,8 @@ public function byIsbn(Request $request, Response $response): Response $fallbackData = $this->fallbackFromOpenLibrary($searchIdentifier); } - // If fallback found data but no cover, try Goodreads cover API - if ($fallbackData !== null && empty($fallbackData['image'])) { - $goodreadsCover = $this->fallbackCoverFromGoodreads($searchIdentifier); - if ($goodreadsCover !== null) { - $fallbackData['image'] = $goodreadsCover; - } - } - if ($fallbackData !== null) { - // Merge partial plugin data (e.g., cover from Goodreads) into fallback data + // Merge partial plugin data into fallback data if (is_array($customResult)) { // Fallback data is the base, plugin data fills gaps (like cover) foreach ($customResult as $key => $value) { @@ -415,7 +407,7 @@ private function fallbackFromGoogleBooks(string $isbn): ?array $url .= '&key=' . urlencode($apiKey); } - $json = $this->safeHttpGet($url, 10); + $json = $this->safeHttpGet($url, 5); if (!$json) { return null; } @@ -545,7 +537,7 @@ private function fallbackFromOpenLibrary(string $isbn): ?array } $url = "https://openlibrary.org/isbn/" . urlencode($isbn) . ".json"; - $json = $this->safeHttpGet($url, 10); + $json = $this->safeHttpGet($url, 5); if (!$json) { return null; } @@ -594,37 +586,7 @@ private function fallbackFromOpenLibrary(string $isbn): ?array } /** - * Fallback: get cover image URL from Goodreads via bookcover.longitood.com API - */ - private function fallbackCoverFromGoodreads(string $isbn): ?string - { - if (!$this->checkRateLimit('goodreads_cover', 60)) { - SecureLogger::debug('[ScrapeController] Goodreads cover API rate limit exceeded', ['isbn' => $isbn]); - return null; - } - - $url = 'https://bookcover.longitood.com/bookcover/' . urlencode($isbn); - $json = $this->safeHttpGet($url, 8); - if (!$json) { - return null; - } - - $data = json_decode($json, true); - if (!is_array($data) || empty($data['url'])) { - return null; - } - - $coverUrl = $data['url']; - if (!filter_var($coverUrl, FILTER_VALIDATE_URL)) { - return null; - } - - SecureLogger::debug('[ScrapeController] Goodreads cover found', ['isbn' => $isbn, 'cover' => $coverUrl]); - return $coverUrl; - } - - /** - * Try built-in sources (Google Books, Open Library, Goodreads) for cover image only. + * Try built-in sources (Google Books, Open Library) for cover image only. * Used when a plugin returned complete metadata but no cover image. * * @param string $isbn ISBN to search @@ -632,19 +594,13 @@ private function fallbackCoverFromGoodreads(string $isbn): ?string */ public function findCoverFromBuiltinSources(string $isbn): ?string { - // 1. Try Goodreads cover API (lightest — single URL check) - $goodreads = $this->fallbackCoverFromGoodreads($isbn); - if ($goodreads !== null) { - return $goodreads; - } - - // 2. Try Google Books (reliable, has good cover database) + // 1. Try Google Books (reliable, has good cover database) $gbData = $this->fallbackFromGoogleBooks($isbn); if (!empty($gbData['image'])) { return $gbData['image']; } - // 3. Try Open Library (has covers for many editions) + // 2. Try Open Library (has covers for many editions) $olData = $this->fallbackFromOpenLibrary($isbn); if (!empty($olData['image'])) { return $olData['image']; @@ -656,7 +612,7 @@ public function findCoverFromBuiltinSources(string $isbn): ?string /** * Safe HTTP GET with timeout and basic validation. */ - private function safeHttpGet(string $url, int $timeout = 10): ?string + private function safeHttpGet(string $url, int $timeout = 5): ?string { // Routed through the shared HttpClient (Guzzle): same timeouts, SSL // verification, User-Agent and http/https-only redirect policy as the diff --git a/storage/plugins/open-library/OpenLibraryPlugin.php b/storage/plugins/open-library/OpenLibraryPlugin.php index bfd5e9c8..566f5fed 100644 --- a/storage/plugins/open-library/OpenLibraryPlugin.php +++ b/storage/plugins/open-library/OpenLibraryPlugin.php @@ -16,8 +16,7 @@ class OpenLibraryPlugin { private const API_BASE = 'https://openlibrary.org'; private const COVERS_BASE = 'https://covers.openlibrary.org'; - private const GOODREADS_COVERS_BASE = 'https://bookcover.longitood.com/bookcover'; - private const TIMEOUT = 15; + private const TIMEOUT = 6; private const USER_AGENT = 'Mozilla/5.0 (compatible; BibliotecaBot/1.0) Safari/537.36'; private const MIN_COVER_SIZE_BYTES = 1000; @@ -231,20 +230,6 @@ public function fetchFromOpenLibrary($existing, array $sources, string $isbn): ? $editionData = $this->fetchEditionByISBN($isbn); if (empty($editionData) || isset($editionData['error']) || empty($editionData['title'])) { - // Try to get at least a cover from Goodreads as last resort - $goodreadsCover = $this->getGoodreadsCover($isbn, '', ''); - - if ($goodreadsCover) { - // Minimal data with just the cover - merge with existing - $coverData = [ - 'image' => $goodreadsCover, - 'isbn' => $isbn, - 'source' => self::GOODREADS_COVERS_BASE . '/' . $isbn, - '_cover_only' => true, - ]; - return $this->mergeBookData($existing, $coverData, 'goodreads'); - } - // Nothing found - return existing data unchanged return $existing; } @@ -271,9 +256,8 @@ public function fetchFromOpenLibrary($existing, array $sources, string $isbn): ? } } - // Fetch cover image (pass first author name for Goodreads fallback) - $firstAuthor = !empty($authorNames) ? $authorNames[0] : ''; - $coverUrl = $this->getCoverUrl($isbn, $editionData, $firstAuthor); + // Fetch cover image + $coverUrl = $this->getCoverUrl($isbn, $editionData); // Build response in the format expected by the application $openLibraryData = [ @@ -366,22 +350,12 @@ public function enrichWithOpenLibraryData(array $payload, string $isbn, array $s // Try to fetch cover if missing if (empty($payload['image'])) { - // Extract author and title from payload for Goodreads fallback - $authorName = ''; - if (!empty($payload['author'])) { - // Get first author if comma-separated list - $authors = explode(',', $payload['author']); - $authorName = trim($authors[0]); - } elseif (!empty($payload['authors'][0])) { - $authorName = $payload['authors'][0]; - } - $editionData = []; if (!empty($payload['title'])) { $editionData['title'] = $payload['title']; } - $coverUrl = $this->getCoverUrl($isbn, $editionData, $authorName); + $coverUrl = $this->getCoverUrl($isbn, $editionData); if ($coverUrl) { $payload['image'] = $coverUrl; } @@ -427,136 +401,83 @@ private function fetchAuthor(string $authorKey): ?array } /** - * Convert ISBN-10 to ISBN-13 + * Scrape a book cover from Goodreads' public book page. * - * @param string $isbn10 ISBN-10 code - * @return string ISBN-13 code + * Goodreads' /search endpoint sits behind an anti-bot challenge (HTTP 202), + * but the canonical /book/isbn/{isbn} page returns full HTML (200). We read + * the Open Graph og:image meta tag — Goodreads fills it with the cover on + * Amazon's CDN, and it is stable across layout changes (unlike the CSS-class + * scraping the retired bookcover.longitood.com service relied on). + * + * @param string $isbn ISBN-10 or ISBN-13 + * @return string|null Cover image URL, or null if not on Goodreads / on error */ - private function convertIsbn10ToIsbn13(string $isbn10): string + private function getGoodreadsCover(string $isbn): ?string { - // Remove any hyphens or spaces - $isbn10 = preg_replace('/[\s\-]/', '', $isbn10); - - // Check if it's already ISBN-13 - if (strlen($isbn10) === 13) { - return $isbn10; - } - - // Check if it's a valid ISBN-10 length - if (strlen($isbn10) !== 10) { - return $isbn10; // Return as-is if invalid - } - - // Remove the check digit from ISBN-10 - $isbn10WithoutCheck = substr($isbn10, 0, 9); - - // Add 978 prefix - $isbn13WithoutCheck = '978' . $isbn10WithoutCheck; - - // Calculate ISBN-13 check digit - $sum = 0; - for ($i = 0; $i < 12; $i++) { - $digit = (int)$isbn13WithoutCheck[$i]; - $sum += ($i % 2 === 0) ? $digit : $digit * 3; + $clean = preg_replace('/[^0-9Xx]/', '', $isbn); + if (!is_string($clean) || (strlen($clean) !== 10 && strlen($clean) !== 13)) { + return null; } - $checkDigit = (10 - ($sum % 10)) % 10; - - return $isbn13WithoutCheck . $checkDigit; - } - - /** - * Get cover from Goodreads API via bookcover.longitood.com - * - * @param string $isbn ISBN/EAN code (ISBN-10, ISBN-13, or any 13-digit EAN) - * @param string $title Book title (optional, for fallback) - * @param string $author Author name (optional, for fallback) - * @return string|null Cover URL or null - */ - private function getGoodreadsCover(string $isbn, string $title = '', string $author = ''): ?string - { try { - // Clean the code - $cleanCode = preg_replace('/[\s\-]/', '', $isbn); - - // Try with original code first (ISBN-13 or EAN-13) - if (strlen($cleanCode) === 13 && ctype_digit($cleanCode)) { - $url = self::GOODREADS_COVERS_BASE . '/' . urlencode($cleanCode); - $coverUrl = $this->fetchGoodreadsCoverUrl($url); - - if ($coverUrl) { - return $coverUrl; - } - } - - // Try converting ISBN-10 to ISBN-13 if it's 10 digits - if (strlen($cleanCode) === 10) { - $isbn13 = $this->convertIsbn10ToIsbn13($cleanCode); - if (strlen($isbn13) === 13) { - $url = self::GOODREADS_COVERS_BASE . '/' . urlencode($isbn13); - $coverUrl = $this->fetchGoodreadsCoverUrl($url); - - if ($coverUrl) { - return $coverUrl; - } - } - } - - // Fallback to title/author search - BOTH parameters are required - if (!empty($title) && !empty($author)) { - $queryParams = [ - 'book_title' => $title, - 'author_name' => $author - ]; - - $url = self::GOODREADS_COVERS_BASE . '?' . http_build_query($queryParams); - $coverUrl = $this->fetchGoodreadsCoverUrl($url); + $res = \App\Support\HttpClient::get( + 'https://www.goodreads.com/book/isbn/' . urlencode($clean), + [ + 'Accept' => 'text/html,application/xhtml+xml', + 'Accept-Language' => 'en-US,en;q=0.9', + ], + ['timeout' => 5, 'max_redirects' => 3, 'user_agent' => self::USER_AGENT] + ); - if ($coverUrl) { - return $coverUrl; - } + if (!$res['ok'] || $res['status'] !== 200 || empty($res['body'])) { + return null; } - return null; + return $this->extractGoodreadsCoverFromHtml((string) $res['body']); } catch (\Throwable $e) { - // Gracefully handle errors - don't break the app - \App\Support\SecureLogger::warning("[OpenLibrary] Goodreads cover API error: " . $e->getMessage()); + \App\Support\SecureLogger::warning('[OpenLibrary] Goodreads cover scrape error: ' . $e->getMessage()); return null; } } /** - * Fetch cover URL from Goodreads API response + * Extract the cover URL from a Goodreads book page's Open Graph og:image tag, + * accepting only the known Goodreads/Amazon image CDNs. Pure string parsing, + * separated from the HTTP fetch so it can be unit-tested deterministically. * - * @param string $apiUrl API endpoint URL - * @return string|null Cover URL or null + * @param string $html Raw HTML of the Goodreads book page + * @return string|null Cover image URL, or null if absent / not a trusted CDN */ - private function fetchGoodreadsCoverUrl(string $apiUrl): ?string + private function extractGoodreadsCoverFromHtml(string $html): ?string { - try { - $res = \App\Support\HttpClient::get( - $apiUrl, - ['Accept' => 'application/json'], - ['timeout' => 10, 'user_agent' => self::USER_AGENT] - ); - - // Gracefully handle non-200 responses - if (!$res['ok'] || $res['status'] !== 200 || empty($res['body'])) { - return null; - } - - $data = json_decode($res['body'], true); + // Open Graph cover image — match property/content in either order. + if (!preg_match('/]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']/i', $html, $m) + && !preg_match('/]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']/i', $html, $m)) { + return null; + } - // Extract URL from response - if (!empty($data['url']) && filter_var($data['url'], FILTER_VALIDATE_URL)) { - return $data['url']; - } + $url = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5); + if (filter_var($url, FILTER_VALIDATE_URL) === false) { + return null; + } + // Only trust covers served from the known Goodreads/Amazon image CDNs. + $scheme = parse_url($url, PHP_URL_SCHEME); + $host = parse_url($url, PHP_URL_HOST); + if (!is_string($scheme) || strtolower($scheme) !== 'https' || !is_string($host)) { return null; - } catch (\Throwable $e) { - \App\Support\SecureLogger::warning("[OpenLibrary] Error fetching Goodreads cover from $apiUrl: " . $e->getMessage()); + } + + $host = strtolower(rtrim($host, '.')); + $trustedHost = $host === 'gr-assets.com' + || str_ends_with($host, '.gr-assets.com') + || $host === 'media-amazon.com' + || str_ends_with($host, '.media-amazon.com'); + if (!$trustedHost) { return null; } + + return $url; } /** @@ -564,10 +485,9 @@ private function fetchGoodreadsCoverUrl(string $apiUrl): ?string * * @param string $isbn ISBN * @param array $editionData Edition data (optional) - * @param string $authorName Author name (optional, for Goodreads fallback) * @return string|null Cover URL or null */ - private function getCoverUrl(string $isbn, array $editionData = [], string $authorName = ''): ?string + private function getCoverUrl(string $isbn, array $editionData = []): ?string { // Try to get cover ID from edition data first if (!empty($editionData['covers'][0])) { @@ -584,11 +504,9 @@ private function getCoverUrl(string $isbn, array $editionData = [], string $auth return $url; } - // Third fallback: Try Goodreads via bookcover.longitood.com - $title = $editionData['title'] ?? ''; - - $goodreadsCover = $this->getGoodreadsCover($isbn, $title, $authorName); - if ($goodreadsCover) { + // Last resort: scrape the cover from Goodreads' public book page. + $goodreadsCover = $this->getGoodreadsCover($isbn); + if ($goodreadsCover !== null) { return $goodreadsCover; } @@ -609,7 +527,7 @@ private function checkCoverExists(string $url): bool CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 5, + CURLOPT_TIMEOUT => 3, CURLOPT_USERAGENT => self::USER_AGENT, ]); @@ -644,7 +562,7 @@ private function checkCoverExists(string $url): bool CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 5, + CURLOPT_TIMEOUT => 3, CURLOPT_USERAGENT => self::USER_AGENT, CURLOPT_RANGE => '0-1023', ]); diff --git a/storage/plugins/open-library/README.md b/storage/plugins/open-library/README.md index df8449be..b6868238 100644 --- a/storage/plugins/open-library/README.md +++ b/storage/plugins/open-library/README.md @@ -2,10 +2,13 @@ Plugin per l'integrazione delle API di Open Library (openlibrary.org) nel sistema di scraping della Biblioteca. +Requisiti: Pinakes 0.7.16 o successivo e PHP 8.2 o successivo. + ## Caratteristiche - **Scraping completo via API**: Utilizza le API ufficiali di Open Library invece dello scraping HTML - **Copertine ad alta qualità**: Accesso diretto alle copertine in alta risoluzione +- **Fallback Goodreads sicuro**: Se Open Library non dispone della copertina, legge `og:image` dalla pagina ISBN pubblica di Goodreads e accetta soltanto URL HTTPS dei CDN Goodreads/Amazon autorizzati - **Dati arricchiti**: Include informazioni su opere, edizioni e autori - **Multilingua**: Supporta libri in tutte le lingue disponibili su Open Library - **Alta priorità**: Configurato con priorità 5 (alta) per essere preferito rispetto ad altre fonti @@ -18,6 +21,7 @@ Il plugin integra le seguenti API di Open Library: 2. **Works API** - `/works/{id}.json` - Informazioni sull'opera 3. **Authors API** - `/authors/{id}.json` - Dettagli autori 4. **Covers API** - `https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg` - Copertine +5. **Goodreads ISBN page** - `https://www.goodreads.com/book/isbn/{isbn}` - Fallback copertina tramite metadato Open Graph ## Dati Forniti diff --git a/storage/plugins/open-library/plugin.json b/storage/plugins/open-library/plugin.json index 08c1a759..39595322 100644 --- a/storage/plugins/open-library/plugin.json +++ b/storage/plugins/open-library/plugin.json @@ -1,15 +1,14 @@ { "name": "open-library", "display_name": "Open Library Scraper", - "description": "Integrazione con le API di Open Library (openlibrary.org) per lo scraping di metadati dei libri. Fornisce dati completi su edizioni, opere, autori e copertine ad alta risoluzione.", - "version": "1.0.1", + "description": "Integrazione con le API di Open Library (openlibrary.org) per lo scraping di metadati dei libri. Fornisce dati completi su edizioni, opere, autori e copertine ad alta risoluzione, con copertine di fallback recuperate dalla pagina pubblica di Goodreads.", + "version": "1.0.2", "author": "Fabiodalez", "author_url": "", "plugin_url": "https://openlibrary.org", "main_file": "wrapper.php", - "requires_php": "7.4", - "requires_app": "0.4.0", - "max_app_version": "0.5.9.6", + "requires_php": "8.2", + "requires_app": "0.7.16", "metadata": { "category": "scraping", "tags": [ @@ -17,14 +16,16 @@ "openlibrary", "scraping", "books", - "covers" + "covers", + "goodreads" ], "priority": 5, "api_endpoints": [ "https://openlibrary.org/isbn/{isbn}.json", "https://openlibrary.org/works/{id}.json", "https://openlibrary.org/authors/{id}.json", - "https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg" + "https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg", + "https://www.goodreads.com/book/isbn/{isbn}" ], "hooks": [ { @@ -49,6 +50,7 @@ "features": [ "Scraping completo via API REST", "Copertine ad alta risoluzione", + "Copertine di fallback da Goodreads (og:image)", "Informazioni su opere e edizioni", "Dati autori completi", "Supporto multilingua", diff --git a/tests/full-test.spec.js b/tests/full-test.spec.js index 64acf658..07720811 100644 --- a/tests/full-test.spec.js +++ b/tests/full-test.spec.js @@ -260,6 +260,7 @@ async function requestLoanViaSwal(page, dateISO) { const el = document.querySelector('#swal-date-start'); return el && /** @type {any} */ (el)._flatpickr; }, + undefined, { timeout: 8000 }, ); @@ -381,6 +382,7 @@ test.describe.serial('Phase 1: Installation (Italian)', () => { const el = document.getElementById('connection-result'); return el && el.style.display !== 'none' && el.textContent.trim().length > 0; }, + undefined, { timeout: 30000 } ); const resultClass = await page.locator('#connection-result').getAttribute('class'); @@ -630,14 +632,14 @@ test.describe.serial('Phase 3: Manual Book Creation', () => { await page.waitForFunction(() => { const sel = document.querySelector('#radice_select'); return sel && sel.options.length > 1; - }, { timeout: 10000 }); + }, undefined, { timeout: 10000 }); await radiceSelect.selectOption({ index: 1 }); // Wait for L2 to load await page.waitForFunction(() => { const sel = document.getElementById('genere_select'); return sel && !sel.disabled && sel.options.length > 1; - }, { timeout: 10000 }).catch(() => {}); + }, undefined, { timeout: 10000 }).catch(() => {}); const genereSelect = page.locator('#genere_select'); if (!await genereSelect.isDisabled()) { @@ -651,7 +653,7 @@ test.describe.serial('Phase 3: Manual Book Creation', () => { const l3Populated = await page.waitForFunction(() => { const sel = document.getElementById('sottogenere_select'); return sel && !sel.disabled && sel.options.length > 1; - }, { timeout: 5000 }).then(() => true).catch(() => false); + }, undefined, { timeout: 5000 }).then(() => true).catch(() => false); if (l3Populated) { // Mandatory selection — no silent swallow. If selection fails here @@ -764,11 +766,16 @@ test.describe.serial('Phase 4: ISBN Scraping', () => { await importInput.fill('9788845292613'); await importBtn.click(); // Wait for title field to be populated or for a timeout (external API may fail) + // Playwright signature is waitForFunction(fn, arg, options) — the options + // object MUST be the third argument. Passing it in the second (arg) slot + // silently drops the 15s timeout, so if scraping doesn't populate the + // title (degraded external APIs) the wait hangs until the 120s test timeout. await page.waitForFunction( () => { const el = document.querySelector('#titolo'); return el && el.value && el.value.length > 0; }, + undefined, { timeout: 15000 }, ).catch(() => {}); await expect(page.locator('#titolo')).toBeVisible(); @@ -799,7 +806,7 @@ test.describe.serial('Phase 4: ISBN Scraping', () => { await page.waitForFunction(() => { const sel = document.querySelector('#radice_select'); return sel && sel.options.length > 1; - }, { timeout: 10000 }).catch(() => {}); + }, undefined, { timeout: 10000 }).catch(() => {}); const currentVal = await radiceSelect.inputValue(); if (!currentVal || currentVal === '0') { await radiceSelect.selectOption({ index: 1 }).catch(() => {}); @@ -1015,7 +1022,7 @@ test.describe.serial('Phase 5: Scraping-Pro Plugin', () => { await page.waitForFunction(() => { const titleInput = document.querySelector('input[name="titolo"]'); return titleInput && titleInput.value && titleInput.value.trim().length > 0; - }, { timeout: 30000 }); + }, undefined, { timeout: 30000 }); } catch { // External API may be down — acceptable } @@ -1033,7 +1040,7 @@ test.describe.serial('Phase 5: Scraping-Pro Plugin', () => { await page.waitForFunction(() => { const sel = document.querySelector('#radice_select'); return sel && sel.options.length > 1; - }, { timeout: 10000 }).catch(() => {}); + }, undefined, { timeout: 10000 }).catch(() => {}); const currentVal = await radiceSelect.inputValue(); if (!currentVal || currentVal === '0') { await radiceSelect.selectOption({ index: 1 }).catch(() => {}); @@ -1086,6 +1093,7 @@ test.describe.serial('Phase 6: Edit Book', () => { const el = document.querySelector('#titolo'); return el && el.value && el.value.length > 0; }, + undefined, { timeout: 10000 }, ); }); @@ -1363,6 +1371,7 @@ test.describe.serial('Phase 7: Author Management', () => { const icon = document.querySelector('.swal2-popup .swal2-icon.swal2-success'); return icon !== null; }, + undefined, { timeout: 15000 }, ).catch(() => {}); await page.keyboard.press('Enter').catch(() => {}); @@ -3478,6 +3487,7 @@ async function createBookWithRelations(page, { title, authors = [], publishers = if (await radiceSelect.isVisible({ timeout: 2000 }).catch(() => false)) { await page.waitForFunction( () => { const s = document.querySelector('#radice_select'); return s && s.options.length > 1; }, + undefined, { timeout: 8000 }, ).catch(() => {}); await radiceSelect.selectOption({ index: 1 }).catch(() => {}); diff --git a/tests/plugin-goodreads-cover.unit.php b/tests/plugin-goodreads-cover.unit.php new file mode 100644 index 00000000..2d7d139c --- /dev/null +++ b/tests/plugin-goodreads-cover.unit.php @@ -0,0 +1,80 @@ +setAccessible(true); +$coverM = new ReflectionMethod($plugin, 'getGoodreadsCover'); +$coverM->setAccessible(true); +$extract = static fn (string $html) => $extractM->invoke($plugin, $html); +$cover = static fn (string $isbn) => $coverM->invoke($plugin, $isbn); + +echo "A. extractGoodreadsCoverFromHtml — og:image parsing (deterministic)\n"; +$amazon = 'https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1327269904i/9661681.jpg'; +$check($extract('') === $amazon, 'Amazon/Goodreads CDN og:image extracted'); +$check($extract('') === 'https://i.gr-assets.com/books/1.jpg', 'gr-assets.com CDN accepted'); +$check($extract('') === 'https://i.gr-assets.com/books/2.jpg', 'reversed content/property attribute order handled'); +$check($extract("") === 'https://i.gr-assets.com/books/3.jpg', 'single-quoted attributes handled'); +$check($extract('') === 'https://m.media-amazon.com/a.jpg?a=1&b=2', 'HTML entities in URL decoded'); + +echo "B. extractGoodreadsCoverFromHtml — rejects untrusted / invalid (security)\n"; +$check($extract('') === null, 'untrusted host rejected (anti-spoof/SSRF)'); +$check($extract('') === null, 'look-alike host (suffix trick) rejected'); +$check($extract('') === null, 'look-alike host without label boundary rejected'); +$check($extract('') === null, 'non-HTTPS cover rejected'); +$check($extract('') === null, 'non-HTTP cover scheme rejected'); +$check($extract('') === null, 'malformed URL rejected'); +$check($extract('no cover here') === null, 'no og:image tag → null'); +$check($extract('') === null, 'empty HTML → null'); + +echo "C. getGoodreadsCover — ISBN validation (no network request)\n"; +$check($cover('abc') === null, 'non-numeric ISBN → null'); +$check($cover('123') === null, 'too-short code → null'); +$check($cover('') === null, 'empty ISBN → null'); +$check($cover('97888452926131234') === null, 'over-long code → null'); + +echo "D. Live Goodreads scrape (best-effort — skipped if unreachable)\n"; +$reachable = @get_headers('https://www.goodreads.com/', true); +if ($reachable === false) { + echo " SKIP Goodreads unreachable — live check not run (not a failure)\n"; +} else { + $live = $cover('9788845292613'); // Il Signore degli Anelli — known to be on Goodreads + if ($live === null) { + echo " SKIP live scrape returned null (rate-limit/transient) — not counted as failure\n"; + } else { + $host = parse_url($live, PHP_URL_HOST) ?: ''; + $trustedHost = $host === 'gr-assets.com' + || str_ends_with($host, '.gr-assets.com') + || $host === 'media-amazon.com' + || str_ends_with($host, '.media-amazon.com'); + $check(is_string($live) && str_starts_with($live, 'https://') && $trustedHost, "live HTTPS cover from trusted CDN: {$host}"); + } +} + +echo "\n{$pass} PASS, {$fail} FAIL\n"; +exit($fail === 0 ? 0 : 1); diff --git a/version.json b/version.json index b9ef8498..4c723781 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "name": "Pinakes", - "version": "0.7.44", + "version": "0.7.45", "description": "Library Management System - Sistema di Gestione Bibliotecaria" } From a9405f8e5398d1db9e8f6b16ab10e6633dffc3a0 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 29 Jul 2026 09:14:50 +0200 Subject: [PATCH 6/9] fix(#304): resolve {{pickup_deadline}} in loan pickup email templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loan_pickup_ready / loan_pickup_expired notifications only exposed the pickup deadline under {{scadenza_ritiro}}. A customised template using {{pickup_deadline}} — the natural name a user copies from the DB column — left the placeholder unresolved. I now pass the value under both names and document {{pickup_deadline}} in each template's placeholder list. --- app/Support/NotificationService.php | 8 +++++++- app/Support/SettingsMailTemplates.php | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index 320c540e..01e06542 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -1098,6 +1098,10 @@ public function sendPickupReadyNotification(int $loanId): bool { 'data_fine' => $this->formatEmailDate($loan['data_scadenza']), 'giorni_prestito' => $days, 'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline']) : '', + // #304: alias under the DB column name so a customised template using + // {{pickup_deadline}} (the natural name a user copies from the schema) + // resolves as well as the canonical {{scadenza_ritiro}}. + 'pickup_deadline' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline']) : '', 'pickup_instructions' => __('Recati in biblioteca durante gli orari di apertura per ritirare il libro.') ]; @@ -1135,7 +1139,9 @@ public function sendPickupExpiredNotification(int $loanId): bool { $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline']) : '' + 'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline']) : '', + // #304: alias under the DB column name, see sendPickupReadyNotification. + 'pickup_deadline' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline']) : '' ]; return $this->emailService->sendTemplate($loan['utente_email'], 'loan_pickup_expired', $variables, \App\Support\I18n::getInstallationLocale()); diff --git a/app/Support/SettingsMailTemplates.php b/app/Support/SettingsMailTemplates.php index 586a00ea..8c3557c2 100644 --- a/app/Support/SettingsMailTemplates.php +++ b/app/Support/SettingsMailTemplates.php @@ -281,7 +281,7 @@ public static function all(?string $locale = null): array 'label' => __('Pronto per il ritiro'), 'description' => __("Inviata quando un prestito è stato approvato e il libro è pronto per il ritiro."), 'subject' => '📦 Libro pronto per il ritiro!', - 'placeholders' => ['utente_nome', 'libro_titolo', 'data_inizio', 'data_fine', 'giorni_prestito', 'scadenza_ritiro', 'pickup_instructions'], + 'placeholders' => ['utente_nome', 'libro_titolo', 'data_inizio', 'data_fine', 'giorni_prestito', 'scadenza_ritiro', 'pickup_deadline', 'pickup_instructions'], 'body' => <<<'HTML'

Il tuo libro è pronto per il ritiro!

Ciao {{utente_nome}},

@@ -306,7 +306,7 @@ public static function all(?string $locale = null): array 'label' => __('Ritiro scaduto'), 'description' => __("Inviata quando il tempo per ritirare un libro è scaduto e il prestito è stato annullato."), 'subject' => '⏰ Tempo per il ritiro scaduto', - 'placeholders' => ['utente_nome', 'libro_titolo', 'scadenza_ritiro'], + 'placeholders' => ['utente_nome', 'libro_titolo', 'scadenza_ritiro', 'pickup_deadline'], 'body' => <<<'HTML'

Tempo per il ritiro scaduto

Ciao {{utente_nome}},

From 2c33c0586db45afc4904613cdfe5cf06c0d59b95 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 29 Jul 2026 09:52:07 +0200 Subject: [PATCH 7/9] feat(#304): localised descriptions (tooltips) for email template placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin email-template editor listed each {{token}} as a bare technical name with no explanation, and nothing translated per locale. I added SettingsMailTemplates::placeholderDescriptions() — a localised, human-readable description for all 41 placeholder tokens — and the settings view now shows it as a tooltip on each placeholder chip. The tokens stay untranslated (they are substitution keys); only the descriptions are localised, across it/en/de/da/fr. --- app/Support/SettingsMailTemplates.php | 55 +++++++++++++++++++++++++++ app/Views/settings/index.php | 4 +- locale/da_DK.json | 42 +++++++++++++++++++- locale/de_DE.json | 42 +++++++++++++++++++- locale/en_US.json | 42 +++++++++++++++++++- locale/fr_FR.json | 42 +++++++++++++++++++- locale/it_IT.json | 42 +++++++++++++++++++- 7 files changed, 263 insertions(+), 6 deletions(-) diff --git a/app/Support/SettingsMailTemplates.php b/app/Support/SettingsMailTemplates.php index 8c3557c2..04db8d74 100644 --- a/app/Support/SettingsMailTemplates.php +++ b/app/Support/SettingsMailTemplates.php @@ -553,4 +553,59 @@ public static function keys(): array { return array_keys(self::all()); } + + /** + * Localised human-readable descriptions for every placeholder token, keyed + * by token name. The settings UI shows these as a translated tooltip next to + * each {{token}} chip (issue #304). The tokens themselves are substitution + * keys and are never translated; only the description is localised. + * + * @return array + */ + public static function placeholderDescriptions(): array + { + return [ + 'admin_users_url' => __('Link alla gestione utenti (area amministrazione)'), + 'app_name' => __('Nome della biblioteca o applicazione'), + 'approve_url' => __('Link per approvare la richiesta'), + 'book_url' => __('Link alla scheda del libro'), + 'codice_tessera' => __('Codice tessera dell\'utente'), + 'cognome' => __('Cognome dell\'utente'), + 'dashboard_url' => __('Link alla dashboard'), + 'data_disponibilita' => __('Data in cui il libro torna disponibile'), + 'data_fine' => __('Data di fine del prestito'), + 'data_inizio' => __('Data di inizio del prestito'), + 'data_prestito' => __('Data del prestito'), + 'data_recensione' => __('Data della recensione'), + 'data_registrazione' => __('Data di registrazione dell\'utente'), + 'data_restituzione' => __('Data di restituzione del libro'), + 'data_richiesta' => __('Data della richiesta'), + 'data_scadenza' => __('Data di scadenza del prestito'), + 'descrizione_recensione' => __('Testo della recensione'), + 'email' => __('Indirizzo email dell\'utente'), + 'giorni_prestito' => __('Durata del prestito in giorni'), + 'giorni_rimasti' => __('Giorni rimanenti alla scadenza'), + 'giorni_ritardo' => __('Giorni di ritardo nella restituzione'), + 'libro_autore' => __('Autore del libro'), + 'libro_isbn' => __('ISBN del libro'), + 'libro_titolo' => __('Titolo del libro'), + 'link_approvazione' => __('Link per approvare la richiesta di prestito'), + 'login_url' => __('Link alla pagina di accesso'), + 'motivo' => __('Motivo'), + 'motivo_rifiuto' => __('Motivo del rifiuto'), + 'nome' => __('Nome dell\'utente'), + 'pickup_deadline' => __('Scadenza per il ritiro del libro'), + 'pickup_instructions' => __('Istruzioni per il ritiro del libro'), + 'prestito_id' => __('Identificativo del prestito'), + 'profile_url' => __('Link al profilo dell\'utente'), + 'reset_url' => __('Link per reimpostare la password'), + 'scadenza_ritiro' => __('Scadenza entro cui ritirare il libro'), + 'sezione_verifica' => __('Sezione di verifica dell\'account'), + 'stelle' => __('Valutazione in stelle'), + 'titolo_recensione' => __('Titolo della recensione'), + 'utente_email' => __('Email dell\'utente'), + 'utente_nome' => __('Nome completo dell\'utente'), + 'wishlist_url' => __('Link alla lista dei desideri'), + ]; + } } diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index 6b6d814c..8aa50e04 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -512,10 +512,12 @@ class="flex-1 min-w-[10rem] px-3 py-2 rounded-lg border border-gray-300 bg-white

+
- {{}} + + >{{}}
diff --git a/locale/da_DK.json b/locale/da_DK.json index 6b4ecafd..1dcf4036 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -6612,5 +6612,45 @@ "Solo gli amministratori possono visualizzare e gestire le API key.": "Kun administratorer kan se og administrere API-nøgler.", "Prenotati": "Reserverede", "Attualmente riservati": "I øjeblikket reserveret", - "Tutto il catalogo": "Hele kataloget" + "Tutto il catalogo": "Hele kataloget", + "Link alla gestione utenti (area amministrazione)": "Link til brugeradministration (adminområde)", + "Nome della biblioteca o applicazione": "Navn på biblioteket eller applikationen", + "Link per approvare la richiesta": "Link til at godkende anmodningen", + "Link alla scheda del libro": "Link til bogsiden", + "Codice tessera dell'utente": "Brugerens kortnummer", + "Cognome dell'utente": "Brugerens efternavn", + "Link alla dashboard": "Link til dashboardet", + "Data in cui il libro torna disponibile": "Dato hvor bogen bliver tilgængelig igen", + "Data di fine del prestito": "Slutdato for lånet", + "Data di inizio del prestito": "Startdato for lånet", + "Data del prestito": "Lånedato", + "Data della recensione": "Anmeldelsesdato", + "Data di registrazione dell'utente": "Brugerens registreringsdato", + "Data di restituzione del libro": "Bogens afleveringsdato", + "Data della richiesta": "Anmodningsdato", + "Data di scadenza del prestito": "Lånets forfaldsdato", + "Testo della recensione": "Anmeldelsestekst", + "Indirizzo email dell'utente": "Brugerens e-mailadresse", + "Durata del prestito in giorni": "Lånevarighed i dage", + "Giorni rimanenti alla scadenza": "Dage tilbage til forfald", + "Giorni di ritardo nella restituzione": "Dage for sen aflevering", + "Autore del libro": "Bogens forfatter", + "ISBN del libro": "Bogens ISBN", + "Link per approvare la richiesta di prestito": "Link til at godkende låneanmodningen", + "Link alla pagina di accesso": "Link til login-siden", + "Motivo": "Årsag", + "Motivo del rifiuto": "Årsag til afvisning", + "Nome dell'utente": "Brugerens navn", + "Scadenza per il ritiro del libro": "Frist for afhentning af bogen", + "Istruzioni per il ritiro del libro": "Instruktioner til afhentning af bogen", + "Identificativo del prestito": "Låne-id", + "Link al profilo dell'utente": "Link til brugerens profil", + "Link per reimpostare la password": "Link til at nulstille adgangskoden", + "Scadenza entro cui ritirare il libro": "Frist for hvornår bogen skal afhentes", + "Sezione di verifica dell'account": "Sektion til kontoverifikation", + "Valutazione in stelle": "Stjernebedømmelse", + "Titolo della recensione": "Anmeldelsens titel", + "Email dell'utente": "Brugerens e-mail", + "Nome completo dell'utente": "Brugerens fulde navn", + "Link alla lista dei desideri": "Link til ønskelisten" } diff --git a/locale/de_DE.json b/locale/de_DE.json index b4ae0c8a..229631a6 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6612,5 +6612,45 @@ "Solo gli amministratori possono visualizzare e gestire le API key.": "Nur Administratoren können API-Schlüssel anzeigen und verwalten.", "Prenotati": "Vorgemerkt", "Attualmente riservati": "Derzeit vorgemerkt", - "Tutto il catalogo": "Gesamter Katalog" + "Tutto il catalogo": "Gesamter Katalog", + "Link alla gestione utenti (area amministrazione)": "Link zur Benutzerverwaltung (Admin-Bereich)", + "Nome della biblioteca o applicazione": "Name der Bibliothek oder Anwendung", + "Link per approvare la richiesta": "Link zum Genehmigen der Anfrage", + "Link alla scheda del libro": "Link zur Buchseite", + "Codice tessera dell'utente": "Ausweisnummer des Benutzers", + "Cognome dell'utente": "Nachname des Benutzers", + "Link alla dashboard": "Link zum Dashboard", + "Data in cui il libro torna disponibile": "Datum, an dem das Buch wieder verfügbar ist", + "Data di fine del prestito": "Enddatum der Ausleihe", + "Data di inizio del prestito": "Startdatum der Ausleihe", + "Data del prestito": "Ausleihdatum", + "Data della recensione": "Datum der Rezension", + "Data di registrazione dell'utente": "Registrierungsdatum des Benutzers", + "Data di restituzione del libro": "Rückgabedatum des Buches", + "Data della richiesta": "Datum der Anfrage", + "Data di scadenza del prestito": "Fälligkeitsdatum der Ausleihe", + "Testo della recensione": "Text der Rezension", + "Indirizzo email dell'utente": "E-Mail-Adresse des Benutzers", + "Durata del prestito in giorni": "Ausleihdauer in Tagen", + "Giorni rimanenti alla scadenza": "Verbleibende Tage bis zur Fälligkeit", + "Giorni di ritardo nella restituzione": "Tage Verzug bei der Rückgabe", + "Autore del libro": "Autor des Buches", + "ISBN del libro": "ISBN des Buches", + "Link per approvare la richiesta di prestito": "Link zum Genehmigen der Ausleihanfrage", + "Link alla pagina di accesso": "Link zur Anmeldeseite", + "Motivo": "Grund", + "Motivo del rifiuto": "Ablehnungsgrund", + "Nome dell'utente": "Name des Benutzers", + "Scadenza per il ritiro del libro": "Frist für die Abholung des Buches", + "Istruzioni per il ritiro del libro": "Anweisungen zur Abholung des Buches", + "Identificativo del prestito": "Ausleih-Kennung", + "Link al profilo dell'utente": "Link zum Benutzerprofil", + "Link per reimpostare la password": "Link zum Zurücksetzen des Passworts", + "Scadenza entro cui ritirare il libro": "Frist, bis zu der das Buch abgeholt werden muss", + "Sezione di verifica dell'account": "Bereich zur Kontoverifizierung", + "Valutazione in stelle": "Sternebewertung", + "Titolo della recensione": "Titel der Rezension", + "Email dell'utente": "E-Mail des Benutzers", + "Nome completo dell'utente": "Vollständiger Name des Benutzers", + "Link alla lista dei desideri": "Link zur Wunschliste" } diff --git a/locale/en_US.json b/locale/en_US.json index a1f43a2b..119c8383 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6612,5 +6612,45 @@ "Solo gli amministratori possono visualizzare e gestire le API key.": "Only administrators can view and manage API keys.", "Prenotati": "Reserved", "Attualmente riservati": "Currently reserved", - "Tutto il catalogo": "Whole catalogue" + "Tutto il catalogo": "Whole catalogue", + "Link alla gestione utenti (area amministrazione)": "Link to user management (admin area)", + "Nome della biblioteca o applicazione": "Name of the library or application", + "Link per approvare la richiesta": "Link to approve the request", + "Link alla scheda del libro": "Link to the book page", + "Codice tessera dell'utente": "User's card number", + "Cognome dell'utente": "User's surname", + "Link alla dashboard": "Link to the dashboard", + "Data in cui il libro torna disponibile": "Date the book becomes available again", + "Data di fine del prestito": "Loan end date", + "Data di inizio del prestito": "Loan start date", + "Data del prestito": "Loan date", + "Data della recensione": "Review date", + "Data di registrazione dell'utente": "User's registration date", + "Data di restituzione del libro": "Book return date", + "Data della richiesta": "Request date", + "Data di scadenza del prestito": "Loan due date", + "Testo della recensione": "Review text", + "Indirizzo email dell'utente": "User's email address", + "Durata del prestito in giorni": "Loan duration in days", + "Giorni rimanenti alla scadenza": "Days remaining until the due date", + "Giorni di ritardo nella restituzione": "Days overdue on the return", + "Autore del libro": "Book author", + "ISBN del libro": "Book ISBN", + "Link per approvare la richiesta di prestito": "Link to approve the loan request", + "Link alla pagina di accesso": "Link to the login page", + "Motivo": "Reason", + "Motivo del rifiuto": "Reason for rejection", + "Nome dell'utente": "User's name", + "Scadenza per il ritiro del libro": "Deadline to pick up the book", + "Istruzioni per il ritiro del libro": "Instructions for picking up the book", + "Identificativo del prestito": "Loan identifier", + "Link al profilo dell'utente": "Link to the user's profile", + "Link per reimpostare la password": "Link to reset the password", + "Scadenza entro cui ritirare il libro": "Deadline by which to pick up the book", + "Sezione di verifica dell'account": "Account verification section", + "Valutazione in stelle": "Star rating", + "Titolo della recensione": "Review title", + "Email dell'utente": "User's email", + "Nome completo dell'utente": "User's full name", + "Link alla lista dei desideri": "Link to the wishlist" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 96673e4f..97045bd6 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6612,5 +6612,45 @@ "Solo gli amministratori possono visualizzare e gestire le API key.": "Seuls les administrateurs peuvent voir et gérer les clés API.", "Prenotati": "Réservés", "Attualmente riservati": "Actuellement réservés", - "Tutto il catalogo": "Tout le catalogue" + "Tutto il catalogo": "Tout le catalogue", + "Link alla gestione utenti (area amministrazione)": "Lien vers la gestion des utilisateurs (zone admin)", + "Nome della biblioteca o applicazione": "Nom de la bibliothèque ou de l'application", + "Link per approvare la richiesta": "Lien pour approuver la demande", + "Link alla scheda del libro": "Lien vers la fiche du livre", + "Codice tessera dell'utente": "Numéro de carte de l'utilisateur", + "Cognome dell'utente": "Nom de famille de l'utilisateur", + "Link alla dashboard": "Lien vers le tableau de bord", + "Data in cui il libro torna disponibile": "Date à laquelle le livre redevient disponible", + "Data di fine del prestito": "Date de fin du prêt", + "Data di inizio del prestito": "Date de début du prêt", + "Data del prestito": "Date du prêt", + "Data della recensione": "Date de l'avis", + "Data di registrazione dell'utente": "Date d'inscription de l'utilisateur", + "Data di restituzione del libro": "Date de retour du livre", + "Data della richiesta": "Date de la demande", + "Data di scadenza del prestito": "Date d'échéance du prêt", + "Testo della recensione": "Texte de l'avis", + "Indirizzo email dell'utente": "Adresse e-mail de l'utilisateur", + "Durata del prestito in giorni": "Durée du prêt en jours", + "Giorni rimanenti alla scadenza": "Jours restants avant l'échéance", + "Giorni di ritardo nella restituzione": "Jours de retard pour le retour", + "Autore del libro": "Auteur du livre", + "ISBN del libro": "ISBN du livre", + "Link per approvare la richiesta di prestito": "Lien pour approuver la demande de prêt", + "Link alla pagina di accesso": "Lien vers la page de connexion", + "Motivo": "Motif", + "Motivo del rifiuto": "Motif du refus", + "Nome dell'utente": "Nom de l'utilisateur", + "Scadenza per il ritiro del libro": "Date limite pour retirer le livre", + "Istruzioni per il ritiro del libro": "Instructions pour retirer le livre", + "Identificativo del prestito": "Identifiant du prêt", + "Link al profilo dell'utente": "Lien vers le profil de l'utilisateur", + "Link per reimpostare la password": "Lien pour réinitialiser le mot de passe", + "Scadenza entro cui ritirare il libro": "Date limite pour retirer le livre", + "Sezione di verifica dell'account": "Section de vérification du compte", + "Valutazione in stelle": "Note en étoiles", + "Titolo della recensione": "Titre de l'avis", + "Email dell'utente": "E-mail de l'utilisateur", + "Nome completo dell'utente": "Nom complet de l'utilisateur", + "Link alla lista dei desideri": "Lien vers la liste de souhaits" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 57f82ef1..911ccc22 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6612,5 +6612,45 @@ "Solo gli amministratori possono visualizzare e gestire le API key.": "Solo gli amministratori possono visualizzare e gestire le API key.", "Prenotati": "Prenotati", "Attualmente riservati": "Attualmente riservati", - "Tutto il catalogo": "Tutto il catalogo" + "Tutto il catalogo": "Tutto il catalogo", + "Link alla gestione utenti (area amministrazione)": "Link alla gestione utenti (area amministrazione)", + "Nome della biblioteca o applicazione": "Nome della biblioteca o applicazione", + "Link per approvare la richiesta": "Link per approvare la richiesta", + "Link alla scheda del libro": "Link alla scheda del libro", + "Codice tessera dell'utente": "Codice tessera dell'utente", + "Cognome dell'utente": "Cognome dell'utente", + "Link alla dashboard": "Link alla dashboard", + "Data in cui il libro torna disponibile": "Data in cui il libro torna disponibile", + "Data di fine del prestito": "Data di fine del prestito", + "Data di inizio del prestito": "Data di inizio del prestito", + "Data del prestito": "Data del prestito", + "Data della recensione": "Data della recensione", + "Data di registrazione dell'utente": "Data di registrazione dell'utente", + "Data di restituzione del libro": "Data di restituzione del libro", + "Data della richiesta": "Data della richiesta", + "Data di scadenza del prestito": "Data di scadenza del prestito", + "Testo della recensione": "Testo della recensione", + "Indirizzo email dell'utente": "Indirizzo email dell'utente", + "Durata del prestito in giorni": "Durata del prestito in giorni", + "Giorni rimanenti alla scadenza": "Giorni rimanenti alla scadenza", + "Giorni di ritardo nella restituzione": "Giorni di ritardo nella restituzione", + "Autore del libro": "Autore del libro", + "ISBN del libro": "ISBN del libro", + "Link per approvare la richiesta di prestito": "Link per approvare la richiesta di prestito", + "Link alla pagina di accesso": "Link alla pagina di accesso", + "Motivo": "Motivo", + "Motivo del rifiuto": "Motivo del rifiuto", + "Nome dell'utente": "Nome dell'utente", + "Scadenza per il ritiro del libro": "Scadenza per il ritiro del libro", + "Istruzioni per il ritiro del libro": "Istruzioni per il ritiro del libro", + "Identificativo del prestito": "Identificativo del prestito", + "Link al profilo dell'utente": "Link al profilo dell'utente", + "Link per reimpostare la password": "Link per reimpostare la password", + "Scadenza entro cui ritirare il libro": "Scadenza entro cui ritirare il libro", + "Sezione di verifica dell'account": "Sezione di verifica dell'account", + "Valutazione in stelle": "Valutazione in stelle", + "Titolo della recensione": "Titolo della recensione", + "Email dell'utente": "Email dell'utente", + "Nome completo dell'utente": "Nome completo dell'utente", + "Link alla lista dei desideri": "Link alla lista dei desideri" } From a33f2b10ba1ceb10ddf9cd3c0befea9f55c4870e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 29 Jul 2026 10:16:58 +0200 Subject: [PATCH 8/9] fix(#303 review): consistent language stats, robust badge, code hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings on this branch: 1. The language *edit* page rendered stored (stale) total_keys/translated_keys while the *list* page rendered source-derived stats — the same locale showed two different denominators (en_US 4653 vs 6654). Added Language::getByCodeWithDerivedStats() (shared helper with getAllWithDerivedStats) and routed edit() through it. 2. Dropped the hardcoded "6613" from a controller comment (already stale at 6654); the denominator is derived at runtime and must not be cited as a literal. 3. Catalogue card badge: "In prestito" now shows ONLY for stato='prestato'; every other not-available case (non_disponibile, or a stale stato on a zero-copy book) falls to "Non disponibile" instead of being mislabelled as on loan. 4. Language::translatedKeyCount validates the locale code shape (^[a-z]{2}_[A-Z]{2}$) before interpolating it into a filesystem path — defence-in-depth against traversal, since the code comes from the DB. Verified: PHPStan clean, language-derived-stats 17/17, catalog-reserved-category 13/13 (badges unchanged for the asserted cases). --- app/Controllers/Admin/LanguagesController.php | 6 ++- app/Models/Language.php | 48 +++++++++++++++++-- app/Views/frontend/catalog-grid.php | 16 +++---- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/app/Controllers/Admin/LanguagesController.php b/app/Controllers/Admin/LanguagesController.php index 2e9f5534..be3a1cfb 100644 --- a/app/Controllers/Admin/LanguagesController.php +++ b/app/Controllers/Admin/LanguagesController.php @@ -34,7 +34,7 @@ public function index(Request $request, Response $response, \mysqli $db, array $ { $languageModel = new Language($db); // Stats derived live from the Italian source (see Language::getAllWithDerivedStats) - // so total_keys is the same 6613-key denominator for every locale and can't drift. + // so total_keys is the same source-derived denominator for every locale and can't drift. $languages = $languageModel->getAllWithDerivedStats(); // Render view content @@ -167,7 +167,9 @@ public function edit(Request $request, Response $response, \mysqli $db, array $a ->withStatus(302); } $languageModel = new Language($db); - $language = $languageModel->getByCode($code); + // Derive the completion stats from the Italian source (same as the list + // view) so the edit page can't show a different denominator (#303 review). + $language = $languageModel->getByCodeWithDerivedStats($code); if (!$language) { $_SESSION['flash_error'] = __("Lingua non trovata"); diff --git a/app/Models/Language.php b/app/Models/Language.php index 4a09714a..1fb1d84d 100644 --- a/app/Models/Language.php +++ b/app/Models/Language.php @@ -72,6 +72,13 @@ private function translatedKeyCount(string $code, int $sourceTotal): int if ($code === self::SOURCE_LOCALE) { return $sourceTotal; } + // Defence-in-depth: $code originates from the languages.code DB column and + // is interpolated into a filesystem path, so require the canonical locale + // shape before touching disk — a crafted value can never traverse out of + // the locale/ directory (#303 review). + if (!preg_match('/^[a-z]{2}_[A-Z]{2}$/', $code)) { + return 0; + } $path = __DIR__ . '/../../locale/' . $code . '.json'; if (!is_file($path)) { return 0; @@ -103,15 +110,48 @@ public function getAllWithDerivedStats(bool $activeOnly = false): array $total = $this->sourceKeyCount(); $languages = $this->getAll($activeOnly); foreach ($languages as &$lang) { - $translated = $this->translatedKeyCount((string) ($lang['code'] ?? ''), $total); - $lang['total_keys'] = $total; - $lang['translated_keys'] = $translated; - $lang['completion_percentage'] = $total > 0 ? round($translated / $total * 100, 2) : 0.00; + $lang = $this->withDerivedStats($lang, $total); } unset($lang); return $languages; } + /** + * getByCode(), but with the same source-derived translation stats as + * getAllWithDerivedStats(). Use this on any admin surface that displays a + * single language's completion so the list and edit views cannot disagree. + * + * @param string $code + * @return array|null + */ + public function getByCodeWithDerivedStats(string $code): ?array + { + $language = $this->getByCode($code); + if ($language === null) { + return null; + } + return $this->withDerivedStats($language, $this->sourceKeyCount()); + } + + /** + * Overlay source-derived translation stats onto a single language row: + * total_keys = source key count, translated_keys = what the locale covers, + * completion_percentage recomputed. Single source of truth for both the + * list (getAllWithDerivedStats) and edit (getByCodeWithDerivedStats) views. + * + * @param array $lang + * @param int $total Source key count (denominator) + * @return array + */ + private function withDerivedStats(array $lang, int $total): array + { + $translated = $this->translatedKeyCount((string) ($lang['code'] ?? ''), $total); + $lang['total_keys'] = $total; + $lang['translated_keys'] = $translated; + $lang['completion_percentage'] = $total > 0 ? round($translated / $total * 100, 2) : 0.00; + return $lang; + } + /** * Get all languages * diff --git a/app/Views/frontend/catalog-grid.php b/app/Views/frontend/catalog-grid.php index 419785ea..70e7ba27 100644 --- a/app/Views/frontend/catalog-grid.php +++ b/app/Views/frontend/catalog-grid.php @@ -9,19 +9,17 @@ ob_start(); $available = ($book['copie_disponibili'] ?? 0) > 0; $stato = $book['stato'] ?? ''; - $reserved = !$available && $stato === 'prenotato'; - // A book whose copies are all out of circulation (maintenance, lost, damaged…) - // is 'non_disponibile' — not on loan. Without this branch it fell through to - // the "In prestito" badge, mislabelling it. - $unavailable = !$available && $stato === 'non_disponibile'; + // "In prestito" is shown ONLY for stato='prestato'. Every other not-available + // case — 'non_disponibile', or a stale/unexpected stato on a zero-copy book — + // falls to "Non disponibile" rather than being mislabelled as on loan (#303 review). if ($available) { echo '' . __("Disponibile"); - } elseif ($reserved) { + } elseif ($stato === 'prenotato') { echo '' . __("Prenotato"); - } elseif ($unavailable) { - echo '' . __("Non disponibile"); - } else { + } elseif ($stato === 'prestato') { echo '' . __("In prestito"); + } else { + echo '' . __("Non disponibile"); } // Hook: Allow plugins to add icons to status badge (e.g., eBook/audio icons) do_action('book.badge.digital_icons', $book); From 3bfeede73c1bc84eaf59b99eb6fcb80093ebebe6 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 29 Jul 2026 10:24:14 +0200 Subject: [PATCH 9/9] refactor(#303 review): make Language locale dir injectable; close test gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 5 from the review — the derived-stats helpers hardcoded the locale/ directory via __DIR__, which forced the unit test to write fixtures into the real repository tree (orphaned on a hard kill) and left the malformed-JSON branch untested. Root-cause fix: - Language::__construct now takes an optional $localeDir (defaults to the shipped locale/); the source-key cache moved from a static to a per-instance property so an injected dir isn't shadowed by a prior instance's cache. - Dropped the redundant min($translated, $sourceTotal) cap: array_intersect_key against the source key set already bounds the count to <= source, so the cap was unreachable dead code — documented the invariant instead. - The unit test now points a dedicated Language instance at a throwaway dir under sys_get_temp_dir(): fixtures never touch repo locale/, and it adds the previously-missing malformed-JSON case. 17 → 20 assertions. Backward compatible (the second constructor arg is optional; no other caller passes it). PHPStan clean; migration/danish/export i18n tests still green. --- app/Models/Language.php | 40 +++++++++------- tests/language-derived-stats.unit.php | 66 +++++++++++++++++---------- 2 files changed, 65 insertions(+), 41 deletions(-) diff --git a/app/Models/Language.php b/app/Models/Language.php index 1fb1d84d..235a0a03 100644 --- a/app/Models/Language.php +++ b/app/Models/Language.php @@ -24,9 +24,18 @@ class Language private mysqli $db; - public function __construct(mysqli $db) + /** Directory holding the per-locale JSON files. Injectable for tests. */ + private string $localeDir; + + /** @var array|null Per-instance cache of the source key set. */ + private ?array $sourceKeysCache = null; + + public function __construct(mysqli $db, ?string $localeDir = null) { $this->db = $db; + // Default to the shipped locale/ directory; tests inject a temp dir so + // they never write fixtures into the real repository tree (#303 review). + $this->localeDir = $localeDir ?? (__DIR__ . '/../../locale'); } /** @@ -48,20 +57,19 @@ public function sourceKeyCount(): int */ private function sourceKeys(): array { - static $keys = null; - if ($keys !== null) { - return $keys; + if ($this->sourceKeysCache !== null) { + return $this->sourceKeysCache; } - $path = __DIR__ . '/../../locale/' . self::SOURCE_LOCALE . '.json'; + $path = $this->localeDir . '/' . self::SOURCE_LOCALE . '.json'; $decoded = is_file($path) ? json_decode((string) file_get_contents($path), true) : null; - $keys = is_array($decoded) ? array_fill_keys(array_keys($decoded), true) : []; - return $keys; + $this->sourceKeysCache = is_array($decoded) ? array_fill_keys(array_keys($decoded), true) : []; + return $this->sourceKeysCache; } /** - * Count of non-empty translations actually present in a locale's file, - * capped at the source total (orphan keys never push completion past 100%). - * The source language is complete by definition. + * Count of non-empty translations actually present in a locale's file that + * correspond to a canonical source key. The source language is complete by + * definition. Returns 0 for a missing file, an invalid code, or malformed JSON. * * @param string $code Locale code * @param int $sourceTotal Source key count (denominator) @@ -79,7 +87,7 @@ private function translatedKeyCount(string $code, int $sourceTotal): int if (!preg_match('/^[a-z]{2}_[A-Z]{2}$/', $code)) { return 0; } - $path = __DIR__ . '/../../locale/' . $code . '.json'; + $path = $this->localeDir . '/' . $code . '.json'; if (!is_file($path)) { return 0; } @@ -87,12 +95,12 @@ private function translatedKeyCount(string $code, int $sourceTotal): int if (!is_array($decoded)) { return 0; } - // Only canonical source keys contribute to completion. Merely capping the - // number of locale entries would let orphan keys hide missing translations - // and could incorrectly report 100% completion. + // Only canonical source keys contribute to completion — intersecting with + // the source key set both excludes orphan keys (which would otherwise hide + // missing translations) and bounds the result to <= $sourceTotal, so no + // explicit cap is needed here. $canonicalEntries = array_intersect_key($decoded, $this->sourceKeys()); - $translated = count(array_filter($canonicalEntries, static fn($value) => is_string($value) && $value !== '')); - return min($translated, $sourceTotal); + return count(array_filter($canonicalEntries, static fn($value) => is_string($value) && $value !== '')); } /** diff --git a/tests/language-derived-stats.unit.php b/tests/language-derived-stats.unit.php index 8a9ac367..ae12fe73 100644 --- a/tests/language-derived-stats.unit.php +++ b/tests/language-derived-stats.unit.php @@ -85,7 +85,7 @@ $src = $lang->sourceKeyCount(); $check($src === $srcCount, "sourceKeyCount() == it_IT.json key count ({$srcCount})"); $check($src > 6000, "source key count is in the expected range (>6000): {$src}"); -$check($lang->sourceKeyCount() === $src, 'sourceKeyCount() is stable across calls (static cache)'); +$check($lang->sourceKeyCount() === $src, 'sourceKeyCount() is stable across calls (per-instance cache)'); echo "B. getAllWithDerivedStats() — total_keys linked to Italian for every locale\n"; $rows = $lang->getAllWithDerivedStats(); @@ -116,40 +116,56 @@ echo "D. translatedKeyCount() unit behaviour\n"; $check($translated(Language::SOURCE_LOCALE, $srcCount) === $srcCount, 'source locale counts as fully translated'); -$check($translated('en_US', $srcCount) <= $srcCount, 'en_US translated <= source total (capped)'); +$check($translated('en_US', $srcCount) <= $srcCount, 'en_US translated <= source total (bounded by source intersection)'); $check($translated('en_US', $srcCount) > 6000, 'en_US is a well-covered locale (>6000)'); $check($translated('zz_NONEXISTENT', $srcCount) === 0, 'missing locale file → 0 translated'); -echo "E. Temp locale: canonical coverage, empty values, and orphan keys\n"; -$tmpCode = 'zz_ZZ'; -$tmpPath = $root . '/locale/' . $tmpCode . '.json'; +echo "E. Injected-locale-dir edge cases (temp dir — never writes into repo locale/)\n"; +// A dedicated Language instance pointed at a throwaway directory under the OS +// temp dir. Fixtures live there, so a hard kill can never leave an orphan file +// in the real locale/ tree (#303 review, finding 5). The temp dir gets its own +// source (it_IT.json) so sourceKeys()/sourceKeyCount() resolve against it. +$tmpDir = sys_get_temp_dir() . '/pinakes-lang-' . getmypid() . '-' . bin2hex(random_bytes(4)); +mkdir($tmpDir, 0700, true); +$rmTmp = static function (string $dir): void { + foreach (glob($dir . '/*') ?: [] as $f) { @unlink($f); } + @rmdir($dir); +}; try { - $canonicalKeys = array_slice(array_keys($sourceEntries), 0, 5); + // Temp source: 5 canonical keys → this instance's denominator is 5. + $canonicalKeys = ['k0', 'k1', 'k2', 'k3', 'k4']; + file_put_contents($tmpDir . '/it_IT.json', json_encode(array_fill_keys($canonicalKeys, 'src'), JSON_UNESCAPED_UNICODE)); + + $langTmp = new Language($db, $tmpDir); + $tmpTotal = $langTmp->sourceKeyCount(); + $check($tmpTotal === 5, "injected source dir drives the denominator ({$tmpTotal} == 5)"); + + $refTmp = new ReflectionMethod(Language::class, 'translatedKeyCount'); + $refTmp->setAccessible(true); + $tr = static fn (string $code): int => $refTmp->invoke($langTmp, $code, $tmpTotal); + // 3 canonical translations + 2 empty canonical values → 3 translated. - file_put_contents($tmpPath, json_encode( - [ - $canonicalKeys[0] => 'x', - $canonicalKeys[1] => 'y', - $canonicalKeys[2] => 'z', - $canonicalKeys[3] => '', - $canonicalKeys[4] => '', - ], + file_put_contents($tmpDir . '/zz_ZZ.json', json_encode( + [$canonicalKeys[0] => 'x', $canonicalKeys[1] => 'y', $canonicalKeys[2] => 'z', $canonicalKeys[3] => '', $canonicalKeys[4] => ''], JSON_UNESCAPED_UNICODE )); - $check($translated($tmpCode, $srcCount) === 3, 'only non-empty canonical values are counted (3 of 5)'); + $check($tr('zz_ZZ') === 3, 'only non-empty canonical values are counted (3 of 5)'); + $check(round($tr('zz_ZZ') / $tmpTotal * 100, 2) < 100.00, 'partial locale completion is below 100%'); - // A partial translation is below 100%. - $partial = $translated($tmpCode, $srcCount); - $pct = round($partial / $srcCount * 100, 2); - $check($pct < 100.00, "partial locale completion is below 100% ({$pct}%)"); - - // Even more orphan entries than the source total contribute no coverage. + // Orphan-only keys (none in the source) contribute no coverage. $big = []; - for ($i = 0; $i < $srcCount + 50; $i++) { $big["k{$i}"] = 'v'; } - file_put_contents($tmpPath, json_encode($big, JSON_UNESCAPED_UNICODE)); - $check($translated($tmpCode, $srcCount) === 0, 'orphan-only keys do not count as translated'); + for ($i = 0; $i < $tmpTotal + 50; $i++) { $big["orphan{$i}"] = 'v'; } + file_put_contents($tmpDir . '/zz_YY.json', json_encode($big, JSON_UNESCAPED_UNICODE)); + $check($tr('zz_YY') === 0, 'orphan-only keys do not count as translated'); + + // GAP 1: a file that exists but holds malformed JSON → 0 (not a crash). + file_put_contents($tmpDir . '/zz_XX.json', '{ this is not valid json ,,, '); + $check($tr('zz_XX') === 0, 'existing file with malformed JSON → 0 translated'); + + // A locale whose file is absent from the injected dir → 0. + $check($tr('zz_QQ') === 0, 'missing locale file in injected dir → 0 translated'); } finally { - if (is_file($tmpPath)) { @unlink($tmpPath); } + $rmTmp($tmpDir); } $db->rollback();