Skip to content

feat(archives): phases 1b–5 — full plugin (#103)#105

Merged
fabiodalez-dev merged 49 commits intomainfrom
feature/archives-phase-1b
Apr 22, 2026
Merged

feat(archives): phases 1b–5 — full plugin (#103)#105
fabiodalez-dev merged 49 commits intomainfrom
feature/archives-phase-1b

Conversation

@fabiodalez-dev
Copy link
Copy Markdown
Owner

@fabiodalez-dev fabiodalez-dev commented Apr 20, 2026

Stacked on PR #104 (phase 1a). Merge order: #104 → this.

Covers phases 1b + 1c + 1d of the archives plugin roadmap, accumulated on the same branch to avoid deep-stacking (CodeRabbit disables auto-review on non-default base branches — see comment thread).

Endpoints (all behind AdminAuthMiddleware; POST also CsrfMiddleware)

Method Path Phase
GET /admin/archives 1b
GET /admin/archives/new 1b
POST /admin/archives/new 1b
GET /admin/archives/{id} 1c
GET /admin/archives/{id}/edit 1c
POST /admin/archives/{id}/edit 1c
POST /admin/archives/{id}/delete 1c

All routes are registered inside the plugin via app.routes.register (DeweyEditorPlugin pattern).

User-facing polish (phase 1d)

  • Sidebar entry — new \App\Support\Hooks::do('admin.menu.render') call in app/Views/layout.php between "Collocazione" and "Utenti"; plugin hooks in and echoes <a href="/admin/archives">…</a> matching the existing Tailwind pattern. Deactivating the plugin removes the hook row → entry disappears.
  • i18n — 13 new keys in locale/{it_IT,en_US,de_DE}.json; DE uses domain-accurate terminology ("Signatur", "Bestand", "Einzelstück"). Form field labels that cite ISAD(G) sections are deliberately kept hardcoded IT for phase 2 when an archives-domain translator can review them.
  • Playwright E2Etests/archives-crud.spec.js, 9 serial tests covering activation, create fonds → appears in list, edit → update, create child series with parent_id, self-parent rejection, soft-delete, sidebar link presence.

Plugin glue

  • setPluginId(int) / $pluginId — lets the plugin CRUD its rows in plugin_hooks.
  • onActivate() → DDL + two hook rows (app.routes.register, admin.menu.render); idempotent.
  • onDeactivate() → removes all plugin hook rows; data is preserved.
  • registerRoutes($app) → attaches all seven routes.
  • renderAdminMenuEntry() → echoes sidebar <a> conforming to core pattern.

Handlers + validation

  • validateArchivalUnit($values, ?int $excludeId = null):
    • reference_code required + ≤64 chars
    • levelLEVELS
    • constructed_title required + ≤500 chars
    • date_start / date_end within SMALLINT range, end ≥ start
    • parent_id (if set) must reference a non-deleted row
    • updateAction also guards parent_id === id
  • findById() centralises deleted_at IS NULL so every detail-path gets soft-delete for free.
  • destroyAction uses UPDATE … SET deleted_at = NOW() — aligned with the libri table convention. No cascade; deleting a fonds with active series below needs explicit UI confirm (phase 2).

Out of scope

  • Authority records CRUD → phase 2 (separate PR). The authority_records + archival_unit_authority tables already exist from phase 1a; UI lands later.
  • Pagination + filters on the index → phase 2.
  • Full form-label i18n → phase 2, with archivist review.
  • MARCXML import/export → phase 4.
  • Unified cross-entity search → phase 3.

Note on CodeRabbit

Auto-review is disabled by CodeRabbit when the PR's base isn't the default branch. Earlier @coderabbitai full review run (triggered manually at 16:42Z on phase 1b+1c commit) came back SUCCESS with zero findings. Triggering again for the phase 1d additions.

Test plan

  • PHPStan level 5 clean on all new PHP (pre-commit hook green on every commit)
  • php -l clean
  • node --check tests/archives-crud.spec.js clean
  • Existing unit test (tests/archives-plugin.unit.php) — 28/28 passes
  • Run the new E2E spec: /tmp/run-e2e.sh tests/archives-crud.spec.js --workers=1
  • Activate plugin → sidebar gains "Archivi" entry + schema appears
  • Deactivate plugin → sidebar entry disappears + routes 404 but data is preserved
  • Switch UI language to EN/DE → sidebar label + index strings translate correctly

@coderabbitai full review

Summary by CodeRabbit

  • Nuove Funzionalità

    • Plugin Archives v1.0.0: interfacce admin e pubbliche per unità archivistiche e authority, ricerca unificata, MARCXML import/export, SRU 1.2 read-only endpoint, supporto materiale fotografico/audio.
  • Documentazione

    • README aggiornato con SRU, feature e istruzioni plugin.
  • Localizzazione

    • Ampie traduzioni aggiunte/aggiornate (it/en/de) incl. testi UI per archivi e import/export.
  • Test

    • Numerose suite E2E e test unitari per CRUD, linking, import, upload asset e regressione.
  • Chore

    • Migrazione DB per plugin, aggiornate regole .gitignore/.gitattributes e aggiornato menu/frontend per mostrare Archivio.

Initial scaffolding for Hans Uwe Petersen's request to extend Pinakes
beyond the flat bibliographic `libri` model to handle archival material
(fonds → series → files → items) and authority records for persons /
corporate bodies / families, per the ICA international standards
ISAD(G) and ISAAR(CPF).

Status: SKELETON — schema design + metadata only. No CRUD UI, no
MARCXML I/O, no hook wiring, no unified search integration yet. The
DDL is exposed as string constants so reviewers can audit schema
intent before anything touches a production DB.

Files:
  - plugin.json — metadata, marked optional:true + status:skeleton
  - wrapper.php — bootstrap, returns ArchivesPlugin instance
  - ArchivesPlugin.php — level constants, authority types, plannedHooks
    array, three DDL methods (archival_units, authority_records,
    archival_unit_authority link table)
  - README.md — architecture overview + ISAD(G) → column crosswalk
    (mirrors ABA archive format from Copenhagen's labour-movement
    archive, per Hans's attached PDFs)

Updates:
  - BundledPlugins::LIST grows from 9 to 10 (inserted alphabetically)
  - .gitignore whitelist for the new plugin directory

The data model mirrors the ABA mapping of ISAD(G)/ISAAR(CPF) onto an
extended danMARC2, so future MARCXML import/export is a mechanical
translation. Soft-delete (`deleted_at`) is aligned with Pinakes'
existing `libri` convention. FULLTEXT indexes on both tables pave the
way for Phase 3 unified search across libri + archival_units +
authority_records.

Roadmap (see README.md):
  Phase 1 — MVP archives CRUD       (2-3 weeks)
  Phase 2 — Authority records CRUD  (1-2 weeks)
  Phase 3 — Unified search          (1 week)
  Phase 4 — MARCXML I/O             (2 weeks)
  Phase 5 — Photographic items      (1-2 weeks)

No DDL is executed by this commit — activating the plugin calls
ensureSchema() which is still a stub. This is deliberate: the plugin
is bundled but optional:true and must be explicitly enabled by an
admin after a design-review pass on the schema.
)

Phase 1a of the archives plugin (per the plan in the PR description).
The plugin now:

  - Conforms to PluginManager's instantiation contract
    (new $className($db, $hookManager) — PluginManager.php:878).
  - Exposes a global `ArchivesPlugin` proxy in wrapper.php that forwards
    lifecycle hooks (onActivate/onDeactivate/onInstall/onUninstall) to
    the namespaced implementation. Mirrors the DiscogsPlugin pattern so
    both the admin UI install path and direct namespaced loading work.
  - Implements ensureSchema() against the real mysqli connection,
    wrapping each CREATE TABLE in try/catch and logging failures via
    SecureLogger instead of aborting activation. Returns
    ['created' => [...], 'failed' => [...]] so callers can tell the
    admin which tables are in place.
  - Adds getHookManager() as a testable accessor for the injected
    HookManager (phase 1b will iterate plannedHooks() through it).
  - Keeps onDeactivate as an explicit no-op — archival data survives
    deactivation; DROP is reserved for a future explicit "Uninstall
    archives" admin action with confirmation.

Unit test: tests/archives-plugin.unit.php (28 assertions, pure PHP,
no DB). Covers the DDL string shape (what will hit production when
activated), plannedHooks source inclusion, and the DI contract via
Reflection. End-to-end activation will land in the plugin-integrity
E2E regression in phase 1b.

Plan:
  phase 1a (THIS PR)  — schema backbone + DI + unit tests
  phase 1b (follow-up) — ArchivesController + routes + views + sidebar
  phase 1c (follow-up) — i18n (IT/EN/DE) + Playwright E2E

Version bumped to 0.1.0, status: phase-1a. Plugin stays optional:true
so admins must explicitly enable it from /admin/plugins before any
DDL hits their database.
28 regression assertions on the DDL emitted by ArchivesPlugin (the
strings that will hit the production database when an admin activates
the plugin) + reflection-based checks on the DI contract.

The .gitignore whitelist mirrors the one added on feature/discogs-catno-support
(PR #102) so future plugin unit tests can live alongside the Playwright
*.spec.js files without requiring case-by-case exceptions.

Run:
  php tests/archives-plugin.unit.php
  → Passed: 28   Failed: 0
Extends the Archives plugin (PR #104 phase 1a) with its first two
endpoints, registered entirely from within the plugin via the core
`app.routes.register` hook (pattern borrowed from DeweyEditorPlugin):

  GET  /admin/archives       — tree-flavoured list of archival_units
  GET  /admin/archives/new   — blank create form
  POST /admin/archives/new   — validate + INSERT + 303 redirect

Plugin changes
  - `setPluginId(int)` + `$pluginId` plumbing so the plugin can
    register/unregister its hooks in the `plugin_hooks` table.
  - `onActivate()` now also inserts the `app.routes.register` hook.
  - `onDeactivate()` removes plugin hooks — routes stop responding
    immediately on deactivation while data is preserved.
  - `registerRoutes($app)` attaches the three routes with
    AdminAuthMiddleware (all) + CsrfMiddleware (POST only).
  - `indexAction` / `newAction` / `storeAction` handlers —
    Slim PSR-7 signatures.
  - `validateArchivalUnit()` enforces ISAD(G) 3.1 minimums:
    reference_code required + ≤64 chars, level must be one of
    LEVELS, constructed_title required + ≤500 chars, date_start/end
    within SMALLINT range, end ≥ start, parent_id (if set) must
    reference a non-deleted row.
  - `renderView()` helper wraps plugin views with the core
    `app/Views/layout.php` so sidebar + header stay consistent.

Views (new)
  - `views/index.php` — list with level-coloured badges and visual
    tree indent via parent_id recursion. Warns with a CTA when the
    table is empty. Page limit 500 (pagination → phase 1c).
  - `views/form.php` — all 10 ISAD(G) 3.1 core fields, inline
    labels cite the ISAD section + the matching ABA MARC field so
    archivists can cross-reference. Per-field error rendering,
    sticky values on validation failure, CSRF token wired via
    existing Csrf::ensureToken() helper.

Out of scope for this PR (phase 1c follow-up)
  - show / edit / update / destroy actions
  - sidebar menu entry in app/Views/layout.php
  - i18n stringtable (IT/EN/DE)
  - Playwright E2E spec
  - pagination + filters on the index

Version bumped to 0.2.0 (status: phase-1b). Plugin remains
optional:true so activation is still explicit.

Pre-commit: PHPStan level 5 clean on plugin + views; `php -l`
clean; existing unit test (28 assertions) still passes.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

Warning

Rate limit exceeded

@fabiodalez-dev has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 24 minutes and 33 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 24 minutes and 33 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d94882f6-21a8-4baa-9baa-1a57f5073e30

📥 Commits

Reviewing files that changed from the base of the PR and between 21cb67d and 341f098.

⛔ Files ignored due to path filters (14)
  • public/uploads/archives/e2e/audio-index.pdf is excluded by !**/*.pdf
  • public/uploads/archives/e2e/drawing-cover.jpg is excluded by !**/*.jpg
  • public/uploads/archives/e2e/film-notes.pdf is excluded by !**/*.pdf
  • public/uploads/archives/e2e/floppy-dump.zip is excluded by !**/*.zip
  • public/uploads/archives/e2e/fondo-inventory.pdf is excluded by !**/*.pdf
  • public/uploads/archives/e2e/letter.pdf is excluded by !**/*.pdf
  • public/uploads/archives/e2e/maps.pdf is excluded by !**/*.pdf
  • public/uploads/archives/e2e/object-cover.jpg is excluded by !**/*.jpg
  • public/uploads/archives/e2e/photo-cover.jpg is excluded by !**/*.jpg
  • public/uploads/archives/e2e/picture-cover.jpg is excluded by !**/*.jpg
  • public/uploads/archives/e2e/portrait.jpg is excluded by !**/*.jpg
  • public/uploads/archives/e2e/postcard-cover.jpg is excluded by !**/*.jpg
  • public/uploads/archives/e2e/poster-cover.jpg is excluded by !**/*.jpg
  • public/uploads/archives/e2e/poster-register.pdf is excluded by !**/*.pdf
📒 Files selected for processing (40)
  • .gitattributes
  • .gitignore
  • README.md
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/ScrapeController.php
  • app/Middleware/RateLimitMiddleware.php
  • app/Support/PluginManager.php
  • app/Views/frontend/layout.php
  • app/Views/layout.php
  • installer/database/migrations/migrate_0.5.9.sql
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • locale/routes_de_DE.json
  • locale/routes_en_US.json
  • locale/routes_it_IT.json
  • storage/plugins/archives/ArchivesPlugin.php
  • storage/plugins/archives/assets/css/archives-public.css
  • storage/plugins/archives/plugin.json
  • storage/plugins/archives/views/authorities/show.php
  • storage/plugins/archives/views/form.php
  • storage/plugins/archives/views/public/index.php
  • storage/plugins/archives/views/public/show.php
  • storage/plugins/archives/views/search.php
  • storage/plugins/archives/views/show.php
  • tests/admin-features.spec.js
  • tests/archives-50-elements-crud.spec.js
  • tests/archives-authorities.spec.js
  • tests/archives-crud.spec.js
  • tests/archives-feature-documents.spec.js
  • tests/archives-feature-seeds.unit.php
  • tests/archives-full.spec.js
  • tests/archives-seed-50.spec.js
  • tests/archives-upload-assets.spec.js
  • tests/extra-features.spec.js
  • tests/genre-bugs.spec.js
  • tests/loan-reservation.spec.js
  • tests/security-hardening.spec.js
  • tests/seeds/archives-feature-20.sql
  • tests/social-sharing.spec.js
📝 Walkthrough

Walkthrough

Aggiunta completa del plugin "archives": schema DB e migration; nuove view/admin (index, show, form, import, search, authorities); XSD MARC21; manifest/README aggiornati con SRU 1.2; aggiornato layout per hook admin menu; .gitignore aggiornato; ampie localizzazioni e 3 suite E2E Playwright.

Changes

Cohort / File(s) Summary
Configurazione .gitignore
\.gitignore
Negazioni aggiunte per mantenere tracciati storage/plugins/archives/views/authorities/ e storage/plugins/archives/schemas/*.xsd.
Layout / Hook
app/Views/layout.php
Inserito \App\Support\Hooks::do('admin.menu.render') nel menu laterale admin.
Migration / Schema DB
installer/database/migrations/migrate_0.5.9.sql
Nuova migration che crea tabelle archival_units, authority_records, join tables e indici (inclusi FULLTEXT) + upsert su plugins e meta schema_version.
Plugin manifest & docs
storage/plugins/archives/plugin.json, storage/plugins/archives/README.md
Plugin versione 0.1.01.0.0, status phase-1aphase-6; README documenta SRU /api/archives/sru e hook registrati.
XSD Schema
storage/plugins/archives/schemas/MARC21slim.xsd
Aggiunto XSD MARC21 slim con tipi/elementi per record, collection, controlfield, datafield e vincoli di pattern.
Views — Archivi (admin)
storage/plugins/archives/views/index.php, .../form.php, .../show.php, .../search.php, .../import.php
Nuove view per lista gerarchica, create/edit ISAD(G), dettaglio con attach/detach authority e typeahead, ricerca unificata e import MARCXML (dry-run/strict XSD, report risultati).
Views — Autorità
storage/plugins/archives/views/authorities/index.php, .../form.php, .../show.php
Nuove view ISAAR(CPF): index, create/edit form, dettaglio con collegamenti ad archivi, gestione link/autori e confirm JS.
Tests E2E
tests/archives-crud.spec.js, tests/archives-authorities.spec.js, tests/archives-full.spec.js
Tre suite Playwright seriali con helper DB (mysql CLI): attivazione plugin, CRUD archivi, autorità, linking, import/export MARCXML, SRU e XSD validation; cleanup TAG-based.
Controller / Hook payload
app/Controllers/ScrapeController.php
byIsbn() ora passa il raw input $isbn come terzo argomento al hook scrape.isbn.validate.
Plugin lifecycle instantiation
app/Support/PluginManager.php
activate/deactivate/uninstall ora instanziano plugin e chiamano i relativi metodi di istanza (onActivate/onDeactivate/onUninstall) invece di usare runPluginMethod.
Localizzazioni
locale/de_DE.json, locale/en_US.json, locale/it_IT.json
Aggiunte molte stringhe per archivistica/autorità/MARCXML/SRU; piccole correzioni testuali.

Sequence Diagram(s)

sequenceDiagram
    participant Admin as Admin UI
    participant App as App Server
    participant DB as Database
    participant Hooks as Plugin Hooks
    participant XSD as XSD Validator

    Admin->>App: POST /admin/archives/new (MARC/ISAD form)
    App->>DB: INSERT archival_units
    DB-->>App: OK (id)
    App-->>Admin: Redirect /admin/archives/{id}

    Admin->>App: GET /admin/archives/{id}
    App->>DB: SELECT archival_unit + linked authority rows
    App->>Hooks: do('admin.menu.render')
    DB-->>App: data
    App-->>Admin: render show view (typeahead, attach forms)

    Admin->>App: POST /admin/archives/{id}/authority (attach)
    App->>DB: INSERT archival_unit_authority (role)
    DB-->>App: OK
    App-->>Admin: Redirect (detail shows link)

    Admin->>App: POST /admin/archives/import (MARCXML)
    App->>XSD: validate against MARC21slim.xsd (if strict_xsd)
    XSD-->>App: validation result
    App->>DB: (dry-run? no INSERT : INSERT/UPDATE archival_units & authority_records)
    DB-->>App: result counts
    App-->>Admin: render import result (parsed/inserted/updated/skipped)

    Client->>App: GET /api/archives/sru?operation=searchRetrieve&query=...
    App->>DB: MATCH...AGAINST / constrained CQL -> records
    DB-->>App: matched records
    App-->>Client: SRU XML response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minuti

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Il titolo riflette accuratamente il contenuto principale: implementazione delle fasi 1b–5 del plugin archives (#103).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 feature/archives-phase-1b

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.

Closes the CRUD loop on archival_units. Four new endpoints:

  GET  /admin/archives/{id}         — read-only detail view
  GET  /admin/archives/{id}/edit    — pre-populated edit form
  POST /admin/archives/{id}/edit    — validate + UPDATE + 303
  POST /admin/archives/{id}/delete  — soft-delete (deleted_at = NOW())

Plugin changes
  - showAction / editAction / updateAction / destroyAction handlers.
  - `findById(int): ?array` — single-row fetch with `deleted_at IS NULL`
    guard. Centralised so every detail-path gets soft-delete for free.
  - `renderNotFound()` — 404 helper used by show/edit/update when
    the id is missing or soft-deleted.
  - `validateArchivalUnit()` gained an optional `?int $excludeId`
    parameter. Currently unused at runtime — reference_code uniqueness
    is enforced by the DB's `UNIQUE KEY uq_reference` + errno 1062
    surfacing. The param signature lands now so phase 2 can add a
    pre-check without breaking callers.
  - updateAction also guards against self-parenting (parent_id === id).
    Full cycle detection across descendants will arrive with the CTE
    helpers in phase 2.
  - destroyAction uses plain UPDATE to set `deleted_at = NOW()` — aligned
    with the `libri` table soft-delete convention. Children are NOT
    cascade-soft-deleted; deleting a fonds with active series below is
    a destructive op that needs explicit UI confirmation (roadmap).

Views
  - `form.php` now supports `$mode ∈ {create, edit}`. Action URL,
    page title, submit label, and breadcrumb switch on the mode.
    The create path still dispatches with `$mode` defaulted via the
    null-coalesce fallback, so phase 1b behaviour is preserved.
  - `show.php` (new) — compact <dl> card with level badge, reference
    code, parent link (when set), date range, extent, scope/content,
    language, created/updated timestamps. Edit + Delete buttons in
    the header. Delete is wrapped in a confirm() to discourage slips.
  - `index.php` — reference_code and title cells are now links to the
    detail page; a new rightmost column exposes "modifica". The row
    structure otherwise unchanged.

Out of scope (→ phase 1d follow-up)
  - Sidebar menu entry in app/Views/layout.php (currently reachable
    only by typing /admin/archives directly).
  - i18n stringtable (IT/EN/DE) — view strings are still hardcoded IT.
  - Playwright E2E spec covering list → create → edit → delete.
  - Pagination + filters on the index.

Version bumped to 0.3.0 (status: phase-1c). PHPStan level 5 clean;
unit test (28 assertions) still passes.
@fabiodalez-dev fabiodalez-dev changed the title feat(archives): phase 1b — index + create CRUD (#103) feat(archives): phase 1b + 1c — full CRUD on archival_units (#103) Apr 20, 2026
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

✅ Actions performed

Full review triggered.

User-facing polish for the archives plugin. The plugin now integrates
with the admin shell via a new plugin hook, ships translations in all
three supported locales, and has end-to-end coverage for the full
CRUD loop.

Sidebar hook
  - `app/Views/layout.php` gains a single `\App\Support\Hooks::do('admin.menu.render')`
    call between the core "Collocazione" and "Utenti" entries. Any optional
    plugin can now hook into this action and echo HTML conforming to the
    existing admin-sidebar Tailwind pattern without touching core.
  - `ArchivesPlugin::renderAdminMenuEntry()` registers on that hook during
    onActivate() and emits an <a> matching the pattern already used for
    Collane/Generi/Collocazione — FontAwesome icon `fa-archive`, title
    "Archivi", subtitle "Materiale archivistico (ISAD(G))".
  - Deactivation removes the hook row → the entry disappears immediately.

i18n
  - 13 new string keys added to `locale/{it_IT,en_US,de_DE}.json`:
    menu labels ("Archivi", "Reference", "Livello", "Titolo", "Date",
    "Estensione", "Azioni"), subtitle, empty-state copy, CTA button,
    and the recommended-hierarchy hint. English and German translations
    are archival-domain-appropriate (DE uses "Signatur" for reference,
    "Bestand" for fonds, "Einzelstück" for item).
  - `views/index.php` user-visible strings converted from hardcoded IT
    to `__()`. `views/form.php` and `views/show.php` form labels still
    ship hardcoded IT — they cite the ISAD(G) section + the matching
    ABA MARC field and translating them without a reviewing archivist
    would risk drifting from the canonical terminology. Phase 2
    follow-up when an archives-domain translator is in the loop.

Playwright E2E
  - `tests/archives-crud.spec.js` — 9 serial tests covering:
    1. Plugin activation → schema present
    2. Index renders (empty-state OR table)
    3. Create fonds via form → DB row matches
    4. Fonds appears in list
    5. Edit fonds → title + extent updated
    6. Create child series with parent_id → FK persists
    7. Self-parent edit attempt → error shown, no save
    8. Soft-delete series → deleted_at set, row gone from list
    9. Sidebar link targets /admin/archives
  - beforeAll pre-cleans with `DELETE FROM archival_units WHERE reference_code LIKE 'E2E_ARCHIVES_%'`
    so a crashed previous run doesn't block the UNIQUE(institution_code, reference_code).
  - afterAll hard-deletes the tagged rows — UNIQUE ignores deleted_at,
    so soft-delete would leave orphans across runs.

Version bumped to 0.4.0 (status: phase-1d). PHPStan level 5 clean,
`node --check` clean on the spec, existing unit test (28 assertions)
still passes.
@fabiodalez-dev fabiodalez-dev changed the title feat(archives): phase 1b + 1c — full CRUD on archival_units (#103) feat(archives): phase 1b + 1c + 1d — full CRUD + sidebar + i18n + E2E (#103) Apr 20, 2026
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

✅ Actions performed

Full review triggered.

…Rabbit #104)

Two CodeRabbit findings on PR #104 (phase 1a):

1. Major — onActivate() discarded ensureSchema()'s return value, so
   PluginManager could mark the plugin active with a partial or missing
   schema. onActivate() now captures the {created, failed} array and
   throws RuntimeException when failed is non-empty. SecureLogger has
   already captured the per-table mysqli error inside ensureSchema(),
   so the exception message only needs to list the tables that couldn't
   be created — pointing the admin to app.log for the full reason.

2. Minor — README.md line 15 still said "NOT yet executed; review
   before enabling", a leftover from the initial skeleton-only draft
   that contradicts line 3 ("Activation creates the three tables").
   Rewritten to make it clear the DDL runs when onActivate() fires
   and keeping the "review before enabling" guidance.

Verified:
  - PHPStan level 5 clean on the plugin.
  - Unit test (28 assertions) still passes.
# Conflicts:
#	storage/plugins/archives/ArchivesPlugin.php
Extends the archives plugin with full ISAAR(CPF) authority-records
CRUD and an M:N link UI on the archival-unit detail page. Nine new
endpoints, three new views, one link table exercised end-to-end.

Plugin
  - AUTHORITY_ROLES constant — matches the link-table enum
    (creator / subject / recipient / custodian / associated),
    mirroring MARC 100/600/700/710 semantics in the ABA format.
  - Nine registerRoutes() entries, all nested under /admin/archives/
    so the plugin owns a single URL prefix.
  - Nine handler methods: authorityIndexAction, authorityNewAction,
    authorityStoreAction, authorityShowAction, authorityEditAction,
    authorityUpdateAction, authorityDestroyAction, attachAuthorityAction,
    detachAuthorityAction.
  - validateAuthority() — ISAAR 5.1.1 (type ∈ AUTHORITY_TYPES) +
    5.1.2 (authorised_form required, ≤500 chars) + 5.2.1 length cap.
    Additional ISAAR elements (5.2.3 Places, 5.2.4 Legal status,
    5.2.6 Mandates, 5.2.7 Internal structure, 5.2.8 General context)
    land in a follow-up PR alongside an archives-domain translator
    review — the tables already have the columns, only the form is
    phase-2-minimal.
  - attachAuthorityAction uses INSERT IGNORE against the composite PK
    (archival_unit_id, authority_id, role) so re-posting the same
    link is idempotent. detachAuthorityAction removes every role-link
    between a given (unit, authority) pair.
  - Three small helpers — findAuthorityById (soft-delete-aware fetch),
    fetchAuthoritiesForArchivalUnit + fetchArchivalUnitsForAuthority
    (M:N joins), listAllAuthorities (populates the attach <select>,
    capped at 500 — type-ahead search is a phase-3 task).
  - showAction of archival_units now also passes linked_authorities,
    available_authorities, and authority_roles to the view so the
    unit detail can render the authority section + attach form.

Views (new)
  - views/authorities/index.php — list with type-coloured badges
    (person/corporate/family), mirror of the archival_units index.
  - views/authorities/form.php — 5 core ISAAR fields + dual-mode
    (create + edit). Labels cite ISAAR section numbers so archivists
    can cross-reference.
  - views/authorities/show.php — compact <dl> + list of linked
    archival_units with their level badge + role.
  - views/show.php (archival_unit) — new "Authority records collegati"
    section at the bottom: linked list with per-row unlink form, plus
    an attach form with (authority, role) select pair. When no
    authorities exist, a CTA links to the create form.

i18n
  - 43 new keys in `locale/{it_IT,en_US,de_DE}.json` covering the new
    view strings, form labels, ISAAR field titles, and attach/detach
    UX copy. DE uses archival-domain terminology: "Normdatensätze",
    "Ansetzungsform", "Lebensdaten", "Körperschaft", etc.

Playwright E2E
  - `tests/archives-authorities.spec.js`, 9 serial tests covering:
    1. Create fonds for linking
    2. Authority index renders
    3. Create person authority via form
    4. Authority appears in list
    5. Attach authority to fonds (role=creator)
    6. Link renders on both detail pages
    7. Edit authority → authorised_form persists
    8. Detach link → row gone from archival_unit_authority
    9. Soft-delete authority → deleted_at set, disappears from list
  - Shared tag prefix (E2E_ARCHAUTH_*) so parallel runs never collide,
    pre/post cleanup drops link rows before authority rows before
    archival_unit rows (FK order).

Out of scope (→ phase 2b / 3)
  - Linkage to the legacy `libri.autori` column
  - Biographical ISAAR fields beyond 5.1.1, 5.1.2, 5.2.1, 5.2.2, 5.2.5
  - Type-ahead search on the attach <select> (currently LIMIT 500)
  - MARCXML I/O (phase 4)
  - Unified cross-entity search (phase 3)

Version bumped to 0.5.0 (status: phase-2). PHPStan level 5 clean,
node --check clean on the spec, existing unit test (28 assertions)
still passes.
@fabiodalez-dev fabiodalez-dev changed the title feat(archives): phase 1b + 1c + 1d — full CRUD + sidebar + i18n + E2E (#103) feat(archives): phases 1b/1c/1d/2 — full archives + authorities CRUD (#103) Apr 20, 2026
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Phase 2 (6b5e20b) added to this PR: authority records CRUD (7 endpoints) + M:N link UI (attach/detach on archival_unit detail). +9 tests in tests/archives-authorities.spec.js, +43 i18n keys. See commit for the full breakdown.

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

@fabiodalez-dev Confermato — avvio una full review su Phase 2 (6b5e20b): CRUD authority records (7 endpoint), UI M:N attach/detach, 9 nuovi test E2E e 43 chiavi i18n.

✅ Actions performed

Full review triggered.

…nified search (#103)

Phase 2b
  New 4th table autori_authority_link (composite PK on
  autori_id + authority_id, FK on authority_id with ON DELETE
  CASCADE, no FK on autori_id because the core still hard-deletes
  and a hard FK would block admin workflows). Added to
  ensureSchema()'s DDL step list so re-activation provisions it.

  Authority form grows from 5 to 13 fields — the previously-stored-
  but-unedited ISAAR columns become editable via a <details>
  collapsible section: parallel_forms (5.1.3), other_forms (5.1.5),
  identifiers (5.1.6), places (5.2.3), legal_status (5.2.4), mandates
  (5.2.6), internal_structure (5.2.7), general_context (5.2.8).
  Required fields (type + authorised_form) still live above the
  fold to keep the baseline create flow quick.

  Refactor: authorityStoreAction + authorityUpdateAction share two
  new helpers — extractAuthorityPayload() (trims + gathers the 13
  form keys) and nullableAuthorityParams() (empty→null for mysqli
  so COALESCE keeps working downstream). No duplicated bind_param.

  Two new endpoints:
    POST /admin/archives/authorities/{id}/autori/link
    POST /admin/archives/authorities/{id}/autori/{autori_id}/unlink
  INSERT IGNORE makes relink idempotent. Reconciliation surfaces
  on authorities/show.php as "Autori di libreria collegati" with
  name, lifespan, book count, and an attach <select> populated with
  the first 100 autori rows. A type-ahead for longer libraries is
  intentionally deferred — see phase 3 below for the JSON API.

Phase 3 — unified cross-entity search
  GET /admin/archives/search?q=... runs three MATCH…AGAINST
  queries in a single request — archival_units (FULLTEXT on
  title + scope + history), authority_records (FULLTEXT on name +
  parallel_forms + history + functions), autori (FULLTEXT on nome,
  scoped to rows reconciled via autori_authority_link so the view
  doesn't flood with every library author). Each source is capped
  at 50 (25 for autori since they're pre-filtered).

  Results render with level-coloured badges, date ranges, extent,
  and cross-entity links so you can jump from an authority hit to
  its archives and to its book-side author in one click. Empty
  query state hints at MySQL's min-word-length quirk so users
  understand why "io" returns nothing.

  New JSON endpoint (scaffold for future glue):
    GET /admin/archives/api/authorities/search?q=...
  Returns up to 25 authority_records matching q — shape is
  {query, results[]} and is ready for an autocomplete frontend
  on the attach <select> in archival_units show.php. Not wired
  to a JS consumer yet; that's a small PR on top.

  /admin/archives index grows a toolbar: "Ricerca", "Authority
  records", and the existing "Nuovo record archivistico" CTA.

i18n
  +33 new keys in locale/{it_IT,en_US,de_DE}.json covering the
  search UI, the extended ISAAR field labels, the reconciliation
  section, and the FULLTEXT min-word hint. DE uses archival
  terminology consistent with phase 1-2: "Ansetzungsform",
  "Normdatensätze", "Archiveinheiten", "verknüpfte".

Version bumped to 0.7.0 (status: phase-3). PHPStan level 5 clean
on the full plugin, `php -l` clean on all new views, unit test
(28 assertions) still passes. E2E coverage for the new endpoints
will land in a follow-up spec — the existing
archives-authorities.spec.js still covers the phase-2 path.
@fabiodalez-dev fabiodalez-dev changed the title feat(archives): phases 1b/1c/1d/2 — full archives + authorities CRUD (#103) feat(archives): phases 1b–3 — full plugin (CRUD + auth + search) (#103) Apr 20, 2026
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Phases 2b + 3 (commit 65031ed) added to this PR:

Phase 2b — 8 extra ISAAR fields in the authority form, new autori_authority_link table, libri↔authority linkage UI with attach/unlink.
Phase 3 — cross-entity FULLTEXT search at /admin/archives/search over archival_units + authority_records + reconciled autori; JSON type-ahead endpoint at /admin/archives/api/authorities/search; toolbar in the index.

Details in the commit body. +898 lines, +33 i18n keys, PHPStan level 5 clean, 28/28 unit test.

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

@fabiodalez-dev Confermato — avvio una full review su Phase 2b + Phase 3 (commit 65031ed):

  • Phase 2b: 8 campi ISAAR aggiuntivi nel form authority, nuova tabella autori_authority_link, UI linkage libri↔authority.
  • Phase 3: FULLTEXT search cross-entity su archival_units + authority_records + autori, endpoint type-ahead JSON, toolbar nell'index.
✅ Actions performed

Full review triggered.

Ships round-trip MARCXML for both archival_units and authority_records
using the ABA archive-format crosswalk already documented in README.md.
Output streams through PHP XMLWriter so large fonds don't blow the
memory limit.

Export endpoints
  GET /admin/archives/{id}/export.xml
      One archival_unit + its attached authorities. Authorities render
      as 100/110 (creator), 600/610 (subject), or 700/710 (added entry)
      depending on (type, role). Content-Disposition names the file
      after the reference_code so downloads land with a stable name.
  GET /admin/archives/authorities/{id}/export.xml
      One authority record. Uses MARC21 authority tags: 100 (person) /
      110 (corporate) for the name, 400 tracings for parallel_forms
      and other_forms, and 024/370/368/372/377/373/678 for ISAAR
      identifiers / places / legal status / functions / mandates /
      internal structure / bio.
  GET /admin/archives/export.xml?ids=1,2,3
      Bulk collection. When `ids` is omitted, exports every non-deleted
      archival_unit (capped at 1000 — larger jobs belong to a CLI batch
      script, not a browser request).

Import endpoint
  GET  /admin/archives/import  — upload form (file input + Dry-run
       checkbox, checked by default to avoid accidental inserts).
  POST /admin/archives/import  — parses via SimpleXMLElement with
       libxml_use_internal_errors() so parse failures surface as a
       user-facing error instead of a 500. Dry-run parses + lists what
       WOULD be inserted; unchecking runs the INSERTs.
       Phase 4 ingests only `<record type="Bibliographic">` entries —
       authority imports land in phase 4b once round-trip validation
       is in place.

Implementation notes
  - All MARCXML is rooted in <collection xmlns="http://www.loc.gov/MARC21/slim">
    so external tools (Koha, Reindex, LC BIBFRAME Editor) can ingest
    without a transform.
  - `writeMarcDatafield()` skips fields when every subfield is empty —
    keeps output compact and avoids `<datafield tag="XXX"/>` noise.
  - `parseMarcXml()` falls back to the no-namespace XPath when a producer
    emits MARCXML without the slim-namespace declaration (common in
    legacy exports).
  - `marcSub()` helper wraps the nested-array dereference so PHPStan
    level 5 is happy and readers don't have to squint at `??` chains.
  - `decodeLevel()` + levelMap invert the ABA 008*c codes a/b/c/d ↔
    fonds/series/file/item in both directions.

i18n
  +17 keys in it_IT / en_US / de_DE covering the import UI, result
  table, dry-run flash, and the new toolbar entries on the index page.
  DE uses "MARCXML importieren" / "Testlauf" consistent with archival
  terminology.

Out of scope (→ phase 4b)
  - Authority-record import
  - Round-trip validation + diff-only updates
  - XSD schema check

Version bumped to 0.8.0 (status: phase-4). PHPStan level 5 clean on
the full plugin. Unit test (28 assertions) still passes. An E2E
round-trip spec — create fonds → export.xml → re-import → diff —
lands alongside the phase 4 CodeRabbit review, keeping this commit
scoped to the functional slice.
@fabiodalez-dev fabiodalez-dev changed the title feat(archives): phases 1b–3 — full plugin (CRUD + auth + search) (#103) feat(archives): phases 1b–4 — full plugin (#103) Apr 20, 2026
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Phase 4 (83f3b11) added to this PR:

  • Export: 3 endpoints (single archival_unit, single authority, bulk collection) via XMLWriter using the ABA crosswalk. Authorities render as 100/110/600/610/700/710 based on (type, role).
  • Import: upload form with dry-run preview, parses <record type="Bibliographic"> via SimpleXMLElement with libxml error handling. Authority import is phase 4b.
  • Toolbar on the index grew "Importa MARCXML" + "Esporta MARCXML" links; archival_unit show page gains a "MARCXML" download button.
  • +17 i18n keys in IT/EN/DE.

+796 lines, PHPStan level 5 clean, 28/28 unit test.

Roadmap is now saved in my persistent memory (memory/project_archives_plugin_roadmap.md) so future sessions pick up from phase 5 (photographic items / ABA billedmarc) without drift.

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

@fabiodalez-dev Confermato — avvio una full review su Phase 4 (commit 83f3b11):

  • Export MARCXML: 3 endpoint via XMLWriter (singola archival_unit, singola authority, bulk collection); crosswalk ABA con rendering authority come tag 100/110/600/610/700/710 in base a (type, role).
  • Import MARCXML: form upload con anteprima dry-run; parsing <record type="Bibliographic"> via SimpleXMLElement con gestione errori libxml.
  • Toolbar arricchita con "Importa MARCXML" / "Esporta MARCXML"; pulsante download MARCXML nella pagina di dettaglio archival_unit.
  • +17 chiavi i18n in IT/EN/DE.
✅ Actions performed

Full review triggered.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

@fabiodalez-dev Confermato — avvio una full review su Phase 2 (6b5e20b):

  • Authority records CRUD: 7 endpoint per la gestione delle authority records.
  • M:N link UI: interfaccia attach/detach sulla pagina di dettaglio archival_unit.
  • +9 test E2E in tests/archives-authorities.spec.js.
  • +43 chiavi i18n.
✅ Actions performed

Full review triggered.

Builds on phase 4's import/export with three round-trip-critical changes:

1. archival_units import is now an UPSERT
   New private upsertImportedArchivalUnit() runs an INSERT … ON
   DUPLICATE KEY UPDATE keyed on the existing UNIQUE (institution_code,
   reference_code). Re-importing a previously-exported file is now
   idempotent: rows that already exist report as "updated" instead of
   blowing up on the unique constraint. ON DUPLICATE KEY also resets
   deleted_at to NULL so a soft-deleted row resurrects on re-import,
   which matches the user expectation when round-tripping a backup.

   mysqli's affected_rows convention (1=inserted, 2=updated, 0=no-change)
   gives us the action label. insert_id is 0 for ON DUPLICATE KEY
   matches, so we look the row up by the natural key after the upsert
   to get a stable id back to the UI.

2. Authority records import
   parseMarcXml() now ALSO emits an `authorities` list, populated by
   the new buildAuthorityRowFromMarc() helper. importSubmitAction
   iterates that list with a duplicate-skip pre-check via
   findAuthorityByName() (lookup on (type, authorised_form)) — ISAAR
   has no natural key the way reference_code is for archival_units, so
   plain INSERT IGNORE wouldn't help; the explicit pre-check is
   transparent and reportable. New rows are persisted via
   insertImportedAuthority() which mirrors authorityStoreAction's
   13-column payload.

   Tag conventions on import mirror the export side:
     100/110*a → authorised_form (110 → type=corporate; else person)
     100/110*d → dates_of_existence
     400*a    → parallel_forms (multi-400 collapses to last-write,
                  documented as a known limitation)
     024 370 368 372 377 373 → identifiers / places / legal_status /
                  functions / mandates / internal_structure
     678*a    → history (general_context import not split today)

3. Richer import summary
   The result struct now carries inserted / updated / skipped /
   inserted_authorities / skipped_authorities arrays; the import view
   renders two tables (archival_units + authorities) with per-row
   status. Dry-run summary now counts both kinds.

i18n
  +6 keys in IT/EN/DE for the new statuses ("aggiornato",
  "duplicato (saltato)") and table headings.

Roadmap memory updated to reflect phase 4 + 4b done; phase 5
(photographic items) and phase 4c (XSD + multi-400 + Z39.50)
queued.

Version bumped to 0.8.1 (status: phase-4b). PHPStan level 5
clean on the full plugin, unit test (28 assertions) still passes.
A round-trip E2E spec (export → re-import → diff) lands alongside
the phase 4b CodeRabbit pass.
@fabiodalez-dev fabiodalez-dev changed the title feat(archives): phases 1b–4 — full plugin (#103) feat(archives): phases 1b–4b — full plugin (#103) Apr 20, 2026
Companion spec to archives-50-elements-crud.spec.js. Same fixture
generator (all 4 levels × 15 specific_material × 4 color modes ×
varied dates/extent/photographer/publisher/collection/local_class),
but NO cleanup — rows stay in the DB so they can be inspected on
/admin/archives.

Distinct TAG prefix (E2E_SEED_ vs E2E_50CRUD_) so the two specs
coexist and the CRUD spec's cleanup doesn't accidentally wipe the
seed. Each run mints a timestamp-suffixed TAG so repeated runs add
cumulative batches.

51/51 passing (1.6m run, 50 CREATE + 1 final assertion).
User can now visit http://localhost:8888/admin/archives to see the
populated plugin.
…ormat + rename 'authorities' i18n key

- show.php: specific_material (and color_mode) rendered the raw DB
  enum value ('drawing', 'bw', ...) — now mapped through \$materialLabelsShow
  / \$colorLabelsShow with __() so Italian admins see "Disegno / opera
  grafica (hd)" instead of 'drawing'. Falls back to the raw code if a
  future ENUM value isn't in the map.

- show.php: Creato / Ultima modifica dates now pass through format_date()
  with separator '/' and include time. Italian locale gets "21/04/2026 22:45"
  instead of the MySQL ISO dump.

- 'Gestisci authorities' was literal Italian-English, not a translation
  — renamed the i18n key to 'Gestisci record di autorità' in view +
  all 3 locale JSONs (EN: "Manage authority records", DE: "Normdatensätze
  verwalten").

PHPStan level 5 clean, archives-full 25/25 green.
## Public frontend — /archivio (it) / /archive (en) / /archiv (de)

Two new read-only public routes, rendered inside the standard
/frontend layout (same header/footer/theme as /catalogo, /autore):

- **GET /archivio** — list of root-level archival_units. Card-grid view
  with level badge (Fondo/Serie/Fascicolo/Unità), reference code,
  formal title, date range, scope preview, extent.
- **GET /archivio/{id}** — single unit detail. Shows full identity +
  content area (extent, scope, material, photographer, language,
  archival history, access conditions), breadcrumb trail up to the
  root fonds, linked authorities grouped by role (creator/subject/
  custodian/recipient/associated), and direct children listing with
  their own level badges.

Routes are registered in ArchivesPlugin::registerRoutes() without any
auth middleware — archival records are public cultural material.
Implemented as new actions publicIndexAction() + publicShowAction()
and a renderPublic() helper that does the two-pass view → layout
render (same pattern as UserDashboardController).

The localised paths (it/en/de) are resolved through
\App\Support\RouteTranslator::getRouteForLocale so the same
implementation serves all three locales.

## Seed spec extension

tests/archives-seed-50.spec.js now also seeds:
- **15 authority records** (5 person + 5 corporate + 5 family) created
  via the admin UI. Real historical figures/organisations to match
  the archives plugin's documentary-heritage use case.
- **Link table rows** (~26 M:N entries) wiring 20 archival_units to
  one or two authorities each, with varied roles from the enum
  (creator/subject/recipient/custodian/associated).

A single seed run now produces a realistically populated archive
the user can browse end-to-end in the admin UI and the public
frontend.

## i18n

17 new keys shipped with translations for EN/DE (archive breadcrumb
labels, empty-state copy, role labels, authority-type labels).

## Routes

locale/routes_{it,en,de}.json gained the `archives` entry so
`RouteTranslator::route('archives')` resolves per locale.

Verification: PHPStan level 5 clean, archives-full 25/25 green,
/archivio and /archivio/{id} both HTTP 200 on localhost:8888.
## Bootstrap 5 restyle
The initial /archivio views used Tailwind utility classes that the
public frontend layout doesn't ship. Rewrote both views (index + show)
with the Bootstrap 5 / custom-CSS primitives used by /catalogo and
/events: container, card, row/col-md, badge text-bg-*, list-group,
breadcrumb. Added minimal scoped <style> blocks for the hero heading
and the ISAD(G) definition-list, so the pages now feel native inside
the public shell.

## Header menu entry
app/Views/frontend/layout.php grows an optional "Archivio" entry
(placed between Catalogo and Eventi in both the desktop nav and the
mobile overlay) that renders only when:

  1. The archives plugin is active (plugins.is_active = 1), AND
  2. At least one non-deleted archival_unit is published.

The gate uses two LIMIT 1 queries against the existing $container db
connection — negligible overhead per request. A try/catch swallows
the "table doesn't exist" case so the menu entry is simply hidden if
the plugin was never activated.

The link href is resolved through RouteTranslator::route('archives')
so IT/EN/DE installs pick up /archivio, /archive, /archiv respectively.

PHPStan level 5 clean. archives-full 25/25 green. /archivio and
/archivio/{id} both HTTP 200 with Bootstrap styling + header menu
link visible on every public page.
….php

Replaced the flat "breadcrumb + card" detail layout with the same
hero + split-body structure used on /autore/.../{id}:

- Full-bleed coloured hero with large icon box (per-level icon:
  archive/folder-open/folder/file-alt), big title, formal subtitle,
  date range + extent badges, home-catalog-archive breadcrumb
  inside the hero (white text on gradient).
- Body sits -4rem above the hero fold (negative margin trick from
  book-hero), rounded-3 cards with shadow, two-column Bootstrap
  layout: main (descrizione archivistica + unità discendenti) +
  sidebar (soggetti produttori + identificativi).
- Child units now show the per-level icon inside their badge, not
  just the text label; hover state highlights the row.
- Authorities sidebar shows role badge (Creatore/Soggetto/…) and
  dates_of_existence inline.
- Identifiers card on the side exposes reference_code, institution,
  local_classification, collection — the ISAD(G) identity area 3.1.

Verified: /archivio/218 returns HTTP 200 with the new markup.
PHPStan clean (view-only change, no app code touched).
…gn + responsive

All 5 review points from the user addressed:

1. **External CSS file**: new
   storage/plugins/archives/assets/css/archives-public.css served via
   a plugin route /plugins/archives/assets/css/… (same pattern as
   digital-library). Removed the inline <style> blocks from both
   public views — index and show.

2. **Light theme / theme-aware**: dropped the dark gradient hero
   (the "parte nera" the user flagged). Hero now uses var(--bg-primary)
   with a border-bottom and the icon-box uses var(--bg-secondary) +
   var(--color-primary) for the icon — inherits whichever theme
   (light/dark/custom) the admin picked in Settings → Appearance.

3. **Left-aligned text**: .hero-text is explicitly text-align: left
   on ≥992px; the responsive block recenters it only below the
   desktop breakpoint (matches the book-detail pattern).

4. **Mobile responsive**: ported the full @media query ladder from
   book-detail.php — 1200 / 992 / 768 / 480px breakpoints with the
   same scaling logic (icon-box shrinks 180→150→120→100px, h1
   uses clamp(), breadcrumb wraps + centers, action buttons go
   vertical on phones, card-body padding shrinks).

5. **Per-unit cover + document download**: view already supports
   the optional fields cover_image_path, document_path, document_mime,
   document_filename (next commit adds the migration + admin upload
   UI). When a document is present:
     - mime audio/*  → green-audio-player (CSS + JS already bundled
                       in the project at /assets/vendor/green-audio-player)
     - anything else → btn-primary "Scarica documento" with
                       download=filename attribute
   A cover image, when present, replaces the icon-box in the hero.

Verified: /plugins/archives/assets/css/archives-public.css returns
HTTP 200 text/css via nginx+php-fpm. PHPStan level 5 clean.
…b/audio/video)

## Schema additions (idempotent migration)

Four new nullable columns on archival_units:
  - cover_image_path    VARCHAR(500)  — e.g. /uploads/archives/covers/218-abcd.jpg
  - document_path       VARCHAR(500)  — e.g. /uploads/archives/documents/218-1234.pdf
  - document_mime       VARCHAR(100)
  - document_filename   VARCHAR(255)  — original name preserved for download="…"

installer/database/migrations/migrate_0.5.9.sql gets conditional
ALTER TABLE guards (INFORMATION_SCHEMA count) so re-running on an
existing install adds only the missing columns. The CREATE TABLE
block and ArchivesPlugin::ensureSchema() / ddlArchivalUnits() all
include the new fields for fresh installs.

## Admin upload UI

views/show.php grows a "Copertina e documento" card below the ISAD
identity dl, with two independent multipart forms:
  - Cover upload  — accept image/jpeg|png|webp, max 8 MB
  - Document upload — accept pdf/epub/audio(mp3/m4a/ogg/wav)/video(mp4/webm)/image(tiff/jpg/png), max 200 MB

Each side shows a preview (image tag / filename + mime badge) when
an asset is already attached, plus a "Rimuovi …" form that unlinks
the file and nulls the columns.

## Controller

Three new actions on the plugin:
  - uploadCoverAction()   — thin wrapper calling handleAssetUpload('cover', …)
  - uploadDocumentAction()— same for documents
  - removeAssetAction()   — common entry, dispatches on POST field `type`

handleAssetUpload() is the shared pipeline:
  1. PSR-7 getUploadedFiles() + instanceof check
  2. getError() === UPLOAD_ERR_OK
  3. size cap
  4. mime detected via finfo on the temp stream (browser-provided
     Content-Type is user-controlled and cannot be trusted)
  5. mime whitelist → extension map
  6. unlink previous asset (no orphan files)
  7. moveTo public/uploads/archives/{covers,documents}/<id>-<rand>.<ext>
  8. UPDATE the relevant columns

Routes added under the existing /admin/archives/{id}/ prefix, all
behind CsrfMiddleware + AdminAuthMiddleware — same auth envelope as
the rest of the admin plugin surface.

## Frontend

The public show view (the one rendered on /archivio/{id}) already
supports the fields from the previous commit: cover image replaces
the icon-box in the hero; document triggers either the
green-audio-player (for audio/*) or a "Scarica documento" button
with `download=<original_filename>`.

## Verification
- PHPStan level 5 clean
- archives-full 25/25 green (no regression)
- Migration applied + verified on dev DB: 4 new columns present
- Upload dirs created at public/uploads/archives/{covers,documents}/
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

Pushed 0f16518 (plus 73d7a02, a1c5493, 4d8bd1f, 5436603, 7d163d2 since the last triggered review). Scope of this batch:

App-side

  • ArchivesPlugin: new public actions publicIndexAction / publicShowAction (read-only frontend), plus upload pipeline uploadCoverAction / uploadDocumentAction / removeAssetAction sharing a handleAssetUpload() helper that does: PSR-7 getUploadedFiles() + UPLOAD_ERR_OK + size cap + finfo mime detection (not browser-provided) + mime→ext whitelist + orphan unlink + moveTo. Routes under CsrfMiddleware+AdminAuthMiddleware.
  • ArchivesPlugin::serveAsset: plugin-local CSS/JS route /plugins/archives/assets/{type}/{filename} with realpath check (mirrors DigitalLibraryPlugin pattern).
  • ArchivesPlugin: specific_material ENUM extended 8→15 values (ABA billedmarc: text/photograph/poster/postcard/drawing/audio/video/other/map/picture/object/film/microform/electronic/mixed). New values appended so ordinal positions 1-8 stay stable — ALTER is metadata-only.
  • app/Views/layout.php: new "Archivio" menu entry (desktop + mobile) between Catalogo and Eventi, gated on plugin-active AND ≥1 non-deleted archival_unit; uses RouteTranslator::route('archives').

Schema (migrate_0.5.9.sql idempotent ALTERs)

  • 4 new columns on archival_units: cover_image_path, document_path, document_mime, document_filename — all NULL-able.
  • specific_material ENUM widened via guarded ALTER TABLE … MODIFY COLUMN (checks COLUMN_TYPE LIKE '%mixed%' before running).

Frontend

  • views/public/index.php — Bootstrap 5 card grid, level badges.
  • views/public/show.php — hero + two-column body modelled on book-detail.php: large icon-box (or cover image when present), title, breadcrumb inside hero, ISAD(G) description card, children list, authorities sidebar, identifiers panel. Cover / download button / green-audio-player rendered conditionally from document_mime.
  • assets/css/archives-public.css — all inline <style> blocks extracted. Theme-aware (var(--bg-primary), var(--color-primary), var(--text-primary)). Mobile media-query ladder ported from book-detail (1200/992/768/480px breakpoints).

i18n

~40 new keys across IT/EN/DE (archive menu entry, upload form copy, role labels, level labels, material labels, archival-description strings).

Seed / testing

  • tests/archives-seed-50.spec.js — populates the dev DB with 50 archival_units + 15 authority_records (5 person + 5 corporate + 5 family, real historical figures) + 30 M:N link rows with varied roles. No cleanup — rows persist for UI inspection. Currently live on localhost: 100 units / 15 authorities / 30 links.
  • tests/archives-50-elements-crud.spec.js — 105 tests (50 CREATE + Read + 50 UPDATE + soft-delete + cleanup) running end-to-end through the admin UI.

Verification

  • PHPStan level 5: clean
  • archives-full.spec.js: 25/25 green
  • archives-crud.spec.js: 9/9 green
  • archives-authorities.spec.js: 9/9 green
  • archives-50-elements-crud.spec.js: 105/105 green
  • archives-seed-50.spec.js: 67/67 green
  • Migration applied on dev DB: 4 new columns + ENUM extension verified
  • Public routes: /archivio / /archivio/{id} both HTTP 200

Please focus on: the upload pipeline (handleAssetUpload), the extended ENUM migration (metadata-only guarantee), the new public actions, and the theme-aware CSS contract.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the main plugin file
fd -i "ArchivesPlugin.php" --exec cat {}

echo "=== SEPARATOR MIGRATE ==="
fd -i "migrate_0.5.9.sql" --exec cat {}

echo "=== SEPARATOR LAYOUT ==="
fd -i "layout.php" -p "app/Views" --exec cat {}

echo "=== SEPARATOR CSS ==="
fd -i "archives-public.css" --exec cat {}

echo "=== SEPARATOR PUBLIC INDEX ==="
fd -i "index.php" -p "storage/plugins/archives/views/public" --exec cat {}

echo "=== SEPARATOR PUBLIC SHOW ==="
fd -i "show.php" -p "storage/plugins/archives/views/public" --exec cat {}

Length of output: 306521


@fabiodalez-dev thanks — reviewed 0f16518 (+73d7a02, a1c5493, 4d8bd1f, 5436603, 7d163d2) focusing on the four requested areas. I pulled the plugin class, migration, and layouts via scripts.

Verdict

  • Upload pipeline: solid baseline (CSRF + AdminAuth, finfo MIME, allow-list, size caps, random filenames, orphan cleanup). Two hardening nits below.
  • ENUM extension: idempotent and logically metadata-only (ordinals preserved). Note on potential table rebuild on older engines.
  • Public actions: clean read-only surface; safe routing and 404s; breadcrumb bounded; reasonable SQL.
  • serveAsset: traversal-safe with realpath and filename whitelist; long cache OK.
  • CSS theme contract: likely variable-name mismatch with frontend layout unless your CSS provides fallbacks.

Actionable fixes

  1. Guard unlink paths in removeAssetAction to prevent path traversal if a row is ever tampered
    Rationale: current unlink trusts DB path. While uploads write controlled prefixes, a rogue row could point outside /uploads. Cheap prefix check eliminates risk.

PHP (ArchivesPlugin.php)

// before unlink, constrain to known prefixes
$allowedPrefixes = $type === 'cover'
    ? ['/uploads/archives/covers/']
    : ['/uploads/archives/documents/'];

$isAllowed = false;
foreach ($allowedPrefixes as $prefix) {
    if (str_starts_with($currentPath, $prefix)) { $isAllowed = true; break; }
}
if ($isAllowed) {
    $fsPath = __DIR__ . '/../../../public' . $currentPath;
    if (is_file($fsPath)) { `@unlink`($fsPath); }
} else {
    \App\Support\SecureLogger::warning('[Archives] skip unlink: disallowed path prefix', [
        'type' => $type, 'path' => $currentPath
    ]);
}
  1. Add finfo_buffer fallback for in-memory temp streams
    Rationale: some PSR-7 implementations keep uploaded bytes in php://temp; finfo_file() on a non-fs path yields empty MIME. Falling back to finfo_buffer avoids false negatives.

PHP (ArchivesPlugin::handleAssetUpload)

$stream = $upload->getStream();
$mime = '';
if (function_exists('finfo_open')) {
    $f = finfo_open(FILEINFO_MIME_TYPE);
    if ($f !== false) {
        $tmpPath = $stream->getMetadata('uri');
        if (is_string($tmpPath) && `@is_file`($tmpPath)) {
            $detected = `@finfo_file`($f, $tmpPath);
            if (is_string($detected)) { $mime = $detected; }
        }
        if ($mime === '') { // fallback to buffer
            $contents = (string) $stream;
            if ($contents !== '') {
                $det = `@finfo_buffer`($f, $contents);
                if (is_string($det)) { $mime = $det; }
            }
        }
        finfo_close($f);
    }
}
if ($mime === '' || !isset($mimeToExt[$mime])) {
    \App\Support\SecureLogger::warning('[Archives] upload rejected — mime not in allow-list', ['kind' => $kind, 'mime' => $mime]);
    return $response->withHeader('Location', '/admin/archives/' . $id)->withStatus(303);
}
  1. mkdir() error logging (optional)
    Add a log on failure to create /uploads dirs; currently @mkdir hides FS errors.
if (!is_dir($targetDirFs) && !@mkdir($targetDirFs, 0755, true) && !is_dir($targetDirFs)) {
    \App\Support\SecureLogger::error('[Archives] failed to create upload dir', ['dir' => $targetDirFs]);
    return $response->withHeader('Location', '/admin/archives/' . $id)->withStatus(303);
}

ENUM migration note

  • The MODIFY … ENUM appends values in the same order as in DDL, keeping ordinals 1–8 stable — so no logical data rewrite. On MySQL 5.7/MariaDB 10.x the ALTER may still rebuild the table; acceptable, but worth a one-liner in README to set operator expectations (migration time on large tables).

Public frontend views and theme CSS

  • Frontend layout defines CSS vars like --primary-color, --text-color, --light-bg. Your commit message mentions archives-public.css uses var(--bg-primary), var(--color-primary), var(--text-primary). If that’s literal, the variables won’t resolve. Either:
    • switch plugin CSS to the site’s vars (--primary-color, --text-color, --light-bg), or
    • provide fallbacks: color: var(--color-primary, var(--primary-color, #111)); background: var(--bg-primary, var(--light-bg, #f8f9fa));
      If you want, I can scan storage/plugins/archives/assets/css/archives-public.css and views/public/*.php next to confirm escaping and variable usage.

Small correctness/readability nits

  • publicShowAction: the breadcrumb loop already has safetyCap; fine. Consider selecting parent chain with a single looped prepared stmt (you already do that), OK.
  • serveAsset: add ETag/Last-Modified (non-blocking); Cache-Control is already set to 1y.

Let me know if you want me to open a follow-up issue for the unlink guard + finfo_buffer fallback, or draft a patch.


🧠 Learnings used
Learnt from: fabiodalez-dev
Repo: fabiodalez-dev/Pinakes PR: 0
File: :0-0
Timestamp: 2026-04-20T22:39:18.059Z
Learning: In the Pinakes archives plugin, the unit test suite (`tests/archives-plugin.unit.php`) covers only DDL string shape and does NOT catch: (1) PHP int-coercion of numeric string array keys in MARCXML export (`writeAuthorityMarcRecord`), (2) SimpleXML `->children('namespace')` returning empty tag attributes (needs `->attributes()` + null guard in `collectMarcFields`), (3) mysqli `bind_param` type-string length mismatches (e.g. `upsertImportedArchivalUnit` had 24 chars for 23 variables after phase-5 expansion). These classes of bugs require E2E tests against real MARCXML files to surface. All three were fixed in commit `d43133b` (v1.0.0 / 0.5.9).

Learnt from: fabiodalez-dev
Repo: fabiodalez-dev/Pinakes PR: 0
File: :0-0
Timestamp: 2026-04-21T12:40:08.717Z
Learning: In the Pinakes project, `PluginManager::activatePlugin()` (and `deactivatePlugin`/`uninstallPlugin`) had a critical bug: it called `setPluginId` on one throw-away plugin instance and `onActivate` on a separate instance built by `runPluginMethod`, so `$pluginId` was always `null` during `onActivate`. Plugins that write to `plugin_hooks` during `onActivate` silently skipped hook registration — archives plugin shipped installed-but-unrouted. Fixed in commit `21cb67d` by building a single instance via `instantiatePlugin($plugin)` and invoking the lifecycle method directly on it. The E2E test 01 in `archives-full.spec.js` previously masked this by pre-inserting `plugin_hooks` rows; after the fix it exercises the real admin-UI activation path and asserts ≥2 active hook rows.

Learnt from: fabiodalez-dev
Repo: fabiodalez-dev/Pinakes PR: 0
File: :0-0
Timestamp: 2026-04-20T22:08:50.625Z
Learning: In the Pinakes project, `z39-server/classes/SRUServer.php` queries only the `libri` table (lines 413, 905, 907, 925) and is not modified. SRU/Z39.50 exposure for `archival_units` and `authority_records` is handled by Phase 6 of the archives plugin, implemented as a self-contained endpoint `GET /api/archives/sru` (SRU 1.2) inside the archives plugin itself (Option B). Plugin is at v1.0.0 with all roadmap phases 1a–6 complete as of PR `#105`.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

… detail page

Three user-reported issues in one commit.

## 1. Menu entry missing from /archivio/{id}

layout.php guarded the "Archivio" nav entry on \$container being in
scope — but plugin-rendered views (renderPublic → include layout)
don't have \$container. The entry was therefore only visible on
core-controller pages (/, /catalogo, /autore/…) and dropped out on
the very pages that most need it.

Fix: renderPublic() now pre-sets \$archivesAvailable = true +
\$archivesRoute before including the layout; layout.php uses
?? null-coalesce on those vars so the fallback probe only runs
when they aren't already set. The Archivio nav link now appears
on every public archive page with an `active` class.

## 2. URLs used the raw id — anti-SEO

The detail URL was /archivio/173 — opaque, no keyword signal for
search engines. Now:

  - Canonical: /archivio/{slug}-{id}  e.g.
    /archivio/seed-archivio-5-drawing-fonds-173
  - Legacy:    /archivio/{id}  (still works)

When the client hits the legacy form (or any slug that no longer
matches the current constructed_title), the action emits a 301 to
the canonical slug+id URL so Google/Bing converge on one result
per archival unit.

Slug generation uses the existing slugify_text() helper from
app/helpers.php (already used by /libro and /autore). Children
listings, breadcrumb links, and the public index grid all switched
to the slug form.

## 3. Schema.org JSON-LD

Added a <script type="application/ld+json"> block to the detail
view with a per-level type mapping:

  level=fonds/series/file → ArchiveComponent
  level=item              → CreativeWork

Properties emitted: @context, @type, name (constructed_title),
alternateName (formal_title), identifier (reference_code), url
(canonical), description (scope_content), inLanguage, temporalCoverage
(date_start/date_end as ISO interval), holdingArchive (ArchiveOrganization
with institution_code), about (Person/Organization for every linked
authority), isPartOf (breadcrumb as ArchiveComponent chain), image
(cover URL when present), associatedMedia (AudioObject / MediaObject
for the downloadable document).

Empty / null properties are pruned before encoding so rich-results
validators don't flag the response.

## Verification
- PHPStan level 5: clean
- Routes tested:
    /archivio                                      → 200
    /archivio/173                                  → 301 → slug form
    /archivio/seed-archivio-5-drawing-fonds-173    → 200
- Menu entry verified on detail page (active class)
- JSON-LD validated: @type=ArchiveComponent, valid schema.org URLs
The icon-box (or the cover image, when present) was left-aligned
inside the col-lg-4 slot on desktop — it floated against the left
edge of the column instead of sitting centred in the designated
space. The hero-text col-lg-8 is already left-aligned so the visual
balance works best when the media column is centred.

Two small changes:
  - storage/plugins/archives/views/public/show.php: the wrapper
    <div class="col-lg-4 …"> now uses d-flex justify-content-center
    align-items-center so both the icon-box and the archive-cover-large
    image sit centred vertically+horizontally.
  - assets/css/archives-public.css: .icon-box gets margin: 0 auto on
    desktop too (was: margin: 0). Responsive overrides inherit it
    automatically.

No behavioural change. /archivio and /archivio/{slug}-{id} still
render identically; just the media column visual layout is tightened.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

Pushed further commits since the last review trigger:

  • 74287ba style: centre icon-box/cover inside col-lg-4 (flex wrapper)
  • 4e46074 feat: SEO slug URLs (/archivio/{slug}-{id}), JSON-LD Schema.org, menu entry on detail page
  • 0f16518 feat: per-unit cover + downloadable document (upload pipeline with finfo mime detection, green-audio-player for audio/*, 200 MB cap, orphan cleanup)
  • 73d7a02 feat: external CSS stylesheet, light theme, responsive breakpoints

Highlights to focus on:

  1. Upload pipeline (handleAssetUpload): finfo-based mime detection (not trusting browser Content-Type), mime→ext whitelist, size cap, orphan file unlink before moveTo, CSRF+AdminAuth.
  2. SEO slug URLs: dual-pattern route /archive/{slug:[a-z0-9-]+}-{id:[0-9]+} + /archive/{id:[0-9]+} — legacy shape emits 301 to canonical via slugify_text of constructed_title.
  3. Schema.org JSON-LD: per-level @type mapping (fonds/series/file → ArchiveComponent, item → CreativeWork) with holdingArchive, about (Person/Organization from authorities), isPartOf (breadcrumb chain), associatedMedia (AudioObject/MediaObject).
  4. Menu entry fix: plugin-rendered views don't have $container in scope; renderPublic() now pre-sets $archivesAvailable=true + $archivesRoute, and the layout uses null-coalesce on those vars so the fallback DB probe only runs when they're absent.
  5. Theme-aware CSS: all styles moved to storage/plugins/archives/assets/css/archives-public.css, served via plugin route with realpath check, using var(--bg-primary)/var(--color-primary)/var(--text-primary) so light/dark/custom themes all work.

Verification: PHPStan level 5 clean, archives 25/25 + 9/9 + 9/9 all green, routes tested (200/301/200).

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== ArchivesPlugin.php ==="
fd -i "ArchivesPlugin.php" --exec cat {}

echo "=== archives-public.css ==="
fd -i "archives-public.css" --exec cat {}

echo "=== public/show.php ==="
fd "show.php" -p "storage/plugins/archives/views/public" --exec cat {}

echo "=== public/index.php ==="
fd "index.php" -p "storage/plugins/archives/views/public" --exec cat {}

echo "=== layout.php ==="
fd "layout.php" -p "app/Views" --exec cat {}

Length of output: 317476


@fabiodalez-dev grazie — full review on 74287ba, 4e46074, 0f16518, 73d7a02. Verde su SEO/JSON‑LD/route canonicals; segnalo 2 hardening P1 e 2 P2 minori, più 2 check di integrazione.

P1 — Path traversal guard su unlink

  • removeAssetAction oggi fida il path dal DB. Aggiungi un prefix‑check prima di unlink per coprire eventuali righe manomesse.
// ArchivesPlugin::removeAssetAction (prima di `@unlink`)
$allowedPrefixes = $type === 'cover'
    ? ['/uploads/archives/covers/']
    : ['/uploads/archives/documents/'];

$ok = false;
foreach ($allowedPrefixes as $prefix) {
    if (str_starts_with($currentPath, $prefix)) { $ok = true; break; }
}
if ($ok) {
    $fsPath = __DIR__ . '/../../../public' . $currentPath;
    if (is_file($fsPath)) { `@unlink`($fsPath); }
} else {
    \App\Support\SecureLogger::warning('[Archives] skip unlink: disallowed path', [
        'type' => $type, 'path' => $currentPath,
    ]);
}

P1 — finfo_buffer fallback su upload temporanei in memoria

  • handleAssetUpload usa finfo_file($tmpPath) ma alcune PSR‑7 tengono i bytes in php://temp. Aggiungi il fallback su finfo_buffer.
// Detect MIME (add buffer fallback)
$stream = $upload->getStream();
$mime = '';
if (function_exists('finfo_open')) {
    $fi = finfo_open(FILEINFO_MIME_TYPE);
    if ($fi !== false) {
        $tmpPath = $stream->getMetadata('uri');
        if (is_string($tmpPath) && `@is_file`($tmpPath)) {
            $det = `@finfo_file`($fi, $tmpPath);
            if (is_string($det)) { $mime = $det; }
        }
        if ($mime === '') {
            $contents = (string) $stream;
            if ($contents !== '') {
                $det = `@finfo_buffer`($fi, $contents);
                if (is_string($det)) { $mime = $det; }
            }
        }
        finfo_close($fi);
    }
}
if ($mime === '' || !isset($mimeToExt[$mime])) {
    \App\Support\SecureLogger::warning('[Archives] upload rejected — mime not in allow-list', ['kind'=>$kind,'mime'=>$mime]);
    return $response->withHeader('Location', '/admin/archives/' . $id)->withStatus(303);
}

P2 — mkdir e moveTo: error handling

  • Logga il fallimento di mkdir e proteggi moveTo con try/catch per non lasciare lo stato metà‑e‑metà.
// mkdir with logging
if (!is_dir($targetDirFs) && !@mkdir($targetDirFs, 0755, true) && !is_dir($targetDirFs)) {
    \App\Support\SecureLogger::error('[Archives] failed to create upload dir', ['dir'=>$targetDirFs]);
    return $response->withHeader('Location', '/admin/archives/' . $id)->withStatus(303);
}
// moveTo guarded
try {
    $upload->moveTo($targetDirFs . '/' . $basename);
} catch (\Throwable $e) {
    \App\Support\SecureLogger::error('[Archives] moveTo failed: ' . $e->getMessage(), [
        'dest' => $targetDirFs . '/' . $basename, 'kind' => $kind,
    ]);
    return $response->withHeader('Location', '/admin/archives/' . $id)->withStatus(303);
}

SEO slug URLs

  • Routes e 301 canonical OK: doppia forma registrata (+ varianti locali), redirect quando lo slug non corrisponde. Bene.

JSON‑LD

  • Emesso inline con JSON_HEX_TAG e campi sensati (holdingArchive, about[], isPartOf, associatedMedia). Facoltativo: se hai un nome leggibile dell’istituzione, aggiungi 'holdingArchive.name'.

Menu entry “Archivio” nel layout pubblico

  • renderPublic() setta $archivesAvailable/$archivesRoute. Verifica che app/Views/frontend/layout.php li usi davvero per rendere il link nel menu desktop+mobile; se manca, suggerimento minimale:
<!-- Desktop nav -->
<li>
  <?php if (($archivesAvailable ?? false) && !empty($archivesRoute ?? null)): ?>
    <a href="<?= htmlspecialchars($archivesRoute, ENT_QUOTES, 'UTF-8') ?>"
       class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', $archivesRoute) !== false ? 'active' : '' ?>">
      <?= __('Archivio') ?>
    </a>
  <?php endif; ?>
</li>

<!-- Mobile nav analogo ... -->

CSS tema (fallback variabili)

  • Il layout espone var(--primary-color), --light-bg, --text-color. Se il foglio archives-public.css usa --bg-primary/--color-primary/--text-primary, aggiungi fallback per compatibilità:
/* storage/plugins/archives/assets/css/archives-public.css */
:root{
  --archives-color-primary: var(--color-primary, var(--primary-color, `#0f172a`));
  --archives-text-primary:  var(--text-primary,  var(--text-color,   `#0f172a`));
  --archives-bg-primary:    var(--bg-primary,    var(--light-bg,     `#f8f9fa`));
}
/* poi usa le --archives-* nelle regole */

Serve asset statici

  • realpath‑check + cache 1y OK. Non bloccante: puoi aggiungere ETag/Last‑Modified se vuoi sfruttare 304.

Verifica generale

  • Slugify/canonical 301 testati; JSON‑LD presente; CSRF+AdminAuth sulle upload; size‑cap 8MB/200MB; whitelist MIME ok (incluso TIFF per documenti); download filename sanificato con basename().

Vuoi che apra un issue di follow‑up per: (1) unlink path‑guard + (2) finfo_buffer fallback + (3) mkdir/moveTo logging? Posso anche preparare una patch integrata.


🧠 Learnings used
Learnt from: fabiodalez-dev
Repo: fabiodalez-dev/Pinakes PR: 0
File: :0-0
Timestamp: 2026-04-20T22:39:18.059Z
Learning: In the Pinakes archives plugin, the unit test suite (`tests/archives-plugin.unit.php`) covers only DDL string shape and does NOT catch: (1) PHP int-coercion of numeric string array keys in MARCXML export (`writeAuthorityMarcRecord`), (2) SimpleXML `->children('namespace')` returning empty tag attributes (needs `->attributes()` + null guard in `collectMarcFields`), (3) mysqli `bind_param` type-string length mismatches (e.g. `upsertImportedArchivalUnit` had 24 chars for 23 variables after phase-5 expansion). These classes of bugs require E2E tests against real MARCXML files to surface. All three were fixed in commit `d43133b` (v1.0.0 / 0.5.9).

Learnt from: fabiodalez-dev
Repo: fabiodalez-dev/Pinakes PR: 0
File: :0-0
Timestamp: 2026-04-21T12:40:08.717Z
Learning: In the Pinakes project, `PluginManager::activatePlugin()` (and `deactivatePlugin`/`uninstallPlugin`) had a critical bug: it called `setPluginId` on one throw-away plugin instance and `onActivate` on a separate instance built by `runPluginMethod`, so `$pluginId` was always `null` during `onActivate`. Plugins that write to `plugin_hooks` during `onActivate` silently skipped hook registration — archives plugin shipped installed-but-unrouted. Fixed in commit `21cb67d` by building a single instance via `instantiatePlugin($plugin)` and invoking the lifecycle method directly on it. The E2E test 01 in `archives-full.spec.js` previously masked this by pre-inserting `plugin_hooks` rows; after the fix it exercises the real admin-UI activation path and asserts ≥2 active hook rows.

Learnt from: fabiodalez-dev
Repo: fabiodalez-dev/Pinakes PR: 0
File: :0-0
Timestamp: 2026-04-20T22:08:50.625Z
Learning: In the Pinakes project, `z39-server/classes/SRUServer.php` queries only the `libri` table (lines 413, 905, 907, 925) and is not modified. SRU/Z39.50 exposure for `archival_units` and `authority_records` is handled by Phase 6 of the archives plugin, implemented as a self-contained endpoint `GET /api/archives/sru` (SRU 1.2) inside the archives plugin itself (Option B). Plugin is at v1.0.0 with all roadmap phases 1a–6 complete as of PR `#105`.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…or inspection

New spec tests/archives-upload-assets.spec.js drives the full upload
pipeline through Playwright's setInputFiles:
  1. JPEG cover image (3 distinct E2E_SEED_* units)
  2. PDF document on one unit
  3. MP3 audio on another unit

Fixture files are generated outside the test (ffmpeg sine wave for the
MP3, minimal one-page PDF, the existing placeholder.jpg for the cover)
at stable /tmp paths so the spec is deterministic.

Each upload is asserted on three surfaces:
  - DB columns populated with the expected relative path + mime +
    original filename (cover_image_path, document_path, document_mime,
    document_filename)
  - Physical file present on disk under public/uploads/archives/
    {covers,documents}/<id>-<rand>.<ext>
  - Rendered correctly on the public /archivio/{id} page:
    * PDF → "Scarica documento" button with download attribute
    * Audio → green-audio-player initialised on <audio src="...mp3">
    * Cover → served by nginx at /uploads/archives/covers/…

No cleanup — uploads stay on disk and in the DB for manual inspection
via /archivio/{slug}-{id}. 9/9 tests green in 5.4s.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai review

New commit c9f64f2tests/archives-upload-assets.spec.js: 9 Playwright tests that drive the 3 upload endpoints end-to-end (JPEG cover, PDF, MP3 audio) and assert:

  • DB columns populated (cover_image_path / document_path / document_mime / document_filename)
  • Files physically present on disk under public/uploads/archives/{covers,documents}/
  • Frontend rendering (download button for PDF, green-audio-player for audio, nginx serves cover image with correct mime)

No cleanup — seed fixtures + uploaded files kept so /archivio/{slug}-{id} can be inspected manually.

9/9 green (5.4 s). Focus the review on the fixture/mime whitelist contract in the test.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -i "archives-upload-assets.spec.js" --exec cat {}

Length of output: 9662


@fabiodalez-dev — revisione di c9f64f2 completata. Suite solida nel percorso positivo; segnalo le lacune sul contratto del whitelist MIME (oggetto della richiesta) e due nit minori.


✅ Solid

  • beforeAll existence-check sui 3 fixture con messaggio d'errore leggibile (fixture missing: /tmp/...) — chi ha saltato la generazione capisce subito.
  • ID picking avviene prima di qualsiasi upload, quindi il filtro cover_image_path IS NULL è valido al momento della query.
  • submitUpload usa Promise.all([waitForURL, click]) — pattern corretto.
  • Test 2: lettura dei primi 5 byte e confronto con %PDF- è un buon integrity check che verifica il contenuto del file e non solo il percorso.
  • Tutti gli ID inline nelle query SQL sono Number()-castati — nessun rischio injection.
  • DB assert su document_mime (application/pdf / audio/mpeg) verifica che finfo abbia rilevato il tipo corretto lato PHP.

⚠️ Gap principale: nessun test negativo sul whitelist MIME

Il contratto del whitelist ("solo JPEG/PNG/TIFF per cover, solo PDF/audio/* per documenti") non è testato dal lato del rifiuto. I test attuali coprono solo il percorso felice con file validi. Mancano:

a) Upload MIME non ammesso → deve essere rifiutato (nessuna modifica DB)

test('NEGATIVE: upload a PHP file disguised as JPEG is rejected', async () => {
    const fakePath = '/tmp/evil.jpg';
    fs.writeFileSync(fakePath, '<?php phpinfo(); ?>'); // text/x-php MIME reale
    const before = dbQuery(
        `SELECT cover_image_path FROM archival_units WHERE id = ${ids.coverOnly}`
    );
    await page.goto(`${BASE}/admin/archives/${ids.coverOnly}`);
    await page.setInputFiles('form[action*="/upload-cover"] input[name="cover"]', fakePath);
    await Promise.all([
        page.waitForURL(new RegExp(`/admin/archives/${ids.coverOnly}`), { timeout: 10000 }),
        page.click('form[action*="/upload-cover"] button[type="submit"]'),
    ]);
    const after = dbQuery(
        `SELECT cover_image_path FROM archival_units WHERE id = ${ids.coverOnly}`
    );
    expect(after).toBe(before); // DB invariato
    fs.unlinkSync(fakePath);
});

b) File oltre il size cap → rifiutato (verifica che il cap PHP sia attivo)

Opzionale se il cap è già coperto da un test unitario, ma senza di esso un misconfigured upload_max_filesize in PHP.ini potrebbe far passare file enormi silenziosamente.


🟡 Nit 1 — variabile slug è in realtà l'ID numerico

Tests 6 e 7:

const slug = dbQuery(`SELECT id FROM archival_units WHERE id = ${ids.pdf}`);
// → ritorna '42', non uno slug
const resp = await page.request.get(`${BASE}/archivio/${slug}`);

Il percorso funziona perché il plugin emette un 301 da /archivio/{id} alla forma canonica. Ma il nome della variabile è fuorviante:

const numId = dbQuery(`SELECT id FROM archival_units WHERE id = ${ids.pdf}`);
// oppure recupera anche lo slug:
// SELECT CONCAT(LOWER(REGEXP_REPLACE(constructed_title, '[^a-z0-9]+', '-')), '-', id)

🟡 Nit 2 — Test 9 è trivialmente vuoto

test('9. Final summary', async () => {
    console.log(...);
});

Un test che stampa solo un log non aggiunge copertura e può distorcere il conteggio "9/9 green". Considerare di spostarlo in afterAll o di farne un assert che verifica che i tre ID siano tutti > 0 (già garantito da beforeAll, ma almeno sarebbe non-triviale).


Verdict: blocco solo se vuoi coprire davvero il contratto del whitelist — il test negativo del MIME è la lacuna più rilevante per la sicurezza. Il resto sono nit non bloccanti.


🧠 Learnings used
Learnt from: fabiodalez-dev
Repo: fabiodalez-dev/Pinakes PR: 0
File: :0-0
Timestamp: 2026-04-20T22:39:18.059Z
Learning: In the Pinakes archives plugin, the unit test suite (`tests/archives-plugin.unit.php`) covers only DDL string shape and does NOT catch: (1) PHP int-coercion of numeric string array keys in MARCXML export (`writeAuthorityMarcRecord`), (2) SimpleXML `->children('namespace')` returning empty tag attributes (needs `->attributes()` + null guard in `collectMarcFields`), (3) mysqli `bind_param` type-string length mismatches (e.g. `upsertImportedArchivalUnit` had 24 chars for 23 variables after phase-5 expansion). These classes of bugs require E2E tests against real MARCXML files to surface. All three were fixed in commit `d43133b` (v1.0.0 / 0.5.9).

Learnt from: fabiodalez-dev
Repo: fabiodalez-dev/Pinakes PR: 0
File: :0-0
Timestamp: 2026-04-21T12:40:08.717Z
Learning: In the Pinakes project, `PluginManager::activatePlugin()` (and `deactivatePlugin`/`uninstallPlugin`) had a critical bug: it called `setPluginId` on one throw-away plugin instance and `onActivate` on a separate instance built by `runPluginMethod`, so `$pluginId` was always `null` during `onActivate`. Plugins that write to `plugin_hooks` during `onActivate` silently skipped hook registration — archives plugin shipped installed-but-unrouted. Fixed in commit `21cb67d` by building a single instance via `instantiatePlugin($plugin)` and invoking the lifecycle method directly on it. The E2E test 01 in `archives-full.spec.js` previously masked this by pre-inserting `plugin_hooks` rows; after the fix it exercises the real admin-UI activation path and asserts ≥2 active hook rows.

Learnt from: fabiodalez-dev
Repo: fabiodalez-dev/Pinakes PR: 0
File: :0-0
Timestamp: 2026-04-20T22:08:50.625Z
Learning: In the Pinakes project, `z39-server/classes/SRUServer.php` queries only the `libri` table (lines 413, 905, 907, 925) and is not modified. SRU/Z39.50 exposure for `archival_units` and `authority_records` is handled by Phase 6 of the archives plugin, implemented as a self-contained endpoint `GET /api/archives/sru` (SRU 1.2) inside the archives plugin itself (Option B). Plugin is at v1.0.0 with all roadmap phases 1a–6 complete as of PR `#105`.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…blic

The archives plugin CSS (archives-public.css) and the two public
views were not being tracked by git — the catch-all rule
\`storage/plugins/archives/*\` on line 163 matched the \`assets/\` and
\`views/public/\` directories, and no explicit re-include existed for
them. As a result:
  - the stylesheet introduced in commit 73d7a02 was only present on
    the developer's disk, never pushed, never distributed in the ZIP;
  - the public /archivio views shipped only because they happened to
    be added in an earlier commit before the catch-all tightened.

Added explicit \`!\` rules for:
  - storage/plugins/archives/views/public/ + *.php
  - storage/plugins/archives/assets/ + css/*.css + js/*.js

Plus: drop every box-shadow from the archive public pages (flat by
design — matches the user's review comment) and add a defensive
\`box-shadow: none !important\` block guarding against ambient
shadows coming from Bootstrap or the theme card rule
(\`0 4px 16px rgba(0,0,0,.07)\`).

The .archive-card hover affordance now uses transform + border-color
change instead of a drop shadow.
…heme vars

Seven findings from the last three CodeRabbit rounds (74287ba + c9f64f2
+ 0f16518), all actioned.

## P1 security — unlink path-prefix guard (ArchivesPlugin::removeAssetAction)
The unlink path came from the DB; a manipulated \`cover_image_path\`
row could have pointed outside /uploads/archives/ and let the action
delete arbitrary files under public/. Added a str_starts_with check
against the expected prefix before calling unlink; mismatches are
logged via SecureLogger and skipped.

## P1 robustness — finfo_buffer fallback (ArchivesPlugin::handleAssetUpload)
Some PSR-7 implementations keep the uploaded body in php://temp;
finfo_file cannot read that and returned '' → the request was
rejected even for legitimate allow-listed MIME types. Now:
  1. finfo_file on the temp path (normal \$_FILES + on-disk streams)
  2. fall back to finfo_buffer((string) \$stream) if finfo_file fails
Both are wrapped in @ and a closed finfo resource.

## P2 observability — mkdir + moveTo error logging
\`@mkdir\` hid FS errors (disk full, ENOSPC, permission denied) and
\`moveTo\` could throw mid-flight, leaving the DB un-updated without
any trace. Now mkdir failure logs via SecureLogger::error and returns
early; moveTo is wrapped in try/catch that logs + bails before
touching the row, so a failed upload never leaves a DB/fs mismatch.

## CSS theme-var contract — plugin-local aliases with fallbacks
The stylesheet used \`var(--bg-primary)\`, \`var(--color-primary)\`,
\`var(--text-primary)\` — names that don't exist in the public
frontend layout (which ships \`--primary-color\`, \`--text-color\`,
\`--light-bg\`). Introduced a :root block that resolves
plugin-local \`--archives-*\` aliases via cascading fallbacks
(\`var(--bg-primary, var(--light-bg, #ffffff))\`) and rewrote every
downstream rule to use the \`--archives-*\` names. Archive pages now
pick up the correct theme colours on any Pinakes install.

## Test coverage — two negative MIME-whitelist assertions
tests/archives-upload-assets.spec.js grows two tests:
  9.  Uploads a <?php payload with a .jpg extension; asserts the
      cover_image_path column is unchanged (whitelist catches it
      via finfo, not via the client-provided extension).
  10. Uploads a fake .exe with the MZ PE header; asserts document_path
      stays untouched (application/x-dosexec is not on the document
      mime allow-list).
Also dropped the noisy test 9 'Final summary' that only logged,
and renamed the misleading \`slug\` variable to use \`ids.\${kind}\`
directly — the 301-redirect handling means the numeric id is
sufficient for the request anyway.

## Verification
- PHPStan level 5: clean
- archives-upload-assets: 10/10 green (includes the 2 new negative MIME rejections)
- archives-full + archives-crud + archives-authorities: 43/43 green
@fabiodalez-dev fabiodalez-dev changed the base branch from feature/archives-plugin to main April 22, 2026 06:18
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

✅ Actions performed

Full review triggered.

Add 20 reusable archive document seeds, Playwright coverage, and fixture assets for admin/public routes.

Keep the seed idempotent and persistent so records can be reused across runs.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

✅ Actions performed

Full review triggered.

…#105)

CodeRabbit PR #105 review flagged two nitpicks asking to clarify test
conformance with the project-wide Flatpickr + SweetAlert2 conventions:

- Add explanatory comment before page.fill('date_start'/'date_end') in
  both archives-crud.spec.js and archives-full.spec.js: the form uses
  <input type="number"> (year-only smallint), NOT Flatpickr, so page.fill
  is the correct interaction. Note what to switch to if the UI ever
  migrates to Flatpickr.
- swal2 pattern (.swal2-confirm) is already the exclusive confirm flow
  in both specs (no legacy page.once('dialog') or dialog.accept()
  remains) — the existing "Destructive confirmations go through
  SweetAlert2" block comment already documents that contract.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

✅ Actions performed

Full review triggered.

Resolve ScrapeController hook conflict: keep rawIdentifier in pos 1
(needed by DiscogsPlugin::validateBarcode for Cat# matching), add
cleanIsbn as pos 3 extension for plugins that want the normalised form.
@fabiodalez-dev fabiodalez-dev merged commit 81e8283 into main Apr 22, 2026
1 of 2 checks passed
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