Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions app/Controllers/Admin/LanguagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
47 changes: 27 additions & 20 deletions app/Controllers/FrontendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])) {
Expand Down Expand Up @@ -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
Expand Down
58 changes: 7 additions & 51 deletions app/Controllers/ScrapeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -594,57 +586,21 @@ 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
* @return string|null Cover image URL or null
*/
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'];
Expand All @@ -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
Expand Down
140 changes: 139 additions & 1 deletion app/Models/Language.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, true>|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<string, true>
*/
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<int, array<string, mixed>>
*/
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<string, mixed>|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<string, mixed> $lang
* @param int $total Source key count (denominator)
* @return array<string, mixed>
*/
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;
}

/**
Expand Down
Loading
Loading