diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 5f3e6ba8..fdc958a9 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,2 +1,155 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# Pinakes — CodeRabbit Configuration +# PHP/Slim 4 library management system with MySQL + +language: "it-IT" + +tone_instructions: | + Sii conciso e diretto. Concentrati su bug reali, vulnerabilità di sicurezza + e violazioni delle regole del progetto. Evita suggerimenti stilistici minori. + +early_access: true + reviews: - max_files: 200 + profile: "assertive" + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + + # ── File Filters ────────────────────────────────────────────────── + path_filters: + - "!vendor/**" + - "!node_modules/**" + - "!public/assets/tinymce/**" + - "!public/assets/fontawesome/**" + - "!public/assets/choices/**" + - "!public/assets/flatpickr/**" + - "!public/assets/sweetalert2/**" + - "!*.min.js" + - "!*.min.css" + - "!*.map" + - "!pinakes-*.zip" + - "!pinakes-*.sha256" + - "!test-results/**" + + # ── Path-Specific Review Instructions ────────────────────────────── + path_instructions: + # Controllers — input validation, auth, soft-delete + - path: "app/Controllers/**" + instructions: | + - CRITICO: ogni query sulla tabella `libri` DEVE avere `AND deleted_at IS NULL` + - Verifica che `getParsedBody()` non sia usato per JSON — serve `json_decode((string)$request->getBody())` + - Input utente: validare e sanitizzare PRIMA dell'uso + - Sessione: `$_SESSION['user']['id']` (NON `$_SESSION['user_id']`) + - Eccezioni: catturare `\Throwable` non `\Exception` (strict_types TypeError extends \Error) + - Logging: `SecureLogger::error()` non `error_log()` per contesti sensibili + - Route: mai hardcodare percorsi URL, usare `route_path('key')` o `RouteTranslator::route('key')` + - Export CSV: tipo_media deve essere incluso, usare stringa vuota come fallback (non 'libro') + + # Models / Repository — query safety + - path: "app/Models/**" + instructions: | + - CRITICO: ogni SELECT/UPDATE/DELETE sulla tabella `libri` DEVE avere `AND deleted_at IS NULL` + - Soft-delete: nullificare isbn10, isbn13, ean quando si fa soft-delete (prevent unique constraint violations) + - Transaction safety: mai annidare `begin_transaction()` in mysqli (causa commit implicito) + - Pattern: verificare `@@autocommit` per rilevare transazioni in corso + - hasColumn() guard per colonne aggiunte in migrazioni recenti (backward compat) + - tipo_media: usare `array_key_exists` guard, non sovrascrivere il valore se non esplicitamente fornito + + # Views — escaping, XSS prevention + - path: "app/Views/**" + instructions: | + - CRITICO: `htmlspecialchars(url(...), ENT_QUOTES, 'UTF-8')` in TUTTI gli attributi HTML (href, action, src) + - `route_path()` richiede lo stesso escaping negli attributi HTML + - PHP->JS: usare `json_encode(..., JSON_HEX_TAG)` per qualsiasi dato PHP inserito in JavaScript + - TinyMCE: SEMPRE includere `model: 'dom'` e `license_key: 'gpl'` in ogni `tinymce.init({})` + - Mai usare `HtmlHelper::e()` nelle view — usare `htmlspecialchars(..., ENT_QUOTES, 'UTF-8')` + - Schema.org: ogni tipo_media deve avere il proprio branch con proprietà specifiche (non mescolare Book con CreativeWork) + - DataTable: ogni valore da API deve passare per `escapeHtml()` prima del rendering + + # Support classes — helpers, utilities + - path: "app/Support/**" + instructions: | + - MediaLabels: `isMusic()` deve essere autoritativo su tipo_media quando impostato + - `inferTipoMedia()`: attenzione ai false positive su token corti ('cd' matcha 'CD-ROM', 'lp' matcha parole con 'lp') + - `formatTracklist()`: deve rilevare HTML pre-formattato (`
    `) e restituirlo as-is + - PluginManager: usare `\Throwable` non `\Exception`, `BundledPlugins::LIST` centralizzato + - Route translation: mai hardcodare percorsi, usare `RouteTranslator::route('key')` + + # Plugins — API safety, rate limiting + - path: "storage/plugins/**" + instructions: | + - SICUREZZA: ogni chiamata curl DEVE avere CURLOPT_PROTOCOLS (HTTP/HTTPS only), CURLOPT_MAXREDIRS, CURLOPT_CONNECTTIMEOUT, CURLOPT_SSL_VERIFYPEER + - SSRF: validare/castare ID esterni (es. releaseId a int) prima di usarli in URL + - Rate limiting: deve essere elapsed-based (microtime) e static (persistere tra istanze) + - Ogni `curl_exec()` deve avere `curl_error()` check con logging + - Hook registration: transazione + rethrow on failure + - Non enrichire dati di libri con cover musicali (gate su resolveTipoMedia) + + # Migrations — versioning, idempotency + - path: "installer/database/migrations/**" + instructions: | + - CRITICO: il nome del file di migrazione DEVE avere versione <= version.json (altrimenti viene silenziosamente saltata) + - L'updater usa `version_compare($migrationVersion, $toVersion, '<=')` — versioni superiori sono IGNORATE + - Ogni migrazione DEVE essere completamente idempotente (IF NOT EXISTS, IF @col_exists = 0, etc.) + - LIKE patterns: evitare `%cd%` e `%lp%` che matchano false positive ('CD-ROM', parole con 'lp') — usare REGEXP word boundaries + - Se servono più migrazioni per una release: unirle in UN file con la versione della release + + # Translations — completeness + - path: "locale/**" + instructions: | + - Ogni chiave presente in it_IT.json DEVE essere presente anche in en_US.json e de_DE.json + - Le chiavi di traduzione devono corrispondere esattamente (case-sensitive) + - I placeholder (%s, %d) devono essere preservati in tutte le lingue + - Nuove chiavi aggiunte nel codice PHP/JS devono essere aggiunte in TUTTE le lingue + + # Tests — E2E patterns + - path: "tests/**" + instructions: | + - I test E2E richiedono `/tmp/run-e2e.sh` per credenziali DB/admin + - `--workers=1` obbligatorio per esecuzione seriale + - SweetAlert: dopo form submit, verificare e cliccare `.swal2-confirm` + - Choices.js: usare `fill` + `waitForTimeout` + click suggestion + - Flatpickr: interagire via JS evaluate, non click diretto + - Pulizia dati test: FK-safe order (prima tabelle figlie, poi padri) + + # Release scripts + - path: "scripts/**" + instructions: | + - MAI creare ZIP manualmente — SEMPRE usare `create-release.sh` + - Lo script verifica 9 file critici nel ZIP prima del rilascio + - `git archive` usa file COMMITTATI, non la working directory + - Verificare che `public/assets/tinymce/models/dom/model.min.js` sia nel ZIP + + # ── Auto Review Settings ─────────────────────────────────────────── + auto_review: + enabled: true + drafts: false + + # ── Tools ────────────────────────────────────────────────────────── + tools: + phpstan: + enabled: true + shellcheck: + enabled: true + semgrep: + enabled: true + gitleaks: + enabled: true + yamllint: + enabled: true + +# ── Chat ────────────────────────────────────────────────────────────── +chat: + auto_reply: true + +# ── Knowledge Base ──────────────────────────────────────────────────── +knowledge_base: + opt_out: false + learnings: + scope: "local" + issues: + scope: "auto" + pull_requests: + scope: "auto" diff --git a/.gitattributes b/.gitattributes index ba93f43c..29186d2f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,7 +2,8 @@ * text=auto # Exclude from release archives (git archive) -public/installer/assets export-ignore +# NOTE: public/installer/assets/ MUST be in the ZIP — contains installer.js +# and style.css. Excluding it causes step 2 (test connection) to silently fail. tests/ export-ignore test/ export-ignore .github/ export-ignore diff --git a/.gitignore b/.gitignore index 29892a41..5dcb209d 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,13 @@ storage/plugins/goodlib/* !storage/plugins/goodlib/*.md !storage/plugins/goodlib/views/ !storage/plugins/goodlib/views/*.php +!storage/plugins/discogs/ +storage/plugins/discogs/* +!storage/plugins/discogs/*.php +!storage/plugins/discogs/*.json +!storage/plugins/discogs/*.md +!storage/plugins/discogs/views/ +!storage/plugins/discogs/views/*.php # Premium plugin - never track (private/commercial) storage/plugins/scraping-pro/ @@ -204,6 +211,7 @@ desktop.ini # Test Artifacts # ======================================== .playwright-mcp/ +test-results/ # ======================================== # Development Documentation (not for distribution) @@ -402,6 +410,9 @@ hackernews.md docs/reference/bug-gemini.md docs/reference/analisi-sicurezza.md scripts/generate_dewey_json.py +scripts/create-release-local.sh +pinakes-v*-local.zip +pinakes-v*-local.zip.sha256 docs/reference/start-server.md docs/reference/security-audit-report.md docs/reference/routes-to-add.md diff --git a/app/Controllers/BulkEnrichController.php b/app/Controllers/BulkEnrichController.php new file mode 100644 index 00000000..cf8fad15 --- /dev/null +++ b/app/Controllers/BulkEnrichController.php @@ -0,0 +1,80 @@ +getStats(); + $enabled = $service->isEnabled(); + + $pageTitle = __('Arricchimento Massivo'); + + ob_start(); + require __DIR__ . '/../Views/admin/bulk-enrich.php'; + $content = ob_get_clean(); + + ob_start(); + require __DIR__ . '/../Views/layout.php'; + $html = ob_get_clean(); + + $response->getBody()->write($html); + return $response; + } + + /** + * POST: Start a manual batch enrichment (20 books per batch) + * CSRF validated by CsrfMiddleware + */ + public function start(Request $request, Response $response, mysqli $db): Response + { + $service = new BulkEnrichmentService($db); + + try { + $results = $service->enrichBatch(20); + + $response->getBody()->write(json_encode([ + 'success' => true, + 'results' => $results, + ], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG)); + } catch (\Throwable $e) { + $response->getBody()->write(json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG)); + } + + return $response->withHeader('Content-Type', 'application/json'); + } + + /** + * POST: Toggle automatic enrichment (cron) on/off + * CSRF validated by CsrfMiddleware + */ + public function toggle(Request $request, Response $response, mysqli $db): Response + { + $data = (array) $request->getParsedBody(); + $enabled = !empty($data['enabled']); + + $service = new BulkEnrichmentService($db); + $service->setEnabled($enabled); + + $response->getBody()->write(json_encode([ + 'success' => true, + 'enabled' => $enabled, + ], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG)); + + return $response->withHeader('Content-Type', 'application/json'); + } +} diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 4b3625f1..ea02e2ac 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -17,6 +17,28 @@ class CsvImportController */ private const CHUNK_SIZE = 10; + /** @var bool|null Cached result of tipo_media column existence check */ + private ?bool $cachedHasTipoMedia = null; + + /** + * Check if tipo_media column exists (cached per controller instance). + */ + private function hasTipoMediaColumn(\mysqli $db): bool + { + if ($this->cachedHasTipoMedia === null) { + try { + $checkCol = $db->query("SHOW COLUMNS FROM libri LIKE 'tipo_media'"); + $this->cachedHasTipoMedia = $checkCol !== false && $checkCol->num_rows > 0; + if ($checkCol instanceof \mysqli_result) { + $checkCol->free(); + } + } catch (\Throwable $e) { + $this->cachedHasTipoMedia = false; + } + } + return $this->cachedHasTipoMedia; + } + /** * Write log message to import log file */ @@ -769,7 +791,10 @@ private function parseCsvRow(array $row): array 'numero_pagine' => !empty($row['numero_pagine']) ? (int)$row['numero_pagine'] : null, 'genere' => !empty($row['genere']) ? trim($row['genere']) : null, 'descrizione' => !empty($row['descrizione']) ? trim($row['descrizione']) : null, - 'formato' => !empty($row['formato']) ? trim($row['formato']) : 'cartaceo', + 'formato' => !empty($row['formato']) ? trim($row['formato']) : null, + 'tipo_media' => array_key_exists('tipo_media', $row) && trim((string) ($row['tipo_media'] ?? '')) !== '' + ? \App\Support\MediaLabels::normalizeTipoMedia(trim((string) $row['tipo_media'])) + : null, 'prezzo' => $this->validatePrice($row['prezzo'] ?? ''), 'copie_totali' => !empty($row['copie_totali']) ? (int)$row['copie_totali'] : 1, 'collana' => !empty($row['collana']) ? trim($row['collana']) : null, @@ -1022,6 +1047,7 @@ private function mapColumnHeaders(array $headers): array 'genere' => ['genere', 'genre', 'género', 'category', 'categoria'], 'descrizione' => ['descrizione', 'description', 'descripción', 'summary', 'riassunto', 'abstract'], 'formato' => ['formato', 'format', 'media', 'binding', 'physical description'], + 'tipo_media' => ['tipo_media', 'media_type', 'type', 'medientyp'], 'prezzo' => ['prezzo', 'price', 'precio', 'prix', 'preis', 'list price', 'purchase price'], 'copie_totali' => ['copie_totali', 'copie', 'copies', 'quantity', 'quantità', 'cantidad'], 'collana' => ['collana', 'series', 'collection', 'collections', 'colección', 'reihe'], @@ -1224,6 +1250,9 @@ private function findExistingBook(\mysqli $db, array $data): ?int */ private function updateBook(\mysqli $db, int $bookId, array $data, ?int $editorId, ?int $genreId): void { + $hasTipoMedia = $this->hasTipoMediaColumn($db); + $tipoMediaSet = $hasTipoMedia ? ', tipo_media = COALESCE(?, tipo_media)' : ''; + $stmt = $db->prepare(" UPDATE libri SET isbn10 = ?, @@ -1237,7 +1266,7 @@ private function updateBook(\mysqli $db, int $bookId, array $data, ?int $editorI numero_pagine = ?, genere_id = ?, descrizione = ?, - formato = ?, + formato = ?{$tipoMediaSet}, prezzo = ?, editore_id = ?, collana = ?, @@ -1247,7 +1276,7 @@ private function updateBook(\mysqli $db, int $bookId, array $data, ?int $editorI parole_chiave = ?, classificazione_dewey = ?, updated_at = NOW() - WHERE id = ? + WHERE id = ? AND deleted_at IS NULL "); $isbn10 = !empty($data['isbn10']) ? $data['isbn10'] : null; @@ -1260,7 +1289,8 @@ classificazione_dewey = ?, $edizione = !empty($data['edizione']) ? $data['edizione'] : null; $pagine = !empty($data['numero_pagine']) ? (int) $data['numero_pagine'] : null; $descrizione = !empty($data['descrizione']) ? $data['descrizione'] : null; - $formato = !empty($data['formato']) ? $data['formato'] : 'cartaceo'; + $tipoMedia = $hasTipoMedia ? \App\Support\MediaLabels::normalizeTipoMedia($data['tipo_media'] ?? null) : null; + $formato = !empty($data['formato']) ? $data['formato'] : (empty($tipoMedia) || $tipoMedia === 'libro' ? 'cartaceo' : null); $prezzo = $data['prezzo'] ?? null; $collana = !empty($data['collana']) ? $data['collana'] : null; $numeroSerie = !empty($data['numero_serie']) ? $data['numero_serie'] : null; @@ -1269,30 +1299,30 @@ classificazione_dewey = ?, $paroleChiave = !empty($data['parole_chiave'] ?? null) ? $data['parole_chiave'] : null; $dewey = !empty($data['classificazione_dewey'] ?? null) ? $data['classificazione_dewey'] : null; - $stmt->bind_param( - 'sssssissiissdissssssi', - $isbn10, - $isbn13, - $ean, - $titolo, - $sottotitolo, - $anno, - $lingua, - $edizione, - $pagine, - $genreId, - $descrizione, - $formato, - $prezzo, - $editorId, - $collana, - $numeroSerie, - $traduttore, - $illustratore, - $paroleChiave, - $dewey, - $bookId - ); + $params = [ + $isbn10, $isbn13, $ean, $titolo, $sottotitolo, + $anno, $lingua, $edizione, $pagine, $genreId, + $descrizione, $formato, + ]; + if ($hasTipoMedia) { + $params[] = $tipoMedia; + } + $params = array_merge($params, [ + $prezzo, $editorId, $collana, $numeroSerie, + $traduttore, $illustratore, $paroleChiave, $dewey, $bookId, + ]); + + $types = ''; + foreach ($params as $p) { + if (is_int($p)) { + $types .= 'i'; + } elseif (is_float($p)) { + $types .= 'd'; + } else { + $types .= 's'; + } + } + $stmt->bind_param($types, ...$params); $stmt->execute(); $stmt->close(); @@ -1323,17 +1353,21 @@ private function upsertBook(\mysqli $db, array $data, ?int $editorId, ?int $genr */ private function insertBook(\mysqli $db, array $data, ?int $editorId, ?int $genreId): int { + $hasTipoMedia = $this->hasTipoMediaColumn($db); + $tipoMediaCol = $hasTipoMedia ? ', tipo_media' : ''; + $tipoMediaVal = $hasTipoMedia ? ', ?' : ''; + $stmt = $db->prepare(" INSERT INTO libri ( isbn10, isbn13, ean, titolo, sottotitolo, anno_pubblicazione, lingua, edizione, numero_pagine, genere_id, - descrizione, formato, prezzo, copie_totali, copie_disponibili, + descrizione, formato{$tipoMediaCol}, prezzo, copie_totali, copie_disponibili, editore_id, collana, numero_serie, traduttore, illustratore, parole_chiave, classificazione_dewey, stato, created_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, + ?, ?{$tipoMediaVal}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'disponibile', NOW() ) @@ -1349,7 +1383,8 @@ classificazione_dewey, stato, created_at $edizione = !empty($data['edizione']) ? $data['edizione'] : null; $pagine = !empty($data['numero_pagine']) ? (int) $data['numero_pagine'] : null; $descrizione = !empty($data['descrizione']) ? $data['descrizione'] : null; - $formato = !empty($data['formato']) ? $data['formato'] : 'cartaceo'; + $tipoMedia = $hasTipoMedia ? \App\Support\MediaLabels::resolveTipoMedia($data['formato'] ?? null, $data['tipo_media'] ?? null) : null; + $formato = !empty($data['formato']) ? $data['formato'] : (empty($tipoMedia) || $tipoMedia === 'libro' ? 'cartaceo' : null); $prezzo = $data['prezzo'] ?? null; $copie = !empty($data['copie_totali']) ? (int) $data['copie_totali'] : 1; // Add bounds checking to prevent DoS attacks @@ -1365,31 +1400,31 @@ classificazione_dewey, stato, created_at $paroleChiave = !empty($data['parole_chiave'] ?? null) ? $data['parole_chiave'] : null; $dewey = !empty($data['classificazione_dewey'] ?? null) ? $data['classificazione_dewey'] : null; - $stmt->bind_param( - 'sssssissiissdiiissssss', - $isbn10, - $isbn13, - $ean, - $titolo, - $sottotitolo, - $anno, - $lingua, - $edizione, - $pagine, - $genreId, - $descrizione, - $formato, - $prezzo, - $copie, - $copie, - $editorId, - $collana, - $numeroSerie, - $traduttore, - $illustratore, - $paroleChiave, - $dewey - ); + $params = [ + $isbn10, $isbn13, $ean, $titolo, $sottotitolo, $anno, + $lingua, $edizione, $pagine, $genreId, + $descrizione, $formato, + ]; + if ($hasTipoMedia) { + $params[] = $tipoMedia; + } + $params = array_merge($params, [ + $prezzo, $copie, $copie, + $editorId, $collana, $numeroSerie, $traduttore, $illustratore, $paroleChiave, + $dewey, + ]); + + $types = ''; + foreach ($params as $p) { + if (is_int($p)) { + $types .= 'i'; + } elseif (is_float($p)) { + $types .= 'd'; + } else { + $types .= 's'; + } + } + $stmt->bind_param($types, ...$params); $stmt->execute(); $bookId = $db->insert_id; @@ -1553,7 +1588,7 @@ private function enrichBookWithScrapedData(\mysqli $db, int $bookId, array $csvD // Update libro if we have data if (!empty($updates)) { - $sql = "UPDATE libri SET " . implode(', ', $updates) . " WHERE id = ?"; + $sql = "UPDATE libri SET " . implode(', ', $updates) . " WHERE id = ? AND deleted_at IS NULL"; $params[] = $bookId; $types .= 'i'; @@ -1594,7 +1629,7 @@ private function enrichBookWithScrapedData(\mysqli $db, int $bookId, array $csvD $publisherResult = $this->getOrCreatePublisher($db, $scrapedData['publisher']); $editorId = $publisherResult['id']; - $stmt = $db->prepare("UPDATE libri SET editore_id = ? WHERE id = ?"); + $stmt = $db->prepare("UPDATE libri SET editore_id = ? WHERE id = ? AND deleted_at IS NULL"); $stmt->bind_param('ii', $editorId, $bookId); $stmt->execute(); $stmt->close(); diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index ec2e9b7f..2da82cc5 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -734,6 +734,10 @@ private function getFilters(array $params): array { // Support both 'q' (header form) and 'search' (hero form) parameters $searchTerm = $params['q'] ?? $params['search'] ?? ''; + $rawTipoMedia = $params['tipo_media'] ?? ''; + if (is_array($rawTipoMedia)) { + $rawTipoMedia = $rawTipoMedia[0] ?? ''; + } return [ 'search' => $searchTerm, @@ -742,6 +746,7 @@ private function getFilters(array $params): array 'editore' => $params['editore'] ?? '', 'anno_min' => $params['anno_min'] ?? '', 'anno_max' => $params['anno_max'] ?? '', + 'tipo_media' => trim((string) $rawTipoMedia), 'sort' => $params['sort'] ?? 'newest' ]; } @@ -896,6 +901,12 @@ private function buildWhereConditions(array $filters, mysqli $db): array $types .= 'i'; } + if (!empty($filters['tipo_media']) && $this->hasLibriColumn($db, 'tipo_media')) { + $conditions[] = "l.tipo_media = ?"; + $params[] = $filters['tipo_media']; + $types .= 's'; + } + return [ 'conditions' => $conditions, 'params' => $params, @@ -903,6 +914,18 @@ private function buildWhereConditions(array $filters, mysqli $db): array ]; } + private function hasLibriColumn(mysqli $db, string $column): bool + { + static $columnCache = []; + + if (!array_key_exists($column, $columnCache)) { + $result = $db->query("SHOW COLUMNS FROM libri LIKE '" . $db->real_escape_string($column) . "'"); + $columnCache[$column] = $result !== false && $result->num_rows > 0; + } + + return $columnCache[$column]; + } + private function buildOrderBy(string $sort): string { switch ($sort) { diff --git a/app/Controllers/LibraryThingImportController.php b/app/Controllers/LibraryThingImportController.php index d760c8fc..adf19dab 100644 --- a/app/Controllers/LibraryThingImportController.php +++ b/app/Controllers/LibraryThingImportController.php @@ -35,6 +35,28 @@ class LibraryThingImportController /** @var bool|null Cached result of descrizione_plain column existence check */ private ?bool $cachedHasDescPlain = null; + /** @var bool|null Cached result of tipo_media column existence check */ + private ?bool $cachedHasTipoMedia = null; + + /** + * Check if tipo_media column exists (cached per controller instance). + */ + private function hasTipoMediaColumn(\mysqli $db): bool + { + if ($this->cachedHasTipoMedia === null) { + try { + $checkCol = $db->query("SHOW COLUMNS FROM libri LIKE 'tipo_media'"); + $this->cachedHasTipoMedia = $checkCol !== false && $checkCol->num_rows > 0; + if ($checkCol instanceof \mysqli_result) { + $checkCol->free(); + } + } catch (\Throwable $e) { + $this->cachedHasTipoMedia = false; + } + } + return $this->cachedHasTipoMedia; + } + /** * Check if descrizione_plain column exists (cached per controller instance). */ @@ -911,6 +933,10 @@ private function parseLibraryThingRow(array $data): array } } + // Infer tipo_media from Media field (null when empty to avoid overwriting) + $mediaRaw = trim((string) ($data['Media'] ?? '')); + $result['tipo_media'] = $mediaRaw !== '' ? \App\Support\MediaLabels::inferTipoMedia($mediaRaw) : null; + // Genre/Subjects $result['genere'] = !empty($data['Subjects']) ? trim(explode(',', $data['Subjects'])[0]) : ''; @@ -1276,13 +1302,17 @@ private function updateBook(\mysqli $db, int $bookId, array $data, ?int $editorI $hasDescPlain = $this->hasDescrizionePlainColumn($db); $descPlainSet = $hasDescPlain ? ', descrizione_plain = ?' : ''; + // Check if tipo_media column exists (cached per controller instance) + $hasTipoMedia = $this->hasTipoMediaColumn($db); + $tipoMediaSet = $hasTipoMedia ? ', tipo_media = COALESCE(?, tipo_media)' : ''; + if ($hasLTFields) { // Full update with all LibraryThing fields $stmt = $db->prepare(" UPDATE libri SET isbn10 = ?, isbn13 = ?, ean = ?, titolo = ?, sottotitolo = ?, anno_pubblicazione = ?, lingua = ?, edizione = ?, numero_pagine = ?, - genere_id = ?, descrizione = ?{$descPlainSet}, formato = ?, prezzo = ?, editore_id = ?, + genere_id = ?, descrizione = ?{$descPlainSet}, formato = ?{$tipoMediaSet}, prezzo = ?, editore_id = ?, collana = ?, numero_serie = ?, traduttore = ?, parole_chiave = ?, classificazione_dewey = ?, peso = ?, dimensioni = ?, data_acquisizione = ?, review = ?, rating = ?, comment = ?, private_comment = ?, @@ -1315,8 +1345,12 @@ classificazione_dewey = ?, peso = ?, dimensioni = ?, data_acquisizione = ?, if ($hasDescPlain) { $params[] = !empty($data['descrizione_plain']) ? $data['descrizione_plain'] : null; } + $tipoMedia = $hasTipoMedia ? \App\Support\MediaLabels::normalizeTipoMedia($data['tipo_media'] ?? null) : null; + $params[] = !empty($data['formato']) ? $data['formato'] : (empty($tipoMedia) || $tipoMedia === 'libro' ? 'cartaceo' : null); + if ($hasTipoMedia) { + $params[] = $tipoMedia; + } $params = array_merge($params, [ - !empty($data['formato']) ? $data['formato'] : 'cartaceo', !empty($data['prezzo']) ? (float) str_replace(',', '.', $data['prezzo']) : null, $editorId, !empty($data['collana']) ? $data['collana'] : null, @@ -1363,7 +1397,7 @@ classificazione_dewey = ?, peso = ?, dimensioni = ?, data_acquisizione = ?, UPDATE libri SET isbn10 = ?, isbn13 = ?, ean = ?, titolo = ?, sottotitolo = ?, anno_pubblicazione = ?, lingua = ?, edizione = ?, numero_pagine = ?, - genere_id = ?, descrizione = ?{$descPlainSet}, formato = ?, prezzo = ?, editore_id = ?, + genere_id = ?, descrizione = ?{$descPlainSet}, formato = ?{$tipoMediaSet}, prezzo = ?, editore_id = ?, collana = ?, numero_serie = ?, traduttore = ?, parole_chiave = ?, classificazione_dewey = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL @@ -1387,8 +1421,12 @@ classificazione_dewey = ?, updated_at = NOW() if ($hasDescPlain) { $params[] = !empty($data['descrizione_plain']) ? $data['descrizione_plain'] : null; } + $tipoMedia = $hasTipoMedia ? \App\Support\MediaLabels::normalizeTipoMedia($data['tipo_media'] ?? null) : null; + $params[] = !empty($data['formato']) ? $data['formato'] : (empty($tipoMedia) || $tipoMedia === 'libro' ? 'cartaceo' : null); + if ($hasTipoMedia) { + $params[] = $tipoMedia; + } $params = array_merge($params, [ - !empty($data['formato']) ? $data['formato'] : 'cartaceo', !empty($data['prezzo']) ? (float) str_replace(',', '.', $data['prezzo']) : null, $editorId, !empty($data['collana']) ? $data['collana'] : null, @@ -1434,6 +1472,11 @@ private function insertBook(\mysqli $db, array $data, ?int $editorId, ?int $genr $descPlainCol = $hasDescPlain ? ', descrizione_plain' : ''; $descPlainVal = $hasDescPlain ? ', ?' : ''; + // Check if tipo_media column exists (cached per controller instance) + $hasTipoMedia = $this->hasTipoMediaColumn($db); + $tipoMediaCol = $hasTipoMedia ? ', tipo_media' : ''; + $tipoMediaVal = $hasTipoMedia ? ', ?' : ''; + $copie = !empty($data['copie_totali']) ? (int) $data['copie_totali'] : 1; if ($copie < 1) { $copie = 1; @@ -1446,7 +1489,7 @@ private function insertBook(\mysqli $db, array $data, ?int $editorId, ?int $genr $stmt = $db->prepare(" INSERT INTO libri ( isbn10, isbn13, ean, titolo, sottotitolo, anno_pubblicazione, - lingua, edizione, numero_pagine, genere_id, descrizione{$descPlainCol}, formato, + lingua, edizione, numero_pagine, genere_id, descrizione{$descPlainCol}, formato{$tipoMediaCol}, prezzo, copie_totali, copie_disponibili, editore_id, collana, numero_serie, traduttore, parole_chiave, classificazione_dewey, peso, dimensioni, data_acquisizione, @@ -1460,7 +1503,7 @@ private function insertBook(\mysqli $db, array $data, ?int $editorId, ?int $genr value, condition_lt, entry_date, stato, created_at ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?{$descPlainVal}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?{$descPlainVal}, ?{$tipoMediaVal}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -1492,8 +1535,13 @@ private function insertBook(\mysqli $db, array $data, ?int $editorId, ?int $genr if ($hasDescPlain) { $params[] = !empty($data['descrizione_plain']) ? $data['descrizione_plain'] : null; } + $tipoMedia = $hasTipoMedia ? \App\Support\MediaLabels::resolveTipoMedia($data['formato'] ?? null, $data['tipo_media'] ?? null) : null; + $formato = !empty($data['formato']) ? $data['formato'] : (empty($tipoMedia) || $tipoMedia === 'libro' ? 'cartaceo' : null); + $params[] = $formato; + if ($hasTipoMedia) { + $params[] = $tipoMedia; + } $params = array_merge($params, [ - !empty($data['formato']) ? $data['formato'] : 'cartaceo', !empty($data['prezzo']) ? (float) str_replace(',', '.', $data['prezzo']) : null, $copie, $copie, @@ -1540,12 +1588,12 @@ private function insertBook(\mysqli $db, array $data, ?int $editorId, ?int $genr $stmt = $db->prepare(" INSERT INTO libri ( isbn10, isbn13, ean, titolo, sottotitolo, anno_pubblicazione, - lingua, edizione, numero_pagine, genere_id, descrizione{$descPlainCol}, formato, + lingua, edizione, numero_pagine, genere_id, descrizione{$descPlainCol}, formato{$tipoMediaCol}, prezzo, copie_totali, copie_disponibili, editore_id, collana, numero_serie, traduttore, parole_chiave, classificazione_dewey, stato, created_at ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?{$descPlainVal}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'disponibile', NOW() + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?{$descPlainVal}, ?{$tipoMediaVal}, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'disponibile', NOW() ) "); @@ -1567,8 +1615,13 @@ private function insertBook(\mysqli $db, array $data, ?int $editorId, ?int $genr if ($hasDescPlain) { $params[] = !empty($data['descrizione_plain']) ? $data['descrizione_plain'] : null; } + $tipoMedia = $hasTipoMedia ? \App\Support\MediaLabels::resolveTipoMedia($data['formato'] ?? null, $data['tipo_media'] ?? null) : null; + $formato = !empty($data['formato']) ? $data['formato'] : (empty($tipoMedia) || $tipoMedia === 'libro' ? 'cartaceo' : null); + $params[] = $formato; + if ($hasTipoMedia) { + $params[] = $tipoMedia; + } $params = array_merge($params, [ - !empty($data['formato']) ? $data['formato'] : 'cartaceo', !empty($data['prezzo']) ? (float) str_replace(',', '.', $data['prezzo']) : null, $copie, $copie, diff --git a/app/Controllers/LibriApiController.php b/app/Controllers/LibriApiController.php index e15a1c65..19793ff3 100644 --- a/app/Controllers/LibriApiController.php +++ b/app/Controllers/LibriApiController.php @@ -32,6 +32,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $anno_from = trim((string) ($q['anno_from'] ?? '')); $anno_to = trim((string) ($q['anno_to'] ?? '')); $collana = trim((string) ($q['collana'] ?? '')); + $tipo_media = trim((string) ($q['tipo_media'] ?? '')); // Build WHERE clause with prepared statement parameters $where = 'WHERE l.deleted_at IS NULL '; @@ -137,19 +138,24 @@ public function list(Request $request, Response $response, mysqli $db): Response $params[] = '%' . $collana . '%'; $types .= 's'; } + if ($tipo_media !== '' && $this->hasColumn($db, 'tipo_media')) { + $where .= " AND l.tipo_media = ?"; + $params[] = $tipo_media; + $types .= 's'; + } // Parse DataTables sorting parameters (with robust null checks to avoid notices) $order = $q['order'][0] ?? null; - $orderColumn = isset($order['column']) ? (int) $order['column'] : 3; // Default to Info column (title) + $orderColumn = isset($order['column']) ? (int) $order['column'] : 4; // Default to Info column (title) $orderDir = (isset($order['dir']) && strtoupper(trim($order['dir'])) === 'DESC') ? 'DESC' : 'ASC'; // Map column indices to database fields - // Columns: 0=checkbox, 1=status, 2=cover, 3=info(title), 4=genre, 5=position, 6=year, 7=actions + // Columns: 0=checkbox, 1=status, 2=media, 3=cover, 4=info(title), 5=genre, 6=position, 7=year, 8=actions $orderByMap = [ - 3 => 'l.titolo', // Info column - sort by title - 4 => 'g.nome', // Genre column - 5 => 's.codice, m.numero_livello, COALESCE(l.posizione_progressiva, p.ordine)', // Position - 6 => 'l.anno_pubblicazione', // Year column + 4 => 'l.titolo', // Info column - sort by title + 5 => 'g.nome', // Genre column + 6 => 's.codice, m.numero_livello, COALESCE(l.posizione_progressiva, p.ordine)', // Position + 7 => 'l.anno_pubblicazione', // Year column ]; $orderByClause = $orderByMap[$orderColumn] ?? 'l.titolo'; diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 59d0de22..6287cf0d 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -2998,6 +2998,7 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res 'numero_pagine', 'genere', 'formato', + 'tipo_media', 'prezzo', 'copie_totali', 'collana', @@ -3035,6 +3036,7 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res $libro['numero_pagine'] ?? '', $libro['genere_nome'] ?? '', $libro['formato'] ?? '', + $libro['tipo_media'] ?? '', $libro['prezzo'] ?? '', $libro['copie_totali'] ?? '1', $libro['collana'] ?? '', diff --git a/app/Controllers/PluginController.php b/app/Controllers/PluginController.php index 98620e0c..e173ce33 100644 --- a/app/Controllers/PluginController.php +++ b/app/Controllers/PluginController.php @@ -53,6 +53,12 @@ public function index(Request $request, Response $response): Response $settings['api_key'] = $settings['api_key_exists'] ? '••••••••' : ''; } + // Redact Discogs token — never expose to template + if ($plugin['name'] === 'discogs' && array_key_exists('api_token', $settings)) { + $settings['api_token_exists'] = $settings['api_token'] !== ''; + $settings['api_token'] = ''; + } + $pluginSettings[$plugin['id']] = $settings; } @@ -214,6 +220,49 @@ public function uninstall(Request $request, Response $response, array $args): Re return $response->withHeader('Content-Type', 'application/json'); } + public function settingsPage(Request $request, Response $response, array $args): Response + { + if (!isset($_SESSION['user']) || $_SESSION['user']['tipo_utente'] !== 'admin') { + return $response->withStatus(403); + } + + $pluginId = (int) $args['id']; + $plugin = $this->pluginManager->getPlugin($pluginId); + if (!$plugin) { + return $response->withStatus(404); + } + + $pluginInstance = $this->pluginManager->getPluginInstance($pluginId); + if ($pluginInstance === null || !is_callable([$pluginInstance, 'hasSettingsPage']) || !$pluginInstance->hasSettingsPage()) { + return $response->withStatus(404); + } + + if (!is_callable([$pluginInstance, 'getSettingsViewPath'])) { + return $response->withStatus(404); + } + + $settingsViewPath = $pluginInstance->getSettingsViewPath(); + if (!is_string($settingsViewPath) || !is_file($settingsViewPath)) { + return $response->withStatus(404); + } + + if (!isset($GLOBALS['plugins'])) { + $GLOBALS['plugins'] = []; + } + $GLOBALS['plugins'][$plugin['name']] = $pluginInstance; + + ob_start(); + require $settingsViewPath; + $content = ob_get_clean(); + + ob_start(); + require __DIR__ . '/../Views/layout.php'; + $html = ob_get_clean(); + + $response->getBody()->write($html); + return $response; + } + /** * Update plugin settings (limited to supported plugins) */ @@ -391,6 +440,23 @@ public function updateSettings(Request $request, Response $response, array $args 'message' => __('Impostazioni GoodLib salvate correttamente.'), 'data' => $normalizedDomains, ])); + } elseif ($plugin['name'] === 'discogs') { + // Discogs: personal access token + $apiToken = trim((string) ($settings['api_token'] ?? '')); + + $saved = $this->pluginManager->setSetting($pluginId, 'api_token', $apiToken, true); + if (!$saved) { + $response->getBody()->write(json_encode(['success' => false, 'message' => __('Errore durante il salvataggio.')])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(500); + } + + $response->getBody()->write(json_encode([ + 'success' => true, + 'message' => __('Impostazioni Discogs salvate correttamente.'), + 'data' => [ + 'has_token' => $apiToken !== '' + ] + ])); } else { // Plugin not supported error_log('[PluginController] Plugin does not support settings: ' . $plugin['name']); diff --git a/app/Controllers/PublicApiController.php b/app/Controllers/PublicApiController.php index 40fa6114..f81308c1 100644 --- a/app/Controllers/PublicApiController.php +++ b/app/Controllers/PublicApiController.php @@ -114,6 +114,7 @@ private function findBooks(mysqli $db, ?string $ean, ?string $isbn13, ?string $i l.descrizione, l.parole_chiave, l.formato, + l.tipo_media, l.peso, l.dimensioni, l.prezzo, @@ -191,6 +192,7 @@ private function findBooks(mysqli $db, ?string $ean, ?string $isbn13, ?string $i 'descrizione' => $row['descrizione'], 'parole_chiave' => $row['parole_chiave'], 'formato' => $row['formato'], + 'tipo_media' => $row['tipo_media'] ?? 'libro', 'peso' => $row['peso'] !== null ? (float)$row['peso'] : null, 'dimensioni' => $row['dimensioni'], 'prezzo' => $row['prezzo'] !== null ? (float)$row['prezzo'] : null, diff --git a/app/Controllers/ScrapeController.php b/app/Controllers/ScrapeController.php index fa3431af..32e93890 100644 --- a/app/Controllers/ScrapeController.php +++ b/app/Controllers/ScrapeController.php @@ -145,6 +145,8 @@ public function byIsbn(Request $request, Response $response): Response $payload = $this->enrichWithSbnData($payload, $cleanIsbn); } + $payload = $this->ensureTipoMedia($payload); + // Normalize ISBN fields (auto-calculate missing isbn10/isbn13) $payload = $this->normalizeIsbnFields($payload, $cleanIsbn); @@ -213,6 +215,8 @@ public function byIsbn(Request $request, Response $response): Response $fallbackData = $this->enrichWithSbnData($fallbackData, $cleanIsbn); } + $fallbackData = $this->ensureTipoMedia($fallbackData); + // Normalize ISBN fields (auto-calculate missing isbn10/isbn13) $fallbackData = $this->normalizeIsbnFields($fallbackData, $cleanIsbn); @@ -763,6 +767,38 @@ private function enrichWithSbnData(array $data, string $originalIsbn): array */ private function normalizeIsbnFields(array $data, string $originalIsbn): array { + $formatRaw = $data['format'] ?? $data['formato'] ?? null; + $tipoMediaRaw = $data['tipo_media'] ?? null; + + // If no scraper set either format or tipo_media, we cannot reliably + // tell whether the barcode is an ISBN or a music/DVD EAN. Skip the + // auto-population entirely to avoid stuffing a non-ISBN barcode into + // isbn13 (the old music-as-book bug). inferTipoMedia(null) defaults + // to 'libro' which would bypass the guard below. + $hasFormatSignal = $formatRaw !== null && $formatRaw !== ''; + $hasTipoMediaSignal = $tipoMediaRaw !== null && $tipoMediaRaw !== ''; + if (!$hasFormatSignal && !$hasTipoMediaSignal) { + // This is a real scraper bug surfacing: the plugin returned a + // partial payload without indicating media type. Warning (not + // debug) because it leaves the book half-populated and the admin + // needs to investigate which scraper misbehaved. + SecureLogger::warning('[ScrapeController] Scraper returned no media-type signal — skipping ISBN normalization', [ + 'isbn' => $originalIsbn, + 'source' => $data['source'] ?? $data['_source'] ?? 'unknown', + 'payload_keys' => array_keys($data), + ]); + return $data; + } + + $resolvedTipoMedia = \App\Support\MediaLabels::resolveTipoMedia($formatRaw, $tipoMediaRaw); + $data['tipo_media'] = $resolvedTipoMedia; + + // Skip ISBN auto-population for non-book media. + // The barcode is an EAN, not an ISBN — don't stuff it into isbn13/isbn10. + if ($resolvedTipoMedia !== 'libro') { + return $data; + } + // First, try to get variants from original search term $variants = IsbnFormatter::getAllVariants($originalIsbn); @@ -803,4 +839,17 @@ private function normalizeIsbnFields(array $data, string $originalIsbn): array return $data; } + + private function ensureTipoMedia(array $payload): array + { + $format = $payload['format'] ?? $payload['formato'] ?? null; + $tipoMedia = $payload['tipo_media'] ?? null; + + if (($format === null || $format === '') && ($tipoMedia === null || $tipoMedia === '')) { + return $payload; + } + + $payload['tipo_media'] = \App\Support\MediaLabels::resolveTipoMedia($format, $tipoMedia); + return $payload; + } } diff --git a/app/Models/BookRepository.php b/app/Models/BookRepository.php index 22262b9b..3811af0a 100644 --- a/app/Models/BookRepository.php +++ b/app/Models/BookRepository.php @@ -300,6 +300,13 @@ public function createBasic(array $data): int if ($this->hasColumn('formato')) { $addField('formato', 's', $data['formato'] ?? null); } + if ($this->hasColumn('tipo_media')) { + $val = \App\Support\MediaLabels::resolveTipoMedia( + $data['formato'] ?? null, + $data['tipo_media'] ?? null + ); + $addField('tipo_media', 's', $this->normalizeEnumValue($val, 'tipo_media', 'libro')); + } if ($this->hasColumn('peso')) { $addField('peso', 'd', $peso); } @@ -639,6 +646,9 @@ public function updateBasic(int $id, array $data): bool if ($this->hasColumn('formato')) { $addSet('formato', 's', $data['formato'] ?? null); } + if ($this->hasColumn('tipo_media') && array_key_exists('tipo_media', $data) && is_string($data['tipo_media'])) { + $addSet('tipo_media', 's', $this->normalizeEnumValue($data['tipo_media'], 'tipo_media', 'libro')); + } if ($this->hasColumn('peso')) { $addSet('peso', 'd', $peso); } @@ -989,7 +999,7 @@ public function delete(int $id): bool public function updateOptionals(int $bookId, array $data): void { $cols = []; - foreach (['numero_pagine', 'ean', 'data_pubblicazione', 'anno_pubblicazione', 'traduttore', 'illustratore', 'curatore', 'collana', 'edizione'] as $c) { + foreach (['numero_pagine', 'ean', 'data_pubblicazione', 'anno_pubblicazione', 'traduttore', 'illustratore', 'curatore', 'collana', 'edizione', 'tipo_media', 'parole_chiave'] as $c) { if ($this->hasColumn($c) && array_key_exists($c, $data) && $data[$c] !== '' && $data[$c] !== null) { if ($c === 'numero_pagine') { $validated = filter_var($data[$c], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); @@ -1001,6 +1011,9 @@ public function updateOptionals(int $bookId, array $data): void if ($validated !== false) { $cols[$c] = $validated; } + } elseif ($c === 'tipo_media') { + if (!is_string($data[$c])) { continue; } + $cols[$c] = $this->normalizeEnumValue((string) $data[$c], 'tipo_media', 'libro'); } elseif (in_array($c, ['traduttore', 'illustratore', 'curatore'], true)) { $cols[$c] = \App\Support\AuthorNormalizer::normalize((string) $data[$c]); } else { @@ -1044,6 +1057,22 @@ public function updateOptionals(int $bookId, array $data): void if ($this->hasColumn('illustratore') && !isset($cols['illustratore']) && !empty($data['scraped_illustrator'])) { $cols['illustratore'] = \App\Support\AuthorNormalizer::normalize((string) $data['scraped_illustrator']); } + if ($this->hasColumn('tipo_media') && !array_key_exists('tipo_media', $cols)) { + $formato = trim((string) ($data['formato'] ?? ($data['scraped_formato'] ?? ''))); + $scrapedTipoMedia = trim((string) ($data['scraped_tipo_media'] ?? '')); + $hasMediaSignal = $formato !== '' || $scrapedTipoMedia !== ''; + + if ($hasMediaSignal) { + $val = \App\Support\MediaLabels::resolveTipoMedia( + $formato !== '' ? $formato : null, + $scrapedTipoMedia !== '' ? $scrapedTipoMedia : null + ); + $normalized = $this->normalizeEnumValue((string) $val, 'tipo_media', 'libro'); + if ($normalized !== '') { + $cols['tipo_media'] = $normalized; + } + } + } if (!$cols) return; $set = []; diff --git a/app/Routes/web.php b/app/Routes/web.php index df218415..fe1284d0 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -1364,6 +1364,25 @@ return $controller->syncCovers($request, $response, $db); })->add(new \App\Middleware\RateLimitMiddleware(1, 120))->add(new AdminAuthMiddleware()); // 1 request per 2 minutes (long-running operation) + // Bulk enrichment routes + $app->get('/admin/libri/bulk-enrich', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + $controller = new \App\Controllers\BulkEnrichController(); + return $controller->index($request, $response, $db); + })->add(new AdminAuthMiddleware()); + + $app->post('/admin/libri/bulk-enrich/start', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + $controller = new \App\Controllers\BulkEnrichController(); + return $controller->start($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/libri/bulk-enrich/toggle', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + $controller = new \App\Controllers\BulkEnrichController(); + return $controller->toggle($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Fallback GET to avoid 405 if user navigates directly $app->get('/admin/libri/update/{id:\d+}', function ($request, $response, $args) { return $response->withHeader('Location', '/admin/libri/modifica/' . (int) $args['id'])->withStatus(302); @@ -2867,6 +2886,12 @@ return $controller->uninstall($request, $response, $args); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->get('/admin/plugins/{id}/settings', function ($request, $response, $args) use ($app) { + $pluginManager = $app->getContainer()->get('pluginManager'); + $controller = new \App\Controllers\PluginController($pluginManager); + return $controller->settingsPage($request, $response, $args); + })->add(new AdminAuthMiddleware()); + // Plugin settings update $app->post('/admin/plugins/{id}/settings', function ($request, $response, $args) use ($app) { $pluginManager = $app->getContainer()->get('pluginManager'); diff --git a/app/Services/BulkEnrichmentService.php b/app/Services/BulkEnrichmentService.php new file mode 100644 index 00000000..254c9a26 --- /dev/null +++ b/app/Services/BulkEnrichmentService.php @@ -0,0 +1,609 @@ +db = $db; + } + + /** + * Get statistics about books eligible for enrichment. + * + * @return array{total_with_isbn: int, missing_cover: int, missing_description: int, pending: int} + */ + public function getStats(): array + { + $totalWithIsbn = 0; + $missingCover = 0; + $missingDescription = 0; + $pending = 0; + + // Total books with at least one ISBN identifier + $result = $this->db->query(" + SELECT COUNT(*) AS cnt FROM libri + WHERE (isbn13 IS NOT NULL OR isbn10 IS NOT NULL OR ean IS NOT NULL) + AND deleted_at IS NULL + "); + if ($result) { + $totalWithIsbn = (int) ($result->fetch_assoc()['cnt'] ?? 0); + $result->free(); + } + + // Books missing cover + $result = $this->db->query(" + SELECT COUNT(*) AS cnt FROM libri + WHERE (isbn13 IS NOT NULL OR isbn10 IS NOT NULL OR ean IS NOT NULL) + AND deleted_at IS NULL + AND (copertina_url IS NULL OR copertina_url = '') + "); + if ($result) { + $missingCover = (int) ($result->fetch_assoc()['cnt'] ?? 0); + $result->free(); + } + + // Books missing description + $result = $this->db->query(" + SELECT COUNT(*) AS cnt FROM libri + WHERE (isbn13 IS NOT NULL OR isbn10 IS NOT NULL OR ean IS NOT NULL) + AND deleted_at IS NULL + AND (descrizione IS NULL OR descrizione = '') + "); + if ($result) { + $missingDescription = (int) ($result->fetch_assoc()['cnt'] ?? 0); + $result->free(); + } + + // Books pending enrichment (missing cover OR description) + $result = $this->db->query(" + SELECT COUNT(*) AS cnt FROM libri + WHERE (isbn13 IS NOT NULL OR isbn10 IS NOT NULL OR ean IS NOT NULL) + AND deleted_at IS NULL + AND (copertina_url IS NULL OR copertina_url = '' + OR descrizione IS NULL OR descrizione = '') + "); + if ($result) { + $pending = (int) ($result->fetch_assoc()['cnt'] ?? 0); + $result->free(); + } + + return [ + 'total_with_isbn' => $totalWithIsbn, + 'missing_cover' => $missingCover, + 'missing_description' => $missingDescription, + 'pending' => $pending, + ]; + } + + /** + * Find books that need enrichment. + * + * Priority for ISBN selection: isbn13 > isbn10 > ean. + * + * @param int|null $limit Maximum number of books to return (default 20) + * @return array + */ + public function findPending(?int $limit = 20): array + { + $limitVal = $limit ?? 20; + + $stmt = $this->db->prepare(" + SELECT id, isbn13, isbn10, ean + FROM libri + WHERE (isbn13 IS NOT NULL OR isbn10 IS NOT NULL OR ean IS NOT NULL) + AND deleted_at IS NULL + AND (copertina_url IS NULL OR copertina_url = '' + OR descrizione IS NULL OR descrizione = '') + ORDER BY id ASC + LIMIT ? + "); + if ($stmt === false) { + SecureLogger::error('[BulkEnrichment] Failed to prepare findPending query', [ + 'error' => $this->db->error, + ]); + return []; + } + + $stmt->bind_param('i', $limitVal); + $stmt->execute(); + $result = $stmt->get_result(); + + $books = []; + while ($row = $result->fetch_assoc()) { + // Priority: isbn13 > isbn10 > ean + $isbn = $row['isbn13'] ?? $row['isbn10'] ?? $row['ean'] ?? ''; + if ($isbn !== '') { + $books[] = [ + 'id' => (int) $row['id'], + 'isbn' => $isbn, + ]; + } + } + + $stmt->close(); + + return $books; + } + + /** + * Enrich a single book by scraping missing data. + * + * Only fills in fields that are currently NULL or empty — never overwrites existing data. + * + * @param int $bookId The book ID to enrich + * @return array{status: string, fields_updated: list, book_id: int} + */ + public function enrichBook(int $bookId): array + { + $result = [ + 'status' => 'error', + 'fields_updated' => [], + 'book_id' => $bookId, + ]; + + try { + // Load book from DB + $stmt = $this->db->prepare(" + SELECT isbn13, isbn10, ean, copertina_url, descrizione, + editore_id, anno_pubblicazione, lingua, numero_pagine, + parole_chiave, collana + FROM libri + WHERE id = ? AND deleted_at IS NULL + "); + if ($stmt === false) { + SecureLogger::error('[BulkEnrichment] Failed to prepare book query', [ + 'book_id' => $bookId, + 'error' => $this->db->error, + ]); + return $result; + } + + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $bookResult = $stmt->get_result(); + $book = $bookResult->fetch_assoc(); + $stmt->close(); + + if ($book === null) { + $result['status'] = 'not_found'; + return $result; + } + + // Determine ISBN to scrape (priority: isbn13 > isbn10 > ean) + $isbn = $book['isbn13'] ?? $book['isbn10'] ?? $book['ean'] ?? ''; + if ($isbn === '') { + $result['status'] = 'skipped'; + return $result; + } + + // Check if book actually needs enrichment + $needsCover = empty($book['copertina_url']); + $needsDescription = empty($book['descrizione']); + if (!$needsCover && !$needsDescription) { + $result['status'] = 'skipped'; + return $result; + } + + // Scrape using the existing ScrapingService + $scraped = ScrapingService::scrapeBookData($isbn, 3, 'BulkEnrichment'); + + if (empty($scraped) || empty($scraped['title'])) { + $result['status'] = 'not_found'; + return $result; + } + + // Build UPDATE sets for missing fields only + $sets = []; + $types = ''; + $values = []; + $fieldsUpdated = []; + + // Cover image + if ($needsCover && !empty($scraped['image'])) { + $sets[] = 'copertina_url = ?'; + $types .= 's'; + $values[] = $scraped['image']; + $fieldsUpdated[] = 'copertina_url'; + } + + // Description + if ($needsDescription && !empty($scraped['description'])) { + $sets[] = 'descrizione = ?'; + $types .= 's'; + $values[] = $scraped['description']; + $fieldsUpdated[] = 'descrizione'; + + // Also generate plain-text description + $plain = strip_tags($scraped['description']); + $sets[] = 'descrizione_plain = ?'; + $types .= 's'; + $values[] = $plain; + } + + // Publisher (editore_id) — only if currently NULL/0 + if (empty($book['editore_id']) && !empty($scraped['publisher'])) { + $publisherId = $this->findOrCreatePublisher($scraped['publisher']); + if ($publisherId !== null) { + $sets[] = 'editore_id = ?'; + $types .= 'i'; + $values[] = $publisherId; + $fieldsUpdated[] = 'editore_id'; + } + } + + // Year + if (empty($book['anno_pubblicazione']) && !empty($scraped['year'])) { + $year = (int) $scraped['year']; + if ($year > 0 && $year <= (int) date('Y') + 2) { + $sets[] = 'anno_pubblicazione = ?'; + $types .= 'i'; + $values[] = $year; + $fieldsUpdated[] = 'anno_pubblicazione'; + } + } + + // Language + if (empty($book['lingua']) && !empty($scraped['language'])) { + $sets[] = 'lingua = ?'; + $types .= 's'; + $values[] = $scraped['language']; + $fieldsUpdated[] = 'lingua'; + } + + // Pages + if (empty($book['numero_pagine']) && !empty($scraped['pages'])) { + $pages = (int) preg_replace('/[^0-9]/', '', (string) $scraped['pages']); + if ($pages > 0) { + $sets[] = 'numero_pagine = ?'; + $types .= 'i'; + $values[] = $pages; + $fieldsUpdated[] = 'numero_pagine'; + } + } + + // Keywords + if (empty($book['parole_chiave']) && !empty($scraped['keywords'])) { + $sets[] = 'parole_chiave = ?'; + $types .= 's'; + $values[] = $scraped['keywords']; + $fieldsUpdated[] = 'parole_chiave'; + } + + // Series / collection + if (empty($book['collana']) && !empty($scraped['series'] ?? $scraped['collana'] ?? '')) { + $series = $scraped['series'] ?? $scraped['collana'] ?? ''; + $sets[] = 'collana = ?'; + $types .= 's'; + $values[] = $series; + $fieldsUpdated[] = 'collana'; + } + + if (empty($sets)) { + $result['status'] = 'skipped'; + return $result; + } + + // Execute UPDATE + $sql = 'UPDATE libri SET ' . implode(', ', $sets) . ' WHERE id = ? AND deleted_at IS NULL'; + $types .= 'i'; + $values[] = $bookId; + + $updateStmt = $this->db->prepare($sql); + if ($updateStmt === false) { + SecureLogger::error('[BulkEnrichment] Failed to prepare update query', [ + 'book_id' => $bookId, + 'error' => $this->db->error, + ]); + return $result; + } + + $updateStmt->bind_param($types, ...$values); + $updateStmt->execute(); + $updateStmt->close(); + + // Handle authors (via libri_autori junction table) — only if book has no authors + if (!empty($scraped['authors']) && is_array($scraped['authors'])) { + $this->enrichAuthorsIfEmpty($bookId, $scraped['authors']); + } + + $result['status'] = 'enriched'; + $result['fields_updated'] = $fieldsUpdated; + + SecureLogger::info('[BulkEnrichment] Book enriched', [ + 'book_id' => $bookId, + 'isbn' => $isbn, + 'fields' => $fieldsUpdated, + ]); + + } catch (\Throwable $e) { + SecureLogger::error('[BulkEnrichment] Exception enriching book', [ + 'book_id' => $bookId, + 'error' => $e->getMessage(), + ]); + $result['status'] = 'error'; + } + + return $result; + } + + /** + * Enrich a batch of books. + * + * @param int $limit Maximum number of books to process + * @param callable|null $onProgress Callback: function(int $current, int $total, array $result): void + * @return array{processed: int, enriched: int, not_found: int, errors: int, details: list} + */ + public function enrichBatch(int $limit = 20, ?callable $onProgress = null): array + { + $summary = [ + 'processed' => 0, + 'enriched' => 0, + 'not_found' => 0, + 'errors' => 0, + 'details' => [], + ]; + + $pending = $this->findPending($limit); + $total = count($pending); + + if ($total === 0) { + return $summary; + } + + foreach ($pending as $index => $book) { + $bookResult = $this->enrichBook($book['id']); + $summary['processed']++; + + switch ($bookResult['status']) { + case 'enriched': + $summary['enriched']++; + break; + case 'not_found': + $summary['not_found']++; + break; + case 'error': + $summary['errors']++; + break; + // 'skipped' is not counted in error/not_found + } + + $summary['details'][] = $bookResult; + + if ($onProgress !== null) { + $onProgress($index + 1, $total, $bookResult); + } + + // Rate-limit: 1 second between requests to avoid hammering scraping APIs + if ($index < $total - 1) { + sleep(1); + } + } + + SecureLogger::info('[BulkEnrichment] Batch completed', [ + 'processed' => $summary['processed'], + 'enriched' => $summary['enriched'], + 'not_found' => $summary['not_found'], + 'errors' => $summary['errors'], + ]); + + return $summary; + } + + /** + * Check if bulk enrichment is enabled in system settings. + * + * @return bool + */ + public function isEnabled(): bool + { + try { + // Check if system_settings table exists + $tableCheck = $this->db->query("SHOW TABLES LIKE 'system_settings'"); + if ($tableCheck === false || $tableCheck->num_rows === 0) { + return false; + } + if ($tableCheck instanceof \mysqli_result) { + $tableCheck->free(); + } + + $stmt = $this->db->prepare( + "SELECT setting_value FROM system_settings WHERE category = 'bulk_enrich' AND setting_key = 'enabled' LIMIT 1" + ); + if ($stmt === false) { + return false; + } + + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result->fetch_assoc(); + $stmt->close(); + + return $row !== null && $row['setting_value'] === '1'; + } catch (\Throwable $e) { + SecureLogger::error('[BulkEnrichment] Failed to check enabled status', [ + 'error' => $e->getMessage(), + ]); + return false; + } + } + + /** + * Enable or disable bulk enrichment. + * + * @param bool $enabled + */ + public function setEnabled(bool $enabled): void + { + $value = $enabled ? '1' : '0'; + $category = 'bulk_enrich'; + $key = 'enabled'; + + $stmt = $this->db->prepare(" + INSERT INTO system_settings (category, setting_key, setting_value) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) + "); + if ($stmt === false) { + SecureLogger::error('[BulkEnrichment] Failed to prepare setEnabled query', [ + 'error' => $this->db->error, + ]); + return; + } + + $stmt->bind_param('sss', $category, $key, $value); + $stmt->execute(); + $stmt->close(); + } + + /** + * Find or create a publisher by name. + * + * @param string $name Publisher name + * @return int|null Publisher ID or null if creation failed + */ + private function findOrCreatePublisher(string $name): ?int + { + $name = trim($name); + if ($name === '') { + return null; + } + + // Try to find existing publisher + $stmt = $this->db->prepare("SELECT id FROM editori WHERE nome = ? LIMIT 1"); + if ($stmt === false) { + return null; + } + + $stmt->bind_param('s', $name); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result->fetch_assoc(); + $stmt->close(); + + if ($row !== null) { + return (int) $row['id']; + } + + // Create new publisher + $stmt = $this->db->prepare("INSERT INTO editori (nome) VALUES (?)"); + if ($stmt === false) { + return null; + } + + $stmt->bind_param('s', $name); + $stmt->execute(); + $id = $stmt->insert_id; + $stmt->close(); + + return $id > 0 ? (int) $id : null; + } + + /** + * Add authors to a book only if it currently has none. + * + * @param int $bookId Book ID + * @param list $authors Author names from scraping + */ + private function enrichAuthorsIfEmpty(int $bookId, array $authors): void + { + // Check if book already has authors + $stmt = $this->db->prepare("SELECT COUNT(*) AS cnt FROM libri_autori WHERE libro_id = ?"); + if ($stmt === false) { + return; + } + + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $result = $stmt->get_result(); + $count = (int) ($result->fetch_assoc()['cnt'] ?? 0); + $stmt->close(); + + if ($count > 0) { + return; // Already has authors, do not overwrite + } + + $order = 1; + foreach ($authors as $authorName) { + $authorName = trim($authorName); + if ($authorName === '') { + continue; + } + + $authorId = $this->findOrCreateAuthor($authorName); + if ($authorId === null) { + continue; + } + + $stmt = $this->db->prepare(" + INSERT IGNORE INTO libri_autori (libro_id, autore_id, ruolo, ordine_credito) + VALUES (?, ?, 'principale', ?) + "); + if ($stmt === false) { + continue; + } + + $stmt->bind_param('iii', $bookId, $authorId, $order); + $stmt->execute(); + $stmt->close(); + $order++; + } + } + + /** + * Find or create an author by name. + * + * @param string $name Author name + * @return int|null Author ID or null if creation failed + */ + private function findOrCreateAuthor(string $name): ?int + { + $name = trim($name); + if ($name === '') { + return null; + } + + // Try to find existing author + $stmt = $this->db->prepare("SELECT id FROM autori WHERE nome = ? LIMIT 1"); + if ($stmt === false) { + return null; + } + + $stmt->bind_param('s', $name); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result->fetch_assoc(); + $stmt->close(); + + if ($row !== null) { + return (int) $row['id']; + } + + // Create new author + $stmt = $this->db->prepare("INSERT INTO autori (nome) VALUES (?)"); + if ($stmt === false) { + return null; + } + + $stmt->bind_param('s', $name); + $stmt->execute(); + $id = $stmt->insert_id; + $stmt->close(); + + return $id > 0 ? (int) $id : null; + } +} diff --git a/app/Support/BundledPlugins.php b/app/Support/BundledPlugins.php new file mode 100644 index 00000000..039bfe12 --- /dev/null +++ b/app/Support/BundledPlugins.php @@ -0,0 +1,21 @@ + + */ + private static function normalizedCandidates(?string $value): array + { + if ($value === null) { + return []; + } + + $trimmed = trim($value); + if ($trimmed === '') { + return []; + } + + $lower = strtolower($trimmed); + $underscore = preg_replace('/[\s-]+/u', '_', $lower) ?? $lower; + $collapsed = preg_replace('/[\s\-_]+/u', '', $lower) ?? $lower; + + return array_values(array_unique([$lower, $underscore, $collapsed])); + } + + /** + * Normalize explicit tipo_media or common aliases to the canonical enum. + */ + public static function normalizeTipoMedia(?string $tipoMedia): ?string + { + foreach (self::normalizedCandidates($tipoMedia) as $candidate) { + if (isset(self::allTypes()[$candidate])) { + return $candidate; + } + + if (in_array($candidate, ['book', 'books', 'paperback', 'hardcover', 'hardback', 'cartaceo', 'print', 'printed'], true)) { + return 'libro'; + } + + if (in_array($candidate, ['disc', 'record', 'album', 'cd', 'cdaudio', 'compactdisc', 'vinyl', 'vinile', 'lp', 'cassette', 'cassetta', 'audiocassetta'], true)) { + return 'disco'; + } + + if (in_array($candidate, ['audiobook', 'audiobooks', 'audiolibro'], true)) { + return 'audiolibro'; + } + + if (in_array($candidate, ['dvd', 'bluray', 'blu_ray', 'movie', 'film'], true)) { + return 'dvd'; + } + + if (in_array($candidate, ['altro', 'other'], true)) { + return 'altro'; + } + } + + return null; + } + + /** + * Resolve the effective tipo_media, preferring an explicit valid value. + */ + public static function resolveTipoMedia(?string $formato, ?string $tipoMedia = null): string + { + $normalized = self::normalizeTipoMedia($tipoMedia); + if ($normalized !== null) { + return $normalized; + } + + return self::inferTipoMedia($formato); + } + + /** + * Check if a format string indicates music media. + */ + public static function isMusic(?string $formato, ?string $tipoMedia = null): bool + { + $normalizedTipoMedia = self::normalizeTipoMedia($tipoMedia); + if ($normalizedTipoMedia !== null) { + return $normalizedTipoMedia === 'disco'; + } + + return self::inferTipoMedia($formato) === 'disco'; + } + + /** + * Map of internal format keys to translatable display names. + */ + private static array $formatDisplayNames = [ + 'cartaceo' => 'Cartaceo', + 'ebook' => 'eBook', + 'audiolibro' => 'Audiolibro', + 'audiobook' => 'Audiolibro', + 'cd_audio' => 'CD Audio', + 'cd' => 'CD', + 'vinile' => 'Vinile', + 'vinyl' => 'Vinile', + 'lp' => 'LP', + 'cassetta' => 'Cassetta', + 'cassette' => 'Cassetta', + 'audiocassetta' => 'Audiocassetta', + 'dvd' => 'DVD', + 'blu-ray' => 'Blu-ray', + 'blu_ray' => 'Blu-ray', + 'digitale' => 'Digitale', + 'altro' => 'Altro', + ]; + + /** + * Get human-readable display name for a format value. + * Returns the translated display name, or the raw value titlecased if unknown. + */ + public static function formatDisplayName(?string $formato): string + { + if ($formato === null || $formato === '') { + return ''; + } + + foreach (self::normalizedCandidates($formato) as $candidate) { + if (isset(self::$formatDisplayNames[$candidate])) { + return __(self::$formatDisplayNames[$candidate]); + } + } + + // Return the original value with first letter uppercase + return ucfirst($formato); + } + + /** + * Format a tracklist string into an HTML ordered list. + * Detects "1. Track (3:45) 2. Track (2:30)" patterns and converts to
      . + */ + public static function formatTracklist(string $text): string + { + $text = trim($text); + if ($text === '') { + return ''; + } + + // If already formatted as HTML ordered list, sanitize and return + if (str_contains($text, '')) { + return strip_tags($text, '

      1. '); + } + + // Remove "Tracklist:" prefix if present + $text = preg_replace('/^Tracklist\s*:\s*/i', '', $text) ?? $text; + + // Try to split on numbered tracks: "1. Title (3:45)" pattern + $tracks = preg_split('/(?<=\))\s+(?=\d+\.\s)/', $text); + if ($tracks === false || count($tracks) < 2) { + // Fallback: split on "N. " pattern + $tracks = preg_split('/\s+(?=\d+\.\s)/', $text); + } + + if ($tracks === false || count($tracks) < 2) { + return nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'), false); + } + + $items = []; + foreach ($tracks as $track) { + $track = trim($track); + // Remove leading "N. " numbering + $track = preg_replace('/^\d+\.\s*/', '', $track) ?? $track; + if ($track !== '') { + $items[] = $track; + } + } + + if (empty($items)) { + return nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'), false); + } + + $html = '
          '; + foreach ($items as $item) { + $html .= '
        1. ' . htmlspecialchars($item, ENT_QUOTES, 'UTF-8') . '
        2. '; + } + $html .= '
        '; + return $html; + } + + /** + * All valid tipo_media values with their metadata. + * @return array + */ + public static function allTypes(): array + { + return [ + 'libro' => ['icon' => 'fa-book', 'schema' => 'Book', 'label' => 'Libro'], + 'disco' => ['icon' => 'fa-compact-disc', 'schema' => 'MusicAlbum', 'label' => 'Disco'], + 'audiolibro' => ['icon' => 'fa-headphones', 'schema' => 'Audiobook', 'label' => 'Audiolibro'], + 'dvd' => ['icon' => 'fa-film', 'schema' => 'Movie', 'label' => 'DVD'], + 'altro' => ['icon' => 'fa-box', 'schema' => 'CreativeWork', 'label' => 'Altro'], + ]; + } + + public static function icon(?string $tipoMedia): string + { + $types = self::allTypes(); + $resolved = self::normalizeTipoMedia($tipoMedia) ?? 'libro'; + return $types[$resolved]['icon'] ?? 'fa-book'; + } + + public static function schemaOrgType(?string $tipoMedia): string + { + $types = self::allTypes(); + $resolved = self::normalizeTipoMedia($tipoMedia) ?? 'libro'; + return $types[$resolved]['schema'] ?? 'Book'; + } + + public static function tipoMediaDisplayName(?string $tipoMedia): string + { + $types = self::allTypes(); + $resolved = self::normalizeTipoMedia($tipoMedia) ?? 'libro'; + $label = $types[$resolved]['label'] ?? 'Libro'; + return __($label); + } + + /** + * Infer tipo_media from formato field (for backward compat / migration). + */ + public static function inferTipoMedia(?string $formato): string + { + if ($formato === null || $formato === '') { + return 'libro'; + } + + $normalized = self::normalizeTipoMedia($formato); + if ($normalized !== null) { + return $normalized; + } + + foreach (self::normalizedCandidates($formato) as $candidate) { + // Check audiobook BEFORE music tokens to prevent "Audiobook CD" matching as disco + if (str_contains($candidate, 'audiolibro') || str_contains($candidate, 'audiobook')) { + return 'audiolibro'; + } + + // Long tokens: safe for substring match (unique enough) + foreach (['cdaudio', 'compactdisc', 'vinile', 'vinyl', 'cassetta', 'cassette', 'audiocassetta'] as $musicToken) { + if (str_contains($candidate, $musicToken)) { + return 'disco'; + } + } + // Short tokens: exact match only to avoid false positives + // ('cd' would match 'cdrom', 'lp' would match 'help') + if ($candidate === 'cd' || $candidate === 'lp') { + return 'disco'; + } + + if (str_contains($candidate, 'dvd') || str_contains($candidate, 'bluray') || str_contains($candidate, 'blu_ray')) { + return 'dvd'; + } + + if (str_contains($candidate, 'other') || str_contains($candidate, 'altro')) { + return 'altro'; + } + + if (str_contains($candidate, 'book') || str_contains($candidate, 'paperback') || str_contains($candidate, 'hardcover') || str_contains($candidate, 'hardback')) { + return 'libro'; + } + } + + foreach (self::normalizedCandidates($formato) as $candidate) { + if (preg_match('/\b(?:music|musik)\b/i', $candidate) === 1) { + return 'disco'; + } + } + + return 'libro'; + } + + /** + * Get the appropriate label for a field based on format. + * Returns the music label if format is music, otherwise the default. + */ + public static function label(string $field, ?string $formato = null, ?string $tipoMedia = null): string + { + $isMusic = self::isMusic($formato, $tipoMedia); + + return match ($field) { + 'autore', 'author' => $isMusic ? __('Artista') : __('Autore'), + 'autori', 'authors' => $isMusic ? __('Artisti') : __('Autori'), + 'editore', 'publisher' => $isMusic ? __('Etichetta') : __('Editore'), + 'anno_pubblicazione', 'year' => $isMusic ? __('Anno di Uscita') : __('Anno di Pubblicazione'), + 'numero_pagine', 'pages' => $isMusic ? __('Tracce') : __('Numero di Pagine'), + 'isbn13' => $isMusic ? __('Barcode') : 'ISBN-13', + 'ean' => $isMusic ? __('Barcode') : 'EAN', + 'descrizione', 'description' => $isMusic ? __('Tracklist') : __('Descrizione'), + 'collana', 'series' => $isMusic ? __('Discografia') : __('Collana'), + 'formato', 'format' => __('Formato'), + default => __($field), + }; + } +} diff --git a/app/Support/PluginManager.php b/app/Support/PluginManager.php index 22c2f659..f4c03514 100644 --- a/app/Support/PluginManager.php +++ b/app/Support/PluginManager.php @@ -38,19 +38,6 @@ public function __construct(mysqli $db, HookManager $hookManager) } } - /** - * Bundled plugins that ship with Pinakes - * These are auto-registered and activated if their folders exist - */ - private const BUNDLED_PLUGINS = [ - 'open-library', - 'z39-server', - 'api-book-scraper', - 'digital-library', - 'dewey-editor', - 'goodlib', - ]; - /** * Auto-register bundled plugins that exist on disk but not in database * This ensures bundled plugins survive updates even if DB entries were lost @@ -61,7 +48,7 @@ public function autoRegisterBundledPlugins(): int { $registered = 0; - foreach (self::BUNDLED_PLUGINS as $pluginName) { + foreach (BundledPlugins::LIST as $pluginName) { $pluginPath = $this->pluginsDir . '/' . $pluginName; $jsonPath = $pluginPath . '/plugin.json'; @@ -75,25 +62,25 @@ public function autoRegisterBundledPlugins(): int $pluginMeta = json_decode($json, true); if (!$pluginMeta || empty($pluginMeta['name'])) { - error_log("[PluginManager] Invalid plugin.json for bundled plugin: $pluginName"); + SecureLogger::warning("[PluginManager] Invalid plugin.json for bundled plugin: $pluginName"); continue; } // Check if already registered $stmt = $this->db->prepare("SELECT id, version, is_active FROM plugins WHERE name = ?"); if ($stmt === false) { - error_log("[PluginManager] Failed to prepare bundled plugin lookup for $pluginName: " . $this->db->error); + SecureLogger::error("[PluginManager] Failed to prepare bundled plugin lookup for $pluginName", ['db_error' => $this->db->error]); continue; } $stmt->bind_param('s', $pluginName); if (!$stmt->execute()) { - error_log("[PluginManager] Failed bundled plugin lookup execute for $pluginName: " . $stmt->error); + SecureLogger::error("[PluginManager] Failed bundled plugin lookup execute for $pluginName", ['stmt_error' => $stmt->error]); $stmt->close(); continue; } $result = $stmt->get_result(); if ($result === false) { - error_log("[PluginManager] Failed bundled plugin lookup result for $pluginName: " . $stmt->error); + SecureLogger::error("[PluginManager] Failed bundled plugin lookup result for $pluginName", ['stmt_error' => $stmt->error]); $stmt->close(); continue; } @@ -109,7 +96,7 @@ public function autoRegisterBundledPlugins(): int "UPDATE plugins SET version = ?, display_name = ?, description = ?, metadata = ? WHERE id = ?" ); if ($updStmt === false) { - error_log("[PluginManager] Failed to prepare bundled plugin update for $pluginName: " . $this->db->error); + SecureLogger::error("[PluginManager] Failed to prepare bundled plugin update for $pluginName", ['db_error' => $this->db->error]); continue; } $updDisplayName = $pluginMeta['display_name'] ?? $pluginName; @@ -120,29 +107,33 @@ public function autoRegisterBundledPlugins(): int $updated = $updStmt->execute(); $updStmt->close(); if (!$updated) { - error_log("[PluginManager] Failed to update bundled plugin $pluginName: " . $this->db->error); + SecureLogger::error("[PluginManager] Failed to update bundled plugin $pluginName", ['db_error' => $this->db->error]); continue; } - error_log("[PluginManager] Updated bundled plugin: $pluginName $dbVersion → $diskVersion"); + SecureLogger::info("[PluginManager] Updated bundled plugin: $pluginName $dbVersion → $diskVersion"); // Re-register hooks only if plugin is active if ((int) ($row['is_active'] ?? 0) === 1) { try { $this->runPluginMethod($pluginName, 'onActivate'); } catch (\Throwable $e) { - error_log("[PluginManager] Note: onActivate failed during upgrade for $pluginName: " . $e->getMessage()); + SecureLogger::warning("[PluginManager] Note: onActivate failed during upgrade for $pluginName: " . $e->getMessage()); } } } continue; } + // Optional plugins (e.g. network-backed scrapers) start inactive + $isOptional = !empty($pluginMeta['metadata']['optional']); + $isActiveValue = $isOptional ? 0 : 1; + // Insert into database $stmt = $this->db->prepare(" INSERT INTO plugins ( name, display_name, description, version, author, author_url, plugin_url, is_active, path, main_file, requires_php, requires_app, metadata, installed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, NOW()) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) "); $metadata = json_encode($pluginMeta['metadata'] ?? []); @@ -158,8 +149,14 @@ public function autoRegisterBundledPlugins(): int $requiresPhp = $pluginMeta['requires_php'] ?? ''; $requiresApp = $pluginMeta['requires_app'] ?? ''; + // Types must line up with the INSERT column order: + // s×7 (name, display_name, description, version, author, author_url, plugin_url), + // i (is_active), s (path), s×4 (main_file, requires_php, requires_app, metadata). + // Prior typo 'ssssssssissss' put `i` at position 9 (path) and `s` at position 8 + // (is_active), causing path='discogs'/'goodlib' to be cast to int 0 — the orphan + // plugin cleanup then deleted the rows on the very next request. $stmt->bind_param( - 'ssssssssssss', + 'sssssssisssss', $name, $displayName, $description, @@ -167,6 +164,7 @@ public function autoRegisterBundledPlugins(): int $author, $authorUrl, $pluginUrl, + $isActiveValue, $path, $mainFile, $requiresPhp, @@ -177,7 +175,8 @@ public function autoRegisterBundledPlugins(): int if ($stmt->execute()) { $pluginId = $this->db->insert_id; $registered++; - error_log("[PluginManager] Auto-registered bundled plugin: $pluginName (ID: $pluginId, active)"); + $activeLabel = $isOptional ? 'inactive (optional)' : 'active'; + SecureLogger::info("[PluginManager] Auto-registered bundled plugin: $pluginName (ID: $pluginId, $activeLabel)"); // Run onInstall if exists try { @@ -189,24 +188,29 @@ public function autoRegisterBundledPlugins(): int try { $this->runPluginMethod($pluginName, 'onInstall'); } catch (\Throwable $e) { - error_log("[PluginManager] Note: onInstall failed for $pluginName: " . $e->getMessage()); + SecureLogger::warning("[PluginManager] Note: onInstall failed for $pluginName: " . $e->getMessage()); } - // Register hooks for active plugin - try { - $this->runPluginMethod($pluginName, 'onActivate'); - } catch (\Throwable $e) { - error_log("[PluginManager] Note: onActivate failed for $pluginName: " . $e->getMessage()); + // Register hooks only for active (non-optional) plugins + if (!$isOptional) { + try { + $this->runPluginMethod($pluginName, 'onActivate'); + } catch (\Throwable $e) { + SecureLogger::warning("[PluginManager] Note: onActivate failed for $pluginName: " . $e->getMessage()); + } } } else { - error_log("[PluginManager] Failed to auto-register $pluginName: " . $this->db->error); + // This is the failure mode that masked the bind_param type-swap + // bug (commit fb1e881). MUST be error severity so it surfaces in + // monitoring instead of being lost in warning-level noise. + SecureLogger::error("[PluginManager] Failed to auto-register $pluginName", ['db_error' => $this->db->error]); } $stmt->close(); } if ($registered > 0) { - error_log("[PluginManager] Auto-registered $registered bundled plugin(s)"); + SecureLogger::info("[PluginManager] Auto-registered $registered bundled plugin(s)"); } return $registered; @@ -264,7 +268,7 @@ public function cleanupOrphanPlugins(): int // Check if plugin folder exists if (!is_dir($pluginPath)) { $orphanIds[] = (int)$row['id']; - error_log("[PluginManager] Orphan plugin detected: '{$row['name']}' - folder missing at {$pluginPath}"); + SecureLogger::warning("[PluginManager] Orphan plugin detected: '{$row['name']}' - folder missing at {$pluginPath}"); } } $result->free(); @@ -277,7 +281,7 @@ public function cleanupOrphanPlugins(): int // Use a loop to avoid mysqli bind_param by-reference issues with spread operator $stmt = $this->db->prepare("DELETE FROM plugins WHERE id = ?"); if ($stmt === false) { - error_log('[PluginManager] Failed to prepare orphan plugin cleanup statement: ' . $this->db->error); + SecureLogger::error('[PluginManager] Failed to prepare orphan plugin cleanup statement', ['db_error' => $this->db->error]); return 0; } @@ -294,7 +298,7 @@ public function cleanupOrphanPlugins(): int $stmt->close(); if ($deleted > 0) { - error_log("[PluginManager] Cleaned up {$deleted} orphan plugin(s) from database"); + SecureLogger::info("[PluginManager] Cleaned up {$deleted} orphan plugin(s) from database"); } return $deleted; @@ -366,6 +370,24 @@ public function getPluginByName(string $name): ?array return $plugin ?: null; } + public function getPluginInstance(int $pluginId): ?object + { + $plugin = $this->getPlugin($pluginId); + if ($plugin === null) { + return null; + } + + try { + return $this->instantiatePlugin($plugin); + } catch (\Throwable $e) { + SecureLogger::error("[PluginManager] Failed to instantiate plugin {$plugin['name']}", [ + 'plugin_id' => $pluginId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + /** * Install plugin from uploaded ZIP file * @@ -375,17 +397,17 @@ public function getPluginByName(string $name): ?array public function installFromZip(string $zipPath): array { try { - error_log("🔌 [PluginManager] Starting plugin installation from: $zipPath"); + SecureLogger::warning("🔌 [PluginManager] Starting plugin installation from: $zipPath"); // Validate ZIP file if (!file_exists($zipPath)) { - error_log("❌ [PluginManager] ZIP file not found: $zipPath"); + SecureLogger::warning("❌ [PluginManager] ZIP file not found: $zipPath"); return ['success' => false, 'message' => __('File ZIP non trovato.'), 'plugin_id' => null]; } $fileSize = filesize($zipPath); if ($fileSize !== false && $fileSize > self::MAX_UPLOAD_BYTES) { - error_log("❌ [PluginManager] ZIP too large: {$fileSize} bytes"); + SecureLogger::warning("❌ [PluginManager] ZIP too large: {$fileSize} bytes"); return ['success' => false, 'message' => __('File ZIP troppo grande. Dimensione massima: 100 MB.'), 'plugin_id' => null]; } @@ -606,13 +628,25 @@ public function installFromZip(string $zipPath): array try { $this->runPluginMethod($pluginMeta['name'], 'setPluginId', [$pluginId]); } catch (\Throwable $e) { - error_log("[PluginManager] Note: Plugin doesn't have setPluginId method (not required): " . $e->getMessage()); + SecureLogger::warning("[PluginManager] Note: Plugin doesn't have setPluginId method (not required): " . $e->getMessage()); } - // Run plugin installation hook if exists - $this->runPluginMethod($pluginMeta['name'], 'onInstall'); + // Run plugin installation hook if exists — rollback on failure + try { + $this->runPluginMethod($pluginMeta['name'], 'onInstall'); + } catch (\Throwable $e) { + SecureLogger::error("[PluginManager] onInstall failed, rolling back", ['error' => $e->getMessage()]); + // Remove the plugins row + $delStmt = $this->db->prepare("DELETE FROM plugins WHERE id = ?"); + $delStmt->bind_param('i', $pluginId); + $delStmt->execute(); + $delStmt->close(); + // Remove extracted files + $this->deleteDirectory($pluginPath); + throw $e; + } - error_log("✅ [PluginManager] Plugin installed successfully: {$pluginMeta['name']} (ID: $pluginId)"); + SecureLogger::info("[PluginManager] Plugin installed successfully: {$pluginMeta['name']} (ID: $pluginId)"); return [ 'success' => true, @@ -620,8 +654,8 @@ public function installFromZip(string $zipPath): array 'plugin_id' => $pluginId ]; } catch (\Throwable $e) { - error_log("❌ [PluginManager] Installation error: " . $e->getMessage()); - error_log("❌ [PluginManager] Stack trace: " . $e->getTraceAsString()); + SecureLogger::error("[PluginManager] Installation error", ['error' => $e->getMessage()]); + SecureLogger::error("[PluginManager] Stack trace: " . $e->getTraceAsString()); return [ 'success' => false, 'message' => 'Errore durante l\'installazione: ' . $e->getMessage(), @@ -741,7 +775,7 @@ public function uninstallPlugin(int $pluginId): array $this->runPluginMethod($plugin['name'], 'onUninstall'); } catch (\Throwable $e) { // Continue with uninstall even if hook fails - error_log("Plugin uninstall hook error: " . $e->getMessage()); + SecureLogger::warning("Plugin uninstall hook error: " . $e->getMessage()); } // Delete from database (cascade will delete hooks, settings, data, logs) @@ -958,10 +992,21 @@ public function setSetting(int $pluginId, string $key, $value, bool $autoload = "); $valueStr = is_array($value) || is_object($value) ? json_encode($value) : (string)$value; - $valueStr = $this->encryptPluginSettingValue($valueStr); - $stmt->bind_param('issi', $pluginId, $key, $valueStr, $autoloadInt); - $result = $stmt->execute(); - $stmt->close(); + + try { + $valueStr = $this->encryptPluginSettingValue($valueStr); + $stmt->bind_param('issi', $pluginId, $key, $valueStr, $autoloadInt); + $result = $stmt->execute(); + } catch (\Throwable $e) { + SecureLogger::error('[PluginManager] setSetting failed', [ + 'plugin_id' => $pluginId, + 'key' => $key, + 'error' => $e->getMessage(), + ]); + return false; + } finally { + $stmt->close(); + } return $result; } @@ -973,24 +1018,40 @@ private function encryptPluginSettingValue(string $value): string { $key = $this->getEncryptionKey(); - if ($key === null || $value === '') { + // Empty strings bypass encryption (idempotent no-op). + if ($value === '') { return $value; } + // If no encryption key is configured, refuse to persist the secret. + // Returning plaintext would silently store API tokens unencrypted. + if ($key === null) { + SecureLogger::error('[PluginManager] Encryption key unavailable — refusing to persist plaintext plugin setting. Configure PLUGIN_ENCRYPTION_KEY or APP_KEY in .env.'); + throw new \RuntimeException('Plugin encryption key not configured — cannot persist secret setting.'); + } + try { $iv = random_bytes(12); $tag = ''; $ciphertext = openssl_encrypt($value, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); if ($ciphertext === false) { - return $value; + SecureLogger::error('[PluginManager] openssl_encrypt failed — refusing plaintext fallback', [ + 'openssl_error' => openssl_error_string() ?: 'unknown', + ]); + throw new \RuntimeException('Plugin setting encryption failed.'); } $payload = base64_encode($iv . $tag . $ciphertext); return 'ENC:' . $payload; + } catch (\RuntimeException $e) { + // Re-raise our own guards (set above) without wrapping. + throw $e; } catch (\Throwable $e) { - error_log('[PluginManager] Errore durante la cifratura del setting: ' . $e->getMessage()); - return $value; + SecureLogger::error('[PluginManager] Errore durante la cifratura del setting — refusing plaintext fallback', [ + 'error' => $e->getMessage(), + ]); + throw new \RuntimeException('Plugin setting encryption failed: ' . $e->getMessage(), 0, $e); } } @@ -1005,13 +1066,13 @@ private function decryptPluginSettingValue(?string $value): ?string $key = $this->getEncryptionKey(); if ($key === null) { - error_log('[PluginManager] Chiave di cifratura mancante: impossibile decrittare il valore.'); + SecureLogger::warning('[PluginManager] Chiave di cifratura mancante: impossibile decrittare il valore.'); return null; } $payload = base64_decode(substr($value, 4), true); if ($payload === false || strlen($payload) <= 28) { - error_log('[PluginManager] Payload cifrato non valido.'); + SecureLogger::warning('[PluginManager] Payload cifrato non valido.'); return null; } @@ -1022,12 +1083,12 @@ private function decryptPluginSettingValue(?string $value): ?string try { $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); if ($plaintext === false) { - error_log('[PluginManager] Impossibile decrittare il valore del plugin setting.'); + SecureLogger::warning('[PluginManager] Impossibile decrittare il valore del plugin setting.'); return null; } return $plaintext; } catch (\Throwable $e) { - error_log('[PluginManager] Eccezione durante la decrittazione: ' . $e->getMessage()); + SecureLogger::warning('[PluginManager] Eccezione durante la decrittazione: ' . $e->getMessage()); return null; } } @@ -1168,7 +1229,7 @@ public function loadActivePlugins(): void try { $this->loadPlugin($plugin); } catch (\Throwable $e) { - error_log("[PluginManager] Failed to load plugin '{$plugin['name']}': " . $e->getMessage()); + SecureLogger::error("[PluginManager] Failed to load plugin '{$plugin['name']}'", ['error' => $e->getMessage()]); // Continue loading other plugins even if one fails } } @@ -1184,10 +1245,18 @@ public function loadActivePlugins(): void * @return void */ private function loadPlugin(array $plugin): void + { + $instance = $this->instantiatePlugin($plugin); + + // Load and register hooks for this plugin + $this->registerPluginHooks((int) $plugin['id'], $instance); + } + + private function instantiatePlugin(array $plugin): object { // Save plugin data to prefixed variables before require_once // This prevents plugin files from overwriting $plugin variable (which some do) - $_pluginId = (int)$plugin['id']; + $_pluginId = (int) $plugin['id']; $_pluginName = $plugin['name']; $_pluginPath = $this->pluginsDir . '/' . $plugin['path']; $_mainFile = $_pluginPath . '/' . $plugin['main_file']; @@ -1196,21 +1265,15 @@ private function loadPlugin(array $plugin): void throw new \Exception("Main file not found: {$_mainFile}"); } - // Load plugin main file - // NOTE: Plugin files may define variables that collide with local scope require_once $_mainFile; - // Get plugin class name $className = $this->getPluginClassName($_pluginName); - if (!class_exists($className)) { throw new \Exception("Plugin class not found: {$className}"); } - // Instantiate plugin $instance = new $className($this->db, $this->hookManager); - // Pass plugin ID to the instance when supported (needed for plugin settings) if (is_callable([$instance, 'setPluginId'])) { try { $instance->setPluginId($_pluginId); @@ -1221,8 +1284,7 @@ private function loadPlugin(array $plugin): void } } - // Load and register hooks for this plugin - $this->registerPluginHooks($_pluginId, $instance); + return $instance; } /** @@ -1255,7 +1317,7 @@ private function registerPluginHooks(int $pluginId, object $pluginInstance): voi if (method_exists($pluginInstance, $callbackMethod) || $this->hasMagicMethod($pluginInstance, $callbackMethod)) { $this->hookManager->addHook($hookName, [$pluginInstance, $callbackMethod], $priority); } else { - error_log("[PluginManager] Method not found: {$callbackMethod} for hook {$hookName}"); + SecureLogger::warning("[PluginManager] Method not found: {$callbackMethod} for hook {$hookName}"); } } diff --git a/app/Support/RouteTranslator.php b/app/Support/RouteTranslator.php index 4a9dc4d4..d95587b8 100644 --- a/app/Support/RouteTranslator.php +++ b/app/Support/RouteTranslator.php @@ -46,6 +46,7 @@ class RouteTranslator 'catalog_legacy' => '/catalog.php', 'book' => '/book', 'book_legacy' => '/book-detail.php', + 'plugins' => '/admin/plugins', 'author' => '/author', 'publisher' => '/publisher', 'genre' => '/genre', diff --git a/app/Support/Updater.php b/app/Support/Updater.php index 061262f5..cfe62733 100644 --- a/app/Support/Updater.php +++ b/app/Support/Updater.php @@ -31,20 +31,6 @@ class Updater private string $tempPath; private string $githubToken = ''; - /** - * Bundled plugins that are updated during app updates. - * scraping-pro is NOT bundled — it's a premium add-on managed separately. - * @var array - */ - private const BUNDLED_PLUGINS = [ - 'api-book-scraper', - 'dewey-editor', - 'digital-library', - 'goodlib', - 'open-library', - 'z39-server', - ]; - /** @var array Files/directories to preserve during update */ private array $preservePaths = [ '.env', @@ -2217,7 +2203,7 @@ private function copyDirectoryRecursive(string $source, string $dest): void /** * Update bundled plugins from the release package. * copyDirectory() skips storage/plugins (preservePaths), so bundled plugins - * must be updated separately. Only plugins listed in BUNDLED_PLUGINS are + * must be updated separately. Only plugins listed in BundledPlugins::LIST are * updated — user-installed and premium plugins (scraping-pro) are untouched. */ private function updateBundledPlugins(string $sourcePath): void @@ -2238,22 +2224,114 @@ private function updateBundledPlugins(string $sourcePath): void } $updated = 0; - foreach (self::BUNDLED_PLUGINS as $pluginName) { - $sourcePluginPath = $sourcePluginsDir . '/' . $pluginName; + $targetPluginsDirReal = realpath($targetPluginsDir); + if ($targetPluginsDirReal === false) { + throw new Exception(__('Impossibile risolvere il percorso della directory plugins.')); + } + + foreach (BundledPlugins::LIST as $pluginName) { + $pluginSlug = $this->normalizeBundledPluginSlug($pluginName); + $sourcePluginPath = $sourcePluginsDir . '/' . $pluginSlug; if (!is_dir($sourcePluginPath)) { - $this->debugLog('DEBUG', 'Plugin bundled non presente nel pacchetto', ['plugin' => $pluginName]); + $this->debugLog('DEBUG', 'Plugin bundled non presente nel pacchetto', ['plugin' => $pluginSlug]); continue; } - $targetPluginPath = $targetPluginsDir . '/' . $pluginName; - $this->debugLog('INFO', 'Aggiornamento plugin bundled', ['plugin' => $pluginName]); - $this->copyDirectoryRecursive($sourcePluginPath, $targetPluginPath); + $targetPluginPath = $targetPluginsDirReal . '/' . $pluginSlug; + $stagingPath = $targetPluginsDirReal . '/.' . $pluginSlug . '.tmp-' . bin2hex(random_bytes(4)); + $backupPath = $targetPluginsDirReal . '/.' . $pluginSlug . '.bak-' . bin2hex(random_bytes(4)); + + $this->debugLog('INFO', 'Aggiornamento plugin bundled', ['plugin' => $pluginSlug]); + + try { + $this->copyDirectoryRecursive($sourcePluginPath, $stagingPath); + + if (is_dir($targetPluginPath) && !rename($targetPluginPath, $backupPath)) { + $this->removeDirectoryTree($stagingPath); + throw new Exception(sprintf(__('Impossibile creare il backup del plugin: %s'), $pluginSlug)); + } + + if (!rename($stagingPath, $targetPluginPath)) { + if (is_dir($backupPath) && !rename($backupPath, $targetPluginPath)) { + throw new Exception(sprintf(__('Impossibile ripristinare il plugin precedente: %s'), $pluginSlug)); + } + throw new Exception(sprintf(__('Impossibile attivare la nuova versione del plugin: %s'), $pluginSlug)); + } + + if (is_dir($backupPath)) { + try { + $this->removeDirectoryTree($backupPath); + } catch (\Throwable $cleanupError) { + $this->debugLog('WARNING', 'Impossibile rimuovere backup plugin', [ + 'plugin' => $pluginSlug, + 'backup' => $backupPath, + 'error' => $cleanupError->getMessage(), + ]); + } + } + } catch (\Throwable $e) { + if (is_dir($stagingPath)) { + $this->removeDirectoryTree($stagingPath); + } + if (is_dir($backupPath) && !is_dir($targetPluginPath)) { + if (!rename($backupPath, $targetPluginPath)) { + $this->debugLog('ERROR', 'Impossibile ripristinare il plugin dal backup', [ + 'plugin' => $pluginSlug, + 'backup' => $backupPath, + 'target' => $targetPluginPath, + ]); + } + } + throw $e; + } + $updated++; } $this->debugLog('INFO', 'Plugin bundled aggiornati', ['count' => $updated]); } + private function normalizeBundledPluginSlug(string $pluginName): string + { + $pluginSlug = trim($pluginName); + if ($pluginSlug === '' || preg_match('/^[a-z0-9][a-z0-9-]*$/', $pluginSlug) !== 1) { + throw new Exception(sprintf(__('Slug plugin bundled non valido: %s'), $pluginName)); + } + + return $pluginSlug; + } + + private function removeDirectoryTree(string $path): void + { + if (!file_exists($path)) { + return; + } + if (!is_dir($path)) { + throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path)); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + if (!rmdir($item->getPathname())) { + throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname())); + } + } else { + if (!unlink($item->getPathname())) { + throw new Exception(sprintf(__('Impossibile rimuovere file: %s'), $item->getPathname())); + } + } + } + + if (!rmdir($path)) { + throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $path)); + } + } + /** * Clean up orphan files */ diff --git a/app/Views/admin/bulk-enrich.php b/app/Views/admin/bulk-enrich.php new file mode 100644 index 00000000..791eba75 --- /dev/null +++ b/app/Views/admin/bulk-enrich.php @@ -0,0 +1,289 @@ + 0, 'missing_cover' => 0, 'missing_description' => 0, 'pending' => 0]; +$enabled = $enabled ?? false; +?> + +
        + +
        +
        +
        +

        +

        +
        +
        +
        + + +
        + +
        +
        +
        + +
        +
        +

        +

        +
        +
        +
        + + +
        +
        +
        + +
        +
        +

        +

        +
        +
        +
        + + +
        +
        +
        + +
        +
        +

        +

        +
        +
        +
        + + +
        +
        +
        + +
        +
        +

        +

        +
        +
        +
        +
        + + +
        + +
        +

        +

        +
        + + + + +
        +
        + + +
        +

        +

        + +
        +
        + + + +
        + + diff --git a/app/Views/admin/plugins.php b/app/Views/admin/plugins.php index 4326a050..238173b0 100644 --- a/app/Views/admin/plugins.php +++ b/app/Views/admin/plugins.php @@ -241,7 +241,7 @@ class="px-4 py-2 " data-plugin-name="" data-plugin-type="api-book-scraper" data-has-config="" - data-settings-url="" + data-settings-url="" data-api-endpoint="" data-timeout="" data-enabled="" onclick="openApiBookScraperModal(this)"> @@ -296,6 +296,13 @@ class="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 tr + + + + + + + + + + +

        + + + + discogs.com/settings/developers + +

        +

        + + +

        + + + + + +
        + + + + + + + + + + +
        + + + + diff --git a/storage/plugins/discogs/wrapper.php b/storage/plugins/discogs/wrapper.php new file mode 100644 index 00000000..a9ca868b --- /dev/null +++ b/storage/plugins/discogs/wrapper.php @@ -0,0 +1,107 @@ +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) + */ + public function onDeactivate(): void + { + if (is_callable([$this->instance, 'onDeactivate'])) { + $this->instance->onDeactivate(); + } + \App\Support\SecureLogger::debug('[Discogs] Plugin deactivated'); + } + + /** + * Called when plugin is installed (by PluginManager) + */ + public function onInstall(): void + { + if (is_callable([$this->instance, 'onInstall'])) { + $this->instance->onInstall(); + } + \App\Support\SecureLogger::debug('[Discogs] Plugin installed'); + } + + /** + * Called when plugin is activated (by PluginManager) + */ + 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'); + } + + /** + * Called when plugin is uninstalled (by PluginManager) + */ + public function onUninstall(): void + { + if (is_callable([$this->instance, 'onUninstall'])) { + $this->instance->onUninstall(); + } + \App\Support\SecureLogger::debug('[Discogs] Plugin uninstalled'); + } + + /** + * Set the plugin ID (called by PluginManager after installation) + */ + public function setPluginId(int $pluginId): void + { + if (is_callable([$this->instance, 'setPluginId'])) { + $this->instance->setPluginId($pluginId); + } + } + + /** + * Forward all method calls to the namespaced instance + */ + public function __call($method, $args) + { + if (is_callable([$this->instance, $method])) { + return call_user_func_array([$this->instance, $method], $args); + } + + throw new \BadMethodCallException("Method {$method} does not exist"); + } + } +} diff --git a/tests/bulk-enrich.spec.js b/tests/bulk-enrich.spec.js new file mode 100644 index 00000000..643fdf3d --- /dev/null +++ b/tests/bulk-enrich.spec.js @@ -0,0 +1,649 @@ +// @ts-check +/** + * E2E tests for the Bulk ISBN Enrichment feature. + * + * Covers: + * - Stats page: correct counts, soft-delete exclusion, ISBN-required filter, already-populated exclusion + * - Toggle: enable/disable auto-enrichment, auth requirement, state persistence + * - Manual batch: cover + description enrichment, no-overwrite, graceful 404, progress counts, + * field preservation (tipo_media, isbn13, ean) + * - UI: stats cards, action button, toggle switch + */ +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const RUN_ID = Date.now().toString(36); + +// ─── DB helpers ─────────────────────────────────────────────────────────── +function mysqlArgs() { + const args = ['-u', DB_USER, `-p${DB_PASS}`]; + if (DB_SOCKET) args.push('--socket=' + DB_SOCKET); + args.push(DB_NAME); + return args; +} + +function dbQuery(sql) { + const args = [...mysqlArgs(), '-N', '-B', '-e', sql]; + return execFileSync('mysql', args, { encoding: 'utf8', timeout: 10000 }).trim(); +} + +function dbExec(sql) { + const args = [...mysqlArgs(), '-e', sql]; + execFileSync('mysql', args, { encoding: 'utf8', stdio: 'pipe', timeout: 10000 }); +} + +// ─── Test data ──────────────────────────────────────────────────────────── +// Real ISBNs for enrichment tests (well-known books with covers on Open Library) +const ISBN_MOCKINGBIRD = '9780061120084'; // To Kill a Mockingbird +const ISBN_1984 = '9780451524935'; // 1984 - George Orwell +const ISBN_GATSBY = '9780743273565'; // The Great Gatsby +const ISBN_CATCHER = '9780316769488'; // The Catcher in the Rye +const ISBN_HOBBIT = '9780547928227'; // The Hobbit +const ISBN_FAKE = '9999999999999'; // Non-existent ISBN + +const prefix = `ENRICH_${RUN_ID}`; + +test.describe.serial('Bulk Enrichment', () => { + /** @type {import('@playwright/test').BrowserContext} */ + let context; + /** @type {import('@playwright/test').Page} */ + let page; + + /** IDs of test books inserted during setup */ + const bookIds = []; + + /** Whether the bulk-enrich feature tables/routes exist */ + let featureAvailable = true; + + test.beforeAll(async ({ browser }) => { + test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_PASS || !DB_NAME, + 'E2E credentials not configured' + ); + + // Verify app is installed + try { + const tables = dbQuery( + "SELECT COUNT(*) FROM information_schema.tables " + + `WHERE table_schema = DATABASE() AND table_name IN ('libri','utenti','system_settings')` + ); + test.skip( + parseInt(tables, 10) < 3, + 'App not installed (run tests/smoke-install.spec.js first)' + ); + } catch { + test.skip(true, 'Cannot reach DB'); + } + + context = await browser.newContext(); + page = await context.newPage(); + + // Login as admin + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin\//, { timeout: 15000 }); + + // Seed 5 test books with ISBNs but NO cover and NO description + const isbns = [ISBN_MOCKINGBIRD, ISBN_1984, ISBN_GATSBY, ISBN_CATCHER, ISBN_HOBBIT]; + for (let i = 0; i < isbns.length; i++) { + dbExec( + "INSERT INTO libri (titolo, isbn13, copertina, descrizione, copie_totali, copie_disponibili, stato, created_at, updated_at) " + + `VALUES ('${prefix}_Book${i}', '${isbns[i]}', NULL, NULL, 1, 1, 'disponibile', NOW(), NOW())` + ); + const id = dbQuery(`SELECT id FROM libri WHERE titolo = '${prefix}_Book${i}' AND deleted_at IS NULL LIMIT 1`); + bookIds.push(parseInt(id, 10)); + } + }); + + test.afterAll(async () => { + // Clean up all test books + try { + dbExec( + `DELETE FROM libri WHERE titolo LIKE '${prefix}%'` + ); + } catch { /* ignore */ } + + // Restore toggle setting to off + try { + dbExec( + "DELETE FROM system_settings WHERE setting_key = 'bulk_enrich_enabled'" + ); + } catch { /* ignore */ } + + await context?.close(); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Stats tests (1-4) + // ═══════════════════════════════════════════════════════════════════ + + test('1. Stats page loads with correct counts', async () => { + const resp = await page.goto(`${BASE}/admin/libri/bulk-enrich`); + if (resp && resp.status() === 404) { + featureAvailable = false; + test.skip(true, 'Bulk enrich route not yet implemented'); + } + expect(resp?.status()).toBe(200); + + // Count pending books in DB (isbn13 not null, missing cover OR description, not deleted) + const pendingCount = parseInt( + dbQuery( + "SELECT COUNT(*) FROM libri " + + "WHERE isbn13 IS NOT NULL AND isbn13 != '' " + + "AND (copertina IS NULL OR copertina = '' OR descrizione IS NULL OR descrizione = '') " + + "AND deleted_at IS NULL" + ), + 10 + ); + + // The page should display the pending count somewhere + const content = await page.content(); + expect(content).toContain(String(pendingCount)); + }); + + test('2. Stats exclude soft-deleted books', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + // Count pending before soft-delete + const countBefore = parseInt( + dbQuery( + "SELECT COUNT(*) FROM libri " + + "WHERE isbn13 IS NOT NULL AND isbn13 != '' " + + "AND (copertina IS NULL OR copertina = '' OR descrizione IS NULL OR descrizione = '') " + + "AND deleted_at IS NULL" + ), + 10 + ); + + // Soft-delete one test book (nullify isbn13 per project rules) + const victimId = bookIds[0]; + dbExec(`UPDATE libri SET deleted_at = NOW(), isbn13 = NULL WHERE id = ${victimId}`); + + // Reload and check count decreased + await page.goto(`${BASE}/admin/libri/bulk-enrich`); + const countAfter = parseInt( + dbQuery( + "SELECT COUNT(*) FROM libri " + + "WHERE isbn13 IS NOT NULL AND isbn13 != '' " + + "AND (copertina IS NULL OR copertina = '' OR descrizione IS NULL OR descrizione = '') " + + "AND deleted_at IS NULL" + ), + 10 + ); + + expect(countAfter).toBe(countBefore - 1); + + const content = await page.content(); + expect(content).toContain(String(countAfter)); + + // Restore the book + dbExec(`UPDATE libri SET deleted_at = NULL, isbn13 = '${ISBN_MOCKINGBIRD}' WHERE id = ${victimId}`); + }); + + test('3. Stats exclude books without ISBN', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + // Insert a book WITHOUT isbn13 — should NOT appear in pending count + dbExec( + "INSERT INTO libri (titolo, isbn13, copertina, descrizione, copie_totali, copie_disponibili, stato, created_at, updated_at) " + + `VALUES ('${prefix}_NoISBN', NULL, NULL, NULL, 1, 1, 'disponibile', NOW(), NOW())` + ); + + const pendingCount = parseInt( + dbQuery( + "SELECT COUNT(*) FROM libri " + + "WHERE isbn13 IS NOT NULL AND isbn13 != '' " + + "AND (copertina IS NULL OR copertina = '' OR descrizione IS NULL OR descrizione = '') " + + "AND deleted_at IS NULL" + ), + 10 + ); + + // The no-ISBN book must not be counted + const noIsbnInPending = dbQuery( + `SELECT COUNT(*) FROM libri WHERE titolo = '${prefix}_NoISBN' AND isbn13 IS NOT NULL AND deleted_at IS NULL` + ); + expect(noIsbnInPending).toBe('0'); + + await page.goto(`${BASE}/admin/libri/bulk-enrich`); + const content = await page.content(); + expect(content).toContain(String(pendingCount)); + }); + + test('4. Stats exclude books with cover AND description already populated', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + // Insert a fully-populated book — should NOT be pending + dbExec( + "INSERT INTO libri (titolo, isbn13, copertina, descrizione, copie_totali, copie_disponibili, stato, created_at, updated_at) " + + `VALUES ('${prefix}_Complete', '9780140449136', 'cover.jpg', 'A great book.', 1, 1, 'disponibile', NOW(), NOW())` + ); + + const completeInPending = dbQuery( + `SELECT COUNT(*) FROM libri WHERE titolo = '${prefix}_Complete' ` + + "AND (copertina IS NULL OR copertina = '' OR descrizione IS NULL OR descrizione = '') " + + "AND deleted_at IS NULL" + ); + expect(completeInPending).toBe('0'); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Toggle tests (5-8) + // ═══════════════════════════════════════════════════════════════════ + + test('5. Toggle ON enables auto-enrichment', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + const resp = await page.request.post(`${BASE}/admin/libri/bulk-enrich/toggle`, { + form: { enabled: '1' }, + }); + expect(resp.status()).toBe(200); + const json = await resp.json(); + expect(json.success ?? json.ok).toBeTruthy(); + + // Verify in DB + const val = dbQuery( + "SELECT setting_value FROM system_settings WHERE setting_key = 'bulk_enrich_enabled' LIMIT 1" + ); + expect(val).toBe('1'); + }); + + test('6. Toggle OFF disables auto-enrichment', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + const resp = await page.request.post(`${BASE}/admin/libri/bulk-enrich/toggle`, { + form: { enabled: '0' }, + }); + expect(resp.status()).toBe(200); + const json = await resp.json(); + expect(json.success ?? json.ok).toBeTruthy(); + + const val = dbQuery( + "SELECT setting_value FROM system_settings WHERE setting_key = 'bulk_enrich_enabled' LIMIT 1" + ); + expect(val).toBe('0'); + }); + + test('7. Toggle requires admin authentication', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + // Create a fresh context with no session (not logged in) + const anonContext = await page.context().browser().newContext(); + try { + const anonPage = await anonContext.newPage(); + const resp = await anonPage.request.post(`${BASE}/admin/libri/bulk-enrich/toggle`, { + form: { enabled: '1' }, + maxRedirects: 0, + }); + + // Should redirect to login (302) or return 401/403 + const status = resp.status(); + expect([302, 401, 403]).toContain(status); + + if (status === 302) { + const location = resp.headers()['location'] || ''; + expect(location).toMatch(/accedi|login/); + } + } finally { + await anonContext.close(); + } + }); + + test('8. Toggle state persists across page loads', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + // Set toggle ON + await page.request.post(`${BASE}/admin/libri/bulk-enrich/toggle`, { + form: { enabled: '1' }, + }); + + // Reload the page + await page.goto(`${BASE}/admin/libri/bulk-enrich`); + await page.waitForLoadState('domcontentloaded'); + + // The toggle should reflect ON state — check for checked attribute or active class + const toggle = page.locator('input[name="enabled"], input[type="checkbox"][name*="enrich"]').first(); + if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) { + const isChecked = await toggle.isChecked(); + expect(isChecked).toBe(true); + } else { + // Fallback: check that the page content reflects the ON state + const content = await page.content(); + const hasOnIndicator = content.includes('checked') || + content.includes('active') || + content.includes('enabled'); + expect(hasOnIndicator).toBe(true); + } + + // Reset to OFF for subsequent tests + await page.request.post(`${BASE}/admin/libri/bulk-enrich/toggle`, { + form: { enabled: '0' }, + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Manual batch tests (9-17) + // ═══════════════════════════════════════════════════════════════════ + + test('9. Manual batch enriches book with valid ISBN (cover)', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + test.setTimeout(30000); + + // Ensure test book has no cover + const targetId = bookIds[0]; + dbExec(`UPDATE libri SET copertina = NULL WHERE id = ${targetId}`); + + let resp; + try { + resp = await page.request.post(`${BASE}/admin/libri/bulk-enrich/start`, { + timeout: 25000, + }); + } catch { + test.skip(true, 'Enrichment API unreachable or timed out'); + return; + } + + expect(resp.status()).toBe(200); + const json = await resp.json(); + + // After batch, check if cover was populated (may depend on API availability) + const cover = dbQuery(`SELECT IFNULL(copertina, '') FROM libri WHERE id = ${targetId}`); + if (json.enriched > 0) { + expect(cover.length).toBeGreaterThan(0); + } + // If enriched === 0, API may be rate-limited — acceptable + }); + + test('10. Manual batch enriches book with valid ISBN (description)', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + test.setTimeout(30000); + + const targetId = bookIds[0]; + + // Re-run batch if needed (or check from previous run) + const desc = dbQuery(`SELECT IFNULL(descrizione, '') FROM libri WHERE id = ${targetId}`); + if (desc === '') { + // Try another batch run + try { + await page.request.post(`${BASE}/admin/libri/bulk-enrich/start`, { + timeout: 25000, + }); + } catch { + test.skip(true, 'Enrichment API unreachable or timed out'); + return; + } + + const descAfter = dbQuery(`SELECT IFNULL(descrizione, '') FROM libri WHERE id = ${targetId}`); + // If still empty, API might not return descriptions — just verify no crash + if (descAfter !== '') { + expect(descAfter.length).toBeGreaterThan(0); + } + } else { + expect(desc.length).toBeGreaterThan(0); + } + }); + + test('11. Manual batch does NOT overwrite existing cover', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + test.setTimeout(30000); + + const existingCover = 'my-existing-cover.jpg'; + + // Insert book with pre-existing cover + dbExec( + "INSERT INTO libri (titolo, isbn13, copertina, descrizione, copie_totali, copie_disponibili, stato, created_at, updated_at) " + + `VALUES ('${prefix}_HasCover', '${ISBN_1984}', '${existingCover}', NULL, 1, 1, 'disponibile', NOW(), NOW())` + ); + const id = dbQuery(`SELECT id FROM libri WHERE titolo = '${prefix}_HasCover' AND deleted_at IS NULL LIMIT 1`); + bookIds.push(parseInt(id, 10)); + + try { + await page.request.post(`${BASE}/admin/libri/bulk-enrich/start`, { + timeout: 25000, + }); + } catch { + // API unreachable — cover should still be original + } + + const cover = dbQuery(`SELECT IFNULL(copertina, '') FROM libri WHERE id = ${id}`); + expect(cover).toBe(existingCover); + }); + + test('12. Manual batch does NOT overwrite existing description', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + test.setTimeout(30000); + + const existingDesc = 'My custom description that must not be overwritten.'; + + // Insert book with pre-existing description + dbExec( + "INSERT INTO libri (titolo, isbn13, copertina, descrizione, copie_totali, copie_disponibili, stato, created_at, updated_at) " + + `VALUES ('${prefix}_HasDesc', '${ISBN_GATSBY}', NULL, '${existingDesc}', 1, 1, 'disponibile', NOW(), NOW())` + ); + const id = dbQuery(`SELECT id FROM libri WHERE titolo = '${prefix}_HasDesc' AND deleted_at IS NULL LIMIT 1`); + bookIds.push(parseInt(id, 10)); + + try { + await page.request.post(`${BASE}/admin/libri/bulk-enrich/start`, { + timeout: 25000, + }); + } catch { + // API unreachable — description should still be original + } + + const desc = dbQuery(`SELECT IFNULL(descrizione, '') FROM libri WHERE id = ${id}`); + expect(desc).toBe(existingDesc); + }); + + test('13. Manual batch handles ISBN not found gracefully', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + test.setTimeout(30000); + + // Insert book with fake ISBN + dbExec( + "INSERT INTO libri (titolo, isbn13, copertina, descrizione, copie_totali, copie_disponibili, stato, created_at, updated_at) " + + `VALUES ('${prefix}_FakeISBN', '${ISBN_FAKE}', NULL, NULL, 1, 1, 'disponibile', NOW(), NOW())` + ); + const id = dbQuery(`SELECT id FROM libri WHERE titolo = '${prefix}_FakeISBN' AND deleted_at IS NULL LIMIT 1`); + bookIds.push(parseInt(id, 10)); + + let resp; + try { + resp = await page.request.post(`${BASE}/admin/libri/bulk-enrich/start`, { + timeout: 25000, + }); + } catch { + test.skip(true, 'Enrichment API unreachable or timed out'); + return; + } + + expect(resp.status()).toBe(200); + const json = await resp.json(); + + // Response should indicate at least one not_found (or the fake ISBN was skipped) + expect(json).toHaveProperty('not_found'); + expect(json.not_found).toBeGreaterThanOrEqual(0); + // No crash — server returned valid JSON + }); + + test('14. Manual batch returns correct progress counts', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + test.setTimeout(30000); + + let resp; + try { + resp = await page.request.post(`${BASE}/admin/libri/bulk-enrich/start`, { + timeout: 25000, + }); + } catch { + test.skip(true, 'Enrichment API unreachable or timed out'); + return; + } + + expect(resp.status()).toBe(200); + const json = await resp.json(); + + // Response must contain progress counters + expect(json).toHaveProperty('processed'); + expect(json).toHaveProperty('enriched'); + expect(json).toHaveProperty('not_found'); + expect(json).toHaveProperty('errors'); + + // processed must be a non-negative number + expect(typeof json.processed).toBe('number'); + expect(json.processed).toBeGreaterThanOrEqual(0); + + // enriched + not_found + errors should equal processed + const sum = (json.enriched || 0) + (json.not_found || 0) + (json.errors || 0); + expect(sum).toBe(json.processed); + }); + + test('15. Batch preserves tipo_media after enrichment', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + test.setTimeout(30000); + + // Insert a disco-type book with ISBN (should be enriched, tipo_media must stay) + dbExec( + "INSERT INTO libri (titolo, isbn13, tipo_media, copertina, descrizione, copie_totali, copie_disponibili, stato, created_at, updated_at) " + + `VALUES ('${prefix}_Disco', '${ISBN_CATCHER}', 'disco', NULL, NULL, 1, 1, 'disponibile', NOW(), NOW())` + ); + const id = dbQuery(`SELECT id FROM libri WHERE titolo = '${prefix}_Disco' AND deleted_at IS NULL LIMIT 1`); + bookIds.push(parseInt(id, 10)); + + try { + await page.request.post(`${BASE}/admin/libri/bulk-enrich/start`, { + timeout: 25000, + }); + } catch { + // API unreachable — field should still be preserved + } + + const tipoMedia = dbQuery(`SELECT IFNULL(tipo_media, '') FROM libri WHERE id = ${id}`); + expect(tipoMedia).toBe('disco'); + }); + + test('16. Batch preserves isbn13 value', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + test.setTimeout(30000); + + // Pick a test book that was (potentially) enriched + const targetId = bookIds[0]; + const isbn = dbQuery(`SELECT IFNULL(isbn13, '') FROM libri WHERE id = ${targetId}`); + + try { + await page.request.post(`${BASE}/admin/libri/bulk-enrich/start`, { + timeout: 25000, + }); + } catch { + // API unreachable — isbn13 should still be preserved + } + + const isbnAfter = dbQuery(`SELECT IFNULL(isbn13, '') FROM libri WHERE id = ${targetId}`); + expect(isbnAfter).toBe(isbn); + }); + + test('17. Batch preserves ean value', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + test.setTimeout(30000); + + const testEan = '5012345678900'; + + // Insert book with EAN + dbExec( + "INSERT INTO libri (titolo, isbn13, ean, copertina, descrizione, copie_totali, copie_disponibili, stato, created_at, updated_at) " + + `VALUES ('${prefix}_WithEAN', '${ISBN_HOBBIT}', '${testEan}', NULL, NULL, 1, 1, 'disponibile', NOW(), NOW())` + ); + const id = dbQuery(`SELECT id FROM libri WHERE titolo = '${prefix}_WithEAN' AND deleted_at IS NULL LIMIT 1`); + bookIds.push(parseInt(id, 10)); + + try { + await page.request.post(`${BASE}/admin/libri/bulk-enrich/start`, { + timeout: 25000, + }); + } catch { + // API unreachable — ean should still be preserved + } + + const ean = dbQuery(`SELECT IFNULL(ean, '') FROM libri WHERE id = ${id}`); + expect(ean).toBe(testEan); + }); + + // ═══════════════════════════════════════════════════════════════════ + // UI tests (18-20) + // ═══════════════════════════════════════════════════════════════════ + + test('18. Bulk enrich page shows stats cards', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + await page.goto(`${BASE}/admin/libri/bulk-enrich`); + await page.waitForLoadState('domcontentloaded'); + const content = await page.content(); + + // Page should contain stat labels (Italian) for books and missing covers/descriptions + const hasBookCount = content.includes('libri') || content.includes('Libri'); + const hasCoverStat = content.includes('copertina') || content.includes('Copertina') || + content.includes('copertine') || content.includes('Copertine'); + const hasDescStat = content.includes('descrizione') || content.includes('Descrizione') || + content.includes('descrizioni') || content.includes('Descrizioni'); + + expect(hasBookCount).toBe(true); + expect(hasCoverStat || hasDescStat).toBe(true); + }); + + test('19. Bulk enrich page has "Arricchisci Adesso" button', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + await page.goto(`${BASE}/admin/libri/bulk-enrich`); + await page.waitForLoadState('domcontentloaded'); + + // Look for the action button — by text or by form action + const buttonByText = page.locator('button, a').filter({ + hasText: /arricchisci|enrich|avvia|start/i, + }).first(); + const buttonByAction = page.locator( + 'form[action*="bulk-enrich/start"] button[type="submit"]' + ).first(); + + const hasButton = await buttonByText.isVisible({ timeout: 3000 }).catch(() => false) || + await buttonByAction.isVisible({ timeout: 3000 }).catch(() => false); + + expect(hasButton).toBe(true); + }); + + test('20. Bulk enrich page has toggle switch', async () => { + test.skip(!featureAvailable, 'Bulk enrich not available'); + + await page.goto(`${BASE}/admin/libri/bulk-enrich`); + await page.waitForLoadState('domcontentloaded'); + + // Toggle can be a checkbox, a switch element, or a custom toggle + const toggle = page.locator( + 'input[type="checkbox"][name*="enable"], ' + + 'input[type="checkbox"][name*="enrich"], ' + + 'input[name="enabled"], ' + + '.toggle-switch, ' + + '[role="switch"]' + ).first(); + + const hasToggle = await toggle.isVisible({ timeout: 3000 }).catch(() => false); + + if (!hasToggle) { + // Fallback: look for any toggle/switch-like element in page content + const content = await page.content(); + const hasSwitchMarkup = content.includes('toggle') || content.includes('switch') || + content.includes('checkbox'); + expect(hasSwitchMarkup).toBe(true); + } else { + expect(hasToggle).toBe(true); + } + }); +}); diff --git a/tests/discogs-advanced.spec.js b/tests/discogs-advanced.spec.js new file mode 100644 index 00000000..1d787afa --- /dev/null +++ b/tests/discogs-advanced.spec.js @@ -0,0 +1,230 @@ +// @ts-check +/** + * Advanced Discogs tests: tipo_media filtering, CSV export, Schema.org, + * tracklist rendering, and edit persistence. + * Requires: app installed, admin user, Discogs plugin active, music records in DB. + */ +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const RUN_ID = Date.now(); +const SEEDED_MUSIC_EAN = `2${String(RUN_ID).slice(-12)}`; +const SEEDED_BOOK_ISBN = `978${String(RUN_ID).slice(-10)}`; + +function dbQuery(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-N', '-B', '-e', sql]; + if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }).trim(); +} + +function dbExec(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-e', sql]; + if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET); + execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }); +} + +test.describe.serial('Discogs Advanced Tests', () => { + /** @type {import('@playwright/test').Page} */ + let page; + /** @type {import('@playwright/test').BrowserContext} */ + let context; + let musicBookId = ''; + let bookBookId = ''; + + test.beforeAll(async ({ browser }) => { + test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_PASS || !DB_NAME, 'Missing E2E env vars'); + context = await browser.newContext(); + page = await context.newPage(); + + // Login + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin\//, { timeout: 15000 }); + + // Seed a music record and a book for comparison + dbExec( + "INSERT INTO libri (titolo, formato, tipo_media, ean, copie_totali, copie_disponibili, descrizione, note_varie, created_at, updated_at) " + + "VALUES ('E2E_ADV_CD_" + RUN_ID + "', 'cd_audio', 'disco', '" + SEEDED_MUSIC_EAN + "', 1, 1, " + + "'Track One - Track Two', 'Cat: TEST-001', NOW(), NOW())" + ); + dbExec( + "INSERT INTO libri (titolo, formato, tipo_media, isbn13, copie_totali, copie_disponibili, descrizione, created_at, updated_at) " + + "VALUES ('E2E_ADV_Book_" + RUN_ID + "', 'cartaceo', 'libro', '" + SEEDED_BOOK_ISBN + "', 1, 1, " + + "'A test book description', NOW(), NOW())" + ); + + musicBookId = dbQuery(`SELECT id FROM libri WHERE titolo = 'E2E_ADV_CD_${RUN_ID}' AND deleted_at IS NULL LIMIT 1`); + bookBookId = dbQuery(`SELECT id FROM libri WHERE titolo = 'E2E_ADV_Book_${RUN_ID}' AND deleted_at IS NULL LIMIT 1`); + }); + + test.afterAll(async () => { + try { + dbExec( + `DELETE FROM libri + WHERE id IN (${Number(musicBookId) || 0}, ${Number(bookBookId) || 0}) + OR ean = '${SEEDED_MUSIC_EAN}' + OR isbn13 = '${SEEDED_BOOK_ISBN}'` + ); + } catch {} + await context?.close(); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Test 1: tipo_media filter in admin book list + // ═══════════════════════════════════════════════════════════════════ + test('1. Admin list filters by tipo_media=disco', async () => { + // Fetch the DataTable API with tipo_media filter + const resp = await page.request.get(`${BASE}/api/libri?tipo_media=disco&start=0&length=100&search_text=E2E_ADV`); + expect(resp.status()).toBe(200); + const data = await resp.json(); + + // Should find the CD but not the book + const titles = (data.data || []).map((r) => r.titolo || r.info || ''); + const flatTitles = titles.join(' '); + + expect(flatTitles).toContain(`E2E_ADV_CD_${RUN_ID}`); + expect(flatTitles).not.toContain(`E2E_ADV_Book_${RUN_ID}`); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Test 2: CSV export includes tipo_media column + // ═══════════════════════════════════════════════════════════════════ + test('2. CSV export includes tipo_media for music records', async () => { + const resp = await page.request.get(`${BASE}/admin/libri/export/csv?ids=${musicBookId}`); + expect(resp.status()).toBe(200); + const body = await resp.text(); + + // Parse header + const lines = body.split('\n'); + const header = lines[0].replace(/^\uFEFF/, ''); + expect(header).toContain('tipo_media'); + + // Parse the data row + const headerFields = header.split(';'); + const tipoMediaIdx = headerFields.indexOf('tipo_media'); + expect(tipoMediaIdx).toBeGreaterThan(-1); + + if (lines.length > 1 && lines[1].trim()) { + const dataFields = lines[1].split(';'); + expect(dataFields[tipoMediaIdx]).toBe('disco'); + } + }); + + // ═══════════════════════════════════════════════════════════════════ + // Test 3: Schema.org uses MusicAlbum for disco, Book for libro + // ═══════════════════════════════════════════════════════════════════ + test('3. Schema.org JSON-LD type is MusicAlbum for disco', async () => { + const musicResp = await page.request.get(`${BASE}/libro/${musicBookId}`); + expect(musicResp.status()).toBe(200); + const musicHtml = await musicResp.text(); + + const jsonLdBlocks = Array.from( + musicHtml.matchAll(/]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi), + (match) => match[1] + ); + const schemas = jsonLdBlocks.flatMap((block) => { + try { + const parsed = JSON.parse(block.trim()); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return []; + } + }); + + const musicSchema = schemas.find((schema) => schema && schema['@type'] === 'MusicAlbum'); + expect(musicSchema, 'Frontend JSON-LD is missing MusicAlbum for disco').toBeTruthy(); + + const tipoMedia = dbQuery(`SELECT tipo_media FROM libri WHERE id = ${musicBookId}`); + expect(tipoMedia).toBe('disco'); + + const bookTipoMedia = dbQuery(`SELECT tipo_media FROM libri WHERE id = ${bookBookId}`); + expect(bookTipoMedia).toBe('libro'); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Test 4: Tracklist renders as
          in admin detail (music) + // vs prose

          for regular books + // ═══════════════════════════════════════════════════════════════════ + test('4. Admin detail: music shows tracklist, book shows prose description', async () => { + // Music book + await page.goto(`${BASE}/admin/libri/${musicBookId}`); + await page.waitForLoadState('domcontentloaded'); + const musicContent = await page.content(); + + // Should have tracklist HTML + expect(musicContent).toContain('Track One'); + expect(musicContent).toContain('Track Two'); + + // Should show music-specific labels + const hasEtichetta = musicContent.includes('Etichetta') || musicContent.includes('Label'); + const hasAnnoUscita = musicContent.includes('Anno di Uscita') || musicContent.includes('Release Year'); + expect(hasEtichetta || hasAnnoUscita).toBe(true); + + // Should have the media type badge + expect(musicContent).toContain('fa-compact-disc'); + + // Regular book + await page.goto(`${BASE}/admin/libri/${bookBookId}`); + await page.waitForLoadState('domcontentloaded'); + const bookContent = await page.content(); + + // Should have standard labels + const hasEditore = bookContent.includes('Editore') || bookContent.includes('Publisher'); + expect(hasEditore).toBe(true); + + // Should have book icon badge + expect(bookContent).toContain('fa-book'); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Test 5: Edit a CD — tipo_media persists after save + // ═══════════════════════════════════════════════════════════════════ + test('5. Edit music record: tipo_media persists after save', async () => { + await page.goto(`${BASE}/admin/libri/modifica/${musicBookId}`); + await page.waitForLoadState('domcontentloaded'); + + // Verify tipo_media select shows "disco" (or equivalent) + const tipoSelect = page.locator('#tipo_media'); + if (await tipoSelect.isVisible({ timeout: 3000 }).catch(() => false)) { + const currentValue = await tipoSelect.inputValue(); + expect(currentValue).toBe('disco'); + + // Change the title slightly + const titleInput = page.locator('input[name="titolo"]'); + const currentTitle = await titleInput.inputValue(); + await titleInput.fill(currentTitle + ' (edited)'); + + // Submit + await page.locator('button[type="submit"]').first().click(); + const swalConfirm = page.locator('.swal2-confirm'); + if (await swalConfirm.isVisible({ timeout: 3000 }).catch(() => false)) { + await swalConfirm.click(); + } + await page.waitForURL(/\/admin\/libri\/\d+/, { timeout: 15000 }).catch(() => {}); + + // Verify tipo_media was NOT overwritten to 'libro' + const tipoAfter = dbQuery(`SELECT tipo_media FROM libri WHERE id = ${musicBookId}`); + expect(tipoAfter).toBe('disco'); + + // Verify title was updated + const titleAfter = dbQuery(`SELECT titolo FROM libri WHERE id = ${musicBookId}`); + expect(titleAfter).toContain('(edited)'); + + // Clean up title + dbExec(`UPDATE libri SET titolo = 'E2E_ADV_CD_${RUN_ID}' WHERE id = ${musicBookId}`); + } else { + // tipo_media column might not exist yet (pre-migration) + const tipoMedia = dbQuery(`SELECT tipo_media FROM libri WHERE id = ${musicBookId}`); + expect(tipoMedia).toBe('disco'); + } + }); +}); diff --git a/tests/discogs-import.spec.js b/tests/discogs-import.spec.js new file mode 100644 index 00000000..743c4cf1 --- /dev/null +++ b/tests/discogs-import.spec.js @@ -0,0 +1,206 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; + +function dbQuery(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-N', '-B', '-e', sql]; + if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }).trim(); +} + +function dbExec(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-e', sql]; + if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET); + execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }); +} + +// Nirvana - Nevermind (very common CD, reliable on Discogs) +const TEST_BARCODE = '0720642442524'; + +test.describe.serial('Discogs Import: full scraping flow', () => { + /** @type {import('@playwright/test').Page} */ + let page; + /** @type {import('@playwright/test').BrowserContext} */ + let context; + let createdId = ''; + + test.beforeAll(async ({ browser }) => { + test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_PASS || !DB_NAME, + 'Missing E2E env vars' + ); + // Skip entire suite if the app is not installed (tables don't exist). + // Run tests/smoke-install.spec.js first on a fresh DB. + try { + const tables = dbQuery( + "SELECT COUNT(*) FROM information_schema.tables " + + `WHERE table_schema = DATABASE() AND table_name IN ('plugins','libri','utenti')` + ); + test.skip( + parseInt(tables, 10) < 3, + 'App not installed (run tests/smoke-install.spec.js first)' + ); + } catch { + test.skip(true, 'Cannot reach DB (run tests/smoke-install.spec.js first)'); + } + context = await browser.newContext(); + page = await context.newPage(); + + // Login + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin\//, { timeout: 15000 }); + + // Soft-delete any pre-existing record with the same barcode (e.g. from a + // previous run of this test that died before afterAll, or from seed data). + // UNIQUE constraint on ean would otherwise block the save step. + try { + dbExec( + `UPDATE libri SET deleted_at = NOW(), ean = NULL, isbn13 = NULL, isbn10 = NULL ` + + `WHERE (ean = '${TEST_BARCODE}' OR isbn13 = '${TEST_BARCODE}') AND deleted_at IS NULL` + ); + } catch {} + }); + + test.afterAll(async () => { + // Cleanup test data + try { + if (createdId !== '') { + dbExec(`DELETE FROM libri WHERE id = ${Number(createdId)} AND deleted_at IS NULL`); + } + } catch {} + await context?.close(); + }); + + test('1. Verify Discogs plugin is active', async () => { + // Discogs is a bundled-but-OPTIONAL plugin — on fresh install it is + // registered with is_active=0 (no auto-activation for optional plugins). + // The scraping flow requires it active, so activate it explicitly. + const registered = parseInt( + dbQuery("SELECT COUNT(*) FROM plugins WHERE name = 'discogs'"), + 10 + ); + expect(registered, 'Discogs plugin must be auto-registered by PluginManager on boot').toBeGreaterThan(0); + + dbExec("UPDATE plugins SET is_active = 1 WHERE name = 'discogs'"); + + const isActive = parseInt( + dbQuery("SELECT COUNT(*) FROM plugins WHERE name = 'discogs' AND is_active = 1"), + 10 + ); + expect(isActive, 'Discogs plugin activation must succeed').toBe(1); + }); + + test('2. Import CD via barcode in book form', async () => { + await page.goto(`${BASE}/admin/libri/crea`); + await page.waitForLoadState('domcontentloaded'); + + // Find the ISBN import field and button + const importField = page.locator('#importIsbn'); + const importBtn = page.locator('#btnImportIsbn'); + + await expect(importBtn, 'Import button not visible — scraping flow unavailable').toBeVisible({ timeout: 5000 }); + + // Enter barcode and trigger import + await importField.fill(TEST_BARCODE); + await importBtn.click(); + + // Wait for scraping response (Discogs needs time + rate limits) + // The scraping service tries multiple sources — wait up to 20s + await page.waitForTimeout(8000); + + // Check if title was populated + const titleField = page.locator('input[name="titolo"]'); + const titleValue = await titleField.inputValue(); + + expect(titleValue.trim().length, 'Scraping did not return a title for the Discogs barcode').toBeGreaterThan(0); + + // Title should contain "Nevermind" (the album name) + expect(titleValue.toLowerCase()).toContain('nevermind'); + }); + + test('3. Verify scraped fields are populated', async () => { + // After successful scraping, check multiple fields + const titleValue = await page.locator('input[name="titolo"]').inputValue(); + expect(titleValue.trim().length, 'No scraped data available after Discogs import').toBeGreaterThan(0); + + // Author/Artist should be populated + // Choices.js creates items — check if any author is selected + const authorItems = page.locator('#autori-wrapper .choices__item--selectable, .choices__item.choices__item--selectable'); + const authorCount = await authorItems.count().catch(() => 0); + + // At minimum, title should be populated + expect(titleValue.length).toBeGreaterThan(0); + + // Check EAN field has the barcode — and isbn13 MUST be empty. + // Regression guard: music barcodes must never be stuffed into isbn13 + // (commit 7016608 + normalizeIsbnFields guard). + const eanValue = await page.locator('input[name="ean"]').inputValue(); + const isbn13Value = await page.locator('input[name="isbn13"]').inputValue(); + expect(eanValue, 'Barcode must land in ean for music media').toBe(TEST_BARCODE); + expect(isbn13Value, 'isbn13 must stay empty for non-book scraping').toBe(''); + }); + + test('4. Save the imported CD', async () => { + const titleValue = await page.locator('input[name="titolo"]').inputValue(); + expect(titleValue.trim().length, 'No scraped data to save').toBeGreaterThan(0); + + // Set copies (required field) + const copieInput = page.locator('input[name="copie_totali"]'); + const copieVal = await copieInput.inputValue(); + if (!copieVal || copieVal === '0') { + await copieInput.fill('1'); + } + + // Submit the form (triggers SweetAlert confirmation) + await page.locator('button[type="submit"]').first().click(); + + // Wait for and confirm SweetAlert dialog + const swalConfirm = page.locator('.swal2-confirm'); + await expect(swalConfirm).toBeVisible({ timeout: 5000 }); + await swalConfirm.click(); + + // Wait for navigation after save + await page.waitForURL(/\/admin\/libri\/\d+/, { timeout: 15000 }); + const finalUrl = page.url(); + expect(/\/admin\/libri\/\d+/.test(finalUrl)).toBe(true); + const createdIdMatch = finalUrl.match(/\/admin\/libri\/(\d+)/); + expect(createdIdMatch, 'Could not resolve created record id from save redirect').not.toBeNull(); + createdId = createdIdMatch?.[1] ?? ''; + }); + + test('5. Verify saved CD in database', async () => { + expect(createdId, 'Created record id not captured during save').not.toBe(''); + const book = dbQuery( + `SELECT titolo, COALESCE(ean, ''), COALESCE(isbn13, ''), formato FROM libri WHERE id = ${Number(createdId)} AND deleted_at IS NULL LIMIT 1` + ); + expect(book, 'CD not found in database after import/save flow').not.toBe(''); + expect(book.toLowerCase()).toContain('nevermind'); + }); + + test('6. Verify music labels on saved CD detail page', async () => { + const bookId = createdId; + expect(bookId, 'CD not found for label check').not.toBe(''); + + await page.goto(`${BASE}/admin/libri/${bookId}`); + await page.waitForLoadState('domcontentloaded'); + const content = await page.content(); + + const tipoMedia = dbQuery(`SELECT tipo_media FROM libri WHERE id = ${bookId}`); + expect(tipoMedia).toBe('disco'); + + const hasMusicLabel = content.includes('Etichetta') || content.includes('Label') || + content.includes('Anno di Uscita') || content.includes('Release Year'); + expect(hasMusicLabel).toBe(true); + }); +}); diff --git a/tests/discogs-plugin.spec.js b/tests/discogs-plugin.spec.js new file mode 100644 index 00000000..f802891a --- /dev/null +++ b/tests/discogs-plugin.spec.js @@ -0,0 +1,246 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; + +function dbQuery(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-N', '-B', '-e', sql]; + if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }).trim(); +} + +function dbExec(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-e', sql]; + if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET); + execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }); +} + +// Unique barcode for testing (not used by seeded records) +const TEST_BARCODE = '9999999999901'; +const TEST_ARTIST = 'Pink Floyd'; + +test.describe.serial('Discogs Plugin (#87)', () => { + /** @type {import('@playwright/test').BrowserContext} */ + let context; + /** @type {import('@playwright/test').Page} */ + let page; + let pluginActivated = false; + let discogsPluginId = ''; + + test.beforeAll(async ({ browser }) => { + test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_PASS || !DB_NAME, + 'Missing E2E env vars' + ); + context = await browser.newContext(); + page = await context.newPage(); + + // Login + await page.goto(`${BASE}/admin/dashboard`); + const emailField = page.locator('input[name="email"]'); + if (await emailField.isVisible({ timeout: 3000 }).catch(() => false)) { + await emailField.fill(ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin\//, { timeout: 15000 }); + } + }); + + test.afterAll(async () => { + // Cleanup: remove test books + try { dbExec("DELETE FROM libri WHERE titolo LIKE '%E2E_DISCOGS_%'"); } catch {} + await context?.close(); + }); + + test('1. Discogs plugin files exist in storage', async () => { + // Verify plugin is shipped (via DB — plugins table may have it installed) + const pluginExists = dbQuery( + "SELECT COUNT(*) FROM plugins WHERE name = 'discogs'" + ); + + // If not installed, check if plugin.json is accessible via the admin page + await page.goto(`${BASE}/admin/plugins`); + await page.waitForLoadState('domcontentloaded'); + const pageContent = await page.content(); + const discogsCard = page.locator('div[data-plugin-id]').filter({ + has: page.getByRole('heading', { name: /discogs/i }), + }).first(); + await expect(discogsCard).toBeVisible({ timeout: 5000 }); + + // Plugin should appear in the list (installed or available) + expect( + pageContent.toLowerCase().includes('discogs') || parseInt(pluginExists) > 0 + ).toBe(true); + }); + + test('2. Activate Discogs plugin', async () => { + await page.goto(`${BASE}/admin/plugins`); + await page.waitForLoadState('domcontentloaded'); + + // Check if already active + const isActive = dbQuery( + "SELECT COUNT(*) FROM plugins WHERE name = 'discogs' AND is_active = 1" + ); + if (parseInt(isActive) > 0) { + pluginActivated = true; + return; + } + + discogsPluginId = dbQuery("SELECT id FROM plugins WHERE name = 'discogs' LIMIT 1"); + expect(discogsPluginId).not.toBe(''); + + const discogsCard = page.locator('div[data-plugin-id]').filter({ + has: page.getByRole('heading', { name: /discogs/i }), + }).first(); + await expect(discogsCard, 'Discogs card not found on the plugins page').toBeVisible({ timeout: 5000 }); + + const activateBtn = discogsCard.getByRole('button', { name: /^Attiva$/ }).first(); + if (await activateBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await activateBtn.click(); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(2000); + } + + // Verify activation + const activeNow = dbQuery( + "SELECT COUNT(*) FROM plugins WHERE name = 'discogs' AND is_active = 1" + ); + expect(parseInt(activeNow, 10), 'Discogs plugin failed to activate').toBeGreaterThan(0); + pluginActivated = true; + }); + + test('3. Plugin settings page loads', async () => { + test.skip(!pluginActivated, 'Discogs plugin not activated'); + if (!discogsPluginId) { + discogsPluginId = dbQuery("SELECT id FROM plugins WHERE name = 'discogs' LIMIT 1"); + } + expect(discogsPluginId).not.toBe(''); + + await page.goto(`${BASE}/admin/plugins/${discogsPluginId}/settings`); + await page.waitForLoadState('domcontentloaded'); + + const tokenField = page.locator('input[name="api_token"]'); + await expect(tokenField).toBeVisible({ timeout: 3000 }); + }); + + test('4. MediaLabels: book with music format shows adapted labels', async () => { + // Create a test book with music format via DB + dbExec(` + INSERT INTO libri (titolo, formato, copie_totali, copie_disponibili, created_at, updated_at) + VALUES ('E2E_DISCOGS_MediaLabel_Test', 'cd_audio', 1, 1, NOW(), NOW()) + `); + const bookId = dbQuery( + "SELECT id FROM libri WHERE titolo = 'E2E_DISCOGS_MediaLabel_Test' AND deleted_at IS NULL LIMIT 1" + ); + expect(bookId).not.toBe(''); + + // Visit the admin book detail page + await page.goto(`${BASE}/admin/libri/${bookId}`); + await page.waitForLoadState('domcontentloaded'); + const adminContent = await page.content(); + + // Labels should be music-aware (check for at least one adapted label) + // "Etichetta" instead of "Editore", or "Anno di Uscita" instead of "Anno di Pubblicazione" + const hasEtichetta = adminContent.includes('Etichetta') || adminContent.includes('Label'); + const hasAnnoUscita = adminContent.includes('Anno di Uscita') || adminContent.includes('Release Year'); + expect(hasEtichetta || hasAnnoUscita).toBe(true); + }); + + test('5. MediaLabels: regular book keeps standard labels', async () => { + // Create a regular book + dbExec(` + INSERT INTO libri (titolo, formato, copie_totali, copie_disponibili, created_at, updated_at) + VALUES ('E2E_DISCOGS_RegularBook', 'cartaceo', 1, 1, NOW(), NOW()) + `); + const bookId = dbQuery( + "SELECT id FROM libri WHERE titolo = 'E2E_DISCOGS_RegularBook' AND deleted_at IS NULL LIMIT 1" + ); + + await page.goto(`${BASE}/admin/libri/${bookId}`); + await page.waitForLoadState('domcontentloaded'); + const content = await page.content(); + + // Should have standard labels (Editore, not Etichetta) + // Won't have "Etichetta" unless there's music data + const hasEditore = content.includes('Editore') || content.includes('Publisher'); + expect(hasEditore).toBe(true); + }); + + test('6. Frontend: music book shows Barcode instead of ISBN-13', async () => { + // Create a music book with EAN + dbExec( + "INSERT INTO libri (titolo, formato, tipo_media, ean, copie_totali, copie_disponibili, created_at, updated_at) " + + "VALUES ('E2E_DISCOGS_Frontend_CD', 'vinile', 'disco', '" + TEST_BARCODE + "', 1, 1, NOW(), NOW())" + ); + const bookId = dbQuery( + "SELECT id FROM libri WHERE titolo = 'E2E_DISCOGS_Frontend_CD' AND deleted_at IS NULL LIMIT 1" + ); + + const resp = await page.request.get(`${BASE}/libro/${bookId}`); + expect(resp.status()).toBe(200); + + // Check that the frontend music page uses the barcode label path + const html = await resp.text(); + const hasBarcode = html.includes('Barcode'); + const hasMusicLabel = html.includes('Etichetta') || html.includes('Label') || + html.includes('Anno di Uscita') || html.includes('Release Year'); + expect(hasBarcode).toBe(true); + expect(hasMusicLabel).toBe(true); + }); + + test('7. Discogs scraping via ISBN import (if plugin active)', async () => { + test.skip(!pluginActivated, 'Discogs plugin not activated'); + + await page.goto(`${BASE}/admin/libri/crea`); + await page.waitForLoadState('domcontentloaded'); + + const importBtn = page.locator('#btnImportIsbn'); + if (!await importBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + test.skip(true, 'Import button not visible'); + return; + } + + // Try importing with a known CD barcode + await page.locator('#importIsbn').fill(TEST_BARCODE); + await importBtn.click(); + + // Wait for response (up to 15s — Discogs can be slow) + await page.waitForTimeout(5000); + + // Check if any fields were populated + const titleField = page.locator('input[name="titolo"]'); + const titleValue = await titleField.inputValue().catch(() => ''); + + if (titleValue !== '') { + // Scraping succeeded — verify some data + expect(titleValue.length).toBeGreaterThan(0); + + // Check if format was set to a music type + const formatField = page.locator('input[name="formato"]'); + const formatValue = await formatField.inputValue().catch(() => ''); + // Format might be populated from Discogs + + // Check description (should contain tracklist) + const descFrame = page.frameLocator('.tox-edit-area__iframe').first(); + if (await descFrame.locator('body').isVisible({ timeout: 2000 }).catch(() => false)) { + const descText = await descFrame.locator('body').textContent().catch(() => ''); + // If Discogs returned tracklist, description should have content + if (descText) { + expect(descText.length).toBeGreaterThan(0); + } + } + } else { + // Scraping might have failed (rate limit, network) — that's OK for CI + // Just verify no JS errors occurred + const logs = []; + page.on('console', msg => { if (msg.type() === 'error') logs.push(msg.text()); }); + } + }); +}); diff --git a/tests/full-test.spec.js b/tests/full-test.spec.js index c6c7ffa9..d9972d5c 100644 --- a/tests/full-test.spec.js +++ b/tests/full-test.spec.js @@ -1333,6 +1333,33 @@ TSV_Book1_${RUN_ID}\tTSV Author\tTSV Publisher\t2024`; }); test('10.4 Export CSV and verify structure (#77)', async () => { + // Count CSV records honoring quoted fields that may contain newlines + // (e.g. multi-line tracklists in descrizione). Naive split('\n') would + // over-count because music records from the Discogs plugin embed \n + // inside quoted CSV cells. + const countCsvRecords = (csv) => { + const text = csv.replace(/^\uFEFF/, ''); // strip UTF-8 BOM + let rows = 0; + let inQuote = false; + let hasContent = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + if (inQuote && text[i + 1] === '"') { i++; continue; } // escaped "" + inQuote = !inQuote; + hasContent = true; + } else if ((ch === '\n' || ch === '\r') && !inQuote) { + if (hasContent) rows++; + hasContent = false; + if (ch === '\r' && text[i + 1] === '\n') i++; + } else { + hasContent = true; + } + } + if (hasContent) rows++; + return rows; + }; + // Get 2 book IDs const idsRaw = dbQuery( "SELECT GROUP_CONCAT(id) FROM (SELECT id FROM libri WHERE deleted_at IS NULL ORDER BY id LIMIT 2) t" @@ -1344,7 +1371,7 @@ TSV_Book1_${RUN_ID}\tTSV Author\tTSV Publisher\t2024`; const allResp = await page.request.get(`${BASE}/admin/libri/export/csv`); expect(allResp.ok()).toBeTruthy(); const allCsv = await allResp.text(); - const allLines = allCsv.trim().split('\n'); + const allRecords = countCsvRecords(allCsv); // Export SELECTED (regression #77) const selectedResp = await page.request.get( @@ -1352,12 +1379,12 @@ TSV_Book1_${RUN_ID}\tTSV Author\tTSV Publisher\t2024`; ); expect(selectedResp.ok()).toBeTruthy(); const selectedCsv = await selectedResp.text(); - const selectedLines = selectedCsv.trim().split('\n'); + const selectedRecords = countCsvRecords(selectedCsv); - // Selected export should have fewer rows than all - expect(allLines.length).toBeGreaterThan(selectedLines.length); + // Selected export should have fewer records than all + expect(allRecords).toBeGreaterThan(selectedRecords); // Selected: header + exactly 2 data rows - expect(selectedLines.length).toBe(3); + expect(selectedRecords).toBe(3); }); }); diff --git a/tests/multisource-scraping.spec.js b/tests/multisource-scraping.spec.js new file mode 100644 index 00000000..3347e1bc --- /dev/null +++ b/tests/multisource-scraping.spec.js @@ -0,0 +1,394 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const RUN_ID = Date.now().toString(); +const RUN_TAG = `E2E_MULTI_${RUN_ID}`; + +const OPEN_LIBRARY_ISBN = '9780140328721'; +const ITALIAN_ISBN = '9788804671664'; +const NEVERMIND_BARCODE = '0720642442524'; +const MEDDLE_BARCODE = '5099902894225'; + +const MANUAL_BOOK_TITLE = `${RUN_TAG} BOOK MANUAL`; +const IMPORTED_BOOK_TITLE = `${RUN_TAG} BOOK IMPORT`; +const MANUAL_DISC_TITLE = `${RUN_TAG} DISC MANUAL`; +const IMPORTED_DISC_1_TITLE = `${RUN_TAG} DISC NEVERMIND`; +const IMPORTED_DISC_2_TITLE = `${RUN_TAG} DISC MEDDLE`; + +const MANUAL_BOOK_ISBN13 = `9781234${RUN_ID.slice(-6)}`; +const MANUAL_DISC_EAN = `999${RUN_ID.slice(-10)}`; +const IMPORTED_BOOK_ISBN13 = `9782234${RUN_ID.slice(-6)}`; +const IMPORTED_DISC_1_EAN = `888${RUN_ID.slice(-10)}`; +const IMPORTED_DISC_2_EAN = `777${RUN_ID.slice(-10)}`; + +function mysqlArgs(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-N', '-B', '-e', sql]; + if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET); + return args; +} + +function dbQuery(sql) { + return execFileSync('mysql', mysqlArgs(sql), { + encoding: 'utf-8', + timeout: 10000, + }).trim(); +} + +function sqlEscape(value) { + return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +async function loginAsAdmin(page) { + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin\//, { timeout: 15000 }); +} + +async function openCreateForm(page) { + await page.goto(`${BASE}/admin/libri/crea`); + await page.waitForLoadState('domcontentloaded'); +} + +async function importIdentifier(page, identifier) { + await page.locator('#importIsbn').fill(identifier); + await page.locator('#btnImportIsbn').click(); + await expect(page.locator('input[name="titolo"]')).not.toHaveValue('', { timeout: 20000 }); + + const sourceNameLocator = page.locator('#scrapeSourceName'); + await expect.poll( + async () => ((await sourceNameLocator.textContent().catch(() => '')) || '').trim(), + { timeout: 5000 } + ).not.toBe(''); + const sourceName = await sourceNameLocator.textContent().catch(() => ''); + return (sourceName || '').trim(); +} + +async function clearImportedEan(page) { + const scrapedEan = page.locator('#scraped_ean'); + if (await scrapedEan.count()) { + await scrapedEan.evaluate((node) => { + node.value = ''; + }); + } +} + +async function saveCurrentForm(page) { + await page.locator('button[type="submit"]').first().click(); + + const swalConfirm = page.locator('.swal2-confirm'); + if (await swalConfirm.isVisible({ timeout: 5000 }).catch(() => false)) { + await swalConfirm.click(); + } + + await page.waitForURL(/\/admin\/libri\/\d+/, { timeout: 15000 }); + const match = page.url().match(/\/admin\/libri\/(\d+)/); + if (!match) { + throw new Error(`Could not resolve saved book id from URL: ${page.url()}`); + } + + return Number(match[1]); +} + +async function getScrapePayload(page, identifier) { + const response = await page.request.get(`${BASE}/api/scrape/isbn?isbn=${encodeURIComponent(identifier)}`); + expect(response.status(), `Unexpected scrape status for ${identifier}`).toBe(200); + return response.json(); +} + +test.describe.serial('Multi-source scraping and creation flows', () => { + /** @type {import('@playwright/test').BrowserContext} */ + let context; + /** @type {import('@playwright/test').Page} */ + let page; + + let manualBookId = 0; + let importedBookId = 0; + let manualDiscId = 0; + let importedDisc1Id = 0; + let importedDisc2Id = 0; + + test.beforeAll(async ({ browser }) => { + test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_PASS || !DB_NAME, + 'Missing E2E env vars' + ); + + context = await browser.newContext(); + page = await context.newPage(); + await loginAsAdmin(page); + }); + + test.afterAll(async () => { + // Clean up records created during this test run + const ids = [manualBookId, importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id] + .filter(id => id > 0); + if (ids.length > 0) { + try { + const idList = ids.join(','); + execFileSync('mysql', mysqlArgs( + `DELETE FROM libri WHERE id IN (${idList})` + ), { encoding: 'utf-8', timeout: 10000 }); + } catch { /* best-effort cleanup */ } + } + // Also remove any stragglers matched by RUN_TAG + try { + execFileSync('mysql', mysqlArgs( + `DELETE FROM libri WHERE titolo LIKE '${RUN_TAG}%'` + ), { encoding: 'utf-8', timeout: 10000 }); + } catch { /* best-effort cleanup */ } + await context?.close(); + }); + + test('1. Scraping plugins are active with the expected priority order', async () => { + const activePlugins = dbQuery( + "SELECT GROUP_CONCAT(name ORDER BY name SEPARATOR ',') FROM plugins WHERE name IN ('discogs','open-library','z39-server') AND is_active = 1" + ); + expect(activePlugins).toContain('discogs'); + expect(activePlugins).toContain('open-library'); + expect(activePlugins).toContain('z39-server'); + + const hookOrder = dbQuery( + "SELECT GROUP_CONCAT(CONCAT(p.name, ':', ph.priority) ORDER BY ph.priority SEPARATOR ',') " + + "FROM plugin_hooks ph JOIN plugins p ON p.id = ph.plugin_id " + + "WHERE ph.hook_name = 'scrape.fetch.custom' AND ph.is_active = 1 AND p.name IN ('z39-server','open-library','discogs')" + ); + expect(hookOrder).toBe('z39-server:3,open-library:5,discogs:8'); + }); + + test('2. Scrape API returns Open Library book data for a known ISBN', async () => { + const payload = await getScrapePayload(page, OPEN_LIBRARY_ISBN); + + expect(payload.title).toContain('Fantastic Mr. Fox'); + expect(payload.tipo_media).toBe('libro'); + expect(payload.source).toContain('openlibrary.org'); + expect(Array.isArray(payload.authors) ? payload.authors.length : 0).toBeGreaterThan(0); + expect(payload.image).toBeTruthy(); + }); + + test('3. Scrape API returns enriched Italian metadata for a second book ISBN', async () => { + const payload = await getScrapePayload(page, ITALIAN_ISBN); + + expect(payload.title).toBeTruthy(); + expect(payload.tipo_media).toBe('libro'); + expect(payload.classificazione_dewey).toBe('188'); + expect(payload.isbn13).toBe(ITALIAN_ISBN); + expect((payload.collana || payload.series || '').length).toBeGreaterThan(0); + }); + + test('4. Scrape API returns Discogs music data for Nevermind barcode', async () => { + const payload = await getScrapePayload(page, NEVERMIND_BARCODE); + + expect(payload.source).toBe('discogs'); + expect(payload.title).toContain('Nevermind'); + expect(payload.tipo_media).toBe('disco'); + expect(payload.ean).toBe(NEVERMIND_BARCODE); + expect(payload.publisher).toBeTruthy(); + }); + + test('5. Scrape API returns Discogs music data for Meddle barcode', async () => { + const payload = await getScrapePayload(page, MEDDLE_BARCODE); + + expect(payload.source).toBe('discogs'); + expect(payload.title).toContain('Meddle'); + expect(payload.tipo_media).toBe('disco'); + expect(payload.ean).toBe(MEDDLE_BARCODE); + expect(payload.image).toBeTruthy(); + }); + + test('6. Admin can create a manual book from the create form', async () => { + await openCreateForm(page); + + await page.locator('input[name="titolo"]').fill(MANUAL_BOOK_TITLE); + await page.locator('input[name="isbn13"]').fill(MANUAL_BOOK_ISBN13); + await page.locator('input[name="copie_totali"]').fill('1'); + await page.locator('#tipo_media').selectOption('libro'); + await page.locator('input[name="formato"]').fill('cartaceo'); + + manualBookId = await saveCurrentForm(page); + expect(manualBookId).toBeGreaterThan(0); + }); + + test('7. The manual book is persisted as a book record', async () => { + const row = dbQuery( + `SELECT CONCAT(id, '|', titolo, '|', COALESCE(tipo_media, ''), '|', COALESCE(isbn13, '')) FROM libri WHERE titolo = '${sqlEscape(MANUAL_BOOK_TITLE)}' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1` + ); + + expect(row).toContain(MANUAL_BOOK_TITLE); + expect(row).toContain('|libro|'); + expect(row).toContain(MANUAL_BOOK_ISBN13); + }); + + test('8. Admin can import and save a book from Open Library', async () => { + await openCreateForm(page); + + const sourceName = await importIdentifier(page, OPEN_LIBRARY_ISBN); + expect(sourceName).toContain('Open Library'); + + await expect(page.locator('input[name="isbn13"]')).toHaveValue(OPEN_LIBRARY_ISBN); + await page.locator('input[name="titolo"]').fill(IMPORTED_BOOK_TITLE); + await page.locator('input[name="isbn10"]').fill(''); + await page.locator('input[name="isbn13"]').fill(IMPORTED_BOOK_ISBN13); + await page.locator('input[name="ean"]').fill(''); + await clearImportedEan(page); + await page.locator('input[name="copie_totali"]').fill('1'); + await expect(page.locator('#tipo_media')).toHaveValue('libro'); + + importedBookId = await saveCurrentForm(page); + expect(importedBookId).toBeGreaterThan(0); + }); + + test('9. The imported book detail keeps book labels and ISBN metadata', async () => { + const row = dbQuery( + `SELECT CONCAT(COALESCE(tipo_media, ''), '|', COALESCE(isbn13, ''), '|', COALESCE(ean, '')) FROM libri WHERE id = ${importedBookId}` + ); + expect(row).toContain('libro'); + expect(row).toContain(IMPORTED_BOOK_ISBN13); + + await page.goto(`${BASE}/admin/libri/${importedBookId}`); + await page.waitForLoadState('domcontentloaded'); + const html = await page.content(); + + const hasBookLabel = html.includes('Editore') || html.includes('Publisher'); + const hasIsbnLabel = html.includes('ISBN-13') || html.includes(IMPORTED_BOOK_ISBN13); + expect(hasBookLabel).toBe(true); + expect(hasIsbnLabel).toBe(true); + }); + + test('10. Admin can create a manual disc from the create form', async () => { + await openCreateForm(page); + + await page.locator('input[name="titolo"]').fill(MANUAL_DISC_TITLE); + await page.locator('input[name="ean"]').fill(MANUAL_DISC_EAN); + await page.locator('input[name="copie_totali"]').fill('1'); + await page.locator('#tipo_media').selectOption('disco'); + await page.locator('input[name="formato"]').fill('cd_audio'); + await page.locator('textarea[name="descrizione"]').fill('Manual test tracklist'); + + manualDiscId = await saveCurrentForm(page); + expect(manualDiscId).toBeGreaterThan(0); + }); + + test('11. The manual disc is persisted with music-specific metadata', async () => { + const row = dbQuery( + `SELECT CONCAT(COALESCE(tipo_media, ''), '|', COALESCE(ean, ''), '|', COALESCE(formato, '')) FROM libri WHERE id = ${manualDiscId}` + ); + expect(row).toContain('disco'); + expect(row).toContain(MANUAL_DISC_EAN); + expect(row).toContain('cd_audio'); + + await page.goto(`${BASE}/admin/libri/${manualDiscId}`); + await page.waitForLoadState('domcontentloaded'); + const html = await page.content(); + + const hasMusicBadge = html.includes('fa-compact-disc') || html.includes('Barcode'); + expect(hasMusicBadge).toBe(true); + }); + + test('12. Admin can import and save a Discogs music release from Nevermind barcode', async () => { + await openCreateForm(page); + + const sourceName = await importIdentifier(page, NEVERMIND_BARCODE); + expect(sourceName.toLowerCase()).toContain('discogs'); + + await expect(page.locator('#tipo_media')).toHaveValue('disco'); + await expect(page.locator('input[name="ean"]')).toHaveValue(NEVERMIND_BARCODE); + await page.locator('input[name="titolo"]').fill(IMPORTED_DISC_1_TITLE); + await page.locator('input[name="ean"]').fill(IMPORTED_DISC_1_EAN); + await page.locator('input[name="isbn10"]').fill(''); + await page.locator('input[name="isbn13"]').fill(''); + await page.locator('input[name="copie_totali"]').fill('1'); + + importedDisc1Id = await saveCurrentForm(page); + expect(importedDisc1Id).toBeGreaterThan(0); + }); + + test('13. The first imported disc exposes music labels and Discogs metadata', async () => { + const row = dbQuery( + `SELECT CONCAT(COALESCE(tipo_media, ''), '|', COALESCE(ean, ''), '|', COALESCE(editore_id, 0), '|', COALESCE(anno_pubblicazione, '')) FROM libri WHERE id = ${importedDisc1Id}` + ); + expect(row).toContain('disco'); + expect(row).toContain(IMPORTED_DISC_1_EAN); + + await page.goto(`${BASE}/admin/libri/${importedDisc1Id}`); + await page.waitForLoadState('domcontentloaded'); + const html = await page.content(); + + const hasMusicLabel = html.includes('Etichetta') || html.includes('Label'); + const hasBarcode = html.includes('Barcode') || html.includes(NEVERMIND_BARCODE); + expect(hasMusicLabel || hasBarcode).toBe(true); + }); + + test('14. Admin can import and save a second Discogs release from Meddle barcode', async () => { + await openCreateForm(page); + + const sourceName = await importIdentifier(page, MEDDLE_BARCODE); + expect(sourceName.toLowerCase()).toContain('discogs'); + + await expect(page.locator('#tipo_media')).toHaveValue('disco'); + await expect(page.locator('input[name="ean"]')).toHaveValue(MEDDLE_BARCODE); + await page.locator('input[name="titolo"]').fill(IMPORTED_DISC_2_TITLE); + await page.locator('input[name="ean"]').fill(IMPORTED_DISC_2_EAN); + await page.locator('input[name="isbn10"]').fill(''); + await page.locator('input[name="isbn13"]').fill(''); + await page.locator('input[name="copie_totali"]').fill('1'); + + importedDisc2Id = await saveCurrentForm(page); + expect(importedDisc2Id).toBeGreaterThan(0); + + const row = dbQuery( + `SELECT CONCAT(COALESCE(tipo_media, ''), '|', COALESCE(ean, ''), '|', COALESCE(titolo, '')) FROM libri WHERE id = ${importedDisc2Id}` + ); + expect(row).toContain('disco'); + expect(row).toContain(IMPORTED_DISC_2_EAN); + expect(row).toContain(IMPORTED_DISC_2_TITLE); + }); + + test('15. Admin filters and public search distinguish the new books and discs', async () => { + const adminResponse = await page.request.get( + `${BASE}/api/libri?tipo_media=disco&start=0&length=200&search_text=${encodeURIComponent(RUN_TAG)}` + ); + expect(adminResponse.status()).toBe(200); + const adminData = await adminResponse.json(); + const adminText = JSON.stringify(adminData.data || []); + + expect(adminText).toContain(MANUAL_DISC_TITLE); + expect(adminText).toContain(IMPORTED_DISC_1_TITLE); + expect(adminText).toContain(IMPORTED_DISC_2_TITLE); + expect(adminText).not.toContain(MANUAL_BOOK_TITLE); + expect(adminText).not.toContain(IMPORTED_BOOK_TITLE); + + const publicResponse = await page.request.get(`${BASE}/api/catalog?q=${encodeURIComponent(RUN_TAG)}`); + expect(publicResponse.status()).toBe(200); + const publicData = await publicResponse.json(); + const publicHtml = publicData.html || ''; + + expect(publicHtml).toContain(MANUAL_BOOK_TITLE); + expect(publicHtml).toContain(IMPORTED_BOOK_TITLE); + expect(publicHtml).toContain(MANUAL_DISC_TITLE); + expect(publicHtml).toContain(IMPORTED_DISC_1_TITLE); + expect(publicHtml).toContain(IMPORTED_DISC_2_TITLE); + + const publicDiscResponse = await page.request.get( + `${BASE}/api/catalog?q=${encodeURIComponent(RUN_TAG)}&tipo_media=disco` + ); + expect(publicDiscResponse.status()).toBe(200); + const publicDiscData = await publicDiscResponse.json(); + const publicDiscHtml = publicDiscData.html || ''; + + expect(publicDiscHtml).toContain(MANUAL_DISC_TITLE); + expect(publicDiscHtml).toContain(IMPORTED_DISC_1_TITLE); + expect(publicDiscHtml).toContain(IMPORTED_DISC_2_TITLE); + expect(publicDiscHtml).not.toContain(MANUAL_BOOK_TITLE); + expect(publicDiscHtml).not.toContain(IMPORTED_BOOK_TITLE); + }); +}); diff --git a/tests/playwright.config.js b/tests/playwright.config.js index 987e830b..6a37cb7c 100644 --- a/tests/playwright.config.js +++ b/tests/playwright.config.js @@ -6,6 +6,7 @@ module.exports = defineConfig({ timeout: 120_000, expect: { timeout: 15_000 }, reporter: 'list', + workers: 1, use: { baseURL: process.env.APP_URL || 'http://localhost:8081', headless: true, diff --git a/tests/pr100-media-types.spec.js b/tests/pr100-media-types.spec.js new file mode 100644 index 00000000..16d55519 --- /dev/null +++ b/tests/pr100-media-types.spec.js @@ -0,0 +1,271 @@ +// @ts-check +/** + * PR #100 Feature Tests: Media Types, Discogs Plugin, Dynamic Labels + * 10 reusable tests covering the tipo_media system end-to-end. + * Requires: app installed, admin user, tipo_media column in DB. + */ +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const RUN_ID = Date.now(); + +function dbQuery(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-N', '-B', '-e', sql]; + if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }).trim(); +} + +function dbExec(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-e', sql]; + if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET); + execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }); +} + +test.describe.serial('PR #100: Media Types System', () => { + /** @type {import('@playwright/test').Page} */ + let page; + /** @type {import('@playwright/test').BrowserContext} */ + let context; + let cdId = '', bookId = '', audiobookId = '', dvdId = ''; + + test.beforeAll(async ({ browser }) => { + test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_PASS || !DB_NAME, 'Missing env vars'); + // Skip if app is not installed yet (run smoke-install first) + try { + const tables = dbQuery( + "SELECT COUNT(*) FROM information_schema.tables " + + `WHERE table_schema = DATABASE() AND table_name IN ('libri','utenti','plugins')` + ); + test.skip( + parseInt(tables, 10) < 3, + 'App not installed (run tests/smoke-install.spec.js first)' + ); + } catch { + test.skip(true, 'Cannot reach DB (run tests/smoke-install.spec.js first)'); + } + context = await browser.newContext(); + page = await context.newPage(); + + // Login + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin\//, { timeout: 15000 }); + + // Seed 4 media types — use RUN_ID in EAN/ISBN to avoid collisions + const eanSuffix = String(RUN_ID).slice(-10).padStart(12, '0'); + const isbnSuffix = '978' + String(RUN_ID).slice(-10).padStart(10, '0'); + dbExec( + "INSERT INTO libri (titolo, formato, tipo_media, ean, copie_totali, copie_disponibili, created_at, updated_at) " + + "VALUES ('PR100_CD_" + RUN_ID + "', 'cd_audio', 'disco', '" + eanSuffix + "1', 1, 1, NOW(), NOW())" + ); + dbExec( + "INSERT INTO libri (titolo, formato, tipo_media, isbn13, copie_totali, copie_disponibili, created_at, updated_at) " + + "VALUES ('PR100_Book_" + RUN_ID + "', 'cartaceo', 'libro', '" + isbnSuffix + "', 1, 1, NOW(), NOW())" + ); + dbExec( + "INSERT INTO libri (titolo, formato, tipo_media, copie_totali, copie_disponibili, created_at, updated_at) " + + "VALUES ('PR100_Audiobook_" + RUN_ID + "', 'audiolibro', 'audiolibro', 1, 1, NOW(), NOW())" + ); + dbExec( + "INSERT INTO libri (titolo, formato, tipo_media, copie_totali, copie_disponibili, created_at, updated_at) " + + "VALUES ('PR100_DVD_" + RUN_ID + "', 'dvd', 'dvd', 1, 1, NOW(), NOW())" + ); + + cdId = dbQuery("SELECT id FROM libri WHERE titolo = 'PR100_CD_" + RUN_ID + "' LIMIT 1"); + bookId = dbQuery("SELECT id FROM libri WHERE titolo = 'PR100_Book_" + RUN_ID + "' LIMIT 1"); + audiobookId = dbQuery("SELECT id FROM libri WHERE titolo = 'PR100_Audiobook_" + RUN_ID + "' LIMIT 1"); + dvdId = dbQuery("SELECT id FROM libri WHERE titolo = 'PR100_DVD_" + RUN_ID + "' LIMIT 1"); + }); + + test.afterAll(async () => { + try { dbExec("UPDATE libri SET deleted_at = NOW(), ean = NULL, isbn13 = NULL WHERE titolo LIKE 'PR100_%_" + RUN_ID + "' AND deleted_at IS NULL"); } catch {} + await context?.close(); + }); + + // ═══════════════════════════════════════════════════════ + // 1. tipo_media column exists in DB + // ═══════════════════════════════════════════════════════ + test('1. tipo_media column exists with correct ENUM values', async () => { + const colType = dbQuery("SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='libri' AND COLUMN_NAME='tipo_media'"); + expect(colType).toContain('libro'); + expect(colType).toContain('disco'); + expect(colType).toContain('audiolibro'); + expect(colType).toContain('dvd'); + expect(colType).toContain('altro'); + }); + + // ═══════════════════════════════════════════════════════ + // 2. Admin book form has tipo_media dropdown + // ═══════════════════════════════════════════════════════ + test('2. Book form has tipo_media dropdown with all options', async () => { + await page.goto(`${BASE}/admin/libri/crea`); + await page.waitForLoadState('domcontentloaded'); + + const select = page.locator('#tipo_media'); + await expect(select).toBeVisible(); + + const options = await select.locator('option').allTextContents(); + expect(options.length).toBeGreaterThanOrEqual(5); + }); + + // ═══════════════════════════════════════════════════════ + // 3. Admin list shows tipo_media icon column + // ═══════════════════════════════════════════════════════ + test('3. Admin list has media type icon column', async () => { + await page.goto(`${BASE}/admin/libri`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(2000); + + const content = await page.content(); + // Should have the icon column header + expect(content).toContain('fa-compact-disc'); + }); + + // ═══════════════════════════════════════════════════════ + // 4. Admin list filters by tipo_media + // ═══════════════════════════════════════════════════════ + test('4. API filters by tipo_media=disco', async () => { + const resp = await page.request.get(`${BASE}/api/libri?tipo_media=disco&start=0&length=100&search_text=PR100`); + expect(resp.status()).toBe(200); + const data = await resp.json(); + const records = data.data || []; + + // Should find CD but not book/audiobook/dvd + const titles = records.map((r) => r.titolo || '').join(' '); + expect(titles).toContain('PR100_CD_'); + expect(titles).not.toContain('PR100_Book_'); + }); + + // ═══════════════════════════════════════════════════════ + // 5. CD shows music labels (Etichetta, Anno di Uscita) + // ═══════════════════════════════════════════════════════ + test('5. CD admin detail shows music-specific labels', async () => { + await page.goto(`${BASE}/admin/libri/${cdId}`); + await page.waitForLoadState('domcontentloaded'); + const content = await page.content(); + + const hasEtichetta = content.includes('Etichetta') || content.includes('Label'); + const hasAnnoUscita = content.includes('Anno di Uscita') || content.includes('Release Year'); + expect(hasEtichetta || hasAnnoUscita).toBe(true); + expect(content).toContain('fa-compact-disc'); + }); + + // ═══════════════════════════════════════════════════════ + // 6. Book shows standard labels (Editore, Anno Pubblicazione) + // ═══════════════════════════════════════════════════════ + test('6. Book admin detail shows standard labels', async () => { + await page.goto(`${BASE}/admin/libri/${bookId}`); + await page.waitForLoadState('domcontentloaded'); + const content = await page.content(); + + const hasEditore = content.includes('Editore') || content.includes('Publisher'); + expect(hasEditore).toBe(true); + expect(content).toContain('fa-book'); + }); + + // ═══════════════════════════════════════════════════════ + // 7. Edit CD — tipo_media persists as 'disco' + // ═══════════════════════════════════════════════════════ + test('7. Edit CD preserves tipo_media=disco', async () => { + await page.goto(`${BASE}/admin/libri/modifica/${cdId}`); + await page.waitForLoadState('domcontentloaded'); + + const select = page.locator('#tipo_media'); + if (await select.isVisible({ timeout: 3000 }).catch(() => false)) { + expect(await select.inputValue()).toBe('disco'); + + // Change title, save + await page.locator('input[name="titolo"]').fill('PR100_CD_' + RUN_ID + '_edited'); + await page.locator('button[type="submit"]').first().click(); + const swal = page.locator('.swal2-confirm'); + if (await swal.isVisible({ timeout: 3000 }).catch(() => false)) await swal.click(); + await page.waitForURL(/\/admin\/libri\/\d+/, { timeout: 15000 }).catch(() => {}); + + // Verify DB + const tipo = dbQuery(`SELECT tipo_media FROM libri WHERE id = ${cdId}`); + expect(tipo).toBe('disco'); + + // Restore title + dbExec("UPDATE libri SET titolo = 'PR100_CD_" + RUN_ID + "' WHERE id = " + cdId); + } + }); + + // ═══════════════════════════════════════════════════════ + // 8. CSV export includes tipo_media column + // ═══════════════════════════════════════════════════════ + test('8. CSV export includes tipo_media', async () => { + // Quote-aware CSV record counter (tracklists contain embedded \n in cells) + const countCsvRecords = (csv) => { + const text = csv.replace(/^\uFEFF/, ''); + let rows = 0, inQuote = false, hasContent = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + if (inQuote && text[i + 1] === '"') { i++; continue; } + inQuote = !inQuote; + hasContent = true; + } else if ((ch === '\n' || ch === '\r') && !inQuote) { + if (hasContent) rows++; + hasContent = false; + if (ch === '\r' && text[i + 1] === '\n') i++; + } else { + hasContent = true; + } + } + if (hasContent) rows++; + return rows; + }; + + const resp = await page.request.get(`${BASE}/admin/libri/export/csv?ids=${cdId},${bookId}`); + expect(resp.status()).toBe(200); + const body = await resp.text(); + const header = body.split('\n')[0].replace(/^\uFEFF/, ''); + + expect(header).toContain('tipo_media'); + + const fields = header.split(';'); + const idx = fields.indexOf('tipo_media'); + expect(idx).toBeGreaterThan(-1); + + // #77 regression: selected export MUST return exactly header + 2 records + const records = countCsvRecords(body); + expect(records, 'Selected export (?ids=2) must have header + 2 data rows').toBe(3); + }); + + // ═══════════════════════════════════════════════════════ + // 9. Format display name: "cd_audio" → "CD Audio" + // ═══════════════════════════════════════════════════════ + test('9. Format shows human-readable name, not raw key', async () => { + await page.goto(`${BASE}/admin/libri/${cdId}`); + await page.waitForLoadState('domcontentloaded'); + const content = await page.content(); + + // Should NOT show raw "cd_audio" + // Should show "CD Audio" (or translated equivalent) + const hasRaw = content.includes('>cd_audio<'); + const hasFormatted = content.includes('CD Audio') || content.includes('Audio CD') || content.includes('Audio-CD'); + expect(hasRaw).toBe(false); + expect(hasFormatted).toBe(true); + }); + + // ═══════════════════════════════════════════════════════ + // 10. Discogs plugin is bundled and registered + // ═══════════════════════════════════════════════════════ + test('10. Discogs plugin registered as bundled', async () => { + const exists = dbQuery("SELECT COUNT(*) FROM plugins WHERE name = 'discogs'"); + expect(parseInt(exists)).toBeGreaterThan(0); + + // Plugin display name should be updated + const displayName = dbQuery("SELECT display_name FROM plugins WHERE name = 'discogs' LIMIT 1"); + expect(displayName.toLowerCase()).toContain('music'); + }); +}); diff --git a/tests/pr100-review-fixes.spec.js b/tests/pr100-review-fixes.spec.js new file mode 100644 index 00000000..ec77f8c3 --- /dev/null +++ b/tests/pr100-review-fixes.spec.js @@ -0,0 +1,324 @@ +// @ts-check +/** + * Regression tests for PR #100 review fixes. + * + * Covers: + * - plugin.json metadata accuracy (version 1.1.0, requires_app 0.5.4, 4 hooks) + * - CSV export #77 regression: quote-aware record counting + * - Music record field persistence: import → DB → admin render → frontend render + * - numero_inventario is NOT pre-filled by Discogs scraping + * - parole_chiave contains Discogs genres + * - note_varie contains "Cat#:" catalog number + * - isbn13 stays empty for music media (barcode→ISBN guard) + * - logApiFailure helper is wired into apiRequest / musicBrainzRequest / fetchCoverArtArchive / enrichFromDeezer + */ +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const REPO_ROOT = path.resolve(__dirname, '..'); + +// ─── DB helpers ─────────────────────────────────────────────────────────── +function mysqlArgs() { + const args = ['-u', DB_USER, `-p${DB_PASS}`]; + if (DB_SOCKET) args.push('--socket=' + DB_SOCKET); + args.push(DB_NAME); + return args; +} +function dbQuery(sql) { + const args = [...mysqlArgs(), '-N', '-B', '-e', sql]; + return execFileSync('mysql', args, { encoding: 'utf8' }).trim(); +} +function dbExec(sql) { + const args = [...mysqlArgs(), '-e', sql]; + execFileSync('mysql', args, { stdio: 'pipe' }); +} + +// Quote-aware CSV record counter (handles embedded \n in tracklists) +function countCsvRecords(csv) { + const text = csv.replace(/^\uFEFF/, ''); + let rows = 0, inQuote = false, hasContent = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + if (inQuote && text[i + 1] === '"') { i++; continue; } + inQuote = !inQuote; + hasContent = true; + } else if ((ch === '\n' || ch === '\r') && !inQuote) { + if (hasContent) rows++; + hasContent = false; + if (ch === '\r' && text[i + 1] === '\n') i++; + } else { + hasContent = true; + } + } + if (hasContent) rows++; + return rows; +} + +// ─── Offline tests: file-level metadata ─────────────────────────────────── +test.describe('PR #100 fixes — offline metadata', () => { + test('1. plugin.json declares version 1.1.0, requires_app 0.5.4, 4 hooks', () => { + const raw = fs.readFileSync( + path.join(REPO_ROOT, 'storage/plugins/discogs/plugin.json'), + 'utf8' + ); + const manifest = JSON.parse(raw); + + expect(manifest.version, 'plugin.json version aligned with getInfo()').toBe('1.1.0'); + expect(manifest.requires_app, 'requires_app must reflect tipo_media dependency').toBe('0.5.4'); + + const hooks = manifest.metadata?.hooks ?? []; + expect(hooks).toHaveLength(4); + const names = hooks.map(h => h.name).sort(); + expect(names).toEqual([ + 'scrape.data.modify', + 'scrape.fetch.custom', + 'scrape.isbn.validate', + 'scrape.sources', + ]); + + // Every hook must declare a callback_method + for (const h of hooks) { + expect(h.callback_method, `hook ${h.name} missing callback_method`) + .toBeTruthy(); + } + }); + + test('2. getInfo() version matches plugin.json', () => { + const php = fs.readFileSync( + path.join(REPO_ROOT, 'storage/plugins/discogs/DiscogsPlugin.php'), + 'utf8' + ); + const m = php.match(/'version'\s*=>\s*'([^']+)'/); + expect(m, 'getInfo() must declare a version').not.toBeNull(); + expect(m[1]).toBe('1.1.0'); + }); + + test('3. README references 4 hooks (not 3)', () => { + const readme = fs.readFileSync( + path.join(REPO_ROOT, 'storage/plugins/discogs/README.md'), + 'utf8' + ); + expect(readme).toContain('quattro hook'); + expect(readme).toContain('scrape.isbn.validate'); + expect(readme).not.toContain('tramite tre hook'); + }); + + test('4. numero_inventario is not written by mapReleaseToPinakes', () => { + const php = fs.readFileSync( + path.join(REPO_ROOT, 'storage/plugins/discogs/DiscogsPlugin.php'), + 'utf8' + ); + // The old code: 'numero_inventario' => $catalogNumber !== '' ? $catalogNumber : null, + // Must be gone — catalog number lives in note_varie ("Cat#: ..."). + expect(php).not.toMatch(/'numero_inventario'\s*=>\s*\$catalogNumber/); + }); + + test('5. logApiFailure helper is called by all 4 external API paths', () => { + const php = fs.readFileSync( + path.join(REPO_ROOT, 'storage/plugins/discogs/DiscogsPlugin.php'), + 'utf8' + ); + const sources = ['Discogs', 'MusicBrainz', 'CoverArt', 'Deezer']; + for (const src of sources) { + const re = new RegExp(`logApiFailure\\('${src}'`); + expect(php, `logApiFailure not wired for ${src}`).toMatch(re); + } + }); + + test('6. ScrapeController normalizeIsbnFields has no-signal guard', () => { + const php = fs.readFileSync( + path.join(REPO_ROOT, 'app/Controllers/ScrapeController.php'), + 'utf8' + ); + // Guard: when neither format nor tipo_media is provided, skip ISBN normalization + expect(php).toContain('hasFormatSignal'); + expect(php).toContain('hasTipoMediaSignal'); + expect(php).toContain('no media-type signal'); + }); + + test('7. PluginManager uses SecureLogger instead of error_log', () => { + const php = fs.readFileSync( + path.join(REPO_ROOT, 'app/Support/PluginManager.php'), + 'utf8' + ); + // Guarantee no error_log calls remain (sensitive context rule from CLAUDE.md §5) + expect(php).not.toMatch(/\berror_log\s*\(/); + expect(php).toMatch(/SecureLogger::warning\(/); + }); +}); + +// ─── Live tests: record lifecycle ───────────────────────────────────────── +test.describe.serial('PR #100 fixes — record lifecycle', () => { + let context, page; + const RUN_ID = Date.now().toString(36); + const TEST_TITLE = `PR100FIX_Disco_${RUN_ID}`; + const TEST_BARCODE = `9988${RUN_ID.slice(-9).padStart(9, '0')}`.slice(0, 13); + const CAT_NO = 'TEST-CAT-' + RUN_ID; + const KEYWORDS = 'Rock,Progressive Rock,Art Rock,Psychedelic Rock'; + let bookId = 0; + + test.beforeAll(async ({ browser }) => { + test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_PASS || !DB_NAME, + 'E2E credentials not configured' + ); + // Skip lifecycle suite when app is not installed — offline tests still run + try { + const tables = dbQuery( + "SELECT COUNT(*) FROM information_schema.tables " + + `WHERE table_schema = DATABASE() AND table_name IN ('libri','utenti','plugins')` + ); + test.skip( + parseInt(tables, 10) < 3, + 'App not installed (run tests/smoke-install.spec.js first)' + ); + } catch { + test.skip(true, 'Cannot reach DB (run tests/smoke-install.spec.js first)'); + } + context = await browser.newContext(); + page = await context.newPage(); + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin\//, { timeout: 15000 }); + }); + + test.afterAll(async () => { + if (bookId > 0) { + dbExec(`DELETE FROM libri WHERE id = ${bookId}`); + } + await context?.close(); + }); + + test('8. Music record insert: all fields land in DB', () => { + // Simulate the output of a Discogs scraping save: tipo_media=disco, + // ean=barcode, isbn13 empty, numero_inventario NULL, + // parole_chiave=genres+styles, note_varie with "Cat#: ..." + const noteVarie = `Paese: UK\nCat#: ${CAT_NO}\nEtichetta: Test Records`; + const description = 'Tracklist:\n1. Track One (3:45)\n2. Track Two (4:12)\n3. Track Three (5:30)'; + const sql = `INSERT INTO libri + (titolo, ean, isbn10, isbn13, formato, tipo_media, anno_pubblicazione, + parole_chiave, note_varie, descrizione, copie_totali, numero_inventario, stato) + VALUES + ('${TEST_TITLE}', '${TEST_BARCODE}', NULL, NULL, 'cd_audio', 'disco', 2024, + '${KEYWORDS}', '${noteVarie.replace(/'/g, "''").replace(/\n/g, "\\n")}', + '${description.replace(/\n/g, "\\n")}', 1, NULL, 'disponibile')`; + dbExec(sql); + bookId = parseInt( + dbQuery(`SELECT id FROM libri WHERE titolo = '${TEST_TITLE}' LIMIT 1`), + 10 + ); + expect(bookId, 'Book must be inserted').toBeGreaterThan(0); + + // Pull the record back and verify every critical field. + const row = dbQuery( + `SELECT CONCAT_WS('|', + IFNULL(ean,''), IFNULL(isbn13,''), IFNULL(isbn10,''), + IFNULL(formato,''), IFNULL(tipo_media,''), + IFNULL(parole_chiave,''), IFNULL(numero_inventario,''), + IFNULL(anno_pubblicazione,'') + ) FROM libri WHERE id = ${bookId}` + ); + const [ean, isbn13, isbn10, formato, tipoMedia, parole, numInv, anno] = row.split('|'); + + expect(ean, 'Barcode must land in ean').toBe(TEST_BARCODE); + expect(isbn13, 'isbn13 must stay empty for disco').toBe(''); + expect(isbn10, 'isbn10 must stay empty for disco').toBe(''); + expect(formato).toBe('cd_audio'); + expect(tipoMedia).toBe('disco'); + expect(parole).toBe(KEYWORDS); + expect(numInv, 'numero_inventario must be NULL (not overwritten by catalog#)').toBe(''); + expect(anno).toBe('2024'); + + // note_varie contains Cat# + const noteRow = dbQuery(`SELECT note_varie FROM libri WHERE id = ${bookId}`); + expect(noteRow).toContain('Cat#:'); + expect(noteRow).toContain(CAT_NO); + }); + + test('9. Admin edit form renders music fields correctly', async () => { + await page.goto(`${BASE}/admin/libri/modifica/${bookId}`); + await page.waitForLoadState('domcontentloaded'); + + // EAN field populated, isbn13 empty + const eanValue = await page.locator('input[name="ean"]').inputValue(); + expect(eanValue).toBe(TEST_BARCODE); + const isbn13Value = await page.locator('input[name="isbn13"]').inputValue(); + expect(isbn13Value).toBe(''); + + // Title + const titleValue = await page.locator('input[name="titolo"]').inputValue(); + expect(titleValue).toBe(TEST_TITLE); + + // tipo_media select should be 'disco' + const tipoMedia = await page.locator('select[name="tipo_media"]').inputValue() + .catch(() => ''); + if (tipoMedia !== '') { + expect(tipoMedia).toBe('disco'); + } + + // parole_chiave field should contain the keywords + const paroleValue = await page.locator('input[name="parole_chiave"], textarea[name="parole_chiave"]') + .first() + .inputValue() + .catch(() => ''); + expect(paroleValue).toBe(KEYWORDS); + }); + + test('10. Frontend book detail shows music-appropriate labels', async () => { + // Frontend route uses slug: /libro/{id}-{slug} — accept either id-based or slug-based + const resp = await page.request.get(`${BASE}/libro/${bookId}`); + // Some installs redirect to /libro/{id}-{slug} + expect([200, 301, 302]).toContain(resp.status()); + + // Fetch the final rendered page + await page.goto(`${BASE}/libro/${bookId}`); + await page.waitForLoadState('domcontentloaded'); + const html = await page.content(); + + expect(html).toContain(TEST_TITLE); + // Music-aware rendering: either the EAN or the barcode itself must appear + expect(html).toContain(TEST_BARCODE); + // Genre/keywords should surface on public page (#86 fix referenced by full-test) + const firstGenre = KEYWORDS.split(',')[0]; + expect(html).toContain(firstGenre); + }); + + test('11. CSV export #77: quote-aware row counting works for music records', async () => { + // Export only our test record. Body contains embedded \n in tracklist, + // naive split('\n') would over-count. + const resp = await page.request.get(`${BASE}/admin/libri/export/csv?ids=${bookId}`); + expect(resp.status()).toBe(200); + const body = await resp.text(); + + const records = countCsvRecords(body); + expect(records, 'Selected export must have header + 1 data row').toBe(2); + + // Sanity: naive count would have been much larger (≥4 due to tracklist newlines) + const naiveLines = body.trim().split('\n').length; + expect(naiveLines, 'Tracklist embeds newlines that naive split counts').toBeGreaterThan(records); + }); + + test('12. Soft-delete nullifies unique-indexed barcode fields', () => { + // Pinakes rule: on soft-delete, isbn10/isbn13/ean MUST be nullified to + // avoid unique constraint violations when re-inserting the same barcode. + // Verify that schema allows a second INSERT with the same EAN after soft-delete. + dbExec(`UPDATE libri SET deleted_at = NOW(), ean = NULL WHERE id = ${bookId}`); + const count = dbQuery(`SELECT COUNT(*) FROM libri WHERE ean = '${TEST_BARCODE}' AND deleted_at IS NULL`); + expect(count).toBe('0'); + + // Restore for afterAll cleanup path + dbExec(`UPDATE libri SET deleted_at = NULL, ean = '${TEST_BARCODE}' WHERE id = ${bookId}`); + }); +}); diff --git a/tests/seed-catalog.spec.js b/tests/seed-catalog.spec.js new file mode 100644 index 00000000..32807e11 --- /dev/null +++ b/tests/seed-catalog.spec.js @@ -0,0 +1,165 @@ +// @ts-check +/** + * Seed the catalog with books and music records. + * This is a SEEDER — it does NOT clean up. Records persist for manual testing. + * SKIPPED by default so regression runs don't mutate DB state. + * To run explicitly: E2E_RUN_SEED=1 /tmp/run-e2e.sh tests/seed-catalog.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); + +test.skip(process.env.E2E_RUN_SEED !== '1', 'Seeder skipped: set E2E_RUN_SEED=1 to run'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; + +// 10 music records via Discogs barcode scraping +const MUSIC_BARCODES = [ + { barcode: '0720642442524', note: 'Nirvana - Nevermind' }, + { barcode: '5099902894225', note: 'Pink Floyd - Meddle' }, + { barcode: '0094638246824', note: 'Beatles - Abbey Road' }, + { barcode: '0888837168625', note: 'Daft Punk - RAM' }, + { barcode: '5099751076322', note: 'AC/DC' }, + { barcode: '0602547428714', note: 'Adele - 25' }, + { barcode: '0602537615810', note: 'Arctic Monkeys - AM' }, + { barcode: '0886971592924', note: 'Muse - The Resistance' }, + { barcode: '0602527947747', note: 'Coldplay - Mylo Xyloto' }, + { barcode: '0602557048032', note: 'Metallica - Hardwired' }, +]; + +// 5 books via ISBN scraping +const BOOK_ISBNS = [ + { isbn: '9780061120084', note: 'To Kill a Mockingbird' }, + { isbn: '9780451524935', note: '1984' }, + { isbn: '9780141439518', note: 'Pride and Prejudice' }, + { isbn: '9780060935467', note: 'Don Quixote' }, + { isbn: '9780142437230', note: 'Moby Dick' }, +]; + +// 1 manual entry (punk split without barcode) +const MANUAL_ENTRIES = [ + { titolo: 'Zeromila / Orsetti HC — Split', formato: 'vinile', tipo_media: 'disco' }, +]; + +test.describe.serial('Seed Catalog (books + music)', () => { + /** @type {import('@playwright/test').Page} */ + let page; + /** @type {import('@playwright/test').BrowserContext} */ + let context; + + test.beforeAll(async ({ browser }) => { + test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'Missing env vars'); + context = await browser.newContext(); + page = await context.newPage(); + + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin\//, { timeout: 15000 }); + }); + + test.afterAll(async () => { + // DO NOT clean up — this is a seeder + await context?.close(); + }); + + // Seed music records via barcode scraping + for (let i = 0; i < MUSIC_BARCODES.length; i++) { + const rec = MUSIC_BARCODES[i]; + test(`Music ${i + 1}: ${rec.note}`, async () => { + test.setTimeout(30000); + await page.goto(`${BASE}/admin/libri/crea`); + await page.waitForLoadState('domcontentloaded'); + + const importBtn = page.locator('#btnImportIsbn'); + if (await importBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.fill('#importIsbn', rec.barcode); + await importBtn.click(); + await page.waitForTimeout(8000); + + const title = await page.locator('input[name="titolo"]').inputValue(); + if (!title) { + await page.locator('input[name="titolo"]').fill(`CD (${rec.barcode})`); + await page.locator('input[name="ean"]').fill(rec.barcode); + await page.locator('input[name="formato"]').fill('cd_audio'); + } + } else { + await page.locator('input[name="titolo"]').fill(`CD (${rec.barcode})`); + await page.locator('input[name="ean"]').fill(rec.barcode); + await page.locator('input[name="formato"]').fill('cd_audio'); + } + + const copie = await page.locator('input[name="copie_totali"]').inputValue(); + if (!copie || copie === '0') await page.locator('input[name="copie_totali"]').fill('1'); + + await page.locator('button[type="submit"]').first().click(); + const swal = page.locator('.swal2-confirm'); + if (await swal.isVisible({ timeout: 3000 }).catch(() => false)) await swal.click(); + await expect(page).toHaveURL(/\/admin\/libri\/\d+/, { timeout: 15000 }); + console.log(` ✓ ${rec.note}`); + }); + } + + // Seed books via ISBN + for (let i = 0; i < BOOK_ISBNS.length; i++) { + const book = BOOK_ISBNS[i]; + test(`Book ${i + 1}: ${book.note}`, async () => { + test.setTimeout(30000); + await page.goto(`${BASE}/admin/libri/crea`); + await page.waitForLoadState('domcontentloaded'); + + const importBtn = page.locator('#btnImportIsbn'); + if (await importBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await page.fill('#importIsbn', book.isbn); + await importBtn.click(); + await page.waitForTimeout(8000); + + const title = await page.locator('input[name="titolo"]').inputValue(); + if (!title) { + await page.locator('input[name="titolo"]').fill(book.note); + await page.locator('input[name="isbn13"]').fill(book.isbn); + } + } else { + await page.locator('input[name="titolo"]').fill(book.note); + await page.locator('input[name="isbn13"]').fill(book.isbn); + } + + const copie = await page.locator('input[name="copie_totali"]').inputValue(); + if (!copie || copie === '0') await page.locator('input[name="copie_totali"]').fill('1'); + + await page.locator('button[type="submit"]').first().click(); + const swal = page.locator('.swal2-confirm'); + if (await swal.isVisible({ timeout: 3000 }).catch(() => false)) await swal.click(); + await expect(page).toHaveURL(/\/admin\/libri\/\d+/, { timeout: 15000 }); + console.log(` ✓ ${book.note}`); + }); + } + + // Manual entries + for (let i = 0; i < MANUAL_ENTRIES.length; i++) { + const entry = MANUAL_ENTRIES[i]; + test(`Manual ${i + 1}: ${entry.titolo}`, async () => { + test.setTimeout(15000); + await page.goto(`${BASE}/admin/libri/crea`); + await page.waitForLoadState('domcontentloaded'); + + await page.locator('input[name="titolo"]').fill(entry.titolo); + await page.locator('input[name="formato"]').fill(entry.formato); + if (entry.tipo_media) { + const sel = page.locator('#tipo_media'); + if (await sel.isVisible({ timeout: 2000 }).catch(() => false)) { + await sel.selectOption(entry.tipo_media); + } + } + await page.locator('input[name="copie_totali"]').fill('1'); + + await page.locator('button[type="submit"]').first().click(); + const swal = page.locator('.swal2-confirm'); + if (await swal.isVisible({ timeout: 3000 }).catch(() => false)) await swal.click(); + await expect(page).toHaveURL(/\/admin\/libri\/\d+/, { timeout: 15000 }); + console.log(` ✓ ${entry.titolo}`); + }); + } +}); diff --git a/tests/smoke-install.spec.js b/tests/smoke-install.spec.js index 4278fa9e..057078cb 100644 --- a/tests/smoke-install.spec.js +++ b/tests/smoke-install.spec.js @@ -63,9 +63,25 @@ test.describe.serial('Smoke: clean install + core operations', () => { let page; let createdBookId = 0; + let installerAvailable = true; + + // RUN_ID makes titles/author names unique per test run, so re-running the + // suite against an already-populated DB does not hit Choices.js autocomplete + // on a matching existing record. + const RUN_ID = Date.now().toString(36); + const BOOK_TITLE = `Il Nome della Rosa ${RUN_ID}`; + const BOOK_TITLE_UPDATED = `${BOOK_TITLE} - Edizione Rivista`; + const AUTHOR_NAME = `Umberto Eco ${RUN_ID}`; + test.beforeAll(async ({ browser }) => { context = await browser.newContext(); page = await context.newPage(); + // Probe the installer: if the app is already installed the installer + // redirects away and the language radio is absent — skip installer steps + // but keep subsequent login/CRUD tests runnable. + await page.goto(`${BASE}/installer/?step=0`); + const radio = page.locator('input[name="language"][value="it_IT"]'); + installerAvailable = await radio.isVisible({ timeout: 5000 }).catch(() => false); }); test.afterAll(async () => { @@ -74,6 +90,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // ── Step 0: Language Selection ────────────────────────────────────── test('Installer step 0: select Italian language', async () => { + test.skip(!installerAvailable, 'App already installed — installer steps skipped'); await page.goto(`${BASE}/installer/?step=0`); await page.locator('input[name="language"][value="it_IT"]').check(); await page.locator('button[type="submit"]').click(); @@ -82,6 +99,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // ── Step 1: Requirements + start ─────────────────────────────────── test('Installer step 1: verify requirements and start', async () => { + test.skip(!installerAvailable, 'App already installed'); // All requirements should be met await expect(page.locator('li.not-met')).toHaveCount(0); await page.locator('button[type="submit"].btn-primary').click(); @@ -90,6 +108,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // ── Step 2: Database Configuration ───────────────────────────────── test('Installer step 2: configure DB and test connection', async () => { + test.skip(!installerAvailable, 'App already installed'); await page.fill('#db_host', DB_HOST || 'localhost'); await page.fill('#db_username', DB_USER); await page.fill('#db_password', DB_PASS); @@ -122,6 +141,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // ── Step 3: DB Import (auto-redirect) ────────────────────────────── test('Installer step 3: wait for DB schema import', async () => { + test.skip(!installerAvailable, 'App already installed'); // If we're already on step=4, the import already completed const currentUrl = page.url(); if (currentUrl.includes('step=4')) return; @@ -131,6 +151,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // ── Step 4: Create Admin User ────────────────────────────────────── test('Installer step 4: create admin user', async () => { + test.skip(!installerAvailable, 'App already installed'); await page.fill('input[name="nome"]', 'Fabio'); await page.fill('input[name="cognome"]', 'Dalez'); await page.fill('input[name="email"]', ADMIN_EMAIL); @@ -143,6 +164,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // ── Step 5: Application Settings ─────────────────────────────────── test('Installer step 5: set app name', async () => { + test.skip(!installerAvailable, 'App already installed'); await page.fill('input[name="app_name"]', 'Pinakes'); await page.locator('button[type="submit"].btn-primary').click(); await page.waitForURL(/step=6/, { timeout: 15000 }); @@ -150,6 +172,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // ── Step 6: Email Configuration ──────────────────────────────────── test('Installer step 6: configure email (mail driver)', async () => { + test.skip(!installerAvailable, 'App already installed'); await page.selectOption('#email_driver', 'mail'); await page.fill('input[name="from_email"]', 'noreply@example.com'); await page.fill('input[name="from_name"]', 'Pinakes'); @@ -159,6 +182,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // ── Step 7: Installation Complete ────────────────────────────────── test('Installer step 7: verify completion and go to app', async () => { + test.skip(!installerAvailable, 'App already installed'); // Wait for finalization to complete (plugin install, .htaccess, permissions) await expect(page.locator('.alert-success').first()).toBeVisible({ timeout: 30000 }); @@ -202,12 +226,12 @@ test.describe.serial('Smoke: clean install + core operations', () => { await page.waitForLoadState('networkidle'); // Fill title - await page.fill('#titolo', 'Il Nome della Rosa'); + await page.fill('#titolo', BOOK_TITLE); // Create author inline via Choices.js // Target the author search input specifically (not the publisher one) const authorInput = page.locator('.choices__input--cloned[aria-label*="autori"]'); - await authorInput.fill('Umberto Eco'); + await authorInput.fill(AUTHOR_NAME); await authorInput.press('Enter'); // Wait for the author item to appear in the Choices.js widget @@ -234,7 +258,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // Get the book ID from the API for later tests const listResp = await page.request.get( - `${BASE}/api/libri?start=0&length=5&search[value]=${encodeURIComponent('Il Nome della Rosa')}` + `${BASE}/api/libri?start=0&length=5&search[value]=${encodeURIComponent(BOOK_TITLE)}` ); const listData = await listResp.json(); expect(listData.data.length).toBeGreaterThan(0); @@ -272,7 +296,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { await page.waitForLoadState('networkidle'); // Change the title - await page.fill('#titolo', 'Il Nome della Rosa - Edizione Rivista'); + await page.fill('#titolo', BOOK_TITLE_UPDATED); // Submit — SweetAlert2 confirmation dialog await page.locator('#bookForm button[type="submit"]').click(); @@ -280,12 +304,16 @@ test.describe.serial('Smoke: clean install + core operations', () => { await page.locator('.swal2-confirm').click(); await page.waitForURL(/admin\/libri(?!.*modifica)/, { timeout: 15000 }); - // Verify the title was updated via API + // Verify the title was updated via API — search by RUN_ID so we only + // match the record this run created (the DB may hold titles from prior runs). const listResp = await page.request.get( - `${BASE}/api/libri?start=0&length=5&search[value]=${encodeURIComponent('Edizione Rivista')}` + `${BASE}/api/libri?start=0&length=5&search[value]=${encodeURIComponent(RUN_ID)}` ); const listData = await listResp.json(); expect(listData.data.length).toBeGreaterThan(0); - expect(listData.data[0].titolo).toContain('Edizione Rivista'); + const match = listData.data.find( + (b) => typeof b.titolo === 'string' && b.titolo.includes('Edizione Rivista') + ); + expect(match, 'Updated title must contain Edizione Rivista').toBeTruthy(); }); }); diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php index cde03fac..922794da 100644 --- a/vendor/composer/autoload_classmap.php +++ b/vendor/composer/autoload_classmap.php @@ -6,555 +6,13 @@ $baseDir = dirname($vendorDir); return array( - 'App\\Controllers\\Admin\\CmsAdminController' => $baseDir . '/app/Controllers/Admin/CmsAdminController.php', - 'App\\Controllers\\Admin\\LanguagesController' => $baseDir . '/app/Controllers/Admin/LanguagesController.php', - 'App\\Controllers\\Admin\\MessagesController' => $baseDir . '/app/Controllers/Admin/MessagesController.php', - 'App\\Controllers\\Admin\\NotificationsController' => $baseDir . '/app/Controllers/Admin/NotificationsController.php', - 'App\\Controllers\\Admin\\RecensioniAdminController' => $baseDir . '/app/Controllers/Admin/RecensioniAdminController.php', - 'App\\Controllers\\Admin\\StatsController' => $baseDir . '/app/Controllers/Admin/StatsController.php', - 'App\\Controllers\\AuthController' => $baseDir . '/app/Controllers/AuthController.php', - 'App\\Controllers\\AutoriApiController' => $baseDir . '/app/Controllers/AutoriApiController.php', - 'App\\Controllers\\AutoriController' => $baseDir . '/app/Controllers/AutoriController.php', - 'App\\Controllers\\CmsController' => $baseDir . '/app/Controllers/CmsController.php', - 'App\\Controllers\\CollaneController' => $baseDir . '/app/Controllers/CollaneController.php', - 'App\\Controllers\\CollocazioneController' => $baseDir . '/app/Controllers/CollocazioneController.php', - 'App\\Controllers\\ContactController' => $baseDir . '/app/Controllers/ContactController.php', - 'App\\Controllers\\CookiesController' => $baseDir . '/app/Controllers/CookiesController.php', - 'App\\Controllers\\CopyController' => $baseDir . '/app/Controllers/CopyController.php', - 'App\\Controllers\\CoverController' => $baseDir . '/app/Controllers/CoverController.php', - 'App\\Controllers\\CsvImportController' => $baseDir . '/app/Controllers/CsvImportController.php', - 'App\\Controllers\\DashboardController' => $baseDir . '/app/Controllers/DashboardController.php', - 'App\\Controllers\\DeweyApiController' => $baseDir . '/app/Controllers/DeweyApiController.php', - 'App\\Controllers\\EditoriApiController' => $baseDir . '/app/Controllers/EditoriApiController.php', - 'App\\Controllers\\EditorsController' => $baseDir . '/app/Controllers/EditorsController.php', - 'App\\Controllers\\EventsController' => $baseDir . '/app/Controllers/EventsController.php', - 'App\\Controllers\\FeedController' => $baseDir . '/app/Controllers/FeedController.php', - 'App\\Controllers\\FrontendController' => $baseDir . '/app/Controllers/FrontendController.php', - 'App\\Controllers\\GeneriApiController' => $baseDir . '/app/Controllers/GeneriApiController.php', - 'App\\Controllers\\GeneriController' => $baseDir . '/app/Controllers/GeneriController.php', - 'App\\Controllers\\ImportHistoryController' => $baseDir . '/app/Controllers/ImportHistoryController.php', - 'App\\Controllers\\LanguageController' => $baseDir . '/app/Controllers/LanguageController.php', - 'App\\Controllers\\LibraryThingImportController' => $baseDir . '/app/Controllers/LibraryThingImportController.php', - 'App\\Controllers\\LibriApiController' => $baseDir . '/app/Controllers/LibriApiController.php', - 'App\\Controllers\\LibriController' => $baseDir . '/app/Controllers/LibriController.php', - 'App\\Controllers\\LoanApprovalController' => $baseDir . '/app/Controllers/LoanApprovalController.php', - 'App\\Controllers\\MaintenanceController' => $baseDir . '/app/Controllers/MaintenanceController.php', - 'App\\Controllers\\PasswordController' => $baseDir . '/app/Controllers/PasswordController.php', - 'App\\Controllers\\PluginController' => $baseDir . '/app/Controllers/PluginController.php', - 'App\\Controllers\\PrestitiApiController' => $baseDir . '/app/Controllers/PrestitiApiController.php', - 'App\\Controllers\\PrestitiController' => $baseDir . '/app/Controllers/PrestitiController.php', - 'App\\Controllers\\PrivacyController' => $baseDir . '/app/Controllers/PrivacyController.php', - 'App\\Controllers\\ProfileController' => $baseDir . '/app/Controllers/ProfileController.php', - 'App\\Controllers\\PublicApiController' => $baseDir . '/app/Controllers/PublicApiController.php', - 'App\\Controllers\\RecensioniController' => $baseDir . '/app/Controllers/RecensioniController.php', - 'App\\Controllers\\RegistrationController' => $baseDir . '/app/Controllers/RegistrationController.php', - 'App\\Controllers\\ReservationManager' => $baseDir . '/app/Controllers/ReservationManager.php', - 'App\\Controllers\\ReservationsAdminController' => $baseDir . '/app/Controllers/ReservationsAdminController.php', - 'App\\Controllers\\ReservationsController' => $baseDir . '/app/Controllers/ReservationsController.php', - 'App\\Controllers\\ScrapeController' => $baseDir . '/app/Controllers/ScrapeController.php', - 'App\\Controllers\\SearchController' => $baseDir . '/app/Controllers/SearchController.php', - 'App\\Controllers\\SecurityLogsController' => $baseDir . '/app/Controllers/SecurityLogsController.php', - 'App\\Controllers\\SeoController' => $baseDir . '/app/Controllers/SeoController.php', - 'App\\Controllers\\SettingsController' => $baseDir . '/app/Controllers/SettingsController.php', - 'App\\Controllers\\ThemeController' => $baseDir . '/app/Controllers/ThemeController.php', - 'App\\Controllers\\UpdateController' => $baseDir . '/app/Controllers/UpdateController.php', - 'App\\Controllers\\UserActionsController' => $baseDir . '/app/Controllers/UserActionsController.php', - 'App\\Controllers\\UserDashboardController' => $baseDir . '/app/Controllers/UserDashboardController.php', - 'App\\Controllers\\UserWishlistController' => $baseDir . '/app/Controllers/UserWishlistController.php', - 'App\\Controllers\\UsersController' => $baseDir . '/app/Controllers/UsersController.php', - 'App\\Controllers\\UtentiApiController' => $baseDir . '/app/Controllers/UtentiApiController.php', - 'App\\Middleware\\AdminAuthMiddleware' => $baseDir . '/app/Middleware/AdminAuthMiddleware.php', - 'App\\Middleware\\ApiKeyMiddleware' => $baseDir . '/app/Middleware/ApiKeyMiddleware.php', - 'App\\Middleware\\AuthMiddleware' => $baseDir . '/app/Middleware/AuthMiddleware.php', - 'App\\Middleware\\BasePathMiddleware' => $baseDir . '/app/Middleware/BasePathMiddleware.php', - 'App\\Middleware\\CsrfMiddleware' => $baseDir . '/app/Middleware/CsrfMiddleware.php', - 'App\\Middleware\\RateLimitMiddleware' => $baseDir . '/app/Middleware/RateLimitMiddleware.php', - 'App\\Middleware\\RememberMeMiddleware' => $baseDir . '/app/Middleware/RememberMeMiddleware.php', - 'App\\Models\\ApiKeyRepository' => $baseDir . '/app/Models/ApiKeyRepository.php', - 'App\\Models\\AuthorRepository' => $baseDir . '/app/Models/AuthorRepository.php', - 'App\\Models\\BookRepository' => $baseDir . '/app/Models/BookRepository.php', - 'App\\Models\\CollocationRepository' => $baseDir . '/app/Models/CollocationRepository.php', - 'App\\Models\\CopyRepository' => $baseDir . '/app/Models/CopyRepository.php', - 'App\\Models\\DashboardStats' => $baseDir . '/app/Models/DashboardStats.php', - 'App\\Models\\GenereRepository' => $baseDir . '/app/Models/GenereRepository.php', - 'App\\Models\\Language' => $baseDir . '/app/Models/Language.php', - 'App\\Models\\LoanRepository' => $baseDir . '/app/Models/LoanRepository.php', - 'App\\Models\\PublisherRepository' => $baseDir . '/app/Models/PublisherRepository.php', - 'App\\Models\\SettingsRepository' => $baseDir . '/app/Models/SettingsRepository.php', - 'App\\Models\\TaxonomyRepository' => $baseDir . '/app/Models/TaxonomyRepository.php', - 'App\\Models\\UserRepository' => $baseDir . '/app/Models/UserRepository.php', - 'App\\Repositories\\RecensioniRepository' => $baseDir . '/app/Repositories/RecensioniRepository.php', - 'App\\Services\\ReservationReassignmentService' => $baseDir . '/app/Services/ReservationReassignmentService.php', - 'App\\Support\\AuthorNormalizer' => $baseDir . '/app/Support/AuthorNormalizer.php', - 'App\\Support\\AuthorizationHelper' => $baseDir . '/app/Support/AuthorizationHelper.php', - 'App\\Support\\BookDataMerger' => $baseDir . '/app/Support/BookDataMerger.php', - 'App\\Support\\Branding' => $baseDir . '/app/Support/Branding.php', - 'App\\Support\\CmsHelper' => $baseDir . '/app/Support/CmsHelper.php', - 'App\\Support\\ConfigStore' => $baseDir . '/app/Support/ConfigStore.php', - 'App\\Support\\ContentSanitizer' => $baseDir . '/app/Support/ContentSanitizer.php', - 'App\\Support\\Csrf' => $baseDir . '/app/Support/Csrf.php', - 'App\\Support\\CsrfHelper' => $baseDir . '/app/Support/CsrfHelper.php', - 'App\\Support\\DataIntegrity' => $baseDir . '/app/Support/DataIntegrity.php', - 'App\\Support\\DateHelper' => $baseDir . '/app/Support/DateHelper.php', - 'App\\Support\\DeweyAutoPopulator' => $baseDir . '/app/Support/DeweyAutoPopulator.php', - 'App\\Support\\EmailService' => $baseDir . '/app/Support/EmailService.php', - 'App\\Support\\GenreHelper' => $baseDir . '/app/Support/GenreHelper.php', - 'App\\Support\\HookManager' => $baseDir . '/app/Support/HookManager.php', - 'App\\Support\\Hooks' => $baseDir . '/app/Support/Hooks.php', - 'App\\Support\\HreflangHelper' => $baseDir . '/app/Support/HreflangHelper.php', - 'App\\Support\\HtmlHelper' => $baseDir . '/app/Support/HtmlHelper.php', - 'App\\Support\\I18n' => $baseDir . '/app/Support/I18n.php', - 'App\\Support\\IcsGenerator' => $baseDir . '/app/Support/IcsGenerator.php', - 'App\\Support\\ImportLogger' => $baseDir . '/app/Support/ImportLogger.php', - 'App\\Support\\InputValidator' => $baseDir . '/app/Support/InputValidator.php', - 'App\\Support\\IsbnFormatter' => $baseDir . '/app/Support/IsbnFormatter.php', - 'App\\Support\\LibraryThingInstaller' => $baseDir . '/app/Support/LibraryThingInstaller.php', - 'App\\Support\\LoanPdfGenerator' => $baseDir . '/app/Support/LoanPdfGenerator.php', - 'App\\Support\\Log' => $baseDir . '/app/Support/Log.php', - 'App\\Support\\Mailer' => $baseDir . '/app/Support/Mailer.php', - 'App\\Support\\MaintenanceService' => $baseDir . '/app/Support/MaintenanceService.php', - 'App\\Support\\MergeHelper' => $baseDir . '/app/Support/MergeHelper.php', - 'App\\Support\\NotificationService' => $baseDir . '/app/Support/NotificationService.php', - 'App\\Support\\PluginManager' => $baseDir . '/app/Support/PluginManager.php', - 'App\\Support\\QueryCache' => $baseDir . '/app/Support/QueryCache.php', - 'App\\Support\\RateLimiter' => $baseDir . '/app/Support/RateLimiter.php', - 'App\\Support\\RememberMeService' => $baseDir . '/app/Support/RememberMeService.php', - 'App\\Support\\RouteTranslator' => $baseDir . '/app/Support/RouteTranslator.php', - 'App\\Support\\ScrapingService' => $baseDir . '/app/Support/ScrapingService.php', - 'App\\Support\\SecureLogger' => $baseDir . '/app/Support/SecureLogger.php', - 'App\\Support\\SettingsEncryption' => $baseDir . '/app/Support/SettingsEncryption.php', - 'App\\Support\\SettingsMailTemplates' => $baseDir . '/app/Support/SettingsMailTemplates.php', - 'App\\Support\\SharingProviders' => $baseDir . '/app/Support/SharingProviders.php', - 'App\\Support\\SitemapGenerator' => $baseDir . '/app/Support/SitemapGenerator.php', - 'App\\Support\\ThemeColorizer' => $baseDir . '/app/Support/ThemeColorizer.php', - 'App\\Support\\ThemeManager' => $baseDir . '/app/Support/ThemeManager.php', - 'App\\Support\\Updater' => $baseDir . '/app/Support/Updater.php', 'Attribute' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/Attribute.php', 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', - 'DI\\Attribute\\Inject' => $vendorDir . '/php-di/php-di/src/Attribute/Inject.php', - 'DI\\Attribute\\Injectable' => $vendorDir . '/php-di/php-di/src/Attribute/Injectable.php', - 'DI\\CompiledContainer' => $vendorDir . '/php-di/php-di/src/CompiledContainer.php', - 'DI\\Compiler\\Compiler' => $vendorDir . '/php-di/php-di/src/Compiler/Compiler.php', - 'DI\\Compiler\\ObjectCreationCompiler' => $vendorDir . '/php-di/php-di/src/Compiler/ObjectCreationCompiler.php', - 'DI\\Compiler\\RequestedEntryHolder' => $vendorDir . '/php-di/php-di/src/Compiler/RequestedEntryHolder.php', - 'DI\\Container' => $vendorDir . '/php-di/php-di/src/Container.php', - 'DI\\ContainerBuilder' => $vendorDir . '/php-di/php-di/src/ContainerBuilder.php', - 'DI\\Definition\\ArrayDefinition' => $vendorDir . '/php-di/php-di/src/Definition/ArrayDefinition.php', - 'DI\\Definition\\ArrayDefinitionExtension' => $vendorDir . '/php-di/php-di/src/Definition/ArrayDefinitionExtension.php', - 'DI\\Definition\\AutowireDefinition' => $vendorDir . '/php-di/php-di/src/Definition/AutowireDefinition.php', - 'DI\\Definition\\DecoratorDefinition' => $vendorDir . '/php-di/php-di/src/Definition/DecoratorDefinition.php', - 'DI\\Definition\\Definition' => $vendorDir . '/php-di/php-di/src/Definition/Definition.php', - 'DI\\Definition\\Dumper\\ObjectDefinitionDumper' => $vendorDir . '/php-di/php-di/src/Definition/Dumper/ObjectDefinitionDumper.php', - 'DI\\Definition\\EnvironmentVariableDefinition' => $vendorDir . '/php-di/php-di/src/Definition/EnvironmentVariableDefinition.php', - 'DI\\Definition\\Exception\\InvalidAttribute' => $vendorDir . '/php-di/php-di/src/Definition/Exception/InvalidAttribute.php', - 'DI\\Definition\\Exception\\InvalidDefinition' => $vendorDir . '/php-di/php-di/src/Definition/Exception/InvalidDefinition.php', - 'DI\\Definition\\ExtendsPreviousDefinition' => $vendorDir . '/php-di/php-di/src/Definition/ExtendsPreviousDefinition.php', - 'DI\\Definition\\FactoryDefinition' => $vendorDir . '/php-di/php-di/src/Definition/FactoryDefinition.php', - 'DI\\Definition\\Helper\\AutowireDefinitionHelper' => $vendorDir . '/php-di/php-di/src/Definition/Helper/AutowireDefinitionHelper.php', - 'DI\\Definition\\Helper\\CreateDefinitionHelper' => $vendorDir . '/php-di/php-di/src/Definition/Helper/CreateDefinitionHelper.php', - 'DI\\Definition\\Helper\\DefinitionHelper' => $vendorDir . '/php-di/php-di/src/Definition/Helper/DefinitionHelper.php', - 'DI\\Definition\\Helper\\FactoryDefinitionHelper' => $vendorDir . '/php-di/php-di/src/Definition/Helper/FactoryDefinitionHelper.php', - 'DI\\Definition\\InstanceDefinition' => $vendorDir . '/php-di/php-di/src/Definition/InstanceDefinition.php', - 'DI\\Definition\\ObjectDefinition' => $vendorDir . '/php-di/php-di/src/Definition/ObjectDefinition.php', - 'DI\\Definition\\ObjectDefinition\\MethodInjection' => $vendorDir . '/php-di/php-di/src/Definition/ObjectDefinition/MethodInjection.php', - 'DI\\Definition\\ObjectDefinition\\PropertyInjection' => $vendorDir . '/php-di/php-di/src/Definition/ObjectDefinition/PropertyInjection.php', - 'DI\\Definition\\Reference' => $vendorDir . '/php-di/php-di/src/Definition/Reference.php', - 'DI\\Definition\\Resolver\\ArrayResolver' => $vendorDir . '/php-di/php-di/src/Definition/Resolver/ArrayResolver.php', - 'DI\\Definition\\Resolver\\DecoratorResolver' => $vendorDir . '/php-di/php-di/src/Definition/Resolver/DecoratorResolver.php', - 'DI\\Definition\\Resolver\\DefinitionResolver' => $vendorDir . '/php-di/php-di/src/Definition/Resolver/DefinitionResolver.php', - 'DI\\Definition\\Resolver\\EnvironmentVariableResolver' => $vendorDir . '/php-di/php-di/src/Definition/Resolver/EnvironmentVariableResolver.php', - 'DI\\Definition\\Resolver\\FactoryResolver' => $vendorDir . '/php-di/php-di/src/Definition/Resolver/FactoryResolver.php', - 'DI\\Definition\\Resolver\\InstanceInjector' => $vendorDir . '/php-di/php-di/src/Definition/Resolver/InstanceInjector.php', - 'DI\\Definition\\Resolver\\ObjectCreator' => $vendorDir . '/php-di/php-di/src/Definition/Resolver/ObjectCreator.php', - 'DI\\Definition\\Resolver\\ParameterResolver' => $vendorDir . '/php-di/php-di/src/Definition/Resolver/ParameterResolver.php', - 'DI\\Definition\\Resolver\\ResolverDispatcher' => $vendorDir . '/php-di/php-di/src/Definition/Resolver/ResolverDispatcher.php', - 'DI\\Definition\\SelfResolvingDefinition' => $vendorDir . '/php-di/php-di/src/Definition/SelfResolvingDefinition.php', - 'DI\\Definition\\Source\\AttributeBasedAutowiring' => $vendorDir . '/php-di/php-di/src/Definition/Source/AttributeBasedAutowiring.php', - 'DI\\Definition\\Source\\Autowiring' => $vendorDir . '/php-di/php-di/src/Definition/Source/Autowiring.php', - 'DI\\Definition\\Source\\DefinitionArray' => $vendorDir . '/php-di/php-di/src/Definition/Source/DefinitionArray.php', - 'DI\\Definition\\Source\\DefinitionFile' => $vendorDir . '/php-di/php-di/src/Definition/Source/DefinitionFile.php', - 'DI\\Definition\\Source\\DefinitionNormalizer' => $vendorDir . '/php-di/php-di/src/Definition/Source/DefinitionNormalizer.php', - 'DI\\Definition\\Source\\DefinitionSource' => $vendorDir . '/php-di/php-di/src/Definition/Source/DefinitionSource.php', - 'DI\\Definition\\Source\\MutableDefinitionSource' => $vendorDir . '/php-di/php-di/src/Definition/Source/MutableDefinitionSource.php', - 'DI\\Definition\\Source\\NoAutowiring' => $vendorDir . '/php-di/php-di/src/Definition/Source/NoAutowiring.php', - 'DI\\Definition\\Source\\ReflectionBasedAutowiring' => $vendorDir . '/php-di/php-di/src/Definition/Source/ReflectionBasedAutowiring.php', - 'DI\\Definition\\Source\\SourceCache' => $vendorDir . '/php-di/php-di/src/Definition/Source/SourceCache.php', - 'DI\\Definition\\Source\\SourceChain' => $vendorDir . '/php-di/php-di/src/Definition/Source/SourceChain.php', - 'DI\\Definition\\StringDefinition' => $vendorDir . '/php-di/php-di/src/Definition/StringDefinition.php', - 'DI\\Definition\\ValueDefinition' => $vendorDir . '/php-di/php-di/src/Definition/ValueDefinition.php', - 'DI\\DependencyException' => $vendorDir . '/php-di/php-di/src/DependencyException.php', - 'DI\\FactoryInterface' => $vendorDir . '/php-di/php-di/src/FactoryInterface.php', - 'DI\\Factory\\RequestedEntry' => $vendorDir . '/php-di/php-di/src/Factory/RequestedEntry.php', - 'DI\\Invoker\\DefinitionParameterResolver' => $vendorDir . '/php-di/php-di/src/Invoker/DefinitionParameterResolver.php', - 'DI\\Invoker\\FactoryParameterResolver' => $vendorDir . '/php-di/php-di/src/Invoker/FactoryParameterResolver.php', - 'DI\\NotFoundException' => $vendorDir . '/php-di/php-di/src/NotFoundException.php', - 'DI\\Proxy\\NativeProxyFactory' => $vendorDir . '/php-di/php-di/src/Proxy/NativeProxyFactory.php', - 'DI\\Proxy\\ProxyFactory' => $vendorDir . '/php-di/php-di/src/Proxy/ProxyFactory.php', - 'DI\\Proxy\\ProxyFactoryInterface' => $vendorDir . '/php-di/php-di/src/Proxy/ProxyFactoryInterface.php', 'Datamatrix' => $vendorDir . '/tecnickcom/tcpdf/include/barcodes/datamatrix.php', - 'Dotenv\\Dotenv' => $vendorDir . '/vlucas/phpdotenv/src/Dotenv.php', - 'Dotenv\\Exception\\ExceptionInterface' => $vendorDir . '/vlucas/phpdotenv/src/Exception/ExceptionInterface.php', - 'Dotenv\\Exception\\InvalidEncodingException' => $vendorDir . '/vlucas/phpdotenv/src/Exception/InvalidEncodingException.php', - 'Dotenv\\Exception\\InvalidFileException' => $vendorDir . '/vlucas/phpdotenv/src/Exception/InvalidFileException.php', - 'Dotenv\\Exception\\InvalidPathException' => $vendorDir . '/vlucas/phpdotenv/src/Exception/InvalidPathException.php', - 'Dotenv\\Exception\\ValidationException' => $vendorDir . '/vlucas/phpdotenv/src/Exception/ValidationException.php', - 'Dotenv\\Loader\\Loader' => $vendorDir . '/vlucas/phpdotenv/src/Loader/Loader.php', - 'Dotenv\\Loader\\LoaderInterface' => $vendorDir . '/vlucas/phpdotenv/src/Loader/LoaderInterface.php', - 'Dotenv\\Loader\\Resolver' => $vendorDir . '/vlucas/phpdotenv/src/Loader/Resolver.php', - 'Dotenv\\Parser\\Entry' => $vendorDir . '/vlucas/phpdotenv/src/Parser/Entry.php', - 'Dotenv\\Parser\\EntryParser' => $vendorDir . '/vlucas/phpdotenv/src/Parser/EntryParser.php', - 'Dotenv\\Parser\\Lexer' => $vendorDir . '/vlucas/phpdotenv/src/Parser/Lexer.php', - 'Dotenv\\Parser\\Lines' => $vendorDir . '/vlucas/phpdotenv/src/Parser/Lines.php', - 'Dotenv\\Parser\\Parser' => $vendorDir . '/vlucas/phpdotenv/src/Parser/Parser.php', - 'Dotenv\\Parser\\ParserInterface' => $vendorDir . '/vlucas/phpdotenv/src/Parser/ParserInterface.php', - 'Dotenv\\Parser\\Value' => $vendorDir . '/vlucas/phpdotenv/src/Parser/Value.php', - 'Dotenv\\Repository\\AdapterRepository' => $vendorDir . '/vlucas/phpdotenv/src/Repository/AdapterRepository.php', - 'Dotenv\\Repository\\Adapter\\AdapterInterface' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/AdapterInterface.php', - 'Dotenv\\Repository\\Adapter\\ApacheAdapter' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/ApacheAdapter.php', - 'Dotenv\\Repository\\Adapter\\ArrayAdapter' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/ArrayAdapter.php', - 'Dotenv\\Repository\\Adapter\\EnvConstAdapter' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/EnvConstAdapter.php', - 'Dotenv\\Repository\\Adapter\\GuardedWriter' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/GuardedWriter.php', - 'Dotenv\\Repository\\Adapter\\ImmutableWriter' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/ImmutableWriter.php', - 'Dotenv\\Repository\\Adapter\\MultiReader' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/MultiReader.php', - 'Dotenv\\Repository\\Adapter\\MultiWriter' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/MultiWriter.php', - 'Dotenv\\Repository\\Adapter\\PutenvAdapter' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/PutenvAdapter.php', - 'Dotenv\\Repository\\Adapter\\ReaderInterface' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/ReaderInterface.php', - 'Dotenv\\Repository\\Adapter\\ReplacingWriter' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/ReplacingWriter.php', - 'Dotenv\\Repository\\Adapter\\ServerConstAdapter' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/ServerConstAdapter.php', - 'Dotenv\\Repository\\Adapter\\WriterInterface' => $vendorDir . '/vlucas/phpdotenv/src/Repository/Adapter/WriterInterface.php', - 'Dotenv\\Repository\\RepositoryBuilder' => $vendorDir . '/vlucas/phpdotenv/src/Repository/RepositoryBuilder.php', - 'Dotenv\\Repository\\RepositoryInterface' => $vendorDir . '/vlucas/phpdotenv/src/Repository/RepositoryInterface.php', - 'Dotenv\\Store\\FileStore' => $vendorDir . '/vlucas/phpdotenv/src/Store/FileStore.php', - 'Dotenv\\Store\\File\\Paths' => $vendorDir . '/vlucas/phpdotenv/src/Store/File/Paths.php', - 'Dotenv\\Store\\File\\Reader' => $vendorDir . '/vlucas/phpdotenv/src/Store/File/Reader.php', - 'Dotenv\\Store\\StoreBuilder' => $vendorDir . '/vlucas/phpdotenv/src/Store/StoreBuilder.php', - 'Dotenv\\Store\\StoreInterface' => $vendorDir . '/vlucas/phpdotenv/src/Store/StoreInterface.php', - 'Dotenv\\Store\\StringStore' => $vendorDir . '/vlucas/phpdotenv/src/Store/StringStore.php', - 'Dotenv\\Util\\Regex' => $vendorDir . '/vlucas/phpdotenv/src/Util/Regex.php', - 'Dotenv\\Util\\Str' => $vendorDir . '/vlucas/phpdotenv/src/Util/Str.php', - 'Dotenv\\Validator' => $vendorDir . '/vlucas/phpdotenv/src/Validator.php', - 'Emleons\\SimRating\\Interfaces\\RendererInterface' => $vendorDir . '/emleons/sim-rating/src/Interfaces/RendererInterface.php', - 'Emleons\\SimRating\\Rating' => $vendorDir . '/emleons/sim-rating/src/Rating.php', - 'Emleons\\SimRating\\Renderer\\HtmlRenderer' => $vendorDir . '/emleons/sim-rating/src/Renderer/HtmlRenderer.php', - 'Emleons\\SimRating\\Renderer\\JsonRenderer' => $vendorDir . '/emleons/sim-rating/src/Renderer/JsonRenderer.php', - 'Emleons\\SimRating\\Renderer\\SvgRenderer' => $vendorDir . '/emleons/sim-rating/src/Renderer/SvgRenderer.php', - 'FastRoute\\BadRouteException' => $vendorDir . '/nikic/fast-route/src/BadRouteException.php', - 'FastRoute\\DataGenerator' => $vendorDir . '/nikic/fast-route/src/DataGenerator.php', - 'FastRoute\\DataGenerator\\CharCountBased' => $vendorDir . '/nikic/fast-route/src/DataGenerator/CharCountBased.php', - 'FastRoute\\DataGenerator\\GroupCountBased' => $vendorDir . '/nikic/fast-route/src/DataGenerator/GroupCountBased.php', - 'FastRoute\\DataGenerator\\GroupPosBased' => $vendorDir . '/nikic/fast-route/src/DataGenerator/GroupPosBased.php', - 'FastRoute\\DataGenerator\\MarkBased' => $vendorDir . '/nikic/fast-route/src/DataGenerator/MarkBased.php', - 'FastRoute\\DataGenerator\\RegexBasedAbstract' => $vendorDir . '/nikic/fast-route/src/DataGenerator/RegexBasedAbstract.php', - 'FastRoute\\Dispatcher' => $vendorDir . '/nikic/fast-route/src/Dispatcher.php', - 'FastRoute\\Dispatcher\\CharCountBased' => $vendorDir . '/nikic/fast-route/src/Dispatcher/CharCountBased.php', - 'FastRoute\\Dispatcher\\GroupCountBased' => $vendorDir . '/nikic/fast-route/src/Dispatcher/GroupCountBased.php', - 'FastRoute\\Dispatcher\\GroupPosBased' => $vendorDir . '/nikic/fast-route/src/Dispatcher/GroupPosBased.php', - 'FastRoute\\Dispatcher\\MarkBased' => $vendorDir . '/nikic/fast-route/src/Dispatcher/MarkBased.php', - 'FastRoute\\Dispatcher\\RegexBasedAbstract' => $vendorDir . '/nikic/fast-route/src/Dispatcher/RegexBasedAbstract.php', - 'FastRoute\\Route' => $vendorDir . '/nikic/fast-route/src/Route.php', - 'FastRoute\\RouteCollector' => $vendorDir . '/nikic/fast-route/src/RouteCollector.php', - 'FastRoute\\RouteParser' => $vendorDir . '/nikic/fast-route/src/RouteParser.php', - 'FastRoute\\RouteParser\\Std' => $vendorDir . '/nikic/fast-route/src/RouteParser/Std.php', - 'Fig\\Http\\Message\\RequestMethodInterface' => $vendorDir . '/fig/http-message-util/src/RequestMethodInterface.php', - 'Fig\\Http\\Message\\StatusCodeInterface' => $vendorDir . '/fig/http-message-util/src/StatusCodeInterface.php', - 'GrahamCampbell\\ResultType\\Error' => $vendorDir . '/graham-campbell/result-type/src/Error.php', - 'GrahamCampbell\\ResultType\\Result' => $vendorDir . '/graham-campbell/result-type/src/Result.php', - 'GrahamCampbell\\ResultType\\Success' => $vendorDir . '/graham-campbell/result-type/src/Success.php', - 'Invoker\\CallableResolver' => $vendorDir . '/php-di/invoker/src/CallableResolver.php', - 'Invoker\\Exception\\InvocationException' => $vendorDir . '/php-di/invoker/src/Exception/InvocationException.php', - 'Invoker\\Exception\\NotCallableException' => $vendorDir . '/php-di/invoker/src/Exception/NotCallableException.php', - 'Invoker\\Exception\\NotEnoughParametersException' => $vendorDir . '/php-di/invoker/src/Exception/NotEnoughParametersException.php', - 'Invoker\\Invoker' => $vendorDir . '/php-di/invoker/src/Invoker.php', - 'Invoker\\InvokerInterface' => $vendorDir . '/php-di/invoker/src/InvokerInterface.php', - 'Invoker\\ParameterResolver\\AssociativeArrayResolver' => $vendorDir . '/php-di/invoker/src/ParameterResolver/AssociativeArrayResolver.php', - 'Invoker\\ParameterResolver\\Container\\ParameterNameContainerResolver' => $vendorDir . '/php-di/invoker/src/ParameterResolver/Container/ParameterNameContainerResolver.php', - 'Invoker\\ParameterResolver\\Container\\TypeHintContainerResolver' => $vendorDir . '/php-di/invoker/src/ParameterResolver/Container/TypeHintContainerResolver.php', - 'Invoker\\ParameterResolver\\DefaultValueResolver' => $vendorDir . '/php-di/invoker/src/ParameterResolver/DefaultValueResolver.php', - 'Invoker\\ParameterResolver\\NumericArrayResolver' => $vendorDir . '/php-di/invoker/src/ParameterResolver/NumericArrayResolver.php', - 'Invoker\\ParameterResolver\\ParameterResolver' => $vendorDir . '/php-di/invoker/src/ParameterResolver/ParameterResolver.php', - 'Invoker\\ParameterResolver\\ResolverChain' => $vendorDir . '/php-di/invoker/src/ParameterResolver/ResolverChain.php', - 'Invoker\\ParameterResolver\\TypeHintResolver' => $vendorDir . '/php-di/invoker/src/ParameterResolver/TypeHintResolver.php', - 'Invoker\\Reflection\\CallableReflection' => $vendorDir . '/php-di/invoker/src/Reflection/CallableReflection.php', - 'Laravel\\SerializableClosure\\Contracts\\Serializable' => $vendorDir . '/laravel/serializable-closure/src/Contracts/Serializable.php', - 'Laravel\\SerializableClosure\\Contracts\\Signer' => $vendorDir . '/laravel/serializable-closure/src/Contracts/Signer.php', - 'Laravel\\SerializableClosure\\Exceptions\\InvalidSignatureException' => $vendorDir . '/laravel/serializable-closure/src/Exceptions/InvalidSignatureException.php', - 'Laravel\\SerializableClosure\\Exceptions\\MissingSecretKeyException' => $vendorDir . '/laravel/serializable-closure/src/Exceptions/MissingSecretKeyException.php', - 'Laravel\\SerializableClosure\\Exceptions\\PhpVersionNotSupportedException' => $vendorDir . '/laravel/serializable-closure/src/Exceptions/PhpVersionNotSupportedException.php', - 'Laravel\\SerializableClosure\\SerializableClosure' => $vendorDir . '/laravel/serializable-closure/src/SerializableClosure.php', - 'Laravel\\SerializableClosure\\Serializers\\Native' => $vendorDir . '/laravel/serializable-closure/src/Serializers/Native.php', - 'Laravel\\SerializableClosure\\Serializers\\Signed' => $vendorDir . '/laravel/serializable-closure/src/Serializers/Signed.php', - 'Laravel\\SerializableClosure\\Signers\\Hmac' => $vendorDir . '/laravel/serializable-closure/src/Signers/Hmac.php', - 'Laravel\\SerializableClosure\\Support\\ClosureScope' => $vendorDir . '/laravel/serializable-closure/src/Support/ClosureScope.php', - 'Laravel\\SerializableClosure\\Support\\ClosureStream' => $vendorDir . '/laravel/serializable-closure/src/Support/ClosureStream.php', - 'Laravel\\SerializableClosure\\Support\\ReflectionClosure' => $vendorDir . '/laravel/serializable-closure/src/Support/ReflectionClosure.php', - 'Laravel\\SerializableClosure\\Support\\SelfReference' => $vendorDir . '/laravel/serializable-closure/src/Support/SelfReference.php', - 'Laravel\\SerializableClosure\\UnsignedSerializableClosure' => $vendorDir . '/laravel/serializable-closure/src/UnsignedSerializableClosure.php', - 'Monolog\\Attribute\\AsMonologProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Attribute/AsMonologProcessor.php', - 'Monolog\\Attribute\\WithMonologChannel' => $vendorDir . '/monolog/monolog/src/Monolog/Attribute/WithMonologChannel.php', - 'Monolog\\DateTimeImmutable' => $vendorDir . '/monolog/monolog/src/Monolog/DateTimeImmutable.php', - 'Monolog\\ErrorHandler' => $vendorDir . '/monolog/monolog/src/Monolog/ErrorHandler.php', - 'Monolog\\Formatter\\ChromePHPFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/ChromePHPFormatter.php', - 'Monolog\\Formatter\\ElasticaFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/ElasticaFormatter.php', - 'Monolog\\Formatter\\ElasticsearchFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/ElasticsearchFormatter.php', - 'Monolog\\Formatter\\FlowdockFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/FlowdockFormatter.php', - 'Monolog\\Formatter\\FluentdFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/FluentdFormatter.php', - 'Monolog\\Formatter\\FormatterInterface' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/FormatterInterface.php', - 'Monolog\\Formatter\\GelfMessageFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/GelfMessageFormatter.php', - 'Monolog\\Formatter\\GoogleCloudLoggingFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/GoogleCloudLoggingFormatter.php', - 'Monolog\\Formatter\\HtmlFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/HtmlFormatter.php', - 'Monolog\\Formatter\\JsonFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/JsonFormatter.php', - 'Monolog\\Formatter\\LineFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/LineFormatter.php', - 'Monolog\\Formatter\\LogglyFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/LogglyFormatter.php', - 'Monolog\\Formatter\\LogmaticFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/LogmaticFormatter.php', - 'Monolog\\Formatter\\LogstashFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/LogstashFormatter.php', - 'Monolog\\Formatter\\MongoDBFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/MongoDBFormatter.php', - 'Monolog\\Formatter\\NormalizerFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/NormalizerFormatter.php', - 'Monolog\\Formatter\\ScalarFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/ScalarFormatter.php', - 'Monolog\\Formatter\\SyslogFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/SyslogFormatter.php', - 'Monolog\\Formatter\\WildfireFormatter' => $vendorDir . '/monolog/monolog/src/Monolog/Formatter/WildfireFormatter.php', - 'Monolog\\Handler\\AbstractHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/AbstractHandler.php', - 'Monolog\\Handler\\AbstractProcessingHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/AbstractProcessingHandler.php', - 'Monolog\\Handler\\AbstractSyslogHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/AbstractSyslogHandler.php', - 'Monolog\\Handler\\AmqpHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/AmqpHandler.php', - 'Monolog\\Handler\\BrowserConsoleHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/BrowserConsoleHandler.php', - 'Monolog\\Handler\\BufferHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/BufferHandler.php', - 'Monolog\\Handler\\ChromePHPHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/ChromePHPHandler.php', - 'Monolog\\Handler\\CouchDBHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/CouchDBHandler.php', - 'Monolog\\Handler\\CubeHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/CubeHandler.php', - 'Monolog\\Handler\\Curl\\Util' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/Curl/Util.php', - 'Monolog\\Handler\\DeduplicationHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/DeduplicationHandler.php', - 'Monolog\\Handler\\DoctrineCouchDBHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/DoctrineCouchDBHandler.php', - 'Monolog\\Handler\\DynamoDbHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/DynamoDbHandler.php', - 'Monolog\\Handler\\ElasticaHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/ElasticaHandler.php', - 'Monolog\\Handler\\ElasticsearchHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/ElasticsearchHandler.php', - 'Monolog\\Handler\\ErrorLogHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/ErrorLogHandler.php', - 'Monolog\\Handler\\FallbackGroupHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FallbackGroupHandler.php', - 'Monolog\\Handler\\FilterHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FilterHandler.php', - 'Monolog\\Handler\\FingersCrossedHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FingersCrossedHandler.php', - 'Monolog\\Handler\\FingersCrossed\\ActivationStrategyInterface' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FingersCrossed/ActivationStrategyInterface.php', - 'Monolog\\Handler\\FingersCrossed\\ChannelLevelActivationStrategy' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FingersCrossed/ChannelLevelActivationStrategy.php', - 'Monolog\\Handler\\FingersCrossed\\ErrorLevelActivationStrategy' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FingersCrossed/ErrorLevelActivationStrategy.php', - 'Monolog\\Handler\\FirePHPHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FirePHPHandler.php', - 'Monolog\\Handler\\FleepHookHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FleepHookHandler.php', - 'Monolog\\Handler\\FlowdockHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FlowdockHandler.php', - 'Monolog\\Handler\\FormattableHandlerInterface' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FormattableHandlerInterface.php', - 'Monolog\\Handler\\FormattableHandlerTrait' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/FormattableHandlerTrait.php', - 'Monolog\\Handler\\GelfHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/GelfHandler.php', - 'Monolog\\Handler\\GroupHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/GroupHandler.php', - 'Monolog\\Handler\\Handler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/Handler.php', - 'Monolog\\Handler\\HandlerInterface' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/HandlerInterface.php', - 'Monolog\\Handler\\HandlerWrapper' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/HandlerWrapper.php', - 'Monolog\\Handler\\IFTTTHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/IFTTTHandler.php', - 'Monolog\\Handler\\InsightOpsHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/InsightOpsHandler.php', - 'Monolog\\Handler\\LogEntriesHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/LogEntriesHandler.php', - 'Monolog\\Handler\\LogglyHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/LogglyHandler.php', - 'Monolog\\Handler\\LogmaticHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/LogmaticHandler.php', - 'Monolog\\Handler\\MailHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/MailHandler.php', - 'Monolog\\Handler\\MandrillHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/MandrillHandler.php', - 'Monolog\\Handler\\MissingExtensionException' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/MissingExtensionException.php', - 'Monolog\\Handler\\MongoDBHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/MongoDBHandler.php', - 'Monolog\\Handler\\NativeMailerHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/NativeMailerHandler.php', - 'Monolog\\Handler\\NewRelicHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/NewRelicHandler.php', - 'Monolog\\Handler\\NoopHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/NoopHandler.php', - 'Monolog\\Handler\\NullHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/NullHandler.php', - 'Monolog\\Handler\\OverflowHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/OverflowHandler.php', - 'Monolog\\Handler\\PHPConsoleHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/PHPConsoleHandler.php', - 'Monolog\\Handler\\ProcessHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/ProcessHandler.php', - 'Monolog\\Handler\\ProcessableHandlerInterface' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/ProcessableHandlerInterface.php', - 'Monolog\\Handler\\ProcessableHandlerTrait' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/ProcessableHandlerTrait.php', - 'Monolog\\Handler\\PsrHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/PsrHandler.php', - 'Monolog\\Handler\\PushoverHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/PushoverHandler.php', - 'Monolog\\Handler\\RedisHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/RedisHandler.php', - 'Monolog\\Handler\\RedisPubSubHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/RedisPubSubHandler.php', - 'Monolog\\Handler\\RollbarHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/RollbarHandler.php', - 'Monolog\\Handler\\RotatingFileHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/RotatingFileHandler.php', - 'Monolog\\Handler\\SamplingHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SamplingHandler.php', - 'Monolog\\Handler\\SendGridHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SendGridHandler.php', - 'Monolog\\Handler\\SlackHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SlackHandler.php', - 'Monolog\\Handler\\SlackWebhookHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SlackWebhookHandler.php', - 'Monolog\\Handler\\Slack\\SlackRecord' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/Slack/SlackRecord.php', - 'Monolog\\Handler\\SocketHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SocketHandler.php', - 'Monolog\\Handler\\SqsHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SqsHandler.php', - 'Monolog\\Handler\\StreamHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/StreamHandler.php', - 'Monolog\\Handler\\SymfonyMailerHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SymfonyMailerHandler.php', - 'Monolog\\Handler\\SyslogHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SyslogHandler.php', - 'Monolog\\Handler\\SyslogUdpHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SyslogUdpHandler.php', - 'Monolog\\Handler\\SyslogUdp\\UdpSocket' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/SyslogUdp/UdpSocket.php', - 'Monolog\\Handler\\TelegramBotHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/TelegramBotHandler.php', - 'Monolog\\Handler\\TestHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/TestHandler.php', - 'Monolog\\Handler\\WebRequestRecognizerTrait' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/WebRequestRecognizerTrait.php', - 'Monolog\\Handler\\WhatFailureGroupHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/WhatFailureGroupHandler.php', - 'Monolog\\Handler\\ZendMonitorHandler' => $vendorDir . '/monolog/monolog/src/Monolog/Handler/ZendMonitorHandler.php', - 'Monolog\\JsonSerializableDateTimeImmutable' => $vendorDir . '/monolog/monolog/src/Monolog/JsonSerializableDateTimeImmutable.php', - 'Monolog\\Level' => $vendorDir . '/monolog/monolog/src/Monolog/Level.php', - 'Monolog\\LogRecord' => $vendorDir . '/monolog/monolog/src/Monolog/LogRecord.php', - 'Monolog\\Logger' => $vendorDir . '/monolog/monolog/src/Monolog/Logger.php', - 'Monolog\\Processor\\ClosureContextProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/ClosureContextProcessor.php', - 'Monolog\\Processor\\GitProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/GitProcessor.php', - 'Monolog\\Processor\\HostnameProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/HostnameProcessor.php', - 'Monolog\\Processor\\IntrospectionProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/IntrospectionProcessor.php', - 'Monolog\\Processor\\LoadAverageProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/LoadAverageProcessor.php', - 'Monolog\\Processor\\MemoryPeakUsageProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/MemoryPeakUsageProcessor.php', - 'Monolog\\Processor\\MemoryProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/MemoryProcessor.php', - 'Monolog\\Processor\\MemoryUsageProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/MemoryUsageProcessor.php', - 'Monolog\\Processor\\MercurialProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/MercurialProcessor.php', - 'Monolog\\Processor\\ProcessIdProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/ProcessIdProcessor.php', - 'Monolog\\Processor\\ProcessorInterface' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/ProcessorInterface.php', - 'Monolog\\Processor\\PsrLogMessageProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/PsrLogMessageProcessor.php', - 'Monolog\\Processor\\TagProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/TagProcessor.php', - 'Monolog\\Processor\\UidProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/UidProcessor.php', - 'Monolog\\Processor\\WebProcessor' => $vendorDir . '/monolog/monolog/src/Monolog/Processor/WebProcessor.php', - 'Monolog\\Registry' => $vendorDir . '/monolog/monolog/src/Monolog/Registry.php', - 'Monolog\\ResettableInterface' => $vendorDir . '/monolog/monolog/src/Monolog/ResettableInterface.php', - 'Monolog\\SignalHandler' => $vendorDir . '/monolog/monolog/src/Monolog/SignalHandler.php', - 'Monolog\\Test\\MonologTestCase' => $vendorDir . '/monolog/monolog/src/Monolog/Test/MonologTestCase.php', - 'Monolog\\Test\\TestCase' => $vendorDir . '/monolog/monolog/src/Monolog/Test/TestCase.php', - 'Monolog\\Utils' => $vendorDir . '/monolog/monolog/src/Monolog/Utils.php', 'PDF417' => $vendorDir . '/tecnickcom/tcpdf/include/barcodes/pdf417.php', - 'PHPMailer\\PHPMailer\\DSNConfigurator' => $vendorDir . '/phpmailer/phpmailer/src/DSNConfigurator.php', - 'PHPMailer\\PHPMailer\\Exception' => $vendorDir . '/phpmailer/phpmailer/src/Exception.php', - 'PHPMailer\\PHPMailer\\OAuth' => $vendorDir . '/phpmailer/phpmailer/src/OAuth.php', - 'PHPMailer\\PHPMailer\\OAuthTokenProvider' => $vendorDir . '/phpmailer/phpmailer/src/OAuthTokenProvider.php', - 'PHPMailer\\PHPMailer\\PHPMailer' => $vendorDir . '/phpmailer/phpmailer/src/PHPMailer.php', - 'PHPMailer\\PHPMailer\\POP3' => $vendorDir . '/phpmailer/phpmailer/src/POP3.php', - 'PHPMailer\\PHPMailer\\SMTP' => $vendorDir . '/phpmailer/phpmailer/src/SMTP.php', - 'PhpOption\\LazyOption' => $vendorDir . '/phpoption/phpoption/src/PhpOption/LazyOption.php', - 'PhpOption\\None' => $vendorDir . '/phpoption/phpoption/src/PhpOption/None.php', - 'PhpOption\\Option' => $vendorDir . '/phpoption/phpoption/src/PhpOption/Option.php', - 'PhpOption\\Some' => $vendorDir . '/phpoption/phpoption/src/PhpOption/Some.php', 'PhpToken' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/PhpToken.php', - 'Psr\\Container\\ContainerExceptionInterface' => $vendorDir . '/psr/container/src/ContainerExceptionInterface.php', - 'Psr\\Container\\ContainerInterface' => $vendorDir . '/psr/container/src/ContainerInterface.php', - 'Psr\\Container\\NotFoundExceptionInterface' => $vendorDir . '/psr/container/src/NotFoundExceptionInterface.php', - 'Psr\\Http\\Message\\MessageInterface' => $vendorDir . '/psr/http-message/src/MessageInterface.php', - 'Psr\\Http\\Message\\RequestFactoryInterface' => $vendorDir . '/psr/http-factory/src/RequestFactoryInterface.php', - 'Psr\\Http\\Message\\RequestInterface' => $vendorDir . '/psr/http-message/src/RequestInterface.php', - 'Psr\\Http\\Message\\ResponseFactoryInterface' => $vendorDir . '/psr/http-factory/src/ResponseFactoryInterface.php', - 'Psr\\Http\\Message\\ResponseInterface' => $vendorDir . '/psr/http-message/src/ResponseInterface.php', - 'Psr\\Http\\Message\\ServerRequestFactoryInterface' => $vendorDir . '/psr/http-factory/src/ServerRequestFactoryInterface.php', - 'Psr\\Http\\Message\\ServerRequestInterface' => $vendorDir . '/psr/http-message/src/ServerRequestInterface.php', - 'Psr\\Http\\Message\\StreamFactoryInterface' => $vendorDir . '/psr/http-factory/src/StreamFactoryInterface.php', - 'Psr\\Http\\Message\\StreamInterface' => $vendorDir . '/psr/http-message/src/StreamInterface.php', - 'Psr\\Http\\Message\\UploadedFileFactoryInterface' => $vendorDir . '/psr/http-factory/src/UploadedFileFactoryInterface.php', - 'Psr\\Http\\Message\\UploadedFileInterface' => $vendorDir . '/psr/http-message/src/UploadedFileInterface.php', - 'Psr\\Http\\Message\\UriFactoryInterface' => $vendorDir . '/psr/http-factory/src/UriFactoryInterface.php', - 'Psr\\Http\\Message\\UriInterface' => $vendorDir . '/psr/http-message/src/UriInterface.php', - 'Psr\\Http\\Server\\MiddlewareInterface' => $vendorDir . '/psr/http-server-middleware/src/MiddlewareInterface.php', - 'Psr\\Http\\Server\\RequestHandlerInterface' => $vendorDir . '/psr/http-server-handler/src/RequestHandlerInterface.php', - 'Psr\\Log\\AbstractLogger' => $vendorDir . '/psr/log/src/AbstractLogger.php', - 'Psr\\Log\\InvalidArgumentException' => $vendorDir . '/psr/log/src/InvalidArgumentException.php', - 'Psr\\Log\\LogLevel' => $vendorDir . '/psr/log/src/LogLevel.php', - 'Psr\\Log\\LoggerAwareInterface' => $vendorDir . '/psr/log/src/LoggerAwareInterface.php', - 'Psr\\Log\\LoggerAwareTrait' => $vendorDir . '/psr/log/src/LoggerAwareTrait.php', - 'Psr\\Log\\LoggerInterface' => $vendorDir . '/psr/log/src/LoggerInterface.php', - 'Psr\\Log\\LoggerTrait' => $vendorDir . '/psr/log/src/LoggerTrait.php', - 'Psr\\Log\\NullLogger' => $vendorDir . '/psr/log/src/NullLogger.php', 'QRcode' => $vendorDir . '/tecnickcom/tcpdf/include/barcodes/qrcode.php', - 'ReCaptcha\\ReCaptcha' => $vendorDir . '/google/recaptcha/src/ReCaptcha/ReCaptcha.php', - 'ReCaptcha\\RequestMethod' => $vendorDir . '/google/recaptcha/src/ReCaptcha/RequestMethod.php', - 'ReCaptcha\\RequestMethod\\Curl' => $vendorDir . '/google/recaptcha/src/ReCaptcha/RequestMethod/Curl.php', - 'ReCaptcha\\RequestMethod\\CurlPost' => $vendorDir . '/google/recaptcha/src/ReCaptcha/RequestMethod/CurlPost.php', - 'ReCaptcha\\RequestMethod\\Post' => $vendorDir . '/google/recaptcha/src/ReCaptcha/RequestMethod/Post.php', - 'ReCaptcha\\RequestMethod\\Socket' => $vendorDir . '/google/recaptcha/src/ReCaptcha/RequestMethod/Socket.php', - 'ReCaptcha\\RequestMethod\\SocketPost' => $vendorDir . '/google/recaptcha/src/ReCaptcha/RequestMethod/SocketPost.php', - 'ReCaptcha\\RequestParameters' => $vendorDir . '/google/recaptcha/src/ReCaptcha/RequestParameters.php', - 'ReCaptcha\\Response' => $vendorDir . '/google/recaptcha/src/ReCaptcha/Response.php', - 'Slim\\App' => $vendorDir . '/slim/slim/Slim/App.php', - 'Slim\\CallableResolver' => $vendorDir . '/slim/slim/Slim/CallableResolver.php', - 'Slim\\Csrf\\Guard' => $vendorDir . '/slim/csrf/src/Guard.php', - 'Slim\\Error\\AbstractErrorRenderer' => $vendorDir . '/slim/slim/Slim/Error/AbstractErrorRenderer.php', - 'Slim\\Error\\Renderers\\HtmlErrorRenderer' => $vendorDir . '/slim/slim/Slim/Error/Renderers/HtmlErrorRenderer.php', - 'Slim\\Error\\Renderers\\JsonErrorRenderer' => $vendorDir . '/slim/slim/Slim/Error/Renderers/JsonErrorRenderer.php', - 'Slim\\Error\\Renderers\\PlainTextErrorRenderer' => $vendorDir . '/slim/slim/Slim/Error/Renderers/PlainTextErrorRenderer.php', - 'Slim\\Error\\Renderers\\XmlErrorRenderer' => $vendorDir . '/slim/slim/Slim/Error/Renderers/XmlErrorRenderer.php', - 'Slim\\Exception\\HttpBadRequestException' => $vendorDir . '/slim/slim/Slim/Exception/HttpBadRequestException.php', - 'Slim\\Exception\\HttpException' => $vendorDir . '/slim/slim/Slim/Exception/HttpException.php', - 'Slim\\Exception\\HttpForbiddenException' => $vendorDir . '/slim/slim/Slim/Exception/HttpForbiddenException.php', - 'Slim\\Exception\\HttpGoneException' => $vendorDir . '/slim/slim/Slim/Exception/HttpGoneException.php', - 'Slim\\Exception\\HttpInternalServerErrorException' => $vendorDir . '/slim/slim/Slim/Exception/HttpInternalServerErrorException.php', - 'Slim\\Exception\\HttpMethodNotAllowedException' => $vendorDir . '/slim/slim/Slim/Exception/HttpMethodNotAllowedException.php', - 'Slim\\Exception\\HttpNotFoundException' => $vendorDir . '/slim/slim/Slim/Exception/HttpNotFoundException.php', - 'Slim\\Exception\\HttpNotImplementedException' => $vendorDir . '/slim/slim/Slim/Exception/HttpNotImplementedException.php', - 'Slim\\Exception\\HttpSpecializedException' => $vendorDir . '/slim/slim/Slim/Exception/HttpSpecializedException.php', - 'Slim\\Exception\\HttpTooManyRequestsException' => $vendorDir . '/slim/slim/Slim/Exception/HttpTooManyRequestsException.php', - 'Slim\\Exception\\HttpUnauthorizedException' => $vendorDir . '/slim/slim/Slim/Exception/HttpUnauthorizedException.php', - 'Slim\\Factory\\AppFactory' => $vendorDir . '/slim/slim/Slim/Factory/AppFactory.php', - 'Slim\\Factory\\Psr17\\GuzzlePsr17Factory' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/GuzzlePsr17Factory.php', - 'Slim\\Factory\\Psr17\\HttpSoftPsr17Factory' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/HttpSoftPsr17Factory.php', - 'Slim\\Factory\\Psr17\\LaminasDiactorosPsr17Factory' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/LaminasDiactorosPsr17Factory.php', - 'Slim\\Factory\\Psr17\\NyholmPsr17Factory' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/NyholmPsr17Factory.php', - 'Slim\\Factory\\Psr17\\Psr17Factory' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/Psr17Factory.php', - 'Slim\\Factory\\Psr17\\Psr17FactoryProvider' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/Psr17FactoryProvider.php', - 'Slim\\Factory\\Psr17\\ServerRequestCreator' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/ServerRequestCreator.php', - 'Slim\\Factory\\Psr17\\SlimHttpPsr17Factory' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/SlimHttpPsr17Factory.php', - 'Slim\\Factory\\Psr17\\SlimHttpServerRequestCreator' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/SlimHttpServerRequestCreator.php', - 'Slim\\Factory\\Psr17\\SlimPsr17Factory' => $vendorDir . '/slim/slim/Slim/Factory/Psr17/SlimPsr17Factory.php', - 'Slim\\Factory\\ServerRequestCreatorFactory' => $vendorDir . '/slim/slim/Slim/Factory/ServerRequestCreatorFactory.php', - 'Slim\\Handlers\\ErrorHandler' => $vendorDir . '/slim/slim/Slim/Handlers/ErrorHandler.php', - 'Slim\\Handlers\\Strategies\\RequestHandler' => $vendorDir . '/slim/slim/Slim/Handlers/Strategies/RequestHandler.php', - 'Slim\\Handlers\\Strategies\\RequestResponse' => $vendorDir . '/slim/slim/Slim/Handlers/Strategies/RequestResponse.php', - 'Slim\\Handlers\\Strategies\\RequestResponseArgs' => $vendorDir . '/slim/slim/Slim/Handlers/Strategies/RequestResponseArgs.php', - 'Slim\\Handlers\\Strategies\\RequestResponseNamedArgs' => $vendorDir . '/slim/slim/Slim/Handlers/Strategies/RequestResponseNamedArgs.php', - 'Slim\\Interfaces\\AdvancedCallableResolverInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/AdvancedCallableResolverInterface.php', - 'Slim\\Interfaces\\CallableResolverInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/CallableResolverInterface.php', - 'Slim\\Interfaces\\DispatcherInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/DispatcherInterface.php', - 'Slim\\Interfaces\\ErrorHandlerInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/ErrorHandlerInterface.php', - 'Slim\\Interfaces\\ErrorRendererInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/ErrorRendererInterface.php', - 'Slim\\Interfaces\\InvocationStrategyInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/InvocationStrategyInterface.php', - 'Slim\\Interfaces\\MiddlewareDispatcherInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/MiddlewareDispatcherInterface.php', - 'Slim\\Interfaces\\Psr17FactoryInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/Psr17FactoryInterface.php', - 'Slim\\Interfaces\\Psr17FactoryProviderInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/Psr17FactoryProviderInterface.php', - 'Slim\\Interfaces\\RequestHandlerInvocationStrategyInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/RequestHandlerInvocationStrategyInterface.php', - 'Slim\\Interfaces\\RouteCollectorInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/RouteCollectorInterface.php', - 'Slim\\Interfaces\\RouteCollectorProxyInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/RouteCollectorProxyInterface.php', - 'Slim\\Interfaces\\RouteGroupInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/RouteGroupInterface.php', - 'Slim\\Interfaces\\RouteInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/RouteInterface.php', - 'Slim\\Interfaces\\RouteParserInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/RouteParserInterface.php', - 'Slim\\Interfaces\\RouteResolverInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/RouteResolverInterface.php', - 'Slim\\Interfaces\\ServerRequestCreatorInterface' => $vendorDir . '/slim/slim/Slim/Interfaces/ServerRequestCreatorInterface.php', - 'Slim\\Logger' => $vendorDir . '/slim/slim/Slim/Logger.php', - 'Slim\\MiddlewareDispatcher' => $vendorDir . '/slim/slim/Slim/MiddlewareDispatcher.php', - 'Slim\\Middleware\\BodyParsingMiddleware' => $vendorDir . '/slim/slim/Slim/Middleware/BodyParsingMiddleware.php', - 'Slim\\Middleware\\ContentLengthMiddleware' => $vendorDir . '/slim/slim/Slim/Middleware/ContentLengthMiddleware.php', - 'Slim\\Middleware\\ErrorMiddleware' => $vendorDir . '/slim/slim/Slim/Middleware/ErrorMiddleware.php', - 'Slim\\Middleware\\MethodOverrideMiddleware' => $vendorDir . '/slim/slim/Slim/Middleware/MethodOverrideMiddleware.php', - 'Slim\\Middleware\\OutputBufferingMiddleware' => $vendorDir . '/slim/slim/Slim/Middleware/OutputBufferingMiddleware.php', - 'Slim\\Middleware\\RoutingMiddleware' => $vendorDir . '/slim/slim/Slim/Middleware/RoutingMiddleware.php', - 'Slim\\Psr7\\Cookies' => $vendorDir . '/slim/psr7/src/Cookies.php', - 'Slim\\Psr7\\Environment' => $vendorDir . '/slim/psr7/src/Environment.php', - 'Slim\\Psr7\\Factory\\RequestFactory' => $vendorDir . '/slim/psr7/src/Factory/RequestFactory.php', - 'Slim\\Psr7\\Factory\\ResponseFactory' => $vendorDir . '/slim/psr7/src/Factory/ResponseFactory.php', - 'Slim\\Psr7\\Factory\\ServerRequestFactory' => $vendorDir . '/slim/psr7/src/Factory/ServerRequestFactory.php', - 'Slim\\Psr7\\Factory\\StreamFactory' => $vendorDir . '/slim/psr7/src/Factory/StreamFactory.php', - 'Slim\\Psr7\\Factory\\UploadedFileFactory' => $vendorDir . '/slim/psr7/src/Factory/UploadedFileFactory.php', - 'Slim\\Psr7\\Factory\\UriFactory' => $vendorDir . '/slim/psr7/src/Factory/UriFactory.php', - 'Slim\\Psr7\\Header' => $vendorDir . '/slim/psr7/src/Header.php', - 'Slim\\Psr7\\Headers' => $vendorDir . '/slim/psr7/src/Headers.php', - 'Slim\\Psr7\\Interfaces\\HeadersInterface' => $vendorDir . '/slim/psr7/src/Interfaces/HeadersInterface.php', - 'Slim\\Psr7\\Message' => $vendorDir . '/slim/psr7/src/Message.php', - 'Slim\\Psr7\\NonBufferedBody' => $vendorDir . '/slim/psr7/src/NonBufferedBody.php', - 'Slim\\Psr7\\Request' => $vendorDir . '/slim/psr7/src/Request.php', - 'Slim\\Psr7\\Response' => $vendorDir . '/slim/psr7/src/Response.php', - 'Slim\\Psr7\\Stream' => $vendorDir . '/slim/psr7/src/Stream.php', - 'Slim\\Psr7\\UploadedFile' => $vendorDir . '/slim/psr7/src/UploadedFile.php', - 'Slim\\Psr7\\Uri' => $vendorDir . '/slim/psr7/src/Uri.php', - 'Slim\\ResponseEmitter' => $vendorDir . '/slim/slim/Slim/ResponseEmitter.php', - 'Slim\\Routing\\Dispatcher' => $vendorDir . '/slim/slim/Slim/Routing/Dispatcher.php', - 'Slim\\Routing\\FastRouteDispatcher' => $vendorDir . '/slim/slim/Slim/Routing/FastRouteDispatcher.php', - 'Slim\\Routing\\Route' => $vendorDir . '/slim/slim/Slim/Routing/Route.php', - 'Slim\\Routing\\RouteCollector' => $vendorDir . '/slim/slim/Slim/Routing/RouteCollector.php', - 'Slim\\Routing\\RouteCollectorProxy' => $vendorDir . '/slim/slim/Slim/Routing/RouteCollectorProxy.php', - 'Slim\\Routing\\RouteContext' => $vendorDir . '/slim/slim/Slim/Routing/RouteContext.php', - 'Slim\\Routing\\RouteGroup' => $vendorDir . '/slim/slim/Slim/Routing/RouteGroup.php', - 'Slim\\Routing\\RouteParser' => $vendorDir . '/slim/slim/Slim/Routing/RouteParser.php', - 'Slim\\Routing\\RouteResolver' => $vendorDir . '/slim/slim/Slim/Routing/RouteResolver.php', - 'Slim\\Routing\\RouteRunner' => $vendorDir . '/slim/slim/Slim/Routing/RouteRunner.php', - 'Slim\\Routing\\RoutingResults' => $vendorDir . '/slim/slim/Slim/Routing/RoutingResults.php', 'Stringable' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/Stringable.php', - 'Symfony\\Polyfill\\Ctype\\Ctype' => $vendorDir . '/symfony/polyfill-ctype/Ctype.php', - 'Symfony\\Polyfill\\Mbstring\\Mbstring' => $vendorDir . '/symfony/polyfill-mbstring/Mbstring.php', - 'Symfony\\Polyfill\\Php80\\Php80' => $vendorDir . '/symfony/polyfill-php80/Php80.php', - 'Symfony\\Polyfill\\Php80\\PhpToken' => $vendorDir . '/symfony/polyfill-php80/PhpToken.php', 'TCPDF' => $vendorDir . '/tecnickcom/tcpdf/tcpdf.php', 'TCPDF2DBarcode' => $vendorDir . '/tecnickcom/tcpdf/tcpdf_barcodes_2d.php', 'TCPDFBarcode' => $vendorDir . '/tecnickcom/tcpdf/tcpdf_barcodes_1d.php', @@ -564,21 +22,6 @@ 'TCPDF_FONT_DATA' => $vendorDir . '/tecnickcom/tcpdf/include/tcpdf_font_data.php', 'TCPDF_IMAGES' => $vendorDir . '/tecnickcom/tcpdf/include/tcpdf_images.php', 'TCPDF_STATIC' => $vendorDir . '/tecnickcom/tcpdf/include/tcpdf_static.php', - 'Thepixeldeveloper\\Sitemap\\ChunkedCollection' => $vendorDir . '/thepixeldeveloper/sitemap/src/ChunkedCollection.php', - 'Thepixeldeveloper\\Sitemap\\ChunkedUrlset' => $vendorDir . '/thepixeldeveloper/sitemap/src/ChunkedUrlset.php', - 'Thepixeldeveloper\\Sitemap\\Collection' => $vendorDir . '/thepixeldeveloper/sitemap/src/Collection.php', - 'Thepixeldeveloper\\Sitemap\\Drivers\\XmlWriterDriver' => $vendorDir . '/thepixeldeveloper/sitemap/src/Drivers/XmlWriterDriver.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\Image' => $vendorDir . '/thepixeldeveloper/sitemap/src/Extensions/Image.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\Link' => $vendorDir . '/thepixeldeveloper/sitemap/src/Extensions/Link.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\Mobile' => $vendorDir . '/thepixeldeveloper/sitemap/src/Extensions/Mobile.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\News' => $vendorDir . '/thepixeldeveloper/sitemap/src/Extensions/News.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\Video' => $vendorDir . '/thepixeldeveloper/sitemap/src/Extensions/Video.php', - 'Thepixeldeveloper\\Sitemap\\Interfaces\\DriverInterface' => $vendorDir . '/thepixeldeveloper/sitemap/src/Interfaces/DriverInterface.php', - 'Thepixeldeveloper\\Sitemap\\Interfaces\\VisitorInterface' => $vendorDir . '/thepixeldeveloper/sitemap/src/Interfaces/VisitorInterface.php', - 'Thepixeldeveloper\\Sitemap\\Sitemap' => $vendorDir . '/thepixeldeveloper/sitemap/src/Sitemap.php', - 'Thepixeldeveloper\\Sitemap\\SitemapIndex' => $vendorDir . '/thepixeldeveloper/sitemap/src/SitemapIndex.php', - 'Thepixeldeveloper\\Sitemap\\Url' => $vendorDir . '/thepixeldeveloper/sitemap/src/Url.php', - 'Thepixeldeveloper\\Sitemap\\Urlset' => $vendorDir . '/thepixeldeveloper/sitemap/src/Urlset.php', 'UnhandledMatchError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php', 'ValueError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/ValueError.php', ); diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 7ca1db85..ef4949c7 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -181,555 +181,13 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c ); public static $classMap = array ( - 'App\\Controllers\\Admin\\CmsAdminController' => __DIR__ . '/../..' . '/app/Controllers/Admin/CmsAdminController.php', - 'App\\Controllers\\Admin\\LanguagesController' => __DIR__ . '/../..' . '/app/Controllers/Admin/LanguagesController.php', - 'App\\Controllers\\Admin\\MessagesController' => __DIR__ . '/../..' . '/app/Controllers/Admin/MessagesController.php', - 'App\\Controllers\\Admin\\NotificationsController' => __DIR__ . '/../..' . '/app/Controllers/Admin/NotificationsController.php', - 'App\\Controllers\\Admin\\RecensioniAdminController' => __DIR__ . '/../..' . '/app/Controllers/Admin/RecensioniAdminController.php', - 'App\\Controllers\\Admin\\StatsController' => __DIR__ . '/../..' . '/app/Controllers/Admin/StatsController.php', - 'App\\Controllers\\AuthController' => __DIR__ . '/../..' . '/app/Controllers/AuthController.php', - 'App\\Controllers\\AutoriApiController' => __DIR__ . '/../..' . '/app/Controllers/AutoriApiController.php', - 'App\\Controllers\\AutoriController' => __DIR__ . '/../..' . '/app/Controllers/AutoriController.php', - 'App\\Controllers\\CmsController' => __DIR__ . '/../..' . '/app/Controllers/CmsController.php', - 'App\\Controllers\\CollaneController' => __DIR__ . '/../..' . '/app/Controllers/CollaneController.php', - 'App\\Controllers\\CollocazioneController' => __DIR__ . '/../..' . '/app/Controllers/CollocazioneController.php', - 'App\\Controllers\\ContactController' => __DIR__ . '/../..' . '/app/Controllers/ContactController.php', - 'App\\Controllers\\CookiesController' => __DIR__ . '/../..' . '/app/Controllers/CookiesController.php', - 'App\\Controllers\\CopyController' => __DIR__ . '/../..' . '/app/Controllers/CopyController.php', - 'App\\Controllers\\CoverController' => __DIR__ . '/../..' . '/app/Controllers/CoverController.php', - 'App\\Controllers\\CsvImportController' => __DIR__ . '/../..' . '/app/Controllers/CsvImportController.php', - 'App\\Controllers\\DashboardController' => __DIR__ . '/../..' . '/app/Controllers/DashboardController.php', - 'App\\Controllers\\DeweyApiController' => __DIR__ . '/../..' . '/app/Controllers/DeweyApiController.php', - 'App\\Controllers\\EditoriApiController' => __DIR__ . '/../..' . '/app/Controllers/EditoriApiController.php', - 'App\\Controllers\\EditorsController' => __DIR__ . '/../..' . '/app/Controllers/EditorsController.php', - 'App\\Controllers\\EventsController' => __DIR__ . '/../..' . '/app/Controllers/EventsController.php', - 'App\\Controllers\\FeedController' => __DIR__ . '/../..' . '/app/Controllers/FeedController.php', - 'App\\Controllers\\FrontendController' => __DIR__ . '/../..' . '/app/Controllers/FrontendController.php', - 'App\\Controllers\\GeneriApiController' => __DIR__ . '/../..' . '/app/Controllers/GeneriApiController.php', - 'App\\Controllers\\GeneriController' => __DIR__ . '/../..' . '/app/Controllers/GeneriController.php', - 'App\\Controllers\\ImportHistoryController' => __DIR__ . '/../..' . '/app/Controllers/ImportHistoryController.php', - 'App\\Controllers\\LanguageController' => __DIR__ . '/../..' . '/app/Controllers/LanguageController.php', - 'App\\Controllers\\LibraryThingImportController' => __DIR__ . '/../..' . '/app/Controllers/LibraryThingImportController.php', - 'App\\Controllers\\LibriApiController' => __DIR__ . '/../..' . '/app/Controllers/LibriApiController.php', - 'App\\Controllers\\LibriController' => __DIR__ . '/../..' . '/app/Controllers/LibriController.php', - 'App\\Controllers\\LoanApprovalController' => __DIR__ . '/../..' . '/app/Controllers/LoanApprovalController.php', - 'App\\Controllers\\MaintenanceController' => __DIR__ . '/../..' . '/app/Controllers/MaintenanceController.php', - 'App\\Controllers\\PasswordController' => __DIR__ . '/../..' . '/app/Controllers/PasswordController.php', - 'App\\Controllers\\PluginController' => __DIR__ . '/../..' . '/app/Controllers/PluginController.php', - 'App\\Controllers\\PrestitiApiController' => __DIR__ . '/../..' . '/app/Controllers/PrestitiApiController.php', - 'App\\Controllers\\PrestitiController' => __DIR__ . '/../..' . '/app/Controllers/PrestitiController.php', - 'App\\Controllers\\PrivacyController' => __DIR__ . '/../..' . '/app/Controllers/PrivacyController.php', - 'App\\Controllers\\ProfileController' => __DIR__ . '/../..' . '/app/Controllers/ProfileController.php', - 'App\\Controllers\\PublicApiController' => __DIR__ . '/../..' . '/app/Controllers/PublicApiController.php', - 'App\\Controllers\\RecensioniController' => __DIR__ . '/../..' . '/app/Controllers/RecensioniController.php', - 'App\\Controllers\\RegistrationController' => __DIR__ . '/../..' . '/app/Controllers/RegistrationController.php', - 'App\\Controllers\\ReservationManager' => __DIR__ . '/../..' . '/app/Controllers/ReservationManager.php', - 'App\\Controllers\\ReservationsAdminController' => __DIR__ . '/../..' . '/app/Controllers/ReservationsAdminController.php', - 'App\\Controllers\\ReservationsController' => __DIR__ . '/../..' . '/app/Controllers/ReservationsController.php', - 'App\\Controllers\\ScrapeController' => __DIR__ . '/../..' . '/app/Controllers/ScrapeController.php', - 'App\\Controllers\\SearchController' => __DIR__ . '/../..' . '/app/Controllers/SearchController.php', - 'App\\Controllers\\SecurityLogsController' => __DIR__ . '/../..' . '/app/Controllers/SecurityLogsController.php', - 'App\\Controllers\\SeoController' => __DIR__ . '/../..' . '/app/Controllers/SeoController.php', - 'App\\Controllers\\SettingsController' => __DIR__ . '/../..' . '/app/Controllers/SettingsController.php', - 'App\\Controllers\\ThemeController' => __DIR__ . '/../..' . '/app/Controllers/ThemeController.php', - 'App\\Controllers\\UpdateController' => __DIR__ . '/../..' . '/app/Controllers/UpdateController.php', - 'App\\Controllers\\UserActionsController' => __DIR__ . '/../..' . '/app/Controllers/UserActionsController.php', - 'App\\Controllers\\UserDashboardController' => __DIR__ . '/../..' . '/app/Controllers/UserDashboardController.php', - 'App\\Controllers\\UserWishlistController' => __DIR__ . '/../..' . '/app/Controllers/UserWishlistController.php', - 'App\\Controllers\\UsersController' => __DIR__ . '/../..' . '/app/Controllers/UsersController.php', - 'App\\Controllers\\UtentiApiController' => __DIR__ . '/../..' . '/app/Controllers/UtentiApiController.php', - 'App\\Middleware\\AdminAuthMiddleware' => __DIR__ . '/../..' . '/app/Middleware/AdminAuthMiddleware.php', - 'App\\Middleware\\ApiKeyMiddleware' => __DIR__ . '/../..' . '/app/Middleware/ApiKeyMiddleware.php', - 'App\\Middleware\\AuthMiddleware' => __DIR__ . '/../..' . '/app/Middleware/AuthMiddleware.php', - 'App\\Middleware\\BasePathMiddleware' => __DIR__ . '/../..' . '/app/Middleware/BasePathMiddleware.php', - 'App\\Middleware\\CsrfMiddleware' => __DIR__ . '/../..' . '/app/Middleware/CsrfMiddleware.php', - 'App\\Middleware\\RateLimitMiddleware' => __DIR__ . '/../..' . '/app/Middleware/RateLimitMiddleware.php', - 'App\\Middleware\\RememberMeMiddleware' => __DIR__ . '/../..' . '/app/Middleware/RememberMeMiddleware.php', - 'App\\Models\\ApiKeyRepository' => __DIR__ . '/../..' . '/app/Models/ApiKeyRepository.php', - 'App\\Models\\AuthorRepository' => __DIR__ . '/../..' . '/app/Models/AuthorRepository.php', - 'App\\Models\\BookRepository' => __DIR__ . '/../..' . '/app/Models/BookRepository.php', - 'App\\Models\\CollocationRepository' => __DIR__ . '/../..' . '/app/Models/CollocationRepository.php', - 'App\\Models\\CopyRepository' => __DIR__ . '/../..' . '/app/Models/CopyRepository.php', - 'App\\Models\\DashboardStats' => __DIR__ . '/../..' . '/app/Models/DashboardStats.php', - 'App\\Models\\GenereRepository' => __DIR__ . '/../..' . '/app/Models/GenereRepository.php', - 'App\\Models\\Language' => __DIR__ . '/../..' . '/app/Models/Language.php', - 'App\\Models\\LoanRepository' => __DIR__ . '/../..' . '/app/Models/LoanRepository.php', - 'App\\Models\\PublisherRepository' => __DIR__ . '/../..' . '/app/Models/PublisherRepository.php', - 'App\\Models\\SettingsRepository' => __DIR__ . '/../..' . '/app/Models/SettingsRepository.php', - 'App\\Models\\TaxonomyRepository' => __DIR__ . '/../..' . '/app/Models/TaxonomyRepository.php', - 'App\\Models\\UserRepository' => __DIR__ . '/../..' . '/app/Models/UserRepository.php', - 'App\\Repositories\\RecensioniRepository' => __DIR__ . '/../..' . '/app/Repositories/RecensioniRepository.php', - 'App\\Services\\ReservationReassignmentService' => __DIR__ . '/../..' . '/app/Services/ReservationReassignmentService.php', - 'App\\Support\\AuthorNormalizer' => __DIR__ . '/../..' . '/app/Support/AuthorNormalizer.php', - 'App\\Support\\AuthorizationHelper' => __DIR__ . '/../..' . '/app/Support/AuthorizationHelper.php', - 'App\\Support\\BookDataMerger' => __DIR__ . '/../..' . '/app/Support/BookDataMerger.php', - 'App\\Support\\Branding' => __DIR__ . '/../..' . '/app/Support/Branding.php', - 'App\\Support\\CmsHelper' => __DIR__ . '/../..' . '/app/Support/CmsHelper.php', - 'App\\Support\\ConfigStore' => __DIR__ . '/../..' . '/app/Support/ConfigStore.php', - 'App\\Support\\ContentSanitizer' => __DIR__ . '/../..' . '/app/Support/ContentSanitizer.php', - 'App\\Support\\Csrf' => __DIR__ . '/../..' . '/app/Support/Csrf.php', - 'App\\Support\\CsrfHelper' => __DIR__ . '/../..' . '/app/Support/CsrfHelper.php', - 'App\\Support\\DataIntegrity' => __DIR__ . '/../..' . '/app/Support/DataIntegrity.php', - 'App\\Support\\DateHelper' => __DIR__ . '/../..' . '/app/Support/DateHelper.php', - 'App\\Support\\DeweyAutoPopulator' => __DIR__ . '/../..' . '/app/Support/DeweyAutoPopulator.php', - 'App\\Support\\EmailService' => __DIR__ . '/../..' . '/app/Support/EmailService.php', - 'App\\Support\\GenreHelper' => __DIR__ . '/../..' . '/app/Support/GenreHelper.php', - 'App\\Support\\HookManager' => __DIR__ . '/../..' . '/app/Support/HookManager.php', - 'App\\Support\\Hooks' => __DIR__ . '/../..' . '/app/Support/Hooks.php', - 'App\\Support\\HreflangHelper' => __DIR__ . '/../..' . '/app/Support/HreflangHelper.php', - 'App\\Support\\HtmlHelper' => __DIR__ . '/../..' . '/app/Support/HtmlHelper.php', - 'App\\Support\\I18n' => __DIR__ . '/../..' . '/app/Support/I18n.php', - 'App\\Support\\IcsGenerator' => __DIR__ . '/../..' . '/app/Support/IcsGenerator.php', - 'App\\Support\\ImportLogger' => __DIR__ . '/../..' . '/app/Support/ImportLogger.php', - 'App\\Support\\InputValidator' => __DIR__ . '/../..' . '/app/Support/InputValidator.php', - 'App\\Support\\IsbnFormatter' => __DIR__ . '/../..' . '/app/Support/IsbnFormatter.php', - 'App\\Support\\LibraryThingInstaller' => __DIR__ . '/../..' . '/app/Support/LibraryThingInstaller.php', - 'App\\Support\\LoanPdfGenerator' => __DIR__ . '/../..' . '/app/Support/LoanPdfGenerator.php', - 'App\\Support\\Log' => __DIR__ . '/../..' . '/app/Support/Log.php', - 'App\\Support\\Mailer' => __DIR__ . '/../..' . '/app/Support/Mailer.php', - 'App\\Support\\MaintenanceService' => __DIR__ . '/../..' . '/app/Support/MaintenanceService.php', - 'App\\Support\\MergeHelper' => __DIR__ . '/../..' . '/app/Support/MergeHelper.php', - 'App\\Support\\NotificationService' => __DIR__ . '/../..' . '/app/Support/NotificationService.php', - 'App\\Support\\PluginManager' => __DIR__ . '/../..' . '/app/Support/PluginManager.php', - 'App\\Support\\QueryCache' => __DIR__ . '/../..' . '/app/Support/QueryCache.php', - 'App\\Support\\RateLimiter' => __DIR__ . '/../..' . '/app/Support/RateLimiter.php', - 'App\\Support\\RememberMeService' => __DIR__ . '/../..' . '/app/Support/RememberMeService.php', - 'App\\Support\\RouteTranslator' => __DIR__ . '/../..' . '/app/Support/RouteTranslator.php', - 'App\\Support\\ScrapingService' => __DIR__ . '/../..' . '/app/Support/ScrapingService.php', - 'App\\Support\\SecureLogger' => __DIR__ . '/../..' . '/app/Support/SecureLogger.php', - 'App\\Support\\SettingsEncryption' => __DIR__ . '/../..' . '/app/Support/SettingsEncryption.php', - 'App\\Support\\SettingsMailTemplates' => __DIR__ . '/../..' . '/app/Support/SettingsMailTemplates.php', - 'App\\Support\\SharingProviders' => __DIR__ . '/../..' . '/app/Support/SharingProviders.php', - 'App\\Support\\SitemapGenerator' => __DIR__ . '/../..' . '/app/Support/SitemapGenerator.php', - 'App\\Support\\ThemeColorizer' => __DIR__ . '/../..' . '/app/Support/ThemeColorizer.php', - 'App\\Support\\ThemeManager' => __DIR__ . '/../..' . '/app/Support/ThemeManager.php', - 'App\\Support\\Updater' => __DIR__ . '/../..' . '/app/Support/Updater.php', 'Attribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php', 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', - 'DI\\Attribute\\Inject' => __DIR__ . '/..' . '/php-di/php-di/src/Attribute/Inject.php', - 'DI\\Attribute\\Injectable' => __DIR__ . '/..' . '/php-di/php-di/src/Attribute/Injectable.php', - 'DI\\CompiledContainer' => __DIR__ . '/..' . '/php-di/php-di/src/CompiledContainer.php', - 'DI\\Compiler\\Compiler' => __DIR__ . '/..' . '/php-di/php-di/src/Compiler/Compiler.php', - 'DI\\Compiler\\ObjectCreationCompiler' => __DIR__ . '/..' . '/php-di/php-di/src/Compiler/ObjectCreationCompiler.php', - 'DI\\Compiler\\RequestedEntryHolder' => __DIR__ . '/..' . '/php-di/php-di/src/Compiler/RequestedEntryHolder.php', - 'DI\\Container' => __DIR__ . '/..' . '/php-di/php-di/src/Container.php', - 'DI\\ContainerBuilder' => __DIR__ . '/..' . '/php-di/php-di/src/ContainerBuilder.php', - 'DI\\Definition\\ArrayDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/ArrayDefinition.php', - 'DI\\Definition\\ArrayDefinitionExtension' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/ArrayDefinitionExtension.php', - 'DI\\Definition\\AutowireDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/AutowireDefinition.php', - 'DI\\Definition\\DecoratorDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/DecoratorDefinition.php', - 'DI\\Definition\\Definition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Definition.php', - 'DI\\Definition\\Dumper\\ObjectDefinitionDumper' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Dumper/ObjectDefinitionDumper.php', - 'DI\\Definition\\EnvironmentVariableDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/EnvironmentVariableDefinition.php', - 'DI\\Definition\\Exception\\InvalidAttribute' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Exception/InvalidAttribute.php', - 'DI\\Definition\\Exception\\InvalidDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Exception/InvalidDefinition.php', - 'DI\\Definition\\ExtendsPreviousDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/ExtendsPreviousDefinition.php', - 'DI\\Definition\\FactoryDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/FactoryDefinition.php', - 'DI\\Definition\\Helper\\AutowireDefinitionHelper' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Helper/AutowireDefinitionHelper.php', - 'DI\\Definition\\Helper\\CreateDefinitionHelper' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Helper/CreateDefinitionHelper.php', - 'DI\\Definition\\Helper\\DefinitionHelper' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Helper/DefinitionHelper.php', - 'DI\\Definition\\Helper\\FactoryDefinitionHelper' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Helper/FactoryDefinitionHelper.php', - 'DI\\Definition\\InstanceDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/InstanceDefinition.php', - 'DI\\Definition\\ObjectDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/ObjectDefinition.php', - 'DI\\Definition\\ObjectDefinition\\MethodInjection' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/ObjectDefinition/MethodInjection.php', - 'DI\\Definition\\ObjectDefinition\\PropertyInjection' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/ObjectDefinition/PropertyInjection.php', - 'DI\\Definition\\Reference' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Reference.php', - 'DI\\Definition\\Resolver\\ArrayResolver' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Resolver/ArrayResolver.php', - 'DI\\Definition\\Resolver\\DecoratorResolver' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Resolver/DecoratorResolver.php', - 'DI\\Definition\\Resolver\\DefinitionResolver' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Resolver/DefinitionResolver.php', - 'DI\\Definition\\Resolver\\EnvironmentVariableResolver' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Resolver/EnvironmentVariableResolver.php', - 'DI\\Definition\\Resolver\\FactoryResolver' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Resolver/FactoryResolver.php', - 'DI\\Definition\\Resolver\\InstanceInjector' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Resolver/InstanceInjector.php', - 'DI\\Definition\\Resolver\\ObjectCreator' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Resolver/ObjectCreator.php', - 'DI\\Definition\\Resolver\\ParameterResolver' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Resolver/ParameterResolver.php', - 'DI\\Definition\\Resolver\\ResolverDispatcher' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Resolver/ResolverDispatcher.php', - 'DI\\Definition\\SelfResolvingDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/SelfResolvingDefinition.php', - 'DI\\Definition\\Source\\AttributeBasedAutowiring' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/AttributeBasedAutowiring.php', - 'DI\\Definition\\Source\\Autowiring' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/Autowiring.php', - 'DI\\Definition\\Source\\DefinitionArray' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/DefinitionArray.php', - 'DI\\Definition\\Source\\DefinitionFile' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/DefinitionFile.php', - 'DI\\Definition\\Source\\DefinitionNormalizer' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/DefinitionNormalizer.php', - 'DI\\Definition\\Source\\DefinitionSource' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/DefinitionSource.php', - 'DI\\Definition\\Source\\MutableDefinitionSource' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/MutableDefinitionSource.php', - 'DI\\Definition\\Source\\NoAutowiring' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/NoAutowiring.php', - 'DI\\Definition\\Source\\ReflectionBasedAutowiring' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/ReflectionBasedAutowiring.php', - 'DI\\Definition\\Source\\SourceCache' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/SourceCache.php', - 'DI\\Definition\\Source\\SourceChain' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/Source/SourceChain.php', - 'DI\\Definition\\StringDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/StringDefinition.php', - 'DI\\Definition\\ValueDefinition' => __DIR__ . '/..' . '/php-di/php-di/src/Definition/ValueDefinition.php', - 'DI\\DependencyException' => __DIR__ . '/..' . '/php-di/php-di/src/DependencyException.php', - 'DI\\FactoryInterface' => __DIR__ . '/..' . '/php-di/php-di/src/FactoryInterface.php', - 'DI\\Factory\\RequestedEntry' => __DIR__ . '/..' . '/php-di/php-di/src/Factory/RequestedEntry.php', - 'DI\\Invoker\\DefinitionParameterResolver' => __DIR__ . '/..' . '/php-di/php-di/src/Invoker/DefinitionParameterResolver.php', - 'DI\\Invoker\\FactoryParameterResolver' => __DIR__ . '/..' . '/php-di/php-di/src/Invoker/FactoryParameterResolver.php', - 'DI\\NotFoundException' => __DIR__ . '/..' . '/php-di/php-di/src/NotFoundException.php', - 'DI\\Proxy\\NativeProxyFactory' => __DIR__ . '/..' . '/php-di/php-di/src/Proxy/NativeProxyFactory.php', - 'DI\\Proxy\\ProxyFactory' => __DIR__ . '/..' . '/php-di/php-di/src/Proxy/ProxyFactory.php', - 'DI\\Proxy\\ProxyFactoryInterface' => __DIR__ . '/..' . '/php-di/php-di/src/Proxy/ProxyFactoryInterface.php', 'Datamatrix' => __DIR__ . '/..' . '/tecnickcom/tcpdf/include/barcodes/datamatrix.php', - 'Dotenv\\Dotenv' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Dotenv.php', - 'Dotenv\\Exception\\ExceptionInterface' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Exception/ExceptionInterface.php', - 'Dotenv\\Exception\\InvalidEncodingException' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Exception/InvalidEncodingException.php', - 'Dotenv\\Exception\\InvalidFileException' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Exception/InvalidFileException.php', - 'Dotenv\\Exception\\InvalidPathException' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Exception/InvalidPathException.php', - 'Dotenv\\Exception\\ValidationException' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Exception/ValidationException.php', - 'Dotenv\\Loader\\Loader' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Loader/Loader.php', - 'Dotenv\\Loader\\LoaderInterface' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Loader/LoaderInterface.php', - 'Dotenv\\Loader\\Resolver' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Loader/Resolver.php', - 'Dotenv\\Parser\\Entry' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Parser/Entry.php', - 'Dotenv\\Parser\\EntryParser' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Parser/EntryParser.php', - 'Dotenv\\Parser\\Lexer' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Parser/Lexer.php', - 'Dotenv\\Parser\\Lines' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Parser/Lines.php', - 'Dotenv\\Parser\\Parser' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Parser/Parser.php', - 'Dotenv\\Parser\\ParserInterface' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Parser/ParserInterface.php', - 'Dotenv\\Parser\\Value' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Parser/Value.php', - 'Dotenv\\Repository\\AdapterRepository' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/AdapterRepository.php', - 'Dotenv\\Repository\\Adapter\\AdapterInterface' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/AdapterInterface.php', - 'Dotenv\\Repository\\Adapter\\ApacheAdapter' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/ApacheAdapter.php', - 'Dotenv\\Repository\\Adapter\\ArrayAdapter' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/ArrayAdapter.php', - 'Dotenv\\Repository\\Adapter\\EnvConstAdapter' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/EnvConstAdapter.php', - 'Dotenv\\Repository\\Adapter\\GuardedWriter' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/GuardedWriter.php', - 'Dotenv\\Repository\\Adapter\\ImmutableWriter' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/ImmutableWriter.php', - 'Dotenv\\Repository\\Adapter\\MultiReader' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/MultiReader.php', - 'Dotenv\\Repository\\Adapter\\MultiWriter' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/MultiWriter.php', - 'Dotenv\\Repository\\Adapter\\PutenvAdapter' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/PutenvAdapter.php', - 'Dotenv\\Repository\\Adapter\\ReaderInterface' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/ReaderInterface.php', - 'Dotenv\\Repository\\Adapter\\ReplacingWriter' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/ReplacingWriter.php', - 'Dotenv\\Repository\\Adapter\\ServerConstAdapter' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/ServerConstAdapter.php', - 'Dotenv\\Repository\\Adapter\\WriterInterface' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/Adapter/WriterInterface.php', - 'Dotenv\\Repository\\RepositoryBuilder' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/RepositoryBuilder.php', - 'Dotenv\\Repository\\RepositoryInterface' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Repository/RepositoryInterface.php', - 'Dotenv\\Store\\FileStore' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Store/FileStore.php', - 'Dotenv\\Store\\File\\Paths' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Store/File/Paths.php', - 'Dotenv\\Store\\File\\Reader' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Store/File/Reader.php', - 'Dotenv\\Store\\StoreBuilder' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Store/StoreBuilder.php', - 'Dotenv\\Store\\StoreInterface' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Store/StoreInterface.php', - 'Dotenv\\Store\\StringStore' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Store/StringStore.php', - 'Dotenv\\Util\\Regex' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Util/Regex.php', - 'Dotenv\\Util\\Str' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Util/Str.php', - 'Dotenv\\Validator' => __DIR__ . '/..' . '/vlucas/phpdotenv/src/Validator.php', - 'Emleons\\SimRating\\Interfaces\\RendererInterface' => __DIR__ . '/..' . '/emleons/sim-rating/src/Interfaces/RendererInterface.php', - 'Emleons\\SimRating\\Rating' => __DIR__ . '/..' . '/emleons/sim-rating/src/Rating.php', - 'Emleons\\SimRating\\Renderer\\HtmlRenderer' => __DIR__ . '/..' . '/emleons/sim-rating/src/Renderer/HtmlRenderer.php', - 'Emleons\\SimRating\\Renderer\\JsonRenderer' => __DIR__ . '/..' . '/emleons/sim-rating/src/Renderer/JsonRenderer.php', - 'Emleons\\SimRating\\Renderer\\SvgRenderer' => __DIR__ . '/..' . '/emleons/sim-rating/src/Renderer/SvgRenderer.php', - 'FastRoute\\BadRouteException' => __DIR__ . '/..' . '/nikic/fast-route/src/BadRouteException.php', - 'FastRoute\\DataGenerator' => __DIR__ . '/..' . '/nikic/fast-route/src/DataGenerator.php', - 'FastRoute\\DataGenerator\\CharCountBased' => __DIR__ . '/..' . '/nikic/fast-route/src/DataGenerator/CharCountBased.php', - 'FastRoute\\DataGenerator\\GroupCountBased' => __DIR__ . '/..' . '/nikic/fast-route/src/DataGenerator/GroupCountBased.php', - 'FastRoute\\DataGenerator\\GroupPosBased' => __DIR__ . '/..' . '/nikic/fast-route/src/DataGenerator/GroupPosBased.php', - 'FastRoute\\DataGenerator\\MarkBased' => __DIR__ . '/..' . '/nikic/fast-route/src/DataGenerator/MarkBased.php', - 'FastRoute\\DataGenerator\\RegexBasedAbstract' => __DIR__ . '/..' . '/nikic/fast-route/src/DataGenerator/RegexBasedAbstract.php', - 'FastRoute\\Dispatcher' => __DIR__ . '/..' . '/nikic/fast-route/src/Dispatcher.php', - 'FastRoute\\Dispatcher\\CharCountBased' => __DIR__ . '/..' . '/nikic/fast-route/src/Dispatcher/CharCountBased.php', - 'FastRoute\\Dispatcher\\GroupCountBased' => __DIR__ . '/..' . '/nikic/fast-route/src/Dispatcher/GroupCountBased.php', - 'FastRoute\\Dispatcher\\GroupPosBased' => __DIR__ . '/..' . '/nikic/fast-route/src/Dispatcher/GroupPosBased.php', - 'FastRoute\\Dispatcher\\MarkBased' => __DIR__ . '/..' . '/nikic/fast-route/src/Dispatcher/MarkBased.php', - 'FastRoute\\Dispatcher\\RegexBasedAbstract' => __DIR__ . '/..' . '/nikic/fast-route/src/Dispatcher/RegexBasedAbstract.php', - 'FastRoute\\Route' => __DIR__ . '/..' . '/nikic/fast-route/src/Route.php', - 'FastRoute\\RouteCollector' => __DIR__ . '/..' . '/nikic/fast-route/src/RouteCollector.php', - 'FastRoute\\RouteParser' => __DIR__ . '/..' . '/nikic/fast-route/src/RouteParser.php', - 'FastRoute\\RouteParser\\Std' => __DIR__ . '/..' . '/nikic/fast-route/src/RouteParser/Std.php', - 'Fig\\Http\\Message\\RequestMethodInterface' => __DIR__ . '/..' . '/fig/http-message-util/src/RequestMethodInterface.php', - 'Fig\\Http\\Message\\StatusCodeInterface' => __DIR__ . '/..' . '/fig/http-message-util/src/StatusCodeInterface.php', - 'GrahamCampbell\\ResultType\\Error' => __DIR__ . '/..' . '/graham-campbell/result-type/src/Error.php', - 'GrahamCampbell\\ResultType\\Result' => __DIR__ . '/..' . '/graham-campbell/result-type/src/Result.php', - 'GrahamCampbell\\ResultType\\Success' => __DIR__ . '/..' . '/graham-campbell/result-type/src/Success.php', - 'Invoker\\CallableResolver' => __DIR__ . '/..' . '/php-di/invoker/src/CallableResolver.php', - 'Invoker\\Exception\\InvocationException' => __DIR__ . '/..' . '/php-di/invoker/src/Exception/InvocationException.php', - 'Invoker\\Exception\\NotCallableException' => __DIR__ . '/..' . '/php-di/invoker/src/Exception/NotCallableException.php', - 'Invoker\\Exception\\NotEnoughParametersException' => __DIR__ . '/..' . '/php-di/invoker/src/Exception/NotEnoughParametersException.php', - 'Invoker\\Invoker' => __DIR__ . '/..' . '/php-di/invoker/src/Invoker.php', - 'Invoker\\InvokerInterface' => __DIR__ . '/..' . '/php-di/invoker/src/InvokerInterface.php', - 'Invoker\\ParameterResolver\\AssociativeArrayResolver' => __DIR__ . '/..' . '/php-di/invoker/src/ParameterResolver/AssociativeArrayResolver.php', - 'Invoker\\ParameterResolver\\Container\\ParameterNameContainerResolver' => __DIR__ . '/..' . '/php-di/invoker/src/ParameterResolver/Container/ParameterNameContainerResolver.php', - 'Invoker\\ParameterResolver\\Container\\TypeHintContainerResolver' => __DIR__ . '/..' . '/php-di/invoker/src/ParameterResolver/Container/TypeHintContainerResolver.php', - 'Invoker\\ParameterResolver\\DefaultValueResolver' => __DIR__ . '/..' . '/php-di/invoker/src/ParameterResolver/DefaultValueResolver.php', - 'Invoker\\ParameterResolver\\NumericArrayResolver' => __DIR__ . '/..' . '/php-di/invoker/src/ParameterResolver/NumericArrayResolver.php', - 'Invoker\\ParameterResolver\\ParameterResolver' => __DIR__ . '/..' . '/php-di/invoker/src/ParameterResolver/ParameterResolver.php', - 'Invoker\\ParameterResolver\\ResolverChain' => __DIR__ . '/..' . '/php-di/invoker/src/ParameterResolver/ResolverChain.php', - 'Invoker\\ParameterResolver\\TypeHintResolver' => __DIR__ . '/..' . '/php-di/invoker/src/ParameterResolver/TypeHintResolver.php', - 'Invoker\\Reflection\\CallableReflection' => __DIR__ . '/..' . '/php-di/invoker/src/Reflection/CallableReflection.php', - 'Laravel\\SerializableClosure\\Contracts\\Serializable' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Contracts/Serializable.php', - 'Laravel\\SerializableClosure\\Contracts\\Signer' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Contracts/Signer.php', - 'Laravel\\SerializableClosure\\Exceptions\\InvalidSignatureException' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Exceptions/InvalidSignatureException.php', - 'Laravel\\SerializableClosure\\Exceptions\\MissingSecretKeyException' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Exceptions/MissingSecretKeyException.php', - 'Laravel\\SerializableClosure\\Exceptions\\PhpVersionNotSupportedException' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Exceptions/PhpVersionNotSupportedException.php', - 'Laravel\\SerializableClosure\\SerializableClosure' => __DIR__ . '/..' . '/laravel/serializable-closure/src/SerializableClosure.php', - 'Laravel\\SerializableClosure\\Serializers\\Native' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Serializers/Native.php', - 'Laravel\\SerializableClosure\\Serializers\\Signed' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Serializers/Signed.php', - 'Laravel\\SerializableClosure\\Signers\\Hmac' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Signers/Hmac.php', - 'Laravel\\SerializableClosure\\Support\\ClosureScope' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Support/ClosureScope.php', - 'Laravel\\SerializableClosure\\Support\\ClosureStream' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Support/ClosureStream.php', - 'Laravel\\SerializableClosure\\Support\\ReflectionClosure' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Support/ReflectionClosure.php', - 'Laravel\\SerializableClosure\\Support\\SelfReference' => __DIR__ . '/..' . '/laravel/serializable-closure/src/Support/SelfReference.php', - 'Laravel\\SerializableClosure\\UnsignedSerializableClosure' => __DIR__ . '/..' . '/laravel/serializable-closure/src/UnsignedSerializableClosure.php', - 'Monolog\\Attribute\\AsMonologProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Attribute/AsMonologProcessor.php', - 'Monolog\\Attribute\\WithMonologChannel' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Attribute/WithMonologChannel.php', - 'Monolog\\DateTimeImmutable' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/DateTimeImmutable.php', - 'Monolog\\ErrorHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/ErrorHandler.php', - 'Monolog\\Formatter\\ChromePHPFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/ChromePHPFormatter.php', - 'Monolog\\Formatter\\ElasticaFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/ElasticaFormatter.php', - 'Monolog\\Formatter\\ElasticsearchFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/ElasticsearchFormatter.php', - 'Monolog\\Formatter\\FlowdockFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/FlowdockFormatter.php', - 'Monolog\\Formatter\\FluentdFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/FluentdFormatter.php', - 'Monolog\\Formatter\\FormatterInterface' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/FormatterInterface.php', - 'Monolog\\Formatter\\GelfMessageFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/GelfMessageFormatter.php', - 'Monolog\\Formatter\\GoogleCloudLoggingFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/GoogleCloudLoggingFormatter.php', - 'Monolog\\Formatter\\HtmlFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/HtmlFormatter.php', - 'Monolog\\Formatter\\JsonFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/JsonFormatter.php', - 'Monolog\\Formatter\\LineFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/LineFormatter.php', - 'Monolog\\Formatter\\LogglyFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/LogglyFormatter.php', - 'Monolog\\Formatter\\LogmaticFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/LogmaticFormatter.php', - 'Monolog\\Formatter\\LogstashFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/LogstashFormatter.php', - 'Monolog\\Formatter\\MongoDBFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/MongoDBFormatter.php', - 'Monolog\\Formatter\\NormalizerFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/NormalizerFormatter.php', - 'Monolog\\Formatter\\ScalarFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/ScalarFormatter.php', - 'Monolog\\Formatter\\SyslogFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/SyslogFormatter.php', - 'Monolog\\Formatter\\WildfireFormatter' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Formatter/WildfireFormatter.php', - 'Monolog\\Handler\\AbstractHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/AbstractHandler.php', - 'Monolog\\Handler\\AbstractProcessingHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/AbstractProcessingHandler.php', - 'Monolog\\Handler\\AbstractSyslogHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/AbstractSyslogHandler.php', - 'Monolog\\Handler\\AmqpHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/AmqpHandler.php', - 'Monolog\\Handler\\BrowserConsoleHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/BrowserConsoleHandler.php', - 'Monolog\\Handler\\BufferHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/BufferHandler.php', - 'Monolog\\Handler\\ChromePHPHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/ChromePHPHandler.php', - 'Monolog\\Handler\\CouchDBHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/CouchDBHandler.php', - 'Monolog\\Handler\\CubeHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/CubeHandler.php', - 'Monolog\\Handler\\Curl\\Util' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/Curl/Util.php', - 'Monolog\\Handler\\DeduplicationHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/DeduplicationHandler.php', - 'Monolog\\Handler\\DoctrineCouchDBHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/DoctrineCouchDBHandler.php', - 'Monolog\\Handler\\DynamoDbHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/DynamoDbHandler.php', - 'Monolog\\Handler\\ElasticaHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/ElasticaHandler.php', - 'Monolog\\Handler\\ElasticsearchHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/ElasticsearchHandler.php', - 'Monolog\\Handler\\ErrorLogHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/ErrorLogHandler.php', - 'Monolog\\Handler\\FallbackGroupHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FallbackGroupHandler.php', - 'Monolog\\Handler\\FilterHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FilterHandler.php', - 'Monolog\\Handler\\FingersCrossedHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FingersCrossedHandler.php', - 'Monolog\\Handler\\FingersCrossed\\ActivationStrategyInterface' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FingersCrossed/ActivationStrategyInterface.php', - 'Monolog\\Handler\\FingersCrossed\\ChannelLevelActivationStrategy' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FingersCrossed/ChannelLevelActivationStrategy.php', - 'Monolog\\Handler\\FingersCrossed\\ErrorLevelActivationStrategy' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FingersCrossed/ErrorLevelActivationStrategy.php', - 'Monolog\\Handler\\FirePHPHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FirePHPHandler.php', - 'Monolog\\Handler\\FleepHookHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FleepHookHandler.php', - 'Monolog\\Handler\\FlowdockHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FlowdockHandler.php', - 'Monolog\\Handler\\FormattableHandlerInterface' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FormattableHandlerInterface.php', - 'Monolog\\Handler\\FormattableHandlerTrait' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/FormattableHandlerTrait.php', - 'Monolog\\Handler\\GelfHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/GelfHandler.php', - 'Monolog\\Handler\\GroupHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/GroupHandler.php', - 'Monolog\\Handler\\Handler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/Handler.php', - 'Monolog\\Handler\\HandlerInterface' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/HandlerInterface.php', - 'Monolog\\Handler\\HandlerWrapper' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/HandlerWrapper.php', - 'Monolog\\Handler\\IFTTTHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/IFTTTHandler.php', - 'Monolog\\Handler\\InsightOpsHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/InsightOpsHandler.php', - 'Monolog\\Handler\\LogEntriesHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/LogEntriesHandler.php', - 'Monolog\\Handler\\LogglyHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/LogglyHandler.php', - 'Monolog\\Handler\\LogmaticHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/LogmaticHandler.php', - 'Monolog\\Handler\\MailHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/MailHandler.php', - 'Monolog\\Handler\\MandrillHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/MandrillHandler.php', - 'Monolog\\Handler\\MissingExtensionException' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/MissingExtensionException.php', - 'Monolog\\Handler\\MongoDBHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/MongoDBHandler.php', - 'Monolog\\Handler\\NativeMailerHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/NativeMailerHandler.php', - 'Monolog\\Handler\\NewRelicHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/NewRelicHandler.php', - 'Monolog\\Handler\\NoopHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/NoopHandler.php', - 'Monolog\\Handler\\NullHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/NullHandler.php', - 'Monolog\\Handler\\OverflowHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/OverflowHandler.php', - 'Monolog\\Handler\\PHPConsoleHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/PHPConsoleHandler.php', - 'Monolog\\Handler\\ProcessHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/ProcessHandler.php', - 'Monolog\\Handler\\ProcessableHandlerInterface' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/ProcessableHandlerInterface.php', - 'Monolog\\Handler\\ProcessableHandlerTrait' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/ProcessableHandlerTrait.php', - 'Monolog\\Handler\\PsrHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/PsrHandler.php', - 'Monolog\\Handler\\PushoverHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/PushoverHandler.php', - 'Monolog\\Handler\\RedisHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/RedisHandler.php', - 'Monolog\\Handler\\RedisPubSubHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/RedisPubSubHandler.php', - 'Monolog\\Handler\\RollbarHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/RollbarHandler.php', - 'Monolog\\Handler\\RotatingFileHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/RotatingFileHandler.php', - 'Monolog\\Handler\\SamplingHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SamplingHandler.php', - 'Monolog\\Handler\\SendGridHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SendGridHandler.php', - 'Monolog\\Handler\\SlackHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SlackHandler.php', - 'Monolog\\Handler\\SlackWebhookHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SlackWebhookHandler.php', - 'Monolog\\Handler\\Slack\\SlackRecord' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/Slack/SlackRecord.php', - 'Monolog\\Handler\\SocketHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SocketHandler.php', - 'Monolog\\Handler\\SqsHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SqsHandler.php', - 'Monolog\\Handler\\StreamHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/StreamHandler.php', - 'Monolog\\Handler\\SymfonyMailerHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SymfonyMailerHandler.php', - 'Monolog\\Handler\\SyslogHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SyslogHandler.php', - 'Monolog\\Handler\\SyslogUdpHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SyslogUdpHandler.php', - 'Monolog\\Handler\\SyslogUdp\\UdpSocket' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/SyslogUdp/UdpSocket.php', - 'Monolog\\Handler\\TelegramBotHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/TelegramBotHandler.php', - 'Monolog\\Handler\\TestHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/TestHandler.php', - 'Monolog\\Handler\\WebRequestRecognizerTrait' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/WebRequestRecognizerTrait.php', - 'Monolog\\Handler\\WhatFailureGroupHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/WhatFailureGroupHandler.php', - 'Monolog\\Handler\\ZendMonitorHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Handler/ZendMonitorHandler.php', - 'Monolog\\JsonSerializableDateTimeImmutable' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/JsonSerializableDateTimeImmutable.php', - 'Monolog\\Level' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Level.php', - 'Monolog\\LogRecord' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/LogRecord.php', - 'Monolog\\Logger' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Logger.php', - 'Monolog\\Processor\\ClosureContextProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/ClosureContextProcessor.php', - 'Monolog\\Processor\\GitProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/GitProcessor.php', - 'Monolog\\Processor\\HostnameProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/HostnameProcessor.php', - 'Monolog\\Processor\\IntrospectionProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/IntrospectionProcessor.php', - 'Monolog\\Processor\\LoadAverageProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/LoadAverageProcessor.php', - 'Monolog\\Processor\\MemoryPeakUsageProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/MemoryPeakUsageProcessor.php', - 'Monolog\\Processor\\MemoryProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/MemoryProcessor.php', - 'Monolog\\Processor\\MemoryUsageProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/MemoryUsageProcessor.php', - 'Monolog\\Processor\\MercurialProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/MercurialProcessor.php', - 'Monolog\\Processor\\ProcessIdProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/ProcessIdProcessor.php', - 'Monolog\\Processor\\ProcessorInterface' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/ProcessorInterface.php', - 'Monolog\\Processor\\PsrLogMessageProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/PsrLogMessageProcessor.php', - 'Monolog\\Processor\\TagProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/TagProcessor.php', - 'Monolog\\Processor\\UidProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/UidProcessor.php', - 'Monolog\\Processor\\WebProcessor' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Processor/WebProcessor.php', - 'Monolog\\Registry' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Registry.php', - 'Monolog\\ResettableInterface' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/ResettableInterface.php', - 'Monolog\\SignalHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/SignalHandler.php', - 'Monolog\\Test\\MonologTestCase' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Test/MonologTestCase.php', - 'Monolog\\Test\\TestCase' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Test/TestCase.php', - 'Monolog\\Utils' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Utils.php', 'PDF417' => __DIR__ . '/..' . '/tecnickcom/tcpdf/include/barcodes/pdf417.php', - 'PHPMailer\\PHPMailer\\DSNConfigurator' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/DSNConfigurator.php', - 'PHPMailer\\PHPMailer\\Exception' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/Exception.php', - 'PHPMailer\\PHPMailer\\OAuth' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/OAuth.php', - 'PHPMailer\\PHPMailer\\OAuthTokenProvider' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/OAuthTokenProvider.php', - 'PHPMailer\\PHPMailer\\PHPMailer' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/PHPMailer.php', - 'PHPMailer\\PHPMailer\\POP3' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/POP3.php', - 'PHPMailer\\PHPMailer\\SMTP' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/SMTP.php', - 'PhpOption\\LazyOption' => __DIR__ . '/..' . '/phpoption/phpoption/src/PhpOption/LazyOption.php', - 'PhpOption\\None' => __DIR__ . '/..' . '/phpoption/phpoption/src/PhpOption/None.php', - 'PhpOption\\Option' => __DIR__ . '/..' . '/phpoption/phpoption/src/PhpOption/Option.php', - 'PhpOption\\Some' => __DIR__ . '/..' . '/phpoption/phpoption/src/PhpOption/Some.php', 'PhpToken' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/PhpToken.php', - 'Psr\\Container\\ContainerExceptionInterface' => __DIR__ . '/..' . '/psr/container/src/ContainerExceptionInterface.php', - 'Psr\\Container\\ContainerInterface' => __DIR__ . '/..' . '/psr/container/src/ContainerInterface.php', - 'Psr\\Container\\NotFoundExceptionInterface' => __DIR__ . '/..' . '/psr/container/src/NotFoundExceptionInterface.php', - 'Psr\\Http\\Message\\MessageInterface' => __DIR__ . '/..' . '/psr/http-message/src/MessageInterface.php', - 'Psr\\Http\\Message\\RequestFactoryInterface' => __DIR__ . '/..' . '/psr/http-factory/src/RequestFactoryInterface.php', - 'Psr\\Http\\Message\\RequestInterface' => __DIR__ . '/..' . '/psr/http-message/src/RequestInterface.php', - 'Psr\\Http\\Message\\ResponseFactoryInterface' => __DIR__ . '/..' . '/psr/http-factory/src/ResponseFactoryInterface.php', - 'Psr\\Http\\Message\\ResponseInterface' => __DIR__ . '/..' . '/psr/http-message/src/ResponseInterface.php', - 'Psr\\Http\\Message\\ServerRequestFactoryInterface' => __DIR__ . '/..' . '/psr/http-factory/src/ServerRequestFactoryInterface.php', - 'Psr\\Http\\Message\\ServerRequestInterface' => __DIR__ . '/..' . '/psr/http-message/src/ServerRequestInterface.php', - 'Psr\\Http\\Message\\StreamFactoryInterface' => __DIR__ . '/..' . '/psr/http-factory/src/StreamFactoryInterface.php', - 'Psr\\Http\\Message\\StreamInterface' => __DIR__ . '/..' . '/psr/http-message/src/StreamInterface.php', - 'Psr\\Http\\Message\\UploadedFileFactoryInterface' => __DIR__ . '/..' . '/psr/http-factory/src/UploadedFileFactoryInterface.php', - 'Psr\\Http\\Message\\UploadedFileInterface' => __DIR__ . '/..' . '/psr/http-message/src/UploadedFileInterface.php', - 'Psr\\Http\\Message\\UriFactoryInterface' => __DIR__ . '/..' . '/psr/http-factory/src/UriFactoryInterface.php', - 'Psr\\Http\\Message\\UriInterface' => __DIR__ . '/..' . '/psr/http-message/src/UriInterface.php', - 'Psr\\Http\\Server\\MiddlewareInterface' => __DIR__ . '/..' . '/psr/http-server-middleware/src/MiddlewareInterface.php', - 'Psr\\Http\\Server\\RequestHandlerInterface' => __DIR__ . '/..' . '/psr/http-server-handler/src/RequestHandlerInterface.php', - 'Psr\\Log\\AbstractLogger' => __DIR__ . '/..' . '/psr/log/src/AbstractLogger.php', - 'Psr\\Log\\InvalidArgumentException' => __DIR__ . '/..' . '/psr/log/src/InvalidArgumentException.php', - 'Psr\\Log\\LogLevel' => __DIR__ . '/..' . '/psr/log/src/LogLevel.php', - 'Psr\\Log\\LoggerAwareInterface' => __DIR__ . '/..' . '/psr/log/src/LoggerAwareInterface.php', - 'Psr\\Log\\LoggerAwareTrait' => __DIR__ . '/..' . '/psr/log/src/LoggerAwareTrait.php', - 'Psr\\Log\\LoggerInterface' => __DIR__ . '/..' . '/psr/log/src/LoggerInterface.php', - 'Psr\\Log\\LoggerTrait' => __DIR__ . '/..' . '/psr/log/src/LoggerTrait.php', - 'Psr\\Log\\NullLogger' => __DIR__ . '/..' . '/psr/log/src/NullLogger.php', 'QRcode' => __DIR__ . '/..' . '/tecnickcom/tcpdf/include/barcodes/qrcode.php', - 'ReCaptcha\\ReCaptcha' => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha/ReCaptcha.php', - 'ReCaptcha\\RequestMethod' => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha/RequestMethod.php', - 'ReCaptcha\\RequestMethod\\Curl' => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha/RequestMethod/Curl.php', - 'ReCaptcha\\RequestMethod\\CurlPost' => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha/RequestMethod/CurlPost.php', - 'ReCaptcha\\RequestMethod\\Post' => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha/RequestMethod/Post.php', - 'ReCaptcha\\RequestMethod\\Socket' => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha/RequestMethod/Socket.php', - 'ReCaptcha\\RequestMethod\\SocketPost' => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha/RequestMethod/SocketPost.php', - 'ReCaptcha\\RequestParameters' => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha/RequestParameters.php', - 'ReCaptcha\\Response' => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha/Response.php', - 'Slim\\App' => __DIR__ . '/..' . '/slim/slim/Slim/App.php', - 'Slim\\CallableResolver' => __DIR__ . '/..' . '/slim/slim/Slim/CallableResolver.php', - 'Slim\\Csrf\\Guard' => __DIR__ . '/..' . '/slim/csrf/src/Guard.php', - 'Slim\\Error\\AbstractErrorRenderer' => __DIR__ . '/..' . '/slim/slim/Slim/Error/AbstractErrorRenderer.php', - 'Slim\\Error\\Renderers\\HtmlErrorRenderer' => __DIR__ . '/..' . '/slim/slim/Slim/Error/Renderers/HtmlErrorRenderer.php', - 'Slim\\Error\\Renderers\\JsonErrorRenderer' => __DIR__ . '/..' . '/slim/slim/Slim/Error/Renderers/JsonErrorRenderer.php', - 'Slim\\Error\\Renderers\\PlainTextErrorRenderer' => __DIR__ . '/..' . '/slim/slim/Slim/Error/Renderers/PlainTextErrorRenderer.php', - 'Slim\\Error\\Renderers\\XmlErrorRenderer' => __DIR__ . '/..' . '/slim/slim/Slim/Error/Renderers/XmlErrorRenderer.php', - 'Slim\\Exception\\HttpBadRequestException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpBadRequestException.php', - 'Slim\\Exception\\HttpException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpException.php', - 'Slim\\Exception\\HttpForbiddenException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpForbiddenException.php', - 'Slim\\Exception\\HttpGoneException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpGoneException.php', - 'Slim\\Exception\\HttpInternalServerErrorException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpInternalServerErrorException.php', - 'Slim\\Exception\\HttpMethodNotAllowedException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpMethodNotAllowedException.php', - 'Slim\\Exception\\HttpNotFoundException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpNotFoundException.php', - 'Slim\\Exception\\HttpNotImplementedException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpNotImplementedException.php', - 'Slim\\Exception\\HttpSpecializedException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpSpecializedException.php', - 'Slim\\Exception\\HttpTooManyRequestsException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpTooManyRequestsException.php', - 'Slim\\Exception\\HttpUnauthorizedException' => __DIR__ . '/..' . '/slim/slim/Slim/Exception/HttpUnauthorizedException.php', - 'Slim\\Factory\\AppFactory' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/AppFactory.php', - 'Slim\\Factory\\Psr17\\GuzzlePsr17Factory' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/GuzzlePsr17Factory.php', - 'Slim\\Factory\\Psr17\\HttpSoftPsr17Factory' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/HttpSoftPsr17Factory.php', - 'Slim\\Factory\\Psr17\\LaminasDiactorosPsr17Factory' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/LaminasDiactorosPsr17Factory.php', - 'Slim\\Factory\\Psr17\\NyholmPsr17Factory' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/NyholmPsr17Factory.php', - 'Slim\\Factory\\Psr17\\Psr17Factory' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/Psr17Factory.php', - 'Slim\\Factory\\Psr17\\Psr17FactoryProvider' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/Psr17FactoryProvider.php', - 'Slim\\Factory\\Psr17\\ServerRequestCreator' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/ServerRequestCreator.php', - 'Slim\\Factory\\Psr17\\SlimHttpPsr17Factory' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/SlimHttpPsr17Factory.php', - 'Slim\\Factory\\Psr17\\SlimHttpServerRequestCreator' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/SlimHttpServerRequestCreator.php', - 'Slim\\Factory\\Psr17\\SlimPsr17Factory' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/Psr17/SlimPsr17Factory.php', - 'Slim\\Factory\\ServerRequestCreatorFactory' => __DIR__ . '/..' . '/slim/slim/Slim/Factory/ServerRequestCreatorFactory.php', - 'Slim\\Handlers\\ErrorHandler' => __DIR__ . '/..' . '/slim/slim/Slim/Handlers/ErrorHandler.php', - 'Slim\\Handlers\\Strategies\\RequestHandler' => __DIR__ . '/..' . '/slim/slim/Slim/Handlers/Strategies/RequestHandler.php', - 'Slim\\Handlers\\Strategies\\RequestResponse' => __DIR__ . '/..' . '/slim/slim/Slim/Handlers/Strategies/RequestResponse.php', - 'Slim\\Handlers\\Strategies\\RequestResponseArgs' => __DIR__ . '/..' . '/slim/slim/Slim/Handlers/Strategies/RequestResponseArgs.php', - 'Slim\\Handlers\\Strategies\\RequestResponseNamedArgs' => __DIR__ . '/..' . '/slim/slim/Slim/Handlers/Strategies/RequestResponseNamedArgs.php', - 'Slim\\Interfaces\\AdvancedCallableResolverInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/AdvancedCallableResolverInterface.php', - 'Slim\\Interfaces\\CallableResolverInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/CallableResolverInterface.php', - 'Slim\\Interfaces\\DispatcherInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/DispatcherInterface.php', - 'Slim\\Interfaces\\ErrorHandlerInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/ErrorHandlerInterface.php', - 'Slim\\Interfaces\\ErrorRendererInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/ErrorRendererInterface.php', - 'Slim\\Interfaces\\InvocationStrategyInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/InvocationStrategyInterface.php', - 'Slim\\Interfaces\\MiddlewareDispatcherInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/MiddlewareDispatcherInterface.php', - 'Slim\\Interfaces\\Psr17FactoryInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/Psr17FactoryInterface.php', - 'Slim\\Interfaces\\Psr17FactoryProviderInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/Psr17FactoryProviderInterface.php', - 'Slim\\Interfaces\\RequestHandlerInvocationStrategyInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/RequestHandlerInvocationStrategyInterface.php', - 'Slim\\Interfaces\\RouteCollectorInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/RouteCollectorInterface.php', - 'Slim\\Interfaces\\RouteCollectorProxyInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/RouteCollectorProxyInterface.php', - 'Slim\\Interfaces\\RouteGroupInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/RouteGroupInterface.php', - 'Slim\\Interfaces\\RouteInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/RouteInterface.php', - 'Slim\\Interfaces\\RouteParserInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/RouteParserInterface.php', - 'Slim\\Interfaces\\RouteResolverInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/RouteResolverInterface.php', - 'Slim\\Interfaces\\ServerRequestCreatorInterface' => __DIR__ . '/..' . '/slim/slim/Slim/Interfaces/ServerRequestCreatorInterface.php', - 'Slim\\Logger' => __DIR__ . '/..' . '/slim/slim/Slim/Logger.php', - 'Slim\\MiddlewareDispatcher' => __DIR__ . '/..' . '/slim/slim/Slim/MiddlewareDispatcher.php', - 'Slim\\Middleware\\BodyParsingMiddleware' => __DIR__ . '/..' . '/slim/slim/Slim/Middleware/BodyParsingMiddleware.php', - 'Slim\\Middleware\\ContentLengthMiddleware' => __DIR__ . '/..' . '/slim/slim/Slim/Middleware/ContentLengthMiddleware.php', - 'Slim\\Middleware\\ErrorMiddleware' => __DIR__ . '/..' . '/slim/slim/Slim/Middleware/ErrorMiddleware.php', - 'Slim\\Middleware\\MethodOverrideMiddleware' => __DIR__ . '/..' . '/slim/slim/Slim/Middleware/MethodOverrideMiddleware.php', - 'Slim\\Middleware\\OutputBufferingMiddleware' => __DIR__ . '/..' . '/slim/slim/Slim/Middleware/OutputBufferingMiddleware.php', - 'Slim\\Middleware\\RoutingMiddleware' => __DIR__ . '/..' . '/slim/slim/Slim/Middleware/RoutingMiddleware.php', - 'Slim\\Psr7\\Cookies' => __DIR__ . '/..' . '/slim/psr7/src/Cookies.php', - 'Slim\\Psr7\\Environment' => __DIR__ . '/..' . '/slim/psr7/src/Environment.php', - 'Slim\\Psr7\\Factory\\RequestFactory' => __DIR__ . '/..' . '/slim/psr7/src/Factory/RequestFactory.php', - 'Slim\\Psr7\\Factory\\ResponseFactory' => __DIR__ . '/..' . '/slim/psr7/src/Factory/ResponseFactory.php', - 'Slim\\Psr7\\Factory\\ServerRequestFactory' => __DIR__ . '/..' . '/slim/psr7/src/Factory/ServerRequestFactory.php', - 'Slim\\Psr7\\Factory\\StreamFactory' => __DIR__ . '/..' . '/slim/psr7/src/Factory/StreamFactory.php', - 'Slim\\Psr7\\Factory\\UploadedFileFactory' => __DIR__ . '/..' . '/slim/psr7/src/Factory/UploadedFileFactory.php', - 'Slim\\Psr7\\Factory\\UriFactory' => __DIR__ . '/..' . '/slim/psr7/src/Factory/UriFactory.php', - 'Slim\\Psr7\\Header' => __DIR__ . '/..' . '/slim/psr7/src/Header.php', - 'Slim\\Psr7\\Headers' => __DIR__ . '/..' . '/slim/psr7/src/Headers.php', - 'Slim\\Psr7\\Interfaces\\HeadersInterface' => __DIR__ . '/..' . '/slim/psr7/src/Interfaces/HeadersInterface.php', - 'Slim\\Psr7\\Message' => __DIR__ . '/..' . '/slim/psr7/src/Message.php', - 'Slim\\Psr7\\NonBufferedBody' => __DIR__ . '/..' . '/slim/psr7/src/NonBufferedBody.php', - 'Slim\\Psr7\\Request' => __DIR__ . '/..' . '/slim/psr7/src/Request.php', - 'Slim\\Psr7\\Response' => __DIR__ . '/..' . '/slim/psr7/src/Response.php', - 'Slim\\Psr7\\Stream' => __DIR__ . '/..' . '/slim/psr7/src/Stream.php', - 'Slim\\Psr7\\UploadedFile' => __DIR__ . '/..' . '/slim/psr7/src/UploadedFile.php', - 'Slim\\Psr7\\Uri' => __DIR__ . '/..' . '/slim/psr7/src/Uri.php', - 'Slim\\ResponseEmitter' => __DIR__ . '/..' . '/slim/slim/Slim/ResponseEmitter.php', - 'Slim\\Routing\\Dispatcher' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/Dispatcher.php', - 'Slim\\Routing\\FastRouteDispatcher' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/FastRouteDispatcher.php', - 'Slim\\Routing\\Route' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/Route.php', - 'Slim\\Routing\\RouteCollector' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/RouteCollector.php', - 'Slim\\Routing\\RouteCollectorProxy' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/RouteCollectorProxy.php', - 'Slim\\Routing\\RouteContext' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/RouteContext.php', - 'Slim\\Routing\\RouteGroup' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/RouteGroup.php', - 'Slim\\Routing\\RouteParser' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/RouteParser.php', - 'Slim\\Routing\\RouteResolver' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/RouteResolver.php', - 'Slim\\Routing\\RouteRunner' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/RouteRunner.php', - 'Slim\\Routing\\RoutingResults' => __DIR__ . '/..' . '/slim/slim/Slim/Routing/RoutingResults.php', 'Stringable' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Stringable.php', - 'Symfony\\Polyfill\\Ctype\\Ctype' => __DIR__ . '/..' . '/symfony/polyfill-ctype/Ctype.php', - 'Symfony\\Polyfill\\Mbstring\\Mbstring' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/Mbstring.php', - 'Symfony\\Polyfill\\Php80\\Php80' => __DIR__ . '/..' . '/symfony/polyfill-php80/Php80.php', - 'Symfony\\Polyfill\\Php80\\PhpToken' => __DIR__ . '/..' . '/symfony/polyfill-php80/PhpToken.php', 'TCPDF' => __DIR__ . '/..' . '/tecnickcom/tcpdf/tcpdf.php', 'TCPDF2DBarcode' => __DIR__ . '/..' . '/tecnickcom/tcpdf/tcpdf_barcodes_2d.php', 'TCPDFBarcode' => __DIR__ . '/..' . '/tecnickcom/tcpdf/tcpdf_barcodes_1d.php', @@ -739,21 +197,6 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c 'TCPDF_FONT_DATA' => __DIR__ . '/..' . '/tecnickcom/tcpdf/include/tcpdf_font_data.php', 'TCPDF_IMAGES' => __DIR__ . '/..' . '/tecnickcom/tcpdf/include/tcpdf_images.php', 'TCPDF_STATIC' => __DIR__ . '/..' . '/tecnickcom/tcpdf/include/tcpdf_static.php', - 'Thepixeldeveloper\\Sitemap\\ChunkedCollection' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/ChunkedCollection.php', - 'Thepixeldeveloper\\Sitemap\\ChunkedUrlset' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/ChunkedUrlset.php', - 'Thepixeldeveloper\\Sitemap\\Collection' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Collection.php', - 'Thepixeldeveloper\\Sitemap\\Drivers\\XmlWriterDriver' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Drivers/XmlWriterDriver.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\Image' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Extensions/Image.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\Link' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Extensions/Link.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\Mobile' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Extensions/Mobile.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\News' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Extensions/News.php', - 'Thepixeldeveloper\\Sitemap\\Extensions\\Video' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Extensions/Video.php', - 'Thepixeldeveloper\\Sitemap\\Interfaces\\DriverInterface' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Interfaces/DriverInterface.php', - 'Thepixeldeveloper\\Sitemap\\Interfaces\\VisitorInterface' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Interfaces/VisitorInterface.php', - 'Thepixeldeveloper\\Sitemap\\Sitemap' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Sitemap.php', - 'Thepixeldeveloper\\Sitemap\\SitemapIndex' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/SitemapIndex.php', - 'Thepixeldeveloper\\Sitemap\\Url' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Url.php', - 'Thepixeldeveloper\\Sitemap\\Urlset' => __DIR__ . '/..' . '/thepixeldeveloper/sitemap/src/Urlset.php', 'UnhandledMatchError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php', 'ValueError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/ValueError.php', ); diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index ae438b30..ae0d98ad 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -1871,6 +1871,6 @@ "install-path": "../vlucas/phpdotenv" } ], - "dev": false, + "dev": true, "dev-package-names": [] } diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index 6e60c67b..a3033520 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,11 +3,11 @@ 'name' => 'pinakes/slim-app', 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '3e18155a994a148e24be8633ce7a4f6f0ab35abd', + 'reference' => 'a12f4d4d6db75672ca67d8cdc02713cb497538de', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), - 'dev' => false, + 'dev' => true, ), 'versions' => array( 'emleons/sim-rating' => array( @@ -112,7 +112,7 @@ 'pinakes/slim-app' => array( 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '3e18155a994a148e24be8633ce7a4f6f0ab35abd', + 'reference' => 'a12f4d4d6db75672ca67d8cdc02713cb497538de', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), diff --git a/version.json b/version.json index a552f390..c1ce972d 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "name": "Pinakes", - "version": "0.5.3", + "version": "0.5.4", "description": "Library Management System - Sistema di Gestione Bibliotecaria" }