diff --git a/README.md b/README.md index 908ffe613..477c80b34 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/Admin/LanguagesController.php b/app/Controllers/Admin/LanguagesController.php index 0f5937fce..be3a1cfb8 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 source-derived denominator for every locale and can't drift. + $languages = $languageModel->getAllWithDerivedStats(); // Render view content ob_start(); @@ -165,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/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index 80cac2c4e..6cdf1995f 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -940,13 +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') { - $conditions[] = "l.copie_disponibili <= 0"; + $conditions[] = "l.stato = 'prestato'"; } if (!empty($filters['anno_min'])) { @@ -1156,31 +1163,31 @@ 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/unavailable books (coerente col filtro: copie_disponibili <= 0) - $queryBorrowed = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.copie_disponibili <= 0"; - $stmt = $db->prepare($queryBorrowed); - if (!empty($paramsAvail)) { - $stmt->bind_param($typesAvail, ...$paramsAvail); - } - $stmt->execute(); - $row = $stmt->get_result()->fetch_assoc(); - $borrowedCount = $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, + 'reserved' => $reservedCount, 'borrowed' => $borrowedCount, - 'total' => $availableCount + $borrowedCount + 'total' => $totalCount ]; // Shared LEFT JOIN block so the remove-self WHERE conditions (which reference diff --git a/app/Controllers/ScrapeController.php b/app/Controllers/ScrapeController.php index 9733d4ae5..21022acc3 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/app/Models/Language.php b/app/Models/Language.php index 0614c8c72..235a0a03c 100644 --- a/app/Models/Language.php +++ b/app/Models/Language.php @@ -15,11 +15,149 @@ */ 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) + /** 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'); + } + + /** + * 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 + { + return count($this->sourceKeys()); + } + + /** + * Canonical translation keys defined by the Italian source catalogue. + * + * @return array + */ + private function sourceKeys(): array + { + if ($this->sourceKeysCache !== null) { + return $this->sourceKeysCache; + } + $path = $this->localeDir . '/' . self::SOURCE_LOCALE . '.json'; + $decoded = is_file($path) ? json_decode((string) file_get_contents($path), true) : null; + $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 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) + * @return int + */ + 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 = $this->localeDir . '/' . $code . '.json'; + if (!is_file($path)) { + return 0; + } + $decoded = json_decode((string) file_get_contents($path), true); + if (!is_array($decoded)) { + return 0; + } + // 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()); + return count(array_filter($canonicalEntries, static fn($value) => is_string($value) && $value !== '')); + } + + /** + * 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) { + $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; } /** diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index 320c540e4..01e065426 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 586a00ea1..04db8d74f 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}},

@@ -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/frontend/catalog-grid.php b/app/Views/frontend/catalog-grid.php index c5c9a3d67..70e7ba27d 100644 --- a/app/Views/frontend/catalog-grid.php +++ b/app/Views/frontend/catalog-grid.php @@ -8,13 +8,18 @@ $getBookStatusBadge = static function ($book) { ob_start(); $available = ($book['copie_disponibili'] ?? 0) > 0; - $reserved = !$available && ($book['stato'] ?? '') === 'prenotato'; + $stato = $book['stato'] ?? ''; + // "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"); - } 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); @@ -252,6 +257,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 a463b8dc0..0aeb67db5 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
+
+
+ +
+
+
+
+
+
+ +
+
+
@@ -1656,6 +1671,7 @@ class="year-slider" // Status labels 'disponibile' => __('Disponibile'), + 'prenotato' => __('Prenotato'), 'in_prestito' => __('In prestito'), // Actions @@ -1863,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') { @@ -2111,10 +2132,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/app/Views/settings/index.php b/app/Views/settings/index.php index 6b6d814c2..8aa50e04b 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 d22bdcfd5..1dcf4036f 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -6609,5 +6609,48 @@ "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", + "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 0467fea0b..229631a61 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6609,5 +6609,48 @@ "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", + "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 7c0921579..119c8383b 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6609,5 +6609,48 @@ "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", + "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 641c120ef..97045bd6a 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6609,5 +6609,48 @@ "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", + "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 125e73b5c..911ccc222 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6609,5 +6609,48 @@ "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", + "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" } diff --git a/storage/plugins/open-library/OpenLibraryPlugin.php b/storage/plugins/open-library/OpenLibraryPlugin.php index bfd5e9c83..566f5fede 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 df8449be9..b68682381 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 08c1a759f..395953221 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/catalog-borrowed-count-empty-copies.spec.js b/tests/catalog-borrowed-count-empty-copies.spec.js new file mode 100644 index 000000000..b591ff56d --- /dev/null +++ b/tests/catalog-borrowed-count-empty-copies.spec.js @@ -0,0 +1,63 @@ +// @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 +// 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'); +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); +}); diff --git a/tests/catalog-reserved-category.spec.js b/tests/catalog-reserved-category.spec.js new file mode 100644 index 000000000..b64596c3a --- /dev/null +++ b/tests/catalog-reserved-category.spec.js @@ -0,0 +1,156 @@ +// @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 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); + }); + + 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/full-test.spec.js b/tests/full-test.spec.js index 64acf6585..07720811d 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/language-derived-stats.unit.php b/tests/language-derived-stats.unit.php new file mode 100644 index 000000000..ae12fe736 --- /dev/null +++ b/tests/language-derived-stats.unit.php @@ -0,0 +1,174 @@ +set_charset('utf8mb4'); +} catch (\Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable: {$e->getMessage()}\n"); + exit(1); +} + +$srcPath = $root . '/locale/' . Language::SOURCE_LOCALE . '.json'; +$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'); +$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 (per-instance 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 (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. 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 { + // 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($tmpDir . '/zz_ZZ.json', json_encode( + [$canonicalKeys[0] => 'x', $canonicalKeys[1] => 'y', $canonicalKeys[2] => 'z', $canonicalKeys[3] => '', $canonicalKeys[4] => ''], + JSON_UNESCAPED_UNICODE + )); + $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%'); + + // Orphan-only keys (none in the source) contribute no coverage. + $big = []; + 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 { + $rmTmp($tmpDir); +} + +$db->rollback(); +$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 1c55e2293..1548a13e3 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 831a4940a..7087bc039 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(); diff --git a/tests/plugin-goodreads-cover.unit.php b/tests/plugin-goodreads-cover.unit.php new file mode 100644 index 000000000..2d7d139c2 --- /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 b9ef84980..4c7237815 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" }