Skip to content

fix: P2 consistency findings across v0.4.9.9-v0.5.2#96

Open
fabiodalez-dev wants to merge 19 commits intomainfrom
fix/p2-consistency-findings
Open

fix: P2 consistency findings across v0.4.9.9-v0.5.2#96
fabiodalez-dev wants to merge 19 commits intomainfrom
fix/p2-consistency-findings

Conversation

@fabiodalez-dev
Copy link
Copy Markdown
Owner

@fabiodalez-dev fabiodalez-dev commented Mar 21, 2026

Summary

Fixes 4 P2 consistency findings identified in a cross-version review (v0.4.9.9 → v0.5.2):

  • descrizione_plain not propagated — Catalog search (FULLTEXT+LIKE) and admin grid (LIKE) now use COALESCE(l.descrizione_plain, l.descrizione) for LIKE conditions. MATCH() unchanged (requires FULLTEXT index columns).
  • ISSN missing from JSON-LD and API — Added issn property to Schema.org Book JSON-LD and to PublicApiController SELECT + response mapping.
  • CollaneController::rename() sync unchecked — Explicit execute() result check with RuntimeException on failure (was relying on MYSQLI_REPORT_STRICT catch only).
  • LibraryThing import bypasses transformations — Added descrizione_plain (strip_tags), ISSN normalization (XXXX-XXXX format), and AuthorNormalizer on traduttore to all 4 INSERT/UPDATE SQL variants.

Test plan

  • Verify catalog search matches on HTML-stripped descriptions
  • Verify admin grid search uses descrizione_plain
  • Verify ISSN appears in JSON-LD on frontend book detail
  • Verify ISSN returned in public API search results
  • Verify rename to existing collana name rolls back properly
  • Verify LibraryThing import populates descrizione_plain
  • Verify LibraryThing import normalizes ISSN format
  • PHPStan level 1 passes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • GoodLib plugin: one‑click external‑source badges and a settings modal for sources, mirrors and visibility.
    • ISSN now included in APIs and frontend Book schema as an identifier.
  • Bug Fixes

    • Collana rename now fails fast on DB sync errors to ensure proper rollback.
  • Improvements

    • Search prefers plain‑text descriptions when available.
    • LibraryThing import/export: improved author/translator handling, plain‑text extraction, ISSN normalization, and safer updates that respect soft‑deletes.
    • Profile locale picker; locale applied on login, profile update (when submitted), and remember‑me.
  • Tests

    • New/updated E2E tests, harnesses and fixtures covering import, search, locale flows, and GoodLib.
  • Chores

    • Refined .gitignore rules.

1. descrizione_plain: catalog search and admin grid now use
   COALESCE(descrizione_plain, descrizione) for LIKE conditions
   (FULLTEXT MATCH unchanged - requires index columns)

2. ISSN: added to Schema.org JSON-LD ($bookSchema['issn']) and
   PublicApiController SELECT + response mapping

3. CollaneController::rename(): explicit execute() check on
   collane sync with RuntimeException on failure

4. LibraryThing import: descrizione_plain (strip_tags),
   ISSN normalization (XXXX-XXXX), AuthorNormalizer on
   traduttore — all 4 INSERT/UPDATE SQL variants updated
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@CodeRabbit full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 21, 2026

✅ Actions performed

Full review triggered.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds descrizione_plain-aware search, hardens collane sync error propagation, enriches LibraryThing parsing/persistence (contributors, descrizione_plain, ISSN/EAN), propagates locale through auth/profile/remember-me, exposes ISSN in APIs/views, registers a new GoodLib plugin with admin UI/hooks, and adds extensive E2E tests and a PHP test harness.

Changes

Cohort / File(s) Summary
Search & list APIs
app/Controllers/FrontendController.php, app/Controllers/LibriApiController.php, tests/full-test.spec.js
Detect/cache libri.descrizione_plain, use COALESCE(NULLIF(...), l.descrizione) for description predicates, add extra LIKE ? predicate and adjust bound params; tests seed space-separated search tokens when descrizione_plain exists.
LibraryThing import/export & persistence
app/Controllers/LibraryThingImportController.php, tests/helpers/branch-fix-harness.php
Normalize contributors (authors/translators), parse/format ISSN/EAN, generate/persist descrizione_plain conditionally, broaden parseDate inputs, infer bind types at runtime, add soft-delete guards and stricter UPDATE checks, replace error_log with SecureLogger.
Series / collane handling
app/Controllers/CollaneController.php, tests/helpers/branch-fix-harness.php
Make collane sync failures fatal: throw on prepare() or failed execute() so transaction catch rolls back and logs.
Locale propagation & profile UI
app/Controllers/AuthController.php, app/Controllers/ProfileController.php, app/Middleware/RememberMeMiddleware.php, app/Views/profile/index.php, tests/helpers/branch-fix-harness.php
Fetch/persist locale in login/remember-me/profile; profile update only writes when locale posted and validated; apply locale via I18n::setLocale; conditional session locale; replace error_log with SecureLogger.
Public API, views & not-found handling
app/Controllers/PublicApiController.php, app/Views/frontend/book-detail.php, app/Views/libri/scheda_libro.php, app/Controllers/LibriController.php
Expose l.issn in public API and JSON‑LD (Schema.org PropertyValue), add book.admin.external_links hook in book admin quick-info, and throw HttpNotFoundException for missing-book paths.
GoodLib plugin + admin UI
storage/plugins/goodlib/GoodLibPlugin.php, storage/plugins/goodlib/wrapper.php, storage/plugins/goodlib/plugin.json, storage/plugins/goodlib/views/*, app/Controllers/PluginController.php, app/Views/admin/plugins.php, app/Support/PluginManager.php, app/Support/Updater.php
Add GoodLib plugin (class, wrapper, manifest, views), register it as bundled, provide settings UI/modal & JS, domain normalization/validation, persist settings, register hooks and render frontend/admin badges.
Views & UI adjustments
app/Views/admin/plugins.php, app/Views/profile/index.php, app/Views/frontend/book-detail.php, app/Views/libri/scheda_libro.php
GoodLib modal/JS, conditional locale select in profile, include ISSN in JSON‑LD, invoke external-links hook, and small i18n/escaping tweaks.
Tests, harness & helpers
tests/*, tests/helpers/*, tests/branch-fix-consistency.spec.js, tests/goodlib-custom-domains.spec.js, tests/helpers/branch-fix-harness.php, tests/helpers/e2e-fixtures.js
Add Playwright E2E suites, a comprehensive PHP branch-fix harness with many scenarios, JS E2E fixtures, and tests covering descrizione_plain search, LibraryThing import/export, collana rollback, locale propagation, and GoodLib custom domains.
Repo config & locales
.gitignore, locale/it_IT.json, locale/en_US.json, locale/de_DE.json
Adjust .gitignore for plugin/test files; add GoodLib localization strings (it/en/de).

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Admin as Admin UI
    participant Controller as PluginController
    participant PM as PluginManager
    participant DB as MySQL
    participant Plugin as GoodLibPlugin
    participant View as Book Page

    Admin->>Controller: POST GoodLib settings (sources, domains)
    Controller->>PM: normalize domains & setSetting(...)
    PM->>DB: upsert plugin_settings rows
    DB-->>PM: OK
    PM-->>Controller: success
    Controller-->>Admin: JSON success

    View->>Plugin: renderFrontendBadges($book)
    Plugin->>PM: load settings
    PM->>DB: select plugin_settings
    DB-->>PM: settings rows
    PM-->>Plugin: settings
    Plugin->>View: include badges.php with $sources and $query
    View-->>Client: HTML with external links
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I hopped through names and strips of text,
I nudged locales, kept ISSNs in check,
I made the collana sync shout when it failed,
I stitched badges and tests so nothing derailed,
A little rabbit patch — now features leap and connect.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.61% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: P2 consistency findings across v0.4.9.9-v0.5.2' clearly summarizes the main objective—addressing consistency issues identified across version ranges. It is concise, specific, and directly reflects the primary change purpose.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/p2-consistency-findings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
app/Controllers/LibriApiController.php (1)

45-48: LGTM! Correctly implements descrizione_plain search support.

The defensive column check via hasTableColumn() and the COALESCE fallback pattern align with the approach in FrontendController::descriptionExpr(). The prepared statement placeholder count remains correct (5 LIKE params), and the interpolated $descExpr is derived from trusted internal logic.

Consider extracting the description expression logic into a shared trait or utility class to reduce duplication with FrontendController::descriptionExpr(). This would centralize the column-existence check and make future changes easier to maintain.

Example shared trait approach
// app/Support/DescriptionExprTrait.php
trait DescriptionExprTrait
{
    private static ?bool $hasDescrizionePlain = null;

    protected static function descriptionExpr(mysqli $db): string
    {
        if (self::$hasDescrizionePlain === null) {
            $result = $db->query("SHOW COLUMNS FROM libri LIKE 'descrizione_plain'");
            self::$hasDescrizionePlain = $result && $result->num_rows > 0;
        }
        return self::$hasDescrizionePlain
            ? 'COALESCE(l.descrizione_plain, l.descrizione)'
            : 'l.descrizione';
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriApiController.php` around lines 45 - 48, Extract the
duplicated description-expression logic into a shared helper (e.g., create a
DescriptionExprTrait with a cached descriptionExpr(mysqli $db): string method)
and have LibriApiController use that trait instead of inlining the
hasTableColumn/COALESCE logic; mirror FrontendController::descriptionExpr()
behavior and return the same expression ('COALESCE(l.descrizione_plain,
l.descrizione)' or 'l.descrizione') while caching the column-exists check, then
replace the inline $descExpr assignment in LibriApiController with a call to the
new descriptionExpr($db) helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/CollaneController.php`:
- Around line 276-284: In rename(), ensure the collane sync prepare failure is
handled so the transaction cannot partially commit: after $stmtSync =
$db->prepare("UPDATE collane SET nome = ? WHERE nome = ?") check if $stmtSync is
false and immediately throw a \RuntimeException (including $db->error) or
otherwise abort/rollback the transaction before proceeding; keep the existing
bind_param/execute/close path for $stmtSync and only allow commit when prepare
and execute succeed so $db, $stmtSync, $newName and $oldName updates remain
atomic.

In `@app/Controllers/LibraryThingImportController.php`:
- Line 1150: The SQL update/insert statements in LibraryThingImportController
that unconditionally include "descrizione_plain" (e.g., the fragment "genere_id
= ?, descrizione = ?, descrizione_plain = ?, formato = ?, prezzo = ?, editore_id
= ?") will fail on upgraded instances missing that column; change the logic to
only include and bind descrizione_plain when the schema/migration has been
applied—detect this with a schema check or migration/version flag (e.g.,
hasColumn('descrizione_plain') or checking the controller's upgrade state) and
build the SQL and parameter list conditionally. Apply the same conditional
inclusion to the other occurrences referenced in the comment so none of the four
SQL branches reference descrizione_plain unless the column exists.
- Line 1162: The UPDATE statements that target the libri table currently use
"WHERE id = ?" and must include the soft-delete guard to avoid racing with
findExistingBook(); update both SQL statements (the ones using "WHERE id = ?" in
LibraryThingImportController) to append "AND deleted_at IS NULL" so they become
"WHERE id = ? AND deleted_at IS NULL", keeping the same bound parameters and
error handling; apply this change to the occurrence around the first update
(shown at the diff) and the second occurrence noted (also at the other update).
- Around line 700-708: The normalized author list may contain duplicates after
calling \App\Support\AuthorNormalizer::normalize for 'Primary Author' and
'Secondary Author', causing duplicate libri_autori rows in processChunk(); to
fix, deduplicate the $authors array after normalization (e.g., use array_unique
or build a keyed set) before setting $result['autori'], so only unique canonical
names are joined with implode('|') and assigned to $result['autori'].
- Around line 791-793: The current assignment to $result['descrizione_plain']
uses strip_tags($result['descrizione']) which glues adjacent blocks and leaves
HTML entities encoded; instead normalize block boundaries and decode entities:
replace common block-level tags (e.g. </p>, <br>, </div>, </li>, headings) with
a space or newline in $result['descrizione'], then strip tags, run
html_entity_decode on the result, collapse repeated whitespace to single spaces
and trim, and assign that cleaned string to $result['descrizione_plain']; refer
to $result['descrizione'], $result['descrizione_plain'] and the existing
strip_tags call to locate where to apply these steps.

---

Nitpick comments:
In `@app/Controllers/LibriApiController.php`:
- Around line 45-48: Extract the duplicated description-expression logic into a
shared helper (e.g., create a DescriptionExprTrait with a cached
descriptionExpr(mysqli $db): string method) and have LibriApiController use that
trait instead of inlining the hasTableColumn/COALESCE logic; mirror
FrontendController::descriptionExpr() behavior and return the same expression
('COALESCE(l.descrizione_plain, l.descrizione)' or 'l.descrizione') while
caching the column-exists check, then replace the inline $descExpr assignment in
LibriApiController with a call to the new descriptionExpr($db) helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4f76c506-f7b0-4368-b903-00d225a7d72c

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 624a1ec.

📒 Files selected for processing (6)
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/PublicApiController.php
  • app/Views/frontend/book-detail.php

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
app/Controllers/CollaneController.php (1)

276-284: ⚠️ Potential issue | 🔴 Critical

Abort transaction when collane sync prepare() fails

Line 276 still allows a silent prepare() failure path (if ($stmtSync)), which can commit a partial rename (libri updated, collane unchanged). This is the same unresolved atomicity issue flagged earlier.

Proposed fix
             if ($this->hasCollaneTable($db)) {
                 $stmtSync = $db->prepare("UPDATE collane SET nome = ? WHERE nome = ?");
-                if ($stmtSync) {
-                    $stmtSync->bind_param('ss', $newName, $oldName);
-                    $ok = $stmtSync->execute();
-                    $stmtSync->close();
-                    if (!$ok) {
-                        throw new \RuntimeException('Collane sync failed: ' . $db->error);
-                    }
-                }
+                if (!$stmtSync) {
+                    throw new \RuntimeException('Collane sync prepare failed: ' . $db->error);
+                }
+                $stmtSync->bind_param('ss', $newName, $oldName);
+                $ok = $stmtSync->execute();
+                $stmtSync->close();
+                if (!$ok) {
+                    throw new \RuntimeException('Collane sync failed: ' . $db->error);
+                }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/CollaneController.php` around lines 276 - 284, The current
prepare() for $stmtSync can fail silently due to the if ($stmtSync) guard,
allowing the transaction to commit with only the libri updates; change the flow
so prepare() returning false triggers an immediate rollback and exception: after
calling $db->prepare("UPDATE collane ...") check for a falsy result and call
$db->rollback() then throw a RuntimeException (including $db->error), otherwise
proceed to bind/execute/close and let the outer transaction logic continue;
ensure the rollback call targets the same DB connection used for the libri
update so the rename remains atomic.
app/Controllers/LibraryThingImportController.php (3)

1146-1163: ⚠️ Potential issue | 🟠 Major

Add deleted_at IS NULL to both libri updates.

findExistingBook() filters deleted rows, but these UPDATE libri ... WHERE id = ? writes can still race a soft-delete and mutate a deleted record.

🛡️ Proposed fix
-                WHERE id = ?
+                WHERE id = ? AND deleted_at IS NULL

Apply the same change to the second UPDATE libri branch as well. As per coding guidelines, Every query on libri table MUST include soft-delete condition: AND deleted_at IS NULL.

Also applies to: 1229-1237

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1146 - 1163,
The UPDATE statements that write to the libri table (the prepared statement
building "UPDATE libri SET ... WHERE id = ?") lack the soft-delete guard and can
race a soft-delete; modify both UPDATE branches (the one shown and the other
UPDATE around the 1229-1237 area) to append "AND deleted_at IS NULL" to the
WHERE clause so every libri update includes the soft-delete condition; locate
the UPDATE SQL in LibraryThingImportController (the prepared statements) and add
the soft-delete predicate to each WHERE id = ?.

790-793: ⚠️ Potential issue | 🟠 Major

Build real plain text for descrizione_plain.

strip_tags() still turns adjacent blocks into glued text and leaves entities encoded, so the new LIKE search can still miss summaries such as <p>foo</p><p>bar</p> or l&#039;amour.

🛠️ Proposed fix
         $result['descrizione'] = !empty($data['Summary']) ? trim($data['Summary']) : '';
         if (!empty($result['descrizione'])) {
-            $result['descrizione_plain'] = strip_tags($result['descrizione']);
+            $plain = preg_replace('/<[^>]+>/', ' ', $result['descrizione']) ?? $result['descrizione'];
+            $plain = html_entity_decode($plain, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+            $result['descrizione_plain'] = trim(preg_replace('/\s+/u', ' ', $plain) ?? $plain);
         }
In PHP, does strip_tags() preserve whitespace between removed block elements or decode HTML entities like &#039;?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 790 - 793, The
current generation of descrizione_plain using strip_tags(trim($data['Summary']))
loses spacing between block elements and keeps HTML entities; update the logic
that sets $result['descrizione_plain'] (currently computed from
$result['descrizione'] after strip_tags) to: 1) decode HTML entities with
html_entity_decode(..., ENT_QUOTES|ENT_HTML5, 'UTF-8'); 2) replace common
block-level tags (e.g., p, div, br, li, h1..h6, blockquote) with a single space
or newline using preg_replace before stripping tags; 3) run strip_tags() and
then normalize internal whitespace (preg_replace('/\s+/',' ', ...)) and trim the
result so descrizione_plain has real plain text spacing and decoded entities.
Ensure changes touch the code that assigns $result['descrizione'] and
$result['descrizione_plain'] and keep trim() behavior for the original
descrizione.

1146-1180: ⚠️ Potential issue | 🟠 Major

Don't hard-require descrizione_plain in every SQL branch.

All four INSERT/UPDATE variants now bind descrizione_plain, including the two “plugin not installed” paths. If an upgraded instance has not applied that column yet, this import path fails with Unknown column 'descrizione_plain'. Build the column/value/type fragments conditionally off a schema check or a migration/version flag.

Expect either a guaranteed migration path for libri.descrizione_plain, or conditional SQL assembly here that omits the column when absent.

#!/bin/bash
set -euo pipefail

# Verify whether the repository guarantees libri.descrizione_plain before this controller can run.
rg -n -C2 --type=php '\bdescrizione_plain\b|\bfunction\s+isInstalled\b|\bisInstalled\s*\('
rg -n -C2 --glob '*.sql' 'descrizione_plain|ALTER TABLE libri|CREATE TABLE libri'

Also applies to: 1229-1253, 1287-1332, 1384-1409

🧹 Nitpick comments (2)
app/Controllers/FrontendController.php (1)

22-30: Use the blank-safe fallback in the shared description expression.

This helper feeds every new catalog LIKE branch below, so the same COALESCE caveat applies here: descrizione_plain = '' suppresses fallback to l.descrizione. Switching the helper to COALESCE(NULLIF(l.descrizione_plain, ''), l.descrizione) makes the search path resilient for legacy rows too.

💡 Suggested change
-        return self::$hasDescrizionePlain
-            ? 'COALESCE(l.descrizione_plain, l.descrizione)'
-            : 'l.descrizione';
+        return self::$hasDescrizionePlain
+            ? "COALESCE(NULLIF(l.descrizione_plain, ''), l.descrizione)"
+            : 'l.descrizione';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/FrontendController.php` around lines 22 - 30, The
descriptionExpr helper currently returns 'COALESCE(l.descrizione_plain,
l.descrizione)' which treats empty strings as valid values and prevents falling
back; update the return expression in descriptionExpr (and keep using the cached
self::$hasDescrizionePlain logic) to use a blank-safe fallback:
'COALESCE(NULLIF(l.descrizione_plain, ''), l.descrizione)' so empty
descrizione_plain values will fall back to l.descrizione everywhere this helper
is used.
app/Controllers/LibriApiController.php (1)

45-48: Use COALESCE(NULLIF(..., ''), ...) to treat empty-string descriptions as NULL.

COALESCE(l.descrizione_plain, l.descrizione) works correctly when descrizione_plain is NULL (the default), but strip_tags() in BookRepository and LibraryThingImportController can produce empty strings when the original descrizione contains only HTML tags or whitespace. In such cases, COALESCE returns '' instead of falling back to l.descrizione. Wrapping with NULLIF(l.descrizione_plain, '') ensures consistent fallback behavior during data migrations and mixed-version scenarios.

💡 Suggested change
-            $descExpr = $this->hasTableColumn($db, 'libri', 'descrizione_plain')
-                ? 'COALESCE(l.descrizione_plain, l.descrizione)'
+            $descExpr = $this->hasTableColumn($db, 'libri', 'descrizione_plain')
+                ? "COALESCE(NULLIF(l.descrizione_plain, ''), l.descrizione)"
                 : 'l.descrizione';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriApiController.php` around lines 45 - 48, The COALESCE
expression for description should treat empty strings as NULL so fallback to
l.descrizione happens when descrizione_plain is ''. Update the $descExpr
construction in the block that calls hasTableColumn($db, 'libri',
'descrizione_plain') to use COALESCE(NULLIF(l.descrizione_plain, ''),
l.descrizione) instead of COALESCE(l.descrizione_plain, l.descrizione) so
queries (and the WHERE clause built with $descExpr) will ignore empty-string
descrizione_plain values; adjust the same expression used in the EXISTS subquery
string interpolation to keep behavior consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 700-708: The current import logic in LibraryThingImportController
collects Primary and Secondary Author into $authors and always stores them into
$result['autori'], which causes translator entries (per Secondary Author Roles)
to be misclassified and never assigned to $result['traduttore']; update the
block that builds $authors to check the 'Secondary Author Roles' value for the
secondary entry and, when that role indicates a translator, push the normalized
name (using \App\Support\AuthorNormalizer::normalize(trim(...))) into
$result['traduttore'] (concatenated with '|' when multiple) instead of into
$authors, and only push non-translator secondaries into $authors; ensure
$result['autori'] remains the '|' join of $authors and $result['traduttore'] is
set to '' when empty.

---

Duplicate comments:
In `@app/Controllers/CollaneController.php`:
- Around line 276-284: The current prepare() for $stmtSync can fail silently due
to the if ($stmtSync) guard, allowing the transaction to commit with only the
libri updates; change the flow so prepare() returning false triggers an
immediate rollback and exception: after calling $db->prepare("UPDATE collane
...") check for a falsy result and call $db->rollback() then throw a
RuntimeException (including $db->error), otherwise proceed to bind/execute/close
and let the outer transaction logic continue; ensure the rollback call targets
the same DB connection used for the libri update so the rename remains atomic.

In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1146-1163: The UPDATE statements that write to the libri table
(the prepared statement building "UPDATE libri SET ... WHERE id = ?") lack the
soft-delete guard and can race a soft-delete; modify both UPDATE branches (the
one shown and the other UPDATE around the 1229-1237 area) to append "AND
deleted_at IS NULL" to the WHERE clause so every libri update includes the
soft-delete condition; locate the UPDATE SQL in LibraryThingImportController
(the prepared statements) and add the soft-delete predicate to each WHERE id =
?.
- Around line 790-793: The current generation of descrizione_plain using
strip_tags(trim($data['Summary'])) loses spacing between block elements and
keeps HTML entities; update the logic that sets $result['descrizione_plain']
(currently computed from $result['descrizione'] after strip_tags) to: 1) decode
HTML entities with html_entity_decode(..., ENT_QUOTES|ENT_HTML5, 'UTF-8'); 2)
replace common block-level tags (e.g., p, div, br, li, h1..h6, blockquote) with
a single space or newline using preg_replace before stripping tags; 3) run
strip_tags() and then normalize internal whitespace (preg_replace('/\s+/',' ',
...)) and trim the result so descrizione_plain has real plain text spacing and
decoded entities. Ensure changes touch the code that assigns
$result['descrizione'] and $result['descrizione_plain'] and keep trim() behavior
for the original descrizione.

---

Nitpick comments:
In `@app/Controllers/FrontendController.php`:
- Around line 22-30: The descriptionExpr helper currently returns
'COALESCE(l.descrizione_plain, l.descrizione)' which treats empty strings as
valid values and prevents falling back; update the return expression in
descriptionExpr (and keep using the cached self::$hasDescrizionePlain logic) to
use a blank-safe fallback: 'COALESCE(NULLIF(l.descrizione_plain, ''),
l.descrizione)' so empty descrizione_plain values will fall back to
l.descrizione everywhere this helper is used.

In `@app/Controllers/LibriApiController.php`:
- Around line 45-48: The COALESCE expression for description should treat empty
strings as NULL so fallback to l.descrizione happens when descrizione_plain is
''. Update the $descExpr construction in the block that calls
hasTableColumn($db, 'libri', 'descrizione_plain') to use
COALESCE(NULLIF(l.descrizione_plain, ''), l.descrizione) instead of
COALESCE(l.descrizione_plain, l.descrizione) so queries (and the WHERE clause
built with $descExpr) will ignore empty-string descrizione_plain values; adjust
the same expression used in the EXISTS subquery string interpolation to keep
behavior consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 10460516-a394-43f7-87fa-bc59dc59bd62

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 624a1ec.

📒 Files selected for processing (6)
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/PublicApiController.php
  • app/Views/frontend/book-detail.php

- CollaneController::rename(): abort on prepare() failure (was silent)
- NULLIF in COALESCE for empty descrizione_plain fallback
- Better plain text: html_entity_decode + tag→space + whitespace normalize
- Soft-delete guard on all LT import UPDATE queries
- descrizione_plain conditional in all 4 LT SQL variants
- Secondary Author Roles routed to traduttore when translator
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Controllers/LibraryThingImportController.php (1)

1179-1239: ⚠️ Potential issue | 🔴 Critical

Don't treat a guarded UPDATE that matched no live row as a successful upsert.

With AND deleted_at IS NULL, a concurrent soft-delete turns this into a silent no-op. upsertBook() still returns updated, and processChunk() then deletes/recreates libri_autori for a book that no longer has a live libri row. Please fail the row in this case instead of continuing.

Also applies to: 1254-1285

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1179 - 1239,
The guarded UPDATE using "AND deleted_at IS NULL" can match zero rows (e.g. due
to concurrent soft-delete) but is currently treated as a successful upsert,
causing processChunk() to proceed and touch libri_autori for a non-live libri
row; change upsertBook() so it distinguishes a no-op UPDATE from a real update:
after executing the UPDATE statement check the statement's affected row count
(e.g. PDOStatement::rowCount()) and only return "updated" when rowCount() > 0;
if the UPDATE affected 0 rows, return a failure (or special flag) so
processChunk() will treat the row as failed instead of continuing to
delete/recreate libri_autori; apply the same fix for the other upsert path
referenced around lines 1254-1285 so all guarded UPDATEs behave the same.
🧹 Nitpick comments (1)
app/Controllers/LibraryThingImportController.php (1)

1152-1160: Cache the descrizione_plain schema probe.

SHOW COLUMNS FROM libri LIKE 'descrizione_plain' is duplicated here and runs for every book upsert. A small cached helper would remove the extra metadata round-trips on long imports and keep the insert/update branches from drifting.

Also applies to: 1301-1310

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1152 - 1160,
The code repeatedly runs SHOW COLUMNS FROM libri LIKE 'descrizione_plain' for
every upsert (using $checkCol/$hasDescPlain/$descPlainSet/$descPlainType),
causing extra DB round-trips; move this probe out of the per-book loop and cache
the boolean once (e.g., compute $hasDescPlain once at start of the import or in
a small helper method) and then reuse the cached $hasDescPlain to build
$descPlainSet/$descPlainType for all inserts/updates (also apply the same change
for the duplicate probe around lines referencing the same logic at 1301-1310).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 700-712: Export loses translator-only secondary authors because
import stores translators in result['traduttore'] but formatLibraryThingRow()
still builds "Secondary Author" only from autori_nomi and leaves "Secondary
Author Roles" empty; update formatLibraryThingRow() to check
result['traduttore'] and, when present and not already in autori_nomi, populate
the "Secondary Author" field with that name and set "Secondary Author Roles" to
"translator" (or localized equivalent), and ensure autori_nomi/Authors output
includes the primary author plus the secondary when appropriate so an imported
translator is preserved on re-export.

---

Outside diff comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1179-1239: The guarded UPDATE using "AND deleted_at IS NULL" can
match zero rows (e.g. due to concurrent soft-delete) but is currently treated as
a successful upsert, causing processChunk() to proceed and touch libri_autori
for a non-live libri row; change upsertBook() so it distinguishes a no-op UPDATE
from a real update: after executing the UPDATE statement check the statement's
affected row count (e.g. PDOStatement::rowCount()) and only return "updated"
when rowCount() > 0; if the UPDATE affected 0 rows, return a failure (or special
flag) so processChunk() will treat the row as failed instead of continuing to
delete/recreate libri_autori; apply the same fix for the other upsert path
referenced around lines 1254-1285 so all guarded UPDATEs behave the same.

---

Nitpick comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1152-1160: The code repeatedly runs SHOW COLUMNS FROM libri LIKE
'descrizione_plain' for every upsert (using
$checkCol/$hasDescPlain/$descPlainSet/$descPlainType), causing extra DB
round-trips; move this probe out of the per-book loop and cache the boolean once
(e.g., compute $hasDescPlain once at start of the import or in a small helper
method) and then reuse the cached $hasDescPlain to build
$descPlainSet/$descPlainType for all inserts/updates (also apply the same change
for the duplicate probe around lines referencing the same logic at 1301-1310).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: be38ce1d-2208-47a6-9ab1-77e40dda91f0

📥 Commits

Reviewing files that changed from the base of the PR and between 624a1ec and de04f1f.

📒 Files selected for processing (4)
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/Controllers/FrontendController.php

- formatLibraryThingRow(): export traduttore as Secondary Author with
  role "Translator" — completes import/export roundtrip (CodeRabbit #7)
- AuthController: add locale to SELECT, store in $_SESSION on login
- RememberMeMiddleware: same locale fix for auto-login via cookie
- ProfileController: read/save locale, apply to session immediately
- profile/index.php: language selector dropdown (visible when >1 locale)
- full-test.spec.js: test 18.11 sets both descrizione and descrizione_plain

Closes #97
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
app/Controllers/LibraryThingImportController.php (2)

1152-1159: Remove unused variable $descPlainType.

The variable is assigned but never used since inferBindTypes() dynamically determines bind types. This was flagged by static analysis.

🧹 Proposed fix
         $hasDescPlain = false;
         $checkCol = $db->query("SHOW COLUMNS FROM libri LIKE 'descrizione_plain'");
         if ($checkCol && $checkCol->num_rows > 0) {
             $hasDescPlain = true;
         }
         $descPlainSet = $hasDescPlain ? ', descrizione_plain = ?' : '';
-        $descPlainType = $hasDescPlain ? 's' : '';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1152 - 1159,
Remove the unused variable $descPlainType from the LibraryThingImportController
import block: keep the logic that detects the descrizione_plain column and the
$descPlainSet string, but delete the assignment "$descPlainType = $hasDescPlain
? 's' : ''" and any dead references to $descPlainType so that bind types
continue to be handled by inferBindTypes() only; ensure no other code relies on
$descPlainType before committing.

1296-1305: Remove unused variable $descPlainType.

Same issue as in updateBook() - this variable is assigned but never used.

🧹 Proposed fix
         $hasDescPlain = false;
         $checkCol = $db->query("SHOW COLUMNS FROM libri LIKE 'descrizione_plain'");
         if ($checkCol && $checkCol->num_rows > 0) {
             $hasDescPlain = true;
         }
         $descPlainCol = $hasDescPlain ? ', descrizione_plain' : '';
         $descPlainVal = $hasDescPlain ? ', ?' : '';
-        $descPlainType = $hasDescPlain ? 's' : '';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1296 - 1305,
The variable $descPlainType is never used and should be removed to avoid dead
code; locate the block that sets $hasDescPlain, $descPlainCol, $descPlainVal and
the unused $descPlainType in LibraryThingImportController (and mirror the same
change in updateBook() if present), delete the $descPlainType assignment and any
references to it, and ensure any parameter-type strings or bind operations
continue to work using only the actually-used variables ($descPlainVal and other
type strings) so the prepared statement parameter handling remains correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/ProfileController.php`:
- Around line 165-167: The session locale is only set when $locale !== null so a
removed preference leaves $_SESSION['locale'] stale; in the ProfileController
method containing the if ($locale !== null) { $_SESSION['locale'] = $locale; }
branch, update the logic to remove the session key when $locale is null (e.g.
unset($_SESSION['locale']) or assign null/empty) so the session mirrors the
cleared DB value; ensure you reference and modify $_SESSION['locale'] in that
same conditional block.

In `@app/Middleware/RememberMeMiddleware.php`:
- Around line 94-97: RememberMeMiddleware currently writes the user's locale
into $_SESSION['locale'] but does not apply it immediately, so the current
request still uses the earlier bootstrapped locale; after setting
$_SESSION['locale'] in RememberMeMiddleware (handle method), also apply the
locale to the runtime translator/locale system used by the app (for example call
the same bootstrap locale initializer or invoke the translator/app locale setter
— e.g. translator->setLocale($row['locale']) or App::setLocale($row['locale']) /
Locale::setDefault($row['locale']) depending on your app) so the remainder of
this request renders with the new language. Ensure you reference and call the
same mechanism the bootstrap uses to avoid duplicate logic.

---

Nitpick comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1152-1159: Remove the unused variable $descPlainType from the
LibraryThingImportController import block: keep the logic that detects the
descrizione_plain column and the $descPlainSet string, but delete the assignment
"$descPlainType = $hasDescPlain ? 's' : ''" and any dead references to
$descPlainType so that bind types continue to be handled by inferBindTypes()
only; ensure no other code relies on $descPlainType before committing.
- Around line 1296-1305: The variable $descPlainType is never used and should be
removed to avoid dead code; locate the block that sets $hasDescPlain,
$descPlainCol, $descPlainVal and the unused $descPlainType in
LibraryThingImportController (and mirror the same change in updateBook() if
present), delete the $descPlainType assignment and any references to it, and
ensure any parameter-type strings or bind operations continue to work using only
the actually-used variables ($descPlainVal and other type strings) so the
prepared statement parameter handling remains correct.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 78b910b8-b934-4e2f-90b9-511129d58236

📥 Commits

Reviewing files that changed from the base of the PR and between de04f1f and b7f9c04.

📒 Files selected for processing (6)
  • app/Controllers/AuthController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/ProfileController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Views/profile/index.php
  • tests/full-test.spec.js

- Remove unused $descPlainType in updateBook() and insertBook()
- unset($_SESSION['locale']) when profile locale cleared to null
- Apply I18n::setLocale() immediately in RememberMeMiddleware
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/full-test.spec.js (1)

2375-2384: ⚠️ Potential issue | 🟠 Major

Make this fixture prove descrizione_plain is the field being searched.

With the same token in both columns, this still passes if the API keeps using descrizione. Seed HTML in descrizione that breaks the contiguous search term, and keep the searchable plain text only in descrizione_plain.

🧪 Example adjustment
   test.skip(!targetId, 'No seeded book id available');
-  const marker = `desc-${RUN_ID}`;
-  dbQuery(`UPDATE libri SET descrizione='${marker}', descrizione_plain='${marker}' WHERE id=${targetId}`);
+  const searchTerm = `desc ${RUN_ID}`;
+  dbQuery(
+    `UPDATE libri SET descrizione='<p>desc <strong>${RUN_ID}</strong></p>', descrizione_plain='${searchTerm}' WHERE id=${targetId}`
+  );

   await page.goto(`${BASE}/admin/libri`);
   await page.waitForLoadState('domcontentloaded');

-  const searchTerm = marker;
   const apiResp = await page.request.get(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/full-test.spec.js` around lines 2375 - 2384, Change the test fixture so
it proves the API searches descrizione_plain, not descrizione: when calling
dbQuery update (currently using marker and targetId), set descrizione to an HTML
string that breaks the contiguous token (e.g., insert tags or whitespace inside
the token) and set descrizione_plain to the searchable marker string; keep the
searchTerm and the API request to /api/libri unchanged so the test will only
pass if the server searches descrizione_plain rather than the HTML-broken
descrizione.
♻️ Duplicate comments (2)
app/Controllers/ProfileController.php (1)

132-167: ⚠️ Potential issue | 🟠 Major

Don’t treat an omitted locale field as a locale reset.

app/Views/profile/index.php only posts locale when more than one locale is available. In single-locale installs this branch writes NULL to utenti.locale, and the success block leaves the previous $_SESSION['locale'] behind. Preserve the stored value unless the field is actually posted; when it is posted, update/unset the session and apply it immediately via the same I18n setter before building the redirect.

💡 One way to guard the update/session flow
-        $locale = trim(strip_tags((string) ($data['locale'] ?? '')));
+        $localeProvided = array_key_exists('locale', $data);
+        $locale = $localeProvided ? trim(strip_tags((string) $data['locale'])) : null;
...
         // Validate locale - only allow known locales
         $availableLocales = \App\Support\I18n::getAvailableLocales();
-        $locale = (!empty($locale) && isset($availableLocales[$locale])) ? $locale : null;
+        if ($localeProvided) {
+            $locale = ($locale !== '' && isset($availableLocales[$locale])) ? $locale : null;
+        }
-        $stmt = $db->prepare("UPDATE utenti SET nome = ?, cognome = ?, telefono = ?, data_nascita = ?, cod_fiscale = ?, sesso = ?, indirizzo = ?, locale = ? WHERE id = ?");
-        $stmt->bind_param('ssssssssi', $nome, $cognome, $telefono, $data_nascita, $cod_fiscale, $sesso, $indirizzo, $locale, $uid);
+        if ($localeProvided) {
+            $stmt = $db->prepare("UPDATE utenti SET nome = ?, cognome = ?, telefono = ?, data_nascita = ?, cod_fiscale = ?, sesso = ?, indirizzo = ?, locale = ? WHERE id = ?");
+            $stmt->bind_param('ssssssssi', $nome, $cognome, $telefono, $data_nascita, $cod_fiscale, $sesso, $indirizzo, $locale, $uid);
+        } else {
+            $stmt = $db->prepare("UPDATE utenti SET nome = ?, cognome = ?, telefono = ?, data_nascita = ?, cod_fiscale = ?, sesso = ?, indirizzo = ? WHERE id = ?");
+            $stmt->bind_param('sssssssi', $nome, $cognome, $telefono, $data_nascita, $cod_fiscale, $sesso, $indirizzo, $uid);
+        }
...
-            if ($locale !== null) {
-                $_SESSION['locale'] = $locale;
-            }
+            if ($localeProvided) {
+                if ($locale !== null) {
+                    \App\Support\I18n::setLocale($locale);
+                    $_SESSION['locale'] = $locale;
+                } else {
+                    unset($_SESSION['locale']);
+                }
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/ProfileController.php` around lines 132 - 167, The code
treats an omitted locale as an explicit reset to NULL; change the flow in
ProfileController so you only touch utenti.locale and session locale when the
locale field was actually POSTed: detect presence via the $data array (e.g.,
isset($data['locale'])), and if present validate and include locale in the
UPDATE and in the session (calling the I18n setter used elsewhere), otherwise
omit locale from the SQL UPDATE (or build a different prepared statement without
the locale column) and leave $_SESSION['locale'] and the DB value unchanged;
ensure the bind_param list and $stmt->execute() call match the chosen SQL
variant and apply the I18n setter immediately when you do update the locale.
app/Middleware/RememberMeMiddleware.php (1)

94-97: ⚠️ Potential issue | 🟠 Major

Apply the remembered locale before continuing the request.

Writing only $_SESSION['locale'] is too late for the response currently being built, so the first remember-me page can still render with the previous language. Call the same locale-application path used by the normal locale switch flow here as well.

💡 Minimal fix
-        // Load user's preferred locale into session
-        if (!empty($row['locale'])) {
-            $_SESSION['locale'] = $row['locale'];
-        }
+        // Apply the remembered locale for this same request
+        if (!empty($row['locale'])) {
+            $locale = (string) $row['locale'];
+            \App\Support\I18n::setLocale($locale);
+            $_SESSION['locale'] = $locale;
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Middleware/RememberMeMiddleware.php` around lines 94 - 97, You currently
only set $_SESSION['locale'] in RememberMeMiddleware.php, which is too late for
the current response; after setting the session key call the same
locale-application routine used by the normal locale switch flow (i.e. invoke
the function that applies the session locale to the current request/response
instead of only writing $_SESSION['locale']). Locate the existing locale
application entrypoint used elsewhere (e.g. the function/class that handles the
normal locale switch—call it
applyLocaleFromSession/applyLocale/setLocaleFromSession) and invoke it
immediately after setting $_SESSION['locale'] so the remembered locale is
applied for the current request.
🧹 Nitpick comments (2)
app/Controllers/LibraryThingImportController.php (2)

1159-1159: Remove unused $descPlainType variable.

The variable $descPlainType is assigned but never used since inferBindTypes() dynamically infers types. This dead code should be cleaned up.

🧹 Proposed fix
         $hasDescPlain = true;
     }
     $descPlainSet = $hasDescPlain ? ', descrizione_plain = ?' : '';
-    $descPlainType = $hasDescPlain ? 's' : '';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` at line 1159, Remove the
dead variable assignment for $descPlainType in LibraryThingImportController.php:
locate the line that sets $descPlainType = $hasDescPlain ? 's' : '' and delete
it because inferBindTypes() already infers types dynamically and $descPlainType
is never used; ensure no other code references $descPlainType (search for
$descPlainType) and run tests to confirm no regressions.

1304-1304: Remove unused $descPlainType variable.

Same issue as in updateBook() — this variable is assigned but never referenced.

🧹 Proposed fix
         $hasDescPlain = true;
     }
     $descPlainCol = $hasDescPlain ? ', descrizione_plain' : '';
     $descPlainVal = $hasDescPlain ? ', ?' : '';
-    $descPlainType = $hasDescPlain ? 's' : '';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` at line 1304, The variable
$descPlainType is assigned but never used in LibraryThingImportController (same
as in updateBook()); remove the unused assignment ($descPlainType =
$hasDescPlain ? 's' : '') and any other dead references or related unused
variables in the same method (and remove the duplicate unused assignment if
present in updateBook()) so there are no leftover unused variables or
meaningless logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@tests/full-test.spec.js`:
- Around line 2375-2384: Change the test fixture so it proves the API searches
descrizione_plain, not descrizione: when calling dbQuery update (currently using
marker and targetId), set descrizione to an HTML string that breaks the
contiguous token (e.g., insert tags or whitespace inside the token) and set
descrizione_plain to the searchable marker string; keep the searchTerm and the
API request to /api/libri unchanged so the test will only pass if the server
searches descrizione_plain rather than the HTML-broken descrizione.

---

Duplicate comments:
In `@app/Controllers/ProfileController.php`:
- Around line 132-167: The code treats an omitted locale as an explicit reset to
NULL; change the flow in ProfileController so you only touch utenti.locale and
session locale when the locale field was actually POSTed: detect presence via
the $data array (e.g., isset($data['locale'])), and if present validate and
include locale in the UPDATE and in the session (calling the I18n setter used
elsewhere), otherwise omit locale from the SQL UPDATE (or build a different
prepared statement without the locale column) and leave $_SESSION['locale'] and
the DB value unchanged; ensure the bind_param list and $stmt->execute() call
match the chosen SQL variant and apply the I18n setter immediately when you do
update the locale.

In `@app/Middleware/RememberMeMiddleware.php`:
- Around line 94-97: You currently only set $_SESSION['locale'] in
RememberMeMiddleware.php, which is too late for the current response; after
setting the session key call the same locale-application routine used by the
normal locale switch flow (i.e. invoke the function that applies the session
locale to the current request/response instead of only writing
$_SESSION['locale']). Locate the existing locale application entrypoint used
elsewhere (e.g. the function/class that handles the normal locale switch—call it
applyLocaleFromSession/applyLocale/setLocaleFromSession) and invoke it
immediately after setting $_SESSION['locale'] so the remembered locale is
applied for the current request.

---

Nitpick comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Line 1159: Remove the dead variable assignment for $descPlainType in
LibraryThingImportController.php: locate the line that sets $descPlainType =
$hasDescPlain ? 's' : '' and delete it because inferBindTypes() already infers
types dynamically and $descPlainType is never used; ensure no other code
references $descPlainType (search for $descPlainType) and run tests to confirm
no regressions.
- Line 1304: The variable $descPlainType is assigned but never used in
LibraryThingImportController (same as in updateBook()); remove the unused
assignment ($descPlainType = $hasDescPlain ? 's' : '') and any other dead
references or related unused variables in the same method (and remove the
duplicate unused assignment if present in updateBook()) so there are no leftover
unused variables or meaningless logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 581e6923-9e71-4257-82d1-d772a6fb6230

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and b7f9c04.

📒 Files selected for processing (11)
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Views/frontend/book-detail.php
  • app/Views/profile/index.php
  • tests/full-test.spec.js

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
app/Controllers/ProfileController.php (1)

171-171: Use SecureLogger::error() instead of error_log() for consistency.

Line 171 uses error_log() while the rest of this controller correctly uses SecureLogger::error() for database errors (lines 64-67, 99-102). This context involves user ID which warrants the secure logger.

Suggested fix
-            error_log("Profile update error for user $uid: " . $stmt->error);
+            SecureLogger::error('ProfileController: profile update failed', [
+                'user_id' => $uid,
+                'db_error' => $stmt->error
+            ]);

As per coding guidelines: "Use SecureLogger::error() not error_log() for sensitive contexts".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/ProfileController.php` at line 171, Replace the direct call
to error_log with the project's secure logging utility: use
SecureLogger::error(...) in the ProfileController where the DB error is handled
(replace the error_log("Profile update error for user $uid: " . $stmt->error)
call). Ensure the message includes the same context (the $uid and $stmt->error)
and follows the existing SecureLogger::error usage pattern used elsewhere in
this controller so sensitive data is logged consistently and securely.
app/Controllers/FrontendController.php (1)

20-31: Extract this schema probe into a shared helper.

LibriApiController now carries a second cached column-existence path for the same descrizione_plain fallback. Keeping these checks duplicated is an easy way for admin and catalog search to drift again later; a shared helper/service would keep the behavior aligned.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/FrontendController.php` around lines 20 - 31, Extract the
duplicated "descrizione_plain" existence probe into a shared helper/service so
both FrontendController::descriptionExpr and LibriApiController can reuse it;
create a single utility (e.g., a SchemaHelper or DescriptionFallback service)
with a cached method like hasColumn(mysqli $db, string $table, string $column)
or descriptionExpression(mysqli $db) that performs the SHOW COLUMNS check and
caches the boolean, then replace the inline logic in
FrontendController::descriptionExpr and the corresponding method in
LibriApiController to call the shared helper's method to return the same
COALESCE/NULLIF expression or 'l.descrizione'.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/full-test.spec.js`:
- Line 2376: The test currently runs dbQuery(`UPDATE libri SET
descrizione='${marker}', descrizione_plain='${marker}' WHERE id=${targetId}`)
which assumes the descrizione_plain column exists; instead run a schema check
(e.g., via dbQuery("PRAGMA table_info('libri')") or equivalent
information_schema query) to detect whether 'descrizione_plain' is present and
only include the descrizione_plain=... assignment when that check returns true,
otherwise issue an UPDATE that sets only descrizione; keep using the existing
variables marker and targetId and the dbQuery helper so the test exercises the
same compatibility fallback path as production.

---

Nitpick comments:
In `@app/Controllers/FrontendController.php`:
- Around line 20-31: Extract the duplicated "descrizione_plain" existence probe
into a shared helper/service so both FrontendController::descriptionExpr and
LibriApiController can reuse it; create a single utility (e.g., a SchemaHelper
or DescriptionFallback service) with a cached method like hasColumn(mysqli $db,
string $table, string $column) or descriptionExpression(mysqli $db) that
performs the SHOW COLUMNS check and caches the boolean, then replace the inline
logic in FrontendController::descriptionExpr and the corresponding method in
LibriApiController to call the shared helper's method to return the same
COALESCE/NULLIF expression or 'l.descrizione'.

In `@app/Controllers/ProfileController.php`:
- Line 171: Replace the direct call to error_log with the project's secure
logging utility: use SecureLogger::error(...) in the ProfileController where the
DB error is handled (replace the error_log("Profile update error for user $uid:
" . $stmt->error) call). Ensure the message includes the same context (the $uid
and $stmt->error) and follows the existing SecureLogger::error usage pattern
used elsewhere in this controller so sensitive data is logged consistently and
securely.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e19a3cf3-a053-4972-9461-a75d644a52b7

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 3fd5360.

📒 Files selected for processing (11)
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Views/frontend/book-detail.php
  • app/Views/profile/index.php
  • tests/full-test.spec.js

- ProfileController: only touch locale when field is POSTed (single-locale
  installs don't render the select, so omitted locale was resetting to NULL)
- ProfileController: error_log → SecureLogger::error per project rules
- Test 18.11: HTML in descrizione breaks token, only descrizione_plain
  is searchable — proves COALESCE fallback works correctly
- Test 18.11: schema-aware UPDATE for descrizione_plain column
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/ProfileController.php`:
- Around line 160-167: The code calls $db->prepare(...) and immediately
$stmt->bind_param(...) in both the $localeProvided and else branches without
checking for prepare() failure; update ProfileController to verify that $stmt is
a valid statement (non-false) after each $db->prepare(...) call and handle
failures (log the error via your logger or $db->error and return/throw an
appropriate response) before calling $stmt->bind_param(...); ensure both the
branch creating a statement with locale and the branch without locale perform
this check and short-circuit on failure to avoid calling bind_param on a false
value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 225f8b70-dc38-49ab-bee1-2b90d46862f3

📥 Commits

Reviewing files that changed from the base of the PR and between 3fd5360 and 682f0f4.

📒 Files selected for processing (2)
  • app/Controllers/ProfileController.php
  • tests/full-test.spec.js

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

✅ Actions performed

Full review triggered.

Check $stmt before bind_param() to avoid fatal error on prepare failure,
consistent with existing pattern in changePassword().
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 24, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
app/Controllers/AuthController.php (1)

109-112: Missing I18n::setLocale() call creates inconsistency with RememberMeMiddleware.

The locale is stored in $_SESSION['locale'] but I18n::setLocale() is not called, unlike RememberMeMiddleware (lines 94-98 in that file) which does both. This means any content rendered in the login response (e.g., flash messages, redirected page content on same request) will use the default locale rather than the user's preference.

While the bootstrap code in public/index.php will apply the locale on subsequent requests, this asymmetry could cause the first post-login response to render in the wrong language if it includes localized content.

♻️ Suggested fix to match RememberMeMiddleware behavior
                 // Load user's preferred locale into session
                 if (!empty($row['locale'])) {
+                    $locale = (string) $row['locale'];
+                    \App\Support\I18n::setLocale($locale);
-                    $_SESSION['locale'] = $row['locale'];
+                    $_SESSION['locale'] = $locale;
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/AuthController.php` around lines 109 - 112, When storing the
user's preferred locale in $_SESSION within AuthController (when $row['locale']
is set), also call I18n::setLocale($row['locale']) so the current request uses
the user's locale (matching RememberMeMiddleware's behavior); update the block
that checks $row['locale'] to both set $_SESSION['locale'] and call
I18n::setLocale with that value to ensure flash messages and immediate response
rendering use the correct locale.
app/Controllers/ProfileController.php (1)

227-227: Inconsistent error logging: use SecureLogger::error() for sensitive contexts.

This method uses error_log() while the update() method correctly uses SecureLogger::error(). For consistency and to comply with the coding guideline about sensitive contexts, consider updating this and the other API methods (revokeSession, revokeAllSessions) to use SecureLogger::error().

♻️ Suggested fix
-            error_log("getSessions error for user $uid: " . $e->getMessage());
+            SecureLogger::error('ProfileController: getSessions failed', [
+                'user_id' => $uid,
+                'error' => $e->getMessage()
+            ]);

Similar changes for lines 272 and 307.

As per coding guidelines: "Use SecureLogger::error() not error_log() for sensitive contexts"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/ProfileController.php` at line 227, Replace the use of PHP's
global error_log() with SecureLogger::error() in the controller methods that
handle sensitive session operations: getSessions, revokeSession, and
revokeAllSessions; locate the error_log(...) calls (e.g., the one inside
getSessions that logs "$uid: " . $e->getMessage()) and change them to call
SecureLogger::error() with the same contextual message and include the exception
message/metadata so logs remain informative while conforming to the secure
logging guideline.
app/Controllers/LibraryThingImportController.php (1)

1152-1159: Cache the descrizione_plain schema probe.

SHOW COLUMNS FROM libri LIKE 'descrizione_plain' now runs on every book write. On larger LibraryThing imports that turns schema introspection into a hot path for no functional benefit; cache the result once per request/controller and reuse it in both methods.

Also applies to: 1295-1303

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1152 - 1159,
The schema probe "SHOW COLUMNS FROM libri LIKE 'descrizione_plain'" is being
executed on every book write; compute it once and cache the boolean on the
controller instance (e.g. add a private property $hasDescPlain and a getter like
getHasDescPlain() that performs the query lazily) so both places use the cached
value instead of re-running the query; replace local $hasDescPlain and repeated
query logic with calls to the cached property/getter and keep $descPlainSet =
$this->getHasDescPlain() ? ', descrizione_plain = ?' : '' where needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/FrontendController.php`:
- Around line 22-31: The schema probe in descriptionExpr currently assumes
$db->query will return false on failure but with MYSQLI_REPORT_STRICT enabled it
can throw mysqli_sql_exception; wrap the $db->query(...) call in a try/catch
(catch \Throwable) around the block that sets self::$hasDescrizionePlain so that
on any exception you set self::$hasDescrizionePlain = false and proceed to
return the fallback 'l.descrizione'; ensure you catch exceptions around the
specific query call inside descriptionExpr and do not change the returned string
logic.

In `@app/Controllers/LibraryThingImportController.php`:
- Around line 797-800: When handling scraped descriptions, ensure the normalized
plain text is written to the descrizione_plain column instead of only storing
raw HTML in descrizione: compute the same HTML→plain normalization you already
use (same preg_replace/html_entity_decode/trim flow) and assign it to
$result['descrizione_plain'] when hasDescrizionePlainColumn() returns true; also
reuse the cached schema probe behind hasDescrizionePlainColumn() so updateBook()
/ insertBook() will include descrizione_plain in their DB writes (apply the same
fix for the other occurrence around the 1555–1562 block).
- Around line 705-712: The current logic treats the entire $data['Secondary
Author'] string as one token and classifies it as a translator if any of the
combined roles contains "translator", which drops co-authors; instead split the
secondary authors and their roles pairwise: explode $data['Secondary Author'] on
';' into an array, explode $data['Secondary Author Roles'] on ';' into a
parallel array, trim each element, then iterate by index and for each pair check
the role string (e.g. $secondaryRolesParts[$i]) for 'translator'/'traduttore'
and push the corresponding normalized author into $result['traduttore'] or into
$authors accordingly (handling unequal lengths by treating missing role as
non-translator); apply the same change to the other identical block that uses
the same variables ($secondaryAuthor, $secondaryRoles, $result['traduttore'],
$authors).

---

Nitpick comments:
In `@app/Controllers/AuthController.php`:
- Around line 109-112: When storing the user's preferred locale in $_SESSION
within AuthController (when $row['locale'] is set), also call
I18n::setLocale($row['locale']) so the current request uses the user's locale
(matching RememberMeMiddleware's behavior); update the block that checks
$row['locale'] to both set $_SESSION['locale'] and call I18n::setLocale with
that value to ensure flash messages and immediate response rendering use the
correct locale.

In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1152-1159: The schema probe "SHOW COLUMNS FROM libri LIKE
'descrizione_plain'" is being executed on every book write; compute it once and
cache the boolean on the controller instance (e.g. add a private property
$hasDescPlain and a getter like getHasDescPlain() that performs the query
lazily) so both places use the cached value instead of re-running the query;
replace local $hasDescPlain and repeated query logic with calls to the cached
property/getter and keep $descPlainSet = $this->getHasDescPlain() ? ',
descrizione_plain = ?' : '' where needed.

In `@app/Controllers/ProfileController.php`:
- Line 227: Replace the use of PHP's global error_log() with
SecureLogger::error() in the controller methods that handle sensitive session
operations: getSessions, revokeSession, and revokeAllSessions; locate the
error_log(...) calls (e.g., the one inside getSessions that logs "$uid: " .
$e->getMessage()) and change them to call SecureLogger::error() with the same
contextual message and include the exception message/metadata so logs remain
informative while conforming to the secure logging guideline.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3af0875b-7e04-4796-8817-a7a5ad1a83e0

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 50a3fee.

📒 Files selected for processing (11)
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Views/frontend/book-detail.php
  • app/Views/profile/index.php
  • tests/full-test.spec.js

- AuthController: call I18n::setLocale() on login (consistent with RememberMe)
- FrontendController: try/catch on schema probe (MYSQLI_REPORT_STRICT)
- ProfileController: error_log → SecureLogger::error in 3 API methods
- LibraryThingImportController: cache hasDescPlain via instance method
- LibraryThingImportController: scraping path generates descrizione_plain
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/Controllers/FrontendController.php (1)

20-38: Consider sharing the descrizione_plain column probe.

FrontendController now has a second schema-detection path for the same feature that app/Controllers/LibriApiController.php::hasTableColumn() already handles. Pulling this behind a shared helper would reduce drift between frontend and admin search behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/FrontendController.php` around lines 20 - 38, The
descriptionExpr method duplicates schema-probing logic already implemented by
LibriApiController::hasTableColumn; extract or reuse that probe so both
controllers share one implementation. Replace the inline probe in
FrontendController::descriptionExpr (and remove the static $hasDescrizionePlain)
with a call to the shared helper (e.g. SchemaInspector::hasTableColumn or
LibriApiController::hasTableColumn) to check for the libri.descrizione_plain
column, and use its boolean result to return the same COALESCE expression;
ensure the helper accepts a mysqli (or wraps connection handling) and caches
results to preserve current behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Views/frontend/book-detail.php`:
- Around line 254-256: Replace the current use of $bookSchema["issn"] when
$book['issn'] is set by adding an identifier entry on the Book JSON-LD object
using a PropertyValue structure: set $bookSchema["identifier"] to an object with
"@type" = "PropertyValue", "propertyID" = "ISSN" and "value" = $book['issn'];
remove the $bookSchema["issn"] assignment and ensure this runs in the same
conditional that checks !empty($book['issn']) in book-detail.php so the ISSN is
represented as a schema.org identifier rather than an issn property.

---

Nitpick comments:
In `@app/Controllers/FrontendController.php`:
- Around line 20-38: The descriptionExpr method duplicates schema-probing logic
already implemented by LibriApiController::hasTableColumn; extract or reuse that
probe so both controllers share one implementation. Replace the inline probe in
FrontendController::descriptionExpr (and remove the static $hasDescrizionePlain)
with a call to the shared helper (e.g. SchemaInspector::hasTableColumn or
LibriApiController::hasTableColumn) to check for the libri.descrizione_plain
column, and use its boolean result to return the same COALESCE expression;
ensure the helper accepts a mysqli (or wraps connection handling) and caches
results to preserve current behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a98fe829-6527-4088-b72e-8a7c7e5502b4

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 8c22460.

📒 Files selected for processing (11)
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Views/frontend/book-detail.php
  • app/Views/profile/index.php
  • tests/full-test.spec.js

Schema.org "issn" property is defined for serial publications
(CreativeWorkSeries, Periodical), not Book. Use "identifier" with
PropertyValue structure instead.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Controllers/LibraryThingImportController.php (1)

1368-1369: ⚠️ Potential issue | 🟠 Major

Abort the import when the guarded libri update no longer matches an active row.

With the new AND deleted_at IS NULL predicates, a book soft-deleted after findExistingBook() but before updateBook() turns this execute into a no-op. upsertBook() still reports updated, and processChunk() then rebuilds author links for that deleted row in the same transaction. Detect the “row no longer active” case here and throw so the transaction rolls back instead of committing a partial update.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1368 - 1369,
After calling $stmt->execute() in the guarded libri update (the block reached
from upsertBook()/updateBook() after findExistingBook()), check the statement’s
affected rows (e.g. $stmt->affected_rows or $mysqli->affected_rows) immediately
before $stmt->close(); if it is 0, throw an exception (RuntimeException or
similar) so the outer transaction aborts instead of continuing; this ensures
processChunk() will not rebuild author links for a row that was soft-deleted
between findExistingBook() and updateBook().
♻️ Duplicate comments (1)
app/Views/admin/plugins.php (1)

220-231: ⚠️ Potential issue | 🟠 Major

Resolve plugin settings endpoints with a named route, not a hardcoded path string.

Wrapping '/admin/plugins/.../settings' in url() still hardcodes the route. If admin routes are translated or renamed, these settings modals break again. Please emit the endpoint via route_path(...) or RouteTranslator::route(...) instead of concatenating the path here.

As per coding guidelines, Never use hardcoded routes. Use route_path('key') or RouteTranslator::route('key') instead of hardcoded paths like /accedi or /login.

Also applies to: 244-245, 262-262, 286-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/admin/plugins.php` around lines 220 - 231, The data-settings-url
attribute is built from a hardcoded path string; replace the
url('/admin/plugins/.../settings') usage with a named-route resolver (route_path
with the admin plugins settings route key or RouteTranslator::route) and pass
the plugin id as a parameter so the settings endpoint is emitted from the
router, e.g. use route_path('admin.plugins.settings', ['id' =>
(int)$plugin['id']]) (or RouteTranslator::route equivalent) for the
data-settings-url used by the openPluginSettingsModal button, and apply the same
change to the other occurrences noted (around the other data-settings-url
attributes at the referenced blocks).
🧹 Nitpick comments (4)
tests/helpers/e2e-fixtures.js (2)

87-92: getPluginIdByName returns NaN if plugin not found.

When the plugin doesn't exist, dbQuery returns an empty string, and Number('') returns 0 (not NaN as I initially thought). However, 0 is a falsy value that may not clearly indicate "not found" vs. a plugin with id=0.

Consider returning null or throwing when the plugin isn't found to make the failure mode explicit:

💡 Optional improvement
 function getPluginIdByName(name) {
   const result = dbQuery(
     `SELECT id FROM plugins WHERE name = '${escapeSql(name)}' LIMIT 1`
   );
-  return Number(result);
+  if (!result || result.trim() === '') {
+    return null;
+  }
+  return Number(result);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/e2e-fixtures.js` around lines 87 - 92, getPluginIdByName
currently converts dbQuery result to Number which yields 0 for empty string and
masks "not found"; update getPluginIdByName to explicitly handle empty/falsey
dbQuery responses from dbQuery( ... escapeSql(name) ... ) by checking the raw
result first and then either returning null (or throwing an explicit Error) when
no row is returned, otherwise parse and return Number(id); keep the function
name getPluginIdByName and the dbQuery/escapeSql usage unchanged while making
the not-found branch explicit.

70-74: escapeSql handles basic cases but consider edge cases for robustness.

The current implementation escapes backslashes and single quotes, which covers common cases. However, it doesn't handle null bytes (\0) or other special characters that could cause issues in MySQL.

For test fixtures with controlled inputs, this is acceptable. If you ever need to handle arbitrary data, consider using a more complete escape implementation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/e2e-fixtures.js` around lines 70 - 74, The escapeSql function
only escapes backslashes and single quotes; update escapeSql to also handle null
bytes and other MySQL special characters (e.g., \0, \n, \r, \x1a) by replacing
them with their escaped equivalents, or switch to using a proven library escape
function (e.g., mysql.escape) or parameterized queries; locate the function
escapeSql in tests/helpers/e2e-fixtures.js and either extend its replace chain
to include replacements for \0, \n, \r, and \x1a or call the library escape
helper so arbitrary input is safely escaped for MySQL in tests.
app/Controllers/LibriApiController.php (1)

663-701: Consider extracting shared column detection logic.

Both LibriApiController::hasTableColumn() and FrontendController::descriptionExpr() implement column existence detection but use different approaches (INFORMATION_SCHEMA.COLUMNS vs SHOW COLUMNS). While both are functionally correct, consolidating into a shared utility would improve maintainability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriApiController.php` around lines 663 - 701, Extract the
column-detection logic used in LibriApiController::hasTableColumn and
FrontendController::descriptionExpr into a single reusable utility (e.g.,
SchemaUtils::getTableColumns or DBSchema::columnExists) that accepts a mysqli,
table name and returns cached column names or a boolean, centralizes the
whitelist check, and uses a single safe query strategy (INFORMATION_SCHEMA or
SHOW COLUMNS) with prepared statements; update
LibriApiController::hasTableColumn and FrontendController::descriptionExpr to
call the new utility and remove duplicated code while preserving existing
caching and logging behavior (use the existing tableColCache semantics moved
into the utility).
app/Controllers/PluginController.php (1)

392-421: Domain validation excludes localhost and IP addresses.

The hostname regex requires a valid domain with TLD (at least one dot), which means localhost, 127.0.0.1, or internal IPs won't be accepted. This appears intentional for external mirror domains but may limit testing scenarios.

If local testing with custom hosts is needed, consider documenting this restriction or adding an exception for development environments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/PluginController.php` around lines 392 - 421,
normalizeGoodLibDomain currently rejects 'localhost' and IP addresses because
the hostname regex enforces a dotted TLD domain; update normalizeGoodLibDomain
to allow localhost and numeric IPs by adding an early-accept branch: after
parsing $parts and obtaining $host, if $host === 'localhost' or it matches an
IPv4/IPv6 pattern accept and return $host (with port if present via $port),
otherwise fall back to the existing domain regex; optionally gate the
localhost/IP acceptance behind a dev check (e.g., APP_ENV === 'local') if you
want to restrict it to development.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/AuthController.php`:
- Around line 109-114: The session locale is being set from the DB even if
\App\Support\I18n::setLocale($locale) fails; modify the block around
AuthController:: (the code using $row['locale'], \App\Support\I18n::setLocale
and $_SESSION['locale']) so that you only assign $_SESSION['locale'] after
confirming setLocale succeeded — either by checking a boolean return value from
setLocale (if it returns true/false) or by wrapping the call in try/catch and
setting $_SESSION['locale'] inside the success path (or after no exception is
thrown); ensure you use the same $locale value and do not persist the raw DB
value on failure.

In `@app/Controllers/LibraryThingImportController.php`:
- Around line 746-760: normalizeContributorField currently combines multiple
translators into a semicolon-joined string which breaks consumers expecting a
single name; to avoid regressing callers (e.g., the frontend book-detail
template that treats libri.traduttore as one Person) change
normalizeContributorField to preserve the single-value contract: use
splitContributorList($value, '/\s*[;|]\s*/u') and AuthorNormalizer::normalize on
parts as you already do, but return only the first non-empty, unique normalized
name (or null) instead of imploding all names; alternatively, if you
intentionally want multi-value behavior, update downstream consumers to split on
';' and render each name as a separate Person—pick one approach and make
matching changes to normalizeContributorField or the consumer rendering to keep
behavior consistent.

In `@locale/en_US.json`:
- Around line 972-975: The English translation for the key "Mostra badge nella
pagina dettaglio libro" uses singular "Show badge on the book detail page" and
should be changed to the plural form to match the other labels; update that
value to "Show badges on the book detail page" so it aligns with the related
entries ("Mostra i badge nella pagina dettaglio libro del catalogo", "Mostra i
badge nella scheda libro dell'area amministrazione") and maintains consistent UI
copy.

In `@locale/it_IT.json`:
- Line 95: Replace the misspelled Italian word in the JSON value for the string
"Sono accettati anche domini personalizzati; se incolli un URL completo verra
salvato solo l'host." by changing "verra" to "verrà" so the value reads "Sono
accettati anche domini personalizzati; se incolli un URL completo verrà salvato
solo l'host."; update the same key/value entry in locale/it_IT.json ensuring
proper UTF-8 encoding and escaping if needed.

In `@tests/branch-fix-consistency.spec.js`:
- Around line 6-8: The test uses a fallback default for BASE_URL which can mask
misconfiguration; update the BASE_URL initialization in
tests/branch-fix-consistency.spec.js (referencing the existing HARNESS constant
and BASE_URL variable) to require an explicit env var: if neither
process.env.E2E_BASE_URL nor process.env.APP_URL is set, throw an informative
error and exit the test run instead of assigning 'http://localhost:8081'; this
ensures the harness that mutates the real DB fails fast when the E2E base URL is
missing.

In `@tests/helpers/branch-fix-harness.php`:
- Around line 292-300: The LibraryThing scenarios call reflected private methods
(from getLibraryThingMethods()) that rely on
LibraryThingInstaller::isInstalled($db); before invoking
insertBook()/updateBook() via the reflected methods, ensure the plugin schema is
installed/asserted by running the installer check/install path (call the
installer install/assert method for LibraryThingInstaller against $db) so the
tests exercise the LT schema branch; add this install/assert step immediately
after creating the test DB and before calling $parse->invoke(...) and before the
other reflected calls (also add the same install/assert at the other referenced
spot around the code near the 438-439 mention).

---

Outside diff comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1368-1369: After calling $stmt->execute() in the guarded libri
update (the block reached from upsertBook()/updateBook() after
findExistingBook()), check the statement’s affected rows (e.g.
$stmt->affected_rows or $mysqli->affected_rows) immediately before
$stmt->close(); if it is 0, throw an exception (RuntimeException or similar) so
the outer transaction aborts instead of continuing; this ensures processChunk()
will not rebuild author links for a row that was soft-deleted between
findExistingBook() and updateBook().

---

Duplicate comments:
In `@app/Views/admin/plugins.php`:
- Around line 220-231: The data-settings-url attribute is built from a hardcoded
path string; replace the url('/admin/plugins/.../settings') usage with a
named-route resolver (route_path with the admin plugins settings route key or
RouteTranslator::route) and pass the plugin id as a parameter so the settings
endpoint is emitted from the router, e.g. use
route_path('admin.plugins.settings', ['id' => (int)$plugin['id']]) (or
RouteTranslator::route equivalent) for the data-settings-url used by the
openPluginSettingsModal button, and apply the same change to the other
occurrences noted (around the other data-settings-url attributes at the
referenced blocks).

---

Nitpick comments:
In `@app/Controllers/LibriApiController.php`:
- Around line 663-701: Extract the column-detection logic used in
LibriApiController::hasTableColumn and FrontendController::descriptionExpr into
a single reusable utility (e.g., SchemaUtils::getTableColumns or
DBSchema::columnExists) that accepts a mysqli, table name and returns cached
column names or a boolean, centralizes the whitelist check, and uses a single
safe query strategy (INFORMATION_SCHEMA or SHOW COLUMNS) with prepared
statements; update LibriApiController::hasTableColumn and
FrontendController::descriptionExpr to call the new utility and remove
duplicated code while preserving existing caching and logging behavior (use the
existing tableColCache semantics moved into the utility).

In `@app/Controllers/PluginController.php`:
- Around line 392-421: normalizeGoodLibDomain currently rejects 'localhost' and
IP addresses because the hostname regex enforces a dotted TLD domain; update
normalizeGoodLibDomain to allow localhost and numeric IPs by adding an
early-accept branch: after parsing $parts and obtaining $host, if $host ===
'localhost' or it matches an IPv4/IPv6 pattern accept and return $host (with
port if present via $port), otherwise fall back to the existing domain regex;
optionally gate the localhost/IP acceptance behind a dev check (e.g., APP_ENV
=== 'local') if you want to restrict it to development.

In `@tests/helpers/e2e-fixtures.js`:
- Around line 87-92: getPluginIdByName currently converts dbQuery result to
Number which yields 0 for empty string and masks "not found"; update
getPluginIdByName to explicitly handle empty/falsey dbQuery responses from
dbQuery( ... escapeSql(name) ... ) by checking the raw result first and then
either returning null (or throwing an explicit Error) when no row is returned,
otherwise parse and return Number(id); keep the function name getPluginIdByName
and the dbQuery/escapeSql usage unchanged while making the not-found branch
explicit.
- Around line 70-74: The escapeSql function only escapes backslashes and single
quotes; update escapeSql to also handle null bytes and other MySQL special
characters (e.g., \0, \n, \r, \x1a) by replacing them with their escaped
equivalents, or switch to using a proven library escape function (e.g.,
mysql.escape) or parameterized queries; locate the function escapeSql in
tests/helpers/e2e-fixtures.js and either extend its replace chain to include
replacements for \0, \n, \r, and \x1a or call the library escape helper so
arbitrary input is safely escaped for MySQL in tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 44961bb8-686f-43ff-80f9-c6910d56803e

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 4730b47.

📒 Files selected for processing (29)
  • .gitignore
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Support/PluginManager.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • app/Views/profile/index.php
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/goodlib/GoodLibPlugin.php
  • storage/plugins/goodlib/plugin.json
  • storage/plugins/goodlib/views/badges.php
  • storage/plugins/goodlib/views/settings.php
  • storage/plugins/goodlib/wrapper.php
  • tests/branch-fix-consistency.spec.js
  • tests/full-test.spec.js
  • tests/goodlib-custom-domains.spec.js
  • tests/helpers/branch-fix-harness.php
  • tests/helpers/e2e-fixtures.js

- Add missing i18n keys: "Cerca su:" and 'Cerca "%s" su %s' (it/en/de)
- badges.php: replace inline style with Tailwind text-[0.6rem]
- settings.php: inline bg colors → Tailwind bg-red-500/bg-blue-500/bg-green-600
- settings.php: escape checkbox name attr, add maxlength/pattern on domain inputs
- TLD regex: disallow leading/trailing hyphens (RFC compliance)
- RememberMeMiddleware: match AuthController order (setLocale before session)
- Test: replace hardcoded /accedi and /libro/ routes with locale-independent URLs
- AuthController: persist session locale only when setLocale() succeeds
- LT normalizeContributorField: return first name only (single-value contract)
- LT updateBook: throw on affected_rows=0 (race with soft-delete)
- i18n: fix "verra" → "verrà" typo in it/en/de (key + value + view)
- i18n: "badge" → "badges" in en_US for consistency
- branch-fix-consistency: fail-fast when E2E_BASE_URL missing
- branch-fix-harness: install LT schema before LT scenarios
When a book has ISBN13 or ISBN10, Anna's Archive and Z-Library badges
link to the ISBN search (exact edition match). Gutenberg always uses
title+author (ISBN search not supported). Falls back to title+author
when no ISBN is available.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Support/PluginManager.php (1)

45-52: ⚠️ Potential issue | 🟠 Major

Adding goodlib here auto-enables it on upgrade.

autoRegisterBundledPlugins() doesn't just make bundled plugins discoverable: it inserts missing records with is_active = 1 and immediately runs onInstall() / onActivate(). Because this PR also ships storage/plugins/goodlib/, upgrading will turn GoodLib on everywhere instead of merely making it available in the admin UI. Keep optional plugins out of the auto-activated bundled list, or split bundled registration from default activation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/PluginManager.php` around lines 45 - 52, The BUNDLED_PLUGINS
constant currently includes 'goodlib' which causes autoRegisterBundledPlugins()
to insert and immediately activate it (calling onInstall()/onActivate()); remove
'goodlib' from the BUNDLED_PLUGINS array or modify autoRegisterBundledPlugins()
to distinguish "bundled but not auto-activate" plugins so that bundled discovery
and DB insertion does not set is_active = 1 or call onActivate() for optional
plugins (refer to BUNDLED_PLUGINS constant and the autoRegisterBundledPlugins(),
onInstall(), onActivate() code paths).
♻️ Duplicate comments (3)
locale/en_US.json (1)

975-975: ⚠️ Potential issue | 🟡 Minor

Use plural wording for badge visibility label.

This string is inconsistent with neighboring badge labels that use plural phrasing.

✏️ Proposed text fix
-  "Mostra badge nell'area amministrazione": "Show badge in the admin area",
+  "Mostra badge nell'area amministrazione": "Show badges in the admin area",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@locale/en_US.json` at line 975, Update the English translation value for the
JSON entry keyed by "Mostra badge nell'area amministrazione" to use plural
phrasing; replace "Show badge in the admin area" with "Show badges in the admin
area" so it matches neighboring plural badge labels.
app/Middleware/RememberMeMiddleware.php (1)

94-99: ⚠️ Potential issue | 🟡 Minor

Only persist the locale when setLocale() succeeds.

Regular login already guards $_SESSION['locale'] behind I18n::setLocale(), but remember-me still writes the raw DB value unconditionally. If the stored locale is invalid or has been removed, the translator keeps the old locale while the session claims a different one.

💡 Suggested fix
         if (!empty($row['locale'])) {
             $locale = (string) $row['locale'];
-            \App\Support\I18n::setLocale($locale);
-            $_SESSION['locale'] = $locale;
+            if (\App\Support\I18n::setLocale($locale)) {
+                $_SESSION['locale'] = $locale;
+            } else {
+                unset($_SESSION['locale']);
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Middleware/RememberMeMiddleware.php` around lines 94 - 99,
RememberMeMiddleware currently writes $_SESSION['locale'] directly from
$row['locale'] even if \App\Support\I18n::setLocale($locale) fails; change the
code to call I18n::setLocale((string)$row['locale']) and only assign
$_SESSION['locale'] when that call returns truthy (or succeeds), and optionally
catch any exception from I18n::setLocale to avoid persisting an invalid locale;
this ensures $_SESSION['locale'] is updated only when setLocale actually applied
the locale.
app/Views/admin/plugins.php (1)

218-221: ⚠️ Potential issue | 🟠 Major

Replace the raw /admin/plugins/.../settings paths with route helpers.

These data-settings-url attributes hardcode the admin path again, so any route translation/rename breaks modal saves. Build them from route_path(...) / RouteTranslator::route(...) instead of embedding the path literal. If there isn't a route key yet, add one rather than keeping the literal in the view.

As per coding guidelines, Never use hardcoded routes. Use route_path('key') or RouteTranslator::route('key') instead of hardcoded paths like /accedi or /login.

Also applies to: 229-232, 241-245, 260-262, 282-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/admin/plugins.php` around lines 218 - 221, Replace hardcoded
data-settings-url values that build "/admin/plugins/{id}/settings" with a route
helper call: construct the URL via route_path('admin.plugins.settings', ['id' =>
(int) $plugin['id']]) or RouteTranslator::route('admin.plugins.settings', ['id'
=> (int) $plugin['id']]) and use htmlspecialchars on that output; update each
occurrence that sets data-settings-url (the attributes in the same block that
call onclick="openPluginSettingsModal(this)") and if the named route key
'admin.plugins.settings' doesn't exist, add that route key in your routes config
so the view can call the helper instead of hardcoding the path.
🧹 Nitpick comments (5)
app/Controllers/LibraryThingImportController.php (1)

885-888: Extract descrizione_plain normalization into one helper.

The same HTML→plain-text pipeline now exists in both import parsing and scrape enrichment. One private method will keep those paths aligned when the normalization rules change.

Also applies to: 1646-1652

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 885 - 888,
Extract the HTML→plain-text normalization into a single private helper on the
LibraryThingImportController (e.g. private function normalizeDescrizione(?string
$html): ?string) that: returns null for empty input, strips tags (preg_replace
'/<[^>]+>/' → ' '), runs html_entity_decode(..., ENT_QUOTES|ENT_HTML5, 'UTF-8'),
collapses whitespace with preg_replace('/\s+/u', ' ', ...) and returns trim(...)
or null; then replace the inline logic that sets $result['descrizione_plain']
(the spot using $result['descrizione'] and the duplicate occurrence in the
scrape enrichment code) to call
$this->normalizeDescrizione($result['descrizione']) and assign the result.
.gitignore (1)

279-285: Prefer a pattern-based allowlist for top-level specs.

These rules already miss tests/full-test.spec.js; tracked files keep working, but every new top-level Playwright spec now needs another .gitignore edit and is easy to forget.

♻️ Suggested cleanup
 tests/*
-!tests/branch-fix-consistency.spec.js
-!tests/goodlib-custom-domains.spec.js
+!tests/*.spec.js
 !tests/helpers/
 tests/helpers/*
 !tests/helpers/branch-fix-harness.php
 !tests/helpers/e2e-fixtures.js
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gitignore around lines 279 - 285, The .gitignore currently ignores tests/*
and then individually negates specific top-level spec files (e.g.,
tests/branch-fix-consistency.spec.js, tests/goodlib-custom-domains.spec.js),
which forces manual updates for each new top-level Playwright spec; update the
ignore rules to use a pattern-based allowlist instead (replace the repeated
single-file negations with a single negation like allowing all top-level spec
patterns such as !tests/*.spec.js or a more specific glob you want), keeping the
existing helpers handling (tests/helpers/ and tests/helpers/* and the explicit
helper exceptions) intact so helper files remain ignored/allowed as before.
app/Controllers/LibriApiController.php (1)

45-48: Please de-duplicate the description fallback SQL.

FrontendController already has a descriptionExpr() helper for this exact descrizione_plain/descrizione fallback. Keeping a second copy here makes catalog and admin search easy to drift apart again the next time this logic changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriApiController.php` around lines 45 - 48, Replace the
duplicated descrizione_plain/descrizione fallback in LibriApiController by
reusing the existing helper instead of re-implementing it: remove the local
$descExpr assignment that calls hasTableColumn and instead obtain the expression
via FrontendController::descriptionExpr() (or the instance helper method
descriptionExpr()) and use that result in the $where clause where {$descExpr} is
referenced; ensure you import or reference FrontendController correctly so the
single canonical descriptionExpr() is used rather than duplicating the fallback
logic.
tests/goodlib-custom-domains.spec.js (1)

54-55: Don't couple this spec to the exact mirror inventory.

These toHaveCount() checks will fail every time a preset domain is rotated or added, even if the custom-domain flow still works. Assert the presence of __custom__ and the specific preset(s) this scenario depends on instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/goodlib-custom-domains.spec.js` around lines 54 - 55, Replace the
brittle toHaveCount assertions on '#goodlib_anna_domain_select option' and
'#goodlib_zlib_domain_select option' with presence checks for the custom-domain
sentinel and the specific preset(s) this scenario requires: assert that an
option with value "__custom__" exists for each select (e.g.,
'#goodlib_anna_domain_select option[value="__custom__"]' and
'#goodlib_zlib_domain_select option[value="__custom__"]') and additionally
assert presence of the particular preset option(s) you depend on by value or
visible text (use the option[value="presetName"] or option:has-text("Preset
Label") selectors). This keeps the test resilient to inventory rotations while
ensuring the custom-domain flow and required presets are present.
storage/plugins/goodlib/GoodLibPlugin.php (1)

304-340: Minor inconsistency: gutenberg_domain in getSettings but not in view docblock.

getSettings() returns gutenberg_domain (line 317), but storage/plugins/goodlib/views/settings.php docblock only declares 7 keys without gutenberg_domain. This is fine since Gutenberg only has one domain (no mirror selection), but consider adding it to the docblock for completeness or adding a comment explaining why it's excluded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/goodlib/GoodLibPlugin.php` around lines 304 - 340, The
docblock for the settings passed to the view is inconsistent with
GoodLibPlugin::getSettings(): getSettings() returns a 'gutenberg_domain' key but
the view's docblock in storage/plugins/goodlib/views/settings.php omits it;
update the view docblock to include 'gutenberg_domain' (or add a short comment
explaining its intentional omission) so the declared shape matches the returned
array from getSettings(), referencing the gutenberg_domain key and the
getSettings() method.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1013-1020: The current LibraryThingImportController code leaves
malformed ISSNs in $result['issn'] (after attempting to normalize via
$issnCompact), which then gets emitted to JSON-LD/public API; change the logic
so that after normalization you only assign to $result['issn'] when the pattern
matches, otherwise set $result['issn'] = null (or unset it) and, if you still
need the original input, store it under a separate field such as
$result['source_issn'] or $result['raw_issn']; update the block that computes
$issnCompact and the assignment to $result['issn'] to implement this
safe-normalize-or-null behavior.
- Around line 1370-1376: The current check uses $stmt->affected_rows to decide
if the row was soft-deleted, but UPDATE returns 0 both for no-matching-row and
for unchanged-row; after the existing UPDATE that yields $stmt->affected_rows
=== 0, run an explicit existence query (e.g., SELECT id FROM books WHERE id = ?)
using the same DB connection to determine whether the book still exists: if the
SELECT finds the row treat the situation as "no-op/unchanged" and continue,
otherwise treat it as soft-deleted and throw the RuntimeException; ensure you
properly close/cleanup the original $stmt before or after the existence check
and reference the same $bookId variable in the SELECT.

In `@app/Controllers/PluginController.php`:
- Around line 354-370: The code in PluginController currently writes boolean
settings via $this->pluginManager->setSetting() before validating GoodLib
domains, causing partial persistence on invalid domains; change the flow in the
method handling $settings/$pluginId to first validate all GoodLib domains using
self::normalizeGoodLibDomain against self::GOODLIB_DEFAULT_DOMAINS and collect
their normalized values (return 400 immediately if any domain is invalid) and
only after all validations pass call $this->pluginManager->setSetting() for the
boolean keys and then for each domain key using the validated normalized domain
values; reference normalizeGoodLibDomain, GOODLIB_DEFAULT_DOMAINS, setSetting,
$settings and $pluginId to locate and reorder logic accordingly.

In `@app/Views/admin/plugins.php`:
- Around line 1822-1832: The success handler currently writes raw form values
into the config button datasets (configBtn.dataset.goodlibAnnaDomain,
goodlibZlibDomain, etc.) which can differ from the normalized domain the backend
saved; instead, after data.success use the canonical domain fields returned in
the response (e.g. data.goodlib_anna_domain, data.goodlib_zlib_domain,
data.goodlib_gutenberg_domain, data.goodlib_frontend_domain,
data.goodlib_admin_domain or whatever keys the API returns) to populate
configBtn.dataset.goodlibAnnaDomain, configBtn.dataset.goodlibZlibDomain,
configBtn.dataset.goodlibGutenbergDomain,
configBtn.dataset.goodlibFrontendDomain and configBtn.dataset.goodlibAdminDomain
so the cached values reflect the normalized persisted values rather than the raw
form inputs.

In `@app/Views/profile/index.php`:
- Around line 434-452: Add an explicit "use site default" option to the locale
<select> so an empty or unknown $userLocale doesn't implicitly select the first
available locale; insert an <option value=""><?= __("Use site default")
?></option> (or similar label) as the first option and set it selected when
$userLocale is empty or when $userLocale is not present in the keys of
$availableLocales (use array_key_exists or isset on $availableLocales to detect
this). Update the selection logic around $userLocale and the foreach so the
empty value is selected when appropriate and keep htmlspecialchars for the
option rendering.

In `@tests/helpers/e2e-fixtures.js`:
- Around line 87-92: The getPluginIdByName function currently converts
dbQuery(...) directly to Number(result), which yields 0 when no row is returned;
change it to inspect the dbQuery return (the row object from dbQuery), extract
the id field (from the result of dbQuery(`SELECT id FROM plugins WHERE name =
'${escapeSql(name)}' LIMIT 1`)), and if the id is missing/undefined throw an
explicit Error (e.g., `throw new Error('Plugin not found: ' + name)`), otherwise
return Number(id) so callers never receive 0 as a missing-plugin sentinel.
- Around line 116-129: The restorePluginSettings function currently performs
DELETE then multiple INSERTs via separate dbExec calls, which is non-atomic and
can leave partially restored state on failure; change it to perform the whole
operation inside a single transaction: begin a transaction, run the DELETE for
pluginId and then a single batched INSERT (or multiple INSERT statements
concatenated) for all rows using escapeSql for values, then COMMIT; if any error
occurs, ROLLBACK. Use the existing restorePluginSettings and dbExec identifiers
(and escapeSql for escaping) so the code runs the DELETE/INSERTs within
BEGIN/COMMIT/ROLLBACK in one dbExec call (or use dbExec per step but ensure
rollback on catch) to make the restoration atomic.

---

Outside diff comments:
In `@app/Support/PluginManager.php`:
- Around line 45-52: The BUNDLED_PLUGINS constant currently includes 'goodlib'
which causes autoRegisterBundledPlugins() to insert and immediately activate it
(calling onInstall()/onActivate()); remove 'goodlib' from the BUNDLED_PLUGINS
array or modify autoRegisterBundledPlugins() to distinguish "bundled but not
auto-activate" plugins so that bundled discovery and DB insertion does not set
is_active = 1 or call onActivate() for optional plugins (refer to
BUNDLED_PLUGINS constant and the autoRegisterBundledPlugins(), onInstall(),
onActivate() code paths).

---

Duplicate comments:
In `@app/Middleware/RememberMeMiddleware.php`:
- Around line 94-99: RememberMeMiddleware currently writes $_SESSION['locale']
directly from $row['locale'] even if \App\Support\I18n::setLocale($locale)
fails; change the code to call I18n::setLocale((string)$row['locale']) and only
assign $_SESSION['locale'] when that call returns truthy (or succeeds), and
optionally catch any exception from I18n::setLocale to avoid persisting an
invalid locale; this ensures $_SESSION['locale'] is updated only when setLocale
actually applied the locale.

In `@app/Views/admin/plugins.php`:
- Around line 218-221: Replace hardcoded data-settings-url values that build
"/admin/plugins/{id}/settings" with a route helper call: construct the URL via
route_path('admin.plugins.settings', ['id' => (int) $plugin['id']]) or
RouteTranslator::route('admin.plugins.settings', ['id' => (int) $plugin['id']])
and use htmlspecialchars on that output; update each occurrence that sets
data-settings-url (the attributes in the same block that call
onclick="openPluginSettingsModal(this)") and if the named route key
'admin.plugins.settings' doesn't exist, add that route key in your routes config
so the view can call the helper instead of hardcoding the path.

In `@locale/en_US.json`:
- Line 975: Update the English translation value for the JSON entry keyed by
"Mostra badge nell'area amministrazione" to use plural phrasing; replace "Show
badge in the admin area" with "Show badges in the admin area" so it matches
neighboring plural badge labels.

---

Nitpick comments:
In @.gitignore:
- Around line 279-285: The .gitignore currently ignores tests/* and then
individually negates specific top-level spec files (e.g.,
tests/branch-fix-consistency.spec.js, tests/goodlib-custom-domains.spec.js),
which forces manual updates for each new top-level Playwright spec; update the
ignore rules to use a pattern-based allowlist instead (replace the repeated
single-file negations with a single negation like allowing all top-level spec
patterns such as !tests/*.spec.js or a more specific glob you want), keeping the
existing helpers handling (tests/helpers/ and tests/helpers/* and the explicit
helper exceptions) intact so helper files remain ignored/allowed as before.

In `@app/Controllers/LibraryThingImportController.php`:
- Around line 885-888: Extract the HTML→plain-text normalization into a single
private helper on the LibraryThingImportController (e.g. private function
normalizeDescrizione(?string $html): ?string) that: returns null for empty
input, strips tags (preg_replace '/<[^>]+>/' → ' '), runs
html_entity_decode(..., ENT_QUOTES|ENT_HTML5, 'UTF-8'), collapses whitespace
with preg_replace('/\s+/u', ' ', ...) and returns trim(...) or null; then
replace the inline logic that sets $result['descrizione_plain'] (the spot using
$result['descrizione'] and the duplicate occurrence in the scrape enrichment
code) to call $this->normalizeDescrizione($result['descrizione']) and assign the
result.

In `@app/Controllers/LibriApiController.php`:
- Around line 45-48: Replace the duplicated descrizione_plain/descrizione
fallback in LibriApiController by reusing the existing helper instead of
re-implementing it: remove the local $descExpr assignment that calls
hasTableColumn and instead obtain the expression via
FrontendController::descriptionExpr() (or the instance helper method
descriptionExpr()) and use that result in the $where clause where {$descExpr} is
referenced; ensure you import or reference FrontendController correctly so the
single canonical descriptionExpr() is used rather than duplicating the fallback
logic.

In `@storage/plugins/goodlib/GoodLibPlugin.php`:
- Around line 304-340: The docblock for the settings passed to the view is
inconsistent with GoodLibPlugin::getSettings(): getSettings() returns a
'gutenberg_domain' key but the view's docblock in
storage/plugins/goodlib/views/settings.php omits it; update the view docblock to
include 'gutenberg_domain' (or add a short comment explaining its intentional
omission) so the declared shape matches the returned array from getSettings(),
referencing the gutenberg_domain key and the getSettings() method.

In `@tests/goodlib-custom-domains.spec.js`:
- Around line 54-55: Replace the brittle toHaveCount assertions on
'#goodlib_anna_domain_select option' and '#goodlib_zlib_domain_select option'
with presence checks for the custom-domain sentinel and the specific preset(s)
this scenario requires: assert that an option with value "__custom__" exists for
each select (e.g., '#goodlib_anna_domain_select option[value="__custom__"]' and
'#goodlib_zlib_domain_select option[value="__custom__"]') and additionally
assert presence of the particular preset option(s) you depend on by value or
visible text (use the option[value="presetName"] or option:has-text("Preset
Label") selectors). This keeps the test resilient to inventory rotations while
ensuring the custom-domain flow and required presets are present.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 728d79e2-aa1d-43a5-a198-45b8f811cda1

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 75865c9.

📒 Files selected for processing (29)
  • .gitignore
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Support/PluginManager.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • app/Views/profile/index.php
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/goodlib/GoodLibPlugin.php
  • storage/plugins/goodlib/plugin.json
  • storage/plugins/goodlib/views/badges.php
  • storage/plugins/goodlib/views/settings.php
  • storage/plugins/goodlib/wrapper.php
  • tests/branch-fix-consistency.spec.js
  • tests/full-test.spec.js
  • tests/goodlib-custom-domains.spec.js
  • tests/helpers/branch-fix-harness.php
  • tests/helpers/e2e-fixtures.js

- RememberMeMiddleware: guard session locale on setLocale() success
- PluginController: validate domains before persisting any settings
- PluginController: return normalized domains in response
- Admin JS: use server-normalized domains for cached data attributes
- LT import: affected_rows=0 → explicit existence check (not just throw)
- LT import: null malformed ISSN instead of keeping raw value
- Profile locale: add "Predefinita del sito" default option
- en_US: "badge" → "badges" for admin area label
- e2e-fixtures: getPluginIdByName throws on not-found
- e2e-fixtures: restorePluginSettings atomic (single transaction)
- .gitignore: pattern-based test file allowlist
- i18n: add "Predefinita del sito" in it/en/de
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
tests/helpers/branch-fix-harness.php (1)

433-478: ⚠️ Potential issue | 🟡 Minor

ISSN scenario should ensure LibraryThing schema is installed.

This scenario uses $insert->invoke() from getLibraryThingMethods() which branches on LibraryThingInstaller::isInstalled($db). Unlike scenarioLibraryThingParseAndPersist (line 293), this function doesn't explicitly install the schema first. If the database lacks LT columns, the INSERT may silently skip issn.

💡 Suggested fix
 function scenarioPublicApiAndFrontendIssn(mysqli $db): array
 {
     $_SESSION = [];
     $factory = new ServerRequestFactory();
     $publicApi = new PublicApiController();
+    // Ensure LibraryThing plugin schema is installed so issn column exists
+    if (!\App\Support\LibraryThingInstaller::isInstalled($db)) {
+        $installer = new \App\Support\LibraryThingInstaller($db);
+        $installer->install();
+    }
     [$controller, $parse, $insert] = getLibraryThingMethods();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/branch-fix-harness.php` around lines 433 - 478, The
scenarioPublicApiAndFrontendIssn test calls $insert->invoke(...) which assumes
LibraryThing schema is present but does not ensure it; call the same
installation step used in scenarioLibraryThingParseAndPersist before invoking
$insert (e.g., check LibraryThingInstaller::isInstalled($db) and if false run
the installer or call the helper that installs the LibraryThing schema) so the
insert will include issn; locate getLibraryThingMethods()/LibraryThingInstaller
usage in this file to mirror the install/check logic already used in
scenarioLibraryThingParseAndPersist.
🧹 Nitpick comments (3)
app/Controllers/FrontendController.php (1)

22-37: Consider sharing the column-introspection helper.

FrontendController::descriptionExpr() now carries its own cached descrizione_plain probe, while app/Controllers/LibriApiController.php already has a separate cached hasTableColumn() path. Keeping both detection strategies in sync makes catalog and API search behavior easier to drift again. Extracting one shared helper would reduce that risk.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/FrontendController.php` around lines 22 - 37,
FrontendController::descriptionExpr duplicates column-introspection logic
already present in LibriApiController (cached probe for descrizione_plain);
extract a single shared helper (e.g. a static method like
SchemaInspector::hasTableColumn(mysqli $db, string $table, string $column)) that
implements the cached SHOW COLUMNS probe and freeing the result, then have
FrontendController::descriptionExpr call that helper instead of using its own
self::$hasDescrizionePlain cache and detection logic; update LibriApiController
to use the same helper so both callers share one cache/implementation and avoid
drift.
storage/plugins/goodlib/GoodLibPlugin.php (1)

210-239: Domain normalization duplicates PluginController logic.

GoodLibPlugin::normalizeDomain() mirrors PluginController::normalizeGoodLibDomain() (see context snippet 1 from app/Controllers/PluginController.php:399-428). Consider extracting to a shared utility to avoid divergence.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/goodlib/GoodLibPlugin.php` around lines 210 - 239,
GoodLibPlugin::normalizeDomain duplicates logic in
PluginController::normalizeGoodLibDomain; extract the validation/normalization
into a single shared utility (e.g., DomainNormalizer::normalize or
Utils::normalizeDomain) and have both GoodLibPlugin::normalizeDomain and
PluginController::normalizeGoodLibDomain call that single implementation; update
references in both classes to use the shared function, remove the duplicated
code body from GoodLibPlugin::normalizeDomain (or make it a thin wrapper) and
add unit tests if present to ensure behavior remains identical.
storage/plugins/goodlib/plugin.json (1)

10-12: max_app_version is declared in plugin.json but not enforced by PluginManager.

The PluginManager only validates requires_php during plugin installation; neither requires_app nor max_app_version are checked. This means the plugin will activate regardless of app version, making the "max_app_version": "1.0.0" declaration effectively advisory-only.

Low risk for a bundled plugin, but worth implementing max_app_version validation in a future refactor to prevent compatibility issues as the application evolves.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/goodlib/plugin.json` around lines 10 - 12, PluginManager
currently only checks requires_php during plugin install; add validation to also
enforce the plugin.json fields requires_app and max_app_version (e.g., in
PluginManager.installPlugin and PluginManager.activatePlugin or a new helper
validatePluginRequirements) by parsing the version strings (use existing semver
utility or add a semver compare) and rejecting installation/activation when
currentAppVersion < requires_app or currentAppVersion > max_app_version,
returning/logging a clear error that includes the offending plugin id and the
expected vs actual app version.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/helpers/e2e-fixtures.js`:
- Around line 7-14: The helper currently falls back to non-E2E sources
(envFromFile, APP_URL, .env) which can run destructive fixtures against the real
app; change the logic in loadDotEnv/this file so BASE_URL, DB_HOST, DB_USER,
DB_PASS, DB_NAME and DB_SOCKET are read only from their E2E_* environment
variables (process.env.E2E_BASE_URL, process.env.E2E_DB_HOST, etc.), and if any
required E2E_* var is missing throw/log a clear fatal error and exit (fail fast)
instead of using APP_URL or values from envFromFile; keep a clear error message
naming the missing E2E_* keys so CI/local run scripts (e.g., /tmp/run-e2e.sh)
must set them.
- Around line 120-137: The restorePluginSettings function currently quotes all
setting_value fields, turning JS null into the string 'null'; change the VALUES
construction so when row.setting_value === null you insert the SQL token NULL
(no quotes) instead of quoting escapeSql(row.setting_value), e.g., produce
${row.setting_value === null ? 'NULL' : `'${escapeSql(row.setting_value)}'`};
keep using escapeSql for non-null values, and leave autoload and other fields
unchanged; update the INSERT generation in restorePluginSettings (refer to
restorePluginSettings, escapeSql, dbExec) so snapshotPluginSettings-null values
roundtrip correctly.

---

Duplicate comments:
In `@tests/helpers/branch-fix-harness.php`:
- Around line 433-478: The scenarioPublicApiAndFrontendIssn test calls
$insert->invoke(...) which assumes LibraryThing schema is present but does not
ensure it; call the same installation step used in
scenarioLibraryThingParseAndPersist before invoking $insert (e.g., check
LibraryThingInstaller::isInstalled($db) and if false run the installer or call
the helper that installs the LibraryThing schema) so the insert will include
issn; locate getLibraryThingMethods()/LibraryThingInstaller usage in this file
to mirror the install/check logic already used in
scenarioLibraryThingParseAndPersist.

---

Nitpick comments:
In `@app/Controllers/FrontendController.php`:
- Around line 22-37: FrontendController::descriptionExpr duplicates
column-introspection logic already present in LibriApiController (cached probe
for descrizione_plain); extract a single shared helper (e.g. a static method
like SchemaInspector::hasTableColumn(mysqli $db, string $table, string $column))
that implements the cached SHOW COLUMNS probe and freeing the result, then have
FrontendController::descriptionExpr call that helper instead of using its own
self::$hasDescrizionePlain cache and detection logic; update LibriApiController
to use the same helper so both callers share one cache/implementation and avoid
drift.

In `@storage/plugins/goodlib/GoodLibPlugin.php`:
- Around line 210-239: GoodLibPlugin::normalizeDomain duplicates logic in
PluginController::normalizeGoodLibDomain; extract the validation/normalization
into a single shared utility (e.g., DomainNormalizer::normalize or
Utils::normalizeDomain) and have both GoodLibPlugin::normalizeDomain and
PluginController::normalizeGoodLibDomain call that single implementation; update
references in both classes to use the shared function, remove the duplicated
code body from GoodLibPlugin::normalizeDomain (or make it a thin wrapper) and
add unit tests if present to ensure behavior remains identical.

In `@storage/plugins/goodlib/plugin.json`:
- Around line 10-12: PluginManager currently only checks requires_php during
plugin install; add validation to also enforce the plugin.json fields
requires_app and max_app_version (e.g., in PluginManager.installPlugin and
PluginManager.activatePlugin or a new helper validatePluginRequirements) by
parsing the version strings (use existing semver utility or add a semver
compare) and rejecting installation/activation when currentAppVersion <
requires_app or currentAppVersion > max_app_version, returning/logging a clear
error that includes the offending plugin id and the expected vs actual app
version.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c1f54edc-34e6-4782-af1b-780c20018ecb

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and c044193.

📒 Files selected for processing (29)
  • .gitignore
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Support/PluginManager.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • app/Views/profile/index.php
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/goodlib/GoodLibPlugin.php
  • storage/plugins/goodlib/plugin.json
  • storage/plugins/goodlib/views/badges.php
  • storage/plugins/goodlib/views/settings.php
  • storage/plugins/goodlib/wrapper.php
  • tests/branch-fix-consistency.spec.js
  • tests/full-test.spec.js
  • tests/goodlib-custom-domains.spec.js
  • tests/helpers/branch-fix-harness.php
  • tests/helpers/e2e-fixtures.js
👮 Files not reviewed due to content moderation or server errors (4)
  • locale/en_US.json
  • tests/goodlib-custom-domains.spec.js
  • storage/plugins/goodlib/views/badges.php
  • app/Controllers/PluginController.php

- Fail-fast when E2E_BASE_URL, E2E_DB_USER, E2E_DB_NAME are missing
- restorePluginSettings preserves SQL NULL (not string 'null')
Without this, the updater's updateBundledPlugins() would not copy
the goodlib plugin directory during upgrades from pre-goodlib versions.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Full review triggered.

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
tests/helpers/e2e-fixtures.js (1)

7-20: ⚠️ Potential issue | 🟠 Major

Require dedicated E2E_* config in this helper.

This still falls back to APP_URL and repo .env values, so a missing test variable can make these destructive fixtures hit the regular app environment instead of failing fast.

🔒 Suggested guard
-const envFromFile = loadDotEnv(path.join(ROOT, '.env'));
-
 // Require explicit E2E env vars — these fixtures create/delete data
-const BASE_URL = process.env.E2E_BASE_URL || process.env.APP_URL;
+const BASE_URL = process.env.E2E_BASE_URL;
 if (!BASE_URL) {
-  throw new Error('E2E_BASE_URL or APP_URL must be set (use /tmp/run-e2e.sh)');
+  throw new Error('E2E_BASE_URL must be set (use /tmp/run-e2e.sh)');
 }
-const DB_HOST = process.env.E2E_DB_HOST || envFromFile.DB_HOST || '';
-const DB_USER = process.env.E2E_DB_USER || envFromFile.DB_USER || '';
-const DB_PASS = process.env.E2E_DB_PASS ?? envFromFile.DB_PASS ?? '';
-const DB_NAME = process.env.E2E_DB_NAME || envFromFile.DB_NAME || '';
-const DB_SOCKET = process.env.E2E_DB_SOCKET || envFromFile.DB_SOCKET || '';
-if (!DB_USER || !DB_NAME) {
-  throw new Error('E2E_DB_USER and E2E_DB_NAME must be set (use /tmp/run-e2e.sh)');
+const DB_HOST = process.env.E2E_DB_HOST || '';
+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 || '';
+if (!DB_USER || !DB_NAME || (!DB_HOST && !DB_SOCKET)) {
+  throw new Error('Missing dedicated E2E_DB_* configuration (use /tmp/run-e2e.sh)');
 }
Based on learnings, E2E tests with Playwright require `/tmp/run-e2e.sh` to set DB/admin credentials as env vars.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/e2e-fixtures.js` around lines 7 - 20, The helper currently
falls back to non-E2E sources (APP_URL and envFromFile) causing tests to
accidentally target real environments; update the logic in the block that sets
BASE_URL, DB_HOST, DB_USER, DB_PASS, DB_NAME, and DB_SOCKET (and the loadDotEnv
usage) to only read from explicit E2E_* environment variables (no fallback to
process.env.APP_URL or envFromFile), and throw clear errors if any required
E2E_* var (E2E_BASE_URL, E2E_DB_USER, E2E_DB_NAME, etc.) is missing so fixtures
fail fast.
tests/helpers/branch-fix-harness.php (1)

433-445: ⚠️ Potential issue | 🟡 Minor

Missing LibraryThingInstaller check before using LT methods.

scenarioPublicApiAndFrontendIssn invokes insertBook via reflection (line 445) but does not verify that the LibraryThing plugin schema is installed. On a clean test DB, LibraryThingInstaller::isInstalled($db) may return false, causing insertBook to use the basic code path that omits LT-specific columns (like issn). This would make the test pass/fail based on harness setup rather than product behavior.

scenarioLibraryThingParseAndPersist correctly includes this check at lines 293-296.

🛠️ Proposed fix
 function scenarioPublicApiAndFrontendIssn(mysqli $db): array
 {
+    // Ensure LibraryThing plugin schema is installed so LT-specific columns exist
+    if (!\App\Support\LibraryThingInstaller::isInstalled($db)) {
+        $installer = new \App\Support\LibraryThingInstaller($db);
+        $installer->install();
+    }
+
     $_SESSION = [];
     $factory = new ServerRequestFactory();
     $publicApi = new PublicApiController();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/branch-fix-harness.php` around lines 433 - 445, The
scenarioPublicApiAndFrontendIssn helper calls LT-specific methods (via
getLibraryThingMethods and invoking insertBook) without checking whether the
LibraryThing plugin schema is present; add the same guard used in
scenarioLibraryThingParseAndPersist by calling
LibraryThingInstaller::isInstalled($db) before invoking the parse/insert
reflection methods and skip or adjust the LT-specific assertions when not
installed. Locate scenarioPublicApiAndFrontendIssn and wrap the code that uses
$parse/$insert (from getLibraryThingMethods) in an if
(LibraryThingInstaller::isInstalled($db)) { ... } branch (or early return) so
insertBook is not invoked against a DB missing LT schema and the test behavior
matches the harness setup.
🧹 Nitpick comments (8)
app/Controllers/LibraryThingImportController.php (3)

35-55: Per-instance caching may diverge from FrontendController's static cache.

FrontendController::descriptionExpr() (lines 20-38 in app/Controllers/FrontendController.php) uses a static property $hasDescrizionePlain to cache the column check, while this controller uses a per-instance property $cachedHasDescPlain. If a migration adds the column mid-process, different controller instances or request paths could disagree on whether the column exists.

Consider aligning caching strategies across controllers (e.g., using a shared static helper) to ensure consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 35 - 55, The
per-instance cache $cachedHasDescPlain in hasDescrizionePlainColumn() can
diverge from FrontendController::descriptionExpr() which uses static
$hasDescrizionePlain; replace the instance-level caching with a shared
static/cache approach so all controllers agree (e.g., move the column-existence
check into a single static helper or reuse FrontendController::descriptionExpr()
logic), update hasDescrizionePlainColumn() to consult and set that shared static
cache instead of $cachedHasDescPlain, and preserve the existing query/exception
handling and result freeing behavior when computing the shared value.

508-521: Prefer SecureLogger::error() over error_log() for potentially sensitive contexts.

Lines 508, 515, 521 use error_log() directly to log import history persistence failures. As per coding guidelines, sensitive contexts should use SecureLogger::error() instead.

♻️ Proposed fix
                 if (!$persisted) {
-                    error_log("[LibraryThingImportController] Failed to persist import history to database");
+                    \App\Support\SecureLogger::error("[LibraryThingImportController] Failed to persist import history to database");
                     // Mark as failed so the record doesn't stay stuck in 'processing'
                     $importLogger->fail('Failed to persist import history', $importData['total_rows']);
                 }
             } catch (\Throwable $e) {
                 // Log error but don't fail the import (already completed)
                 // Catches \Error/TypeError too (strict_types=1 can throw TypeError)
-                error_log("[LibraryThingImportController] Failed to persist import history (" . get_class($e) . "): " . $e->getMessage());
+                \App\Support\SecureLogger::error("[LibraryThingImportController] Failed to persist import history (" . get_class($e) . "): " . $e->getMessage());
                 // Mark as failed so the record doesn't stay stuck in 'processing'
                 if (isset($importLogger)) {
                     try {
                         $importLogger->fail($e->getMessage(), $importData['total_rows']);
                     } catch (\Throwable $inner) {
-                        error_log("[LibraryThingImportController] Also failed to mark import as failed: " . $inner->getMessage());
+                        \App\Support\SecureLogger::error("[LibraryThingImportController] Also failed to mark import as failed: " . $inner->getMessage());
                     }
                 }
             }

As per coding guidelines, Use SecureLogger::error() not error_log() for sensitive contexts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 508 - 521,
Replace direct error_log() calls in LibraryThingImportController's import
history persistence error paths with SecureLogger::error() so sensitive data is
logged via the secure logger; specifically update the three occurrences inside
the persistence failure branch and the outer and inner catch blocks (the
messages that include get_class($e) and $e->getMessage(), and the
inner->getMessage()) to call SecureLogger::error() with the same contextual
strings, ensure SecureLogger is imported/available in the class scope, and keep
the existing calls to $importLogger->fail(...) unchanged.

1650-1650: Minor: error_log usage in cover download error handling.

Line 1650 uses error_log() for cover download failures. While less sensitive than import history errors, consider using SecureLogger::error() for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` at line 1650, Replace the
direct PHP error_log call used for cover download failures in
LibraryThingImportController with the application's SecureLogger error method to
maintain consistent logging; locate the cover download error handling where
error_log("[LT Import] Cover download failed for book $bookId: " .
$e->getMessage()) is called and change it to call SecureLogger::error (or the
controller's injected secure logger) with the same message/context and include
the exception details so the bookId and $e->getMessage() are preserved.
app/Controllers/LibriApiController.php (1)

45-48: Extract the description expression into shared search code.

app/Controllers/FrontendController.php:22-38 already owns the same COALESCE(NULLIF(...), ...) + schema-probe logic. Keeping a second copy here makes the frontend catalog and admin grid easy to drift apart again on the next search tweak.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriApiController.php` around lines 45 - 48, Extract the
duplicated description-expression logic into a shared helper used by both
controllers: move the COALESCE(NULLIF(...), ...) + schema-probe logic currently
duplicated in FrontendController and the $descExpr calculation in
LibriApiController into a single function (e.g., a static helper like
getDescriptionExpression or a method on a shared SearchHelper/Libri model).
Replace the inline $descExpr construction in LibriApiController (which currently
uses hasTableColumn($db, 'libri', 'descrizione_plain')) with a call to that
shared function and update FrontendController to call the same function so both
controllers use the identical expression for searches.
app/Support/Updater.php (1)

39-46: Consider a single source of truth for bundled plugins.

Line 43 is correct, but Updater::BUNDLED_PLUGINS now duplicates the list also maintained in app/Support/PluginManager.php (Line 45-52). This duplication is easy to drift again; centralizing the list would reduce future consistency regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/Updater.php` around lines 39 - 46, Updater::BUNDLED_PLUGINS
duplicates the same plugin list maintained in PluginManager, causing drift;
remove the duplicate constant from Updater and have Updater read the canonical
list from PluginManager (e.g., reference the PluginManager constant or accessor
method that holds the bundled plugins), updating any Updater code that used
BUNDLED_PLUGINS to use PluginManager::... or PluginManager::getBundledPlugins()
instead so there is a single source of truth.
tests/helpers/branch-fix-harness.php (3)

307-310: Consider adding soft-delete condition for guideline compliance.

Per coding guidelines, queries on the libri table should include AND deleted_at IS NULL. While these test scenarios create and immediately query records that are never soft-deleted, adding the condition would maintain consistency with production code patterns.

Note: The query at line 339 intentionally omits this condition to verify that soft-deleted records aren't modified.

♻️ Suggested change
         $created = $db->query(
             'SELECT titolo, descrizione_plain, collana, numero_serie, traduttore, issn, copie_totali, copie_disponibili
-             FROM libri WHERE id = ' . $bookId
+             FROM libri WHERE id = ' . $bookId . ' AND deleted_at IS NULL'
         )->fetch_assoc();

As per coding guidelines: "Every query on libri table MUST include soft-delete condition: AND deleted_at IS NULL"

Also applies to: 320-322

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/branch-fix-harness.php` around lines 307 - 310, The SELECT
against the libri table in the test uses $db->query(...)->fetch_assoc() and must
include the soft-delete filter; update the query string that builds the SELECT
(the one selecting titolo, descrizione_plain, collana, numero_serie, traduttore,
issn, copie_totali, copie_disponibili for $bookId) to append "AND deleted_at IS
NULL". Also apply the same change to the other similar query referenced around
the $db->query call at lines noted (the earlier query at 320-322) so all queries
against the libri table in this file include the soft-delete condition.

252-263: Reflection access to private methods is inherently fragile.

getLibraryThingMethods() uses reflection to access private methods (parseLibraryThingRow, insertBook, updateBook). If these method names or signatures change in LibraryThingImportController, the tests will fail at runtime with cryptic reflection errors rather than clear compile-time feedback.

This is acceptable for a test harness, but consider documenting the expected signatures in a comment to aid future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/branch-fix-harness.php` around lines 252 - 263, The helper uses
fragile reflection to access private methods parseLibraryThingRow, insertBook,
and updateBook on LibraryThingImportController; add a concise doc comment above
getLibraryThingMethods() that lists the expected method names and their
signatures (parameters and return types) for parseLibraryThingRow, insertBook,
and updateBook so maintainers know what to update if those methods change and to
reduce runtime surprises when reflection fails.

480-481: Unused $db parameter in export/roundtrip scenarios.

scenarioLibraryThingExportTranslatorRoundtrip and scenarioLibraryThingSecondaryAuthorRolePairing don't use the $db parameter since they only test parsing/formatting logic without database operations. Consider removing the parameter or adding a PHPStan ignore annotation for signature consistency.

♻️ Option A: Remove unused parameter
-function scenarioLibraryThingExportTranslatorRoundtrip(mysqli $db): array
+function scenarioLibraryThingExportTranslatorRoundtrip(): array
 {

Then update the match expression at line 757 accordingly.

♻️ Option B: Add PHPStan ignore
+/** `@phpstan-ignore-next-line` */
 function scenarioLibraryThingExportTranslatorRoundtrip(mysqli $db): array

Also applies to: 518-519

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/branch-fix-harness.php` around lines 480 - 481, Two helper
scenario functions, scenarioLibraryThingExportTranslatorRoundtrip and
scenarioLibraryThingSecondaryAuthorRolePairing, declare an unused mysqli $db
parameter; either remove the unused $db parameter from both function signatures
and update every call site (including the match expression that builds the
scenarios) to stop passing $db, or keep the signatures and add a PHPStan ignore
annotation to suppress the unused-parameter warning (e.g. add a docblock like
/** `@phpstan-ignore-next-line` */ or /** `@param` mysqli $db */ with an explicit
ignore) so signature consistency remains; update the scenario registry/match
expression that references these functions accordingly to match the chosen
option.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/helpers/e2e-fixtures.js`:
- Around line 173-177: The helper currently converts dbQuery(...) to Number
which yields 0 on no rows; update the logic around the id assignment that calls
dbQuery and escapeSql so you first capture the raw query result (e.g., const row
= dbQuery(...)), verify that row is present and contains an id, and throw a
descriptive Error if not (e.g., "fixture lookup failed for email ..."), then
convert the found value to Number for return; apply the same
presence/check-and-throw fix to the other occurrence referenced (the block
around lines 186-189).

---

Duplicate comments:
In `@tests/helpers/branch-fix-harness.php`:
- Around line 433-445: The scenarioPublicApiAndFrontendIssn helper calls
LT-specific methods (via getLibraryThingMethods and invoking insertBook) without
checking whether the LibraryThing plugin schema is present; add the same guard
used in scenarioLibraryThingParseAndPersist by calling
LibraryThingInstaller::isInstalled($db) before invoking the parse/insert
reflection methods and skip or adjust the LT-specific assertions when not
installed. Locate scenarioPublicApiAndFrontendIssn and wrap the code that uses
$parse/$insert (from getLibraryThingMethods) in an if
(LibraryThingInstaller::isInstalled($db)) { ... } branch (or early return) so
insertBook is not invoked against a DB missing LT schema and the test behavior
matches the harness setup.

In `@tests/helpers/e2e-fixtures.js`:
- Around line 7-20: The helper currently falls back to non-E2E sources (APP_URL
and envFromFile) causing tests to accidentally target real environments; update
the logic in the block that sets BASE_URL, DB_HOST, DB_USER, DB_PASS, DB_NAME,
and DB_SOCKET (and the loadDotEnv usage) to only read from explicit E2E_*
environment variables (no fallback to process.env.APP_URL or envFromFile), and
throw clear errors if any required E2E_* var (E2E_BASE_URL, E2E_DB_USER,
E2E_DB_NAME, etc.) is missing so fixtures fail fast.

---

Nitpick comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 35-55: The per-instance cache $cachedHasDescPlain in
hasDescrizionePlainColumn() can diverge from
FrontendController::descriptionExpr() which uses static $hasDescrizionePlain;
replace the instance-level caching with a shared static/cache approach so all
controllers agree (e.g., move the column-existence check into a single static
helper or reuse FrontendController::descriptionExpr() logic), update
hasDescrizionePlainColumn() to consult and set that shared static cache instead
of $cachedHasDescPlain, and preserve the existing query/exception handling and
result freeing behavior when computing the shared value.
- Around line 508-521: Replace direct error_log() calls in
LibraryThingImportController's import history persistence error paths with
SecureLogger::error() so sensitive data is logged via the secure logger;
specifically update the three occurrences inside the persistence failure branch
and the outer and inner catch blocks (the messages that include get_class($e)
and $e->getMessage(), and the inner->getMessage()) to call SecureLogger::error()
with the same contextual strings, ensure SecureLogger is imported/available in
the class scope, and keep the existing calls to $importLogger->fail(...)
unchanged.
- Line 1650: Replace the direct PHP error_log call used for cover download
failures in LibraryThingImportController with the application's SecureLogger
error method to maintain consistent logging; locate the cover download error
handling where error_log("[LT Import] Cover download failed for book $bookId: "
. $e->getMessage()) is called and change it to call SecureLogger::error (or the
controller's injected secure logger) with the same message/context and include
the exception details so the bookId and $e->getMessage() are preserved.

In `@app/Controllers/LibriApiController.php`:
- Around line 45-48: Extract the duplicated description-expression logic into a
shared helper used by both controllers: move the COALESCE(NULLIF(...), ...) +
schema-probe logic currently duplicated in FrontendController and the $descExpr
calculation in LibriApiController into a single function (e.g., a static helper
like getDescriptionExpression or a method on a shared SearchHelper/Libri model).
Replace the inline $descExpr construction in LibriApiController (which currently
uses hasTableColumn($db, 'libri', 'descrizione_plain')) with a call to that
shared function and update FrontendController to call the same function so both
controllers use the identical expression for searches.

In `@app/Support/Updater.php`:
- Around line 39-46: Updater::BUNDLED_PLUGINS duplicates the same plugin list
maintained in PluginManager, causing drift; remove the duplicate constant from
Updater and have Updater read the canonical list from PluginManager (e.g.,
reference the PluginManager constant or accessor method that holds the bundled
plugins), updating any Updater code that used BUNDLED_PLUGINS to use
PluginManager::... or PluginManager::getBundledPlugins() instead so there is a
single source of truth.

In `@tests/helpers/branch-fix-harness.php`:
- Around line 307-310: The SELECT against the libri table in the test uses
$db->query(...)->fetch_assoc() and must include the soft-delete filter; update
the query string that builds the SELECT (the one selecting titolo,
descrizione_plain, collana, numero_serie, traduttore, issn, copie_totali,
copie_disponibili for $bookId) to append "AND deleted_at IS NULL". Also apply
the same change to the other similar query referenced around the $db->query call
at lines noted (the earlier query at 320-322) so all queries against the libri
table in this file include the soft-delete condition.
- Around line 252-263: The helper uses fragile reflection to access private
methods parseLibraryThingRow, insertBook, and updateBook on
LibraryThingImportController; add a concise doc comment above
getLibraryThingMethods() that lists the expected method names and their
signatures (parameters and return types) for parseLibraryThingRow, insertBook,
and updateBook so maintainers know what to update if those methods change and to
reduce runtime surprises when reflection fails.
- Around line 480-481: Two helper scenario functions,
scenarioLibraryThingExportTranslatorRoundtrip and
scenarioLibraryThingSecondaryAuthorRolePairing, declare an unused mysqli $db
parameter; either remove the unused $db parameter from both function signatures
and update every call site (including the match expression that builds the
scenarios) to stop passing $db, or keep the signatures and add a PHPStan ignore
annotation to suppress the unused-parameter warning (e.g. add a docblock like
/** `@phpstan-ignore-next-line` */ or /** `@param` mysqli $db */ with an explicit
ignore) so signature consistency remains; update the scenario registry/match
expression that references these functions accordingly to match the chosen
option.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b47482a2-c39f-4816-b7e7-534737ee16a7

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 0e4f906.

📒 Files selected for processing (30)
  • .gitignore
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Support/PluginManager.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • app/Views/profile/index.php
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/goodlib/GoodLibPlugin.php
  • storage/plugins/goodlib/plugin.json
  • storage/plugins/goodlib/views/badges.php
  • storage/plugins/goodlib/views/settings.php
  • storage/plugins/goodlib/wrapper.php
  • tests/branch-fix-consistency.spec.js
  • tests/full-test.spec.js
  • tests/goodlib-custom-domains.spec.js
  • tests/helpers/branch-fix-harness.php
  • tests/helpers/e2e-fixtures.js

- e2e-fixtures: remove .env/APP_URL fallback, require E2E_* vars only
- e2e-fixtures: fail-fast on createTempAdminUser/createTempBook (ID=0)
- branch-fix-harness: install LT schema in scenarioPublicApiAndFrontendIssn
- LibraryThingImportController: error_log → SecureLogger::error (4 occurrences)
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Controllers/ProfileController.php (1)

20-24: ⚠️ Potential issue | 🟠 Major

Guard the prepare() call in show() before bind_param().

Line 20's query now selects the locale column; if prepare() fails (schema mismatch on partial migration, DB error, etc.), Line 21 dereferences false with bind_param() and causes a fatal error. Mirror the if (!$stmt) guard you already use in changePassword() (line 63) and update() (line 166), logging the failure with SecureLogger::error().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/ProfileController.php` around lines 20 - 24, In the show()
method, guard the $db->prepare(...) result before calling $stmt->bind_param() by
checking if $stmt is falsy; if prepare() returns false, call
SecureLogger::error() with a clear message (include context like the query or
uid) and return/handle gracefully (same pattern used in changePassword() and
update()) to avoid dereferencing $stmt when prepare fails.
♻️ Duplicate comments (4)
tests/helpers/e2e-fixtures.js (2)

173-189: ⚠️ Potential issue | 🟡 Minor

Fail fast when fixture row lookup returns no ID.

Number(dbQuery(...)) can still yield 0 on empty output in both temp-user and temp-book creation paths; throw immediately when lookup fails.

Suggested guard checks
-  const id = Number(dbQuery(
-    `SELECT id FROM utenti WHERE email = '${escapeSql(email)}' LIMIT 1`
-  ));
+  const userIdRaw = dbQuery(
+    `SELECT id FROM utenti WHERE email = '${escapeSql(email)}' LIMIT 1`
+  );
+  const id = Number(userIdRaw);
+  if (!Number.isInteger(id) || id <= 0) {
+    throw new Error(`Failed to resolve temp admin user id: ${email}`);
+  }
@@
-  const id = Number(dbQuery(
-    `SELECT id FROM libri WHERE titolo = '${escapeSql(title)}' ORDER BY id DESC LIMIT 1`
-  ));
+  const bookIdRaw = dbQuery(
+    `SELECT id FROM libri WHERE titolo = '${escapeSql(title)}' ORDER BY id DESC LIMIT 1`
+  );
+  const id = Number(bookIdRaw);
+  if (!Number.isInteger(id) || id <= 0) {
+    throw new Error(`Failed to resolve temp book id: ${title}`);
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/e2e-fixtures.js` around lines 173 - 189, Both temp-entity
creation paths currently use Number(dbQuery(...)) which can yield 0 for missing
rows; update the lookup in the user creation (the query that selects id from
utenti by email) and createTempBook (the query that selects id from libri by
titolo) to validate the dbQuery result, coerce to a Number, and immediately
throw a clear error if the resulting id is NaN or <= 0 (include the email or
title in the error message) instead of returning a 0 id; locate and modify the
id extraction logic in the functions that perform these queries to add the guard
and throw.

10-20: ⚠️ Potential issue | 🟠 Major

Require strict E2E_* config only for destructive fixtures.

This still falls back to APP_URL and repo .env, so a misconfigured run can mutate a non-test environment. Make BASE_URL and DB connection values E2E-only and fail fast if missing.

Suggested hardening
-const envFromFile = loadDotEnv(path.join(ROOT, '.env'));
-
-const BASE_URL = process.env.E2E_BASE_URL || process.env.APP_URL;
+const BASE_URL = process.env.E2E_BASE_URL || '';
 if (!BASE_URL) {
-  throw new Error('E2E_BASE_URL or APP_URL must be set (use /tmp/run-e2e.sh)');
+  throw new Error('E2E_BASE_URL must be set (use /tmp/run-e2e.sh)');
 }
-const DB_HOST = process.env.E2E_DB_HOST || envFromFile.DB_HOST || '';
-const DB_USER = process.env.E2E_DB_USER || envFromFile.DB_USER || '';
-const DB_PASS = process.env.E2E_DB_PASS ?? envFromFile.DB_PASS ?? '';
-const DB_NAME = process.env.E2E_DB_NAME || envFromFile.DB_NAME || '';
-const DB_SOCKET = process.env.E2E_DB_SOCKET || envFromFile.DB_SOCKET || '';
-if (!DB_USER || !DB_NAME) {
-  throw new Error('E2E_DB_USER and E2E_DB_NAME must be set (use /tmp/run-e2e.sh)');
+const DB_HOST = process.env.E2E_DB_HOST || '';
+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 || '';
+if (!DB_USER || !DB_NAME || (!DB_HOST && !DB_SOCKET)) {
+  throw new Error('Missing required E2E_DB_* configuration');
 }
Based on learnings, E2E tests with Playwright require `/tmp/run-e2e.sh` to set DB/admin credentials as env vars.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/e2e-fixtures.js` around lines 10 - 20, Change the fixture env
parsing to require E2E-only variables and fail fast: stop falling back to
APP_URL or envFromFile for BASE_URL and DB_*—read only process.env.E2E_BASE_URL,
process.env.E2E_DB_HOST, process.env.E2E_DB_USER, process.env.E2E_DB_PASS,
process.env.E2E_DB_NAME, process.env.E2E_DB_SOCKET and throw an Error if any
required E2E_* (at least E2E_BASE_URL, E2E_DB_USER and E2E_DB_NAME) are missing;
update the code that defines BASE_URL, DB_HOST, DB_USER, DB_PASS, DB_NAME and
DB_SOCKET to remove the non-E2E fallbacks so tests fail immediately when E2E
credentials are not provided.
app/Views/admin/plugins.php (1)

220-220: ⚠️ Potential issue | 🟠 Major

Stop hardcoding admin routes in these attributes.

These buttons now read their endpoint from data-settings-url, but the new attributes still embed /admin/... literals. If admin routes are translated or renamed, the modal buttons break again. Resolve them through route_path() or RouteTranslator::route() instead. As per coding guidelines, Never use hardcoded routes. Use route_path('key') or RouteTranslator::route('key') instead of hardcoded paths like /accedi or /login.

Also applies to: 231-231, 244-244, 262-262, 276-276, 286-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/admin/plugins.php` at line 220, Replace all hardcoded "/admin/..."
route fragments used in data attributes (e.g., data-settings-url, other data-*
attributes around the plugin actions that build URLs using (int) $plugin['id'])
with framework route helpers: call route_path('route_name', ['id' => (int)
$plugin['id']]) or RouteTranslator::route('route_name', ['id' => (int)
$plugin['id']]) to generate the URL; update every occurrence that currently
concatenates "/admin/plugins/" + (int) $plugin['id'] + "/settings" (and the
other similar endpoints at the noted locations) to use the appropriate named
route key (the route name for plugin settings/actions) and pass the id param so
the attributes read a resolved, non-hardcoded URL.
tests/helpers/branch-fix-harness.php (1)

433-445: ⚠️ Potential issue | 🟠 Major

Install/assert the LT schema before this reflected insert path too.

insertBook() still branches on \App\Support\LibraryThingInstaller::isInstalled($db) in app/Controllers/LibraryThingImportController.php:1393-1403. On a clean test DB this scenario can take the non-LT branch, so issn disappears before the API/frontend assertions even run.

🛠️ Proposed fix
 function scenarioPublicApiAndFrontendIssn(mysqli $db): array
 {
     $_SESSION = [];
     $factory = new ServerRequestFactory();
     $publicApi = new PublicApiController();
+
+    if (!\App\Support\LibraryThingInstaller::isInstalled($db)) {
+        $installer = new \App\Support\LibraryThingInstaller($db);
+        $installer->install();
+    }
+
     [$controller, $parse, $insert] = getLibraryThingMethods();
     $runId = randomRunId();
     $bookId = 0;
     $baseUrl = rtrim(getenv('BASE_URL') ?: 'http://localhost:8081', '/');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/branch-fix-harness.php` around lines 433 - 445, The
scenarioPublicApiAndFrontendIssn test harness currently calls the reflected
insert via getLibraryThingMethods() and insert->invoke(...) without ensuring the
LibraryThing schema is present, so insertBook() may take the non-LT branch;
before invoking $insert (or immediately after creating $db) call or assert the
installer so \App\Support\LibraryThingInstaller::isInstalled($db) returns
true—i.e., run the same install/assert routine used by other tests (or call the
LibraryThingInstaller install/assert method) so the reflected insert path uses
the LT branch and the issn field remains present for the API/frontend
assertions.
🧹 Nitpick comments (2)
storage/plugins/goodlib/views/settings.php (1)

16-130: Avoid maintaining a second GoodLib settings UI.

This template now overlaps almost 1:1 with the modal in app/Views/admin/plugins.php lines 707-848, but the two copies already diverge (datalist text inputs here vs. preset selects/custom fields there). Please collapse them to one shared partial before they drift further.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/goodlib/views/settings.php` around lines 16 - 130, The
settings UI blocks (the Sources/Visibility/Domains markup using $sourcesList,
$settings, and the anna_domain_options / zlib_domain_options datalists) are
duplicated between this settings.php and the admin plugins modal—extract those
repeated blocks into a single reusable partial (e.g., a view fragment) that
accepts $settings and the domain option arrays, replace the duplicated markup
here and in the admin modal with a single include/echo of that partial, and
ensure the partial preserves the same input attributes (name, pattern,
maxlength, htmlspecialchars usage and datalist IDs) so behavior remains
identical and divergence is avoided.
tests/helpers/branch-fix-harness.php (1)

214-214: Use route helpers in the harness instead of literal paths.

These scenarios hardcode both request URIs and an expected redirect target, so route translation or route-key changes will break the harness even when the controller behavior is still correct. Resolve the paths through route_path() / RouteTranslator::route() before building the request and when asserting redirects.

As per coding guidelines, Never use hardcoded routes. Use route_path('key') or RouteTranslator::route('key') instead of hardcoded paths like /accedi or /login.

Also applies to: 403-403, 418-418, 448-448, 456-456, 572-572, 583-583, 615-615, 666-666

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/branch-fix-harness.php` at line 214, Replace hardcoded URIs
used to build requests and to assert redirects with route resolution helpers:
instead of passing '/admin/collane/rinomina' into ->createServerRequest(), call
route_path('route.key.for.rinomina') or
RouteTranslator::route('route.key.for.rinomina') to obtain the URI and use that
same resolved value for any redirect assertions; update the createServerRequest
call and any subsequent redirect assertions in
tests/helpers/branch-fix-harness.php (and the other listed occurrences) to use
route_path() or RouteTranslator::route() so route-key changes won’t break the
harness.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1248-1252: The update/insert SQL still writes the ean column and
can hit the unique index; in upsertBook() add the same pre-upsert
conflict-clearing for ean as done for isbn10/isbn13: if $data['ean'] is
non-empty run an UPDATE libri SET ean = NULL WHERE ean = ? AND deleted_at IS
NULL (and if $existingBookId !== null use AND id != ?), execute and close the
statement before performing the INSERT/UPDATE that writes ean so any other live
book with that barcode is nulled first.

In `@app/Views/admin/plugins.php`:
- Around line 258-264: The button's data-enable-server attribute is reading the
wrong key; update the admin view (plugin modal seed) to read
$z39Settings['server_enabled'] (which PluginController::updateSettings() now
saves as 'true'/'false') and convert it to the expected '1'/'0' value for the
attribute (e.g., treat 'true' as '1' and anything else as '0'), replacing the
existing ($z39Settings['enable_server'] ?? '0') === '1' check so the modal
reflects the persisted server_enabled state correctly.

In `@storage/plugins/goodlib/GoodLibPlugin.php`:
- Around line 181-207: dbSetSetting currently swallows DB prepare/execute
failures and returns void, causing saveSettings to report success even when
writes fail; change dbSetSetting to return a boolean success flag (true on
successful set, false on any failure), ensure the fallback path checks prepare()
and execute() return values and returns false on error (or rethrow), and update
all call sites (including saveSettings and the other occurrence around lines
468-495) to check the returned bool and propagate/fail saveSettings accordingly
so the admin flow accurately reports persistence failures.
- Around line 390-391: The ISBN assignment for $isbn in GoodLibPlugin.php uses
the null coalescing operator which doesn't fall back when isbn13 is an empty
string; change the logic in the $isbn assignment (where $bookData['isbn13'] and
$bookData['isbn10'] are used) to explicitly prefer a non-empty isbn13 and fall
back to isbn10 (for example using a conditional that checks trim/empty or the ?:
operator) before casting and trimming, so an empty isbn13 won't block using
isbn10.

In `@storage/plugins/goodlib/views/badges.php`:
- Line 18: The translated string __("Cerca su:") is output raw inside the span
with class "font-medium", creating an XSS risk; change the echo to escape the
translation for HTML text context by wrapping the call to __() with the
project's HTML-escaping helper (e.g., h(), e(), or htmlspecialchars) so the
content is safely encoded before rendering.

In `@tests/branch-fix-consistency.spec.js`:
- Around line 24-31: The test currently calls JSON.parse(output) which hides raw
harness output on parse failure; wrap the JSON.parse(output) call in a try/catch
and on any parse error re-throw an Error that includes the original output (the
variable `output`) or attach it to the thrown error so the original PHP harness
snippet/warning/HTML is preserved for debugging; update the block surrounding
the existing catch that sets `output` (the same `output` variable referenced in
JSON.parse) so parse failures surface the raw output instead of only the JSON
parsing message.

In `@tests/goodlib-custom-domains.spec.js`:
- Around line 24-27: Ensure the test fails fast if the GoodLib plugin ID is
missing: after calling getPluginIdByName('goodlib') (assigned to pluginId) add a
guard that checks pluginId is truthy and throws or fails the test with a clear
message (e.g., "GoodLib plugin ID not found") before calling
snapshotPluginSettings(pluginId) or any other functions that depend on it; this
makes the failure immediate and more actionable.
- Line 14: The test uses .describe.serial but there's no enforced way to run it
with /tmp/run-e2e.sh and --workers=1 as required by CLAUDE.md; add a dedicated
npm script (e.g. "test:e2e" or "e2e") in package.json that invokes
/tmp/run-e2e.sh --workers=1 and update CI to call that script (or create a CI
workflow that runs /tmp/run-e2e.sh --workers=1), or alternatively add a
prominent header comment in tests/goodlib-custom-domains.spec.js referencing the
required invocation and CLAUDE.md so maintainers know to run the suite with
/tmp/run-e2e.sh and --workers=1.

---

Outside diff comments:
In `@app/Controllers/ProfileController.php`:
- Around line 20-24: In the show() method, guard the $db->prepare(...) result
before calling $stmt->bind_param() by checking if $stmt is falsy; if prepare()
returns false, call SecureLogger::error() with a clear message (include context
like the query or uid) and return/handle gracefully (same pattern used in
changePassword() and update()) to avoid dereferencing $stmt when prepare fails.

---

Duplicate comments:
In `@app/Views/admin/plugins.php`:
- Line 220: Replace all hardcoded "/admin/..." route fragments used in data
attributes (e.g., data-settings-url, other data-* attributes around the plugin
actions that build URLs using (int) $plugin['id']) with framework route helpers:
call route_path('route_name', ['id' => (int) $plugin['id']]) or
RouteTranslator::route('route_name', ['id' => (int) $plugin['id']]) to generate
the URL; update every occurrence that currently concatenates "/admin/plugins/" +
(int) $plugin['id'] + "/settings" (and the other similar endpoints at the noted
locations) to use the appropriate named route key (the route name for plugin
settings/actions) and pass the id param so the attributes read a resolved,
non-hardcoded URL.

In `@tests/helpers/branch-fix-harness.php`:
- Around line 433-445: The scenarioPublicApiAndFrontendIssn test harness
currently calls the reflected insert via getLibraryThingMethods() and
insert->invoke(...) without ensuring the LibraryThing schema is present, so
insertBook() may take the non-LT branch; before invoking $insert (or immediately
after creating $db) call or assert the installer so
\App\Support\LibraryThingInstaller::isInstalled($db) returns true—i.e., run the
same install/assert routine used by other tests (or call the
LibraryThingInstaller install/assert method) so the reflected insert path uses
the LT branch and the issn field remains present for the API/frontend
assertions.

In `@tests/helpers/e2e-fixtures.js`:
- Around line 173-189: Both temp-entity creation paths currently use
Number(dbQuery(...)) which can yield 0 for missing rows; update the lookup in
the user creation (the query that selects id from utenti by email) and
createTempBook (the query that selects id from libri by titolo) to validate the
dbQuery result, coerce to a Number, and immediately throw a clear error if the
resulting id is NaN or <= 0 (include the email or title in the error message)
instead of returning a 0 id; locate and modify the id extraction logic in the
functions that perform these queries to add the guard and throw.
- Around line 10-20: Change the fixture env parsing to require E2E-only
variables and fail fast: stop falling back to APP_URL or envFromFile for
BASE_URL and DB_*—read only process.env.E2E_BASE_URL, process.env.E2E_DB_HOST,
process.env.E2E_DB_USER, process.env.E2E_DB_PASS, process.env.E2E_DB_NAME,
process.env.E2E_DB_SOCKET and throw an Error if any required E2E_* (at least
E2E_BASE_URL, E2E_DB_USER and E2E_DB_NAME) are missing; update the code that
defines BASE_URL, DB_HOST, DB_USER, DB_PASS, DB_NAME and DB_SOCKET to remove the
non-E2E fallbacks so tests fail immediately when E2E credentials are not
provided.

---

Nitpick comments:
In `@storage/plugins/goodlib/views/settings.php`:
- Around line 16-130: The settings UI blocks (the Sources/Visibility/Domains
markup using $sourcesList, $settings, and the anna_domain_options /
zlib_domain_options datalists) are duplicated between this settings.php and the
admin plugins modal—extract those repeated blocks into a single reusable partial
(e.g., a view fragment) that accepts $settings and the domain option arrays,
replace the duplicated markup here and in the admin modal with a single
include/echo of that partial, and ensure the partial preserves the same input
attributes (name, pattern, maxlength, htmlspecialchars usage and datalist IDs)
so behavior remains identical and divergence is avoided.

In `@tests/helpers/branch-fix-harness.php`:
- Line 214: Replace hardcoded URIs used to build requests and to assert
redirects with route resolution helpers: instead of passing
'/admin/collane/rinomina' into ->createServerRequest(), call
route_path('route.key.for.rinomina') or
RouteTranslator::route('route.key.for.rinomina') to obtain the URI and use that
same resolved value for any redirect assertions; update the createServerRequest
call and any subsequent redirect assertions in
tests/helpers/branch-fix-harness.php (and the other listed occurrences) to use
route_path() or RouteTranslator::route() so route-key changes won’t break the
harness.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a56f6fe2-7dab-4f30-a951-696c448e226b

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 0e4f906.

📒 Files selected for processing (30)
  • .gitignore
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Support/PluginManager.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • app/Views/profile/index.php
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/goodlib/GoodLibPlugin.php
  • storage/plugins/goodlib/plugin.json
  • storage/plugins/goodlib/views/badges.php
  • storage/plugins/goodlib/views/settings.php
  • storage/plugins/goodlib/wrapper.php
  • tests/branch-fix-consistency.spec.js
  • tests/full-test.spec.js
  • tests/goodlib-custom-domains.spec.js
  • tests/helpers/branch-fix-harness.php
  • tests/helpers/e2e-fixtures.js

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/Controllers/AuthController.php (1)

51-56: ⚠️ Potential issue | 🟠 Major

Wrap the database query in a try-catch block to handle exceptions.

With MYSQLI_REPORT_STRICT enabled in ConfigStore.php, prepare(), bind_param(), and execute() throw exceptions on failure rather than returning false. The current code has no exception handling, so a database error becomes an unhandled exception and produces a 500 error instead of a controlled login failure path. Catch \Throwable and return an appropriate auth error response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/AuthController.php` around lines 51 - 56, Wrap the database
query (the sequence using $db->prepare(...), $stmt->bind_param('s', $email),
$stmt->execute(), $stmt->get_result() and $res->fetch_assoc(), and
$stmt->close()) in a try-catch that catches \Throwable; on any caught exception
close/free the statement if set and return the controlled authentication error
response used by this controller (the same path used for invalid credentials)
instead of letting the exception bubble up. Ensure the catch block prevents
leaking exception details and maps failures during
prepare/bind/execute/get_result to the normal auth failure response.
app/Controllers/LibraryThingImportController.php (1)

1198-1227: ⚠️ Potential issue | 🟠 Major

Clear ean conflicts before writes to avoid unique constraint violations.

The conflict-clearing prelude nullifies isbn13 and isbn10 on other rows before upsert, but ean is still written without the same conflict-clearing step. A row whose barcode already belongs to another live book will still hit the unique index and roll back the chunk.

🛠️ Proposed fix (add EAN conflict clearing)
             if ($conflictsCleared > 0) {
                 $this->log("[upsertBook] Cleared ISBN10 '{$data['isbn10']}' from $conflictsCleared conflicting book(s)");
             }
         }
+        if (!empty($data['ean'])) {
+            $stmt = $db->prepare("UPDATE libri SET ean = NULL WHERE ean = ? AND id != ? AND deleted_at IS NULL");
+            $stmt->bind_param('si', $data['ean'], $existingBookId);
+            $stmt->execute();
+            $conflictsCleared = $stmt->affected_rows;
+            $stmt->close();
+            if ($conflictsCleared > 0) {
+                $this->log("[upsertBook] Cleared EAN '{$data['ean']}' from $conflictsCleared conflicting book(s)");
+            }
+        }

         $this->updateBook($db, $existingBookId, $data, $editorId, $genreId);

For the INSERT path (when $existingBookId is null), use:

if (!empty($data['ean'])) {
    $stmt = $db->prepare("UPDATE libri SET ean = NULL WHERE ean = ? AND deleted_at IS NULL");
    $stmt->bind_param('s', $data['ean']);
    $stmt->execute();
    $stmt->close();
}

Based on learnings, Nullify unique-indexed columns (isbn10, isbn13, ean) on soft-delete of libri table records.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1198 - 1227,
The upsertBook flow clears conflicting isbn13/isbn10 values but omits ean, which
can trigger unique-index violations; update upsertBook to mirror the isbn
handling by nullifying conflicting ean values on other live rows before writing
(in the existingBookId != null branch do an UPDATE libri SET ean = NULL WHERE
ean = ? AND id != ? AND deleted_at IS NULL and log affected rows) and also add
the same pre-write ean conflict-clear step in the INSERT path (when
findExistingBook returns null) using UPDATE libri SET ean = NULL WHERE ean = ?
AND deleted_at IS NULL so ean uniqueness won't block the insert; touch the same
helper flow that precedes calling updateBook/insert logic and reference
$data['ean'], upsertBook, findExistingBook, and updateBook when making the
changes.
♻️ Duplicate comments (8)
app/Controllers/ProfileController.php (1)

20-24: ⚠️ Potential issue | 🟠 Major

Handle prepare() failure in show() before binding.

This lookup now selects locale, but the method still assumes statement creation succeeded. A prepare failure here becomes a fatal error on the profile page instead of a handled server failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/ProfileController.php` around lines 20 - 24, The prepare()
call in ProfileController::show (the $stmt = $db->prepare(...) line) can return
false and must be checked before calling $stmt->bind_param, otherwise a fatal
error occurs; update show() to verify $stmt !== false, and if prepare failed log
the DB error (or $db->error), return/throw a handled server error response (e.g.
render an error page or send a 500) and avoid calling bind_param/execute/close;
keep existing logic for successful prepares (bind_param, execute, fetch_assoc,
close) unchanged.
storage/plugins/goodlib/views/badges.php (1)

18-18: ⚠️ Potential issue | 🟠 Major

Escape translated text before rendering in HTML.

Line 18 outputs __("Cerca su:") raw. If translation content is ever compromised, this can inject markup/script in page content.

Suggested fix
-  <span class="font-medium"><?= __("Cerca su:") ?></span>
+  <span class="font-medium"><?= htmlspecialchars(__("Cerca su:"), ENT_QUOTES, 'UTF-8') ?></span>

Based on learnings: Use htmlspecialchars(url(...), ENT_QUOTES, 'UTF-8') in ALL HTML attributes for view escaping.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/goodlib/views/badges.php` at line 18, The translated string
output __("Cerca su:") in badges.php is rendered raw; wrap the translation with
an HTML-escaping helper (e.g., call htmlspecialchars(__(...), ENT_QUOTES,
'UTF-8') or the framework's h() escape helper) before echoing so the span
renders escaped text and prevents injected markup/scripts.
app/Views/admin/plugins.php (2)

258-264: ⚠️ Potential issue | 🟠 Major

Seed the Z39 modal from server_enabled, not enable_server.

PluginController::updateSettings() now persists server_enabled as true/false. Reading enable_server === '1' here reopens the modal in the wrong state, and the next save can silently disable the server.

Minimal fix
-                                        data-enable-server="<?= ($z39Settings['enable_server'] ?? '0') === '1' ? '1' : '0' ?>"
+                                        data-enable-server="<?= ($z39Settings['server_enabled'] ?? 'false') === 'true' ? '1' : '0' ?>"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/admin/plugins.php` around lines 258 - 264, The modal seed uses the
old string flag; update the data attribute to read the persisted boolean field
instead: replace uses of ($z39Settings['enable_server'] ?? '0') === '1' with
checking ($z39Settings['server_enabled'] ?? false) (or equivalent truthy check)
when setting data-enable-server on the button in admin/plugins.php so the UI
reflects the true persisted state (also verify data-enable-client remains
correct and that PluginController::updateSettings() persists server_enabled).

215-221: ⚠️ Potential issue | 🟠 Major

Use route helpers instead of hardcoded /admin/plugins/... paths.

These data-settings-url attributes still bake the literal settings path into the view. If admin routes are translated or renamed, all of these modals break again.

As per coding guidelines, Never use hardcoded routes. Use route_path('key') or RouteTranslator::route('key') instead of hardcoded paths like /accedi or /login.

Also applies to: 226-232, 239-245, 258-262, 283-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/admin/plugins.php` around lines 215 - 221, Replace the hardcoded
settings URL in the data-settings-url attribute with a route helper call (e.g.,
RouteTranslator::route(...) or route_path(...)) so the plugin settings URL is
generated from the named admin route rather than "/admin/plugins/{id}/settings";
update the same change for all other similar attributes in this file (the other
button blocks referenced) and ensure you pass the plugin id (from $plugin['id'])
as the route parameter so openPluginSettingsModal receives a correct,
translatable URL.
tests/goodlib-custom-domains.spec.js (1)

23-27: ⚠️ Potential issue | 🟡 Minor

Fail fast if GoodLib isn't registered.

Without a guard after getPluginIdByName('goodlib'), the suite snapshots/restores settings for 0 and the real problem shows up much later.

Suggested hardening
   test.beforeAll(() => {
     pluginId = getPluginIdByName('goodlib');
+    if (!pluginId) {
+      throw new Error('GoodLib plugin not found; cannot run custom-domain E2E test.');
+    }
     originalSettings = snapshotPluginSettings(pluginId);
     adminUser = createTempAdminUser('it_IT');
     book = createTempBook();
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/goodlib-custom-domains.spec.js` around lines 23 - 27, The test setup in
test.beforeAll calls getPluginIdByName('goodlib') into pluginId and proceeds
without verifying it, causing later failures when pluginId is 0; update
test.beforeAll to check the returned pluginId immediately (e.g., if falsy or 0)
and fail fast by throwing or calling test.skip with a clear message that GoodLib
is not registered so snapshotPluginSettings/restore and subsequent operations
are not executed for an invalid pluginId.
tests/branch-fix-consistency.spec.js (1)

24-31: ⚠️ Potential issue | 🟡 Minor

Keep the raw harness output when parsing fails.

The final parse step turns a PHP warning or HTML error page into Unexpected token ..., even though output was already captured above.

Suggested hardening
-  return JSON.parse(output);
+  try {
+    return JSON.parse(output);
+  } catch {
+    throw new Error(`Invalid harness output for "${name}": ${output.slice(0, 500)}`);
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/branch-fix-consistency.spec.js` around lines 24 - 31, The code
currently does JSON.parse(output) unguarded so a parse error (e.g. PHP
warning/HTML) loses the captured raw harness output; wrap the JSON.parse(output)
in a try/catch and on parse failure rethrow an Error (or augment the thrown
error) that includes the original raw output string (variable output) so callers
see the raw harness output alongside the parse error; locate the JSON.parse call
and the output variable in this file (tests/branch-fix-consistency.spec.js) to
implement the safe-parse and preserve-output behavior.
storage/plugins/goodlib/GoodLibPlugin.php (2)

181-207: ⚠️ Potential issue | 🟠 Major

Make dbSetSetting() report failures.

The PluginManager path ignores the boolean return from setSetting(), and the fallback SQL path ignores prepare() / execute() failures. That leaves onActivate() and saveSettings() unable to tell whether anything was actually persisted.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/goodlib/GoodLibPlugin.php` around lines 181 - 207,
dbSetSetting currently returns void and ignores failures from
PluginManager::setSetting and from the direct SQL path; change dbSetSetting to
return bool (true on success, false on any failure), use the boolean return
value from \App\Support\PluginManager::setSetting($this->pluginId, $key, $value,
true) and immediately return that result, and in the fallback path check that
$this->db->prepare(...) returned a statement, that bind_param and execute
succeed (return false on any failure), and close the statement before returning
true; update callers like onActivate() and saveSettings() to check the new
boolean return and handle/report failures accordingly.

390-391: ⚠️ Potential issue | 🟡 Minor

Use isbn10 when isbn13 is blank.

An empty isbn13 still wins here, so books with isbn13 = '' and a populated isbn10 lose the precise ISBN search and fall back to the broader title/author query.

Minimal fix
-        $isbn = trim((string) ($bookData['isbn13'] ?? $bookData['isbn10'] ?? ''));
+        $isbn13 = trim((string) ($bookData['isbn13'] ?? ''));
+        $isbn10 = trim((string) ($bookData['isbn10'] ?? ''));
+        $isbn = $isbn13 !== '' ? $isbn13 : $isbn10;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/goodlib/GoodLibPlugin.php` around lines 390 - 391, The
current selection for $isbn in GoodLibPlugin.php uses the null coalescing
operator on $bookData['isbn13'] which still selects an empty string when isbn13
exists but is blank; change the logic that sets $isbn (the line creating $isbn
from $bookData) to prefer a non-empty trimmed isbn13 and only fall back to
isbn10 when isbn13 is empty or whitespace (e.g., compute trimmed values and
choose isbn13 if !== '' else isbn10) so precise ISBN searches use isbn10 when
isbn13 is blank.
🧹 Nitpick comments (1)
tests/helpers/branch-fix-harness.php (1)

58-68: Consider using prepared statements for consistency.

While the int type hints provide protection against SQL injection, using prepared statements would be more consistent with the rest of the codebase and serve as a better reference pattern.

♻️ Optional refactor
 function deleteBook(mysqli $db, int $bookId): void
 {
-    $db->query('DELETE FROM copie WHERE libro_id = ' . $bookId);
-    $db->query('DELETE FROM libri WHERE id = ' . $bookId);
+    $stmt = $db->prepare('DELETE FROM copie WHERE libro_id = ?');
+    $stmt->bind_param('i', $bookId);
+    $stmt->execute();
+    $stmt->close();
+
+    $stmt = $db->prepare('DELETE FROM libri WHERE id = ?');
+    $stmt->bind_param('i', $bookId);
+    $stmt->execute();
+    $stmt->close();
 }
 
 function deleteUser(mysqli $db, int $userId): void
 {
-    $db->query('DELETE FROM user_sessions WHERE utente_id = ' . $userId);
-    $db->query('DELETE FROM utenti WHERE id = ' . $userId);
+    $stmt = $db->prepare('DELETE FROM user_sessions WHERE utente_id = ?');
+    $stmt->bind_param('i', $userId);
+    $stmt->execute();
+    $stmt->close();
+
+    $stmt = $db->prepare('DELETE FROM utenti WHERE id = ?');
+    $stmt->bind_param('i', $userId);
+    $stmt->execute();
+    $stmt->close();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/branch-fix-harness.php` around lines 58 - 68, The deleteBook
and deleteUser functions currently build SQL with string concatenation which,
despite int type hints, is inconsistent with the codebase; change both functions
(deleteBook(mysqli $db, int $bookId) and deleteUser(mysqli $db, int $userId)) to
use prepared statements: prepare the DELETE queries with placeholders for
libro_id/id and utente_id/id, bind the integer parameter(s) (bookId or userId)
using mysqli_stmt::bind_param, execute and close the statement for each query
(both DELETEs in each function) to mirror the existing prepared-statement
pattern used elsewhere.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/PluginController.php`:
- Around line 372-384: The current loop in PluginController ignores the return
value of pluginManager->setSetting for both the $boolKeys and
$normalizedDomains, always returning success:true; update the logic to check
each pluginManager->setSetting($pluginId, $key, $value, true) and
pluginManager->setSetting($pluginId, $domainKey, $domain, true) call, collect
any failures (or short-circuit on first failure), and return success:false with
an appropriate message and the failing key(s) instead of emitting success:true
when any write fails; ensure the response->getBody()->write payload reflects
failure and includes which setting(s) failed (use $pluginId, $boolKeys,
$normalizedDomains, and the pluginManager->setSetting return values to build the
error info).

In `@app/Controllers/ProfileController.php`:
- Around line 180-192: The success flash ($_SESSION['success_message']) is set
before the locale change, so after a language update the confirmation shows in
the old language; in ProfileController, after you call $stmt->execute() move the
assignment of $_SESSION['success_message'] to occur after the locale update
block (the logic that checks $localeProvided and calls
\App\Support\I18n::setLocale and updates/unsets $_SESSION['locale']) so the
message is generated with the new locale; keep the session name update
($_SESSION['user']['name']) where appropriate or also move it together if you
want all session changes applied after the locale switch.

In `@storage/plugins/goodlib/plugin.json`:
- Around line 11-12: The manifest declares requires_app and max_app_version but
the system doesn't enforce them; update the plugin registration path in
PluginManager (e.g., registerBundledPlugin/registerPlugin methods) to parse and
validate the plugin.json fields requires_app and max_app_version against the
running app version (using semver range checks) and reject or skip registration
when the app version is out of range, logging a clear message; alternatively, if
you prefer not to add enforcement, remove the requires_app and max_app_version
keys from plugin.json so the manifest does not advertise unsupported bounds.

In `@storage/plugins/goodlib/views/settings.php`:
- Around line 92-101: The HTML input for the Anna domain (input id/name
"anna_domain" in settings.php) uses a restrictive pattern that only allows bare
host[:port], but the backend normalizers accept full URLs (with scheme, path,
query) and strip them; update the input's validation to match the backend by
removing or relaxing the pattern attribute (or replace it with a pattern that
allows an optional scheme (http|https):// and optional path/query, and optional
port) so strings like "https://annas-archive.custom.test/path?q=ignored" are
accepted; apply the same change to the sibling input(s) around lines 110-119
that use the same pattern.

---

Outside diff comments:
In `@app/Controllers/AuthController.php`:
- Around line 51-56: Wrap the database query (the sequence using
$db->prepare(...), $stmt->bind_param('s', $email), $stmt->execute(),
$stmt->get_result() and $res->fetch_assoc(), and $stmt->close()) in a try-catch
that catches \Throwable; on any caught exception close/free the statement if set
and return the controlled authentication error response used by this controller
(the same path used for invalid credentials) instead of letting the exception
bubble up. Ensure the catch block prevents leaking exception details and maps
failures during prepare/bind/execute/get_result to the normal auth failure
response.

In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1198-1227: The upsertBook flow clears conflicting isbn13/isbn10
values but omits ean, which can trigger unique-index violations; update
upsertBook to mirror the isbn handling by nullifying conflicting ean values on
other live rows before writing (in the existingBookId != null branch do an
UPDATE libri SET ean = NULL WHERE ean = ? AND id != ? AND deleted_at IS NULL and
log affected rows) and also add the same pre-write ean conflict-clear step in
the INSERT path (when findExistingBook returns null) using UPDATE libri SET ean
= NULL WHERE ean = ? AND deleted_at IS NULL so ean uniqueness won't block the
insert; touch the same helper flow that precedes calling updateBook/insert logic
and reference $data['ean'], upsertBook, findExistingBook, and updateBook when
making the changes.

---

Duplicate comments:
In `@app/Controllers/ProfileController.php`:
- Around line 20-24: The prepare() call in ProfileController::show (the $stmt =
$db->prepare(...) line) can return false and must be checked before calling
$stmt->bind_param, otherwise a fatal error occurs; update show() to verify $stmt
!== false, and if prepare failed log the DB error (or $db->error), return/throw
a handled server error response (e.g. render an error page or send a 500) and
avoid calling bind_param/execute/close; keep existing logic for successful
prepares (bind_param, execute, fetch_assoc, close) unchanged.

In `@app/Views/admin/plugins.php`:
- Around line 258-264: The modal seed uses the old string flag; update the data
attribute to read the persisted boolean field instead: replace uses of
($z39Settings['enable_server'] ?? '0') === '1' with checking
($z39Settings['server_enabled'] ?? false) (or equivalent truthy check) when
setting data-enable-server on the button in admin/plugins.php so the UI reflects
the true persisted state (also verify data-enable-client remains correct and
that PluginController::updateSettings() persists server_enabled).
- Around line 215-221: Replace the hardcoded settings URL in the
data-settings-url attribute with a route helper call (e.g.,
RouteTranslator::route(...) or route_path(...)) so the plugin settings URL is
generated from the named admin route rather than "/admin/plugins/{id}/settings";
update the same change for all other similar attributes in this file (the other
button blocks referenced) and ensure you pass the plugin id (from $plugin['id'])
as the route parameter so openPluginSettingsModal receives a correct,
translatable URL.

In `@storage/plugins/goodlib/GoodLibPlugin.php`:
- Around line 181-207: dbSetSetting currently returns void and ignores failures
from PluginManager::setSetting and from the direct SQL path; change dbSetSetting
to return bool (true on success, false on any failure), use the boolean return
value from \App\Support\PluginManager::setSetting($this->pluginId, $key, $value,
true) and immediately return that result, and in the fallback path check that
$this->db->prepare(...) returned a statement, that bind_param and execute
succeed (return false on any failure), and close the statement before returning
true; update callers like onActivate() and saveSettings() to check the new
boolean return and handle/report failures accordingly.
- Around line 390-391: The current selection for $isbn in GoodLibPlugin.php uses
the null coalescing operator on $bookData['isbn13'] which still selects an empty
string when isbn13 exists but is blank; change the logic that sets $isbn (the
line creating $isbn from $bookData) to prefer a non-empty trimmed isbn13 and
only fall back to isbn10 when isbn13 is empty or whitespace (e.g., compute
trimmed values and choose isbn13 if !== '' else isbn10) so precise ISBN searches
use isbn10 when isbn13 is blank.

In `@storage/plugins/goodlib/views/badges.php`:
- Line 18: The translated string output __("Cerca su:") in badges.php is
rendered raw; wrap the translation with an HTML-escaping helper (e.g., call
htmlspecialchars(__(...), ENT_QUOTES, 'UTF-8') or the framework's h() escape
helper) before echoing so the span renders escaped text and prevents injected
markup/scripts.

In `@tests/branch-fix-consistency.spec.js`:
- Around line 24-31: The code currently does JSON.parse(output) unguarded so a
parse error (e.g. PHP warning/HTML) loses the captured raw harness output; wrap
the JSON.parse(output) in a try/catch and on parse failure rethrow an Error (or
augment the thrown error) that includes the original raw output string (variable
output) so callers see the raw harness output alongside the parse error; locate
the JSON.parse call and the output variable in this file
(tests/branch-fix-consistency.spec.js) to implement the safe-parse and
preserve-output behavior.

In `@tests/goodlib-custom-domains.spec.js`:
- Around line 23-27: The test setup in test.beforeAll calls
getPluginIdByName('goodlib') into pluginId and proceeds without verifying it,
causing later failures when pluginId is 0; update test.beforeAll to check the
returned pluginId immediately (e.g., if falsy or 0) and fail fast by throwing or
calling test.skip with a clear message that GoodLib is not registered so
snapshotPluginSettings/restore and subsequent operations are not executed for an
invalid pluginId.

---

Nitpick comments:
In `@tests/helpers/branch-fix-harness.php`:
- Around line 58-68: The deleteBook and deleteUser functions currently build SQL
with string concatenation which, despite int type hints, is inconsistent with
the codebase; change both functions (deleteBook(mysqli $db, int $bookId) and
deleteUser(mysqli $db, int $userId)) to use prepared statements: prepare the
DELETE queries with placeholders for libro_id/id and utente_id/id, bind the
integer parameter(s) (bookId or userId) using mysqli_stmt::bind_param, execute
and close the statement for each query (both DELETEs in each function) to mirror
the existing prepared-statement pattern used elsewhere.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1bcd8b23-ad05-4b98-9422-515615d88dbe

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and 18dbd73.

📒 Files selected for processing (30)
  • .gitignore
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Support/PluginManager.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • app/Views/profile/index.php
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/goodlib/GoodLibPlugin.php
  • storage/plugins/goodlib/plugin.json
  • storage/plugins/goodlib/views/badges.php
  • storage/plugins/goodlib/views/settings.php
  • storage/plugins/goodlib/wrapper.php
  • tests/branch-fix-consistency.spec.js
  • tests/full-test.spec.js
  • tests/goodlib-custom-domains.spec.js
  • tests/helpers/branch-fix-harness.php
  • tests/helpers/e2e-fixtures.js

Comment on lines +372 to +384
foreach ($boolKeys as $key) {
$value = isset($settings[$key]) && $settings[$key] === '1' ? '1' : '0';
$this->pluginManager->setSetting($pluginId, $key, $value, true);
}
foreach ($normalizedDomains as $domainKey => $domain) {
$this->pluginManager->setSetting($pluginId, $domainKey, $domain, true);
}

$response->getBody()->write(json_encode([
'success' => true,
'message' => __('Impostazioni GoodLib salvate correttamente.'),
'data' => $normalizedDomains,
]));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't return success when a GoodLib write fails.

This branch ignores every PluginManager::setSetting() result and still emits success: true. If any write fails, the config can be partially updated while the modal says everything was saved.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/PluginController.php` around lines 372 - 384, The current
loop in PluginController ignores the return value of pluginManager->setSetting
for both the $boolKeys and $normalizedDomains, always returning success:true;
update the logic to check each pluginManager->setSetting($pluginId, $key,
$value, true) and pluginManager->setSetting($pluginId, $domainKey, $domain,
true) call, collect any failures (or short-circuit on first failure), and return
success:false with an appropriate message and the failing key(s) instead of
emitting success:true when any write fails; ensure the
response->getBody()->write payload reflects failure and includes which
setting(s) failed (use $pluginId, $boolKeys, $normalizedDomains, and the
pluginManager->setSetting return values to build the error info).

Comment on lines +11 to +12
"requires_app": "0.4.0",
"max_app_version": "1.0.0",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

requires_app / max_app_version are declarative only right now.

Bundled-plugin registration currently stores these fields but does not enforce them, so GoodLib will still auto-register outside this declared range. Either add compatibility checks in PluginManager or drop the unsupported bounds from this manifest for now.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/goodlib/plugin.json` around lines 11 - 12, The manifest
declares requires_app and max_app_version but the system doesn't enforce them;
update the plugin registration path in PluginManager (e.g.,
registerBundledPlugin/registerPlugin methods) to parse and validate the
plugin.json fields requires_app and max_app_version against the running app
version (using semver range checks) and reject or skip registration when the app
version is out of range, logging a clear message; alternatively, if you prefer
not to add enforcement, remove the requires_app and max_app_version keys from
plugin.json so the manifest does not advertise unsupported bounds.

- ProfileController::show(): prepare() guard before bind_param
- ProfileController: flash message after locale switch (correct language)
- badges.php: escape __("Cerca su:") with htmlspecialchars
- Z39 plugins.php: read server_enabled (not enable_server) for modal state
- GoodLib ISBN: prefer non-empty isbn13 over isbn10 (?: not ??)
- LT upsertBook: clear EAN conflicts before insert/update (like isbn13/isbn10)
- GoodLib dbSetSetting: return bool, propagate failures in saveSettings
- branch-fix-consistency: safe JSON.parse with raw output on failure
- settings.php: remove restrictive pattern on domain inputs (backend normalizes)
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 26, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/Controllers/AuthController.php (1)

51-56: ⚠️ Potential issue | 🟠 Major

Guard prepare() failure before calling bind_param() (Line 51).

If mysqli::prepare() returns false, the login flow fails fatally on bind_param(). Other controllers in the codebase guard this condition; implement the same pattern here.

Proposed fix
-            $stmt = $db->prepare("SELECT id, email, password, tipo_utente, email_verificata, stato, nome, cognome, locale FROM utenti WHERE LOWER(email) = LOWER(?) LIMIT 1");
-            $stmt->bind_param('s', $email);
+            $stmt = $db->prepare("SELECT id, email, password, tipo_utente, email_verificata, stato, nome, cognome, locale FROM utenti WHERE LOWER(email) = LOWER(?) LIMIT 1");
+            if ($stmt === false) {
+                Log::security('login.db_prepare_failed', [
+                    'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
+                    'db_error' => $db->error,
+                ]);
+                return $response->withHeader('Location', RouteTranslator::route('login') . '?error=server')->withStatus(302);
+            }
+            $stmt->bind_param('s', $email);
             $stmt->execute();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/AuthController.php` around lines 51 - 56, The code calls
$db->prepare(...) and immediately uses $stmt->bind_param(...), but $db->prepare
can return false causing a fatal error; update the AuthController login flow to
check the result of $db->prepare (the $stmt variable) before calling
$stmt->bind_param, mirroring other controllers: if $stmt === false, log or
handle the DB error (using existing logger or return an appropriate response),
and only proceed to bind_param, execute, get_result and fetch_assoc when $stmt
is a valid statement; ensure $stmt->close() is only called when $stmt is a valid
object.
app/Controllers/LibraryThingImportController.php (1)

1267-1284: ⚠️ Potential issue | 🟠 Major

Add error handling to all mysqli prepare() and execute() calls in these write paths.

The code calls prepare(), bind_param(), and execute() without checking return values. If prepare() fails with strict reporting off, calling bind_param() on false will fatal. If execute() fails silently, code continues using unreliable affected_rows or insert_id, creating orphaned or invalid records.

The pattern appears throughout updateBook(), insertBook(), and the conflict-clearing operations in upsertBook(). Only one check exists (line 1395: if ($checkStmt)), proving the risk was recognized but not applied consistently.

🛠️ Minimal hardening pattern
 $stmt = $db->prepare("UPDATE libri SET ...");
+if (!$stmt) {
+    throw new \RuntimeException('Failed to prepare statement: ' . $db->error);
+}
 $stmt->bind_param(...);
-$stmt->execute();
+if (!$stmt->execute()) {
+    $error = $stmt->error;
+    $stmt->close();
+    throw new \RuntimeException('Failed to execute: ' . $error);
+}
 $stmt->close();

Applies to: 1210–1249 (conflict clearing), 1267–1284, 1348–1356, 1391–1393, 1432–1461, 1526–1535, 1572–1573.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1267 - 1284,
The prepare()/bind_param()/execute() calls in updateBook(), insertBook(), and
upsertBook() (e.g. the $stmt prepared in the UPDATE block and the
conflict-clearing statements) lack error handling: check that $db->prepare(...)
returns a valid statement before calling bind_param(), and verify that
->execute() returns true; on failure log or throw with $db->error and/or
$stmt->error and abort the write path (return false or propagate exception) to
avoid calling methods on false or proceeding with bad affected_rows/insert_id;
apply this pattern to every mysqli prepare/execute use in the listed regions
(conflict clearing, the UPDATE stmt around $stmt, and the other insert/update
statements).
♻️ Duplicate comments (3)
app/Controllers/PluginController.php (1)

372-378: ⚠️ Potential issue | 🟠 Major

Do not report success when a GoodLib setting write fails.

Line 374 and Line 377 ignore setSetting() outcomes, so the response at Line 381 can return success: true after partial write failures.

🛠️ Suggested fix
             // All validated — now persist
+            $failedKeys = [];
             foreach ($boolKeys as $key) {
                 $value = isset($settings[$key]) && $settings[$key] === '1' ? '1' : '0';
-                $this->pluginManager->setSetting($pluginId, $key, $value, true);
+                if (!$this->pluginManager->setSetting($pluginId, $key, $value, true)) {
+                    $failedKeys[] = $key;
+                }
             }
             foreach ($normalizedDomains as $domainKey => $domain) {
-                $this->pluginManager->setSetting($pluginId, $domainKey, $domain, true);
+                if (!$this->pluginManager->setSetting($pluginId, $domainKey, $domain, true)) {
+                    $failedKeys[] = $domainKey;
+                }
+            }
+
+            if ($failedKeys !== []) {
+                $response->getBody()->write(json_encode([
+                    'success' => false,
+                    'message' => __('Errore durante il salvataggio delle impostazioni.'),
+                    'failed_keys' => $failedKeys,
+                ]));
+                return $response->withHeader('Content-Type', 'application/json')->withStatus(500);
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/PluginController.php` around lines 372 - 378, The success
response is returned even if some writes via
$this->pluginManager->setSetting(...) fail; update the loops in PluginController
so you capture and check each setSetting() boolean return (both in the boolKeys
loop and normalizedDomains loop), accumulate an overall $allSaved flag (start
true, set false on any failure), and use that flag when building the response
instead of unconditionally returning success; also consider short-circuiting or
collecting failed keys for clearer error messages.
tests/goodlib-custom-domains.spec.js (1)

23-27: ⚠️ Potential issue | 🟡 Minor

Fail fast when GoodLib plugin ID is not resolved.

Line 24 feeds directly into Line 25 without validation; if the plugin is missing, failures surface later with less-clear diagnostics.

💡 Suggested hardening
   test.beforeAll(() => {
     pluginId = getPluginIdByName('goodlib');
+    if (!pluginId) {
+      throw new Error('GoodLib plugin ID not found; cannot run custom-domain E2E test.');
+    }
     originalSettings = snapshotPluginSettings(pluginId);
     adminUser = createTempAdminUser('it_IT');
     book = createTempBook();
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/goodlib-custom-domains.spec.js` around lines 23 - 27, After calling
getPluginIdByName('goodlib') in the test.beforeAll block, immediately validate
the returned pluginId and fail fast if it's missing: check the pluginId variable
(from pluginId = getPluginIdByName('goodlib')) and throw a clear error or call
the test framework's fail/assert before calling
snapshotPluginSettings(pluginId), createTempAdminUser, or createTempBook so
missing plugin resolution surfaces with a precise diagnostic.
app/Views/admin/plugins.php (1)

220-220: ⚠️ Potential issue | 🟠 Major

Use named route helpers for these modal endpoints.

These data-settings-url values still hardcode /admin/... path segments via url(). If admin routes are translated or renamed, the modals will post to dead URLs even though the page itself still renders. Resolve each settings endpoint from a named route instead of embedding literal path strings in the view.

As per coding guidelines, Never use hardcoded routes. Use route_path('key') or RouteTranslator::route('key') instead of hardcoded paths like /accedi or /login.

Also applies to: 231-231, 244-244, 262-262, 286-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/admin/plugins.php` at line 220, Replace the hardcoded admin plugin
settings URLs in the data-settings-url attributes with a named-route resolver:
instead of building "/admin/plugins/{id}/settings" with url(), call the route
helper (e.g. route_path('admin.plugins.settings', ['id' => (int)$plugin['id']])
or RouteTranslator::route('admin.plugins.settings', ['id' =>
(int)$plugin['id']])) and keep the htmlspecialchars encoding around the result;
apply the same change to the other instances referenced (lines where
data-settings-url is set for the same plugin id).
🧹 Nitpick comments (5)
app/Support/Updater.php (1)

39-46: Consider a single source of truth for bundled plugins.

BUNDLED_PLUGINS is now maintained in multiple classes (App\Support\Updater and App\Support\PluginManager). Centralizing it (shared config/constant) will reduce future drift risk.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/Updater.php` around lines 39 - 46, The BUNDLED_PLUGINS constant
is duplicated between App\Support\Updater and App\Support\PluginManager; create
a single source of truth (either a config entry like config/bundled_plugins.php
or a dedicated class constant, e.g. App\Support\BundledPlugins::LIST) and
replace the local BUNDLED_PLUGINS definitions with references to that new source
from both Updater and PluginManager; update all usages to read from the shared
constant/method (e.g., BundledPlugins::LIST or config('bundled_plugins')) and
remove the old duplicated constant declarations, then run tests to ensure no
references remain to the removed constant.
app/Controllers/FrontendController.php (1)

22-34: Consider: Different caching strategies exist between controllers.

FrontendController uses static variable caching with SHOW COLUMNS, while LibriApiController (per relevant snippets at lines 664-702) uses instance-level caching with INFORMATION_SCHEMA.COLUMNS. Both approaches work correctly, but the inconsistency could lead to subtle differences:

  • Static cache persists across requests in long-running processes (e.g., PHP-FPM workers)
  • Instance cache is isolated per-request

This isn't a defect—both detect the column reliably—but unifying the approach could improve maintainability if schema detection is needed more broadly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/FrontendController.php` around lines 22 - 34, The code in
FrontendController::descriptionExpr uses a static cache
(self::$hasDescrizionePlain) with SHOW COLUMNS, which is inconsistent with
LibriApiController's instance-level cache using INFORMATION_SCHEMA.COLUMNS;
change FrontendController to use the same instance-level caching strategy as
LibriApiController: replace the static self::$hasDescrizionePlain with an
instance property (e.g., $this->hasDescrizionePlain), switch the column check to
the same INFORMATION_SCHEMA.COLUMNS query or call the shared helper used by
LibriApiController, and ensure descriptionExpr (or a refactored helper method)
uses the instance property so schema detection is per-request and consistent
across controllers.
app/Controllers/PluginController.php (1)

399-428: Avoid duplicated domain-normalization logic across controller and plugin.

PluginController::normalizeGoodLibDomain() mirrors storage/plugins/goodlib/GoodLibPlugin.php::normalizeDomain(). Consider extracting a shared normalizer to prevent future drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/PluginController.php` around lines 399 - 428, The
domain-normalization logic in PluginController::normalizeGoodLibDomain
duplicates storage/plugins/goodlib/GoodLibPlugin::normalizeDomain; extract the
shared logic into a single utility (e.g., a static method normalize in a new
DomainNormalizer or GoodLibDomainNormalizer class) and have both
PluginController::normalizeGoodLibDomain and GoodLibPlugin::normalizeDomain
delegate to that new shared method; ensure the new normalizer preserves current
behavior (trim, default handling, scheme prefix, parse_url host/port validation
and regex) and update call sites to use the shared utility to avoid duplication
and drift.
tests/helpers/branch-fix-harness.php (1)

486-522: Consider removing unused $db parameter for clarity.

The $db parameter is unused in scenarioLibraryThingExportTranslatorRoundtrip() and scenarioLibraryThingSecondaryAuthorRolePairing() (line 524) since these scenarios only test parsing/formatting logic via reflection without database operations.

However, keeping the parameter maintains a uniform signature across all scenarios, simplifying the dispatcher. This is acceptable for test harness code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/helpers/branch-fix-harness.php` around lines 486 - 522, The mysqli $db
parameter is unused in scenarioLibraryThingExportTranslatorRoundtrip and
scenarioLibraryThingSecondaryAuthorRolePairing; remove the unused parameter from
both function signatures (delete "mysqli $db"), update any callers/dispatcher to
stop passing the DB argument (or adjust the dispatcher to call these scenarios
without an argument), and leave the reflection/format/parse logic inside
scenarioLibraryThingExportTranslatorRoundtrip unchanged (keep references to
getLibraryThingMethods, formatLibraryThingRow, getLibraryThingHeaders, and the
parsing assertions intact).
app/Controllers/LibriApiController.php (1)

45-47: Reuse hasColumn() cache for libri.descrizione_plain detection.

This works functionally, but using hasColumn($db, 'descrizione_plain') here would avoid an extra schema lookup path for the same table.

♻️ Suggested simplification
-            $descExpr = $this->hasTableColumn($db, 'libri', 'descrizione_plain')
+            $descExpr = $this->hasColumn($db, 'descrizione_plain')
                 ? "COALESCE(NULLIF(l.descrizione_plain, ''), l.descrizione)"
                 : 'l.descrizione';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriApiController.php` around lines 45 - 47, The
libri.descrizione_plain existence check currently calls hasTableColumn($db,
'libri', 'descrizione_plain'), causing an extra schema lookup; replace that call
with the cached check hasColumn($db, 'descrizione_plain') where $descExpr is set
(the assignment using COALESCE/NULLIF) so the controller reuses the cached
column result; ensure you remove the hasTableColumn(...) invocation in this
expression and use hasColumn(...) consistently in LibriApiController for
detecting descrizione_plain.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/LibriController.php`:
- Line 11: The global exception handler currently logs every exception at error
level before checking for HttpNotFoundException, which turns normal 404s into
noisy error-level stack traces; update the handler in public/index.php to first
detect instances of Slim\Exception\HttpNotFoundException (or check $exception
instanceof HttpNotFoundException) and either handle them without calling
SecureLogger::error() or log them at a lower level, and replace any raw
error_log() calls with SecureLogger::error() for sensitive contexts; also apply
the same change to the other handler locations referenced (around lines noted
for LibriController.php and the other occurrences) so 404s are classified before
error-level logging.

In `@app/Controllers/ProfileController.php`:
- Around line 191-201: The runtime locale isn't reset when the user clears their
preference, so the success flash is still translated with the old locale; in the
else branch of the locale handling in ProfileController (where you currently
unset $_SESSION['locale']) call the I18n runtime reset (e.g.
\App\Support\I18n::setLocale(null) or the library's
resetLocale()/setDefaultLocale() method) before unsetting the session key so the
rest of the current request uses the default locale, and keep the
$_SESSION['success_message'] assignment after that change.

In `@tests/branch-fix-consistency.spec.js`:
- Around line 38-124: This test suite ("Branch Fix Consistency" using
runScenario) mutates shared DB state and must run serially; update your
Playwright configuration to set workers: 1 (add workers: 1 to the exported
config object in your Playwright config) or, alternatively, change the test npm
invocation to pass --workers=1 (add --workers=1 to the Playwright test script in
package.json) so all tests using runScenario execute with a single worker.

In `@tests/helpers/e2e-fixtures.js`:
- Around line 198-207: The teardown helpers deleteTempAdminUser and
deleteTempBook miss non-cascading dependents (prenotazioni, prestiti), causing
FK failures and leaked state; update both functions to first delete dependent
rows in prenotazioni and prestiti that reference the target (for
deleteTempAdminUser delete from prenotazioni and prestiti where utente_id =
Number(userId) before deleting from user_sessions and utenti; for deleteTempBook
delete from prenotazioni and prestiti where libro_id = Number(bookId), and also
delete prestiti/prenotazioni that reference copie if applicable, before deleting
from copie and libri), using the existing dbExec calls and keeping the
Number(...) casting to avoid SQL errors.

---

Outside diff comments:
In `@app/Controllers/AuthController.php`:
- Around line 51-56: The code calls $db->prepare(...) and immediately uses
$stmt->bind_param(...), but $db->prepare can return false causing a fatal error;
update the AuthController login flow to check the result of $db->prepare (the
$stmt variable) before calling $stmt->bind_param, mirroring other controllers:
if $stmt === false, log or handle the DB error (using existing logger or return
an appropriate response), and only proceed to bind_param, execute, get_result
and fetch_assoc when $stmt is a valid statement; ensure $stmt->close() is only
called when $stmt is a valid object.

In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1267-1284: The prepare()/bind_param()/execute() calls in
updateBook(), insertBook(), and upsertBook() (e.g. the $stmt prepared in the
UPDATE block and the conflict-clearing statements) lack error handling: check
that $db->prepare(...) returns a valid statement before calling bind_param(),
and verify that ->execute() returns true; on failure log or throw with
$db->error and/or $stmt->error and abort the write path (return false or
propagate exception) to avoid calling methods on false or proceeding with bad
affected_rows/insert_id; apply this pattern to every mysqli prepare/execute use
in the listed regions (conflict clearing, the UPDATE stmt around $stmt, and the
other insert/update statements).

---

Duplicate comments:
In `@app/Controllers/PluginController.php`:
- Around line 372-378: The success response is returned even if some writes via
$this->pluginManager->setSetting(...) fail; update the loops in PluginController
so you capture and check each setSetting() boolean return (both in the boolKeys
loop and normalizedDomains loop), accumulate an overall $allSaved flag (start
true, set false on any failure), and use that flag when building the response
instead of unconditionally returning success; also consider short-circuiting or
collecting failed keys for clearer error messages.

In `@app/Views/admin/plugins.php`:
- Line 220: Replace the hardcoded admin plugin settings URLs in the
data-settings-url attributes with a named-route resolver: instead of building
"/admin/plugins/{id}/settings" with url(), call the route helper (e.g.
route_path('admin.plugins.settings', ['id' => (int)$plugin['id']]) or
RouteTranslator::route('admin.plugins.settings', ['id' => (int)$plugin['id']]))
and keep the htmlspecialchars encoding around the result; apply the same change
to the other instances referenced (lines where data-settings-url is set for the
same plugin id).

In `@tests/goodlib-custom-domains.spec.js`:
- Around line 23-27: After calling getPluginIdByName('goodlib') in the
test.beforeAll block, immediately validate the returned pluginId and fail fast
if it's missing: check the pluginId variable (from pluginId =
getPluginIdByName('goodlib')) and throw a clear error or call the test
framework's fail/assert before calling snapshotPluginSettings(pluginId),
createTempAdminUser, or createTempBook so missing plugin resolution surfaces
with a precise diagnostic.

---

Nitpick comments:
In `@app/Controllers/FrontendController.php`:
- Around line 22-34: The code in FrontendController::descriptionExpr uses a
static cache (self::$hasDescrizionePlain) with SHOW COLUMNS, which is
inconsistent with LibriApiController's instance-level cache using
INFORMATION_SCHEMA.COLUMNS; change FrontendController to use the same
instance-level caching strategy as LibriApiController: replace the static
self::$hasDescrizionePlain with an instance property (e.g.,
$this->hasDescrizionePlain), switch the column check to the same
INFORMATION_SCHEMA.COLUMNS query or call the shared helper used by
LibriApiController, and ensure descriptionExpr (or a refactored helper method)
uses the instance property so schema detection is per-request and consistent
across controllers.

In `@app/Controllers/LibriApiController.php`:
- Around line 45-47: The libri.descrizione_plain existence check currently calls
hasTableColumn($db, 'libri', 'descrizione_plain'), causing an extra schema
lookup; replace that call with the cached check hasColumn($db,
'descrizione_plain') where $descExpr is set (the assignment using
COALESCE/NULLIF) so the controller reuses the cached column result; ensure you
remove the hasTableColumn(...) invocation in this expression and use
hasColumn(...) consistently in LibriApiController for detecting
descrizione_plain.

In `@app/Controllers/PluginController.php`:
- Around line 399-428: The domain-normalization logic in
PluginController::normalizeGoodLibDomain duplicates
storage/plugins/goodlib/GoodLibPlugin::normalizeDomain; extract the shared logic
into a single utility (e.g., a static method normalize in a new DomainNormalizer
or GoodLibDomainNormalizer class) and have both
PluginController::normalizeGoodLibDomain and GoodLibPlugin::normalizeDomain
delegate to that new shared method; ensure the new normalizer preserves current
behavior (trim, default handling, scheme prefix, parse_url host/port validation
and regex) and update call sites to use the shared utility to avoid duplication
and drift.

In `@app/Support/Updater.php`:
- Around line 39-46: The BUNDLED_PLUGINS constant is duplicated between
App\Support\Updater and App\Support\PluginManager; create a single source of
truth (either a config entry like config/bundled_plugins.php or a dedicated
class constant, e.g. App\Support\BundledPlugins::LIST) and replace the local
BUNDLED_PLUGINS definitions with references to that new source from both Updater
and PluginManager; update all usages to read from the shared constant/method
(e.g., BundledPlugins::LIST or config('bundled_plugins')) and remove the old
duplicated constant declarations, then run tests to ensure no references remain
to the removed constant.

In `@tests/helpers/branch-fix-harness.php`:
- Around line 486-522: The mysqli $db parameter is unused in
scenarioLibraryThingExportTranslatorRoundtrip and
scenarioLibraryThingSecondaryAuthorRolePairing; remove the unused parameter from
both function signatures (delete "mysqli $db"), update any callers/dispatcher to
stop passing the DB argument (or adjust the dispatcher to call these scenarios
without an argument), and leave the reflection/format/parse logic inside
scenarioLibraryThingExportTranslatorRoundtrip unchanged (keep references to
getLibraryThingMethods, formatLibraryThingRow, getLibraryThingHeaders, and the
parsing assertions intact).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 688070b2-d4c9-4191-90ea-b3f3846755e2

📥 Commits

Reviewing files that changed from the base of the PR and between 8a730f9 and d55bb22.

📒 Files selected for processing (30)
  • .gitignore
  • app/Controllers/AuthController.php
  • app/Controllers/CollaneController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/ProfileController.php
  • app/Controllers/PublicApiController.php
  • app/Middleware/RememberMeMiddleware.php
  • app/Support/PluginManager.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • app/Views/profile/index.php
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/goodlib/GoodLibPlugin.php
  • storage/plugins/goodlib/plugin.json
  • storage/plugins/goodlib/views/badges.php
  • storage/plugins/goodlib/views/settings.php
  • storage/plugins/goodlib/wrapper.php
  • tests/branch-fix-consistency.spec.js
  • tests/full-test.spec.js
  • tests/goodlib-custom-domains.spec.js
  • tests/helpers/branch-fix-harness.php
  • tests/helpers/e2e-fixtures.js

use Psr\Http\Message\ServerRequestInterface as Request;
use App\Support\SecureLogger;
use App\Support\LibraryThingInstaller;
use Slim\Exception\HttpNotFoundException;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid turning expected 404s into error-level stack traces.

These HttpNotFoundExceptions now send missing-book cases through the global handler in public/index.php, and that handler currently logs every exception before it checks whether the exception is a 404. That will make normal not-found requests show up as noisy error logs with stack traces. Please classify HttpNotFoundException first there, or skip error-level logging for 404s altogether.

As per coding guidelines, "Use SecureLogger::error() not error_log() for sensitive contexts".

Also applies to: 473-475, 1139-1140, 1174-1175, 2399-2400

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriController.php` at line 11, The global exception handler
currently logs every exception at error level before checking for
HttpNotFoundException, which turns normal 404s into noisy error-level stack
traces; update the handler in public/index.php to first detect instances of
Slim\Exception\HttpNotFoundException (or check $exception instanceof
HttpNotFoundException) and either handle them without calling
SecureLogger::error() or log them at a lower level, and replace any raw
error_log() calls with SecureLogger::error() for sensitive contexts; also apply
the same change to the other handler locations referenced (around lines noted
for LibriController.php and the other occurrences) so 404s are classified before
error-level logging.

Comment on lines +38 to +124
test.describe('Branch Fix Consistency', () => {
test('collana rename rolls back when target already exists', () => {
const result = runScenario('collana-rename-rollback');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.bookCollana).toBe(result.source);
expect(result.sourceExists).toBe(1);
expect(result.targetExists).toBe(1);
});

test('librarything import normalizes and persists translator, series, descrizione_plain and issn', () => {
const result = runScenario('librarything-parse-and-persist');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.parsed.autori).toBe('George Orwell');
expect(result.parsed.traduttore).toBe('Barbara Pym');
expect(result.parsed.descrizione_plain).toBe('Alpha & Omega');
expect(result.parsed.collana).toBe('Branch Saga');
expect(result.parsed.numero_serie).toBe('7');
expect(result.parsed.issn).toBe('1234-567X');
expect(result.created.issn).toBe('1234-567X');
expect(result.updated.collana).toBe('Branch Saga Updated');
expect(result.softDeleted.descrizione_plain).toBe('before soft delete');
});

test('librarything export keeps translator roundtrip-compatible', () => {
const result = runScenario('librarything-export-translator-roundtrip');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.row.secondaryAuthor).toBe('Barbara Pym');
expect(result.row.secondaryAuthorRoles).toBe('Translator');
expect(result.parsed.autori).toBe('George Orwell');
expect(result.parsed.traduttore).toBe('Barbara Pym');
});

test('librarything keeps secondary author roles paired during roundtrip', () => {
const result = runScenario('librarything-secondary-author-role-pairing');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.row.secondaryAuthor).toBe('Jane Austen; Barbara Pym');
expect(result.row.secondaryAuthorRoles).toBe('; Translator');
expect(result.parsed.autori).toBe('George Orwell|Jane Austen');
expect(result.parsed.traduttore).toBe('Barbara Pym');
});

test('admin and frontend search use descrizione_plain', () => {
const result = runScenario('descrizione-plain-search');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.apiTitles).toContain(result.title);
expect(result.catalogFound).toBe(true);
});

test('public api and frontend detail expose issn with schema identifier', () => {
const result = runScenario('public-api-and-frontend-issn');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.apiIssn).toBe('1234-567X');
expect(result.detailHasIssn).toBe(true);
expect(result.detailHasSchemaIdentifier).toBe(true);
});

test('login applies user locale immediately', () => {
const result = runScenario('auth-login-loads-locale');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.location).toBe('/admin/dashboard');
expect(result.sessionLocale).toBe('en_US');
expect(result.currentLocale).toBe('en_US');
});

test('profile locale update persists and updates session locale', () => {
const result = runScenario('profile-locale-update');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.dbLocale).toBe('it_IT');
expect(result.sessionLocale).toBe('it_IT');
expect(result.currentLocale).toBe('it_IT');
});

test('profile update without locale keeps the stored locale intact', () => {
const result = runScenario('profile-locale-omitted-keeps-value');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.dbLocale).toBe('en_US');
expect(result.sessionLocale).toBe('en_US');
expect(result.currentLocale).toBe('en_US');
});

test('remember-me middleware restores user locale on auto-login', () => {
const result = runScenario('remember-me-loads-locale');
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.sessionLocale).toBe('de_DE');
expect(result.currentLocale).toBe('de_DE');
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
# Expect to find a global worker constraint or an invocation that passes --workers=1.
fd 'playwright\.config'
rg -n -C2 'workers\s*:\s*1' .
rg -n -C2 -- '--workers=1' .

Repository: fabiodalez-dev/Pinakes

Length of output: 92


🏁 Script executed:

cat -n tests/playwright.config.js

Repository: fabiodalez-dev/Pinakes

Length of output: 463


🏁 Script executed:

cat -n package.json | head -100

Repository: fabiodalez-dev/Pinakes

Length of output: 1409


Configure Playwright to run tests serially with --workers=1.

The test suite mutates shared DB state through tests/helpers/branch-fix-harness.php. Without a worker constraint, Playwright will spawn multiple parallel workers (defaulting to CPU core count), causing race conditions when this suite runs alongside other E2E tests against the same database.

Add workers: 1 to tests/playwright.config.js or pass --workers=1 to the test invocation in package.json.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/branch-fix-consistency.spec.js` around lines 38 - 124, This test suite
("Branch Fix Consistency" using runScenario) mutates shared DB state and must
run serially; update your Playwright configuration to set workers: 1 (add
workers: 1 to the exported config object in your Playwright config) or,
alternatively, change the test npm invocation to pass --workers=1 (add
--workers=1 to the Playwright test script in package.json) so all tests using
runScenario execute with a single worker.

- AuthController: prepare() guard before bind_param in login
- ProfileController: reset runtime locale to installation default on clear
- PluginController: check setSetting return, report failure
- plugins.php: Z39 read server_enabled (not enable_server)
- e2e-fixtures: delete prenotazioni/prestiti in teardown (FK safety)
- goodlib-custom-domains: explicit pluginId guard
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant