From b0280c4a11b1172453eb376320909e513b909680 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 11:00:20 +0200 Subject: [PATCH 01/16] fix(plugins): stop api-book-scraper wiping its API key on blank save; gate book-club AI settings to admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api-book-scraper: PluginController::updateSettings re-saved api_key unconditionally, so any settings save without re-entering the key (toggling enabled, changing the timeout) wiped the stored key and silently disabled scraping — contradicting the UI's 'leave blank to keep the existing key'. The key is now overwritten only when a non-blank value is submitted. book-club: /admin/book-club/ai (GET + POST) was guarded only by AdminAuthMiddleware, which admits staff too, letting a staff account read the masked API key and repoint the outbound AI endpoint that AiService::generate() sends prompt content to. Added an inline tipo_utente==='admin' re-check to both handlers. --- app/Controllers/PluginController.php | 11 +++++++++-- storage/plugins/book-club/src/AiController.php | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/Controllers/PluginController.php b/app/Controllers/PluginController.php index d2f6d8aa9..3886ca351 100644 --- a/app/Controllers/PluginController.php +++ b/app/Controllers/PluginController.php @@ -380,7 +380,14 @@ public function updateSettings(Request $request, Response $response, array $args // Save all settings $this->pluginManager->setSetting($pluginId, 'api_endpoint', $apiEndpoint, true); - $this->pluginManager->setSetting($pluginId, 'api_key', $apiKey, true); + // Preserve the stored API key when the field is left blank: the admin + // modal always clears the key input on open and tells the admin that + // blank means "keep the existing key". Only overwrite when a new value + // is actually entered, otherwise a save that just toggles enabled or + // changes the timeout would silently wipe the key and disable scraping. + if ($apiKey !== '') { + $this->pluginManager->setSetting($pluginId, 'api_key', $apiKey, true); + } $this->pluginManager->setSetting($pluginId, 'timeout', (string) $timeout, true); $this->pluginManager->setSetting($pluginId, 'enabled', $enabled ? '1' : '0', true); @@ -392,7 +399,7 @@ public function updateSettings(Request $request, Response $response, array $args 'message' => __('Impostazioni API Book Scraper salvate correttamente.'), 'data' => [ 'api_endpoint' => $apiEndpoint !== '' ? 'saved' : 'empty', - 'api_key' => $apiKey !== '' ? 'saved' : 'empty', + 'api_key' => $apiKey !== '' ? 'saved' : 'preserved', 'timeout' => $timeout, 'enabled' => $enabled ] diff --git a/storage/plugins/book-club/src/AiController.php b/storage/plugins/book-club/src/AiController.php index 9348d5868..cff7741a2 100644 --- a/storage/plugins/book-club/src/AiController.php +++ b/storage/plugins/book-club/src/AiController.php @@ -224,6 +224,13 @@ public function generateMinutes(ServerRequestInterface $request, ResponseInterfa public function adminSettings(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { + // AdminAuthMiddleware admits both 'admin' and 'staff'; the AI settings + // expose the global API key and outbound endpoint, so require a real + // admin inline (a staff account must not read the masked key or repoint + // the endpoint that AiService::generate() sends prompt content to). + if (($_SESSION['user']['tipo_utente'] ?? null) !== 'admin') { + return $this->notFound($response); + } return $this->renderAdmin($response, 'admin/ai_settings', [ 'pluginFound' => $this->ai->pluginId() !== null, 'enabled' => $this->ai->isEnabled(), @@ -242,6 +249,12 @@ public function adminSettings(ServerRequestInterface $request, ResponseInterface public function adminSettingsSave(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { + // Admin-only: rotating/clearing the global API key and repointing the + // outbound endpoint must not be reachable by a 'staff' account that + // AdminAuthMiddleware would otherwise let through (see adminSettings()). + if (($_SESSION['user']['tipo_utente'] ?? null) !== 'admin') { + return $this->notFound($response); + } if ($this->ai->pluginId() === null) { $this->flash('error', __('Plugin Book Club non trovato nel registro dei plugin.')); return $this->redirect($response, '/admin/book-club/ai'); From b77883f29ffa6612bb609712a30dc03227c2d31c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 11:06:40 +0200 Subject: [PATCH 02/16] fix(plugins): route SRU + book-club AI through the guarded HttpClient; fix digital-library tabnabbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit z39-server: SruClient::fetchUrl hit admin-configured SRU endpoints with raw curl plus a file_get_contents fallback (the latter via $http_response_header, deprecated in PHP 8.5). It now goes through HttpClient — the project's centralised SSRF / DNS-rebinding / redirect-scheme hardening — preserving timeout, redirect and TLS behaviour. book-club: AiService::generate() posted the API key to an admin-configurable endpoint with raw curl. It now uses HttpClient::post with https_only and no redirects, so the x-api-key header cannot follow a 30x onto cleartext or reach a private-IP target. digital-library: the admin 'current file' preview links (file_url, audio_url) opened target=_blank without rel=noopener, enabling reverse-tabnabbing; added rel=noopener noreferrer. --- storage/plugins/book-club/src/AiService.php | 56 +++++-------- .../views/admin-form-fields.php | 4 +- .../plugins/z39-server/classes/SruClient.php | 80 +++++-------------- 3 files changed, 41 insertions(+), 99 deletions(-) diff --git a/storage/plugins/book-club/src/AiService.php b/storage/plugins/book-club/src/AiService.php index 454ed4bc9..b35f16992 100644 --- a/storage/plugins/book-club/src/AiService.php +++ b/storage/plugins/book-club/src/AiService.php @@ -156,8 +156,8 @@ public function maskedKey(): string public function generate(string $system, string $user): ?string { $apiKey = $this->apiKey(); - if ($apiKey === '' || !function_exists('curl_init')) { - SecureLogger::warning('[BookClub:ai] generate() called without API key or cURL extension'); + if ($apiKey === '') { + SecureLogger::warning('[BookClub:ai] generate() called without API key'); return null; } @@ -175,46 +175,32 @@ public function generate(string $system, string $user): ?string } try { - $ch = curl_init($this->endpoint()); - if ($ch === false) { - SecureLogger::error('[BookClub:ai] curl_init failed'); - return null; - } - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $payload, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT => 10, - CURLOPT_TIMEOUT => 20, - // SSRF/redirect/TLS hardening: HTTPS-only, no redirects, - // certificate verification always on. - CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, - CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_MAXREDIRS => 0, - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2, - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/json', - 'anthropic-version: 2023-06-01', - 'x-api-key: ' . $apiKey, - ], + // Route through the project HttpClient (centralised SSRF / + // DNS-rebinding / redirect hardening). https_only pins the scheme so + // the x-api-key header can never follow a 30x onto cleartext, and + // max_redirects=0 forbids redirects outright — the endpoint is + // admin-configurable, so it must go through the guarded client. + $res = \App\Support\HttpClient::post($this->endpoint(), $payload, [ + 'Content-Type' => 'application/json', + 'anthropic-version' => '2023-06-01', + 'x-api-key' => $apiKey, + ], [ + 'timeout' => 20, + 'connect_timeout' => 10, + 'max_redirects' => 0, + 'https_only' => true, ]); - $raw = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - $curlError = curl_error($ch); - /* curl_close(): no-op since PHP 8.0, deprecated 8.5 */ - if ($raw === false || $raw === '') { - SecureLogger::error('[BookClub:ai] HTTP request failed: ' . ($curlError !== '' ? $curlError : 'empty response')); + if (!$res['ok'] || $res['body'] === '') { + SecureLogger::error('[BookClub:ai] HTTP request failed (transport or blocked target)'); return null; } - if ($httpCode < 200 || $httpCode >= 300) { + if ($res['status'] < 200 || $res['status'] >= 300) { // Body may contain provider error details but never the key. - SecureLogger::error('[BookClub:ai] endpoint returned HTTP ' . $httpCode . ': ' . mb_substr((string) $raw, 0, 500)); + SecureLogger::error('[BookClub:ai] endpoint returned HTTP ' . $res['status'] . ': ' . mb_substr($res['body'], 0, 500)); return null; } - $data = json_decode((string) $raw, true); + $data = json_decode($res['body'], true); $text = is_array($data) ? ($data['content'][0]['text'] ?? null) : null; if (!is_string($text) || trim($text) === '') { SecureLogger::error('[BookClub:ai] unexpected response shape (no content[0].text)'); diff --git a/storage/plugins/digital-library/views/admin-form-fields.php b/storage/plugins/digital-library/views/admin-form-fields.php index b0b5d5ecc..a04789700 100644 --- a/storage/plugins/digital-library/views/admin-form-fields.php +++ b/storage/plugins/digital-library/views/admin-form-fields.php @@ -34,7 +34,7 @@
: - +
@@ -84,7 +84,7 @@ class="ui-button btn-primary flex items-center justify-center gap-2 w-full md:w-
: - +
diff --git a/storage/plugins/z39-server/classes/SruClient.php b/storage/plugins/z39-server/classes/SruClient.php index b0cf84175..a319b409e 100644 --- a/storage/plugins/z39-server/classes/SruClient.php +++ b/storage/plugins/z39-server/classes/SruClient.php @@ -283,77 +283,33 @@ private function queryServer(array $server, string $index, string $term): ?array } /** - * Fetch URL with cURL for better error handling and TLS validation + * Fetch a URL through the project HttpClient, which centralises the + * SSRF / DNS-rebinding / redirect-scheme hardening and TLS handling. + * Replaces the previous raw curl + file_get_contents fallback (the latter + * also relied on $http_response_header, deprecated in PHP 8.5). The SRU + * endpoint URLs are admin-configured, so they must go through the guarded + * client rather than hitting arbitrary hosts directly. */ private function fetchUrl(string $url): ?string { - // Try cURL first (better SSL/TLS handling) - if (function_exists('curl_init')) { - $ch = curl_init(); - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, - CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => $this->timeout, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_MAXREDIRS => 3, - CURLOPT_USERAGENT => 'Pinakes/1.0 (Z39.50/SRU Client)', - CURLOPT_SSL_VERIFYPEER => $this->verifySsl, - CURLOPT_SSL_VERIFYHOST => $this->verifySsl ? 2 : 0, - ]); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - /* curl_close(): no-op since PHP 8.0, deprecated 8.5 */ - - if ($response === false || !empty($error)) { - \App\Support\SecureLogger::error('[SruClient] cURL error', [ - 'url' => $url, - 'error' => $error, - ]); - throw new \Exception("Connection failed: $error"); - } - - if ($httpCode >= 400) { - throw new \Exception("HTTP $httpCode error from server"); - } - - return $response ?: null; - } - - // Fallback to file_get_contents - $context = stream_context_create([ - 'http' => [ - 'timeout' => $this->timeout, - 'user_agent' => 'Pinakes/1.0 (Z39.50/SRU Client)', - 'ignore_errors' => true, - ], - 'ssl' => [ - 'verify_peer' => $this->verifySsl, - 'verify_peer_name' => $this->verifySsl, - ], + $res = \App\Support\HttpClient::get($url, [], [ + 'timeout' => $this->timeout, + 'connect_timeout' => 5, + 'max_redirects' => 3, + 'user_agent' => 'Pinakes/1.0 (Z39.50/SRU Client)', + 'verify' => $this->verifySsl, ]); - $response = @file_get_contents($url, false, $context); - - if ($response === false) { - throw new \Exception("Failed to connect to server"); + if (!$res['ok']) { + \App\Support\SecureLogger::error('[SruClient] HTTP request failed', ['url' => $url]); + throw new \Exception('Connection failed'); } - // Check HTTP status from headers - if (isset($http_response_header[0])) { - if (preg_match('/HTTP\/\d+\.\d+\s+(\d+)/', $http_response_header[0], $matches)) { - $statusCode = (int)$matches[1]; - if ($statusCode >= 400) { - throw new \Exception("HTTP $statusCode error from server"); - } - } + if ($res['status'] >= 400) { + throw new \Exception("HTTP {$res['status']} error from server"); } - return $response; + return $res['body'] !== '' ? $res['body'] : null; } /** From 6da3fa26f38c3afbf0b17b532c30a7afef82c942 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 11:20:35 +0200 Subject: [PATCH 03/16] fix(plugins): rate-limit resource-sync basic auth; require admin for dewey-editor destructive actions resource-sync: the opt-in require_basic_auth gate verified admin/staff passwords with no throttling. I now rate-limit credential attempts per client IP (10/300s) via the app's RateLimiter and reset on success, and add a constant-time dummy password_verify() on the not-found path to close the account-enumeration timing side channel. dewey-editor: saveData/importData/mergeImportData/restoreBackup overwrite the sitewide Dewey classification but were guarded only by AdminAuthMiddleware, which also admits staff. I add an inline tipo_utente === 'admin' re-check (403 otherwise) to each destructive handler. --- .../dewey-editor/DeweyEditorPlugin.php | 21 ++++++++++++++++ .../resource-sync/ResourceSyncPlugin.php | 25 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/storage/plugins/dewey-editor/DeweyEditorPlugin.php b/storage/plugins/dewey-editor/DeweyEditorPlugin.php index 8e0f195df..823b24ae6 100644 --- a/storage/plugins/dewey-editor/DeweyEditorPlugin.php +++ b/storage/plugins/dewey-editor/DeweyEditorPlugin.php @@ -222,6 +222,7 @@ public function getData(Request $_request, Response $response, array $args): Res public function saveData(Request $request, Response $response, array $args): Response { + if (($denied = $this->requireAdmin($response)) !== null) { return $denied; } $locale = $args['locale'] ?? 'it_IT'; if (!$this->isLocaleSupported($locale)) { return $this->jsonError($response, __('Locale non supportato.'), 400); @@ -325,6 +326,7 @@ public function exportData(Request $_request, Response $response, array $args): public function importData(Request $request, Response $response, array $args): Response { + if (($denied = $this->requireAdmin($response)) !== null) { return $denied; } $locale = $args['locale'] ?? 'it_IT'; if (!$this->isLocaleSupported($locale)) { return $this->jsonError($response, __('Locale non supportato.'), 400); @@ -380,6 +382,7 @@ public function importData(Request $request, Response $response, array $args): R public function mergeImportData(Request $request, Response $response, array $args): Response { + if (($denied = $this->requireAdmin($response)) !== null) { return $denied; } $locale = $args['locale'] ?? 'it_IT'; if (!$this->isLocaleSupported($locale)) { return $this->jsonError($response, __('Locale non supportato.'), 400); @@ -553,6 +556,7 @@ public function listBackups(Request $_request, Response $response, array $args): public function restoreBackup(Request $request, Response $response, array $args): Response { + if (($denied = $this->requireAdmin($response)) !== null) { return $denied; } $locale = $args['locale'] ?? 'it_IT'; if (!$this->isLocaleSupported($locale)) { return $this->jsonError($response, __('Locale non supportato.'), 400); @@ -691,4 +695,21 @@ private function jsonError(Response $response, string $message, int $status, arr $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus($status); } + + /** + * Inline admin-only re-check for destructive endpoints. + * + * AdminAuthMiddleware admits both 'admin' and 'staff', but the save/import/ + * merge/restore endpoints overwrite the sitewide Dewey classification, so + * they must be limited to full administrators — mirroring the project-wide + * pattern for destructive admin actions. Returns a 403 response when the + * current session is not an admin, or null when access is allowed. + */ + private function requireAdmin(Response $response): ?Response + { + if (($_SESSION['user']['tipo_utente'] ?? null) !== 'admin') { + return $this->jsonError($response, __('Non autorizzato.'), 403); + } + return null; + } } diff --git a/storage/plugins/resource-sync/ResourceSyncPlugin.php b/storage/plugins/resource-sync/ResourceSyncPlugin.php index 30b403913..2793ef47d 100644 --- a/storage/plugins/resource-sync/ResourceSyncPlugin.php +++ b/storage/plugins/resource-sync/ResourceSyncPlugin.php @@ -5,6 +5,7 @@ namespace App\Plugins\ResourceSync; use App\Support\HookManager; +use App\Support\RateLimiter; use App\Support\SecureLogger; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -209,10 +210,25 @@ public function gateRequest( $auth = $request->getHeaderLine('Authorization'); if ($auth !== '' && str_starts_with($auth, 'Basic ')) { + // Throttle credential guessing per client IP. This is the only + // brute-force control on the Basic Auth gate, so every attempt must + // count toward the limit (mirrors the core login throttle: + // RateLimitMiddleware(15, 300, 'login') and mobile_login 10/300). + $ip = (string) ($request->getServerParams()['REMOTE_ADDR'] ?? 'unknown'); + $rlId = 'resourcesync_basic:' . $ip; + if (RateLimiter::isLimited($rlId, 10, 300)) { + $out = $response + ->withStatus(429) + ->withHeader('Retry-After', '300'); + return false; + } + $decoded = base64_decode(substr($auth, 6), true); if ($decoded !== false) { $parts = explode(':', $decoded, 2); if (count($parts) === 2 && $this->authenticateBasic($parts[0], $parts[1])) { + // Successful auth clears the throttle for this IP. + RateLimiter::reset($rlId); return true; } } @@ -245,7 +261,14 @@ private function authenticateBasic(string $email, string $pass): bool $res = $stmt->get_result(); $row = ($res instanceof \mysqli_result) ? $res->fetch_assoc() : null; $stmt->close(); - return $row !== null && password_verify($pass, (string) $row['password']); + if ($row === null) { + // Constant-time dummy verify: avoids an account-enumeration timing + // side channel between "no such admin/staff email" and "wrong + // password" by always spending one bcrypt verification. + password_verify($pass, '$2y$12$FYWkjQ0krgMEuFnovQ3C6.vL6MZP/pdGGrLm.Q1PBhX29YNNu.Bfe'); + return false; + } + return password_verify($pass, (string) $row['password']); } /** From 37965b491987155f955b84576b059eef49955029 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 11:42:39 +0200 Subject: [PATCH 04/16] fix(plugins): schema and correctness across archives, digital-library, dewey-editor, ncip-server, resource-sync, discogs, open-library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit digital-library: extract the file_url/audio_url column checks into an idempotent ensureSchema() called from BOTH onActivate() and onInstall() (upgrades re-call onActivate(), not onInstall()), and align the widths to VARCHAR(255) to match installer/database/schema.sql. Bump the class docblock @version to 1.3.1. archives: on soft-delete of an archival_unit, rewrite reference_code to a per-id token and NULL ark_identifier so the UNIQUE keys (unscoped by deleted_at) no longer block reuse — mirroring the core libri isbn soft-delete pattern. dewey-editor: use the full locale for the data filename so en_US/en_GB (pt_PT/pt_BR) no longer clobber a shared file; reads fall back to the legacy language-prefix file so existing installs keep their data, while writes migrate to the canonical full-locale path. Drop the stale max_app_version and sync the docblock @version to 1.0.1. resource-sync: point resourcelist/changelist at the BIBFRAME endpoint only when bibframe-linked-data is active, otherwise fall back to the always-available core book URL; replace the hardcoded 500 literals with self::PAGE_SIZE; drop the stale max_app_version (mirroring oai-pmh-server). ncip-server: add the partner_id/prestito_id FOREIGN KEY constraints to the ncip_transactions DDL so the plugin-created table matches schema.sql; remove the unused HtmlHelper import from partners.php and transactions.php. discogs/open-library: remove the dead activate() method (and the matching wrapper.php proxy method and onActivate() fallback branch) that registered hooks in-memory via Hooks::add(); PluginManager only drives the DB-persisted onActivate()/registerHooks() path. --- storage/plugins/archives/ArchivesPlugin.php | 15 +++- .../dewey-editor/DeweyEditorPlugin.php | 52 +++++++++++--- storage/plugins/dewey-editor/plugin.json | 1 - .../digital-library/DigitalLibraryPlugin.php | 50 +++++++++----- storage/plugins/discogs/DiscogsPlugin.php | 13 ---- storage/plugins/discogs/wrapper.php | 18 +---- .../plugins/ncip-server/NcipServerPlugin.php | 4 +- .../plugins/ncip-server/views/partners.php | 1 - .../ncip-server/views/transactions.php | 1 - .../open-library/OpenLibraryPlugin.php | 17 ----- storage/plugins/open-library/wrapper.php | 21 +----- .../resource-sync/ResourceSyncPlugin.php | 69 +++++++++++++++---- storage/plugins/resource-sync/plugin.json | 1 - 13 files changed, 155 insertions(+), 108 deletions(-) diff --git a/storage/plugins/archives/ArchivesPlugin.php b/storage/plugins/archives/ArchivesPlugin.php index e878cea35..86c07b68b 100644 --- a/storage/plugins/archives/ArchivesPlugin.php +++ b/storage/plugins/archives/ArchivesPlugin.php @@ -1822,8 +1822,21 @@ public function destroyAction( } if (!$wasInTransaction) { $db->begin_transaction(); } try { + // Free the UNIQUE-indexed identifiers on soft-delete so the codes + // can be reissued to a new/corrected record. Neither uq_reference + // (institution_code, reference_code) nor uq_ark_identifier is scoped + // by deleted_at, so leaving the values in place would permanently + // block reuse — the same reason core libri nullifies isbn10/13/ean. + // reference_code is NOT NULL, so it is replaced with a per-id token + // (rewriting it alone frees the combined key); ark_identifier is + // nullable and set to NULL (multiple NULLs are allowed in a UNIQUE + // index). $stmt = $this->db->prepare( - 'UPDATE archival_units SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL' + "UPDATE archival_units + SET deleted_at = NOW(), + reference_code = CONCAT('__deleted_', id), + ark_identifier = NULL + WHERE id = ? AND deleted_at IS NULL" ); if ($stmt === false) { SecureLogger::error('[Archives] delete prepare failed: ' . $this->db->error, ['id' => $id]); diff --git a/storage/plugins/dewey-editor/DeweyEditorPlugin.php b/storage/plugins/dewey-editor/DeweyEditorPlugin.php index 823b24ae6..73bb3c1bb 100644 --- a/storage/plugins/dewey-editor/DeweyEditorPlugin.php +++ b/storage/plugins/dewey-editor/DeweyEditorPlugin.php @@ -6,7 +6,7 @@ * Allows adding, modifying, and deleting decimal codes with validation. * * @package DeweyEditorPlugin - * @version 1.0.0 + * @version 1.0.1 */ declare(strict_types=1); @@ -206,7 +206,7 @@ public function getData(Request $_request, Response $response, array $args): Res return $this->jsonError($response, __('Locale non supportato.'), 400); } - $filePath = $this->getJsonPath($locale); + $filePath = $this->resolveDataPath($locale); if (!file_exists($filePath)) { return $this->jsonError($response, __('File Dewey non trovato.'), 404); } @@ -306,7 +306,7 @@ public function exportData(Request $_request, Response $response, array $args): return $this->jsonError($response, __('Locale non supportato.'), 400); } - $filePath = $this->getJsonPath($locale); + $filePath = $this->resolveDataPath($locale); if (!file_exists($filePath)) { return $this->jsonError($response, __('File non trovato.'), 404); } @@ -408,11 +408,13 @@ public function mergeImportData(Request $request, Response $response, array $arg return $this->jsonError($response, __('Formato dati non valido.'), 400); } - // Load existing data + // Load existing data (read may fall back to the legacy file) but always + // write the merged result to the canonical full-locale path. + $readPath = $this->resolveDataPath($locale); $filePath = $this->getJsonPath($locale); $existingData = []; - if (file_exists($filePath)) { - $existingContent = file_get_contents($filePath); + if (file_exists($readPath)) { + $existingContent = file_get_contents($readPath); if ($existingContent === false) { return $this->jsonError($response, __('Errore nella lettura del file Dewey esistente.'), 500); } @@ -592,14 +594,44 @@ public function restoreBackup(Request $request, Response $response, array $args) return $this->jsonSuccess($response, ['message' => __('Backup ripristinato con successo.')]); } + /** + * Canonical (write) path for a locale's Dewey data file. + * + * Uses the FULL locale code so distinct locales sharing a language prefix + * (e.g. en_US vs en_GB, pt_PT vs pt_BR) map to separate files and never + * clobber each other. Previously the locale was collapsed to its first two + * characters, silently aliasing such pairs onto one shared file. + */ private function getJsonPath(string $locale): string { - // Extract language code from locale (e.g., 'it_IT' -> 'it', 'en_US' -> 'en') - $langCode = strtolower(substr($locale, 0, 2)); - $filename = "dewey_completo_{$langCode}.json"; + $filename = "dewey_completo_{$locale}.json"; return $this->dataDir . '/' . $filename; } + /** + * Resolve the path to READ a locale's Dewey data. + * + * Prefers the canonical full-locale file; falls back to the legacy + * language-prefix file (e.g. dewey_completo_en.json) shipped/created before + * the full-locale scheme, so existing installs keep reading their data. + * Writes always target getJsonPath() (full locale), so the first save of a + * locale migrates it to the canonical file and separates prefix-sharing + * locales going forward. + */ + private function resolveDataPath(string $locale): string + { + $canonical = $this->getJsonPath($locale); + if (is_file($canonical)) { + return $canonical; + } + $langCode = strtolower(substr($locale, 0, 2)); + $legacy = $this->dataDir . "/dewey_completo_{$langCode}.json"; + if (is_file($legacy)) { + return $legacy; + } + return $canonical; + } + private function createBackup(string $locale): void { if (!is_dir($this->backupDir)) { @@ -609,7 +641,7 @@ private function createBackup(string $locale): void } } - $sourcePath = $this->getJsonPath($locale); + $sourcePath = $this->resolveDataPath($locale); if (!file_exists($sourcePath)) { return; } diff --git a/storage/plugins/dewey-editor/plugin.json b/storage/plugins/dewey-editor/plugin.json index afdd2d189..492d96341 100644 --- a/storage/plugins/dewey-editor/plugin.json +++ b/storage/plugins/dewey-editor/plugin.json @@ -9,7 +9,6 @@ "main_file": "DeweyEditorPlugin.php", "requires_php": "7.4", "requires_app": "0.4.0", - "max_app_version": "0.5.9.6", "settings": false, "metadata": { "category": "admin", diff --git a/storage/plugins/digital-library/DigitalLibraryPlugin.php b/storage/plugins/digital-library/DigitalLibraryPlugin.php index d3a7957a0..dac1d63c5 100644 --- a/storage/plugins/digital-library/DigitalLibraryPlugin.php +++ b/storage/plugins/digital-library/DigitalLibraryPlugin.php @@ -15,7 +15,7 @@ * - Optional and fully disableable * * @package Pinakes\Plugins\DigitalLibrary - * @version 1.1.0 + * @version 1.3.1 */ class DigitalLibraryPlugin { @@ -129,10 +129,40 @@ public function onActivate(): void ])); } + // Ensure the schema columns exist. Upgrades re-call onActivate() (not + // onInstall()) for an already-active plugin, so the schema logic must + // run here too — per the project's Plugin Schema Rule. + $this->ensureSchema(); + // Register hooks in database $this->registerHooks(); } + /** + * Ensure the digital-content columns exist on `libri`. + * + * Idempotent (SHOW COLUMNS guards make it safe to call repeatedly) and + * called from BOTH onActivate() and onInstall() so the schema is never + * skipped on an upgrade of an already-active plugin. Widths match the + * canonical installer/database/schema.sql definition (VARCHAR(255)). + */ + private function ensureSchema(): void + { + if (!$this->db) { + return; + } + + $result = $this->db->query("SHOW COLUMNS FROM libri LIKE 'file_url'"); + if ($result instanceof \mysqli_result && $result->num_rows === 0) { + $this->db->query("ALTER TABLE libri ADD COLUMN file_url VARCHAR(255) DEFAULT NULL COMMENT 'eBook file URL' AFTER note_varie"); + } + + $result = $this->db->query("SHOW COLUMNS FROM libri LIKE 'audio_url'"); + if ($result instanceof \mysqli_result && $result->num_rows === 0) { + $this->db->query("ALTER TABLE libri ADD COLUMN audio_url VARCHAR(255) DEFAULT NULL COMMENT 'Audiobook file URL' AFTER file_url"); + } + } + /** * Plugin deactivation hook */ @@ -157,22 +187,8 @@ public function onDeactivate(): void */ public function onInstall(): void { - // Verify database columns exist - if (!$this->db) { - return; - } - - $result = $this->db->query("SHOW COLUMNS FROM libri LIKE 'file_url'"); - if ($result instanceof \mysqli_result && $result->num_rows === 0) { - // Add file_url column if missing - $this->db->query("ALTER TABLE libri ADD COLUMN file_url VARCHAR(500) DEFAULT NULL COMMENT 'eBook file URL' AFTER note_varie"); - } - - $result = $this->db->query("SHOW COLUMNS FROM libri LIKE 'audio_url'"); - if ($result instanceof \mysqli_result && $result->num_rows === 0) { - // Add audio_url column if missing - $this->db->query("ALTER TABLE libri ADD COLUMN audio_url VARCHAR(500) DEFAULT NULL COMMENT 'Audiobook file URL' AFTER file_url"); - } + // Verify database columns exist (shared idempotent schema logic). + $this->ensureSchema(); } /** diff --git a/storage/plugins/discogs/DiscogsPlugin.php b/storage/plugins/discogs/DiscogsPlugin.php index eafe16ffa..3f425e798 100644 --- a/storage/plugins/discogs/DiscogsPlugin.php +++ b/storage/plugins/discogs/DiscogsPlugin.php @@ -4,8 +4,6 @@ namespace App\Plugins\Discogs; -use App\Support\Hooks; - /** * Multi-source Music Scraper Plugin (Discogs, MusicBrainz, Deezer) * @@ -37,17 +35,6 @@ public function __construct(?\mysqli $db = null, ?object $hookManager = null) $this->hookManager = $hookManager; } - /** - * Activate the plugin and register all hooks - */ - public function activate(): void - { - Hooks::add('scrape.sources', [$this, 'addDiscogsSource'], 8); - Hooks::add('scrape.fetch.custom', [$this, 'fetchFromDiscogs'], 8); - Hooks::add('scrape.data.modify', [$this, 'enrichWithDiscogsData'], 15); - Hooks::add('scrape.isbn.validate', [$this, 'validateBarcode'], 8); - } - /** * Validate barcode: accept ISBN, EAN-13, UPC-A, and Discogs Catalog Numbers. * diff --git a/storage/plugins/discogs/wrapper.php b/storage/plugins/discogs/wrapper.php index a9ca868b9..a977a1a7f 100644 --- a/storage/plugins/discogs/wrapper.php +++ b/storage/plugins/discogs/wrapper.php @@ -2,9 +2,9 @@ /** * Discogs Plugin Wrapper * - * This wrapper allows the plugin to work with both: - * 1. Direct loading via activate.php (with namespace) - * 2. PluginManager installation (without namespace) + * Exposes a global (non-namespaced) DiscogsPlugin class that proxies to the + * namespaced implementation, as required by PluginManager. Hooks are persisted + * to the DB via the namespaced onActivate()/registerHooks() path. */ // Load the main plugin file @@ -26,16 +26,6 @@ public function __construct($db = null, $hookManager = null) $this->instance = new \App\Plugins\Discogs\DiscogsPlugin($db, $hookManager); } - /** - * Activate the plugin - */ - public function activate(): void - { - if (is_callable([$this->instance, 'activate'])) { - $this->instance->activate(); - } - } - /** * Deactivate the plugin (called by PluginManager) */ @@ -65,8 +55,6 @@ public function onActivate(): void { if (is_callable([$this->instance, 'onActivate'])) { $this->instance->onActivate(); - } elseif (is_callable([$this->instance, 'activate'])) { - $this->instance->activate(); } \App\Support\SecureLogger::debug('[Discogs] Plugin activated'); } diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index 889d3e3c1..c9485ed1b 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -149,7 +149,9 @@ private static function schemaSteps(): array created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_partner (partner_id), KEY idx_status (status), - KEY idx_prestito (prestito_id) + KEY idx_prestito (prestito_id), + CONSTRAINT ncip_transactions_ibfk_1 FOREIGN KEY (partner_id) REFERENCES ncip_partners (id) ON DELETE SET NULL, + CONSTRAINT ncip_transactions_ibfk_2 FOREIGN KEY (prestito_id) REFERENCES prestiti (id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ]; } diff --git a/storage/plugins/ncip-server/views/partners.php b/storage/plugins/ncip-server/views/partners.php index 74ad97ae7..22c4f5808 100644 --- a/storage/plugins/ncip-server/views/partners.php +++ b/storage/plugins/ncip-server/views/partners.php @@ -5,7 +5,6 @@ * @var string $error * @var string $success */ -use App\Support\HtmlHelper; ?>
diff --git a/storage/plugins/ncip-server/views/transactions.php b/storage/plugins/ncip-server/views/transactions.php index b7dd9dd03..f6dd40b9f 100644 --- a/storage/plugins/ncip-server/views/transactions.php +++ b/storage/plugins/ncip-server/views/transactions.php @@ -6,7 +6,6 @@ * @var int $page * @var int $perPage */ -use App\Support\HtmlHelper; $totalPages = (int) ceil($total / max(1, $perPage)); $statusColors = [ diff --git a/storage/plugins/open-library/OpenLibraryPlugin.php b/storage/plugins/open-library/OpenLibraryPlugin.php index 47a7cca81..9d362d1bd 100644 --- a/storage/plugins/open-library/OpenLibraryPlugin.php +++ b/storage/plugins/open-library/OpenLibraryPlugin.php @@ -2,8 +2,6 @@ namespace App\Plugins\OpenLibrary; -use App\Support\Hooks; - /** * Open Library API Plugin * @@ -31,21 +29,6 @@ public function __construct(?\mysqli $db = null, ?object $hookManager = null) $this->hookManager = $hookManager; } - /** - * Activate the plugin and register all hooks - */ - public function activate(): void - { - // Add Open Library as a scraping source with high priority - Hooks::add('scrape.sources', [$this, 'addOpenLibrarySource'], 5); - - // Use custom scraping logic for Open Library API - Hooks::add('scrape.fetch.custom', [$this, 'fetchFromOpenLibrary'], 5); - - // Enrich data with additional information - Hooks::add('scrape.data.modify', [$this, 'enrichWithOpenLibraryData'], 10); - } - /** * Called when plugin is installed via PluginManager */ diff --git a/storage/plugins/open-library/wrapper.php b/storage/plugins/open-library/wrapper.php index 37bfd1d56..6d434cd59 100644 --- a/storage/plugins/open-library/wrapper.php +++ b/storage/plugins/open-library/wrapper.php @@ -2,9 +2,9 @@ /** * OpenLibrary Plugin Wrapper * - * This wrapper allows the plugin to work with both: - * 1. Direct loading via activate.php (with namespace) - * 2. PluginManager installation (without namespace) + * Exposes a global (non-namespaced) OpenLibraryPlugin class that proxies to the + * namespaced implementation, as required by PluginManager. Hooks are persisted + * to the DB via the namespaced onActivate()/registerHooks() path. */ // Load the main plugin file @@ -26,19 +26,6 @@ public function __construct($db = null, $hookManager = null) $this->instance = new \App\Plugins\OpenLibrary\OpenLibraryPlugin($db, $hookManager); } - /** - * Activate the plugin - */ - public function activate(): void - { - if (method_exists($this->instance, 'activate')) { - $this->instance->activate(); - } - } - - /** - * Activate the plugin - */ /** * Deactivate the plugin (called by PluginManager) */ @@ -71,8 +58,6 @@ public function onActivate(): void { if (method_exists($this->instance, 'onActivate')) { $this->instance->onActivate(); - } elseif (method_exists($this->instance, 'activate')) { - $this->instance->activate(); } \App\Support\SecureLogger::debug('[OpenLibrary] Plugin activated'); } diff --git a/storage/plugins/resource-sync/ResourceSyncPlugin.php b/storage/plugins/resource-sync/ResourceSyncPlugin.php index 2793ef47d..6f9696aca 100644 --- a/storage/plugins/resource-sync/ResourceSyncPlugin.php +++ b/storage/plugins/resource-sync/ResourceSyncPlugin.php @@ -31,6 +31,8 @@ class ResourceSyncPlugin private HookManager $hookManager; private \mysqli $db; private ?int $pluginId = null; + /** Per-request cache for the bibframe-linked-data active check. */ + private ?bool $bibframeActiveCache = null; public function __construct(\mysqli $db, HookManager $hookManager) { @@ -496,9 +498,8 @@ private function buildResourceList(string $base, array $books, int $page = 0): s } foreach ($books as $book) { - $id = (int) $book['id']; $modified = $this->w3cDate((string) ($book['updated_at'] ?? $book['created_at'] ?? '')); - $loc = $base . '/api/bibframe/book/' . $id; + $loc = $this->resourceLoc($base, $book); $xw->startElement('url'); $xw->writeElement('loc', $loc); @@ -554,7 +555,7 @@ private function buildChangeList(string $base, array $books, ?string $since, int // Pagination links: next/prev for harvesters $sinceParam = $since !== null ? '&from=' . urlencode($since) : ''; - if (count($books) === 500) { + if (count($books) === self::PAGE_SIZE) { $xw->startElementNs('rs', 'ln', null); $xw->writeAttribute('rel', 'next'); $xw->writeAttribute('href', $base . '/resync/changelist.xml?page=' . ($page + 1) . $sinceParam); @@ -568,7 +569,6 @@ private function buildChangeList(string $base, array $books, ?string $since, int } foreach ($books as $book) { - $id = (int) $book['id']; if ($book['deleted_at'] !== null) { $modified = $this->w3cDate((string) $book['deleted_at']); $change = 'deleted'; @@ -578,7 +578,7 @@ private function buildChangeList(string $base, array $books, ?string $since, int } $xw->startElement('url'); - $xw->writeElement('loc', $base . '/api/bibframe/book/' . $id); + $xw->writeElement('loc', $this->resourceLoc($base, $book)); $xw->writeElement('lastmod', $modified); $xw->startElementNs('rs', 'md', null); $xw->writeAttribute('change', $change); @@ -602,7 +602,7 @@ private function fetchBooks(int $page = 0): array { $offset = $page * self::PAGE_SIZE; $stmt = $this->db->prepare( - 'SELECT id, updated_at, created_at + 'SELECT id, titolo, updated_at, created_at FROM libri WHERE deleted_at IS NULL ORDER BY id ASC @@ -638,7 +638,7 @@ private function fetchChangedBooks(?string $since, int $page = 0): array // the harvester asks (?from=1970-01-01 no longer leaks every soft-deleted // id/timestamp ever recorded). $stmt = $this->db->prepare( - 'SELECT id, updated_at, created_at, deleted_at, + 'SELECT id, titolo, updated_at, created_at, deleted_at, (created_at >= ?) AS is_new_entry FROM libri WHERE (deleted_at IS NULL AND updated_at >= ?) @@ -650,13 +650,13 @@ private function fetchChangedBooks(?string $since, int $page = 0): array if ($stmt === false) { return []; } - $limit = 500; - $offset = max(0, $page) * 500; + $limit = self::PAGE_SIZE; + $offset = max(0, $page) * self::PAGE_SIZE; $stmt->bind_param('sssii', $since, $since, $since, $limit, $offset); } else { // Include recent tombstones (≤30 days) for ResourceSync — intentional exception to strict deleted_at IS NULL rule $stmt = $this->db->prepare( - 'SELECT id, updated_at, created_at, deleted_at, 0 AS is_new_entry + 'SELECT id, titolo, updated_at, created_at, deleted_at, 0 AS is_new_entry FROM libri WHERE (deleted_at IS NULL OR deleted_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) ORDER BY COALESCE(deleted_at, updated_at, created_at) DESC @@ -665,8 +665,8 @@ private function fetchChangedBooks(?string $since, int $page = 0): array if ($stmt === false) { return []; } - $limit = 500; - $offset = max(0, $page) * 500; + $limit = self::PAGE_SIZE; + $offset = max(0, $page) * self::PAGE_SIZE; $stmt->bind_param('ii', $limit, $offset); } @@ -684,6 +684,51 @@ private function baseUrl(): string return \App\Support\HtmlHelper::getBaseUrl(); } + /** + * Resolve the canonical URL for a book. + * + * Prefers the BIBFRAME JSON-LD endpoint (richer linked-data view) only when + * the bibframe-linked-data plugin is active, since that route is registered + * exclusively by that plugin. Otherwise falls back to the always-available + * core book detail URL, so ResourceSync documents never emit URLs that + * 404 for harvesters when the optional plugin is not installed/active. + * + * @param array $book + */ + private function resourceLoc(string $base, array $book): string + { + if ($this->bibframeActive()) { + return $base . '/api/bibframe/book/' . (int) $book['id']; + } + // book_path() is host-relative and WITHOUT the base path; $base already + // carries scheme+host+base path, so the concatenation is absolute. + return $base . \book_path($book); + } + + /** + * Whether the bibframe-linked-data plugin is installed and active. + * Cached per request. + */ + private function bibframeActive(): bool + { + if ($this->bibframeActiveCache !== null) { + return $this->bibframeActiveCache; + } + + $active = false; + $stmt = $this->db->prepare( + "SELECT id FROM plugins WHERE name = 'bibframe-linked-data' AND is_active = 1 LIMIT 1" + ); + if ($stmt !== false) { + $stmt->execute(); + $res = $stmt->get_result(); + $active = ($res instanceof \mysqli_result) && $res->num_rows > 0; + $stmt->close(); + } + + return $this->bibframeActiveCache = $active; + } + private function w3cDate(string $mysqlDatetime): string { if ($mysqlDatetime === '' || $mysqlDatetime === '0000-00-00 00:00:00') { diff --git a/storage/plugins/resource-sync/plugin.json b/storage/plugins/resource-sync/plugin.json index 3bfa908f4..b81881bd0 100644 --- a/storage/plugins/resource-sync/plugin.json +++ b/storage/plugins/resource-sync/plugin.json @@ -8,7 +8,6 @@ "plugin_url": "https://www.openarchives.org/rs/toc/", "requires_php": "8.1", "requires_app": "0.7.1", - "max_app_version": "0.7.9", "path": "resource-sync", "main_file": "wrapper.php", "metadata": { From 09eb8a420dd024ff2a7e306c0381b5e24cb7cd9f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 11:45:04 +0200 Subject: [PATCH 05/16] fix(plugins): align version metadata and drop stale max_app_version goodlib: sync the class docblock @version to 1.0.1 to match plugin.json, and drop the stale, unenforced max_app_version (0.5.9.6). openurl-resolver: drop the stale, unenforced max_app_version (0.7.9). max_app_version is never read by PluginManager (only requires_app is enforced), so a stale cap is misleading dead metadata; I remove it entirely, mirroring the earlier oai-pmh-server fix. --- storage/plugins/goodlib/GoodLibPlugin.php | 2 +- storage/plugins/goodlib/plugin.json | 1 - storage/plugins/openurl-resolver/plugin.json | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/storage/plugins/goodlib/GoodLibPlugin.php b/storage/plugins/goodlib/GoodLibPlugin.php index 111593845..849fd3eb2 100644 --- a/storage/plugins/goodlib/GoodLibPlugin.php +++ b/storage/plugins/goodlib/GoodLibPlugin.php @@ -10,7 +10,7 @@ * Inspired by the GoodLib browser extension. * * @package Pinakes\Plugins\GoodLib - * @version 1.0.0 + * @version 1.0.1 */ class GoodLibPlugin { diff --git a/storage/plugins/goodlib/plugin.json b/storage/plugins/goodlib/plugin.json index 94ebe3135..08621ff7b 100644 --- a/storage/plugins/goodlib/plugin.json +++ b/storage/plugins/goodlib/plugin.json @@ -9,7 +9,6 @@ "main_file": "wrapper.php", "requires_php": "8.0", "requires_app": "0.4.0", - "max_app_version": "0.5.9.6", "metadata": { "category": "discovery", "priority": 10, diff --git a/storage/plugins/openurl-resolver/plugin.json b/storage/plugins/openurl-resolver/plugin.json index b9181c16a..6928efd48 100644 --- a/storage/plugins/openurl-resolver/plugin.json +++ b/storage/plugins/openurl-resolver/plugin.json @@ -8,7 +8,6 @@ "plugin_url": "https://www.niso.org/standards-committees/openurl", "requires_php": "8.1", "requires_app": "0.7.2", - "max_app_version": "0.7.9", "path": "openurl-resolver", "main_file": "wrapper.php", "metadata": { From 6fd3ca551cc11255aa279566a50524e9f02cc98e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 12:00:18 +0200 Subject: [PATCH 06/16] Wire up api-book-scraper plugin settings page The plugin shipped a settings view at views/settings.php, but the class never implemented hasSettingsPage()/getSettingsViewPath(), so the GET /admin/plugins/{id}/settings route resolved to a 404 and the view was dead code. I add both methods to ApiBookScraperPlugin, mirroring the DiscogsPlugin pattern that PluginController::settingsPage() expects, so the existing view is now served. I also bring the view in line with the app: - all user-facing strings go through __() and are added to every shipped locale (it_IT, en_US, de_DE, fr_FR, da_DK) - output escaped with htmlspecialchars(..., ENT_QUOTES, 'UTF-8'), the admin route escaped in its href attribute - the API key field renders empty and only overwrites the stored key when a new value is submitted, so the masked display value is never round-tripped back into the encrypted setting Setting keys stay consistent with the PluginController save path: api_endpoint, api_key, timeout, enabled. --- locale/da_DK.json | 28 ++++- locale/de_DE.json | 28 ++++- locale/en_US.json | 28 ++++- locale/fr_FR.json | 28 ++++- locale/it_IT.json | 28 ++++- .../api-book-scraper/ApiBookScraperPlugin.php | 16 +++ .../api-book-scraper/views/settings.php | 118 ++++++++++-------- 7 files changed, 217 insertions(+), 57 deletions(-) diff --git a/locale/da_DK.json b/locale/da_DK.json index b7ac89fef..5279c0fae 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -6901,5 +6901,31 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "Alle værker af %s i biblioteket: gennemse de tilgængelige titler og bestil dem til udlån.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "Alle bøger udgivet af %s i vores bibliotek, tilgængelige til udlån.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Udforsk bøgerne i genren %s, som kan lånes på vores bibliotek.", - "Pagina %d": "Side %d" + "Pagina %d": "Side %d", + "API key non valida": "Ugyldig API-nøgle", + "Chiave configurata": "Nøgle konfigureret", + "Chiave configurata — lascia vuoto per mantenere": "Nøgle konfigureret — lad feltet stå tomt for at beholde den", + "Codici Risposta HTTP": "HTTP-svarkoder", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Konfigurer klienten til webtjenesten til indsamling af bogdata via et brugerdefineret API.", + "Configurazione API": "API-konfiguration", + "Documentazione Completa:": "Fuld dokumentation:", + "Errore server": "Serverfejl", + "Formato Richiesta": "Anmodningsformat", + "Formato Risposta JSON (Campi Supportati)": "JSON-svarformat (understøttede felter)", + "Guida Rapida": "Hurtig guide", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "Det har prioritet 3 (højere end Open Library, som har prioritet 5), så det forespørges først.", + "ISBN non trovato nel database": "ISBN blev ikke fundet i databasen", + "Impostazioni salvate correttamente!": "Indstillinger gemt!", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "API-nøglen krypteres med AES-256-GCM, før den gemmes i databasen.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "API-nøglen krypteres i databasen og sendes via en sikker HTTP-header.", + "Libro trovato, dati restituiti": "Bog fundet, data returneret", + "Mostra/nascondi chiave": "Vis/skjul nøgle", + "Per la guida completa su come implementare il server API, consulta il file": "Den komplette vejledning i, hvordan du implementerer API-serveren, finder du i filen", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "Dette plugin opretter forbindelse til en ekstern webtjeneste for automatisk at hente bogdata under oprettelse eller redigering.", + "Sicurezza:": "Sikkerhed:", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Maksimal ventetid på API-svaret. Anbefalet: 10 sekunder.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Testen er endnu ikke implementeret. Gem indstillingerne, og prøv at importere en bog.", + "Timeout Richiesta (secondi)": "Timeout for anmodning (sekunder)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Alle felter er valgfrie. Pluginnet bruger kun de felter, der findes i svaret.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Brug pladsholderen {isbn} i URL'en; ellers tilføjes ISBN som parameteren ?isbn=..." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 790157fdf..a581a52c8 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6901,5 +6901,31 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "Alle Werke von %s in der Bibliothek: stöbern Sie in den verfügbaren Titeln und leihen Sie sie aus.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "Alle von %s veröffentlichten Bücher in unserer Bibliothek, zum Ausleihen verfügbar.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Entdecken Sie die Bücher des Genres %s, die in unserer Bibliothek zum Ausleihen verfügbar sind.", - "Pagina %d": "Seite %d" + "Pagina %d": "Seite %d", + "API key non valida": "Ungültiger API-Schlüssel", + "Chiave configurata": "Schlüssel konfiguriert", + "Chiave configurata — lascia vuoto per mantenere": "Schlüssel konfiguriert — leer lassen, um ihn beizubehalten", + "Codici Risposta HTTP": "HTTP-Antwortcodes", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Konfigurieren Sie den Client für den Webdienst zum Auslesen von Buchdaten über eine benutzerdefinierte API.", + "Configurazione API": "API-Konfiguration", + "Documentazione Completa:": "Vollständige Dokumentation:", + "Errore server": "Serverfehler", + "Formato Richiesta": "Anfrageformat", + "Formato Risposta JSON (Campi Supportati)": "JSON-Antwortformat (unterstützte Felder)", + "Guida Rapida": "Kurzanleitung", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "Es hat Priorität 3 (höher als Open Library mit Priorität 5) und wird daher zuerst abgefragt.", + "ISBN non trovato nel database": "ISBN nicht in der Datenbank gefunden", + "Impostazioni salvate correttamente!": "Einstellungen erfolgreich gespeichert!", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "Der API-Schlüssel wird mit AES-256-GCM verschlüsselt, bevor er in der Datenbank gespeichert wird.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "Der API-Schlüssel wird in der Datenbank verschlüsselt und über einen sicheren HTTP-Header übertragen.", + "Libro trovato, dati restituiti": "Buch gefunden, Daten zurückgegeben", + "Mostra/nascondi chiave": "Schlüssel anzeigen/ausblenden", + "Per la guida completa su come implementare il server API, consulta il file": "Die vollständige Anleitung zur Implementierung des API-Servers finden Sie in der Datei", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "Dieses Plugin verbindet sich mit einem externen Webdienst, um Buchdaten beim Erstellen oder Bearbeiten automatisch abzurufen.", + "Sicurezza:": "Sicherheit:", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Maximale Wartezeit auf die API-Antwort. Empfohlen: 10 Sekunden.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test noch nicht implementiert. Speichern Sie die Einstellungen und versuchen Sie, ein Buch zu importieren.", + "Timeout Richiesta (secondi)": "Anfrage-Timeout (Sekunden)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Alle Felder sind optional. Das Plugin verwendet nur die in der Antwort vorhandenen Felder.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Verwenden Sie den Platzhalter {isbn} in der URL; andernfalls wird die ISBN als Parameter ?isbn=... angehängt." } diff --git a/locale/en_US.json b/locale/en_US.json index 78c9392c1..f0869d704 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6901,5 +6901,31 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "All works by %s in the library: browse the available titles and request them on loan.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "All books published by %s in our library, available for loan.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Explore the books in the %s genre available for loan in our library.", - "Pagina %d": "Page %d" + "Pagina %d": "Page %d", + "API key non valida": "Invalid API key", + "Chiave configurata": "Key configured", + "Chiave configurata — lascia vuoto per mantenere": "Key configured — leave empty to keep", + "Codici Risposta HTTP": "HTTP Response Codes", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Configure the client for the book data scraping web service via a custom API.", + "Configurazione API": "API Configuration", + "Documentazione Completa:": "Full Documentation:", + "Errore server": "Server error", + "Formato Richiesta": "Request Format", + "Formato Risposta JSON (Campi Supportati)": "JSON Response Format (Supported Fields)", + "Guida Rapida": "Quick Guide", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "It has priority 3 (higher than Open Library, which has priority 5), so it is queried first.", + "ISBN non trovato nel database": "ISBN not found in the database", + "Impostazioni salvate correttamente!": "Settings saved successfully!", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "The API key is encrypted with AES-256-GCM before being saved to the database.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "The API key is encrypted in the database and transmitted via a secure HTTP header.", + "Libro trovato, dati restituiti": "Book found, data returned", + "Mostra/nascondi chiave": "Show/hide key", + "Per la guida completa su come implementare il server API, consulta il file": "For the complete guide on how to implement the API server, see the file", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "This plugin connects to an external web service to automatically retrieve book data during creation or editing.", + "Sicurezza:": "Security:", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Maximum wait time for the API response. Recommended: 10 seconds.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test not yet implemented. Save the settings and try importing a book.", + "Timeout Richiesta (secondi)": "Request Timeout (seconds)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "All fields are optional. The plugin will only use the fields present in the response.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Use the {isbn} placeholder in the URL; otherwise the ISBN is appended as an ?isbn=... parameter." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 21c83078e..26f0e8c77 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6901,5 +6901,31 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "Toutes les œuvres de %s présentes dans la bibliothèque : parcourez les titres disponibles et demandez-les en prêt.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "Tous les livres publiés par %s présents dans notre bibliothèque, disponibles au prêt.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Explorez les livres du genre %s disponibles au prêt dans notre bibliothèque.", - "Pagina %d": "Page %d" + "Pagina %d": "Page %d", + "API key non valida": "Clé API non valide", + "Chiave configurata": "Clé configurée", + "Chiave configurata — lascia vuoto per mantenere": "Clé configurée — laisser vide pour la conserver", + "Codici Risposta HTTP": "Codes de réponse HTTP", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Configurez le client pour le service web de récupération de données de livres via une API personnalisée.", + "Configurazione API": "Configuration de l'API", + "Documentazione Completa:": "Documentation complète :", + "Errore server": "Erreur du serveur", + "Formato Richiesta": "Format de la requête", + "Formato Risposta JSON (Campi Supportati)": "Format de réponse JSON (champs pris en charge)", + "Guida Rapida": "Guide rapide", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "Il a la priorité 3 (supérieure à Open Library qui a la priorité 5), il est donc interrogé en premier.", + "ISBN non trovato nel database": "ISBN introuvable dans la base de données", + "Impostazioni salvate correttamente!": "Paramètres enregistrés avec succès !", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "La clé API est chiffrée avec AES-256-GCM avant d'être enregistrée dans la base de données.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "La clé API est chiffrée dans la base de données et transmise via un en-tête HTTP sécurisé.", + "Libro trovato, dati restituiti": "Livre trouvé, données renvoyées", + "Mostra/nascondi chiave": "Afficher/masquer la clé", + "Per la guida completa su come implementare il server API, consulta il file": "Pour le guide complet sur la mise en œuvre du serveur API, consultez le fichier", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "Ce plugin se connecte à un service web externe pour récupérer automatiquement les données des livres lors de la création ou de la modification.", + "Sicurezza:": "Sécurité :", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Temps d'attente maximal pour la réponse de l'API. Recommandé : 10 secondes.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test pas encore implémenté. Enregistrez les paramètres et essayez d'importer un livre.", + "Timeout Richiesta (secondi)": "Délai d'expiration de la requête (secondes)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Tous les champs sont facultatifs. Le plugin n'utilisera que les champs présents dans la réponse.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Utilisez le paramètre fictif {isbn} dans l'URL ; sinon l'ISBN est ajouté comme paramètre ?isbn=..." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 9c39c85fe..745f4937f 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6901,5 +6901,31 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.", - "Pagina %d": "Pagina %d" + "Pagina %d": "Pagina %d", + "API key non valida": "API key non valida", + "Chiave configurata": "Chiave configurata", + "Chiave configurata — lascia vuoto per mantenere": "Chiave configurata — lascia vuoto per mantenere", + "Codici Risposta HTTP": "Codici Risposta HTTP", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.", + "Configurazione API": "Configurazione API", + "Documentazione Completa:": "Documentazione Completa:", + "Errore server": "Errore server", + "Formato Richiesta": "Formato Richiesta", + "Formato Risposta JSON (Campi Supportati)": "Formato Risposta JSON (Campi Supportati)", + "Guida Rapida": "Guida Rapida", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.", + "ISBN non trovato nel database": "ISBN non trovato nel database", + "Impostazioni salvate correttamente!": "Impostazioni salvate correttamente!", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.", + "Libro trovato, dati restituiti": "Libro trovato, dati restituiti", + "Mostra/nascondi chiave": "Mostra/nascondi chiave", + "Per la guida completa su come implementare il server API, consulta il file": "Per la guida completa su come implementare il server API, consulta il file", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.", + "Sicurezza:": "Sicurezza:", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.", + "Timeout Richiesta (secondi)": "Timeout Richiesta (secondi)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=..." } diff --git a/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php b/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php index 85ab878c1..d81f07952 100644 --- a/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php +++ b/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php @@ -608,4 +608,20 @@ public function getSettings(): array 'enabled' => $this->enabled ]; } + + /** + * Whether this plugin has a dedicated settings page + */ + public function hasSettingsPage(): bool + { + return true; + } + + /** + * Get the path to the settings view file + */ + public function getSettingsViewPath(): string + { + return __DIR__ . '/views/settings.php'; + } } diff --git a/storage/plugins/api-book-scraper/views/settings.php b/storage/plugins/api-book-scraper/views/settings.php index 8402e0bb4..de99e60a4 100644 --- a/storage/plugins/api-book-scraper/views/settings.php +++ b/storage/plugins/api-book-scraper/views/settings.php @@ -4,7 +4,7 @@ */ // Recupera il plugin -$plugin = $GLOBALS['plugins']['api-book-scraper'] ?? null; +$plugin = $pluginInstance ?? ($GLOBALS['plugins']['api-book-scraper'] ?? null); if (!$plugin) { echo '
Errore: Plugin non caricato correttamente.
'; return; @@ -14,35 +14,43 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_api_scraper_settings'])) { if (\App\Support\Csrf::validate($_POST['csrf_token'] ?? null)) { $settings = [ - 'api_endpoint' => $_POST['api_endpoint'] ?? '', - 'api_key' => $_POST['api_key'] ?? '', - 'timeout' => $_POST['timeout'] ?? 10, - 'enabled' => isset($_POST['enabled']) ? true : false + 'api_endpoint' => trim((string) ($_POST['api_endpoint'] ?? '')), + 'timeout' => max(5, min(60, (int) ($_POST['timeout'] ?? 10))), + 'enabled' => isset($_POST['enabled']), ]; + // Only overwrite the stored API key when a new value is submitted; + // an empty field means "keep the existing key" (the input renders empty + // so the encrypted key is never round-tripped through the form). + $submittedKey = trim((string) ($_POST['api_key'] ?? '')); + if ($submittedKey !== '') { + $settings['api_key'] = $submittedKey; + } + if ($plugin->saveSettings($settings)) { - $successMessage = 'Impostazioni salvate correttamente!'; + $successMessage = __('Impostazioni salvate correttamente!'); } else { - $errorMessage = 'Errore nel salvataggio delle impostazioni.'; + $errorMessage = __('Errore nel salvataggio delle impostazioni.'); } } else { - $errorMessage = 'Token CSRF non valido.'; + $errorMessage = __('Token CSRF non valido.'); } } // Test connessione $testResult = null; if (isset($_POST['test_connection']) && \App\Support\Csrf::validate($_POST['csrf_token'] ?? null)) { - $testIsbn = '9788804668619'; // ISBN di test // In produzione, qui chiameremmo l'API per testare la connessione $testResult = [ 'success' => false, - 'message' => 'Funzione di test non ancora implementata. Salva le impostazioni e prova a importare un libro.' + 'message' => __('Test non ancora implementato. Salva le impostazioni e prova a importare un libro.'), ]; } $currentSettings = $plugin->getSettings(); +$hasApiKey = !empty($currentSettings['api_key']); $csrfToken = \App\Support\Csrf::ensureToken(); +$pluginsRoute = htmlspecialchars(url('/admin/plugins'), ENT_QUOTES, 'UTF-8'); ?>
@@ -50,10 +58,10 @@

- API Book Scraper - Configurazione + API Book Scraper -

- Configura il client per il servizio web di scraping dati libri tramite API personalizzata. +

@@ -61,14 +69,14 @@
- +
- +
@@ -80,7 +88,7 @@ : 'bg-yellow-50 border border-yellow-200 text-yellow-800'; ?>
- Test Connessione: + :
@@ -89,11 +97,11 @@
-

Informazioni Plugin

+

-

Funzionamento: Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.

-

Priorità: Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.

-

Sicurezza: L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.

+

+

+

@@ -101,14 +109,14 @@
- +

- Configurazione API +

@@ -116,20 +124,22 @@

- Suggerimento: Usa {isbn} come placeholder per l'ISBN. - Esempio: https://api.example.com/books/{isbn} - oppure https://api.example.com/books/search (ISBN verrà aggiunto come parametro ?isbn=...) + + +

+

+ https://api.example.com/books/{isbn}

@@ -137,25 +147,29 @@ class="block w-full rounded-xl border-gray-300 focus:border-blue-500 focus:ring-
+ placeholder="" + >
+ + +

- L'API key viene criptata con AES-256-GCM prima di essere salvata nel database. +

@@ -163,7 +177,7 @@ class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-7
- secondi (min: 5, max: 60) +

- Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi. +

@@ -192,10 +206,10 @@ class="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
- Abilita Plugin +

- Quando abilitato, il plugin interrogherà l'API durante l'importazione dati libri. +

@@ -210,7 +224,7 @@ class="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"> value="1" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-blue-600 text-white text-sm font-semibold hover:bg-blue-700 transition-colors"> - Salva Impostazioni + - - Torna ai Plugin +
@@ -235,14 +249,14 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gray-100 text-gray

- Guida Rapida +

-

1. Formato Richiesta

+

1.

GET {api_endpoint}?isbn={ISBN}
Header: X-API-Key: {api_key} @@ -250,7 +264,7 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gray-100 text-gray
-

2. Formato Risposta JSON (Campi Supportati)

+

2.

{
  "success": true,
@@ -273,17 +287,17 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gray-100 text-gray }

- Nota: Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta. +

-

3. Codici Risposta HTTP

+

3.

    -
  • 200 OK - Libro trovato, dati restituiti
  • -
  • 404 Not Found - ISBN non trovato nel database
  • -
  • 401 Unauthorized - API key non valida
  • -
  • 500 Server Error - Errore server
  • +
  • 200 OK -
  • +
  • 404 Not Found -
  • +
  • 401 Unauthorized -
  • +
  • 500 Server Error -
@@ -291,8 +305,8 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gray-100 text-gray
- Documentazione Completa: - Per la guida completa su come implementare il server API, consulta il file + + storage/plugins/api-book-scraper/SERVER_IMPLEMENTATION_GUIDE.md
From 814db6ddf88996f08cb954cf93999d02b2c85ef6 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 12:11:58 +0200 Subject: [PATCH 07/16] Wire UNIMARC import to complete the advertised round-trip (z39-server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The z39-server plugin advertises "round-trip UNIMARC" but only the export direction was wired: UnimarcLibriParser::fromUnimarcXml() was never reached from any route or hook, so the import half of the round-trip did not exist. I add the inverse of the existing export.unimarc.xml endpoint: - POST /admin/books/import-unimarc — accepts a pasted record or an uploaded .xml file, runs it through fromUnimarcXml(), and returns the parsed libri fields as JSON. Like import-sbn it only pre-fills the book form; it never writes to libri, so book creation stays on the standard catalogue validation/soft-delete/duplicate path. - An "Import UNIMARC (MARCXchange)" block in the REICAT/SBN panel of the book form (textarea + file upload), mirroring the SBN import block. Its JS pre-fills the same form fields the SBN import does, plus edizione, lingua, numero_serie, and the Nuovo Soggettario subject chips parsed from 606. Route is CSRF + admin gated, matching import-sbn. New user-facing strings go through __() and are added to all five locale files. --- locale/da_DK.json | 6 ++ locale/de_DE.json | 6 ++ locale/en_US.json | 6 ++ locale/fr_FR.json | 6 ++ locale/it_IT.json | 6 ++ .../plugins/z39-server/Z39ServerPlugin.php | 55 +++++++++++++ storage/plugins/z39-server/plugin.json | 1 + .../z39-server/views/reicat/book-fields.php | 81 +++++++++++++++++++ 8 files changed, 167 insertions(+) diff --git a/locale/da_DK.json b/locale/da_DK.json index 5279c0fae..2cb443093 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "Importér UNIMARC (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Indsæt en UNIMARC/MARCXchange-post, eller upload en .xml-fil, for at udfylde formularen ud fra den eksporterede post.", + "Incolla o carica un record UNIMARC.": "Indsæt eller upload en UNIMARC-post.", + "Dati importati da UNIMARC.": "Data importeret fra UNIMARC.", + "Record UNIMARC non valido o non riconosciuto.": "Ugyldig eller ukendt UNIMARC-post.", + "Nessun dato UNIMARC valido da importare.": "Ingen gyldige UNIMARC-data at importere.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "En bruger med e-mailadressen %s findes allerede. Hvis du geninstallerer på en eksisterende database, skal du logge ind med de eksisterende legitimationsoplysninger eller bruge en anden e-mailadresse.", " libri": " bøger", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" udlånt til %s er %d dage forsinket", diff --git a/locale/de_DE.json b/locale/de_DE.json index a581a52c8..c67fbb81e 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "UNIMARC importieren (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Fügen Sie einen UNIMARC/MARCXchange-Datensatz ein oder laden Sie eine .xml-Datei hoch, um das Formular aus dem exportierten Datensatz vorauszufüllen.", + "Incolla o carica un record UNIMARC.": "Fügen Sie einen UNIMARC-Datensatz ein oder laden Sie ihn hoch.", + "Dati importati da UNIMARC.": "Daten aus UNIMARC importiert.", + "Record UNIMARC non valido o non riconosciuto.": "Ungültiger oder nicht erkannter UNIMARC-Datensatz.", + "Nessun dato UNIMARC valido da importare.": "Keine gültigen UNIMARC-Daten zum Importieren.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "Ein Benutzer mit der E-Mail-Adresse %s existiert bereits. Wenn Sie auf einer bestehenden Datenbank neu installieren, melden Sie sich mit den vorhandenen Zugangsdaten an oder verwenden Sie eine andere E-Mail-Adresse.", " libri": " Bücher", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" ausgeliehen an %s ist %d Tage überfällig", diff --git a/locale/en_US.json b/locale/en_US.json index f0869d704..c64c55ed8 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "Import UNIMARC (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Paste a UNIMARC/MARCXchange record, or upload an .xml file, to pre-fill the form from the exported record.", + "Incolla o carica un record UNIMARC.": "Paste or upload a UNIMARC record.", + "Dati importati da UNIMARC.": "Data imported from UNIMARC.", + "Record UNIMARC non valido o non riconosciuto.": "Invalid or unrecognised UNIMARC record.", + "Nessun dato UNIMARC valido da importare.": "No valid UNIMARC data to import.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "A user with the email %s already exists. If you are reinstalling over an existing database, log in with the existing credentials or use a different email.", " libri": " books", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" loaned to %s is %d days overdue", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 26f0e8c77..a8b703fab 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "Importer UNIMARC (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Collez une notice UNIMARC/MARCXchange, ou téléversez un fichier .xml, pour préremplir le formulaire à partir de la notice exportée.", + "Incolla o carica un record UNIMARC.": "Collez ou téléversez une notice UNIMARC.", + "Dati importati da UNIMARC.": "Données importées depuis UNIMARC.", + "Record UNIMARC non valido o non riconosciuto.": "Notice UNIMARC invalide ou non reconnue.", + "Nessun dato UNIMARC valido da importare.": "Aucune donnée UNIMARC valide à importer.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "Un utilisateur avec l'e-mail %s existe déjà. Si vous réinstallez sur une base de données existante, connectez-vous avec les identifiants existants ou utilisez une autre adresse e-mail.", " libri": " livres", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" emprunté par %s est en retard de %d jours", diff --git a/locale/it_IT.json b/locale/it_IT.json index 745f4937f..6c048ea77 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "Importa UNIMARC (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.", + "Incolla o carica un record UNIMARC.": "Incolla o carica un record UNIMARC.", + "Dati importati da UNIMARC.": "Dati importati da UNIMARC.", + "Record UNIMARC non valido o non riconosciuto.": "Record UNIMARC non valido o non riconosciuto.", + "Nessun dato UNIMARC valido da importare.": "Nessun dato UNIMARC valido da importare.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.", " libri": " libri", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" prestato a %s è in ritardo di %d giorni", diff --git a/storage/plugins/z39-server/Z39ServerPlugin.php b/storage/plugins/z39-server/Z39ServerPlugin.php index c3a121708..915962f05 100644 --- a/storage/plugins/z39-server/Z39ServerPlugin.php +++ b/storage/plugins/z39-server/Z39ServerPlugin.php @@ -702,6 +702,16 @@ public function registerRoutes($app): void return $plugin->bulkImportSbnAction($request, $response); })->add($csrfMiddleware)->add($adminMiddleware); + // POST /admin/books/import-unimarc — parse a UNIMARC/MARCXchange record + // (paste or .xml upload) into libri fields for the form to pre-fill. + // Closes the round-trip advertised alongside export.unimarc.xml. + $app->post('/admin/books/import-unimarc', function ( + ServerRequestInterface $request, + ResponseInterface $response + ) use ($plugin): ResponseInterface { + return $plugin->importUnimarcAction($request, $response); + })->add($csrfMiddleware)->add($adminMiddleware); + // GET /admin/books/{id}/export.unimarc.xml — single-book UNIMARC export. $app->get('/admin/books/{id:[0-9]+}/export.unimarc.xml', function ( ServerRequestInterface $request, @@ -741,6 +751,7 @@ public function registerRoutes($app): void 'routes' => [ '/api/sru', '/api/sbn/search', '/admin/books/import-sbn', '/admin/books/bulk-import-sbn', + '/admin/books/import-unimarc', '/admin/books/{id}/export.unimarc.xml', '/admin/books/soggettario-search', '/admin/authors/{id}/lookup-ccn', '/admin/authors/{id}/apply-authority', ] @@ -1298,6 +1309,50 @@ public function bulkImportSbnAction(ServerRequestInterface $request, ResponseInt ]); } + /** + * POST /admin/books/import-unimarc — parse a UNIMARC/MARCXchange record. + * + * The inverse of {@see exportUnimarcAction()}: accepts a pasted record or an + * uploaded `.xml` file, runs it through {@see UnimarcLibriParser::fromUnimarcXml()} + * and returns the parsed libri fields for the book form to pre-fill (copy + * cataloguing). It never writes to `libri`; book creation stays on the + * standard catalogue validation flow, exactly like the SBN import. + */ + public function importUnimarcAction(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $this->loadReicatClasses(); + $body = (array) $request->getParsedBody(); + $xml = trim((string) ($body['xml'] ?? '')); + + // Accept an uploaded .xml file as an alternative to pasting. + $files = $request->getUploadedFiles(); + if ($xml === '' && isset($files['xml_file']) + && $files['xml_file'] instanceof \Psr\Http\Message\UploadedFileInterface + && $files['xml_file']->getError() === UPLOAD_ERR_OK) { + $xml = trim((string) $files['xml_file']->getStream()); + } + + if ($xml === '') { + return $this->jsonResponse($response, ['success' => false, 'error' => __('Incolla o carica un record UNIMARC.')], 400); + } + + try { + $parser = new \Z39Server\UnimarcLibriParser(); + $book = $parser->fromUnimarcXml($xml); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('[Z39 import-unimarc] error', ['error' => $e->getMessage()]); + return $this->jsonResponse($response, ['success' => false, 'error' => __('Record UNIMARC non valido o non riconosciuto.')], 422); + } + + // fromUnimarcXml() returns [] on unparseable XML and may return a record + // with no bibliographic core if the tags are unrecognised. + if (!isset($book['titolo']) && !isset($book['isbn13']) && !isset($book['isbn10'])) { + return $this->jsonResponse($response, ['success' => false, 'error' => __('Nessun dato UNIMARC valido da importare.')], 422); + } + + return $this->jsonResponse($response, ['success' => true, 'book' => $book]); + } + /** * GET /admin/books/{id}/export.unimarc.xml — UNIMARC (MARCXchange) export. */ diff --git a/storage/plugins/z39-server/plugin.json b/storage/plugins/z39-server/plugin.json index e39cb8772..eec7f5a0f 100644 --- a/storage/plugins/z39-server/plugin.json +++ b/storage/plugins/z39-server/plugin.json @@ -53,6 +53,7 @@ "/api/sbn/search - Ricerca SBN (OPAC Nazionale)", "/admin/books/import-sbn - Import UNIMARC da SBN per ISBN/BID", "/admin/books/bulk-import-sbn - Import batch da CSV di ISBN", + "/admin/books/import-unimarc - Import UNIMARC/MARCXchange per precompilare il form (round-trip)", "/admin/books/{id}/export.unimarc.xml - Export UNIMARC del singolo libro", "/admin/authors/{id}/lookup-ccn - Lookup authority CCN dell'autore" ], diff --git a/storage/plugins/z39-server/views/reicat/book-fields.php b/storage/plugins/z39-server/views/reicat/book-fields.php index 7acbfcf2f..feddf3ea2 100644 --- a/storage/plugins/z39-server/views/reicat/book-fields.php +++ b/storage/plugins/z39-server/views/reicat/book-fields.php @@ -61,6 +61,29 @@ class="btn btn-primary flex items-center justify-center gap-2">
+ +
+ +

+ +

+ +
+ + +
+
+ +
@@ -170,6 +193,8 @@ function openReicat() { imported: , error: , removeSubject: , + noUnimarc: , + unimarcImported: , }; function clearEl(el) { while (el.firstChild) { el.removeChild(el.firstChild); } } @@ -329,5 +354,61 @@ function status(msg, kind) { .catch(function () { status(T.error, 'err'); }) .finally(function () { btn.disabled = false; }); }); + + // ── Import from UNIMARC/MARCXchange (round-trip with the export) ────────── + function unimarcStatus(msg, kind) { + const box = document.getElementById('reicat-unimarc-status'); + box.textContent = msg; + box.className = 'text-sm mb-4 ' + (kind === 'err' ? 'text-red-600' : (kind === 'ok' ? 'text-emerald-700' : 'text-gray-600')); + box.classList.remove('hidden'); + } + + document.getElementById('reicat-import-unimarc-btn').addEventListener('click', function () { + const btn = this; + const ta = document.getElementById('reicat_import_unimarc'); + const fileInput = document.getElementById('reicat_import_unimarc_file'); + const xml = (ta && ta.value ? ta.value : '').trim(); + const file = (fileInput && fileInput.files && fileInput.files.length) ? fileInput.files[0] : null; + if (!xml && !file) { unimarcStatus(T.noUnimarc, 'err'); return; } + + btn.disabled = true; + unimarcStatus(T.importing, 'info'); + + const fd = new FormData(); + fd.set('csrf_token', csrf); + if (xml) { fd.set('xml', xml); } + if (file) { fd.set('xml_file', file); } + + fetch(base + '/admin/books/import-unimarc', { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-CSRF-Token': csrf }, + body: fd + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d || !d.success || !d.book) { unimarcStatus((d && d.error) ? d.error : T.error, 'err'); return; } + const b = d.book; + setField('titolo', b.titolo); + setField('sottotitolo', b.sottotitolo); + setField('anno_pubblicazione', b.anno_pubblicazione); + setField('numero_pagine', b.numero_pagine); + setField('isbn13', b.isbn13); + setField('isbn10', b.isbn10); + setField('collana', b.collana); + setField('numero_serie', b.numero_serie); + setField('edizione', b.edizione); + setField('lingua', b.lingua); + if (Array.isArray(b.soggetti)) { + b.soggetti.forEach(function (s) { + if (s && s.termine) { addSubject({ termine: s.termine, bncf_id: s.bncf_id || null, uri: s.uri || null }); } + }); + } + openReicat(); + unimarcStatus(T.unimarcImported, 'ok'); + }) + .catch(function () { unimarcStatus(T.error, 'err'); }) + .finally(function () { btn.disabled = false; }); + }); })(); From dc8eaa8625a9429e39b59cbc73f64df221d4ea72 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 12:25:03 +0200 Subject: [PATCH 08/16] Surface VIAF/ISNI authority endpoints in the author edit UI (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VIAF suggest and set-VIAF/ISNI endpoints had no entry point in the shipped UI: they were reachable only via raw HTTP or a third-party OpenRefine client. I add a VIAF / ISNI authority widget to the author edit page, rendered through the existing author.form.fields action hook (the same hook z39-server uses for its SBN panel) so no core view is touched. The widget shows the current VIAF ID and ISNI, a Search VIAF button that queries GET /api/viaf/suggest and lists candidates, and applying a candidate (or a manual Save) persists via POST /api/viaf/author/{id}/set. It reuses the existing endpoints — no parallel ones are added. Markup and Tailwind classes are copied verbatim from the sibling authority panel; every string goes through __() and is added to all five locales. --- locale/da_DK.json | 15 +- locale/de_DE.json | 15 +- locale/en_US.json | 15 +- locale/fr_FR.json | 15 +- locale/it_IT.json | 15 +- .../viaf-authority/ViafAuthorityPlugin.php | 25 ++ .../viaf-authority/views/author-fields.php | 219 ++++++++++++++++++ 7 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 storage/plugins/viaf-authority/views/author-fields.php diff --git a/locale/da_DK.json b/locale/da_DK.json index 2cb443093..192d58b13 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -6933,5 +6933,18 @@ "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Testen er endnu ikke implementeret. Gem indstillingerne, og prøv at importere en bog.", "Timeout Richiesta (secondi)": "Timeout for anmodning (sekunder)", "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Alle felter er valgfrie. Pluginnet bruger kun de felter, der findes i svaret.", - "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Brug pladsholderen {isbn} i URL'en; ellers tilføjes ISBN som parameteren ?isbn=..." + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Brug pladsholderen {isbn} i URL'en; ellers tilføjes ISBN som parameteren ?isbn=...", + "Authority control VIAF / ISNI": "VIAF-/ISNI-autoritetskontrol", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Knyt forfatteren til Virtual International Authority File (VIAF) og til ISNI. Søg efter navn for at finde en identifikator, eller indtast den manuelt, og gem.", + "VIAF ID": "VIAF-id", + "es. 102333412": "f.eks. 102333412", + "Apri in VIAF": "Åbn i VIAF", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "f.eks. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 cifre (det sidste kan være et X).", + "Cerca su VIAF": "Søg i VIAF", + "Salva VIAF / ISNI": "Gem VIAF / ISNI", + "Ricerca su VIAF…": "Søger i VIAF…", + "Nessun risultato trovato su VIAF.": "Ingen resultater fundet i VIAF.", + "Inserisci prima il nome dell'autore.": "Indtast forfatterens navn først." } diff --git a/locale/de_DE.json b/locale/de_DE.json index c67fbb81e..fb3c8a44e 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6933,5 +6933,18 @@ "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test noch nicht implementiert. Speichern Sie die Einstellungen und versuchen Sie, ein Buch zu importieren.", "Timeout Richiesta (secondi)": "Anfrage-Timeout (Sekunden)", "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Alle Felder sind optional. Das Plugin verwendet nur die in der Antwort vorhandenen Felder.", - "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Verwenden Sie den Platzhalter {isbn} in der URL; andernfalls wird die ISBN als Parameter ?isbn=... angehängt." + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Verwenden Sie den Platzhalter {isbn} in der URL; andernfalls wird die ISBN als Parameter ?isbn=... angehängt.", + "Authority control VIAF / ISNI": "VIAF-/ISNI-Normdaten", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Verknüpfe den Autor mit dem Virtual International Authority File (VIAF) und mit ISNI. Suche nach Namen, um einen Bezeichner zu finden, oder gib ihn manuell ein und speichere.", + "VIAF ID": "VIAF-ID", + "es. 102333412": "z. B. 102333412", + "Apri in VIAF": "In VIAF öffnen", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "z. B. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 Ziffern (die letzte kann ein X sein).", + "Cerca su VIAF": "In VIAF suchen", + "Salva VIAF / ISNI": "VIAF / ISNI speichern", + "Ricerca su VIAF…": "Suche in VIAF…", + "Nessun risultato trovato su VIAF.": "Keine Ergebnisse in VIAF gefunden.", + "Inserisci prima il nome dell'autore.": "Gib zuerst den Namen des Autors ein." } diff --git a/locale/en_US.json b/locale/en_US.json index c64c55ed8..f300df049 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6933,5 +6933,18 @@ "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test not yet implemented. Save the settings and try importing a book.", "Timeout Richiesta (secondi)": "Request Timeout (seconds)", "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "All fields are optional. The plugin will only use the fields present in the response.", - "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Use the {isbn} placeholder in the URL; otherwise the ISBN is appended as an ?isbn=... parameter." + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Use the {isbn} placeholder in the URL; otherwise the ISBN is appended as an ?isbn=... parameter.", + "Authority control VIAF / ISNI": "VIAF / ISNI authority control", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Link the author to the Virtual International Authority File (VIAF) and to ISNI. Search by name to find an identifier, or enter it manually, then save.", + "VIAF ID": "VIAF ID", + "es. 102333412": "e.g. 102333412", + "Apri in VIAF": "Open in VIAF", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "e.g. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 digits (the last one may be an X).", + "Cerca su VIAF": "Search VIAF", + "Salva VIAF / ISNI": "Save VIAF / ISNI", + "Ricerca su VIAF…": "Searching VIAF…", + "Nessun risultato trovato su VIAF.": "No results found on VIAF.", + "Inserisci prima il nome dell'autore.": "Enter the author's name first." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index a8b703fab..1b2cb3d37 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6933,5 +6933,18 @@ "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test pas encore implémenté. Enregistrez les paramètres et essayez d'importer un livre.", "Timeout Richiesta (secondi)": "Délai d'expiration de la requête (secondes)", "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Tous les champs sont facultatifs. Le plugin n'utilisera que les champs présents dans la réponse.", - "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Utilisez le paramètre fictif {isbn} dans l'URL ; sinon l'ISBN est ajouté comme paramètre ?isbn=..." + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Utilisez le paramètre fictif {isbn} dans l'URL ; sinon l'ISBN est ajouté comme paramètre ?isbn=...", + "Authority control VIAF / ISNI": "Contrôle d'autorité VIAF / ISNI", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Reliez l'auteur au Virtual International Authority File (VIAF) et à l'ISNI. Recherchez par nom pour trouver un identifiant, ou saisissez-le manuellement, puis enregistrez.", + "VIAF ID": "Identifiant VIAF", + "es. 102333412": "ex. 102333412", + "Apri in VIAF": "Ouvrir dans VIAF", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "ex. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 chiffres (le dernier peut être un X).", + "Cerca su VIAF": "Rechercher dans VIAF", + "Salva VIAF / ISNI": "Enregistrer VIAF / ISNI", + "Ricerca su VIAF…": "Recherche dans VIAF…", + "Nessun risultato trovato su VIAF.": "Aucun résultat trouvé sur VIAF.", + "Inserisci prima il nome dell'autore.": "Saisissez d'abord le nom de l'auteur." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 6c048ea77..5fc77f094 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6933,5 +6933,18 @@ "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.", "Timeout Richiesta (secondi)": "Timeout Richiesta (secondi)", "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.", - "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=..." + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...", + "Authority control VIAF / ISNI": "Authority control VIAF / ISNI", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.", + "VIAF ID": "VIAF ID", + "es. 102333412": "es. 102333412", + "Apri in VIAF": "Apri in VIAF", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "es. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 cifre (l'ultima può essere una X).", + "Cerca su VIAF": "Cerca su VIAF", + "Salva VIAF / ISNI": "Salva VIAF / ISNI", + "Ricerca su VIAF…": "Ricerca su VIAF…", + "Nessun risultato trovato su VIAF.": "Nessun risultato trovato su VIAF.", + "Inserisci prima il nome dell'autore.": "Inserisci prima il nome dell'autore." } diff --git a/storage/plugins/viaf-authority/ViafAuthorityPlugin.php b/storage/plugins/viaf-authority/ViafAuthorityPlugin.php index e5767ce72..3cf28575b 100644 --- a/storage/plugins/viaf-authority/ViafAuthorityPlugin.php +++ b/storage/plugins/viaf-authority/ViafAuthorityPlugin.php @@ -107,6 +107,10 @@ public function onActivate(): void $this->db->begin_transaction(); try { $this->registerHookInDb('app.routes.register', 'registerRoutes', 20); + // Finding #32: surface the VIAF/ISNI endpoints in the author edit UI. + // Renders the authority widget into the core author form's + // `author.form.fields` action hook (same hook z39-server uses). + $this->registerHookInDb('author.form.fields', 'renderAuthorFields', 20); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollback(); @@ -305,6 +309,27 @@ private function deleteHooksFromDb(): void $stmt->close(); } + // ── View hooks ──────────────────────────────────────────────────────────── + + /** + * Hook: author.form.fields — render the VIAF/ISNI authority widget in the + * author edit form (finding #32). Reuses the existing plugin endpoints + * (`/api/viaf/suggest` and `/api/viaf/author/{id}/set`); no new endpoints. + */ + public function renderAuthorFields(?array $autore): void + { + $authorId = is_array($autore) ? (int) ($autore['id'] ?? 0) : 0; + $authorName = is_array($autore) ? (string) ($autore['nome'] ?? '') : ''; + $viafId = is_array($autore) ? (string) ($autore['viaf_id'] ?? '') : ''; + $viafUri = is_array($autore) ? (string) ($autore['viaf_uri'] ?? '') : ''; + $isniId = is_array($autore) ? (string) ($autore['isni_id'] ?? '') : ''; + if ($viafUri === '' && $viafId !== '') { + $viafUri = 'https://viaf.org/viaf/' . $viafId; + } + $csrf = \App\Support\Csrf::ensureToken(); + include __DIR__ . '/views/author-fields.php'; + } + // ── Route registration ──────────────────────────────────────────────────── public function registerRoutes($app): void diff --git a/storage/plugins/viaf-authority/views/author-fields.php b/storage/plugins/viaf-authority/views/author-fields.php new file mode 100644 index 000000000..53d3b4033 --- /dev/null +++ b/storage/plugins/viaf-authority/views/author-fields.php @@ -0,0 +1,219 @@ + + +
+
+

+ + +

+
+
+

+ + +

+ +
+
+ + +

+ + + + + + + +

+
+
+ + +

+
+
+ +
+ + + +
+ + +
+
+ + From 7f1ba1a2ee68a93835bc2cffcc732cf5277bf30e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 12:43:26 +0200 Subject: [PATCH 09/16] Wire FRBR/LRM book-to-Work linking and Expression CRUD into the admin UI The frbr-lrm plugin shipped working endpoints for attaching a book to an Opera and a repository for Expressions, but neither was reachable from the product: no book-form widget called the attach endpoint, and the espressioni table had no create/edit/delete route at all. - #6: I render an Opera panel into the book edit form via the shared book.form.fields hook (the same hook the z39-server REICAT/SBN panel uses). It searches Works through the existing GET /api/opere/search endpoint and links/unlinks through the existing POST /admin/books/{id}/attach-opera endpoint, which now answers JSON to an AJAX caller and still 302-redirects a plain submit. The attach endpoint now rejects a non-existent or soft-deleted Opera so no dangling FK is stored. - #7: I add admin CRUD for Expressions tied to a Work: create from the Opera detail page, plus edit and soft-delete routes and views. EspressioniRepository gains update(); the type is clamped to the ENUM server-side. All new strings go through __() and are added to all five locales. --- locale/da_DK.json | 27 +- locale/de_DE.json | 27 +- locale/en_US.json | 27 +- locale/fr_FR.json | 27 +- locale/it_IT.json | 27 +- .../frbr-lrm/EspressioniRepository.php | 24 ++ storage/plugins/frbr-lrm/FrbrLrmPlugin.php | 156 +++++++++- storage/plugins/frbr-lrm/OpereRepository.php | 33 +++ .../frbr-lrm/views/admin/espressioni/form.php | 115 ++++++++ .../frbr-lrm/views/admin/opere/show.php | 83 +++++- .../frbr-lrm/views/book/opera-panel.php | 271 ++++++++++++++++++ 11 files changed, 805 insertions(+), 12 deletions(-) create mode 100644 storage/plugins/frbr-lrm/views/admin/espressioni/form.php create mode 100644 storage/plugins/frbr-lrm/views/book/opera-panel.php diff --git a/locale/da_DK.json b/locale/da_DK.json index 192d58b13..5bdfadd5a 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -6946,5 +6946,30 @@ "Salva VIAF / ISNI": "Gem VIAF / ISNI", "Ricerca su VIAF…": "Søger i VIAF…", "Nessun risultato trovato su VIAF.": "Ingen resultater fundet i VIAF.", - "Inserisci prima il nome dell'autore.": "Indtast forfatterens navn først." + "Inserisci prima il nome dell'autore.": "Indtast forfatterens navn først.", + "Opera FRBR/LRM (Work)": "FRBR/LRM-værk (Work)", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Knyt denne bog til et værk for at samle alle dens udgaver under én post.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Gem bogen først: du kan knytte den til et værk fra redigeringssiden.", + "Collegato a": "Knyttet til", + "Vedi Opera": "Vis værk", + "Scollega": "Fjern tilknytning", + "Cerca un'Opera": "Søg efter et værk", + "Digita un titolo uniforme…": "Skriv en ensartet titel…", + "Crea una nuova Opera": "Opret et nyt værk", + "Ricerca in corso…": "Søger…", + "Nessuna Opera trovata.": "Intet værk fundet.", + "Collegamento aggiornato.": "Tilknytning opdateret.", + "Libro scollegato dall'Opera.": "Bogens tilknytning til værket er fjernet.", + "Testo (originale)": "Tekst (original)", + "Traduzione": "Oversættelse", + "Revisione": "Revision", + "Adattamento": "Bearbejdning", + "Edizione critica": "Kritisk udgave", + "Modifica espressione": "Rediger udtryk", + "Tipo di espressione": "Udtrykstype", + "Titolo dell'espressione": "Udtrykkets titel", + "Revisore": "Revisor", + "Salva espressione": "Gem udtryk", + "Eliminare questa espressione?": "Slet dette udtryk?", + "Aggiungi espressione": "Tilføj udtryk" } diff --git a/locale/de_DE.json b/locale/de_DE.json index fb3c8a44e..0385811fa 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6946,5 +6946,30 @@ "Salva VIAF / ISNI": "VIAF / ISNI speichern", "Ricerca su VIAF…": "Suche in VIAF…", "Nessun risultato trovato su VIAF.": "Keine Ergebnisse in VIAF gefunden.", - "Inserisci prima il nome dell'autore.": "Gib zuerst den Namen des Autors ein." + "Inserisci prima il nome dell'autore.": "Gib zuerst den Namen des Autors ein.", + "Opera FRBR/LRM (Work)": "FRBR/LRM-Werk (Work)", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Verknüpfen Sie dieses Buch mit einem Werk, um alle seine Ausgaben unter einem Datensatz zu gruppieren.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Speichern Sie zuerst das Buch: Sie können es dann auf der Bearbeitungsseite mit einem Werk verknüpfen.", + "Collegato a": "Verknüpft mit", + "Vedi Opera": "Werk anzeigen", + "Scollega": "Trennen", + "Cerca un'Opera": "Werk suchen", + "Digita un titolo uniforme…": "Einheitstitel eingeben…", + "Crea una nuova Opera": "Neues Werk erstellen", + "Ricerca in corso…": "Suche läuft…", + "Nessuna Opera trovata.": "Kein Werk gefunden.", + "Collegamento aggiornato.": "Verknüpfung aktualisiert.", + "Libro scollegato dall'Opera.": "Buch vom Werk getrennt.", + "Testo (originale)": "Text (Original)", + "Traduzione": "Übersetzung", + "Revisione": "Überarbeitung", + "Adattamento": "Bearbeitung", + "Edizione critica": "Kritische Ausgabe", + "Modifica espressione": "Expression bearbeiten", + "Tipo di espressione": "Expressionstyp", + "Titolo dell'espressione": "Titel der Expression", + "Revisore": "Bearbeiter", + "Salva espressione": "Expression speichern", + "Eliminare questa espressione?": "Diese Expression löschen?", + "Aggiungi espressione": "Expression hinzufügen" } diff --git a/locale/en_US.json b/locale/en_US.json index f300df049..bf6927684 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6946,5 +6946,30 @@ "Salva VIAF / ISNI": "Save VIAF / ISNI", "Ricerca su VIAF…": "Searching VIAF…", "Nessun risultato trovato su VIAF.": "No results found on VIAF.", - "Inserisci prima il nome dell'autore.": "Enter the author's name first." + "Inserisci prima il nome dell'autore.": "Enter the author's name first.", + "Opera FRBR/LRM (Work)": "FRBR/LRM Work", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Link this book to a Work to group all its editions under a single record.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Save the book first: you can link it to a Work from the edit page.", + "Collegato a": "Linked to", + "Vedi Opera": "View Work", + "Scollega": "Unlink", + "Cerca un'Opera": "Search for a Work", + "Digita un titolo uniforme…": "Type a uniform title…", + "Crea una nuova Opera": "Create a new Work", + "Ricerca in corso…": "Searching…", + "Nessuna Opera trovata.": "No Work found.", + "Collegamento aggiornato.": "Link updated.", + "Libro scollegato dall'Opera.": "Book unlinked from the Work.", + "Testo (originale)": "Text (original)", + "Traduzione": "Translation", + "Revisione": "Revision", + "Adattamento": "Adaptation", + "Edizione critica": "Critical edition", + "Modifica espressione": "Edit expression", + "Tipo di espressione": "Expression type", + "Titolo dell'espressione": "Expression title", + "Revisore": "Reviser", + "Salva espressione": "Save expression", + "Eliminare questa espressione?": "Delete this expression?", + "Aggiungi espressione": "Add expression" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 1b2cb3d37..86c9eaff2 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6946,5 +6946,30 @@ "Salva VIAF / ISNI": "Enregistrer VIAF / ISNI", "Ricerca su VIAF…": "Recherche dans VIAF…", "Nessun risultato trovato su VIAF.": "Aucun résultat trouvé sur VIAF.", - "Inserisci prima il nome dell'autore.": "Saisissez d'abord le nom de l'auteur." + "Inserisci prima il nome dell'autore.": "Saisissez d'abord le nom de l'auteur.", + "Opera FRBR/LRM (Work)": "Œuvre FRBR/LRM (Work)", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Reliez ce livre à une Œuvre pour regrouper toutes ses éditions sous une seule notice.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Enregistrez d'abord le livre : vous pourrez le relier à une Œuvre depuis la page de modification.", + "Collegato a": "Relié à", + "Vedi Opera": "Voir l'Œuvre", + "Scollega": "Dissocier", + "Cerca un'Opera": "Rechercher une Œuvre", + "Digita un titolo uniforme…": "Saisissez un titre uniforme…", + "Crea una nuova Opera": "Créer une nouvelle Œuvre", + "Ricerca in corso…": "Recherche en cours…", + "Nessuna Opera trovata.": "Aucune Œuvre trouvée.", + "Collegamento aggiornato.": "Lien mis à jour.", + "Libro scollegato dall'Opera.": "Livre dissocié de l'Œuvre.", + "Testo (originale)": "Texte (original)", + "Traduzione": "Traduction", + "Revisione": "Révision", + "Adattamento": "Adaptation", + "Edizione critica": "Édition critique", + "Modifica espressione": "Modifier l'expression", + "Tipo di espressione": "Type d'expression", + "Titolo dell'espressione": "Titre de l'expression", + "Revisore": "Réviseur", + "Salva espressione": "Enregistrer l'expression", + "Eliminare questa espressione?": "Supprimer cette expression ?", + "Aggiungi espressione": "Ajouter une expression" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 5fc77f094..d61048fa7 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6946,5 +6946,30 @@ "Salva VIAF / ISNI": "Salva VIAF / ISNI", "Ricerca su VIAF…": "Ricerca su VIAF…", "Nessun risultato trovato su VIAF.": "Nessun risultato trovato su VIAF.", - "Inserisci prima il nome dell'autore.": "Inserisci prima il nome dell'autore." + "Inserisci prima il nome dell'autore.": "Inserisci prima il nome dell'autore.", + "Opera FRBR/LRM (Work)": "Opera FRBR/LRM (Work)", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.", + "Collegato a": "Collegato a", + "Vedi Opera": "Vedi Opera", + "Scollega": "Scollega", + "Cerca un'Opera": "Cerca un'Opera", + "Digita un titolo uniforme…": "Digita un titolo uniforme…", + "Crea una nuova Opera": "Crea una nuova Opera", + "Ricerca in corso…": "Ricerca in corso…", + "Nessuna Opera trovata.": "Nessuna Opera trovata.", + "Collegamento aggiornato.": "Collegamento aggiornato.", + "Libro scollegato dall'Opera.": "Libro scollegato dall'Opera.", + "Testo (originale)": "Testo (originale)", + "Traduzione": "Traduzione", + "Revisione": "Revisione", + "Adattamento": "Adattamento", + "Edizione critica": "Edizione critica", + "Modifica espressione": "Modifica espressione", + "Tipo di espressione": "Tipo di espressione", + "Titolo dell'espressione": "Titolo dell'espressione", + "Revisore": "Revisore", + "Salva espressione": "Salva espressione", + "Eliminare questa espressione?": "Eliminare questa espressione?", + "Aggiungi espressione": "Aggiungi espressione" } diff --git a/storage/plugins/frbr-lrm/EspressioniRepository.php b/storage/plugins/frbr-lrm/EspressioniRepository.php index a96671213..d03399e8d 100644 --- a/storage/plugins/frbr-lrm/EspressioniRepository.php +++ b/storage/plugins/frbr-lrm/EspressioniRepository.php @@ -76,6 +76,30 @@ public function create(array $data): int return $id; } + /** @param array $data */ + public function update(int $id, array $data): bool + { + $sql = "UPDATE espressioni SET + lingua = ?, tipo_espressione = ?, traduttore_autore_id = ?, + curatore_autore_id = ?, revisore_autore_id = ?, titolo_espressione = ?, + anno_espressione = ?, note = ?, updated_at = NOW() + WHERE id = ? AND deleted_at IS NULL"; + $stmt = $this->db->prepare($sql); + $lingua = ($data['lingua'] ?? '') !== '' ? (string) $data['lingua'] : null; + $tipo = (string) ($data['tipo_espressione'] ?? 'testo'); + $trad = !empty($data['traduttore_autore_id']) ? (int) $data['traduttore_autore_id'] : null; + $cur = !empty($data['curatore_autore_id']) ? (int) $data['curatore_autore_id'] : null; + $rev = !empty($data['revisore_autore_id']) ? (int) $data['revisore_autore_id'] : null; + $titolo = ($data['titolo_espressione'] ?? '') !== '' ? (string) $data['titolo_espressione'] : null; + $anno = ($data['anno_espressione'] ?? '') !== '' ? (int) $data['anno_espressione'] : null; + $note = ($data['note'] ?? '') !== '' ? (string) $data['note'] : null; + // 9 params: lingua(s) tipo(s) trad(i) cur(i) rev(i) titolo(s) anno(i) note(s) id(i) + $stmt->bind_param('ssiiisisi', $lingua, $tipo, $trad, $cur, $rev, $titolo, $anno, $note, $id); + $ok = $stmt->execute(); + $stmt->close(); + return $ok; + } + public function softDelete(int $id): bool { $this->db->query("UPDATE libri SET espressione_id = NULL WHERE espressione_id = " . (int) $id); diff --git a/storage/plugins/frbr-lrm/FrbrLrmPlugin.php b/storage/plugins/frbr-lrm/FrbrLrmPlugin.php index 071efd1f4..d8d822117 100644 --- a/storage/plugins/frbr-lrm/FrbrLrmPlugin.php +++ b/storage/plugins/frbr-lrm/FrbrLrmPlugin.php @@ -72,6 +72,9 @@ public function onActivate(): void try { $this->registerHookInDb('app.routes.register', 'registerRoutes', 20); $this->registerHookInDb('admin.menu.render', 'renderAdminMenuEntry', 20); + // Flagship integration (#6): inject the "link this book to a Work" + // panel into the book edit form via the shared book-form hook. + $this->registerHookInDb('book.form.fields', 'renderBookOperaPanel', 20); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollback(); @@ -126,6 +129,26 @@ public function registerRoutes($app): void return $plugin->adminDeleteAction($req, $res, (int) $args['id']); })->add($csrfMiddleware)->add($adminMiddleware); + // ── Admin: create an espressione under an opera ── + $app->post('/admin/opere/{id:[0-9]+}/espressioni', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { + return $plugin->adminEspressioneStoreAction($req, $res, (int) $args['id']); + })->add($csrfMiddleware)->add($adminMiddleware); + + // ── Admin: edit espressione form ── + $app->get('/admin/opere/espressioni/{eid:[0-9]+}/edit', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { + return $plugin->adminEspressioneEditAction($req, $res, (int) $args['eid']); + })->add($adminMiddleware); + + // ── Admin: update espressione ── + $app->post('/admin/opere/espressioni/{eid:[0-9]+}/edit', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { + return $plugin->adminEspressioneUpdateAction($req, $res, (int) $args['eid']); + })->add($csrfMiddleware)->add($adminMiddleware); + + // ── Admin: delete espressione (soft-delete) ── + $app->post('/admin/opere/espressioni/{eid:[0-9]+}/delete', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { + return $plugin->adminEspressioneDeleteAction($req, $res, (int) $args['eid']); + })->add($csrfMiddleware)->add($adminMiddleware); + // ── Admin: attach a book to an opera (from the book edit form) ── $app->post('/admin/books/{id:[0-9]+}/attach-opera', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { return $plugin->attachBookToOperaAction($req, $res, (int) $args['id']); @@ -414,6 +437,7 @@ public function adminShowAction(ServerRequestInterface $request, ResponseInterfa 'opera' => $opera, 'edizioni' => $repo->editionsForOpera($id), 'espressioni' => (new EspressioniRepository($this->db))->listForOpera($id), + 'autori' => $this->autoriForSelect(), 'pageTitle' => $opera['titolo_uniforme'], ]); } @@ -445,18 +469,130 @@ public function adminDeleteAction(ServerRequestInterface $request, ResponseInter return $this->redirect($response, '/admin/opere'); } - /** Attach a book to an opera (POST from the book edit form). */ + /** + * Attach (or, with an empty opera_id, detach) a book to/from an opera. + * POSTed from the book edit form's Opera panel. Answers JSON to an AJAX + * caller and 302-redirects a plain form submit, so the endpoint is usable + * both ways. + */ public function attachBookToOperaAction(ServerRequestInterface $request, ResponseInterface $response, int $libroId): ResponseInterface { $data = (array) $request->getParsedBody(); $operaId = !empty($data['opera_id']) ? (int) $data['opera_id'] : null; + $wantsJson = $this->wantsJson($request); + + // Reject a non-existent / soft-deleted opera so we never store a dangling FK. + if ($operaId !== null && !(new OpereRepository($this->db))->exists($operaId)) { + if ($wantsJson) { + return $this->json($response, ['success' => false, 'error' => __('Opera non trovata')], 404); + } + return $this->redirect($response, '/admin/books/' . $libroId); + } + $stmt = $this->db->prepare('UPDATE libri SET opera_id = ? WHERE id = ? AND deleted_at IS NULL'); $stmt->bind_param('ii', $operaId, $libroId); $stmt->execute(); $stmt->close(); + + if ($wantsJson) { + $opera = $operaId !== null ? (new OpereRepository($this->db))->getForBook($libroId) : null; + return $this->json($response, ['success' => true, 'opera' => $opera]); + } return $this->redirect($response, '/admin/books/' . $libroId); } + // ───────────────────────────────────────────────────────────────────── + // Book-form hook (book.form.fields) — link a manifestation to a Work + // ───────────────────────────────────────────────────────────────────── + + /** + * Render the "link this book to an Opera" panel inside the book edit form. + * + * @param array|null $book + */ + public function renderBookOperaPanel(?array $book, ?int $bookId): void + { + $id = $bookId ?? (is_array($book) ? (int) ($book['id'] ?? 0) : 0); + $currentOpera = $id > 0 ? (new OpereRepository($this->db))->getForBook($id) : null; + $csrf = \App\Support\Csrf::ensureToken(); + include __DIR__ . '/views/book/opera-panel.php'; + } + + // ───────────────────────────────────────────────────────────────────── + // Admin actions — espressioni CRUD (tied to a Work) + // ───────────────────────────────────────────────────────────────────── + + /** Allowed Expression types — must match the `espressioni.tipo_espressione` ENUM. */ + private const TIPI_ESPRESSIONE = [ + 'testo', 'traduzione', 'revisione', 'adattamento', 'edizione_critica', 'audio', 'altro', + ]; + + public function adminEspressioneStoreAction(ServerRequestInterface $request, ResponseInterface $response, int $operaId): ResponseInterface + { + if (!(new OpereRepository($this->db))->exists($operaId)) { + return $this->redirect($response, '/admin/opere'); + } + $data = $this->sanitizeEspressioneInput((array) $request->getParsedBody()); + $data['opera_id'] = $operaId; + (new EspressioniRepository($this->db))->create($data); + return $this->redirect($response, '/admin/opere/' . $operaId); + } + + public function adminEspressioneEditAction(ServerRequestInterface $request, ResponseInterface $response, int $espressioneId): ResponseInterface + { + $repo = new EspressioniRepository($this->db); + $espressione = $repo->getById($espressioneId); + if ($espressione === null) { + return $this->redirect($response, '/admin/opere'); + } + return $this->renderView($response, 'admin/espressioni/form', [ + 'espressione' => $espressione, + 'autori' => $this->autoriForSelect(), + 'pageTitle' => __('Modifica espressione'), + ]); + } + + public function adminEspressioneUpdateAction(ServerRequestInterface $request, ResponseInterface $response, int $espressioneId): ResponseInterface + { + $repo = new EspressioniRepository($this->db); + $espressione = $repo->getById($espressioneId); + if ($espressione === null) { + return $this->redirect($response, '/admin/opere'); + } + $data = $this->sanitizeEspressioneInput((array) $request->getParsedBody()); + $repo->update($espressioneId, $data); + return $this->redirect($response, '/admin/opere/' . (int) $espressione['opera_id']); + } + + public function adminEspressioneDeleteAction(ServerRequestInterface $request, ResponseInterface $response, int $espressioneId): ResponseInterface + { + $repo = new EspressioniRepository($this->db); + $espressione = $repo->getById($espressioneId); + if ($espressione === null) { + return $this->redirect($response, '/admin/opere'); + } + $operaId = (int) $espressione['opera_id']; + $repo->softDelete($espressioneId); + return $this->redirect($response, '/admin/opere/' . $operaId); + } + + /** + * Normalise the espressione form payload: clamp the type to the ENUM and + * pass the remaining fields through as-is for the repository to bind. + * + * @param array $data + * @return array + */ + private function sanitizeEspressioneInput(array $data): array + { + $tipo = (string) ($data['tipo_espressione'] ?? 'testo'); + if (!in_array($tipo, self::TIPI_ESPRESSIONE, true)) { + $tipo = 'testo'; + } + $data['tipo_espressione'] = $tipo; + return $data; + } + /** Opera autocomplete JSON for the attach UI. */ public function apiSearchOpereAction(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { @@ -544,6 +680,24 @@ private function redirect(ResponseInterface $response, string $path): ResponseIn return $response->withHeader('Location', url($path))->withStatus(302); } + /** Does the caller want a JSON response (fetch/XHR) rather than a redirect? */ + private function wantsJson(ServerRequestInterface $request): bool + { + if (strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest') { + return true; + } + return str_contains($request->getHeaderLine('Accept'), 'application/json'); + } + + /** + * @param array $payload + */ + private function json(ResponseInterface $response, array $payload, int $status = 200): ResponseInterface + { + $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE)); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); + } + /** @return array */ private function autoriForSelect(): array { diff --git a/storage/plugins/frbr-lrm/OpereRepository.php b/storage/plugins/frbr-lrm/OpereRepository.php index fab87ee9c..0e691430d 100644 --- a/storage/plugins/frbr-lrm/OpereRepository.php +++ b/storage/plugins/frbr-lrm/OpereRepository.php @@ -63,6 +63,39 @@ public function getById(int $id): ?array return $row ?: null; } + /** + * The opera (Work) currently attached to a given book, or null. + * + * @return array|null + */ + public function getForBook(int $bookId): ?array + { + $authorDisplay = \App\Support\AuthorName::displaySql('a'); + $sql = "SELECT o.id, o.titolo_uniforme, o.slug, {$authorDisplay} AS autore_nome + FROM libri l + INNER JOIN opere o ON l.opera_id = o.id AND o.deleted_at IS NULL + LEFT JOIN autori a ON o.autore_principale_id = a.id + WHERE l.id = ? AND l.deleted_at IS NULL + LIMIT 1"; + $stmt = $this->db->prepare($sql); + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return $row ?: null; + } + + /** True if a non-deleted opera with this id exists. */ + public function exists(int $id): bool + { + $stmt = $this->db->prepare("SELECT 1 FROM opere WHERE id = ? AND deleted_at IS NULL LIMIT 1"); + $stmt->bind_param('i', $id); + $stmt->execute(); + $found = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $found; + } + /** @return array|null */ public function getBySlug(string $slug): ?array { diff --git a/storage/plugins/frbr-lrm/views/admin/espressioni/form.php b/storage/plugins/frbr-lrm/views/admin/espressioni/form.php new file mode 100644 index 000000000..39aaa36cb --- /dev/null +++ b/storage/plugins/frbr-lrm/views/admin/espressioni/form.php @@ -0,0 +1,115 @@ + $espressione + * @var array $autori + * @var string $pageTitle + */ +$operaId = (int) ($espressione['opera_id'] ?? 0); +$action = url('/admin/opere/espressioni/' . (int) $espressione['id'] . '/edit'); +$val = static function (string $key) use ($espressione): string { + return htmlspecialchars((string) ($espressione[$key] ?? ''), ENT_QUOTES, 'UTF-8'); +}; +/** @var array $tipiEspressione tipo => label */ +$tipiEspressione = [ + 'testo' => __("Testo (originale)"), + 'traduzione' => __("Traduzione"), + 'revisione' => __("Revisione"), + 'adattamento' => __("Adattamento"), + 'edizione_critica' => __("Edizione critica"), + 'audio' => __("Audio"), + 'altro' => __("Altro"), +]; +$currentTipo = (string) ($espressione['tipo_espressione'] ?? 'testo'); +$autoreSelect = static function (string $name, int $current) use ($autori): string { + $html = ''; +}; +?> +
+
+ + +
+

+ + +

+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+
diff --git a/storage/plugins/frbr-lrm/views/admin/opere/show.php b/storage/plugins/frbr-lrm/views/admin/opere/show.php index 316e57aff..cd6af4404 100644 --- a/storage/plugins/frbr-lrm/views/admin/opere/show.php +++ b/storage/plugins/frbr-lrm/views/admin/opere/show.php @@ -3,8 +3,20 @@ * @var array $opera * @var array> $edizioni * @var array> $espressioni + * @var array $autori * @var string $pageTitle */ +/** @var array $tipiEspressione tipo => label */ +$tipiEspressione = [ + 'testo' => __("Testo (originale)"), + 'traduzione' => __("Traduzione"), + 'revisione' => __("Revisione"), + 'adattamento' => __("Adattamento"), + 'edizione_critica' => __("Edizione critica"), + 'audio' => __("Audio"), + 'altro' => __("Altro"), +]; +$autori = $autori ?? []; ?>
@@ -69,18 +81,77 @@ -

+

-
    +
      -
    • - - - · + +
    • + + + + · + + + +
      "> + + +
      +
    + + +
    + + + +
    + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    +
diff --git a/storage/plugins/frbr-lrm/views/book/opera-panel.php b/storage/plugins/frbr-lrm/views/book/opera-panel.php new file mode 100644 index 000000000..254a9a1ee --- /dev/null +++ b/storage/plugins/frbr-lrm/views/book/opera-panel.php @@ -0,0 +1,271 @@ +|null $currentOpera the Work this book is linked to + * @var string $csrf + * + * Chrome copied from the sibling z39-server REICAT/SBN book-form panel so the + * two panels read as one system. All writes go through the existing endpoints + * (POST /admin/books/{id}/attach-opera, GET /api/opere/search) via fetch — + * no nested
(the panel lives inside the book form). + */ + +$hasOpera = $currentOpera !== null; +$operaLabel = $hasOpera ? (string) ($currentOpera['titolo_uniforme'] ?? '') : ''; +$operaAuthor = $hasOpera ? (string) ($currentOpera['autore_nome'] ?? '') : ''; +$operaId = $hasOpera ? (int) ($currentOpera['id'] ?? 0) : 0; +?> + +
+ + + +
+

+ + +

+ + +

+ + +

+ + +
+

+
+ + + + + · + + + + + + + + + + +
+
+ + +
+ +
+ + +
+

+ + +

+
+ + +
+
+ + 0): ?> + + From 701d6500e3195775f27be9b11bcaf81d45b4f752 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 12:46:25 +0200 Subject: [PATCH 10/16] Drop the orphaned relator table and de-advertise unbuilt FRBR features Two of the manifest's claims had no implementing code behind them: - #20: ensureSchema() created a `libri_autori_ruoli` table for MARC21 relator codes, but nothing ever read or wrote it. I remove the table creation and its feature bullet/tag rather than ship a dead table. The translator/curator/reviser roles that ARE modelled live on the espressioni rows; a comment records how to re-introduce the table if relator-code assignment is built later. Existing empty tables are left untouched (no destructive drop). - #21: the manifest advertised a "deduplication assistant" and an "author page grouped by Work", neither of which exists. I remove both from the description, feature list and changelog so the catalog matches reality. The genuinely-implemented items (attach-to-Opera on the book form, Expression CRUD) are now reflected accurately in the manifest. --- storage/plugins/frbr-lrm/FrbrLrmPlugin.php | 34 ++++++---------------- storage/plugins/frbr-lrm/plugin.json | 13 ++++----- 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/storage/plugins/frbr-lrm/FrbrLrmPlugin.php b/storage/plugins/frbr-lrm/FrbrLrmPlugin.php index d8d822117..8c68086d3 100644 --- a/storage/plugins/frbr-lrm/FrbrLrmPlugin.php +++ b/storage/plugins/frbr-lrm/FrbrLrmPlugin.php @@ -206,9 +206,8 @@ public function expectedTables(): array private static function schemaSteps(): array { return [ - 'opere' => self::ddlOpere(), - 'espressioni' => self::ddlEspressioni(), - 'libri_autori_ruoli' => self::ddlLibriAutoriRuoli(), + 'opere' => self::ddlOpere(), + 'espressioni' => self::ddlEspressioni(), ]; } @@ -343,28 +342,13 @@ private static function ddlEspressioni(): string SQL; } - private static function ddlLibriAutoriRuoli(): string - { - return << Date: Sun, 23 Aug 2026 12:47:45 +0200 Subject: [PATCH 11/16] Make EspressioniRepository::create() null-safe on absent form fields The quick-add Expression form on the Opera detail page omits the optional note/curatore/revisore fields, so create() must not assume every key is present. I switch the empty-string guards to null-coalescing, matching update(), so a partial payload no longer emits undefined-key warnings. --- storage/plugins/frbr-lrm/EspressioniRepository.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/storage/plugins/frbr-lrm/EspressioniRepository.php b/storage/plugins/frbr-lrm/EspressioniRepository.php index d03399e8d..92406908b 100644 --- a/storage/plugins/frbr-lrm/EspressioniRepository.php +++ b/storage/plugins/frbr-lrm/EspressioniRepository.php @@ -60,14 +60,14 @@ public function create(array $data): int VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; $stmt = $this->db->prepare($sql); $operaId = (int) ($data['opera_id'] ?? 0); - $lingua = $data['lingua'] !== '' ? ($data['lingua'] ?? null) : null; + $lingua = ($data['lingua'] ?? '') !== '' ? (string) $data['lingua'] : null; $tipo = (string) ($data['tipo_espressione'] ?? 'testo'); $trad = !empty($data['traduttore_autore_id']) ? (int) $data['traduttore_autore_id'] : null; $cur = !empty($data['curatore_autore_id']) ? (int) $data['curatore_autore_id'] : null; $rev = !empty($data['revisore_autore_id']) ? (int) $data['revisore_autore_id'] : null; - $titolo = $data['titolo_espressione'] !== '' ? ($data['titolo_espressione'] ?? null) : null; - $anno = $data['anno_espressione'] !== '' ? (int) ($data['anno_espressione'] ?? 0) : null; - $note = $data['note'] !== '' ? ($data['note'] ?? null) : null; + $titolo = ($data['titolo_espressione'] ?? '') !== '' ? (string) $data['titolo_espressione'] : null; + $anno = ($data['anno_espressione'] ?? '') !== '' ? (int) $data['anno_espressione'] : null; + $note = ($data['note'] ?? '') !== '' ? (string) $data['note'] : null; // 9 params: opera_id(i) lingua(s) tipo(s) trad(i) cur(i) rev(i) titolo(s) anno(i) note(s) $stmt->bind_param('issiiisis', $operaId, $lingua, $tipo, $trad, $cur, $rev, $titolo, $anno, $note); $stmt->execute(); From 74d755a277e408c2101d72b0a277ad1b8f0032ee Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 13:53:32 +0200 Subject: [PATCH 12/16] test(goodlib): make the custom-domains spec self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec assumed every GoodLib source and both visibility toggles were enabled, but those live in the plugin's encrypted, shared settings — a prior run could leave anna_enabled/show_admin at 0, so the modal (correctly) opened with the checkboxes unticked and the save persisted them off, suppressing the Anna's Archive badge the spec asserts. I now tick the sources and visibility toggles in the modal before saving, which both makes the test deterministic and mirrors the real operator flow. No plugin code changed: the modal already reflects the stored state faithfully. --- tests/goodlib-custom-domains.spec.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/goodlib-custom-domains.spec.js b/tests/goodlib-custom-domains.spec.js index 334b1e2fe..a45fb7ad9 100644 --- a/tests/goodlib-custom-domains.spec.js +++ b/tests/goodlib-custom-domains.spec.js @@ -54,6 +54,16 @@ test.describe.serial('GoodLib custom domains', () => { await page.goto(`${BASE_URL}/admin/plugins`); await page.getByRole('button', { name: 'Configura Fonti' }).click(); + // Make the test self-contained: the sources + visibility checkboxes reflect + // the plugin's stored (encrypted) settings, which are shared state a prior + // run may have left disabled. Enable them explicitly so the badges render — + // this is also the real operator flow (tick the sources, then set domains). + await page.locator('#goodlib_anna').check(); + await page.locator('#goodlib_zlib').check(); + await page.locator('#goodlib_gutenberg').check(); + await page.locator('#goodlib_frontend').check(); + await page.locator('#goodlib_admin').check(); + await expect(page.locator('#goodlib_anna_domain_select option')).toHaveCount(4); await expect(page.locator('#goodlib_zlib_domain_select option')).toHaveCount(7); From 9176fa7ab746436db563ab8491bde4900fcb1363 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 23 Aug 2026 14:34:55 +0200 Subject: [PATCH 13/16] Harden plugin schema, hooks, and input guards from PR #376 review - api-book-scraper: wrap settings save + hook re-registration in a single transaction; registerHooks/deleteHooks now throw on any DELETE/INSERT failure so a partial hook replacement rolls back instead of reporting success with the scrape.* hooks missing. Settings view catches the rethrow. - digital-library: ensureSchema() now fails loudly (SecureLogger + throw) when a SHOW COLUMNS/ALTER TABLE query errors, so activation cannot finish with a partial file_url/audio_url schema. - ncip-server: add an idempotent FK migration in ensureSchema() that adds the two ncip_transactions foreign keys when missing on an upgraded table, nulling orphan partner_id/prestito_id rows first. - frbr-lrm opera-panel: add rel=noopener noreferrer to the new-Opera link and drop stale out-of-order autocomplete responses via a request counter. - RateLimiter: measure the window with microtime(true) instead of whole seconds; same limits and shared state, finer-grained boundary. - z39-server: reject UNIMARC import when title/isbn13/isbn10 are all empty by value, not just by key presence. --- app/Support/RateLimiter.php | 12 ++- .../api-book-scraper/ApiBookScraperPlugin.php | 90 ++++++++++++------- .../api-book-scraper/views/settings.php | 11 ++- .../digital-library/DigitalLibraryPlugin.php | 36 ++++++-- .../frbr-lrm/views/book/opera-panel.php | 16 +++- .../plugins/ncip-server/NcipServerPlugin.php | 67 ++++++++++++++ .../plugins/z39-server/Z39ServerPlugin.php | 8 +- 7 files changed, 189 insertions(+), 51 deletions(-) diff --git a/app/Support/RateLimiter.php b/app/Support/RateLimiter.php index e8c929c88..3f9b93043 100644 --- a/app/Support/RateLimiter.php +++ b/app/Support/RateLimiter.php @@ -14,7 +14,11 @@ class RateLimiter */ public static function isLimited(string $identifier, int $maxAttempts = self::MAX_ATTEMPTS, int $window = self::WINDOW): bool { - $currentTime = time(); + // Elapsed-based window measurement: microtime(true) keeps the window a + // true float duration instead of quantizing the first-attempt marker to + // whole seconds. Same limits and same file-backed shared state — only + // the window boundary is finer-grained. + $currentTime = microtime(true); $file = self::getStateFile($identifier); // Open (or create) state file with exclusive lock for atomic read-modify-write @@ -34,20 +38,20 @@ public static function isLimited(string $identifier, int $maxAttempts = self::MA // Read current state under lock $raw = stream_get_contents($handle); - $state = ['attempts' => 0, 'first_attempt' => 0]; + $state = ['attempts' => 0, 'first_attempt' => 0.0]; if ($raw !== false && $raw !== '') { $data = json_decode($raw, true); if (is_array($data)) { $state = [ 'attempts' => (int) ($data['attempts'] ?? 0), - 'first_attempt' => (int) ($data['first_attempt'] ?? 0), + 'first_attempt' => (float) ($data['first_attempt'] ?? 0), ]; } } // Reset if window expired if ($state['first_attempt'] > 0 && ($currentTime - $state['first_attempt']) > $window) { - $state = ['attempts' => 0, 'first_attempt' => 0]; + $state = ['attempts' => 0, 'first_attempt' => 0.0]; } if ($state['attempts'] === 0) { diff --git a/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php b/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php index d81f07952..d8d15bc0d 100644 --- a/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php +++ b/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php @@ -163,15 +163,16 @@ private function registerHooks(): void ); if (!$stmt) { - \App\Support\SecureLogger::error('[ApiBookScraper] Failed to prepare statement: ' . $this->db->error); - continue; + throw new \RuntimeException('[ApiBookScraper] prepare() failed for hook ' . $hookName . ': ' . $this->db->error); } $className = __CLASS__; $stmt->bind_param('isssi', $this->pluginId, $hookName, $className, $method, $priority); if (!$stmt->execute()) { - \App\Support\SecureLogger::error('[ApiBookScraper] Failed to register hook ' . $hookName . ': ' . $stmt->error); + $err = $stmt->error; + $stmt->close(); + throw new \RuntimeException('[ApiBookScraper] hook insert failed for ' . $hookName . ': ' . $err); } $stmt->close(); @@ -190,10 +191,15 @@ private function deleteHooks(): void } $stmt = $this->db->prepare("DELETE FROM plugin_hooks WHERE plugin_id = ?"); - if ($stmt) { - $stmt->bind_param('i', $this->pluginId); - $stmt->execute(); - $stmt->close(); + if ($stmt === false) { + throw new \RuntimeException('[ApiBookScraper] prepare() failed for hook delete: ' . $this->db->error); + } + $stmt->bind_param('i', $this->pluginId); + $ok = $stmt->execute(); + $err = $stmt->error; + $stmt->close(); + if (!$ok) { + throw new \RuntimeException('[ApiBookScraper] hook delete failed: ' . $err); } } @@ -525,44 +531,60 @@ public function saveSettings(array $settings): bool return false; } - $success = true; - - foreach ($settings as $key => $value) { - $shouldEncrypt = ($key === 'api_key'); - $finalValue = $shouldEncrypt ? $this->encryptValue($value) : $value; - - // Delete existing - $deleteStmt = $this->db->prepare( - "DELETE FROM plugin_settings WHERE plugin_id = ? AND setting_key = ?" - ); - if ($deleteStmt) { + // Wrap the settings replacement AND the hook re-registration in one + // transaction: if registerHooks() fails after deleteHooks() has run, + // the whole change must roll back so we never leave the scrape.* hooks + // missing while reporting success (which would silently stop scraping). + $this->db->begin_transaction(); + try { + foreach ($settings as $key => $value) { + $shouldEncrypt = ($key === 'api_key'); + $finalValue = $shouldEncrypt ? $this->encryptValue($value) : $value; + + // Delete existing + $deleteStmt = $this->db->prepare( + "DELETE FROM plugin_settings WHERE plugin_id = ? AND setting_key = ?" + ); + if ($deleteStmt === false) { + throw new \RuntimeException('[ApiBookScraper] prepare() failed for setting delete ' . $key . ': ' . $this->db->error); + } $deleteStmt->bind_param('is', $this->pluginId, $key); - $deleteStmt->execute(); + $ok = $deleteStmt->execute(); + $err = $deleteStmt->error; $deleteStmt->close(); - } - - // Insert new - $insertStmt = $this->db->prepare( - "INSERT INTO plugin_settings (plugin_id, setting_key, setting_value, autoload) - VALUES (?, ?, ?, 1)" - ); + if (!$ok) { + throw new \RuntimeException('[ApiBookScraper] setting delete failed for ' . $key . ': ' . $err); + } - if ($insertStmt) { + // Insert new + $insertStmt = $this->db->prepare( + "INSERT INTO plugin_settings (plugin_id, setting_key, setting_value, autoload) + VALUES (?, ?, ?, 1)" + ); + if ($insertStmt === false) { + throw new \RuntimeException('[ApiBookScraper] prepare() failed for setting insert ' . $key . ': ' . $this->db->error); + } $insertStmt->bind_param('iss', $this->pluginId, $key, $finalValue); - $success = $success && $insertStmt->execute(); + $ok = $insertStmt->execute(); + $err = $insertStmt->error; $insertStmt->close(); - } else { - $success = false; + if (!$ok) { + throw new \RuntimeException('[ApiBookScraper] setting insert failed for ' . $key . ': ' . $err); + } } - } - if ($success) { $this->loadSettings(); - // Re-register hooks con nuove impostazioni + // Re-register hooks con nuove impostazioni (throws on any failure) $this->registerHooks(); + + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollback(); + \App\Support\SecureLogger::error('[ApiBookScraper] saveSettings failed, rolled back: ' . $e->getMessage()); + throw $e; } - return $success; + return true; } /** diff --git a/storage/plugins/api-book-scraper/views/settings.php b/storage/plugins/api-book-scraper/views/settings.php index de99e60a4..689739c34 100644 --- a/storage/plugins/api-book-scraper/views/settings.php +++ b/storage/plugins/api-book-scraper/views/settings.php @@ -27,9 +27,14 @@ $settings['api_key'] = $submittedKey; } - if ($plugin->saveSettings($settings)) { - $successMessage = __('Impostazioni salvate correttamente!'); - } else { + try { + if ($plugin->saveSettings($settings)) { + $successMessage = __('Impostazioni salvate correttamente!'); + } else { + $errorMessage = __('Errore nel salvataggio delle impostazioni.'); + } + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('[ApiBookScraper] settings save failed: ' . $e->getMessage()); $errorMessage = __('Errore nel salvataggio delle impostazioni.'); } } else { diff --git a/storage/plugins/digital-library/DigitalLibraryPlugin.php b/storage/plugins/digital-library/DigitalLibraryPlugin.php index dac1d63c5..9ee4a3b44 100644 --- a/storage/plugins/digital-library/DigitalLibraryPlugin.php +++ b/storage/plugins/digital-library/DigitalLibraryPlugin.php @@ -152,14 +152,40 @@ private function ensureSchema(): void return; } - $result = $this->db->query("SHOW COLUMNS FROM libri LIKE 'file_url'"); - if ($result instanceof \mysqli_result && $result->num_rows === 0) { - $this->db->query("ALTER TABLE libri ADD COLUMN file_url VARCHAR(255) DEFAULT NULL COMMENT 'eBook file URL' AFTER note_varie"); + $this->ensureColumn( + 'file_url', + "ALTER TABLE libri ADD COLUMN file_url VARCHAR(255) DEFAULT NULL COMMENT 'eBook file URL' AFTER note_varie" + ); + $this->ensureColumn( + 'audio_url', + "ALTER TABLE libri ADD COLUMN audio_url VARCHAR(255) DEFAULT NULL COMMENT 'Audiobook file URL' AFTER file_url" + ); + } + + /** + * Add a column to `libri` if it is missing, failing loudly on any DDL + * error. Returning silently would let onActivate() complete with a partial + * schema (missing file_url/audio_url), so a later book save then fails. + */ + private function ensureColumn(string $column, string $alterSql): void + { + if (!$this->db) { + return; + } + + $result = $this->db->query("SHOW COLUMNS FROM libri LIKE '" . $this->db->real_escape_string($column) . "'"); + if ($result === false) { + $err = "[Digital Library] SHOW COLUMNS for {$column} failed: " . $this->db->error; + \App\Support\SecureLogger::error($err); + throw new \RuntimeException($err); } - $result = $this->db->query("SHOW COLUMNS FROM libri LIKE 'audio_url'"); if ($result instanceof \mysqli_result && $result->num_rows === 0) { - $this->db->query("ALTER TABLE libri ADD COLUMN audio_url VARCHAR(255) DEFAULT NULL COMMENT 'Audiobook file URL' AFTER file_url"); + if ($this->db->query($alterSql) === false) { + $err = "[Digital Library] ALTER TABLE adding {$column} failed: " . $this->db->error; + \App\Support\SecureLogger::error($err); + throw new \RuntimeException($err); + } } } diff --git a/storage/plugins/frbr-lrm/views/book/opera-panel.php b/storage/plugins/frbr-lrm/views/book/opera-panel.php index 254a9a1ee..ab31300da 100644 --- a/storage/plugins/frbr-lrm/views/book/opera-panel.php +++ b/storage/plugins/frbr-lrm/views/book/opera-panel.php @@ -88,7 +88,7 @@ class="absolute z-20 left-0 right-0 mt-1 bg-white border border-gray-200 rounded

-

@@ -221,6 +221,7 @@ function renderCurrent(opera) { const sInput = document.getElementById('frbr_opera_search'); const sResults = document.getElementById('frbr_opera_results'); let sTimer = null; + let sReq = 0; function hideResults() { if (sResults) { clearEl(sResults); sResults.classList.add('hidden'); } } function showResults(items) { @@ -252,13 +253,22 @@ function showResults(items) { if (sTimer) { clearTimeout(sTimer); } if (q.length < 2) { hideResults(); return; } sTimer = setTimeout(function () { + // Track a per-request sequence so out-of-order responses from a + // slower earlier fetch can't overwrite the latest query's results. + const seq = ++sReq; fetch(base + '/api/opere/search?q=' + encodeURIComponent(q), { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.json(); }) - .then(function (d) { showResults(Array.isArray(d) ? d : []); }) - .catch(function () { hideResults(); }); + .then(function (d) { + if (seq !== sReq) { return; } + showResults(Array.isArray(d) ? d : []); + }) + .catch(function () { + if (seq !== sReq) { return; } + hideResults(); + }); }, 280); }); sInput.addEventListener('keydown', function (e) { if (e.key === 'Escape') { hideResults(); } }); diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index c9485ed1b..e3b51b8c2 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -171,11 +171,78 @@ public function ensureSchema(): array } } + // CREATE TABLE IF NOT EXISTS is a no-op for a pre-existing + // ncip_transactions table, so the two FK constraints are never applied + // on an upgrade from a build that shipped the table without them. + // Add them idempotently now (safe to re-run). + if (!in_array('ncip_transactions', $failed, true)) { + $this->ensureForeignKeys(); + } + // Core schema changes for prestiti.ncip_request_id and origine ENUM are in migrate_0.7.4.sql return ['created' => $created, 'failed' => $failed]; } + /** + * Add the two ncip_transactions foreign keys when missing. Detects each FK + * by column + referenced table (name-agnostic) via KEY_COLUMN_USAGE, nulls + * out orphan rows that would violate the constraint, then ALTERs it in with + * ON DELETE SET NULL. Idempotent and safe to re-run from onActivate/onInstall. + * All table/column names below are static literals — no user input. + */ + private function ensureForeignKeys(): void + { + $fks = [ + ['column' => 'partner_id', 'ref_table' => 'ncip_partners', 'ref_col' => 'id', 'name' => 'ncip_transactions_ibfk_1'], + ['column' => 'prestito_id', 'ref_table' => 'prestiti', 'ref_col' => 'id', 'name' => 'ncip_transactions_ibfk_2'], + ]; + + foreach ($fks as $fk) { + $stmt = $this->db->prepare( + "SELECT COUNT(*) AS c FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ncip_transactions' + AND COLUMN_NAME = ? + AND REFERENCED_TABLE_NAME = ?" + ); + if ($stmt === false) { + SecureLogger::error('[NcipServer] FK probe prepare failed: ' . $this->db->error); + continue; + } + $stmt->bind_param('ss', $fk['column'], $fk['ref_table']); + if (!$stmt->execute()) { + SecureLogger::error('[NcipServer] FK probe failed for ' . $fk['column'] . ': ' . $stmt->error); + $stmt->close(); + continue; + } + $res = $stmt->get_result(); + $exists = $res instanceof \mysqli_result && ((int) ($res->fetch_assoc()['c'] ?? 0)) > 0; + $stmt->close(); + if ($exists) { + continue; + } + + // Null out orphan references that would fail the FK on ADD. + if ($this->db->query( + "UPDATE ncip_transactions t + LEFT JOIN {$fk['ref_table']} r ON t.{$fk['column']} = r.{$fk['ref_col']} + SET t.{$fk['column']} = NULL + WHERE t.{$fk['column']} IS NOT NULL AND r.{$fk['ref_col']} IS NULL" + ) === false) { + SecureLogger::error('[NcipServer] Orphan cleanup for ' . $fk['column'] . ' failed: ' . $this->db->error); + continue; + } + + $alter = "ALTER TABLE ncip_transactions + ADD CONSTRAINT {$fk['name']} + FOREIGN KEY ({$fk['column']}) REFERENCES {$fk['ref_table']} ({$fk['ref_col']}) ON DELETE SET NULL"; + if ($this->db->query($alter) === false) { + SecureLogger::error('[NcipServer] Adding FK ' . $fk['name'] . ' failed: ' . $this->db->error); + } + } + } + // ─── Hook registration ──────────────────────────────────────────────────── private function registerHookInDb(string $hookName, string $method, int $priority): void diff --git a/storage/plugins/z39-server/Z39ServerPlugin.php b/storage/plugins/z39-server/Z39ServerPlugin.php index 915962f05..b4b969310 100644 --- a/storage/plugins/z39-server/Z39ServerPlugin.php +++ b/storage/plugins/z39-server/Z39ServerPlugin.php @@ -1345,8 +1345,12 @@ public function importUnimarcAction(ServerRequestInterface $request, ResponseInt } // fromUnimarcXml() returns [] on unparseable XML and may return a record - // with no bibliographic core if the tags are unrecognised. - if (!isset($book['titolo']) && !isset($book['isbn13']) && !isset($book['isbn10'])) { + // with no bibliographic core if the tags are unrecognised. Guard on the + // VALUES, not just the keys: a present-but-empty title/ISBN is still no + // usable bibliographic data. + if (trim((string) ($book['titolo'] ?? '')) === '' + && trim((string) ($book['isbn13'] ?? '')) === '' + && trim((string) ($book['isbn10'] ?? '')) === '') { return $this->jsonResponse($response, ['success' => false, 'error' => __('Nessun dato UNIMARC valido da importare.')], 422); } From 602ab46778acbd589946a8439c9224dd22ac82f9 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 24 Aug 2026 00:06:45 +0200 Subject: [PATCH 14/16] fix(ci): drop redundant null-coalesce and point the discogs hook test at the live registration path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHPStan (level 5, full tree) flagged `$autori = $autori ?? []` in the frbr-lrm Opera detail view as a redundant coalesce — the controller always passes $autori — so I removed it. The scrape-hooks-z39 unit test's T6/T7 asserted the `Hooks::add(...)` string that lived in discogs's dead `activate()` method (removed in this branch as unreachable). I updated them to assert the live `registerHooks()` array registration (['scrape.data.modify', 'enrichWithDiscogsData', ...]) that actually runs in production — the behaviour under test is unchanged, only the path it inspects. --- storage/plugins/frbr-lrm/views/admin/opere/show.php | 1 - tests/scrape-hooks-z39.unit.php | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/storage/plugins/frbr-lrm/views/admin/opere/show.php b/storage/plugins/frbr-lrm/views/admin/opere/show.php index cd6af4404..ffc0408ac 100644 --- a/storage/plugins/frbr-lrm/views/admin/opere/show.php +++ b/storage/plugins/frbr-lrm/views/admin/opere/show.php @@ -16,7 +16,6 @@ 'audio' => __("Audio"), 'altro' => __("Altro"), ]; -$autori = $autori ?? []; ?>
diff --git a/tests/scrape-hooks-z39.unit.php b/tests/scrape-hooks-z39.unit.php index 9e4cd0885..e023ad2c0 100644 --- a/tests/scrape-hooks-z39.unit.php +++ b/tests/scrape-hooks-z39.unit.php @@ -117,10 +117,10 @@ function phpFilesUnder(array $dirs): array { 'T5 core still emits scrape.isbn.validate (discogs validateBarcode is a LIVE hook, issue #101)'); // ─── Group B — plugin registrations against those hooks are all live ──────── -aContains("Hooks::add('scrape.data.modify', [\$this, 'enrichWithDiscogsData']", $discogsSrc, +aContains("['scrape.data.modify', 'enrichWithDiscogsData'", $discogsSrc, 'T6 discogs registers enrichWithDiscogsData on scrape.data.modify'); -aContains("Hooks::add('scrape.isbn.validate', [\$this, 'validateBarcode']", $discogsSrc, +aContains("['scrape.isbn.validate', 'validateBarcode'", $discogsSrc, 'T7 discogs registers validateBarcode on scrape.isbn.validate'); if ($scrapingPro === null) { From 5776ccf42940718713e9c0079c92970654bd8dae Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 24 Aug 2026 00:33:29 +0200 Subject: [PATCH 15/16] Make plugin hook registration and NCIP FK migration atomic and fail-loud api-book-scraper: wrap registerHooks() delete+inserts in one transaction so a mid-loop failure can never leave a partial hook set. Detect an outer transaction (saveSettings already owns one) with a savepoint probe and scope to a savepoint there instead of nesting begin_transaction(), which would implicitly commit the caller's transaction. After a rollback, reload the persisted settings so the instance never reflects unpersisted in-memory values. Reject enabling the plugin when no effective API key exists (submitted or stored) in saveSettings() so a hand-built POST cannot bypass the view's required attribute. ncip-server: ensureForeignKeys() now returns whether every FK was ensured; ensureSchema() marks ncip_transactions as failed when the FK migration fails, so onActivate()/onInstall() no longer register hooks over a half-applied schema. Idempotent and safe to re-run. --- .../api-book-scraper/ApiBookScraperPlugin.php | 110 +++++++++++++++--- .../plugins/ncip-server/NcipServerPlugin.php | 21 +++- 2 files changed, 111 insertions(+), 20 deletions(-) diff --git a/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php b/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php index d8d15bc0d..1745c299f 100644 --- a/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php +++ b/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php @@ -153,34 +153,91 @@ private function registerHooks(): void ['scrape.isbn.validate', 'validateIsbn', 2], ]; - // Delete existing hooks for this plugin - $this->deleteHooks(); + // The DELETE + N INSERTs must be atomic: a mid-loop failure must never + // leave plugin_hooks with a partial subset of the hooks. saveSettings() + // may already own an outer transaction, so detect it with a savepoint + // probe and scope this work to a savepoint there; otherwise own a fresh + // transaction. Nesting begin_transaction() would implicitly commit the + // caller's transaction, so it must never be nested. + $inTransaction = $this->hasActiveTransaction(); + $savepoint = 'pinakes_sp_' . bin2hex(random_bytes(6)); + if ($inTransaction) { + $this->db->query('SAVEPOINT ' . $savepoint); + } else { + $this->db->begin_transaction(); + } + + try { + // Delete existing hooks for this plugin + $this->deleteHooks(); - foreach ($hooks as [$hookName, $method, $priority]) { - $stmt = $this->db->prepare( - "INSERT INTO plugin_hooks (plugin_id, hook_name, callback_class, callback_method, priority, is_active, created_at) - VALUES (?, ?, ?, ?, ?, 1, NOW())" - ); + foreach ($hooks as [$hookName, $method, $priority]) { + $stmt = $this->db->prepare( + "INSERT INTO plugin_hooks (plugin_id, hook_name, callback_class, callback_method, priority, is_active, created_at) + VALUES (?, ?, ?, ?, ?, 1, NOW())" + ); - if (!$stmt) { - throw new \RuntimeException('[ApiBookScraper] prepare() failed for hook ' . $hookName . ': ' . $this->db->error); - } + if (!$stmt) { + throw new \RuntimeException('[ApiBookScraper] prepare() failed for hook ' . $hookName . ': ' . $this->db->error); + } - $className = __CLASS__; - $stmt->bind_param('isssi', $this->pluginId, $hookName, $className, $method, $priority); + $className = __CLASS__; + $stmt->bind_param('isssi', $this->pluginId, $hookName, $className, $method, $priority); + + if (!$stmt->execute()) { + $err = $stmt->error; + $stmt->close(); + throw new \RuntimeException('[ApiBookScraper] hook insert failed for ' . $hookName . ': ' . $err); + } - if (!$stmt->execute()) { - $err = $stmt->error; $stmt->close(); - throw new \RuntimeException('[ApiBookScraper] hook insert failed for ' . $hookName . ': ' . $err); } - $stmt->close(); + if ($inTransaction) { + $this->db->query('RELEASE SAVEPOINT ' . $savepoint); + } else { + $this->db->commit(); + } + } catch (\Throwable $e) { + if ($inTransaction) { + $this->db->query('ROLLBACK TO SAVEPOINT ' . $savepoint); + } else { + $this->db->rollback(); + } + throw $e; } \App\Support\SecureLogger::debug('[ApiBookScraper] Hooks registered'); } + /** + * Whether a transaction is already open on the connection. @@autocommit is + * unreliable here — it stays 1 after begin_transaction() — so probe with a + * savepoint instead: outside a transaction the SAVEPOINT is a no-op that + * autocommit discards, so the following RELEASE cannot find it. Works under + * both mysqli exception and silent error modes. Unique probe name per call + * so it can never clobber a caller's own savepoint boundary. + */ + private function hasActiveTransaction(): bool + { + if ($this->db === null) { + return false; + } + $probe = 'pinakes_sp_' . bin2hex(random_bytes(6)); + try { + if ($this->db->query('SAVEPOINT ' . $probe) === false) { + return false; + } + } catch (\mysqli_sql_exception $e) { + return false; + } + try { + return $this->db->query('RELEASE SAVEPOINT ' . $probe) !== false; + } catch (\mysqli_sql_exception $e) { + return false; + } + } + /** * Delete all hooks for this plugin */ @@ -531,6 +588,22 @@ public function saveSettings(array $settings): bool return false; } + // Reject enabling the plugin with no effective API key. The view's HTML + // `required` attribute does not protect a hand-built POST: without a key + // saveSettings would "succeed" while registerHooks() silently registers + // nothing (its own guard requires a key). An empty submitted key keeps + // the stored one, so only reject when neither exists. + $enabledRequested = array_key_exists('enabled', $settings) + ? (bool) $settings['enabled'] + : $this->enabled; + $submittedKey = array_key_exists('api_key', $settings) + ? trim((string) $settings['api_key']) + : ''; + $effectiveKey = $submittedKey !== '' ? $submittedKey : $this->apiKey; + if ($enabledRequested && $effectiveKey === '') { + throw new \RuntimeException('[ApiBookScraper] cannot enable plugin without an API key'); + } + // Wrap the settings replacement AND the hook re-registration in one // transaction: if registerHooks() fails after deleteHooks() has run, // the whole change must roll back so we never leave the scrape.* hooks @@ -580,6 +653,11 @@ public function saveSettings(array $settings): bool $this->db->commit(); } catch (\Throwable $e) { $this->db->rollback(); + // The rollback reverts the DB, but the in-memory properties were + // already repopulated by loadSettings() from the (uncommitted) + // transaction. Reload from the now-restored persisted state so the + // instance/UI never reflect unpersisted values after the failure. + $this->loadSettings(); \App\Support\SecureLogger::error('[ApiBookScraper] saveSettings failed, rolled back: ' . $e->getMessage()); throw $e; } diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index e3b51b8c2..efc26130a 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -174,9 +174,11 @@ public function ensureSchema(): array // CREATE TABLE IF NOT EXISTS is a no-op for a pre-existing // ncip_transactions table, so the two FK constraints are never applied // on an upgrade from a build that shipped the table without them. - // Add them idempotently now (safe to re-run). - if (!in_array('ncip_transactions', $failed, true)) { - $this->ensureForeignKeys(); + // Add them idempotently now (safe to re-run). If the FK migration + // fails, mark ncip_transactions as failed so onActivate()/onInstall() + // do NOT register hooks against a half-applied schema. + if (!in_array('ncip_transactions', $failed, true) && !$this->ensureForeignKeys()) { + $failed[] = 'ncip_transactions'; } // Core schema changes for prestiti.ncip_request_id and origine ENUM are in migrate_0.7.4.sql @@ -190,14 +192,19 @@ public function ensureSchema(): array * out orphan rows that would violate the constraint, then ALTERs it in with * ON DELETE SET NULL. Idempotent and safe to re-run from onActivate/onInstall. * All table/column names below are static literals — no user input. + * + * @return bool True when every FK is present (or was added); false when any + * probe/cleanup/ALTER failed so the caller can report the schema + * as half-applied instead of registering hooks over it. */ - private function ensureForeignKeys(): void + private function ensureForeignKeys(): bool { $fks = [ ['column' => 'partner_id', 'ref_table' => 'ncip_partners', 'ref_col' => 'id', 'name' => 'ncip_transactions_ibfk_1'], ['column' => 'prestito_id', 'ref_table' => 'prestiti', 'ref_col' => 'id', 'name' => 'ncip_transactions_ibfk_2'], ]; + $ok = true; foreach ($fks as $fk) { $stmt = $this->db->prepare( "SELECT COUNT(*) AS c FROM information_schema.KEY_COLUMN_USAGE @@ -208,12 +215,14 @@ private function ensureForeignKeys(): void ); if ($stmt === false) { SecureLogger::error('[NcipServer] FK probe prepare failed: ' . $this->db->error); + $ok = false; continue; } $stmt->bind_param('ss', $fk['column'], $fk['ref_table']); if (!$stmt->execute()) { SecureLogger::error('[NcipServer] FK probe failed for ' . $fk['column'] . ': ' . $stmt->error); $stmt->close(); + $ok = false; continue; } $res = $stmt->get_result(); @@ -231,6 +240,7 @@ private function ensureForeignKeys(): void WHERE t.{$fk['column']} IS NOT NULL AND r.{$fk['ref_col']} IS NULL" ) === false) { SecureLogger::error('[NcipServer] Orphan cleanup for ' . $fk['column'] . ' failed: ' . $this->db->error); + $ok = false; continue; } @@ -239,8 +249,11 @@ private function ensureForeignKeys(): void FOREIGN KEY ({$fk['column']}) REFERENCES {$fk['ref_table']} ({$fk['ref_col']}) ON DELETE SET NULL"; if ($this->db->query($alter) === false) { SecureLogger::error('[NcipServer] Adding FK ' . $fk['name'] . ' failed: ' . $this->db->error); + $ok = false; } } + + return $ok; } // ─── Hook registration ──────────────────────────────────────────────────── From 024ecb4893aef9e7c157f7a4f9ee5bbca1a3ecae Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 24 Aug 2026 01:11:55 +0200 Subject: [PATCH 16/16] fix(archives): preserve the reference_code prefix on soft-delete instead of overwriting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My earlier soft-delete fix replaced reference_code with CONCAT('__deleted_', id) to free the UNIQUE key, but that discarded the original code — breaking archives-50-elements-crud D01 (which counts rows by reference_code LIKE the fixture tag after soft-delete) and removing the code needed for audit and reference lookups. I now APPEND a per-id token, truncating the original prefix just enough to keep the result within VARCHAR(64) with the token intact. The UNIQUE key is still freed — the result always differs from the original, verified even for a reference_code already at the full 64 chars — while the original code stays readable as a prefix. --- storage/plugins/archives/ArchivesPlugin.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/storage/plugins/archives/ArchivesPlugin.php b/storage/plugins/archives/ArchivesPlugin.php index 86c07b68b..80a1d1d9f 100644 --- a/storage/plugins/archives/ArchivesPlugin.php +++ b/storage/plugins/archives/ArchivesPlugin.php @@ -1827,14 +1827,20 @@ public function destroyAction( // (institution_code, reference_code) nor uq_ark_identifier is scoped // by deleted_at, so leaving the values in place would permanently // block reuse — the same reason core libri nullifies isbn10/13/ean. - // reference_code is NOT NULL, so it is replaced with a per-id token - // (rewriting it alone frees the combined key); ark_identifier is - // nullable and set to NULL (multiple NULLs are allowed in a UNIQUE - // index). + // reference_code is NOT NULL and only VARCHAR(64), so rather than + // overwriting it (which would lose the original code needed for audit + // and reference lookups) I APPEND a per-id token, truncating the + // original prefix just enough to keep the result within 64 chars with + // the token intact. This frees the combined key while preserving the + // original code as a readable prefix; ark_identifier is nullable and + // set to NULL (multiple NULLs are allowed in a UNIQUE index). $stmt = $this->db->prepare( "UPDATE archival_units SET deleted_at = NOW(), - reference_code = CONCAT('__deleted_', id), + reference_code = CONCAT( + LEFT(reference_code, GREATEST(0, 64 - CHAR_LENGTH(CONCAT('__deleted_', id)))), + '__deleted_', id + ), ark_identifier = NULL WHERE id = ? AND deleted_at IS NULL" );