From 75365ac26df41e6239fdcd3c9a500d8a68a10514 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 22 Jul 2026 18:58:52 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(#282):=20mobile=20API=20=E2=80=94=20re?= =?UTF-8?q?al=20catalogue=20languages=20endpoint=20+=20tolerant=20language?= =?UTF-8?q?=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uwe reports (#282) that combining a language with any other filter (category, author, publisher) in the Android app returns no results. Root cause: libri.lingua is unnormalized free text (a dev catalogue holds "italiano", "inglese", "English", "Deutsch", "Français" side by side), but the app's language filter is a hardcoded predefined list. So the app sends e.g. "German" while the row says "Deutsch", the exact-match l.lingua = ? matches nothing, and any filter combined with language collapses to zero. - New GET /api/v1/catalog/languages returns the distinct language values actually present in the (non-deleted) catalogue, each with its book count, ordered by frequency. The app should populate its language filter from this — the real collection values — exactly as it already sources genres/authors/publishers, so a selected language always matches. Read-only, ETag-cached like the other catalog read endpoints; documented in OpenApi; one manifest row added to the idempotency guard. - The search language filter is now case/space-insensitive (LOWER(TRIM(l.lingua)) = LOWER(TRIM(?))) so a value echoed back from the endpoint matches regardless of casing/whitespace. Note: catalog search already supports sort (title/author), so the app's "sort by title/author" request in #282 needs only an app-side UI change; the language-filter fix needs the app to consume the new endpoint. Those app changes ship in the Pinakes-Android repo. Mobile API plugin 1.4.0 -> 1.4.1. Verified: mobile-api-idempotency.spec.js 43/43 (incl. the manifest-covers-every-route guard and 2x /catalog/languages ETag/304), PHPStan clean. --- .../plugins/mobile-api/MobileApiPlugin.php | 8 +++ storage/plugins/mobile-api/plugin.json | 5 +- .../src/Controllers/CatalogController.php | 68 ++++++++++++++++++- .../src/Controllers/OpenApiController.php | 24 +++++++ tests/mobile-api-idempotency.spec.js | 1 + 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/storage/plugins/mobile-api/MobileApiPlugin.php b/storage/plugins/mobile-api/MobileApiPlugin.php index c987d1550..2e4ce3e1b 100644 --- a/storage/plugins/mobile-api/MobileApiPlugin.php +++ b/storage/plugins/mobile-api/MobileApiPlugin.php @@ -539,6 +539,14 @@ public function registerRoutes(\Slim\App $app): void return (new CatalogController($db))->genres($request, $response); })->add($quotaMw())->add($authMw()); + // #282: real catalogue language values for the app's language filter. + $group->get('/catalog/languages', function ( + ServerRequestInterface $request, + ResponseInterface $response + ) use ($db): ResponseInterface { + return (new CatalogController($db))->languages($request, $response); + })->add($quotaMw())->add($authMw()); + // ── User actions (loans / reservations / wishlist / profile / msg) ── // Every handler is bearer-authenticated and strictly scoped to the // token-resolved user; loan/reservation overlap + availability reuse diff --git a/storage/plugins/mobile-api/plugin.json b/storage/plugins/mobile-api/plugin.json index 9846dc015..2d65c05ae 100644 --- a/storage/plugins/mobile-api/plugin.json +++ b/storage/plugins/mobile-api/plugin.json @@ -2,7 +2,7 @@ "name": "mobile-api", "display_name": "Mobile API", "description": "API REST/JSON versionata (/api/v1) per l'app companion mobile di Pinakes: discovery, autenticazione a token per dispositivo, ricerca catalogo, prestiti/prenotazioni, wishlist, profilo, messaggi e notifiche push. Disattivata per default finché non viene abilitata.", - "version": "1.4.0", + "version": "1.4.1", "author": "Fabiodalez", "author_url": "", "plugin_url": "", @@ -22,5 +22,6 @@ ], "optional": true, "status": "stable" - } + }, + "changelog": "v1.4.1: endpoint GET /api/v1/catalog/languages che restituisce i valori di lingua reali del catalogo (con conteggi) per popolare il filtro lingua dell'app dai dati veri invece che da una lista fissa (#282); il filtro lingua della ricerca ora e' case/space-insensitive. " } diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index da0d394f6..3190566ba 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -365,6 +365,69 @@ public function genres( } } + // ─── GET /catalog/languages ────────────────────────────────────────────── + + /** + * Distinct language values actually present in the (non-deleted) catalogue, + * each with its book count. `libri.lingua` is unnormalized free text, so a + * hardcoded language list in the app cannot match it (e.g. the app sends + * "German" while the row says "Deutsch"): filtering by language then returns + * nothing, which is exactly issue #282. The app should populate its language + * filter from THIS endpoint — the real collection values — the same way it + * already sources genres / authors / publishers. Read-only, ETag-cached. + */ + public function languages( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + try { + $rows = []; + // Static query, no user input; soft-delete honoured per CLAUDE.md rule 2. + $res = $this->db->query( + "SELECT TRIM(lingua) AS language, COUNT(*) AS count + FROM libri + WHERE deleted_at IS NULL AND lingua IS NOT NULL AND TRIM(lingua) <> '' + GROUP BY TRIM(lingua) + ORDER BY count DESC, language ASC" + ); + if ($res !== false) { + while ($r = $res->fetch_assoc()) { + $rows[] = [ + 'language' => (string) $r['language'], + 'count' => (int) $r['count'], + ]; + } + } + + $etag = $this->computeLanguagesEtag($rows); + if ($this->notModified($request, $etag)) { + return $this->notModifiedResponse($response, $etag); + } + + $response = ResponseEnvelope::success($response, $rows, ['count' => count($rows)], 200); + + return $response + ->withHeader('ETag', $etag) + ->withHeader('Cache-Control', 'private, max-age=0, must-revalidate'); + } catch (\Throwable $e) { + SecureLogger::error('[MobileApi] catalog languages failed: ' . $e->getMessage()); + return ResponseEnvelope::error($response, 'internal_error', __('Lingue non disponibili.'), 500); + } + } + + /** + * @param list $rows + */ + private function computeLanguagesEtag(array $rows): string + { + $seed = ''; + foreach ($rows as $r) { + $seed .= $r['language'] . ':' . $r['count'] . '|'; + } + + return '"' . sha1('languages:' . $seed) . '"'; + } + // ─── Filters ───────────────────────────────────────────────────────────── /** @@ -460,7 +523,10 @@ private function buildFilters(array $params): array $language = isset($params['language']) ? trim((string) $params['language']) : ''; if ($language !== '') { - $conditions[] = 'l.lingua = ?'; + // libri.lingua is unnormalized free text (#282): tolerate case / + // surrounding whitespace so a value taken from /catalog/languages + // matches regardless of how the client echoes it back. + $conditions[] = 'LOWER(TRIM(l.lingua)) = LOWER(TRIM(?))'; $bind[] = $language; $types .= 's'; } diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 644e2452f..10bac5811 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -554,6 +554,30 @@ private function paths(): array ], ], + '/catalog/languages' => [ + 'get' => [ + 'tags' => ['catalog'], + 'summary' => 'Catalogue languages', + 'description' => 'Distinct language values present in the catalogue, each with a book count, for building the app language filter from real data rather than a hardcoded list (#282).', + 'operationId' => 'getCatalogLanguages', + 'security' => [['bearerAuth' => []]], + 'responses' => [ + '200' => ['description' => 'Languages.', 'content' => ['application/json' => ['schema' => [ + 'allOf' => [['$ref' => '#/components/schemas/Envelope']], + 'properties' => ['data' => ['type' => 'array', 'items' => [ + 'type' => 'object', + 'properties' => [ + 'language' => ['type' => 'string'], + 'count' => ['type' => 'integer'], + ], + ]]], + ]]]], + '401' => ['$ref' => '#/components/responses/Unauthorized'], + '500' => ['$ref' => '#/components/responses/InternalError'], + ], + ], + ], + '/me/loans' => [ 'get' => [ 'tags' => ['loans'], diff --git a/tests/mobile-api-idempotency.spec.js b/tests/mobile-api-idempotency.spec.js index c76afdc62..82bc7e49a 100644 --- a/tests/mobile-api-idempotency.spec.js +++ b/tests/mobile-api-idempotency.spec.js @@ -157,6 +157,7 @@ const ENDPOINTS = [ { name: 'GET /catalog/books/{bookId}', method: 'GET', path: '/catalog/books/{bookId}', auth: true, kind: 'etag' }, { name: 'GET /catalog/books/{bookId}/availability', method: 'GET', path: '/catalog/books/{bookId}/availability', auth: true, kind: 'safeGet' }, { name: 'GET /catalog/genres', method: 'GET', path: '/catalog/genres', auth: true, kind: 'etag' }, + { name: 'GET /catalog/languages', method: 'GET', path: '/catalog/languages', auth: true, kind: 'etag' }, { name: 'GET /me/loans', method: 'GET', path: '/me/loans', auth: true, kind: 'safeGet' }, { name: 'GET /me/reservations', method: 'GET', path: '/me/reservations', auth: true, kind: 'safeGet' }, { name: 'POST /reservations', method: 'POST', path: '/reservations', auth: true, kind: 'conflict2', From 3032ceef5815bc0c3415adc3e405253efffb468f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 22 Jul 2026 20:58:58 +0200 Subject: [PATCH 2/3] fix: complete mobile language filter contract --- locale/de_DE.json | 27 +++- locale/en_US.json | 27 +++- locale/fr_FR.json | 27 +++- locale/it_IT.json | 27 +++- .../src/Controllers/CatalogController.php | 5 +- tests/code-quality.spec.js | 85 +++++++++- tests/mobile-language-filter-282.unit.php | 153 ++++++++++++++++++ 7 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 tests/mobile-language-filter-282.unit.php diff --git a/locale/de_DE.json b/locale/de_DE.json index 5e5bd7d5f..6bc4df30a 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -5658,6 +5658,9 @@ "proposto da": "vorgeschlagen von", "scade il": "endet am", "scaduta il": "endete am", + "Votazione terminata": "Abstimmung beendet", + "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.": "Dieses Buch ist bereits mit einem anderen Vorschlag dieses Clubs verknüpft: Es wurde nicht erneut übernommen.", + "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.": "Die Frist der Abstimmung ist abgelaufen: Die Abstimmung wurde geschlossen und deine Stimme wurde nicht gezählt.", "sì": "ja", "votanti": "Teilnehmende", "voti": "Stimmen", @@ -6577,5 +6580,27 @@ "Genere e sottogeneri eliminati con successo!": "Genre und Untergenres erfolgreich gelöscht!", "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Diese Aktion löscht das Genre und alle seine Untergenres dauerhaft. Zugeordnete Bücher werden von diesen Genres getrennt.", "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Sind Sie sicher, dass Sie dieses Genre und alle seine Untergenres löschen möchten? Zugeordnete Bücher werden von diesen Genres getrennt.", - "Elimina genere e sottogeneri": "Genre und Untergenres löschen" + "Elimina genere e sottogeneri": "Genre und Untergenres löschen", + "Proposte non selezionate": "Nicht ausgewählte Vorschläge", + "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.": "Vorschläge, die noch auf eine Auswahl warten. Dazu gehören Bücher, über die noch nie abgestimmt wurde, und Bücher, die noch nie eine abgeschlossene Abstimmung gewonnen haben.", + "votazione": "Abstimmung", + "votazioni": "Abstimmungen", + "ultima": "zuletzt", + "Non in catalogo": "Nicht im Katalog", + "Votazioni chiuse": "Geschlossene Abstimmungen", + "mai votata": "noch nicht abgestimmt", + "Seleziona tutti i prestiti estendibili": "Alle verlängerbaren Ausleihen auswählen", + "Estendi di": "Verlängern um", + "Estendi prestiti selezionati": "Ausgewählte Ausleihen verlängern", + "%d prestiti selezionati": "%d Ausleihen ausgewählt", + "Estendere i prestiti selezionati?": "Ausgewählte Ausleihen verlängern?", + "%n prestiti verranno estesi di %d giorni.": "%n Ausleihen werden um %d Tage verlängert.", + "Prestiti estesi": "Ausleihen verlängert", + "Nessun prestito esteso": "Keine Ausleihe verlängert", + "%n prestiti estesi di %d giorni.": "%n Ausleihen um %d Tage verlängert.", + "Selezione o numero di giorni non validi.": "Ungültige Auswahl oder Anzahl der Tage.", + "Estensione non riuscita, riprova.": "Verlängerung fehlgeschlagen, bitte erneut versuchen.", + "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Verlängerung abgebrochen: Mindestens eine Ausleihe würde mit einer anderen Ausleihe oder Reservierung in Konflikt geraten.", + "Estendi": "Verlängern", + "Lingue non disponibili.": "Sprachen sind nicht verfügbar." } diff --git a/locale/en_US.json b/locale/en_US.json index 0bd39013f..3d5ff4a3b 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -5658,6 +5658,9 @@ "proposto da": "proposed by", "scade il": "closes on", "scaduta il": "closed on", + "Votazione terminata": "Voting ended", + "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.": "This book is already linked to another proposal in this club: it was not acquired again.", + "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.": "The poll's deadline has passed: the poll has been closed and your vote was not recorded.", "sì": "yes", "votanti": "voters", "voti": "votes", @@ -6577,5 +6580,27 @@ "Genere e sottogeneri eliminati con successo!": "Genre and subgenres deleted successfully!", "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "This action permanently deletes the genre and all of its subgenres. Associated books will be unlinked from these genres.", "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Are you sure you want to delete this genre and all of its subgenres? Associated books will be unlinked from these genres.", - "Elimina genere e sottogeneri": "Delete genre and subgenres" + "Elimina genere e sottogeneri": "Delete genre and subgenres", + "Proposte non selezionate": "Proposals not selected", + "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.": "Proposals still awaiting selection. The list includes books not yet voted on and books that have never won a closed poll.", + "votazione": "poll", + "votazioni": "polls", + "ultima": "last", + "Non in catalogo": "Not in catalog", + "Votazioni chiuse": "Closed polls", + "mai votata": "not yet voted on", + "Seleziona tutti i prestiti estendibili": "Select all extendable loans", + "Estendi di": "Extend by", + "Estendi prestiti selezionati": "Extend selected loans", + "%d prestiti selezionati": "%d loans selected", + "Estendere i prestiti selezionati?": "Extend the selected loans?", + "%n prestiti verranno estesi di %d giorni.": "%n loans will be extended by %d days.", + "Prestiti estesi": "Loans extended", + "Nessun prestito esteso": "No loan extended", + "%n prestiti estesi di %d giorni.": "%n loans extended by %d days.", + "Selezione o numero di giorni non validi.": "Invalid selection or number of days.", + "Estensione non riuscita, riprova.": "Extension failed, please try again.", + "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Extension cancelled: at least one loan would conflict with another loan or reservation.", + "Estendi": "Extend", + "Lingue non disponibili.": "Languages are unavailable." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 2ea7a0674..df10f4cdd 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -5658,6 +5658,9 @@ "proposto da": "proposé par", "scade il": "expire le", "scaduta il": "expiré le", + "Votazione terminata": "Vote terminé", + "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.": "Ce livre est déjà lié à une autre proposition de ce club : il n'a pas été acquis à nouveau.", + "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.": "La date limite du vote est dépassée : le vote a été clôturé et votre vote n'a pas été enregistré.", "sì": "oui", "votanti": "votants", "voti": "votes", @@ -6577,5 +6580,27 @@ "Genere e sottogeneri eliminati con successo!": "Genre et sous-genres supprimés avec succès !", "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Cette action supprime définitivement le genre et tous ses sous-genres. Les livres associés seront dissociés de ces genres.", "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Voulez-vous vraiment supprimer ce genre et tous ses sous-genres ? Les livres associés seront dissociés de ces genres.", - "Elimina genere e sottogeneri": "Supprimer le genre et les sous-genres" + "Elimina genere e sottogeneri": "Supprimer le genre et les sous-genres", + "Proposte non selezionate": "Propositions non retenues", + "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.": "Propositions encore en attente de sélection. La liste comprend les livres jamais soumis au vote et ceux qui n'ont jamais remporté un vote clos.", + "votazione": "vote", + "votazioni": "votes", + "ultima": "dernière", + "Non in catalogo": "Pas au catalogue", + "Votazioni chiuse": "Votes clos", + "mai votata": "pas encore soumise au vote", + "Seleziona tutti i prestiti estendibili": "Sélectionner tous les prêts prolongeables", + "Estendi di": "Prolonger de", + "Estendi prestiti selezionati": "Prolonger les prêts sélectionnés", + "%d prestiti selezionati": "%d prêts sélectionnés", + "Estendere i prestiti selezionati?": "Prolonger les prêts sélectionnés ?", + "%n prestiti verranno estesi di %d giorni.": "%n prêts seront prolongés de %d jours.", + "Prestiti estesi": "Prêts prolongés", + "Nessun prestito esteso": "Aucun prêt prolongé", + "%n prestiti estesi di %d giorni.": "%n prêts prolongés de %d jours.", + "Selezione o numero di giorni non validi.": "Sélection ou nombre de jours invalide.", + "Estensione non riuscita, riprova.": "La prolongation a échoué, veuillez réessayer.", + "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Prolongation annulée : au moins un prêt entrerait en conflit avec un autre prêt ou une réservation.", + "Estendi": "Prolonger", + "Lingue non disponibili.": "Les langues ne sont pas disponibles." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 00a96034b..31e21b6cd 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -5658,6 +5658,9 @@ "proposto da": "proposto da", "scade il": "scade il", "scaduta il": "scaduta il", + "Votazione terminata": "Votazione terminata", + "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.": "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.", + "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.": "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.", "sì": "sì", "votanti": "votanti", "voti": "voti", @@ -6577,5 +6580,27 @@ "Genere e sottogeneri eliminati con successo!": "Genere e sottogeneri eliminati con successo!", "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.", "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.", - "Elimina genere e sottogeneri": "Elimina genere e sottogeneri" + "Elimina genere e sottogeneri": "Elimina genere e sottogeneri", + "Proposte non selezionate": "Proposte non selezionate", + "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.": "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.", + "votazione": "votazione", + "votazioni": "votazioni", + "ultima": "ultima", + "Non in catalogo": "Non in catalogo", + "Votazioni chiuse": "Votazioni chiuse", + "mai votata": "mai votata", + "Seleziona tutti i prestiti estendibili": "Seleziona tutti i prestiti estendibili", + "Estendi di": "Estendi di", + "Estendi prestiti selezionati": "Estendi prestiti selezionati", + "%d prestiti selezionati": "%d prestiti selezionati", + "Estendere i prestiti selezionati?": "Estendere i prestiti selezionati?", + "%n prestiti verranno estesi di %d giorni.": "%n prestiti verranno estesi di %d giorni.", + "Prestiti estesi": "Prestiti estesi", + "Nessun prestito esteso": "Nessun prestito esteso", + "%n prestiti estesi di %d giorni.": "%n prestiti estesi di %d giorni.", + "Selezione o numero di giorni non validi.": "Selezione o numero di giorni non validi.", + "Estensione non riuscita, riprova.": "Estensione non riuscita, riprova.", + "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.", + "Estendi": "Estendi", + "Lingue non disponibili.": "Lingue non disponibili." } diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index 3190566ba..ed1cd02d4 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -17,6 +17,7 @@ * GET /api/v1/catalog/search — filtered, cursor-paginated search. * GET /api/v1/catalog/books/{id} — full book detail + personal history. * GET /api/v1/catalog/genres — genre cascade tree for the filter UI. + * GET /api/v1/catalog/languages — real catalogue language values + counts. * * Design notes: * - Soft-delete is non-negotiable: EVERY query on `libri` carries @@ -384,10 +385,10 @@ public function languages( $rows = []; // Static query, no user input; soft-delete honoured per CLAUDE.md rule 2. $res = $this->db->query( - "SELECT TRIM(lingua) AS language, COUNT(*) AS count + "SELECT MIN(TRIM(lingua)) AS language, COUNT(*) AS count FROM libri WHERE deleted_at IS NULL AND lingua IS NOT NULL AND TRIM(lingua) <> '' - GROUP BY TRIM(lingua) + GROUP BY LOWER(TRIM(lingua)) ORDER BY count DESC, language ASC" ); if ($res !== false) { diff --git a/tests/code-quality.spec.js b/tests/code-quality.spec.js index 132ab0ac4..93ab51d20 100644 --- a/tests/code-quality.spec.js +++ b/tests/code-quality.spec.js @@ -311,15 +311,77 @@ test.describe.serial('Code Quality — 15 static analysis tests', () => { test('10. API endpoints documented in README.md exist in PHP code', () => { const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf-8'); - const phpSources = [ + const phpFiles = [ ...glob('app', '.php', ['vendor']), ...glob('storage/plugins', '.php'), - ].map(f => fs.readFileSync(f, 'utf-8')).join('\n'); + ].map(f => fs.readFileSync(f, 'utf-8')); + const phpSources = phpFiles.join('\n'); const DOC_RE = /`GET (\/(?:api|resync|oai|openurl|archives)[^\s`]+)/g; const violations = []; const normalizedPhpSources = phpSources.replace(/\{([^}:]+):[^}]+\}/g, '{$1}'); const normalizedRouteSources = normalizedPhpSources.replace(/\{[^}:]+(?::[^}]+)?\}/g, '{}'); + + // Return only the lexical body of each /api/v1 group callback. Looking + // for a group and a child route anywhere in the same file is too weak: + // an unrelated top-level GET later in that file would be a false pass. + const apiV1GroupBodies = source => { + const bodies = []; + const groupRe = /->group\(\s*(['"])\/api\/v1\1\s*,\s*function\b[^{]*\{/g; + let groupMatch; + while ((groupMatch = groupRe.exec(source)) !== null) { + const open = groupRe.lastIndex - 1; + let depth = 0; + let quote = null; + let escaped = false; + let lineComment = false; + let blockComment = false; + for (let i = open; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1] ?? ''; + if (lineComment) { + if (ch === '\n') lineComment = false; + continue; + } + if (blockComment) { + if (ch === '*' && next === '/') { blockComment = false; i++; } + continue; + } + if (quote !== null) { + if (escaped) { escaped = false; continue; } + if (ch === '\\') { escaped = true; continue; } + if (ch === quote) quote = null; + continue; + } + if (ch === '/' && next === '/') { lineComment = true; i++; continue; } + if (ch === '#') { lineComment = true; continue; } + if (ch === '/' && next === '*') { blockComment = true; i++; continue; } + if (ch === "'" || ch === '"' || ch === '`') { quote = ch; continue; } + if (ch === '{') depth++; + if (ch === '}' && --depth === 0) { + bodies.push(source.slice(open + 1, i)); + groupRe.lastIndex = i + 1; + break; + } + } + } + return bodies; + }; + const groupedGetExists = (source, child) => { + const escapedChild = child.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const getRe = new RegExp(`->get\\(\\s*(['"])${escapedChild}\\1`); + return apiV1GroupBodies(source).some(body => { + const normalized = body + .replace(/\{([^}:]+):[^}]+\}/g, '{$1}') + .replace(/\{[^}:]+(?::[^}]+)?\}/g, '{}'); + return getRe.test(normalized); + }); + }; + const falsePositiveFixture = "$app->group('/api/v1', function ($g) {}); $app->get('/auth/registration-fields', $handler);"; + expect(groupedGetExists(falsePositiveFixture, '/auth/registration-fields'), + 'a top-level GET outside the /api/v1 callback must not satisfy the route check' + ).toBe(false); + let m; while ((m = DOC_RE.exec(readme)) !== null) { // Strip the query string + hash before matching against the PHP @@ -330,7 +392,24 @@ test.describe.serial('Code Quality — 15 static analysis tests', () => { // `{}` to align with the PHP-side normalisation above. const pathOnly = m[1].split('?')[0].split('#')[0]; const search = pathOnly.replace(/\/$/, '').replace(/\{[^}:]+(?::[^}]+)?\}/g, '{}'); - if (!normalizedRouteSources.includes(search)) violations.push(m[1]); + let found = normalizedRouteSources.includes(search); + + // Slim plugins commonly register `/api/v1` once with group(), then + // declare child routes as `/auth/...`. Require the GET child literal + // inside that exact group's callback so unrelated routes elsewhere + // in the same PHP file cannot satisfy a documented API path. + if (search.startsWith('/api/v1/')) { + const child = search.slice('/api/v1'.length); + const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const directGetRe = new RegExp(`->get\\(\\s*(['"])${escapedSearch}\\1`); + found = phpFiles.some(source => { + const normalized = source + .replace(/\{([^}:]+):[^}]+\}/g, '{$1}') + .replace(/\{[^}:]+(?::[^}]+)?\}/g, '{}'); + return directGetRe.test(normalized) || groupedGetExists(source, child); + }); + } + if (!found) violations.push(m[1]); } expect(violations, `Endpoints documented in README but no matching route found in PHP:\n${violations.map(v => ' GET ' + v).join('\n')}` diff --git a/tests/mobile-language-filter-282.unit.php b/tests/mobile-language-filter-282.unit.php new file mode 100644 index 000000000..af6e94055 --- /dev/null +++ b/tests/mobile-language-filter-282.unit.php @@ -0,0 +1,153 @@ +set_charset('utf8mb4'); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); + exit(1); +} + +$run = substr(hash('sha256', uniqid((string) getmypid(), true)), 0, 10); +$titlePrefix = 'ZZ_LANG282_' . $run; +$language = 'ZzLang' . $run; +$languageVariant = strtolower($language); + +$cleanup = static function () use ($db, $titlePrefix): void { + $like = $titlePrefix . '%'; + $stmt = $db->prepare('DELETE FROM libri WHERE titolo LIKE ?'); + $stmt->bind_param('s', $like); + $stmt->execute(); + $stmt->close(); +}; +$cleanup(); +set_exception_handler(static function (Throwable $e) use ($cleanup, $db): void { + try { + $cleanup(); + } catch (Throwable) { + } + fwrite(STDERR, "FAIL: {$e->getMessage()}\n"); + $db->close(); + exit(1); +}); + +$pass = 0; +$check = static function (bool $ok, string $label) use (&$pass): void { + if (!$ok) { + throw new RuntimeException($label); + } + $pass++; + echo " OK {$label}\n"; +}; + +$insertBook = static function (string $suffix, ?string $bookLanguage, bool $deleted = false) use ($db, $titlePrefix): int { + $title = $titlePrefix . '_' . $suffix; + $deletedAt = $deleted ? date('Y-m-d H:i:s') : null; + $stmt = $db->prepare( + "INSERT INTO libri (titolo, lingua, stato, copie_totali, copie_disponibili, deleted_at) + VALUES (?, ?, 'non_disponibile', 0, 0, ?)" + ); + $stmt->bind_param('sss', $title, $bookLanguage, $deletedAt); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; +}; + +$firstId = $insertBook('one', ' ' . $language . ' '); +$secondId = $insertBook('two', $languageVariant); +$deletedId = $insertBook('deleted', strtoupper($language), true); +$insertBook('blank', ' '); +$insertBook('null', null); + +$controller = new CatalogController($db); +$requestFactory = new ServerRequestFactory(); +$responseFactory = new ResponseFactory(); + +echo "A. Catalogue language discovery\n"; +$request = $requestFactory->createServerRequest('GET', '/api/v1/catalog/languages'); +$response = $controller->languages($request, $responseFactory->createResponse()); +$check($response->getStatusCode() === 200, 'languages endpoint returns 200'); +$payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); +$rows = is_array($payload['data'] ?? null) ? $payload['data'] : []; +$match = null; +foreach ($rows as $row) { + if (strtolower(trim((string) ($row['language'] ?? ''))) === strtolower($language)) { + $match = $row; + break; + } +} +$check($match !== null && (int) ($match['count'] ?? 0) === 2, 'case/space variants collapse to one value with the correct active-book count'); +$check((int) ($payload['meta']['count'] ?? -1) === count($rows), 'response metadata reports the number of distinct values'); +$etag = $response->getHeaderLine('ETag'); +$check($etag !== '', 'languages response includes an ETag'); + +$conditional = $requestFactory + ->createServerRequest('GET', '/api/v1/catalog/languages') + ->withHeader('If-None-Match', $etag); +$notModified = $controller->languages($conditional, $responseFactory->createResponse()); +$check($notModified->getStatusCode() === 304, 'matching If-None-Match returns 304'); + +echo "B. Tolerant search filter\n"; +$searchRequest = $requestFactory + ->createServerRequest('GET', '/api/v1/catalog/search') + ->withQueryParams(['language' => ' ' . strtoupper($language) . ' ', 'limit' => 50]); +$searchResponse = $controller->search($searchRequest, $responseFactory->createResponse()); +$check($searchResponse->getStatusCode() === 200, 'case-insensitive language search returns 200'); +$searchPayload = json_decode((string) $searchResponse->getBody(), true, 512, JSON_THROW_ON_ERROR); +$ids = array_map('intval', array_column(is_array($searchPayload['data'] ?? null) ? $searchPayload['data'] : [], 'id')); +$check(in_array($firstId, $ids, true) && in_array($secondId, $ids, true), 'filter matches stored case and surrounding-space variants'); +$check(!in_array($deletedId, $ids, true), 'soft-deleted books are excluded from the filter'); + +echo "C. Localized error contract\n"; +foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR'] as $locale) { + $catalogue = json_decode( + (string) file_get_contents($root . '/locale/' . $locale . '.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $check(isset($catalogue['Lingue non disponibili.']) && trim((string) $catalogue['Lingue non disponibili.']) !== '', "{$locale} contains the endpoint error translation"); +} + +$cleanup(); +$db->close(); +echo "\n{$pass} PASS, 0 FAIL\n"; +exit(0); From 6d7675aaac787fcfa8e918561d6fd9905adf2388 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 23 Jul 2026 16:33:07 +0200 Subject: [PATCH 3/3] fix: use %s instead of the non-standard %n placeholder in loan-extension strings CodeRabbit flagged %n (a dangerous C sprintf specifier PHP rejects) in the bulk loan-extension count strings. They are only ever expanded via JS String.replace, so no crash occurs today, but %n is a footgun if ever passed to PHP sprintf/vsprintf. Switched to the standard %s (count) + %d (days), which sprintf accepts and the placeholder-parity check recognizes. Keeps all shared locales byte-identical across the open PRs. --- locale/de_DE.json | 4 ++-- locale/en_US.json | 4 ++-- locale/fr_FR.json | 4 ++-- locale/it_IT.json | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/locale/de_DE.json b/locale/de_DE.json index 6bc4df30a..923e6dbd0 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6594,10 +6594,10 @@ "Estendi prestiti selezionati": "Ausgewählte Ausleihen verlängern", "%d prestiti selezionati": "%d Ausleihen ausgewählt", "Estendere i prestiti selezionati?": "Ausgewählte Ausleihen verlängern?", - "%n prestiti verranno estesi di %d giorni.": "%n Ausleihen werden um %d Tage verlängert.", + "%s prestiti verranno estesi di %d giorni.": "%s Ausleihen werden um %d Tage verlängert.", "Prestiti estesi": "Ausleihen verlängert", "Nessun prestito esteso": "Keine Ausleihe verlängert", - "%n prestiti estesi di %d giorni.": "%n Ausleihen um %d Tage verlängert.", + "%s prestiti estesi di %d giorni.": "%s Ausleihen um %d Tage verlängert.", "Selezione o numero di giorni non validi.": "Ungültige Auswahl oder Anzahl der Tage.", "Estensione non riuscita, riprova.": "Verlängerung fehlgeschlagen, bitte erneut versuchen.", "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Verlängerung abgebrochen: Mindestens eine Ausleihe würde mit einer anderen Ausleihe oder Reservierung in Konflikt geraten.", diff --git a/locale/en_US.json b/locale/en_US.json index 3d5ff4a3b..ac9813d6f 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6594,10 +6594,10 @@ "Estendi prestiti selezionati": "Extend selected loans", "%d prestiti selezionati": "%d loans selected", "Estendere i prestiti selezionati?": "Extend the selected loans?", - "%n prestiti verranno estesi di %d giorni.": "%n loans will be extended by %d days.", + "%s prestiti verranno estesi di %d giorni.": "%s loans will be extended by %d days.", "Prestiti estesi": "Loans extended", "Nessun prestito esteso": "No loan extended", - "%n prestiti estesi di %d giorni.": "%n loans extended by %d days.", + "%s prestiti estesi di %d giorni.": "%s loans extended by %d days.", "Selezione o numero di giorni non validi.": "Invalid selection or number of days.", "Estensione non riuscita, riprova.": "Extension failed, please try again.", "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Extension cancelled: at least one loan would conflict with another loan or reservation.", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index df10f4cdd..117184a4d 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6594,10 +6594,10 @@ "Estendi prestiti selezionati": "Prolonger les prêts sélectionnés", "%d prestiti selezionati": "%d prêts sélectionnés", "Estendere i prestiti selezionati?": "Prolonger les prêts sélectionnés ?", - "%n prestiti verranno estesi di %d giorni.": "%n prêts seront prolongés de %d jours.", + "%s prestiti verranno estesi di %d giorni.": "%s prêts seront prolongés de %d jours.", "Prestiti estesi": "Prêts prolongés", "Nessun prestito esteso": "Aucun prêt prolongé", - "%n prestiti estesi di %d giorni.": "%n prêts prolongés de %d jours.", + "%s prestiti estesi di %d giorni.": "%s prêts prolongés de %d jours.", "Selezione o numero di giorni non validi.": "Sélection ou nombre de jours invalide.", "Estensione non riuscita, riprova.": "La prolongation a échoué, veuillez réessayer.", "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Prolongation annulée : au moins un prêt entrerait en conflit avec un autre prêt ou une réservation.", diff --git a/locale/it_IT.json b/locale/it_IT.json index 31e21b6cd..7a97a440d 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6594,10 +6594,10 @@ "Estendi prestiti selezionati": "Estendi prestiti selezionati", "%d prestiti selezionati": "%d prestiti selezionati", "Estendere i prestiti selezionati?": "Estendere i prestiti selezionati?", - "%n prestiti verranno estesi di %d giorni.": "%n prestiti verranno estesi di %d giorni.", + "%s prestiti verranno estesi di %d giorni.": "%s prestiti verranno estesi di %d giorni.", "Prestiti estesi": "Prestiti estesi", "Nessun prestito esteso": "Nessun prestito esteso", - "%n prestiti estesi di %d giorni.": "%n prestiti estesi di %d giorni.", + "%s prestiti estesi di %d giorni.": "%s prestiti estesi di %d giorni.", "Selezione o numero di giorni non validi.": "Selezione o numero di giorni non validi.", "Estensione non riuscita, riprova.": "Estensione non riuscita, riprova.", "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.",