Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

Full version-by-version history for Pinakes. The README shows only the last three releases; everything older lives here.

## [0.9.4]

A patch release for the reader app. No migration.

### Mobile API 1.12.0

- **`GET /me/loans` tells the reader the last day to collect a ready loan.** Each loan carries `pickup_deadline` (`YYYY-MM-DD`) while it is `da_ritirare`: the stored pickup day moved to the next open day of the copy's branch by `LibraryCalendar::effectivePickupDeadline()`, the same day the staff loan reader shows at the desk. The stored day is left as written. The field is `null` for every other state, and for a ready loan without a stored pickup day: the return day is never offered in its place. The OpenAPI `LoanItem` schema documents it.
- The plugin requires Pinakes 0.9.3 or later (`requires_app`), the first version with the effective pickup deadline.

## [0.9.3]

A patch release: reservations follow the closures calendar everywhere, and scraping and imports share one pipeline. No migration.

### Reservations and the closures calendar

- **Nothing expires on a closed day.** A pickup deadline or a queue deadline that falls on a closed day gives the reader the whole first open day after it. Maintenance, the integrity audit and its repair use the same rule (`LibraryCalendar::reservationExpired()`), so the audit no longer reports as expired a reservation maintenance rightly keeps.
- **Dates offered are dates that work.** The request calendar proposes the first open day with a free copy; for a title with copies in several branches at least one of those branches must be open. Admin create and edit move explicit start and return dates off closed days, as the reader's own request does.
- **Capacity reads the effective end.** Capacity, the public calendar and the request calendar count a loan or reservation until its usable return day, as moved by a later closure, so a second promise cannot take the days a closure added. A booking with no copy yet ends on the first day a branch of the title reopens, in every reader.
- **Approval, activation and reassignment check the chosen copy.** The copy's branch must be open and its effective return window free before the loan is committed. When maintenance moves a booking to a copy in another branch, the loan keeps the return day the reader was promised, and waits instead when the title no longer has room for it. Promotion follows FIFO on the promised period but does not stretch it into another reader's promise; the integrity audit shows that conflict to the staff.
- **Pickup, expiry and cancellation use the usable deadline.** Pickup confirmation is refused while the branch is closed or after the effective deadline. NCIP, the web and mobile calendar feeds and the pickup notifications report the same dates.
- **Waiting pickups are reconciled on every maintenance pass.** A pickup whose book is locked by another operation is retried on the next pass while every other pickup still moves, so a retroactive closure cannot make a pickup expire before the reader could collect it.
- **Year-coverage guard.** The "at least one open day every year" check includes the holiday rules, scans the full 400-year Gregorian cycle plus any explicit holiday years, and also runs when a custom holiday is re-activated or edited.

### Scraping and imports

- **Imports use the web pipeline.** CSV import, LibraryThing import, bulk enrichment and cover sync go through the same `ScrapeController::byIsbn()` path as the scraping form: identifier normalization, the `scrape.data.modify` enrichment, the SBN Dewey lookup and the final hooks now run for them too. Bulk enrichment and cover sync keep their three plugin attempts; CSV and LibraryThing imports make one plugin attempt per book, as their chunk timeout requires (in 0.9.2 the built-in fallback called the plugin a second time after a failure).
- **The scraping form accepts an ISBN as typed.** An `ISBN`/`ISBN-13:` label, dots, non-breaking spaces and Unicode dashes are stripped before the lookup; sources receive the compact ISBN.
- **ISBN-10 lookups keep their real identifiers.** The API book scraper maps `isbn13`/`isbn10` to the converted variants of the looked-up ISBN, and `ean` only takes a checksum-valid barcode; a provider's own identifiers still win.
- **Book club reconcile matches ISBN-10 and ISBN-13 of the same edition**, and never uses codes that are not ISBNs (e.g. a music UPC) as match keys.

## [0.9.2]

A patch release for the Android app's visitor log. No migration.

### Mobile API 1.11.0

- **A check-in can name the member instead of the card.** Since 0.9.0 the web visitor log finds a member by name, e-mail, phone or card when the reader forgot the card; the app could only scan a card. `POST /staff/attendance/check-ins` now also accepts `user_id`, a member picked from `GET /staff/users` (the borrower search of the loan desk), resolved with `AttendanceRepository::findMemberById()` under the operator's borrower scope, as the web kiosk does. Exactly one of `card_code` and `user_id` is required (both, or a `user_id` that is not a positive integer, is `422 validation`); a member out of reach is `404 member_not_found` and records nothing. The idempotency key covers the member as it covers the card.
- **`/health` advertises it.** `features.staff_attendance_member_pick` follows `staff_attendance`, so an app can offer the member search only on servers that accept it.

## [0.9.1]

A patch release: updates now check the database against the schema. No migration.

### Updater

- **Missing tables and columns are created after every update.** The updater took a migration recorded as executed as proof that its objects existed. The 0.3.x updater split each migration on `;` and dropped every chunk that began with a `--` comment, so installs it upgraded to 0.4.x recorded `migrate_0.4.0.sql` (and any later migration it ran) while most of its statements never ran: `user_sessions`, `gdpr_requests` and `consent_log` were missing, and so was `utenti.privacy_accettata`, which registration writes, so new readers could not register. A file-only upgrade could skip later migrations as well (on one install, the LibraryThing and catalogue columns of `migrate_0.4.6.sql` and `migrate_0.4.7.sql`). `App\Support\SchemaRepair` reads `installer/database/schema.sql` and creates every table the database lacks and adds every missing column with the schema's definition, after the previous schema column; it never drops, renames or alters anything, and leaves indexes of existing tables alone. A reader with a recorded `data_accettazione_privacy` gets `privacy_accettata = 1` when that column arrives late.
- **It also runs once on the first page load after the files change.** The update that installs a release runs the previous release's updater, so the check runs again on the first request of each new version (marker `storage/.schema-repaired`, serialised with a MySQL named lock); this covers the upgrade to 0.9.1 itself and file-only upgrades. Failures are logged and never block the update or the page.

## [0.9.0]

First stable release of the 0.9.0 cycle. It contains everything in 0.9.0-rc.1, rc.2 and rc.3 below, plus the changes listed here. Upgrading from 0.8.9 runs two additive migrations, `migrate_0.9.0-rc.1.sql` and `migrate_0.9.0.sql`; upgrading from 0.9.0-rc.3 runs only the second. The optional modules follow the upgrade defaults described under rc.1 (Feature toggles).

### Visitor log

- **The card field also finds a member by name.** A reader who forgot the card can be found by name, surname, e-mail, phone or card, with the borrower search of the loan form. Picking a member records the entry for that member, within the operator's borrower scope as a card is; the operator confirms with "Registra ingresso". Enter picks a suggestion only when one is highlighted with the arrows, so a desk card reader that types the code and sends Enter works as before, also while the list is open.

### Loans and closures: the limits left in rc.3

- **A promoted reservation ends on an open day of the branch of the copy it takes.** With a book's copies in several branches the request's end followed the general calendar, because the copy was not known yet. Once the copy is chosen, a day closed at its branch now moves the end to the branch's next open day, when the book and that copy can take the added days; otherwise the requested end stays, as approval already did.
- **The "book available" e-mail names the loan's return day.** It read the day from the request again, so an e-mail sent later (a retry after a failed send) could name the requested day while the loan had been moved, or the general day for a copy in a closed branch. It now reads the loan the promotion created.
- **A refused move re-arms the due-soon reminder once.** When a closure makes the return day later but the move is refused (another reader holds the next days), the stored date stays and the reader's return day changes. A reminder already sent for the old day is now sent again for the new one, and only once: `prestiti.warning_sent_for` (new, `migrate_0.9.0.sql`) keeps the day the reminder announced. Loans warned before the upgrade count as warned for their stored date.
- **A loan whose copy is replaced during a calendar save is still moved.** The save looked only at the branch of the copy found by its first scan and skipped the loan when that branch was open, even if the copy was being replaced by one in a branch closed that day. Every branch holding a copy of the book is now considered before the lock, and the decision is taken on the current row under the lock.
- **The history of a calendar move records the loan as it was.** The "before" state of `loan.due_moved` and of a late flag cleared by the calendar is read with a current read, so a desk edit committed while the save was running is not shown as changed by the calendar.

### Maintenance

- **The npm audit waiver for `braces` is gone.** The advisory no longer reports against the dependency tree (`npm audit` is clean in both trees), so `.github/npm-audit-waivers.json` is empty again.

## [0.9.0-rc.3]

Release candidate: published as a GitHub prerelease, offered only to installations that opt in with `UPDATER_CHANNEL=rc` or `UPDATER_ALLOW_PRERELEASE=1`. No migration and no schema change.

### Loans follow closures added later

- **Existing due dates move.** Saving a closure, a weekly closed day or a holiday in Admin → Calendar now moves every active loan (out, waiting for pickup or booked ahead) whose due date, today or later, falls on a day that is now closed for the copy's branch to the next open day, as a loan made after the change would get; pickup deadlines follow. Each move is recorded in the loan history and re-arms the due-soon reminder. A move the book cannot take (the next reservation of that copy starts on the new date, or the added days are promised to another reservation) is left out and the loan keeps its date. A loan already marked late whose due date is now closed goes back to in progress. When a move is blocked by a lock (the book row held by another transaction) the calendar page warns that the moves are incomplete and the next save or the scheduled maintenance completes them; nothing is half-written.
- **Overdue counts start from the first open day.** A loan due on a day that was closed afterwards is not late until the first open day after it: the overdue sweep, the overdue e-mail, the mobile overdue push and the fine all follow `LibraryCalendar::isPastDue()`. Notices and recalls state the days late in open days, as the fine counts them, and never say "0 days" for a loan that is late.
- **Renewals, reader alerts and native clients agree with the calendar.** A reader can renew on the first usable return day after a retroactive closure, and the renewal counts from that day, as it would had the closure been saved before the due date. A return on that day is not recorded as late. Web lists, reader alerts, the mobile notification feed and the due-soon push use that same deadline. Reader and staff loan responses expose `effective_due_at` and `is_overdue`, while `due_at` retains the stored date for history and editing. Calendar saves also re-read capacity under the book lock, including reservations confirmed by another operator after the initial scan.
- **Recalls and pushes re-read the loan under the lock.** A manual recall or an overdue push claimed on stale data (a loan renewed or returned in the meantime) no longer goes out; the librarian is asked to reload the page, or told that another recall for the same loan has just gone out. A failed release of the calendar lock is retried at once, so the next batch does not wait for the session to end.
- **NCIP, reminder e-mails and exported calendars use the usable return day too.** NCIP renewals count from the same first open day as the desk, including when the old late flag has not been reconciled yet. Due-soon e-mails use that day for both the warning window and the message, checked again under the calendar lock. The library feed, the reader's personal feed and the per-loan calendar file keep an outstanding loan through that day and use its actual overdue status. The staff dashboard's "due today" count uses the same day, including dates whose move was blocked by capacity.
- **The remaining e-mails and staff pages use the usable return day too.** The overdue, recall, fine, receipt (with its PDF), approval, pickup and staff alert e-mails name the first open day after a closed due date, beside the open days counted from it. The admin dashboard, the loan desk, the staff tiles and lists, the reader's home page and the book page show that same day and mark a loan late only once it has gone by; a loan due today is no longer red on the book page, and a cancelled one no longer reads as overdue in its history. Saving a loan without changing its date no longer moves it or resets its reminders, and a renewal of a loan that is not yet late clears an old late flag.
- **Book availability follows the usable return day, by the library's choice.** A loan due on a closed day holds its book through the first open day after it, the day the reader is told, even when the move was refused and the row kept its date. Settings → Loans gains "Same-day handover after a closure": when on, another reader can start on that open day itself. It is off by default. A request's start day now also follows the branch of the book's copies when they all sit in one.
- **Concurrent circulation changes keep their commitments.** A calendar save re-reads the current copy and branch after taking the book lock. Reservations made eligible by a return under a per-material limit, or by a payment settling a debt, remain counted when the calendar checks whether a due date can move.
- **Patron types and accounts save consistently.** Changing the default type, editing it and deleting another type decide from locked rows, so a concurrent default change cannot erase the type's assignments or rules. Creating or editing a reader saves the account and its patron type together: a type removed during the request leaves neither a half-created account nor partially saved account fields.

### Large holdings

- **Book details fit within the default PHP memory limit.** A title with 9,999 physical copies can be opened without exhausting the 128 MiB limit. Rendering uses temporary streams instead of keeping several full HTML documents in memory. The creation regression now checks the complete detail response, and the deep CI gate rejects PHP fatal errors even when browser assertions pass.

### The 0.9.0 admin on a phone

- **Circulation settings.** The patron types table and the patron type × material rules matrix stack as cards below the `sm` breakpoint, with the same inputs; on desktop their labels no longer break mid-word.
- **Patron photo.** On a phone or tablet "Scatta foto" opens the device's own camera app (either lens); the shot is cropped to the card's 4:5 format and scaled to 480 × 600 with the EXIF orientation honoured. Desktops keep the inline webcam.
- **Visitor log.** The card field has the self-hosted camera scanner of the loan desk (Code128 and QR): a card read by the camera records the entry at once, a typed code still waits for "Registra ingresso".
- **Every 0.9.0 page checked at 390 px.** Attendance, closures calendar, stocktake, MARC import, suppliers, patron import, the new settings tabs, the reader record with its ledger and the return with a fine: nothing scrolls sideways, no field is small enough to make iOS zoom, wide tables read as cards with each value under its column name (as real text, for screen readers), the calendar's month arrows sit side by side and a notification count over 99 shows as "99+".

### Older admin pages on a phone

- **Book list, book page and book form.** The row actions stay inside the book list at full size and titles are no longer clamped; the book page's reservations, copies, volumes and loan history stack as cards; the change log wraps long values; the TinyMCE toolbar wraps on several rows; the chip remove cross is readable in both themes; a cover whose file is gone shows no empty frame and keeps "Rimuovi".
- **Long values wrap.** Write-permission paths in Admin → Updates, catalogue URLs in copy cataloguing and the linked records of an archival authority no longer run past the screen edge.

## [0.9.0-rc.2]

Release candidate: published as a GitHub prerelease, offered only to installations that opt in with `UPDATER_CHANNEL=rc` or `UPDATER_ALLOW_PRERELEASE=1`. No migration: the only schema change is in the Mobile API plugin, which widens `mobile_import_jobs.kind` from an ENUM to `VARCHAR(32)` in its own `ensureSchema()` when it upgrades to 1.10.0.

### Everything the 0.9.0 admin does, from the Android app (Mobile API 1.10.0)

- **Stocktake from the phone.** `/api/v1/staff/stocktake`: the create-form options (shelves with their levels, genres, media types, the scan outcomes with the web's colours and sounds), the session list with its counters, opening a session, a light polling state, scanning (each read classified as on the web, replayable with an `Idempotency-Key`), uploading a code list, the paged scans and report rows, closing with or without marking missing copies lost (administrators only, resumable as on the web), cancelling, and the CSV and PDF reports. Open, scan, close, cancel and the reports go through `InventoryController`, so the module check, the administrator check, the messages and the activity log are the web's. `StocktakeService` now returns the id of the scan it records and reads and pages scans by offset and outcome.
- **Patrons and the ledger at the desk.** A reader's patron type (the card expiry recomputed as in the user form), card renewal, card printing and photo; the reader's ledger with balance, accruing fines, movements, payments, charges and waivers (each write retried safely with an `Idempotency-Key`) and the PDF receipt; the fine preview at return and its waiver with a reason.
- **Library settings, patron types, rules and closures.** The module switches, Settings → Loans and the card settings; patron types and the patron type × material rules matrix; the closures calendar with weekly closed days, closures, the public holidays of each country, the library's own holidays and ICS import. Patron types, the rules and the calendar are administrator-only, as on the web.
- **Cataloguing.** Suppliers and the supplier and RDA fields in the book form, catalogue cards as PDF, MARC export and chunked MARC import (driven like the CSV import, with the open jobs listed so the app can resume one started elsewhere), and copy cataloguing over SRU.
- **Visitor log, statistics and patron import.** The visitor log with card reading at the entrance and guests, the statistics page, and patron import from CSV with column mapping, chunked processing and the error report.
- **One gate per area.** Every area answers 404 `feature_disabled` when its module is off and goes through the same controllers and services as the website. An action the account's role does not allow answers 403 `not_permitted`; `forbidden` stays the refusal of the role itself, so the app keeps the staff area open. `/health` reports each 0.9.0 module and one `staff_<area>` flag per area; `/me` gives the reader's patron type. `Permissions` maps the new `/api/v1/staff/*` prefixes to the same permissions as their admin pages (a custom stocktake role reaches the stocktake endpoints and nothing else).
- **Consultation-only copies and the patron type.** Staff copies (copy detail, a book's copy list, a scanned copy) carry `consultation_only`, and `PUT /staff/copies/{id}` sets it through `CopyController::toggleConsultation()`, the book page's own toggle, so a copy held by a loan, a pickup or a request is refused with the same message (409 `conflict`) and a copy made lendable again promotes the waitlist. `GET /me` names the reader's `patron_type` (id, code, name), their own or the library default, null with the rules off or when no type exists.
- **Core support.** The statistics queries moved to `App\Support\LoanStatistics` so the admin page and the app read the same numbers; the loans settings resolver, the circulation-form field limits and the module dependency map are reused by the API instead of copied.

The Android app gains the matching screens in its next release.

## [0.9.0-rc.1]

Release candidate: published as a GitHub prerelease, offered only to installations that opt in with `UPDATER_CHANNEL=rc` or `UPDATER_ALLOW_PRERELEASE=1`. Every new module can be switched off in Settings → Modules and Settings → Loans; an upgraded installation keeps its settings, and stocktake, suppliers, the visitor log, RDA overrides and fines start switched off there (a library that already used one of them on an earlier 0.9.0 build keeps it on).

### Inventory stocktake

- **Stocktake sessions** (Admin → Revisione inventariale, `/admin/inventory`). A session is opened with a name, an optional branch and a filter (bookcase, shelf level, genre with its subgenres, media type, collocation range). Opening it freezes the list of expected copies in `inventario_attese`, books removed from the catalogue excluded, so moving a book, adding or deleting a copy while the stocktake runs does not change what the session looks for. Copies that are not on any shelf when the session opens (lost, damaged, being restored, in transit, in maintenance) are left out of the expected set, so they are not reported missing for the whole session; lent copies and copies waiting for pickup stay in, with a bucket of their own.
- **Scanning** works with keyboard-wedge barcode readers (the code field keeps the focus, Enter records), with the self-hosted camera scanner already used at the loan desk, and with an uploaded `.txt` (one code per line, the whole line is the code) or `.csv` (first column, read with a CSV parser) list. Each read is classified as found, misplaced (found on a bookcase other than the expected one, as declared by "Scaffale in cui stai lavorando"), on loan or awaiting pickup, unknown code, out of scope or repeated, with its own colour and sound. Several operators can scan the same session at once: the counters refresh every few seconds and "Solo le mie scansioni" narrows the list to one's own reads.
- **Live report**: expected copies always add up as found + misplaced + on loan (lent, or set aside on the pickup shelf for a reader) + missing; unknown, out-of-scope and repeated reads are counted on the side. PDF and CSV reports are available while the session is open and after it ends. The report of a closed session is frozen at close: the bucket of every expected copy is written to `inventario_attese.esito_chiusura` (found, misplaced, on loan, missing, or lost when the close marked it so), and a loan or a return after the close no longer moves a copy between buckets in the page, the counters or the exports.
- **Closing** either leaves the missing copies as they are or marks them `perso`. Marking them lost is an administrator action, checked on the server: Staff and custom roles can scan and close a session, but not declare copies lost. Copies on loan or waiting to be picked up (by their state or by an active loan row), damaged, being restored, in transit, in maintenance or already lost are never touched; holds on a copy declared lost move to another copy as with a manual change of state; book availability is recalculated through `DataIntegrity`, and each copy gets a "Copia dichiarata persa in revisione inventariale" entry in the book's activity log. The close records its choice (`inventario_sessioni.azione_mancanti`): when a mark-lost close did not reach every copy (the request died, or a book failed), an administrator completes it from the closed session's report, and running it again is harmless. A session can also be cancelled without changing any copy.
- **Permission** `inventory.manage` for custom roles, and a new starting profile "Revisore inventario" (backend access + stocktakes). Staff and administrators have it already.

### MARC bulk import, export and copy cataloguing
- **Shared MARC library in core** (`app/Support/Marc/`). One neutral `MarcRecord`, streaming ISO 2709 and MARCXML readers and writers written in house (no new dependency), MARC-8 to UTF-8 for Basic and Extended Latin (ANSEL spacing letters and combining diacritics), format detection, and mappers from MARC 21, UNIMARC and danMARC2 to book data. A malformed record, or one in a MARC-8 character set that is not Latin, is reported on its own and the rest of the file goes on. The OAI-PMH server builds its MARCXML, UNIMARC and ISO 2709 records with it (golden files pin the output byte for byte; compared with 0.8.9 the records gain 007, the RDA 336-338 fields and leader/06 by media type, the genre moves from 650 to 655 `$2 local`, every language gets its own 041 `$a` and HTML entities in the summary are decoded), and the z39-server plugin's role reader, danMARC2 reader and UNIMARC reader now live there.
- **Bulk MARC import** (Books → Import → MARC, `/admin/books/import/marc`). A `.mrc`/`.iso` (ISO 2709) or `.xml` (MARCXML) file is read once, a preview of the first ten mapped records says which are already in the catalogue, and the import runs in chunks with a progress bar through the CSV import's own row pipeline: authors and publishers are found with the exact-spelling rules of #501, one copy per book, the import history and its error download work as for CSV. A record matching a book by ISBN-13, ISBN-10 or EAN (deleted books have them cleared, so they never match) is skipped, completed in its empty fields, or overwritten with what the record carries. Covers come from 856. Holdings (852/949/952) are not turned into copies.
- **Bulk MARC export** (Books → Export, and "Esporta in MARC" on a selection). The catalogue or the selected books as MARC 21 `.mrc`, a MARCXML collection or UNIMARC `.mrc`, written while the download is sent, so a large catalogue never sits in memory. Deleted books and desiderata requests are never exported. The records carry the EAN, the LC classification, the Dewey number and the cover, so an export re-imports without duplicates whenever the import can recognise the book: by ISBN-13, ISBN-10 or EAN, or else by title and first author. A book with none of these (a title alone) has nothing to match on and is imported again as a new book.
- **Copy cataloguing** (Books → Import → Copy cataloging, `/admin/books/copy-cataloging`, z39-server plugin). Search by title, author, ISBN/ISSN or all fields in the configured SRU servers, SBN, the Library of Congress, BnF, DNB, K10plus and this installation's own SRU server; look at each record's MARC fields; import the chosen ones with the same duplicate modes and cover download. `SruClient::search()` returns every record of a query. Another Pinakes installation is a preset in the plugin settings, and its SRU server's UNIMARC records now carry the cover in 856 (the MARCXML ones already did).
- **The media type travels in MARC.** A book's media type (libro, disco, audiolibro, dvd, altro) sets leader/06 (a, j, i, g), the 007 category of material and the RDA content, media and carrier types in 336/337/338 (`$a` term, `$b` code, `$2` vocabulary; a book's own RDA codes, when it has them, override the implied ones); UNIMARC records set label/06 the same way. On import the media type is read from leader/06 (a sound recording of performed music is a disco, of spoken word an audiolibro) and RDA codes the media type does not imply are kept as the book's own, once the catalogue has the columns for them; in overwrite mode they replace those of a book already in the catalogue (a DVD record matched to a printed book makes it a DVD), while its format and price are never read from a record. OAI-PMH MARC 21 records now carry leader/06 by media type and RDA 336-338 (the golden files are regenerated).
- **The genre survives a MARC round trip.** A MARC 21 record carries the genre as 655 (ind2 7, `$2 local`), the keywords as 650; before, both were 650 and the genre came back as a keyword. On import a 655 marked `$2 local` becomes the book's genre (created when the catalogue lacks it, as the CSV import does); form headings of other schemes (LCGFT, RBMS) are left alone. In fill mode a book without a genre gets the record's. OAI-PMH MARC 21 records emit the genre as 655 too. UNIMARC does the same with its 606: the genre carries `$2 local` (in the bulk export and in OAI-PMH UNIMARC records), and the UNIMARC import reads that 606 as the genre and the other 606 as keywords. OAI-PMH MARCXML records now declare `xmlns:xsi` on the record, as the oai_dc and UNIMARC ones already did, so a record lifted out of the envelope is a valid document.
- **Corporate and meeting authors are read** from MARC 21 110/111 and 710/711 (`$a` and `$b` joined), with the same roles as personal names; 082 prime marks ("891.73/3") are dropped from the Dewey number.
- **Fill mode completes a family as soon as one of its columns is empty** (an ISBN-10 next to an existing ISBN-13, a series number next to its series) and never overwrites a field the book has.
- **Smaller MARC 21 corrections.** A book in several languages exports one 041 `$a` per language and an import joins every 041 `$a` ("English, Italiano"); an unknown language code is no language rather than a raw code in the field. A monograph's series ISSN (490/830 `$x`) is read when 022 is absent. "2 v." or "24 cm" in 300 is no page count. The number and name of a part (245 `$n` `$p`, UNIMARC 200 `$h` `$i`) are appended to the title. A field over the 9,999-byte ISO 2709 field limit is cut at the last subfield boundary that fits and the cut is logged with the book id; the UNIMARC `.mrc` download of the OAI-PMH plugin shortens a record over 99,999 bytes as the bulk export does. The copy cataloguing presets use `bib.fuzzyIsbn` for the BnF (either ISBN form) and `woe all` for the DNB's all-fields search, a server configuration may name the relation of an index, and a per-record diagnostic in an SRU answer (the BnF's "erreur de traitement") skips that record instead of the whole search.

### Minor features (visitor log, catalogue cards, suppliers, RDA types, statistics)
- **Visitor log** (`/admin/attendance`, sidebar "Presenze"). A kiosk page records each visit: a member scans the library card (or the desk types the code), a guest is recorded with an optional name and reason, and an optional branch when the installation has more than one. The page shows today's visits, visits per day and per hour slot over 7/30/90/365 days, the latest visits with members' names shortened to "Name S.", and a CSV export of the daily counts. New table `presenze`.
- **GDPR retention for the visitor log.** Visits are kept for 365 days by default; an administrator can change the number of days (0 keeps them forever) and apply it immediately. The scheduled maintenance (`cron/full-maintenance.php`) deletes older visits on every run. Deleting a member keeps the visit, anonymised.
- **ISBD catalogue cards.** "Scheda catalografica" on the book page and "Schede catalografiche" on a selection in the book list produce a PDF of 125 × 75 mm cards, one per book: call number, main entry heading (principal author, surname first), ISBD description, standard number and subject tracings. Deleted books are never printed.
- **Suppliers** (`/admin/suppliers`, sidebar "Fornitori"). New table `fornitori` with name, contact, VAT number, website, address and notes; a supplier select in the book form ("Dettagli Acquisizione"), the supplier on the book page and a "Fornitore" filter in the book list. Deleting a supplier keeps its books and clears their supplier (`libri.fornitore_id`, ON DELETE SET NULL). An e-mail or website that is not one is refused by the supplier form with a message, never saved as empty. The MARC and CSV import pages have a "Fornitore" select: the chosen supplier goes on the books the import creates and, for a MARC import in "Sovrascrivi" mode, on the books it overwrites; skipped duplicates and books only completed in their empty fields keep theirs.
- **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME output carries the same effective types: books keep txt/n/nc as before; DVD, disc and audiobook records now emit their real RDA types (tdi/v/vd, prm/s/sd, spw/s/sd) instead of a book's; an override wins over the derived value.
- **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. The outcomes of the last closed stocktake (expected, found, misplaced, on loan, missing, unknown codes) appear once a session has been closed. Loans of the last 12 months by patron type (readers without a type under the default type) come with the circulation policy; fines have their place on the page and appear as soon as that feature provides data (`App\Support\StatisticsSections`).

### Library cards, patron photo and user import
- **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets.
- **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card.
- **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo, also when the account has a loan history and is therefore only suspended. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change.
- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column ("Tipologia") assigns the type by its code or, failing that, by its name (both in any case; a name shared by two types is not guessed), so the users export of another installation with the same types re-imports cleanly; an empty value leaves the reader on the default type, and so does an unknown or deactivated one, reported as a warning with its line. The card expiry follows the user form: when the file gives none, it is computed from the card duration of the assigned patron type (the default type when the row has no type), and an expiry present in the CSV always wins. The separator (";", "," or TAB) is the one that splits the first lines into the same number of columns as the header, quoted fields respected, so a ";" file whose addresses hold commas is no longer read as a one-column "," file.

### Circulation policy
- **One source for the circulation rules.** Loan duration, request window, renewals and the active-loan limit were read straight from the loans settings in about twenty places (staff desk, reader requests on the website and in the app, approval, queue promotion, renewals, NCIP). They now all go through `App\Services\CirculationPolicy`, which returns immutable `UserPolicy`/`LoanPolicy` objects, and every due date is computed by `CirculationPolicy::dueDateFor()`. With only the global settings the numbers are the same as before (a unit test compares every old read site over a grid of stored values); a CI step rejects any new direct read of those settings.
- **Patron types** (Settings → Circulation). Every reader belongs to a patron type (child, student, teacher…) that decides the circulation rules; the role (`tipo_utente`) keeps deciding only the permissions. A type can set the total active-loan limit, loan duration, longest request, renewals and days per renewal, whether reservations are allowed and how many, and the card duration; an empty field inherits the global loans setting. Types are created, edited, ordered and deleted from the new tab, one of them is the default for patrons without a type. The user forms have a "Tipologia lettore" select, the reader page shows the effective rules, the user list filters by type and the CSV export has the column, followed by the type's code ("Codice tipologia lettore") so a round trip through the user import is exact even when two types share a name. Existing users are not migrated: everyone starts on the default "Standard" type, which inherits everything.
- **Rules matrix patron type × material** (`libri.tipo_media`). A cell can make a material consultation only, cap the loans of that material on top of the total limit, and set its duration, renewals and days per renewal; the matrix shows the inherited value of every empty cell in grey. The book page's renew button and its confirmation say the real renewal length of the loan instead of a fixed "+14 giorni". Per field, the first value wins among rule (type, material) → rule (type, every material) → rule (every type, material) → patron type → global settings.
- **Consultation-only copies.** A single copy can be kept for in-house consultation from the book page (`copie.solo_consultazione`): it is never allocated to a loan nor counted as lendable capacity, and a title whose every copy is kept for consultation is consultation only. The book page shows "Solo consultazione" instead of the request button. The availability counter `libri.copie_disponibili` (book page button and badge, catalogue "available" filter, OPAC and mobile availability) is recalculated without the consultation copies, so a title whose only copy on the shelf is a consultation copy offers "Prenota quando disponibile", not "Richiedi prestito"; `copie_totali` keeps counting every physical copy in circulation. Existing installations get the counters recalculated by the upgrade (the Updater re-derives availability after the migrations) or from Maintenance → "Ricalcola disponibilità".
- **The same rules on every channel.** Staff desk, reader requests, approval, queue promotion, renewals, the Mobile API and NCIP all refuse with the same codes: `media_limit_reached`, `not_loanable`, `reservations_disabled`, `reservation_limit_reached` (Mobile API `CirculationOutcome`, NCIP *Item Does Not Circulate* / *Maximum Check Outs Exceeded*). `GET /api/v1/catalog/books/{id}/availability` gains `loanable` and `reservations_enabled`. NCIP RequestItem due dates are now capped by the maximum loan duration like every other channel (they used to take the bare loan duration).
- **Reservation limits per patron type.** A type without reservations does not see "reserve when available" on the book page and a request that would join the waitlist is refused (`reservations_disabled`); `max_prenotazioni` counts the active queue reservations plus the loan requests still awaiting approval (`reservation_limit_reached`). Website, app, NCIP RequestItem and the admin reservation form apply them; the admin form can override them with an explicit "Ignora i limiti di prenotazione" tick (`override_limits: true` from the staff app).
- **Card duration of the patron type.** When the type sets a card duration, the card expiry is computed on creation and edit ("Calcola la scadenza dalla durata tessera", on by default when creating, where it fills an empty date only: a date typed by the librarian wins), on self-registration and on registration approval; the date can still be set by hand. The reader page has a **"Rinnova tessera"** button: expiry = today + the type's duration (one year when the type sets none), an expired account becomes active again, and the renewal is in the activity log.
- **"Tessera in scadenza" email** (`card_expiring`, five languages). Sent `loans.card_expiry_warning_days` before the card expires (Settings → Loans, default 0 = off) by `cron/automatic-notifications.php` and `cron/full-maintenance.php` (both report the count), once per expiry date (`utenti.card_expiry_warning_sent_for`); a renewed card re-arms it. Fresh installations get the template seeded in the five `data_*.sql` files (row 29) like every other template; upgrades use the shipped text until the template is saved.
- **Administrators only.** Patron types and the rules matrix (Settings → Circulation, the type pages and every form behind them) are reserved to administrators and to custom roles with the settings permission; a staff account is sent back to the dashboard and nothing changes. For staff the Circulation section is absent from the settings navigation and `/admin/settings?tab=circulation` opens the default section without the circulation panel.

### Closures calendar
- **The library's closed days.** A new admin page, Admin → Calendario chiusure (`/admin/calendar`, also linked from Settings → Loans), holds the weekly closed days (`loans.closed_weekdays`) and the closures: single days or periods, once or every year, for every branch or for one. Public holidays come from a country preset (see below), any other calendar can be imported from an ICS file, and a monthly preview shows which days are open.
- **Due dates land on an open day.** The shift lives in `CirculationPolicy::dueDateFor()`, so the per-patron-type durations and renewal days of the circulation policy land on an open day too. A due date that falls on a closed day moves to the next open day: admin loans (proposed and saved), loan edits, reader requests from the website and the app, reservations and their promotion to a loan, renewals and bulk extensions, NCIP CheckOut, RequestItem and RenewItem. A closure of one branch counts for the loans of its copies: when the staff desk lends a copy, an NCIP CheckOutItem binds one, or a request is approved onto one, the due date moves to that branch's next open day (at approval only if the longer window still fits; the NCIP checkout is refused, like the desk's, when the longer window no longer fits). A refused renewal proposes the last open day before the conflict.
- **Pickup days are open days.** The pickup deadline counts only the days the library is open, and the nightly sweep expires no pickup, no scheduled loan and no queue reservation on a closed day; a deadline that falls on a day closed afterwards moves to the next open day when the calendar is saved, so the desk can still hand the book over that day, but never past the loan's due date (a pickup after the loan window is refused), which the calendar save does not move.
- **The request calendars know the closed days.** On the book page and in the admin loan form a closed day cannot be picked as the start (a request that reaches the server with one starts on the next open day); the availability payload carries `closed_dates`, on the website and in the Mobile API `GET /catalog/books/{id}/availability` (additive field). A request auto-approved on a closed day is reported by `POST /reservations` with its real state, `prenotato` (new enum value) with the start day in `start_date` (additive), instead of `da_ritirare`.
- **ICS imports meet the form's limits.** Every event is checked like a closure typed in the form (a yearly closure within a year, a one-off within ten years, end not before start); the import message counts the events discarded. A yearly closure of a year or more, however stored, is applied once as the interval it names and never closes every day; a one-off closure of any admissible length (up to ten years) is stepped over whole, so a date inside it lands on the day after it ends; an ICS date-time in UTC is read in the application timezone. The calendar page follows the settings permission (`settings.manage`), so a custom role with that permission can use it.
- With no closed weekday and no closure configured every date is computed exactly as before.

### Public holidays for every country, and the library's own holidays
- **Pick the country, then the holidays.** The holiday card of `/admin/calendar` replaces the Italian-only import: the librarian chooses the country (Italy, United States, United Kingdom, Ireland, Germany, France, Denmark, Spain, Portugal, Netherlands, Belgium, Austria, Switzerland, Sweden, Norway, Finland, Poland, Canada, Australia, Brazil, Argentina, Mexico; preselected from the installation language) and, where holidays differ, the region (German Länder, UK nations, Canadian provinces and territories, Australian states and territories, Alsace-Moselle, six Swiss cantons), sees every holiday with its native name and its date in the preview year, ticks the ones the library closes for (select all / none; customary holidays such as Christmas Eve start unticked) and applies.
- **Rules, not dates.** Fixed dates, Easter offsets (Good Friday, Ascension, Whit Monday, Corpus Christi, Carnival…), the nth weekday of a month (Thanksgiving, Memorial Day, bank holidays), the weekday on or before/after a date (Victoria Day, Buß- und Bettag, Midsommar), the weekend substitutes of the US, UK, Ireland, Canada and Australia (including Christmas and Boxing Day both on a weekend), Koningsdag, the Argentine movable holidays, first and last years (Store bededag until 2023, Juneteenth from 2021, San Francesco in Italy from 2026, Wigilia in Poland from 2025) and the one-year moves of the early May and spring bank holidays (VE Day 2020, the Jubilees).
- **Applying is idempotent and selective.** The choice is stored in `calendar.holiday_country`, `calendar.holiday_region` and `calendar.holiday_rules`; each ticked holiday becomes closures marked with its rule (`calendario_chiusure.regola_festivita`): a fixed date as one yearly closure, any other holiday dated per year up to two years ahead, extended every year by the maintenance run (`holiday_rows_added` in the cron log). Applying again adds nothing, unticking removes only that holiday, switching country replaces the preset; closures added by hand or imported from ICS are never touched. Holiday rows show a "Festività" badge in the list and are changed from the selection only. Rows stored by the former Italian import are recognised and adopted, not duplicated.
- **Holiday rules also cover distant and historical dates.** The dated rows are the admin preview, not a limit on loan policy: due dates, pickup days, renewal caps, availability and late-fine days evaluate the selected rules for the year requested, including an observed New Year that falls in December of the preceding year. Switching the calendar back on works immediately, without waiting for maintenance. Applying a selection and yearly upkeep share a transaction lock and current reads, so a concurrent save cannot restore an unticked holiday or duplicate its rows; manual and ICS closures remain independent.
- **Yearly closures on and off.** The yearly closures added by hand or from ICS are listed in their own card and can be suspended and resumed without deleting them (`calendario_chiusure.attiva`); a suspended closure stays in the list with a "Sospesa" badge and closes no day.
- **Every country of the world.** Besides the 22 hand-written rule tables, the country list holds every country and territory (250) with its official holidays from the open data of the python-holidays project (MIT licence, `app/Support/Holidays/data/`, years 2010-2060, regenerated by `scripts/generate-holiday-dataset.py`): Islamic, Chinese, Hindu, Buddhist, Orthodox and national holidays alike, in the country's own language with the English name beside it. Holidays of public offices, banks, schools, half days and those of a single community (Islamic, Hindu, Jewish, Orthodox, Yazidi…) are offered unticked with their category; dates that depend on the sighting of the moon are marked as estimated. Country names follow the interface language.
- **A chosen holiday that vanished is said, not silently dropped.** When a stored holiday id is no longer in the rule table or the dataset (a regeneration accepted with `--accept-removed`), the holiday card shows which chosen holidays close nothing any more, so the librarian can tick their replacements and save again; `HolidayPresets::selection()` lists them as `missing`.
- **Days in lieu close both days.** When a weekend holiday gets a substitute (US, UK, Ireland, Canada, Australia), the library is closed on the holiday itself and on the substitute; holidays that are moved instead (Koningsdag, the Argentine movable holidays) keep only the new date. Easter Sunday in Western Australia (from 2022), the Northern Territory (2022) and South Australia (2024), and the extra Monday of a weekend Anzac Day in Western Australia.
- **The library's own holidays.** A new card "Festività della biblioteca" (table `festivita_personalizzate`) adds holidays the official lists do not have or that the community celebrates: the same day every year, the nth weekday of a month, days from Western or Orthodox Easter, a date in the Islamic (Umm al-Qura), Hebrew, Chinese (published lunar table, 1900-2100), Persian, Indian national, Ethiopian or Coptic calendar, or dates listed by hand; one or more days, a weekend rule, first and last year, every branch or one. They apply to every year, can be suspended, edited and deleted, and show their next dates. An installation that defines none behaves as before.

### Fines, patron ledger and debt block (plan §7)
- **Late fines.** Settings → Loans → "Multe e blocco per debito": daily fine, grace days, maximum fine per loan. These are the global layer of `CirculationPolicy`: a patron type (Settings → Circulation) can set its own daily fine, grace days, cap per loan and debt threshold, and a cell of the rules matrix its own fine, cap and grace for one material; an empty field inherits, 0 switches fines (or the block) off for that type or material. Late days are the days the library is open (closures calendar: weekly closed days, closures, yearly closures, branch closures for the copy's branch); with no closure configured every day counts. The grace period is a threshold (SLiMS rule): up to the grace days nothing is charged, beyond it every late day counts from the first. With the daily fine empty or 0 (the default) nothing changes. The three amounts accept up to 9999.99 (the form says so when a larger figure is typed), and the fine of one loan never exceeds what the ledger stores (99,999,999.99), however long the loan was overdue. The fine is written at return, in the same transaction as the close, on every return path (return screen, quick return, NCIP check-in), and only once per return (idempotency key `late:<loan>`). Fines accruing on open overdue loans are shown on the loan, the reader record, the reader's own page and the app, never written in advance.
- **Patron ledger.** New append-only table `movimenti_lettore` (charges, payments, waivers, reversals); the balance is the sum of the rows. The reader record has a "Movimenti" section: balance, accruing fines, list, register a payment (cash, card, transfer, other) with a PDF receipt, manual charge (description required), waiver (reason required). Each desk form is recorded once even when it is sent twice (the same form sent again with another amount, method or text is refused with "Richiesta già registrata" instead of being answered with the earlier movement; a page restored from the browser cache gets a fresh form), and a long ledger is shown 200 rows per page. Recording a payment, a charge or a waiver and downloading a receipt are loan-desk actions: they need the loans permission (`loans.manage`, as Staff has), so a custom role that only manages readers sees the ledger read-only. The return screen shows the fine before the confirmation and can waive it on the spot, with a reason; a waived fine no longer appears in the return email, the fine email or on the loan page. A reader with ledger rows is suspended instead of deleted.
- **Debt block.** With a threshold set, a reader who owes more than it gets `debt_block`. The threshold is the one of the reader's patron type; a reader without a type (or whose type no longer exists) is governed by the default type; a type whose field is empty inherits the global `debt_block_threshold`, and 0 switches the block off for that type. A reader who owes more than the applicable threshold gets `debt_block` ("Saldo a debito oltre la soglia consentita…") on loan requests, admin loans, approvals, renewals and the reservation queue, on the website, the Mobile API and NCIP.
- **Reports and email.** Statistics: charged, collected, waived and reversed per month with CSV export. Return email: new placeholder `{{multa}}` (empty when nothing was charged); an upgraded install whose stored "Restituzione confermata" template is still the previously shipped text gets the line when Settings → Email templates is opened, a customised template is left as it is. New optional email "Multa per ritardo addebitata" (`fine_charged`), off by default.
- **Mobile API 1.9.0** (requires 0.9.0): `GET /me/movements` with balance, movements and accruing fines; `debt_block` arrives as `not_eligible` with `meta.reason` (and as `eligibility_reason` in the staff loan lists). The same plugin version carries the 0.9.0 circulation changes: `closed_dates`, `loanable` and `reservations_enabled` in `GET /catalog/books/{id}/availability`, the 409 codes `media_limit_reached`, `not_loanable`, `reservations_disabled` and `reservation_limit_reached`, and `override_limits` on the staff reservation form.
- `app.currency` is now read from the database too (it was a code default only).
- **Fine settings are administrators only.** Staff can still save the Loans tab, but the daily fine, grace days, cap per loan, debt threshold and fine email stay as they are unless an administrator changes them; the fields are read-only for staff.

### Feature toggles
- **Every optional 0.9.0 module has its own switch** (`features.*` in `system_settings`, `App\Support\Features`). A new Settings → Modules tab, administrators only, switches stocktake, MARC import and export (with copy cataloguing), catalogue cards, suppliers, RDA types, library cards and patron photos, user CSV import and the visitor log; the Loans tab switches the rules by patron type, the closures calendar and fines with the patron ledger, next to their settings. Staff sees the Loans switches locked and does not see the Modules tab; a hand-built post is refused by an inline `mayConfigure()` check.
- **A module switched off is gone**: no sidebar or menu entry, no button, no form field, no settings section; its routes answer as catalogue mode does (302 to the dashboard with "Funzione disattivata nelle impostazioni.", or 404 `feature_disabled` for JSON callers) through `FeatureGateMiddleware`, and the controllers re-check. The services carry the switch for every channel at once: with patron type rules off only the global loan rules apply and consultation-only copies are lendable; with the calendar off due dates ignore closures, the holiday card and the yearly closures card are gone with the calendar page (their posts are refused) and the maintenance run adds no holiday row until it is switched back on; with fines off nothing is charged, nobody is blocked for debt, previews, balances and the fine email are gone and `prestiti.sanzione` is written as before; with RDA types off the stored overrides are kept but exports derive the types from the material; with suppliers off a book save or an import never touches `libri.fornitore_id`; with the visitor log off maintenance purges nothing. Each statistics section follows its module. Data is never deleted: switching a module back on shows it again.
- **Defaults.** A fresh install starts with everything on except the visitor log (personal data) and fines (money). An upgrade keeps on what is reachable only from an explicit action (MARC, catalogue cards, library cards, user import, patron type rules, closures calendar) and starts off what adds sidebar entries, cataloguing fields, money or personal data (stocktake, suppliers, RDA types, visitor log, fines), unless the library already has data for the module, which then stays on. A missing row behaves as the upgrade default.
- **Catalogue mode** still wins: it also switches off the visitor log, the closures calendar, fines and patron type rules, and the Presenze sidebar entry, which used to stay visible in catalogue mode, now disappears.
- **Mobile API**: `/health.features` gains `fines`, `closures_calendar` and `circulation_rules`; with fines off `GET /me/movements` answers 404 `feature_disabled`.

### Fixes
- **Installer: no false "triggers missing" warning.** Every fresh install ended on a page saying the database user could not create triggers and asking for `installer/database/triggers.sql` to be run by hand, even when every trigger had been created: the importer counted a trigger only when its chunk began with `CREATE TRIGGER`, while each chunk begins with comments and a `DROP TRIGGER`. The count now finds the statement wherever it sits, and when a trigger really fails the final page says that some triggers could not be created, instead of blaming the database user's permissions, and shows the database's own message (escaped) under it.

### Migration

- `migrate_0.9.0-rc.1.sql`, section "MARC bulk import / copy cataloguing": `import_logs.import_type` gains the value `marc`, appended to the enum computed from the current one, so the order of the sections does not matter. Idempotent; existing rows are untouched.
- `migrate_0.9.0-rc.1.sql`, section "Inventory stocktake": creates `inventario_sessioni` (with `azione_mancanti`, the close's choice), `inventario_attese` (with `esito_chiusura`, the bucket frozen at close, and `codice_atteso`/`titolo_atteso`, the copy code and title frozen at opening so a copy deleted mid-session stays identifiable in the report; each added with a guard to a table an earlier build created) and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes.
- `migrate_0.9.0-rc.1.sql`, section "minor features": creates `presenze` (visitor log) and `fornitori` (suppliers), adds `libri.fornitore_id` (ON DELETE SET NULL) and the nullable `libri.rda_content_type`, `rda_media_type`, `rda_carrier_type` overrides. Additive and idempotent.
- `migrate_0.9.0-rc.1.sql`, section "patron-cards-import": `import_logs.import_type` gains the value `users`; the new enum is computed from the current one, so `marc` and any value added by another section are kept.
- `migrate_0.9.0-rc.1.sql`, section "Circulation policy": creates `tipologie_lettore` and `regole_prestito`, seeds the default "Standard" type with every rule NULL, adds `utenti.tipologia_id` (FK `ON DELETE SET NULL`), `utenti.card_expiry_warning_sent_for` and `copie.solo_consultazione`. Idempotent; no existing row changes.
- `migrate_0.9.0-rc.1.sql`, section "closures calendar": creates `calendario_chiusure` (closure intervals, optional yearly recurrence and branch, FK to `sedi` ON DELETE CASCADE); the weekly closed days are the setting `loans.closed_weekdays`, no schema. Additive and idempotent.
- `migrate_0.9.0-rc.1.sql`, section "holiday presets": adds `calendario_chiusure.regola_festivita` (varchar(64) NULL, indexed: the holiday rule a preset row stands for, NULL for manual and ICS rows) and `calendario_chiusure.attiva` (default 1). Guarded on `information_schema`, idempotent; existing rows stay manual and active until the first apply adopts the former Italian ones.
- `migrate_0.9.0-rc.1.sql`, section "fines-ledger": creates `movimenti_lettore` and converts every existing `prestiti.sanzione > 0` into one charge (`addebito_perdita` for a lost copy, `addebito_danno` otherwise, key `penalty:<loan>`), in batches; maintenance converts anything past the batches and records the scan as complete only once the table exists and a pass has run to the end. Until the migration has run, the queue selection and the eligibility check carry no debt predicate. `prestiti.sanzione` stays and is still written.
- `migrate_0.9.0-rc.1.sql`, section "feature toggles": one `features.<key>` row per module with the upgrade default, insert-if-missing (a saved value is never overwritten); stocktake, suppliers, visitor log, RDA types and fines start on when the library already has sessions, suppliers, visits, RDA overrides on live titles, ledger rows other than the converted legacy penalties, or a configured daily fine. The fresh-install defaults are in the five `data_*.sql` seeds.

## [0.8.9]

### Security
- **DOMPurify 3.4.17** in the frontend bundle, for GHSA-cg2g-xh64-v4q7 (critical), GHSA-mrv2-3pqr-j5jr and GHSA-c6gr-qvv4-5v3f, published on 22 October 2026. Only `public/assets/vendor.bundle.js` changes.

### Added
- **Search engine verification field** ([#497](https://github.com/fabiodalez-dev/Pinakes/pull/497)). Settings → Advanced → "Verifica motori di ricerca" takes the `<meta>` verification tags exactly as Google Search Console, Bing, Yandex, Pinterest or Facebook hand them out and prints them in the `<head>` of every public page. Only the `name` and `content` of a verification tag are kept; scripts, `robots`, `description` or anything else pasted there are dropped, and the admin is told how many lines were ignored. The custom JavaScript fields could not do this: they print inside `<script>`.
- **Pickup reminder** ([#498](https://github.com/fabiodalez-dev/Pinakes/pull/498)). A new "Promemoria ritiro" email goes out `loans.pickup_reminder_days` before the pickup deadline (Settings → Loans, default 2, `0` turns it off). It is sent once per deadline, only after the "ready for pickup" notice was delivered and never on that same day; a failed send is retried and a moved deadline re-arms it. The template exists in the five languages and is editable in Settings → Email templates.
- **The staff is told when a pickup expires** ([#498](https://github.com/fabiodalez-dev/Pinakes/pull/498)). The maintenance sweep leaves an in-app notice with the book, the borrower and the deadline, written in the same transaction as the expiry.

### Changed
- **"Loan approved" says how long the reader has to pick the book up** ([#498](https://github.com/fabiodalez-dev/Pinakes/pull/498)). New placeholders `{{giorni_ritiro}}` and `{{scadenza_ritiro}}` (start date + pickup days, never past the end of the loan) are available in every pickup email. The new default text ships to **new installations only**: a template already stored in the database is never rewritten, so to use them on an existing installation add them in Settings → Email templates. The book page's message for a scheduled loan names the start date, the pickup days and the deadline.
- **The loan request calendar fills its row** ([#497](https://github.com/fabiodalez-dev/Pinakes/pull/497)). On desktop the date pickers were a fixed 308px box in the popup; they now take the full width at every size.

### Fixed (SEO)
- The 404 page is `noindex`, has its own title and no canonical to the missing address. A page past the last one of the catalogue, an author, a publisher, a genre or the events answers 404 instead of an empty 200, and an address with a trailing slash answers 301 to the one without it.
- Catalogue pages 2+ have their own title and description; author biography and photo reach the meta description, `og:image` and the `Person` schema; publisher pages publish an `Organization`; contact, privacy and cookie pages describe themselves; `inLanguage` is a BCP 47 code; the home always publishes a `Library` entity; descriptions stay on one line, also in `llms.txt`; events and Emeroteca issues get a working `og:image`.
- The sitemap no longer lists `feed.xml`, lists parent genres whose books sit in subgenres, gives publishers and genres a `lastmod`, and leaves events out when the events page is switched off.

### Migration
- `migrate_0.8.9.sql` adds `prestiti.pickup_reminder_sent_for` (nullable `DATE`), additive and idempotent. The notification service also adds it on its own if it is missing.

## [0.8.8]

### Fixed
- **The French loan-window sentences include the last day** ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)). "retour avant le D" excluded the day the server still accepts as the return date; it now reads "retour au plus tard le D", as the Italian "entro il" does, in both the plural and the singular sentence. The book-club due date says "À rendre au plus tard le D" for the same reason.

### Tests
- The two database-backed loan-window suites pin the default and maximum loan durations for their run and restore the installation's values afterwards, so they no longer depend on what the environment configures; the capped-loan browser spec restores the title's derived columns that an auto-approved request recomputes.

No migration, no plugin change. 0.8.7 is still the release that carries the PHP security notice for CVE-2026-109618 and CVE-2026-109619 ([#491](https://github.com/fabiodalez-dev/Pinakes/issues/491)).

## [0.8.7]

### Security
- **Administrators are told when the server's PHP lacks the fixes for CVE-2026-109618 and CVE-2026-109619** ([#491](https://github.com/fabiodalez-dev/Pinakes/issues/491)). PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, released on 2026-10-21, fix two unauthenticated remote code execution flaws in the PHP runtime. They do not depend on Pinakes and no Pinakes update can fix them: PHP has to be updated on the server. The dashboard and the updates page now show administrators a notice with the running version, the release to move to, the interim `session.upload_progress.enabled = 0`, and that some distributions backport the fixes without changing the version. `php.ini.recommended` turns session upload progress off; Pinakes does not use it.

### Fixed
- **A loan request stops before a scheduled loan and says how long it can be** ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)). When the only copy has a scheduled loan or a reservation inside the default loan window, the book page proposed a return date that fell on a blocked day and the request was refused with a bare "no copies available". Every surface that creates or edits a loan window (book page, user area, Mobile API, admin create, approval, date edit, admin reservations) now caps the window at the day before the conflict and says, in the reader's language, the longest possible loan and the day the book is booked from.
- **A renewal says until when it is still possible, and can renew until that day** ([#493](https://github.com/fabiodalez-dev/Pinakes/pull/493)). A renewal that crossed a scheduled loan on every copy was refused outright on the admin page, the bulk extension, the Mobile API and NCIP RenewItem. It now names the furthest possible due date and how many days that is, the loans list and the book page offer "Rinnova fino al D", and `POST /staff/loans/{id}/renew` accepts a `new_due_date`.
- **Copies received through Desiderata get the book's own inventory prefix** ([#489](https://github.com/fabiodalez-dev/Pinakes/issues/489), [#494](https://github.com/fabiodalez-dev/Pinakes/pull/494)). The plugin built them on `LIB-<id>` and ignored "Prefisso inventario copie", and an inventory code cannot be changed afterwards. One helper, `CopyRepository::inventoryBase()`, now gives the rule to Desiderata, book creation, the copies form, the increase-copies endpoint and the Mobile API. Existing codes are not renamed.
- **`storage/digital` keeps its group write bits after an upgrade.** At the end of every upgrade the updater reset every folder under `storage/` to 0755, so an installation already on 0.8.6 lost the group write bits of `storage/digital` that the maintenance cron needs when it runs as another user than the web server. The updater now gives them back, and the first request after upgrading to 0.8.7 repairs a folder the previous updater already reset.
- **The "Whoever has the book on loan" visibility says that authorized users open the file too** ([#492](https://github.com/fabiodalez-dev/Pinakes/pull/492)). The access rule was already so; only the description in the Digital Library settings left them out.

Plugin versions: Desiderata 1.2.1 and Mobile API 1.8.2, both requiring 0.8.7 (they call `CopyRepository::inventoryBase()`). No migration.

## [0.8.6]

### Added
- **Who may open each Digital Library file** ([#480](https://github.com/fabiodalez-dev/Pinakes/issues/480)). Every attachment can be open to everyone, to signed-in readers, to authorised operators and readers, or only to the reader who has the book on loan right now; "Default" follows a library-wide setting in Administration → Plugins → Digital Library. A locked file keeps its card on the book page with the reason (sign in, borrow the book), or can be hidden; staff-only files are never listed to anyone else. The book page, the file route, the public API, OAI-PMH and the Mobile API all ask the same core decision, also with the plugin switched off.
- **Files outside the web root.** New uploads live in `storage/digital/`; their `/uploads/digital/<name>` address is served by Pinakes after the check, with Range, HEAD, ETag and If-Range for players, and optional `DIGITAL_LIBRARY_SENDFILE=apache|nginx`. Files of older versions move out of `public/uploads/digital/` on their own (page visits, the maintenance cron, plugin activation, or "Sposta ora"), verified, subfolders included, and stay protected while they wait.
- **Custom roles and a permission matrix** (Administration → Roles and permissions). Backend access, cataloguing, loans, managing digital attachments (which includes opening them), opening restricted attachments, users and settings are separate choices; three editable presets are seeded: Catalogatore, Addetto prestiti, Archivista. A role is assigned from Users → Edit and keeps the Standard or Premium membership. A reader can also receive only the individual grant to open restricted files, without backend access. Changes apply on the next request, existing sessions and app links included.
- **App links** for the Mobile API: signed, 24–25 hours, ended at once by a password change or by "Revoca tutti i link dell'app". A file uploaded from the app is attached to the title.

### Security
- Settings delegated to a custom role cover the ordinary tabs only: the advanced tab, mail transport and templates, and API keys stay with administrators. Only administrators delete accounts, or create, promote and remove administrators; exporting the account list stays with administrators and Staff, and a custom users manager lists, counts and searches only the readers it may edit.
- Upgraded installations get `[NC]` on the private-uploads rewrite of `public/.htaccess`, so `/uploads/Digital/...` cannot skip the check on a case-insensitive disk.

### Fixed
- Seeking forward in an audiobook (an open-ended `Range` request) sent the first bytes of the file under the requested range.
- **Every older `public/.htaccess` is healed, not only the 0.7.71 one** ([#483](https://github.com/fabiodalez-dev/Pinakes/pull/483)). The file is kept across upgrades, so installations first set up before 0.7.71 still served private uploads straight from disk (before 0.7.18 always, up to 0.7.70 under a subfolder). On the first request after the upgrade each shipped shape becomes the 0.8.6 pair: the private rule with `[NC]` first, then the public passthrough. Lines an operator edited, and blocks under a pending `RewriteCond` or chain, are left alone; a backup is kept next to the file.
- Digital Library files uploaded on an older release keep working after the upgrade, both from the attachments list (0.7.89+) and from the single eBook and audiobook fields of older releases.
- A bundled plugin that is installed but switched off now gets its existing tables brought to its version on upgrade, without being switched on: an install upgraded from 0.7.x kept NCIP's `ncip_transactions` without `prenotazione_id` until NCIP was activated.
- **Downloads of older Digital Library links** ([#484](https://github.com/fabiodalez-dev/Pinakes/pull/484)). Attachments saved with a relative path, percent-encoded or JSON-escaped address showed on the book page and in the API but answered 403 when opened. Catalogatore and Archivista accounts can use the FRBR work and expression lookups of the book editor.
- **The return date of a loan request defaults to the configured loan duration** ([#485](https://github.com/fabiodalez-dev/Pinakes/pull/485)). A reader who sent the request without touching the calendars asked for a one-day loan. The Mobile API availability calendar now reports `loan_duration_days`, so the app shows the return date before the request is sent.
- **Lighter public pages** ([#486](https://github.com/fabiodalez-dev/Pinakes/pull/486)). The JavaScript translation table is no longer inlined in every page: it is a cached `/i18n/<locale>` script that carries only real translations (an Italian home page went from 828 KB to 196 KB). The sitemap lists CMS pages at their route instead of a redirect and leaves out names with a slash; the home title no longer repeats the site name; descriptions have no raw line breaks; a book without a cover uses the site's social image; `llms.txt` names the site language only.
- **The mail template editor says which mail an approved loan sends** ([#487](https://github.com/fabiodalez-dev/Pinakes/pull/487)). Approving a loan that starts today sends «Pronto per il ritiro», not «Prestito approvato»; both descriptions now say when each one goes out, and the expiry reminder names the advance set in the advanced settings instead of "three days".

Plugin versions: Digital Library 1.5.0, Mobile API 1.8.1, OAI-PMH Server 1.1.2 (each requires 0.8.6-rc.1 or later). Migration `migrate_0.8.6-rc.1.sql`: additive; creates `ruoli`, adds `utenti.ruolo_id` and `utenti.accesso_digitale`, seeds the presets, and leaves every existing account exactly as it was. It runs when upgrading from 0.8.5 or earlier; an installation already on 0.8.6-rc.1 has it and gets no migration.

## [0.8.5]

### Added
- **A staff area in the Android app** (Mobile API 1.7.0, admin and staff accounts only). Cataloguing: books with ISBN/EAN lookup through the full scraping pipeline and duplicate detection, authors, publishers, genres, shelves, per-copy management, series, merging duplicates, label PDFs, CSV and LibraryThing import, cover download and bulk enrichment. Circulation: desk counters, loans and reservations with every action the web desk has (approve, pickup, return, renew, reminders, receipts), return by scanned copy code, and an `Idempotency-Key` so a retried request never lends a second copy. Plugin content: desiderata, archives, the periodicals desk, book clubs, FRBR works and digital files. Every write goes through the same controllers as the web forms, so hooks, activity log and emails behave the same.
- **An offline catalogue for the app** (`GET /api/v1/catalog/sync`): a delta feed with deletions kept for 30 days. Renaming or merging an author, publisher or genre marks the linked books as changed, so the next sync carries the new names.
- **A personal loans calendar**: each reader can subscribe from the app to an ICS feed of their own loans and reservations, without personal data. The link can be revoked and is revoked with the other credentials on a password change.
- **The Mobile API answers in the app's language** (`Accept-Language`, echoed in `Content-Language`) when the installation has that language.
- Registration settings can hide gender and tax code everywhere, the Mobile API included.

### Fixed
- **The library calendar no longer publishes borrowers.** The anonymous `/calendar/events.ics` listed every borrower's name and email; it now carries the same events without personal data. Admin and staff get a personal link from the dashboard, checked on every request, and an admin can regenerate it. The static copy under `storage/calendar` is rewritten once if it still holds personal data.
- Renaming a series by changing only its case or accents deleted the series and its memberships.
- A failed publisher update shows an error instead of a 500, without logging the raw database message.
- Author and publisher pages have padded cards and whole covers on phones; the month view of every calendar works on phones, and the calendar's remaining English labels are translated.
- On Admin → Updates an update that can be installed reads in green again: the 2026 admin palette had turned the new version, its icon and the banner the same grey as the installed one.

Plugin versions: Mobile API 1.7.0, Book Club 1.5.0, Emeroteca 1.15.0. No core migration; Mobile API creates its three new tables (calendar tokens, idempotency keys, import jobs) on its own when it updates.

## [0.8.4]

### Fixed
- **Switches show their state again.** In the administration every switch (cookie banner, settings, plugins) kept a white track whether on or off, on phones and on desktop alike: the 0.8.3 shared surface rule painted the track too. An active switch is dark with the knob on the right again.
- **The administration fits a phone.** Below 768px the filter headers and actions of Users and Loans, the loan status chips, "Mark all as read" in contact messages, the GitHub token status in Updates and the custom registration field labels each take a full row instead of squeezing beside their neighbours; "Clear filters" and "Apply filters" no longer overlap. Cards whose padding the phone stylesheet removed (genre quick add and merge, archives, periodical mode and issues, article citation, language danger zone, table pagination) keep their fields off the border.
- On phones the loan PDF and the books export are icon buttons, with their name kept for screen readers; the description and the first button of the Users and Loans headers have room between them.
- **Share buttons in Settings are black with a white logo**, in the list and in the preview, instead of each network's colour.
- **The book page lines up at any zoom.** The authors start at the left edge, as the "Request loan" button does, and the availability icon sits on the same edge as the button also on phones whose page zoom gives a viewport between 641 and 767px.

No migration.

## [0.8.3]

### Added
- **Several publishers and responsible editors per periodical** ([#467](https://github.com/fabiodalez-dev/Pinakes/issues/467)). The Publisher and Responsible editor fields of a masthead work like the author field: removable chips, search, and Enter to add a new name, up to 20 of each. The first publisher stays the primary one, so everything that read the single publisher keeps working. All publishers appear on the public masthead page and in its schema.org data, in the public and admin publisher filters, in the KBART export and in the mobile API (`publishers`, next to the unchanged `publisher`). Merging or deleting a publisher keeps the lists consistent: deleting the primary promotes the next one. Without JavaScript the fields fall back to a multi-select and a text input that takes names separated by semicolons.
- **A reader for PDFs inside the site.** A PDF attachment opens in a full-screen dialog with a light toolbar, page-by-page reading, zoom and search, built on Mozilla PDF.js 6.4.299 served from the installation itself, with no CDN. Several attachments share the same reader; Escape closes it and gives the focus back. The direct link and "Open in a new tab" stay available, and PDFs hosted on another site keep the browser's own reader.
- **Books first in the recent catalogue, on request.** With the setting `catalog.books_first=1` the recent view lists books before articles, before pagination; alphabetical and oldest orders stay mixed. Installations without the setting keep the current order.

### Changed
- **The administration and the plugin screens read as one application.** Every core and plugin screen has the same header with title, breadcrumb (Home / section / page) and actions; buttons, tables, filters and dialogs share one geometry and the typography of the 2026 public site. On phones fields have room, language tabs no longer overflow, and long plugin dialogs keep Save and Cancel in view while their fields scroll.
- **The staff book page is a bibliographic sheet**: authors beside the title, the cover in a rail of its own, clear metadata and description sections. A book without a usable cover shows its title, author and publisher with a "No cover" caption.
- **CMS texts open in the site's language.** The homepage, catalogue and events editors start on the configured language; the other active languages are under "Other translations", one at a time, and the chosen language is kept after saving. Saving Desiderata texts keeps every translation not yet submitted.
- **The homepage cover picker works from the keyboard**: arrows and Enter choose, Escape closes, loading, empty and error states are shown, and Enter in the search box never submits the CMS form. Below 400px the section visibility controls get their own row and a drag handle, so toggling no longer reorders by accident.
- **Article keywords look like a book's** ([#468](https://github.com/fabiodalez-dev/Pinakes/issues/468)): tag links on the admin article page that open the article list filtered by that keyword.
- **The public article page labels its placement** ([#469](https://github.com/fabiodalez-dev/Pinakes/issues/469)): `In: <publication>, Volume 4, Number 1, Pages 10-17`, with the issue linked when the article belongs to one.
- On catalogue cards the title sits clear of the cover's shadow, and the availability badge and the wishlist heart sit inside the bottom of the cover. On desktop the book page's cover follows the scroll up to the end of the media section.

### Fixed
- **Desiderata and donations name the authors only.** The wanted list, the donation form and the mobile book detail put translators and other contributors in the author field; they now take principal authors and co-authors in credit order. The mobile detail lists every contributor with role and pseudonym in a new field, so older app versions are unaffected.
- The series tree keeps its indentation, connector lines and group rules under the shared table styles, also on phones.

Plugin versions: Emeroteca 1.14.0. No core migration; Emeroteca adds the `emeroteca_testate_editori` table and widens the responsible editor to `TEXT` on its own when it updates, keeping the existing values.

## [0.8.2]

### Fixed
- **The catalogue filters are a sidebar again, whatever the names in them.** A publisher named in a hundred characters (shown with an ellipsis) still counted at full length in the width of the filters column: on a live library the column took the whole page and the books went under the filters. The column now has a width of its own, a quarter of the page between 250 and 300px.
- **One tap on the phone's tab bar.** For a visitor who had not answered the cookie banner yet, the banner lay over the bar and took the first tap, so "Catalogo" needed a second one. The banner now sits above the bar.
- **The book page on a phone has no uneven rows.** Year, pages, format and ISBN go two by two instead of three and one; the share buttons and the citation actions go one per row, the citation styles stay on one line that scrolls sideways; the card titles keep room above their buttons; the loan and favourite buttons start where "Available" / "Not available today" starts and fill the box.
- The home search no longer draws a second rectangle around the text while typing: the rounded box alone shows the focus.

No migration.

## [0.8.1]

### Fixed
- **Every text the CMS edits is the text the site shows.** In CMS → Homepage the fields of the hero, the sections, the events band and the closing call to action hold exactly what the home displays, and a field emptied shows nothing. The site used to put a hidden default in place of an empty field (the hero subtitle "Scopri, prenota e gestisci i tuoi libri preferiti…"), which no field showed and nobody could change. The catalogue header in Settings → CMS follows the same rule. The hero title and the two buttons cannot be left blank: their fields show the text on the page and are required.
- **The image of a CMS page (About us) is saved when Save is pressed.** The image uploads as soon as it is chosen, and Save waits for an upload still running; before, an image chosen without the separate "Upload" click was left behind. A refused or failed upload is reported on screen, with the size limit the server actually accepts, and never leaves Save blocked; an image removed while it is still uploading is not saved.
- **The quick search's suggestions open on top of the page.** On the 2026 home the list under the hero's search box was cut at the hero's edge and covered by the statistics and the next section, most visibly on phones.
- **The catalogue grid lines its cards up**: title, author, publisher and Details sit at the same height in every row, whatever the length of the title.
- The settings address names its tab once (`?tab=cms`, no longer `?tab=cms#cms`).

### Added
- **The title and subtitle of the events page can be edited** in Settings → CMS, per language.

No migration.

## [0.8.0]

### Changed
- **A new design for the public site and the account pages (2026).** The catalogue, the book page, the home, the wishlist, loans and reservations, the periodicals, the archive and the events read as one site, built on the Fraunces and Geist typefaces, warm paper and one dark surface. Every colour the site sets as text is computed from the theme so it reads at WCAG AA on every theme. A book without cover art is drawn as a cloth binding with its author, title and publisher; book covers fill the whole book.
- **Two theme options replace the four layout variants.** Under Admin → Themes, *Hero style* is either *Covers* (title on the left and a fan of covers: the latest ones or up to four picked in CMS → Homepage) or *Centred*, and *Card style* is either *Classic* or *Tinted*. The hero photo upload is gone.
- **The catalogue on phones.** Rows line the cover up with the title, the filters fold away, and the list view stays a list after paging. A bar at the bottom, as in the Android app, links Home, Catalogue, Loans, Favourites and the account; it appears at the first scroll and gets out of the way at the footer.
- **The catalogue filters** ([#461](https://github.com/fabiodalez-dev/Pinakes/issues/461)): publishers get the same searchable list as authors, sub-genres can be chosen, and clearing the filters no longer reloads the page.
- **The admin quick search lists books, articles and periodicals as one list** ([#463](https://github.com/fabiodalez-dev/Pinakes/issues/463)): the title that matches what was typed first, then the ones starting with it or containing it, in alphabetical order by the operator's language.
- **The Emeroteca and Book Club addresses follow the site's language** (`/emeroteca`, `/periodicals`, `/zeitschriften`, `/periodiques`, `/tidsskrifter`; `/club-di-lettura`, `/book-club`, `/lesekreis`, `/club-de-lecture`, `/laeseklub`). The old addresses keep working.
- **Interoperability follows the standards more closely.** OAI-PMH: MAG records use the official MAG 2.0.1 namespace, MARC 008 has its 40 positions, MODS names use namePart, languages are ISO 639-2. NCIP 2.02: circulation statuses use the scheme values and due dates fall at the end of the day. SRU: diagnostics in the SRU namespace, CQL NOT and sorting fixed. ResourceSync, BIBFRAME and OpenURL KEV are aligned with their specifications.
- **Archives export MARC 21 by default** (danMARC2 stays available on request), EAD3 places creators in `did/origination`, and a unit can be kept off the site with a *Published* flag honoured by every public page, feed and protocol.

### Added
- **The VIAF and ISNI identifiers can be entered when an author is created**, not only afterwards.
- **Private book clubs and confirmed invitations.** In a private club, books, discussions, polls and meetings are visible to members only; opening an invitation link shows a confirmation page, so a mail scanner can no longer join anyone.
- **The Android app's push messages are encrypted** (Web Push, RFC 8291) when the device registers its keys.
- **An issue's PDF opens page by page** (HTTP byte ranges) instead of downloading the whole scan first.
- **The admin can take the Emeroteca and the Archive out of the public menu**, and edit the title of the home's events section.

### Fixed
- **The image uploaded for a CMS page is shown.** Every version up to this one stored it outside the web root while saving a public address, so it answered 404; images left there are moved on the next view. The CMS editor no longer creates an empty duplicate of the About page on an Italian installation seeded with `about-us`.
- **A large PDF or download no longer fails on a low memory limit.** The security-header layer read every response into memory to check whether it was HTML.
- **Plugin review.** Open Library sends the Google Books key in a header, not in the URL; API Book Scraper fails closed on an unreadable key; GoodLib starts with Anna's Archive, Z-Library and its public block off; Discogs, MusicBrainz and Deezer share one throttle per service; Digital Library checks an upload's content before storing it; the mobile API refuses loans, reservations and the wishlist in catalogue-only mode, expires idle tokens and rate-limits messages.
- **Custom CSS, custom scripts and the cookie banner apply on every public page**, account and login pages included.

Plugin versions: Archives 1.5.2, Book Club 1.4.8, Emeroteca 1.13.2, Mobile API 1.5.1, Desiderata 1.2.0, VIAF Authority 1.1.2, SRU Server 1.4.1, Digital Library 1.4.1, Open Library 1.0.6, API Book Scraper 1.1.3, GoodLib 1.0.2, Discogs 1.1.1. No core migration; the Archives plugin adds its *published* column on its own when it updates.

## [0.7.94]

### Added
- **An admin page for each article** ([#453](https://github.com/fabiodalez-dev/Pinakes/issues/453), [#454](https://github.com/fabiodalez-dev/Pinakes/issues/454)), laid out like the admin book page. It shows the cover, authors, publication, masthead and issue, genre with its path, keywords and the rest of the record, the PDF and the RIS and MARCXML exports, with Edit, Delete and the public page as buttons. `/admin/periodicals/articles/{id}` is now this page and the form moved to `/admin/periodicals/articles/{id}/edit`. The quick search, the Articles list (which gains View and Edit icons) and the Details button on the author page open it, and saving the form returns to it. Before, every link opened the form, so saving looked as if nothing had happened.
- **`GET /admin/updates/status`** ([#450](https://github.com/fabiodalez-dev/Pinakes/issues/450)): the installed version, whether the update lock is held, the latest update attempt and the outcome of the latest run, and, with `?attempt=`, the update log row and outcome of that one install attempt. Admin only (staff get 403), never cached, reachable during maintenance.

### Fixed
- **An update behind a reverse proxy is no longer reported as failed when it succeeded** ([#450](https://github.com/fabiodalez-dev/Pinakes/issues/450)). The install request runs the backup, the files and the migrations in one go. A proxy in front of the site (Apache `mod_proxy`, a NAS's remote access, Cloudflare) can give up on it after a minute with a 502 or 504 while PHP, which ignores the aborted connection, finishes the update. The page treated that as a failure. On a gateway error or a dropped connection it now polls the status endpoint until the update lock is free, and reports how the run ended: success, or the error it stopped on. The page sends an identifier with its install request, and the run files its update log row and its outcome under it, so the page reads its own result and never another administrator's update. When nothing is filed under its attempt, the page says the outcome is to check and shows the installed version, instead of guessing. A gateway error whose body is empty or cut short is waited out like the others. The run writes its outcome before it releases the lock, so a failure before the install step (space, backup, extraction, package checks) is reported with its own message too, and an attempt the server left half-way is reported as interrupted. The install request releases the PHP session before the update runs, so those polls are not held behind it.
- **Only the most specific admin menu entry is highlighted.** On an article page both Periodicals and Articles lit up, because the address of one starts with the other's.

The Emeroteca plugin goes to 1.12.1. No migration.

## [0.7.93]

### Added
- **An Articles entry in the admin menu** ([#454](https://github.com/fabiodalez-dev/Pinakes/issues/454)). The articles were reachable only through the periodicals list, which someone who catalogues single articles without keeping a run of journals had no reason to open.
- **The admin quick search finds articles and periodicals** ([#453](https://github.com/fabiodalez-dev/Pinakes/issues/453)). An article shows its authors and where it was published and opens its edit form, unpublished ones included; a periodical opens its issues. Its label now says so. The public header search keeps suggesting published articles only, with their public page.
- **Articles have a genre** ([#455](https://github.com/fabiodalez-dev/Pinakes/issues/455)), from the same tree as the books. The article page shows it with its path, and the catalogue filtered by a genre, or by any genre above it, lists the articles filed there next to the books. Merging two genres moves the articles too.
- **An Edit button on the public article page** for admin and staff ([#455](https://github.com/fabiodalez-dev/Pinakes/issues/455)). The page is never cached, so a visitor never sees it.

### Changed
- **A genre filter in the catalogue reaches every level below it.** It matched a book filed under the genre, a child or a grandchild of it; on a deeper tree, books further down were missing. Books and articles now follow the same rule, at any depth. The genre counts in the catalogue sidebar follow it too, and a genre on the fourth level or deeper can be opened there like any other.
- **On an author's page the articles are cards like the books** ([#453](https://github.com/fabiodalez-dev/Pinakes/issues/453)): image (the article's, else its issue's cover, else the periodical's logo), title, publication, date and pages, and the Details and Edit buttons. They were a plain list of links.
- **"Other works by" on the article page** ([#453](https://github.com/fabiodalez-dev/Pinakes/issues/453)) lists the author's books next to their other articles, with a link to all of them on the author's page. It listed only articles.
- **Keywords and genre are in the advanced description of the article form** ([#455](https://github.com/fabiodalez-dev/Pinakes/issues/455)), together with language, country and classification. The section opens by itself when any of them is filled in.

The Emeroteca plugin goes to 1.12.0. Its migration adds `emeroteca_contributi.genere_id`, with a foreign key to `generi`, and runs by itself when the plugin updates; it is additive and can run again safely.

## [0.7.92]

### Fixed
- **The automatic update no longer fails with "The server returned an invalid response"** ([#450](https://github.com/fabiodalez-dev/Pinakes/issues/450)). It ran the backup, the download from GitHub, the file copy and the migrations in one long request, holding the 30 MB package in memory; on hosting that cuts long requests off behind a proxy, or will not raise PHP's memory limit, the request ended with an error page instead of an answer, while uploading the same package by hand worked. The automatic update now runs as two requests, like a manual one: the server downloads the release and verifies its sha256, then installs it through the same request a manual update ends with. The package is written to disk as it arrives instead of being held in memory, by the single-request update too.
- **A failed update says why.** A PHP fatal error during an update (memory, a time limit the host enforces) now comes back as a message with PHP's own reason, and an answer that is not JSON at all, such as a proxy's timeout page, is shown with its HTTP status and the start of its text. Before, both read only "invalid response". The update requests also ask for JSON, so an expired session answers in words the page can show.
- **A download that breaks off is reported as one.** On a slow link the package download can stop partway, for instance on a timeout; the partial file then reached the checksum, and the update failed with "the archive does not match the expected checksum", which reads like a tampered release. It now fails as a download, with the transport's reason. Packages downloaded or uploaded for an install that never ran (a closed tab, a session that expired in between) are also removed after an hour, with the other temporary update folders, instead of staying in storage/tmp.
- **The install request installs the package its own page prepared.** A download and an upload, in two tabs of the same session, shared one waiting package, and the install request took whichever came last. The page now sends back the id of the package it downloaded or uploaded; a package replaced in another tab is refused, not installed, and the replaced one is deleted. A downloaded package is checked against its sha256 again right before it is installed.

This fix runs from the update after 0.7.92: an installation older than 0.7.92 still updates with its own, older updater. If the automatic update fails there, upload the 0.7.92 package under Admin → Updates → Manual update.

## [0.7.91]

### Added
- **danMARC2 records can be imported**, from the Danish union catalogue (the new **DBC** preset, through DBC OpenSearch) or from any SRU server that returns danMARC2 in MARCXchange (syntax "danMARC2"). Title, authors, translator and other contributors (100, 700 and the free-form 720), edition, place, publisher, year, ISBN, pages, language, series, summary and subject terms are read as danMARC2 defines them. Before, a danMARC2 record could not be read at all: as MARCXML it was not found, as MARCXchange it was read as UNIMARC and came back without a title. A MARCXchange record that declares itself danMARC2 is now read as danMARC2 whatever syntax is set. Z39.50/SRU is now 1.4.0.
- **A colorist is imported as a colorist**, into the book's Colorist picker, from MARC 21 and danMARC2 records.

### Fixed
- **A translator or an editor the record names without a role is no longer imported as an author.** Many catalogues (Library of Congress, LIBRIS) name the translator only in an added entry with no relator, and say "translated by" on the title page; the statement of responsibility is now read for that name. 0.7.90 sorted names only when the record gave the role as a code or an English or Italian term.
- **Roles in other languages and neutral codes.** Terms are read in German, French, Spanish, Danish, Norwegian, Swedish, Dutch and Polish as well ("Übers.", "Hrsg.", "traducteur", "oversætter", "redigeret af"…). A neutral code ("oth", "ctb", danMARC2's "led") no longer hides the term next to it: K10plus's "$e Übers. $4 oth" is a translator. The reader of an audiobook and the writer of a teaching note are no longer authors.
- **Place and edition without ISBD brackets.** "London [u.a.]" is "London" (0.7.90 cut it to "London [u.a."), "[Kbh.]" is "Kbh.", "[New ed.]" is "New ed."; this now applies to every source, the plugins included.
- **The ISBN searched for is the one the form receives.** A MARC record listing several ISBNs (hardback, paperback) handed over the first of them, which could be a different edition's.
- **Page counts from UNIMARC.** "1 vol. (308 p.)" is 308 pages, not 1.

## [0.7.90]

### Added
- **An ISBN import fills Edition, Place of publication and Editor.** Every book source now hands the form the same fields: the edition goes to *Edizione*, the place to *Luogo di pubblicazione*, editors ("a cura di") to the Editor picker, alongside the translator and illustrator the form already took. Before, the edition and the place were dropped or ended up in the notes, and editors were lost. SRU/MARC reads them from 250/260/264 (MARC 21) and 205/210/214 (UNIMARC), SBN from the record's edition and its *[Curatore]*, *[Traduttore]* and *[Illustratore]* names, Open Library from `edition_name`, `publish_places` and the edition's contributors. Open Library is now 1.0.5, Z39.50/SRU 1.3.2, API Book Scraper 1.1.2. No migration.

### Fixed
- **A translator or an editor is no longer imported as an author.** SRU records listed every added entry (MARC 700/UNIMARC 700-702) among the authors whatever its role; the relator code (`$4`, MARC 21 or UNIMARC) or term (`$e`, English or Italian) now sends each name to its role, and a name with no relator, or with a role not recognised (a joint author, a compiler, a composer), stays an author as before. Only an editor, a translator, an illustrator, the writer of part of the book (an introduction, preface, foreword, afterword or postface, a commentary, notes or supplementary text) and the publisher leave the authors; a relator given as a URI is read too.
- **An unknown place is not imported as a place.** "[S.l.]", RDA's "[Place of publication not identified]" and their Italian and German forms are statements that the place is unknown; they no longer fill *Luogo di pubblicazione* or reach the citations, from SRU, SBN or Open Library.
- **"Aggiorna Dati" leaves the notes already written as they are.** Lines brought by the import are added only when the field does not hold them yet.
- **The external book API's cover and publication date reach the form.** They were sent under names the form never read.

## [0.7.89]

### Added
- **A book records where it was published** ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)). The book form has a *Place of publication* field, shown on the public and admin record. Chicago and Harvard citations now read "Lund: Nordic Academic Press", the MARCXML export carries it in 264 `$a` (SRU and OAI-PMH), and an empty place leaves no dangling colon. Core migration 0.7.89 adds the nullable `libri.luogo_pubblicazione` column; existing books get no invented value.
- **A book's citation can be downloaded as RIS** for EndNote, Zotero and Mendeley, from the Cite dialog, built from the same data as the citation styles.
- **Oxford (Umeå) in the shared Cite dialog** for books, articles and anthology chapters, alongside APA, Harvard, MLA and Chicago ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)).
- **Articles in the live search and on the catalogue** ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)). The header search suggests published articles while the reader types; on /catalogo an article card links its authors and its publication. The article form links a record to a catalogued masthead by searching it and copies the masthead's title and ISSN into empty fields. Article MARCXML gains the MARC country code in 008/15-17, the public PDF in 856 and the authors in direct order in 245 `$c`; RIS names the journal in JF as well.
- **Multiple digital attachments on one book** ([#445](https://github.com/fabiodalez-dev/Pinakes/issues/445)): PDF/ePub editions, reviews and articles about the book, and multiple audio recordings, each with a title, download and appropriate viewer. Uploads append files; existing single-file records remain available. Digital Library is now 1.4.0.
- **The catalogue header is editable per language.** Settings → CMS has a Catalogue card with a title and subtitle for every active language; an empty field keeps the shipped wording.
- **"New article" in the sidebar quick actions**, under "New book", whenever the Emeroteca is active ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)).

### Changed
- **The public site reads as one product.** The periodicals section is rebuilt and linked end to end (masthead, year, issue, article, with previous/next and a way back), and every public page uses the same shared pieces: catalogue hero, breadcrumb, filter sidebar, article card, pagination and resource hero.
- **Event images: three presets instead of four.** "Thumbnail beside the text" rendered exactly like the recommended cover, so it was merged into it; an event saved with it looks the same. The cover beside the title is now held to 350px (a masthead logo to 240px), as intended: a global `max-width: 100% !important` on images had been letting it fill its column.

### Fixed
- **An update that cannot fit is refused before anything is touched.** The updater measures the space the rollback copy needs and proves the account can write before starting, and a copy error now names its cause (space or quota, permissions, missing directory) instead of an arbitrary file name. The standalone recovery script does the same, and its "space checked by hand" option still writes 16 MB first, so a full disk stops there rather than halfway through the dump.
- **Saving an article never unlinks its masthead by accident.** A masthead that sorted past the picker's list, or a list that could not be read, left the form on "not linked" and a plain save removed the link and the issue.
- **A blank place of publication is stored as empty**, not as the spaces typed into it.
- **The events pages keep their own styles.** The listing's spacing and the event image's margin and rounded corners were silently dropped, because the page wrapped its rules in a second `<style>` tag inside the layout's.
- **Digital files linked before 0.7.89 stay visible after the upgrade.** A book whose eBook or audiobook pointed to a path of the site outside `/uploads/` showed it before; the new attachment list now accepts any path of the same site (never `//`, `..` or a backslash), so such a book neither loses its file on the public page nor refuses to save.


## [0.7.88]

### Added
- **An article you hold a copy of can now be catalogued as what it is: an analytic record** ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)). A standalone article — a photocopy, an offprint, a PDF of one paper out of a journal the library does not take — was nine free-text boxes. It can now carry the apparatus a component-part record needs: a subtitle, the language and country of the host publication, a classification, a note saying the library holds a copy and not the run, and the electronic location with its link text and access conditions. Every one of those fields is **optional** and lives behind a section the cataloguer opens deliberately, so a collection that only wants a citation still fills in a title and stops. The classification is stored as a **scheme plus a value** — `DK5` and `33.129`, or `DDC`, `UDC`, `LCC`, `RVK` — which is MARC 21 084 `$2` and `$a`: no national scheme is privileged, and a notation arrives with the name of the list that gives it meaning. Language and country are ISO codes rather than names, so `dan` reads as "Danish" to one visitor and "danese" to another; a name typed into the box would have been that name for everybody.
- **The citation the catalogue already knew.** These records exist to be cited, and until now the database held every part of a citation and would not assemble one, leaving the librarian to retype a string it already had. The public page renders **APA 7** and **Harvard** with a copy button, and offers the record as **RIS** for EndNote, Mendeley and Zotero. Absence is handled rather than ignored, because on these records it is the normal case: an inverted name is reduced to initials while a name with no comma is treated as corporate and left alone, a missing author moves the title into its slot as both styles prescribe, a missing year says `n.d.` instead of inventing one — though a year written only in a free-text date is still found — and an abstract pasted out of a PDF is collapsed to one line, because in RIS a line break starts a new tag and would truncate the record at its first paragraph.
- **The OpenURL resolver understands journal articles.** It used to answer a journal request with "not supported", which was honest while the catalogue held no articles and stopped being honest when it did — the very question a researcher asks it, *do you have this article*, was the one it refused. `/openurl` now resolves an incoming journal request to a local article by DOI, or by exact title, before falling back to an external resolver, and an article page carries COinS metadata so Zotero and Mendeley can import the record straight from it. Matching by exact title only is deliberate: sending a reader to a paper with a similar name is worse than sending them to the publisher.
- **An article can be started from where books are started** ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)). “Add article” is available beside “New book” in book management. Article records offer MARCXML downloads with analytic Leader/07, author/title fields, extent (300), and host entry (773), including the existing masthead control number when linked. Public exports exclude unpublished records and private metadata.
- **Books and articles share their authors** ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)). Books and articles can reference the same author record. The article editor selects people by name and dates, supports explicit creation and preserves legacy unlinked credits. Names, pseudonyms, merges and deletion remain consistent across corpora. Core migration 0.7.88 adds a shared GND identifier; article MARCXML includes confirmed authority URIs in 100/700 `$0`.

### Changed
- **The public periodicals section now reads like the catalogue, and every level links to the others** ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)). `/emeroteca` lists the mastheads the way `/catalogo` lists books — a sidebar of filters (type, publisher, subject, initial letter) whose counts already apply the other active filters, twenty cards per page with real page links, and the latest articles underneath. A masthead, an issue and an article each open on the same kind of page as a book: the cover beside a breadcrumb, a title and the facts that matter. The links that were missing are there now. An issue lists the articles catalogued in it, in page order, above its printed table of contents — the link already existed in the database, because the article form sets it, but no public page read it, so a reader holding an issue could not reach its articles. An article says where it was published (`In {masthead}, n. X (year), pp.`), every part of that line is a link, the breadcrumb runs Home › Emeroteca › masthead › issue › article, the previous and next article of the same issue are one click away, and the way back is the issue it came from rather than a bare link to the article list. A masthead page lists its own articles, searchable and paginated. An article with no image of its own now shows its issue's cover, and only then the masthead's logo. A missing record answers with the site's own 404 page instead of a plugin notice.
- **One article card, one breadcrumb, one pagination.** The pieces the emeroteca pages are built from are shared partials of the core (`app/Views/frontend/partials`) — the same card an article has in the mixed catalogue grid, the catalogue's filter sidebar and result header, its pagination, and the book page's hero — and the catalogue's and the book page's styles moved out of their views into `public/assets/catalog-pages.css` and `public/assets/book-detail.css`, which a page opts into. A plugin page no longer hand-rolls markup that drifts from the catalogue's.

### Fixed
- **Articles and books are one catalogue** ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)). Published articles now share catalogue results, sorting and pagination with books, and author navigation searches both corpora. OpenURL resolves complete titles with subtitles and rejects ambiguous title matches or disabled plugin content.
- **An author's page lists everything that author wrote, and only what a visitor may see.** The public author archive is now reached by author id rather than by name, so two people who share a name no longer see each other's work, and a name link gathers every record of that name instead of the first one found. The archive is sorted by publication date, newest first, and linked articles sort by the same author key as books — principal author first — so a mixed list no longer files an article under its second author. On the staff author page, unpublished articles are listed only to staff, and the author search endpoint returns birth and death dates only to an operator whose role is re-validated on that request.
- **The article MARCXML is a record another catalogue can load.** Classification goes to the field its scheme belongs to (DDC 082, UDC 080, LCC 050, anything else 084 with `$2`); language uses MARC codes and the country of the host goes to 044 `$c`; 008 carries the date entered and an honest `nuuu` when the date is unknown; fields are ordered by tag and a field whose required subfield is empty is left out; names are in citation form with the matching first indicator; 856 carries the resource only when it is published and public, and the record page as a related resource. The shelf mark in 852 `$c` is exported only to staff. The SRU server's own public link uses the same 856 indicator.
- **OpenURL no longer picks an article at random when two share a DOI.** The schema does not make a DOI unique, and a lookup that took the first row sent the reader to an arbitrary record. A DOI now resolves locally only when exactly one published article carries it, the rule the title lookup already followed; anything else falls back to the external resolver.
- **The article form cannot promise a public resource that does not exist.** The "public resource" flag is disabled until a URL is entered, and cleared on save when there is none; the author picker stops at twenty credits and says so.

## [0.7.87]

### Fixed
- **Coming back to the site with "remember me" no longer breaks the first form you submit.** A browser returning with only the remember-me cookie asks for several things at once — the page, and whatever that page fetches for itself. Every one of those requests arrived without a session, every one of them authenticated from the same cookie, and every one of them created its own session with its own CSRF token. The browser keeps the last one it is given, so the token printed into the page could belong to a session that had already been left behind, and the next thing the visitor submitted was refused with "Errore di Sicurezza" — for no reason they could see, and not reliably enough to report. Measured on the admin dashboard, whose own statistics request is the usual second creator: it happened on about half of page loads. The first request to authenticate a token now records the session it made, and a sibling arriving in the next few seconds joins it instead of starting another; the note is published in one step and only where there is none already, so two requests reaching that point at the same instant still agree on one session rather than publishing an id each. The note is keyed by a hash of the token, never holds the token, is dropped whenever the cookie is retired, and is swept once it is past its short window.
- **The map snippet OpenStreetMap hands out is accepted again.** Its Share panel used to produce `…/export/embed.html?bbox=…` and now produces `…/export/embed?bbox=…`; the contacts form only knew the first spelling, so the one place a librarian is told to copy from produced the one thing the form refused, with a message naming a URL the site no longer gives out. Both spellings are accepted — OpenStreetMap serves both, and existing installs have the older one stored. Two related defects went with it: the query string of a pasted snippet arrives HTML-encoded, and escaping it a second time on the way out turned `&layer=mapnik` into a parameter called `amp;layer`, so every argument after the first was silently dropped; and the provider paths were matched by prefix, which also accepted `/export/embed.html.anything`.
- **A map no longer depends on a consent banner that was switched off.** The contacts map is held back until a visitor consents, and the consent manager only loaded when the cookie banner was enabled. With the banner off the manager was absent entirely, so the map could never be shown, the placeholder's "manage cookie preferences" button called an API that did not exist and did nothing when clicked, and no floating cookie icon existed to reopen preferences from. "No banner" is now honoured as what it says — no banner on arrival — rather than as "no way to consent": when content needs consent the manager still loads, the cookie icon and the preferences panel are still there, and a visitor who wants the map has a way to ask for it. A control that cannot work is no longer shown at all.
- **An upgraded install no longer runs the Content-Security-Policy it had years ago.** The policy used to be a static line in `public/.htaccess` and now comes from the application, per response and with a nonce, but that file is preserved across upgrades — for good reason, it holds the operator's own rules — so the old line survived and a header set by the web server is the one the browser obeys. It surfaced as a Google Maps embed refused by a policy that named only `openstreetmap.org`, a provider list from an earlier era. The quieter half matters more: the same line carries `script-src 'unsafe-inline'` and no nonce, so every install upgraded across that change had been running the weaker policy the move was made to replace, with nothing in the admin interface to show it. The upgrade now removes that one directive, keeps a copy of the file first, and leaves everything else in it alone.
- **A facet list that is cut off now says so.** Reported from a live library: in the catalogue sidebar the authors list stopped mid-alphabet with ninety-two names still inside it, and nothing said it could be scrolled — no rule closing the box, no fade, and a thin scrollbar most platforms only paint while the pointer is over it. It read as a finished list, which in turn made the short facet below it look broken rather than short. Every open facet list is now closed by a hairline, and while there is content below the fold its last line fades out; the fade clears once the list is scrolled to the bottom, because a cue that never goes away stops being read as one. It is a mask rather than a gradient over the content on purpose: the panel is transparent and the four layouts and the colour theme paint different things behind it, so a fade hard-coded to white would be a white smear on three of them. On a phone the filters panel starts closed, and a closed panel has no height to measure — so the cue is worked out again when the panel is opened, and again whenever a facet is reopened from its «Cambia» link, rather than only once on load.
- **The article block on an Emeroteca page shares the page's margin.** The block that lists articles carried a width container of its own while the pages around it use the site's, so on a masthead page and on the Emeroteca home it sat eighty-six pixels further in on each side than the heading, the search box and the list of mastheads above it. The block no longer decides how wide the page is — it is placed inside the page container like everything else, and the article search page now uses the same container as the rest of the Emeroteca instead of a narrower one of its own.

### Changed
- **The privacy settings tab is organised by subject.** It used to interleave three of them: one button labelled "Salva Privacy Policy" saved the privacy page, the cookie policy page, the switch that turns the cookie banner on, two links and the category visibility — while the texts of that same banner sat in a second form with a second button, and nothing on the page said so. A librarian who enabled the banner and rewrote its wording had to press two different buttons to keep both, with no way to tell which was which. There are still two forms, but the line between them is now the subject rather than an accident of which endpoint existed: **the pages** a visitor reads at /privacy and /cookies, each with its own preview link, and **the cookie banner** — whether it appears, which categories it offers, the links in its preferences panel and everything it says — saved as one thing by one button. Turning the banner on or off, and choosing which categories it offers, is now admin-only, as its wording already was: it governs what every visitor is asked to consent to.

## [0.7.86]

### Added
- Optional Desiderata plugin: library requests without physical copies, a homepage section that is reorderable, hideable and editable per locale from the CMS, public donations searchable by title, author, publisher or ISBN, a wanted badge in catalogue search, dashboard management, direct receipt, receipt history and operator notifications in each recipient's own language. A wanted title is findable by name and reachable by link, and stays out of catalogue browsing, feeds, the sitemap, the mobile API and the interop protocols. With the plugin off, every one of those surfaces behaves as it did before the feature existed.
- Test coverage for the feature: 475 PHP checks across thirteen suites and 13 browser scenarios, including two that deactivate the plugin through the real admin endpoints, three for reCAPTCHA — which had no automated coverage at all — twenty release regression cases for proposal/receipt concurrency and for Book Club member lending of wanted books, and six new suites covering the defects listed under Fixed: the return-address allow-list, a club book surviving the flag, the search facets counting the same books the results show, every spelling of the CSV affirmative, the OAI-PMH tombstone distinction, the upgrade that carries it to installations that already have the plugin, the thank-you banner surviving a submission sent from a page that has no session, and the donor whose browser was never given one.
- **The catalogue search reaches the articles in the Emeroteca** ([#412](https://github.com/fabiodalez-dev/Pinakes/issues/412)). A catalogue search that matched an article used to produce a line of prose and nothing to click. The hint now carries two sections — the articles themselves and the mastheads that hold them — each entry linking to its record, with a "see all N results" line when there are more than fit. Articles gained a cover image on the cataloguing form, and the public listings gained filters by author, publication and keyword, with the active filters shown as chips that can be removed one at a time.

### Fixed
- **A donation can be sent from a browser the site never gave a session to.** `/` and a book page are served without a session on purpose — they are edge-cacheable, and a per-visitor CSRF token baked into a shared cache would be handed to everyone — so the form there carries an empty token and the browser mints one before posting. Two things followed from that. The token is now fetched when the form is wired rather than inside the submit itself, so a donor's first write no longer depends on a session created a few milliseconds earlier in the same gesture; a page that already has a session makes no extra request at all. And with scripting off, where nothing can mint anything, the send is answered with the donor's own form back — everything they typed still in it, now carrying a real token — and one more confirmation, instead of a bare "Sessione Scaduta" for something they did nothing wrong in. Nothing is accepted on that first send, and a wrong or missing token where a session already exists is refused exactly as before.
- **A disabled or read-only field now looks it.** `.form-input` kept the white background and the hover border of an editable field in both states, so the only sign was that typing did nothing — which reads as a broken page rather than a field the form is deliberately holding. It shows most on the book form: ticking Desiderata locks the initial copies to zero, and the locked field was indistinguishable from the one beside it. The rule is on the component, so every disabled field in the application gains it.
- **The thank-you banner no longer depends on a session the page does not have.** A proposal sent from the homepage confirmed itself through a session flash, but `/` is deliberately served without a session — so the confirmation had to survive a continuity nothing guarantees, and sometimes did not. It now travels in the redirect as well, which is also what makes it work for a visitor whose browser was never given a session at all.
- **OAI-PMH and ResourceSync no longer announce the deletion of records they never published.** A wanted title produced a `status=deleted` header whether it had been *withdrawn* from the catalogue — which its harvesters are genuinely owed — or simply *created* as a request, which no harvester ever saw; the flag records no difference, because the copies that would have told them apart are deleted outright. Deletions are now derived from a write-once `libri.catalogued_at`, so they are sent for records that were once public and for nothing else. Existing wanted titles cannot be judged retroactively and are left alone: they stop producing deletions rather than keep producing wrong ones.
- **`SÌ` in a CSV's desiderata column is recognised.** The importer lower-cased byte by byte, which folds only the unaccented half of the alphabet, so the accented affirmative worked in lower case and silently imported as an owned holding in upper case — with no warning, since an unrecognised value simply means no.
- **A donor whose book stops being wanted mid-form can act on what the message tells them.** The error says the book can still be offered as a separate donation; the title field stayed locked, and the only control that released it was wired in JavaScript, so the offer held for visitors running scripts and was a dead end for everyone else. Everything already typed survives the refusal.
- The plugin's three public paths are defined once instead of being retyped in seven places across five files, where a rename could land half-done and fail only at the moment someone followed the stale link.
- **A reading club no longer loses a book the library flags as wanted.** Filtering wanted titles out of the club's queries was expressed as a condition on the join over `libri`, which cannot mean "hide the title, keep the entry": on a left join it empties the whole book row, and the guard beside it — written to hide a *deleted* book — then read that as a missing record and dropped the entry outright, so the club book stopped resolving and its state changes, reading schedule, polls, meetings, surveys, discussions, buddy readings and quotes all answered 404. On the sibling queries, where the same condition sat on an inner join, the entry simply vanished from the list. The filter is gone rather than relocated: the wish list is published to anonymous visitors at `/desiderata` by design, so a club naming a book its members chose to read discloses nothing the feature does not publish itself.
- **An open redirect in the donation form.** The allow-list for the address a donor returns to rejected a leading `//` or `/\`, but browsers delete tab, line feed and carriage return from a URL wherever they occur before parsing it, so a value carrying a tab was judged in a form the browser would never see and then read as a reference to another host. Any control character now refuses the value outright.
- **Searching for a wanted title no longer disagrees with the counters beside it.** The result grid widens to include wanted titles as soon as something is typed in the search box; the genre, publisher, author, media and year counts did not, so the same page showed the book and denied it existed, and clicking any filter made it disappear.
- **A favourite is no longer destroyed by trying to add it.** A book flagged as wanted disappears from the reader's favourites list, so the heart on its page shows empty and clicking it means *add* — while the click was in fact deleting the hidden entry for good. The removal is now limited to what the reader can actually see, and the favourite comes back on its own when the donation arrives.
- **Recording a direct receipt refuses a book that already has copies**, instead of adding another one, matching the two other receipt paths.
- **The homepage save no longer claims nothing was saved when something was.** A plugin section reporting a problem produced "Nessuna modifica è stata salvata" although every core section had already been written; the two cases now read differently, because they are different.
- **The book form's save confirmation says what saving will do.** Clearing the Desiderata box creates real inventory copies, and the dialog only asked whether to update the book. Plugins that add a field to that form can now add a line to its confirmation, which is how the warning gets there without the core form having to know what a desiderata is.
- Unticking Desiderata on the book form no longer clears the request before the copies exist: the flag now falls only inside the transaction that creates them, so a copy-creation failure leaves the book a request rather than a catalogue record with nothing on the shelf, and the operator is told instead of seeing a success message. That path also refuses to register copies while a donor proposal is still pending or accepted, which until now only the direct-receipt button did.
- Donation proposals and receipts serialize on the same book row, preventing proposals from being attached to a request that closed during submission.
- reCAPTCHA email tests create their own temporary administrator instead of depending on existing installation users.
- **A saved article no longer reports "Salvataggio non riuscito".** Post-write cleanup ran inside the same guard as the write, and under this project's mysqli error mode a failure there throws rather than returning false — so the defensive branches in the cleanup helpers were dead code and the exception surfaced as a failed save over a record that had actually been stored. Worse, the rollback then deleted the PDF the committed row still referenced, and the re-rendered form carried the pre-save revision, so the retry and the delete button were both rejected as a phantom concurrent edit. The cleanup helpers can no longer propagate a failure — which also closes the same hazard at the other eighteen call sites that used them — and the rollback is gated on whether this request actually wrote the row.
- **The author link on a co-authored article led nowhere.** It filtered on the whole credit line, so "browse every article by this author" matched only articles crediting exactly the same list of names. Names are now split on `;` alone, never on the comma that belongs inside "Cognome, Nome", and each one links separately on both the listing and the detail page. The separator is documented on the form and in the plugin's README rather than left to guesswork.
- **Removing one filter chip could silently remove another.** The link was built with a filter that drops every falsy value, so a sibling filter whose value was the string `0` — an ordinary issue number for a periodical — disappeared along with the one that was clicked. One definition of "empty" now builds every address the listing produces.
- **Filtered article listings advertised themselves as indexable.** Narrowing by masthead or by search term left the page `index,follow` while pointing at the same canonical address, and pages after the first canonicalised onto page one, so records reachable only later in the sequence had nothing pointing at them. Both now follow the rule the catalogue already applied to its own search.
- **The search hint's masthead count disagreed with the page it linked to.** The hint matched mastheads on title, subtitle and ISSN while the destination also matched them through their indexed articles, so the label could say "(1)" over a page reporting two; and a term too short for the full-text index produced no hint at all while the page listed results. Both sides now build from one definition of the predicate.
- **The published sitemap outlived the plugin changes that invalidated it.** Invalidation was wired only into the cache-clearing path, which neither bundled-plugin registration nor orphan cleanup goes through — including during an upgrade, where the old file is explicitly preserved. Both now invalidate, but only when they actually changed plugin state: clearing unconditionally would delete the file on every admin page view and every maintenance window, so the fast path would cease to exist.
- **The plugin self-heal stopped repeating itself.** It read "no registered hooks" as a missed sync, which is permanently true for a plugin whose hooks depend on configuration it has not been given, so it re-ran that plugin's activation on every maintenance pass for ever. It now records that the attempt achieved nothing for this version and skips it until the version changes — while an incomplete schema still triggers a repair regardless, since suppressing a real one would re-create the missing-table failure of #138.
- **`plugin_data` could not be written twice.** The table's key pair was not unique, so the upsert the API has always used never matched and every write appended a row, leaving reads to return the value the caller thought had been replaced. `migrate_0.7.86.sql` collapses any duplicates to the newest row per key before adding the constraint.
- **A restored backup no longer stops at the first generated column.** The dump listed every column of every table and the import replayed it verbatim, but MySQL refuses an `INSERT` that supplies a value for a generated column — and this schema has one, `bookclub_member_loans.open_key`. A restore therefore failed on that table and left the database part-way through. The dump now asks `information_schema` which columns are actually insertable, names them in both the `SELECT` and the `INSERT`, and falls back to `SHOW COLUMNS` when the probe is refused.
- **An interrupted restore no longer reopens the site on a half-replaced database.** MySQL cannot roll back DDL, so an archive whose `database.sql` fails part-way through leaves some tables replaced and the rest as they were — and the code lifted maintenance mode and reported the restore as done, handing the library a database that is neither the backup nor what preceded it. The window between the first table being dropped and the import finishing is now marked: the shutdown handler leaves the site closed while it is open, and the failure comes back as a partial restore naming what happened instead of as a success. The flag it leaves behind is also exempt from the thirty-minute staleness net in the front controller — that net exists for an update that died, and a large import easily outlasts it, so without the exemption the site would have reopened itself on the half-replaced database a moment later.
- **An invitation email now carries a password link that works.** Creating a user minted a setup token and stored it, and the notifier then minted a second one for the same account before sending — so the database held the second while the email carried the first, and the recipient's only link answered "token expired" on the first click. Minting has a single owner now, `PasswordSetupToken`, which the notifier is handed rather than duplicating, and the twenty-four hours the email promises is the constant the code actually uses.
- **Resetting a forgotten password now ends the sessions someone else was holding.** The point of a reset is that whoever had the account stops having it, but remember-me sessions and mobile tokens survived it untouched, so an intruder who had already signed in stayed signed in. A reset now revokes every session on the account and every mobile application token. That includes an ordinary sign-in, which is the part that was missing: only a remembered one left a row anywhere, and everything else was decided from the PHP session, which the authentication middleware trusts without asking the database — so a reset ended the intruder's automatic sign-ins while the browser they were already using carried on. Every sign-in now records a row, whether or not it asked to be remembered. Changing the password from the profile revokes the same set but keeps the device doing the changing, and says how many sessions it closed. The write and the revocations are one act: the connection runs in autocommit, so the new password used to land on its own and a revocation that failed afterwards left the account carrying it with the old access still open — while the page reported success. A failure now rolls the whole thing back and leaves the reset token unused, so the link in the mailbox still works.
- **Signing in retires the remembered cookie the browser was already carrying.** A sign-in replaced the session but not the cookie, so on a shared machine — a reading-room terminal is the case that matters — the previous person's token stayed live in the browser and would sign it back in as them once the new session lapsed. Reaching the login form that way is not possible, because the cookie signs you in before you get there; a sign-in posted over the top of it was, and that is the path that stayed open. The cookie is now cleared and the row behind it revoked, which costs its owner only the device being handed over.
- **A revoked remember-me session is now actually revoked.** Revoking the row stopped the cookie from opening a new session, but a session already established from it carried on until it expired on its own, because nothing tied the live session to the row it came from. The session now records which row issued it, and a request whose row has been revoked, has expired or no longer exists is signed out on the spot.
- **An administrative endpoint answers a page's own JavaScript with 403 instead of the login page — and one of them was not administrative at all.** The middleware recognised an API request only by a `/api/` prefix, so an action called over XHR received a redirect the interface reported as a malformed answer rather than as "you no longer have access"; it now also recognises `X-Requested-With: XMLHttpRequest` and an `Accept` header asking for JSON. Separately, the digital library's upload route carried no administrative check at all, so a suspended account reached the handler.
- **Extending several loans at once no longer grants an extension over a reservation confirmed while the page was open.** The query that gathers the selection ran inside the transaction, and under REPEATABLE READ the first consistent read fixes the snapshot every later non-locking read in that transaction is stuck with — including the capacity checks, which run *after* the locks are taken precisely so they can see what was committed in the meantime. A reservation confirmed between the scan and the lock was invisible to them, and the extension was granted on top of it. (The loans themselves were never at risk: they are re-read under their own lock, and any that closed meanwhile are skipped.) The gathering now happens before the transaction opens, which is the order `close()`, `renew()` and `store()` already follow, and already explain in a comment.
- **A link in an email can no longer point at a host the request chose.** The address in password-recovery and invitation emails was built from the host of the request that triggered it, so a request carrying a forged `Host` produced a mail with a valid token pointing at somebody else's site. Every emailed link now comes from `TrustedLink`, which uses the configured canonical address and, when there is none, sends nothing rather than a link to an unverified host — closing the same hazard in three senders, one of which, the invitation mail, had not been flagged. The link carries the installation's base path too, so a library living in a sub-directory is not sent a valid token pointing one directory too high. An invitation that could not be sent is now reported to the administrator who created or promoted the account, instead of being logged and called a success.

### Testing
- Five browser checks waited for a SweetAlert popup to be *absent* before reading the database. The handlers close the confirm dialog, await the fetch and only then open the success dialog, so "or no popup" is already true while the request is still in flight — the assertion then read a row the server had not written yet. Two of the five already waited for the HTTP response; the other three now do too. This is what made a rejected reservation intermittently read as still pending in the regression gate.
- The reCAPTCHA browser check flushes the web server's settings cache after writing the key. The fixture cleared its own process's cache, which is not Apache's — with the APCu backend that cache is in the web server's shared memory — so the check passed or failed according to whether anything had read the contacts settings in the previous sixty seconds.
- The plugin-upgrade check picks its MySQL transport the way the rest of the suite does instead of hard-coding a socket path, which is another way of writing down the developer's own machine.
- The desiderata browser fixture writes the cover it needs instead of borrowing whichever image the machine happened to have in `public/uploads/copertine`. It passed on a developer's installation, where demo covers are lying around, and failed on a clean runner — reported as eight "suspicious skips" and a missing-fixture error that said nothing about the code under test.
- New suites: `tests/migration-0.7.86.unit.php` (the real migration against a sandbox seeded with the old schema: the collapse, the surviving value, the constraint and two clean re-runs) and `tests/plugin-selfheal-idempotence.unit.php` (the marker suppresses the pointless repeat, a version bump lifts it, and an incomplete schema still heals with the marker in place). `tests/sitemap-cache-invalidation.unit.php` gains a no-op guard asserting the published file *survives* a maintenance pass that changed nothing — the assertion that was missing, and which caught a real regression on its first run. `tests/emeroteca-412.unit.php` goes from 159 to 170 checks.
- The eight security and backup findings are each covered by a suite that exercises the real thing rather than the shape of the code path: `tests/backup-generated-columns.unit.php` dumps a real table carrying a generated column, replays the dump into a second table and compares, and derives the list of generated columns from `information_schema` so the next one added anywhere breaks it; `tests/backup-partial-restore.unit.php` restores a deliberately broken archive into a sandbox database and asserts the site stays closed, then that a valid archive still reopens it; `tests/auth-credential-lifetime.unit.php` covers the token contract, what a reset revokes, what a change keeps, the bound-session check and the middleware through real PSR-7 requests; `tests/loan-bulk-extend-snapshot.unit.php` puts two connections on the same sandbox and shows the snapshot the old ordering produced.

## [0.7.85]

### Added
- **Edit one field across a selection of books** ([#380](https://github.com/fabiodalez-dev/Pinakes/issues/380)). Correcting the same field on a whole shelf — the illustrator nobody filled in, a publisher spelled differently, a genre that belongs to all of them — meant opening every book in turn. The books list gains a "Modifica campo" action next to the bulk actions already there: authors, illustrators, translators, curators, colourists, publisher and genre, each in one of two modes. *Add* joins the value to what is already credited and never duplicates it; for the genre, which holds one value, it fills only the books that have none, so a bulk fill can never overwrite a curated classification. *Replace* makes the value the only one and asks for a second confirmation naming the field and the number of books. The whole selection is applied in one transaction, the typed value is resolved once for the batch (a name typed for three hundred books creates one author, not three hundred), soft-deleted books are reported rather than edited, and an unknown genre is refused instead of invented. Every book that changes has its search index rebuilt and an entry in the activity log.

### Changed
- **The series list draws the hierarchy instead of describing it** ([#428](https://github.com/fabiodalez-dev/Pinakes/issues/428)). The rows were already ordered as a tree — universe first, then its series by cycle order — but every row looked the same, so the only way to see that a cycle belonged to an universe was to read the parent column and rebuild the hierarchy in your head. The block is now drawn: its header carries the universe icon, a tinted row and the number of series it collects; the children are indented and joined by a line that runs while the block continues and closes at a corner on the last one; a darker rule marks where the block ends, which matters most when what follows is an ordinary series with no hierarchy at all.

### Fixed
- **Saving the homepage no longer switches sections back on.** Reported from a live library: the features section was switched off, and it came back on the next save. A section's visibility is on that page twice — the toggle in the ordering list, which writes immediately, and the "Visibile" checkbox inside the section's own card, which is written on submit — and the two did not talk to each other, so switching a section off at the top of the page and then pressing Save re-sent the value the page had been loaded with. The two controls now follow each other, in both directions, and back again when a toggle request is refused.
- **Feature cards that were switched off are no longer drawn.** The same library was publishing four cards reading "Feature 1" to "Feature 4", a star icon each and no text: a card that is off is simply absent from the section's data, and the template filled each gap from the defaults meant for a fresh install. It now renders the cards that are actually there, and draws no grid when none are left.
- **An invalid field on the homepage form no longer discards every other edit in silence.** Each section is written only when there are no errors, so one bad URL threw away the whole submission while the message named the offending field and stopped there. It now says that nothing was saved and lists what to fix. The errors were also joined with `<br>` before a view that escapes what it is given, so two problems rendered with the tag visible between them.
- **`/admin/cms` answered 404.** The three CMS entry points existed only as buttons inside the settings page, so the address they all shorten to led nowhere, and a page that settings does not link — the privacy policy, anything a locale adds — could be reached only by typing its slug. There is now an index listing the homepage, the events and every content page in the active language, read from the database rather than from a fixed menu.
- **A content page's heading now lines up with its own text.** It sat in the page container while the text sat in a narrower column: invisible while the theme centres the heading, but the editorial and command layouts align it left, and there it started about a hundred pixels further left than its first line.

### Testing
- The Emeroteca 1.4.0 schema suite runs its 1.3.0 → 1.4.0 upgrade in a sandbox database of its own instead of skipping it. Tearing the schema down on the installation's own tables was gated behind an environment variable, and without it the suite printed SKIP and passed — the one thing it exists to prove was the one thing nobody was running outside CI. It now goes from 215 checks to 360, with the installation's tables out of reach.
- New suites: `tests/bulk-field-edit-380.unit.php` (31 behavioural checks against the real schema), `tests/issue-380-bulk-edit.spec.js` and `tests/cms-admin.spec.js` (the CMS as an administrator uses it: the index, section visibility through both controls, the all-or-nothing save, reordering, content pages, heading alignment and events).

## [0.7.84]

### Added
- Emeroteca 1.5.0: Simple and Complete workflows, with one shared setting available from Plugins and Periodicals. Existing collections keep Complete mode; for a new collection nothing is decided on the administrator's behalf — the initial workflow is chosen from either page, and the plugin behaves as Complete until it is.
- Standalone articles with structured host citation, partial publication dates, non-numeric page spans, ISSN/DOI, keywords, medium, private notes and independently controlled record/PDF visibility.
- Create a masthead after cataloguing articles and associate them in bulk through a preview. Optional issue links are checked against the masthead. Citations and attachments survive host deletion; masthead merges preserve associations.
- CSV preview, row-level validation, revision-checked updates and lossless export/reimport. Article input in the book importer is rejected with an explicit route to Emeroteca instead of silently becoming a book.
- Public article search and permanent detail pages, sitemap entries, and additive mobile article endpoints with cursor pagination, ETags and capability discovery. Existing issue endpoints retain their contract; displaying standalone articles in Android requires a compatible client.

### Fixed
- **Backups keep the ones you asked for, and rotate the ones you did not.** The rotation added in 0.7.83 kept the ten most recent automatic backups; it now records where each archive came from — automatic, manual, pre-restore safety copy or uploaded — shows it in the list, and only ever rotates the automatic ones, so a backup taken by hand before a risky change is never reclaimed to make room. Legacy `update_*` directories rotate too, but only when a directory really is one: one holding anything besides its `database.sql` is left alone instead of being deleted with its extra files. A failed deletion is reported rather than passed over in silence.
- **A copy out of circulation no longer erases the book's holdings.** With its only copy under maintenance, a book published "Available Copies 0 / 0" ([#426](https://github.com/fabiodalez-dev/Pinakes/issues/426)), which reads as "this library does not have it". `libri.copie_totali` counts copies *in circulation* — it is the lending capacity, shared with the loan logic, the integrity checks and the edit form — so the catalogue now publishes what the library owns instead, and says why some copies are not available: "0 / 1 — Copies out of circulation: Under maintenance: 1". The same numbers reach the page, the live availability payload and `/api/books/{id}/availability`, where `copies_total` keeps its meaning and `copies_owned` is added next to it. When every owned copy is out of circulation the request button no longer offers a reservation the queue would refuse.

### Changed
- **The book form says where an article belongs.** `tipo_media` offers Book, Record, Audiobook, DVD and Other, so a cataloguer holding a single journal article looks there, finds nothing that fits and stops — which is how [#412](https://github.com/fabiodalez-dev/Pinakes/issues/412) started. The media-type field now carries one line pointing at the Emeroteca: it links to the article form when the plugin is active, to the plugins page when the plugin is installed but switched off, and renders nothing at all when the plugin is not installed, so it can never offer a dead end.
- **API Book Scraper sends its key only over HTTPS.** Every request carries the API key in `X-API-Key`, and the plugin accepted an `http://` endpoint and followed redirects down to cleartext. The endpoint must now be a complete `https://` address, and redirects can no longer downgrade the scheme — as the plugin's README has always stated. An installation that still holds an `http://` endpoint stops sending lookups to it, logs the reason, and asks for an `https://` address the next time the plugin is enabled; turning the plugin off keeps working.

### Migration
- `migrate_0.7.84.sql` preserves the existing workflow setting without overwriting explicit choices. The bundled plugin owns `emeroteca_contributi` and upgrades it through its install/activation and boot-time self-heal paths. No changes to the core `tipo_media` enum or existing holdings.

### Testing
- Behavioural MySQL coverage using disposable tables for the actual migration, schema upgrade/retry, lifecycle, concurrency, privacy, typed CSV import and round-trip preservation; browser coverage for creation, attachment visibility, late masthead association, modes, imports, CSRF and responsive forms.

## [0.7.83]

Same content as 0.7.82, whose tag could not be reused after the release was pulled, plus the fix below. Upgrading from 0.7.81 goes straight here.

### Fixed
- **Automatic backups are now rotated.** A backup is written before every update and nothing ever removed the old ones: on a live installation that meant 73 files and about 300 MB accumulated in three months, until the account ran out of space and the *next* update failed while copying the application aside for rollback — the backup meant to make updates safe was what broke them. The failure surfaced as a copy error on whichever file the process happened to reach, which said nothing about the cause. The ten most recent automatic backups are kept (`backup.retention_count`, 0 disables the rotation); archives placed in the directory by hand are never touched, and a rotation failure can never fail the backup itself.

## [0.7.82]

The Emeroteca becomes a working serials desk, and the core gains the extension points it was missing ([PR #419](https://github.com/fabiodalez-dev/Pinakes/pull/419)). The bundled plugin goes to **1.4.0**; its schema migrates itself on upgrade. No core migration.

### Added
- **Barcodes at the desk.** A masthead now carries the EAN-13 base derived from its ISSN (the 977 prefix) and an issue carries its own barcode with the add-on that identifies it, both indexed. The Kardex has a scanner — the same self-hosted one the copy tracking uses — that resolves a scan to the issue and offers to receive it. The ISSN itself is validated by checksum, not merely by shape: a mistyped one used to be accepted and would have produced a barcode pointing at another journal. e-ISSN and ISSN-L are recorded too.
- **Claims and subscriptions**, the part of the Kardex that was missing: a subscriptions table (supplier, cost, dates, renewal, badged when expiring), the acquisition mode and price on the issue, and a claim workflow with its counters and dates — including a bulk claim over a volume year that never touches the current year and never re-claims what it already claimed.
- **Exports and labels**: KBART TSV and an ACNP-style CSV of the holdings, and issue labels with their barcode through the same machinery as copy labels.
- **Merging duplicate mastheads**, in one transaction: volume years fold together, colliding issue numbers are resolved by a documented rule where the held copy keeps the plain number and the loser is renumbered rather than dropped, subscriptions and title history follow the survivor, and the transaction refuses to commit unless the issue count afterwards equals the sum before.
- **Periodicals reach the outside world**: an OAI-PMH set in Dublin Core, serial records in all four Z39.50/SRU formats with a Bath ISSN index, entries in the sitemap, and a hint in the catalogue search pointing at the section that actually holds what the reader looked for.
- **Core extension points**: `sitemap.entries` and `search.external_suggestions` filters, `publisher.merging`/`publisher.deleting`/`genre.merging` actions with a `shelf.can_delete` veto, and `ActivityLog::recordEntityEvent()` so plugins can audit their own tables.

### Fixed
- **Merging two publishers no longer silently detaches what a plugin attached to them.** The core repointed only its own tables before deleting the duplicate, so every masthead linked to it lost its publisher without an error or a log line; the same applied to genres, and a shelf occupied only by issues could be deleted. Listeners now run inside those transactions, before the delete.
- **Possession and physical condition are separate fields.** They shared one column, so an issue that was held but damaged silently left the holdings count — the consistency statement lied. The upgrade migrates the legacy rows without losing any, and withdrawn issues now leave the public catalogue and its counts.
- **The declared holdings statement means the same thing everywhere.** Three implementations disagreed: the list ignored it, the issues page appended it, and the export replaced everything with it — so the file sent to union catalogues silently lost the issues actually held.
- Publishing or revoking an issue PDF is finally auditable, exported cells cannot carry spreadsheet formulas, an ISSN is validated before being exported rather than merely reformatted, and the admin list no longer runs one query per row.

## [0.7.81]

Circulation coherence: a full five-domain review of the reservation/loan system — states & transitions, queues & capacity, emails, audit trail, NCIP/mobile parity — with every finding fixed and covered by tests ([PR #416](https://github.com/fabiodalez-dev/Pinakes/pull/416)). No schema changes: no migration to run.

### Added
- **Pickup confirmation email** (`loan_picked_up`, seeded in all five locales): the physical pickup was the last silent lifecycle transition. The email reports the ACTUAL day the copy left the desk — captured before commit and preserved in the outbox for delayed retries — not the scheduled loan start.
- **Self-cancellation confirmations**: cancelling a reservation or a pending request now emails the reader a confirmation.
- **Complete audit trail for autonomous mutations**: every sweep and repair action records a SYSTEM event with truthful provenance — `loan.expired` on revoked pickups, `loan.overdue` on the bulk overdue flip (with `source=repair` when the data repair performs it), `reservation.expired` on expired holds, `reservation.promoted` on queue promotions, `loan.lost`/`loan.damaged` on returns. The loan snapshot now preserves the assessed penalty.

### Fixed
- **Queues no longer freeze behind an unpromotable head**: capacity accounting uses the same shared promotable-now predicate as the promotion gate (legacy NULL-start rows included), and only window-overlapping commitments count against future reservation windows — a loan scheduled beyond the window's end no longer blocks the next reader.
- **Recipient-locale coherence everywhere**: every motivo and fallback (pickup cancelled, renewed title, zero-penalty "Nessun addebito", NCIP cancellations) renders in the recipient's language, never the session's; empty-address guards fire before outbox persistence.
- **NCIP parity with the desk**: RenewItem refuses date-overdue loans and resets the recall state; CheckOutItem no longer sends a false "approved" email; CheckInItem treats any multi-loan match as an ambiguity error instead of closing an arbitrary loan (an identical retried message can no longer fall through to another borrower's copy); every event carries `source=ncip`.
- **Maintenance is race-free and never hollow**: `runAll()` holds a database lock for its entire execution (admin button, login hook and cron all funnel through it), the manual button always runs — simultaneous presses deduplicate, sequential presses re-run — and the data repair executes even when the sweep was just claimed by another session.
- **Stale `pendente` rows expire**: requests with `origine` `richiesta`/`ncip` whose window fully passed are closed by the sweep instead of dangling forever; expired rows repaired by DataIntegrity emit `reservation.expired`, keeping `reservation.cancelled` for duplicates.
- Lost/damaged outcomes surface the penalty in the CSV export and the loan detail view using the configured currency; archived titles show their real name on the user's own active loans (single convention with the mobile API and the emails).

### Testing
- Five new behavioural suites against the real database (~200 checks): sweep audit (SMTP forced unreachable for deterministic outbox asserts), NCIP coherence (check-in ambiguity and retry safety), mail coherence (persisted recipient locale, currency token), queue/capacity coherence (window overlap, legacy unblock, source contracts), loan outcome visibility. Five existing suites realigned. Three independent review rounds absorbed (internal adversarial, two CodeRabbit passes).

## [0.7.80]

Circulation hardening: database-level integrity for the loan/reservation lifecycle, durable circulation emails, and richer lost/damaged handling ([PR #415](https://github.com/fabiodalez-dev/Pinakes/pull/415)).

### Added

- **Durable email outbox.** Failed terminal circulation notifications land in the new `email_delivery_outbox` table (migration `migrate_0.7.80.sql`, plus a runtime-compatible creator for mixed-version deployments) and are retried with claim tokens and exponential backoff; permanently undeliverable rows are discarded after 10 attempts and logged, so dead rows neither pile up nor retain recipient data forever. Transaction-safe on both MySQL and MariaDB.
- **Two new circulation emails**, shipped in all five locales (code templates, installer seeds and settings editor): *reservation assigned — awaiting approval* (the reader is told a copy is allocated but must wait for the pickup email) and *loan closed with a lost/damaged copy*, including the outcome, closing date, notes and the charged amount formatted with the configured `app.currency`.
- **Assessed amount on lost/damaged returns.** The return form gains an optional charge field (decimal-comma tolerant, validated server-side) recorded on the loan and included in the outcome email.

### Changed

- **Database triggers now guard the circulation invariants**: a loan start after its due date, non-positive or duplicate active queue positions, double active reservations for the same user and book, and invalid reservation windows are rejected at the source — protecting against malformed imports and direct SQL as well as application bugs. Every queue reorder (user/admin/approval/NCIP cancellations, integrity repair, ReservationManager) moves rows through a dynamically-computed temporary range first, so reorders can never trip the new guards mid-flight, even with legacy NULL positions or leftovers from interrupted runs.
- Expiry sweeps and scheduled-loan activation were hardened around the new invariants; barcode returns accept active loans on archived titles without violating the soft-delete rule.

### Fixed

- The transaction probe guarding outbox creation used a MariaDB-only system variable and silently disabled the outbox on MySQL; it now uses the portable autocommit + savepoint probe.

### Testing

- New behavioural suites: `circulation-hardening-0780` (22 checks), `email-outbox-0780` (8), `migration-0.7.80` (real-file sandbox, idempotency); `loan-edge-cases` grew to 68 scenarios and its fixtures now satisfy the triggers they exercise; adversarial reproductions for the queue-reorder collision cases.

## [0.7.79]

The Emeroteca reaches the Android app (re-cut of the unpublished 0.7.78: its tag-triggered build died on the npm registry's retired audit endpoint before any asset was produced, and tags are immutable — same recovery as 0.7.74/0.7.75) ([PR #411](https://github.com/fabiodalez-dev/Pinakes/pull/411)).

### Added

- **Periodicals mobile bridge.** The emeroteca plugin (1.2.3 → 1.3.0) mounts five read-only endpoints under `/api/v1/periodicals/*`, mirroring the book-club bridge pattern: inert when the Mobile API plugin is inactive, nonexistent when emeroteca is inactive, and reusing the Mobile API authentication, token-quota and HTTPS middleware. Mastheads (keyset cursor, search over title/subtitle/ISSN, type filter with 422 on invalid values, shared-publisher join guarded for degraded installs without the optional `editori` table), volume years with derived holdings, issues with per-copy state and `has_public_pdf`, issue detail with the article-level table of contents. The PDF URL is exposed only when the per-issue public flag is on, always via the public streaming route — internal paths, notes and inventory numbers are never serialized. ETag/304 on every read endpoint; OpenAPI contributed through the `mobile_api.openapi` filter; one manifest row per endpoint in the idempotency spec. The plugin version bump re-runs activation on already-active installs so the new hook lands without manual steps. The companion Android release (1.5.0) gates its new Emeroteca section on the `/periodicals/health` probe.

## [0.7.77]

A feature release: a new bundled plugin for periodicals, a CSP opening for remote media backed by new guards, and a broad UI/hardening pass ([PR #410](https://github.com/fabiodalez-dev/Pinakes/pull/410)).

### Added

- **Emeroteca bundled plugin.** Periodicals management for newspapers, magazines, journals, bulletins and fanzines: testate with ISSN/type/periodicity and the shared publisher registry, bound or loose annate, numbered fascicoli with per-issue state, covers and per-issue PDFs (public or admin-only, served `no-store` so revoking public access is immediate), a Kardex workflow (expected issues generated from the periodicity, receive / mark-missing, derived consistenza with gaps), article-level spoglio with full-text search, and a public `/emeroteca` section (A–Z / publisher / type views, search, prev-next issue navigation, schema.org markup). Registered **inactive** on fresh installs and upgrades; activation from the admin UI creates its four tables idempotently. Covered by 22 E2E tests, 74 unit checks and a UI-driven demo seeder.
- **Remote HTTPS media under CSP.** `img-src`, `media-src` and `frame-src` now allow `https:` so remote covers, author photos, externally hosted audio and user-configured PDFs render; scripts and styles remain nonce-only. The contract is enforced three ways: a CSP spec + unit suite (token-delimited wildcard detection anywhere in active-content directives), and a narrowly-scoped OWASP ZAP allowlist that still blocks any wildcard on active content, on other directives, or on other origins (158 filter assertions).

### Changed

- **Redesigned admin lists** for authors, publishers, genres and books (responsive filter grids, scrollable tables, synced `aria-expanded` on the export/import dropdowns) and a **redesigned user reservations page** with canonical per-state text badges.
- **Plugin-aware public header.** Active plugin sections (Archive, Emeroteca, Events) appear in the navigation with segment-boundary active-state matching; the desktop search bar shrinks to make room.
- The fresh installer registers optional bundled plugins **inactive**, preserving the existing activation state on reinstalls.

### Fixed

- **French apostrophes broke two JS surfaces**: the digital-uploads initializer (three raw translations inside single-quoted JS literals killed the whole Uppy script) and 24 book-club confirmation dialogs (`htmlspecialchars` in `onX` attributes lets the browser hand a live quote back to the JS parser, so the destructive form submitted without confirmation). Both now use `json_encode(JSON_HEX_*)`.
- Issue PDF sizes render with locale-aware separators (intl `NumberFormatter`, locale-derived fallback without it); cover-fallback images that 404 before `DOMContentLoaded` still get the placeholder; stale hardcoded cache-busters bumped (archives CSS, emeroteca upload JS); terminology fixes in de/en/fr locales.

### Testing

- Emeroteca: 22-test Playwright spec (activation from the real admin UI, CRUD, kardex, uploads, public views, access control) + 74-check unit suite (schema idempotency, hook idempotency asserted after both activations, consistenza/kardex logic).
- CSP: dedicated spec + unit suite; the ZAP PII/CSP filter test grew to 158 assertions with negative coverage for every exception branch.
- Form submissions in the emeroteca spec and demo seeder arm the navigation waiter before clicking (no more `waitForLoadState` races); the seeder blocks every off-origin request at the network layer.

## [0.7.76]

### Fixed

- **Backfilled loan events now carry the physical copy.** The 0.7.75 history backfill synthesized `loan.created`/`loan.returned` events without `copia_id`, so the timeline could not show which physical copy was involved — the exact information the feature was asked for. An idempotent migration copies each loan's `copia_id` into every backfilled event that lacks it; loans without a copy, already-enriched events and real runtime events are untouched.

## [0.7.75]

### Fixed

- **Readable event cards.** The change list no longer shows a redundant `libro_id` row (the event is already anchored to its book), `utente_id` resolves to the account's display name instead of a bare number, and bare ISO dates render in the interface's localized format. Purely a rendering fix: it applies retroactively to events already recorded.
- **Build toolchain advisory cleared.** The frontend lockfile pins a patched `fast-uri` (four high-severity advisories published 2026-09-02, reachable only through the dev-time webpack toolchain — nothing shipped was affected); the compiled assets are byte-identical.
- **The activity timeline now includes pre-existing circulation.** The feed shipped in 0.7.73 recorded events from the moment of the action only, so every upgraded install started with an empty timeline even for books currently on loan. An idempotent migration backfills one `loan.created` event per existing loan (plus `loan.returned` for closed ones) and one `reservation.created` per reservation, with the historical timestamps, a NULL operator (rendered as "Sistema") and `source=backfill`; equivalent events already recorded for real are preserved, never duplicated.

## [0.7.73]

A feature and hardening release: a full activity timeline for every book, per-field control over CSV imports, a circulation and settings hardening pass backed by ~140 new checks, and an immediate-effect fix for plugin activation.

### Added

- **Activity feed (issue #374).** Admin and staff can inspect a field-level timeline at the bottom of each admin book page and a paginated library-wide feed at the bottom of the dashboard. Both can be filtered by event type and operator, searched server-side by book title or operator name, and update in place via AJAX (filters, search and pagination swap the feed without a page reload; the list scrolls inside its own container). Manual edits, physical copies, imports, enrichment, loans and reservations write to the existing `log_modifiche` audit table; all interface strings ship in every locale.
- **Per-field updates for existing books in CSV/TSV import (issue #380).** Eighteen field-family checkboxes — including twelve individual bibliographic fields (ISBN, EAN, title, subtitle, year, language, edition, pages, format, price, series, Dewey) — decide which data an import may overwrite on books already in the catalogue. Unchecked families are preserved untouched, and the post-import scraping enrichment honours the same selection.

### Changed

- **Audit history survives operator deletion.** The `log_modifiche.utente_id` foreign key now uses `ON DELETE SET NULL`, while each new event also retains the operator display name in its JSON metadata. Existing installations receive the change through an idempotent migration.
- **Audit events are atomic with their mutations.** Every circulation audit event is recorded inside the same transaction as the change it describes, with the before-snapshot taken after the row lock — a concurrent transition can no longer be misattributed. Automatic loan approvals are attributed to the system rather than to the requesting reader, and an expired pickup records its own `loan.expired` event instead of a generic cancellation.

### Fixed

- **Plugin activation now takes effect immediately.** The cross-request active-plugins + hooks payload was invalidated with a point delete, so a slower concurrent request could re-cache the pre-activation payload for up to 5 minutes and a freshly activated plugin's hooks stayed invisible. Invalidation now bumps a generation embedded in the cache key: stale writers land under the old generation, unreachable to every subsequent reader.
- **Circulation hardening.** Notifications for archived titles are no longer silently lost (four sweep/notification fetches still filtered soft-deleted books after the loan had been expired on purpose); cancelling or expiring a pickup releases only a copy actually held for it (`prenotato`), never a copy physically out under another open loan; the reassignment allocator shares the canonical copy-preference ordering (issue #384); NCIP checkout replays return `duplicate-request` instead of consuming a second copy; the legacy maintenance script no longer nests transactions and flushes deferred promotion emails post-commit.
- **Settings and themes hardening.** Activating a non-existent theme no longer deactivates every theme; the theme's custom CSS is actually rendered on the frontend (re-sanitized at render); the derived `--primary-dark` variable is emitted instead of falling back to a hardcoded colour; cookie-banner HTML descriptions are sanitized before persistence; the llms.txt API section renders when the API is enabled; settings inputs are hardened against array-typed POST values and out-of-range SMTP ports.

### Testing

- ~140 new checks land with this release: 106 circulation checks across four new suites plus an NCIP replay E2E, 31 settings/themes checks, a behavioural E2E for the loan-form UX (discussion #238), the CSV scraping-family gate, and the plugin-cache generation contract. The comprehensive browser suite grows to 145 tests.

## [0.7.72]

A small usability release: the two cache-maintenance controls on Settings → Advanced are unified into one.

### Fixed

- **Rate limits remain per-client behind trusted Docker/reverse proxies.** Forwarded client addresses are still ignored unless the immediate peer matches `TRUSTED_PROXIES`, but a validated proxy chain is now walked from right to left instead of trusting its caller-controlled first value. Private LAN client addresses are accepted after that trust boundary, preventing every patron behind the same proxy from sharing the registration/login bucket.

### Changed

- **A single "Svuota cache" button clears every cache.** The Advanced settings tab previously showed two separate controls — a LiteSpeed edge-cache purge and an application query-cache flush. They are now one admin button that, in a single action, always flushes the application query cache (APCu or file backend, shown as a hint under the button) and additionally purges the LiteSpeed edge cache where the server actually runs LiteSpeed. On plain Apache/nginx and in the Docker image only the query cache is cleared and no purge header is sent, so a non-LiteSpeed install no longer shows a control it cannot use and a LiteSpeed install no longer shows two buttons. The flush runs inside the web request, so it reaches the same APCu segment that serves pages — which a CLI command cannot.

## [0.7.71]

A performance release: a full caching overhaul for anonymous traffic (issue #387), delivered as one stable jump from 0.7.68. Every install upgrades with identical behaviour on a cold cache; the optional LiteSpeed edge cache is off by default and configured by administrators. Existing installs receive an idempotent catalog-materialization migration.

### Performance

- **Single-backend query cache with O(1) invalidation.** `QueryCache` uses one backend per request (APCu when available, else file) and invalidates the content namespaces (catalog, home, genre-tree, book-detail, reviews) through monotonic, file-backed generation counters — a bump makes every prior entry unreachable without scanning or deleting, and an atomic (temp + `rename()`) write can never wipe a counter and re-expose stale data. A non-blocking GC reclaims orphaned files.
- **Hot public datasets are cached, availability stays live.** The book-detail static DTO (metadata/authors/publishers/series/related), the reviews block and the bounded catalog/search listings are cached per locale, while `copie_disponibili`/`copie_totali`/`stato` are stripped before storage and re-read live on every request — a stale availability number can never be served. Every write path that changes a cached column invalidates the right namespace.
- **Materialized catalog aggregates and principal-author projection.** Bounded catalog counts and facet payloads are materialized in MySQL under the same generation token and short safety TTL as `QueryCache`; catalog rows read a write-maintained principal-author projection instead of three correlated `libri_autori` subqueries per book. Both fail open to live SQL if the table, counter or named lock is unavailable, and a rolling upgrade keeps the legacy subquery fallback until the projection columns exist.
- **Optional LiteSpeed full-page edge cache.** Anonymous home, catalog/search and canonical book pages can be served straight from LiteSpeed/LSCache with independent TTLs, locale-aware variation and tag-based purge — no PHP on a hit. Authenticated requests, private mode, mutations, authorization headers, visitor cookies, `Set-Cookie` responses and every unmarked route fail closed to `no-cache`. Book and card availability stays live outside the shared HTML via a capped, batched `no-store` hydration endpoint.
- **Sessionless anonymous path + lazy CSRF.** Anonymous read-only requests with no auth cookie no longer open a PHP session or embed a per-visitor CSRF token (minted on demand via `GET /csrf-token`), which is what makes the shared edge cache safe. CSRF validation is unchanged — every state-changing request still opens a session and is validated exactly as before.

### Administration and delivery

- **LiteSpeed is configured from Settings → Advanced**, with separate home/catalog/book TTLs and server/CLI-purge diagnostics. The section is shown only where it applies — a detected LiteSpeed/OpenLiteSpeed server, or when already enabled — and is hidden on plain Apache/nginx and in the Docker image, which never has LiteSpeed. Enabling it writes the required `CacheLookup on` and privacy-bypass rules into `public/.htaccess`; an upgrade heals a pre-existing block in place so the toggle is never inert. A "Clear edge cache" button purges every Pinakes-tagged entry on demand.
- **Cache coherence and front-end delivery.** Content, availability, review and home writes emit scoped LiteSpeed tags; successful admin/staff mutations also purge shared HTML defensively. Shared cached HTML uses a stable hash-based Content Security Policy instead of per-response nonces. The Digital Library plugin loads the audio-player CSS/JS only on pages that actually render an audiobook, and FontAwesome icon fonts use `font-display: swap` so they no longer block first paint.

### Compatibility

- No breaking change and no new required dependency. Existing installs receive an idempotent catalog-materialization migration; the Apache-based official Docker image permanently disables LiteSpeed at runtime and in Settings while keeping the APCu/file query cache active. Redis remains deliberately deferred.

### Internal

- Extensive new behavioural coverage across the cache backend, generation invalidation and atomicity, the availability split, the sessionless/CSRF predicate, LiteSpeed cache admission/bypass and CSP stabilization, the `.htaccess` self-heal, catalog materialization and the settings UI. The Verified Release pipeline was hardened for reproducible, attested prereleases.

## [0.7.68]

Circulation fixes. A request for a book that is currently out no longer fails on approval — it becomes a real waitlist reservation — and copy allocation is made consistent everywhere a copy is chosen. Also restores the "waiting for pickup" cancel action (#381), the terminal pickup email for archived books, and author photos supplied as a URL (#382).

### Fixed

- **A request for an already-loaned single-copy book now routes to the waitlist instead of failing on approval (#384).** The request used to be recorded as a pending loan, and approving it returned HTTP 500 because no copy could be allocated for the overlapping window. The reservation-vs-loan decision is now unified in one gate: when no copy is assignable through the requested window but physical copies exist, a real `prenotazioni` waitlist reservation is created; when a copy is free the loan proceeds (auto-approving where enabled). Legacy copy-less titles keep the pending-approval fallback.
- **Copy allocation is consistent across every site that picks a copy (#384).** The request gate, the approval step and the FIFO waitlist promotion now share one rule: a copy carrying a *preceding* commitment — one whose availability would depend on an earlier borrower returning on time — is never bound, and among otherwise-free copies the one with no *later* scheduled loan is preferred. A future scheduled loan therefore never comes to depend on a new borrower returning early, and an idle sibling is used instead of the copy a later loan already needs.
- **A "waiting for pickup" loan can be cancelled again (#381).** The cancel action resolves to `annullato` — or `scaduto` when the pickup deadline has already passed — restores the copy to availability, and never interferes with other loans or later reservations on the same or another copy.
- **The terminal pickup email reaches the borrower even for a soft-deleted book.** The cancel/expire notification query filtered out soft-deleted books, so a borrower whose book had since been archived silently lost the expired/cancelled email even though the loan was closed; the notice now fires regardless.
- **Author photos supplied as a URL now display (#382).** A photo entered as a remote URL is downloaded into `uploads/autori` on save instead of being stored as a bare link the page could not render.

### Internal

- The reservation-vs-loan decision now lives in a single shared `LoanRequestGate` that **every** public request entry point routes through (the book-detail modal, `POST /user/loan`, and the NCIP `RequestItem` service), so the #384 contract can never diverge between paths. NCIP `RequestItem` creates a real FIFO reservation when no copy is assignable instead of a bare pending request; `ncip_transactions` gains a `prenotazione_id` audit link (added to existing installs by the plugin schema self-heal), and `CancelRequestItem` cancels the linked reservation, reorders the queue and runs any promotions. Bundled ncip-server plugin bumped to 1.0.4.
- The companion Mobile API now preserves that same outcome at the JSON boundary: when the canonical loan path routes an apparently-immediate request to the FIFO waitlist it returns HTTP 201 with `data.type=reservation`, never a false 500 after committing the reservation. Mobile API 1.4.4 also exposes `DELETE /loans/{id}` and a `cancellable` loan hint so native clients can safely cancel pending, scheduled and `da_ritirare` loans (#381) without relying on the overlapping numeric id spaces of loans and reservations.
- New behavioural coverage for the reservation-routing gate, the three copy-selection sites (I1 preceding-dependency and I3 future-commitment ordering), the soft-deleted-book pickup notification and the NCIP request/cancel lifecycle, each driving the real controllers against a seeded database and failing on the pre-fix code by design.

## [0.7.67]

Fixes a silent upgrade gap discovered on a live install: a foreign key or column that a bundled plugin adds in a release was not applied when an already-active plugin was upgraded through the admin UI, even though the plugin version was bumped.

### Fixed

- **Plugin schema self-heal now covers foreign keys and columns, not just tables.** An admin-UI upgrade loads a plugin's old class into memory before the new files are swapped in, so PHP keeps running the old `onActivate`/`ensureSchema` during the upgrade even as the version is stamped forward — any foreign key or column added in that release never runs at upgrade time. The boot-time self-heal is the only path that applies it, and it previously probed missing *tables* only. It now also probes declared columns (`expectedColumns()`) and foreign keys (`expectedForeignKeys()`) and re-runs `onActivate` when any declared element is missing. Concrete effect: `ncip_transactions`' two foreign keys and `libri.file_url`/`libri.audio_url` are now restored automatically on the next page load after upgrading. No action is required; already-upgraded installs heal themselves.

### Internal

- ncip-server and digital-library declare their foreign keys / columns from a single source shared with the code that creates them, so the self-heal probe and the DDL cannot drift apart. New behavioural coverage (`tests/plugin-schema-selfheal-fkcol.unit.php`) reproduces the missing-FK / missing-column state against the real `PluginManager` and asserts it self-heals; it fails on the pre-fix code by design and is wired into the mandatory schema gate.

## [0.7.66]

A full audit of all 20 bundled plugins — security, correctness and schema fixes — plus four previously-orphaned plugin features wired into the UI.

### Security

- **Centralised SSRF guard for caller-configurable endpoints**: `HttpClient` gains
  an `ssrf_guard` option that resolves the target host, rejects hosts resolving to
  private/reserved address space, pins the connection to the vetted public IP, and
  rejects cross-host/port/scheme redirects. Enabled for the book-club AI endpoint
  and the z39 SRU client, both of which also moved off raw `curl` onto the guarded
  client.
- **book-club AI settings are admin-only**: the global API key and outbound
  endpoint were reachable by `staff` (AdminAuthMiddleware admits both roles); an
  inline admin re-check now gates both read and save.
- **resource-sync** Basic Auth is rate-limited; **dewey-editor** destructive
  endpoints require admin inline; **digital-library** file-preview links get
  `rel="noopener"`.

### Features

- **api-book-scraper settings page** is wired (`hasSettingsPage`/
  `getSettingsViewPath`) — previously a 404.
- **z39-server UNIMARC import** completes the advertised round-trip: a pasted or
  uploaded UNIMARC record pre-fills the book form (like the SBN import), never a
  direct insert, so validation and soft-delete rules are preserved.
- **viaf-authority** surfaces a VIAF/ISNI panel on the author edit page via the
  existing `author.form.fields` hook.
- **frbr-lrm** wires Book↔Work linking and full Expression CRUD into the admin UI
  via `book.form.fields`; the manifest drops the unbuilt dedup/relator claims.

### Fixed

- **api-book-scraper no longer wipes its stored API key** on a settings save that
  leaves the key field blank (blank means "keep the existing key").
- **archives soft-delete** frees the UNIQUE `reference_code`/`ark_identifier` for
  reuse by appending a per-id token (truncated to fit `VARCHAR(64)`), keeping the
  original code as a readable prefix.
- **dewey-editor** keys its data file off the full locale (`en_US` ≠ `en_GB`) with
  a legacy fallback; readers and writers share one resolver.
- Plugin hook registration (api-book-scraper) is now transactional and rethrows on
  failure; removed dead `activate()` code in discogs/open-library; aligned
  plugin.json versions and dropped stale `max_app_version` fields.

### Upgrade

- The five plugins with schema or hook changes (ncip-server, digital-library,
  frbr-lrm, viaf-authority, z39-server) get a plugin.json version bump so the
  Updater re-runs their `ensureSchema()`/hook registration on existing installs —
  the ncip `ncip_transactions` FOREIGN KEYs, digital-library's `file_url`/
  `audio_url` columns and the new frbr/viaf hooks are applied on upgrade, not only
  on fresh install. No core SQL migration.

## [0.7.65] - 2026-08-22

Add-to-calendar links in loan emails, the #366 legacy ready-pickup backfill, and a public-facing SEO pass.

### Features

- **Add-to-calendar links in loan emails**: the loan-approved and pickup-ready
  emails carry a calendar block with a Google Calendar link and a tokenized
  per-loan `.ics` download, so borrowers can save the due date in any calendar
  app (multi-calendar, no account linking). Rendered by `NotificationService`
  via `LoanCalendarLinks`; upgraded installs get the `{{sezione_calendario}}`
  placeholder appended to already-seeded templates by an idempotent migration,
  fresh installs from the template defaults. Strings translated in all five locales.

### Fixed

- **#366 residual repair**: legacy `da_ritirare` rows with a NULL
  `pickup_deadline` could stay "ready for pickup" forever — the expiry sweep
  skips NULL deadlines and the 0.7.63 repair intentionally preserves a pickup
  whose copy is free. `PickupDeadlineBackfill` now runs on every upgrade
  (admin updater and Docker both drive `Updater::runMigrations()`), after the
  canonical triggers are re-applied, assigning `today + pickup_expiry_days`
  capped at the loan's `data_scadenza`; rows whose window already closed get a
  past deadline and are culled by the next sweep through the normal expiry
  path. Covered by `tests/migration-0.7.65-rc.1.unit.php`.

### SEO

- Canonical URLs no longer inherit tracking query strings (layout fallback and
  book detail), so `?utm_*` variants stop becoming indexable duplicates.
- hreflang alternates and locale-prefixed sitemap URLs (`/en/…`) pointed to
  non-routable 404s: hreflang is no longer emitted while the language is
  session-based, and the sitemap lists only default-locale URLs.
- The sitemap drops the auth pages (blocked by robots.txt anyway), skips
  authors/publishers with no visible books, and logs when the explicit
  book/author caps truncate; robots.txt now disallows every active locale's
  login/register variant.
- Author, publisher and genre archives share one localized SEO block:
  canonical on the name-based URL (the id route collapses into it),
  translated titles/descriptions, CollectionPage (+Person) and BreadcrumbList
  JSON-LD. Book pages brand their title and Library schema with the
  configured `app.name`.
- Internal search results and the standalone auth pages carry
  `noindex,follow`; empty `og:image`/`twitter:image` tags are omitted.
- Catalog pagination is server-rendered with real `?page=N` links (crawlable
  without JavaScript); grid covers load lazily and the book cover — the LCP —
  gets `fetchpriority="high"`. Book meta descriptions are cut multibyte-safe.
- Public events emit `Event` + `BreadcrumbList` JSON-LD.

## [0.7.64] - 2026-08-21

Optional multiple physical copies of the same title per borrower (#238).

### Features

- **Multiple copies per borrower** (opt-in, Settings → Loans → "Più copie dello
  stesso titolo", off by default): staff can lend more than one distinct
  physical copy of the same title to the same borrower. Every loan stays bound
  to its own copy, so no two open loans for a borrower can share a copy.
  Pending requests, reservations and legacy copyless rows keep the historical
  borrower/title uniqueness, and per-copy overlap guards, eligibility, capacity
  and the max-active-loans limit are all unchanged (each copy counts as one
  active loan). The desk "Salva e registra un'altra copia" flow keeps the
  selected title and focuses the next copy scan for fast batch check-outs.
  Existing installations are unaffected without a migration — the setting
  resolves to off until it is enabled, and fresh installs seed it off in all
  five shipped locales.

### Fixed

- **Circulation can no longer hand out a copy that is physically out** (#366
  residual): creation, approval, rescheduling, renewal, reassignment and the DB
  triggers all treat an `in_corso` loan whose due date has passed but that the
  maintenance sweep has not yet flipped to `in_ritardo` as an open-ended
  commitment (same rule as `CapacityService`). The pre-assigned copy is
  validated against the loan's book, and a request whose whole window is already
  past is rejected instead of becoming an unpickable `da_ritirare`.
  `confirmPickup` re-checks the loan rows holding the copy (not just
  `copie.stato`) before issuing. Approval and the date-aware allocator still
  accept a copy that is physically out as long as the requested window does not
  overlap its open loan — the per-copy overlap check is the authority — so a
  future-dated request is not needlessly blocked when the only copy is on loan.
- **Backdating a loan's due date reports a clear error**: actively moving an
  active loan's due date into the past used to reach the per-copy overlap
  trigger and surface an opaque `loan_update_failed`; the edit flow now rejects
  it up front with the same `expired_window` message the create/approve flows
  use, while still allowing edits to an already-overdue loan whose due date is
  left unchanged.
- **Queue promotion re-checks borrower eligibility**: a user suspended (or
  with an expired card) while waiting is skipped — their reservation stays
  active in the queue — instead of being promoted to a pending loan that
  approval then refuses, burning their position and pinning the copy. The scan
  is not capped at the first 25 rows, so an eligible reader farther down the
  FIFO can still receive a free copy.
- **Reservations on books without copy rows are refused up front**: the queue
  can only convert physical `copie` rows, so a legacy book with none produced
  reservations that silently never converted until they expired.
- **Reassigning a promoted loan keeps queue semantics** (multiplicity ON): a
  row with `origine='prenotazione'` still in `prenotato`/`da_ritirare` is
  treated as a queue commitment when reassigned to another borrower, so a user
  can never end up with a physical loan plus a promoted queue position for the
  same title.
- **The desk create form is double-submit safe**: submit buttons disable on
  first submit and every rendered form carries a one-time server token. A
  replayed POST therefore cannot register a second real loan on another copy,
  including retries caused by latency or double-clicks.
- **A freed copy now serves every compatible hold**: `reassignOnReturn` keeps
  assigning the returned copy until no blocked FIFO candidate can take it
  (holds with disjoint windows previously waited for the next maintenance
  sweep), and holds whose window is entirely past are no longer eligible.
- **Pickup-ready emails are claim-and-retry**: a new
  `prestiti.pickup_notification_sent` flag (seeded in the schema and added by
  an idempotent upgrade migration) makes the "ready for pickup" email
  idempotent. Token-owned claims recover after a worker crash, failures are
  retried fairly by the hourly notification cron, and existing rows are
  initialized as already handled without firing legacy circulation triggers.
  Every future transition to ready resets the flag; reassigning a ready loan
  resets the recipient-specific claim as well.
- **Borrowers without an email no longer wedge the notification crons**:
  expiry warnings and overdue notices for them keep their claim (no more
  endless SMTP retry churn) and still produce the in-app/admin notifications,
  which previously never fired for email-less borrowers.
- **Cron process locks no longer unlink after unlock** (the race
  `scripts/maintenance.php` already documented), both cron entrypoints refuse
  non-CLI execution, and `runAll()` refreshes the cross-session cooldown
  marker so an admin login right after the cron no longer re-runs the whole
  maintenance synchronously.
- **NCIP**: a recognized active `FromAgencyId` is attributed to transaction
  logs without silently turning the historically informational partner table
  into a new authorization gate. `CheckInItem` and `RenewItem` reject a
  malformed `UserId`, use a valid optional `UserId` to select the correct loan,
  and refuse an ambiguous title-only mutation when several NCIP loans are open;
  check-out/check-in/renew are logged to `ncip_transactions`; renewals claim
  the window from the day after the current due date (#336 parity with web).
- **Return flow**: a deadlock/lock-timeout now reports a dedicated
  "another operation was updating this book, retry" error instead of the
  generic failure; reservation availability emails format dates in the
  recipient's language like the rest of the #360 pipeline; cancelling a
  promoted pending loan releases and reassigns its copy immediately.
- **Admin reservation edits**: an out-of-whitelist status is rejected instead
  of silently coerced to `attiva`, and a completed (promoted) reservation
  cannot be flipped while its converted loan is still open.
- **Localized gender on the admin user-details page** (#371): the stored
  `sesso` enum (`M`/`F`/`Altro`) now renders through the same translated labels
  as the edit form instead of the raw code, with a safe fallback for unexpected
  values.
- **Notifications dropdown no longer clips on phones**: the panel was anchored
  to the right-hand bell button, so a near-full-width dropdown overflowed the
  right screen edge and cut off "Segna tutte come lette" and the notification
  bodies. Below the `md` breakpoint it is now pinned to the viewport and
  centred; the desktop panel is unchanged.

### Changed

- Release artifacts now have one canonical producer: the tag-triggered
  `Verified Release` workflow builds twice, verifies the package, uploads a
  draft, checks every server-side digest and only then publishes it. The local
  release script performs preflight, pushes the annotated tag and monitors that
  workflow instead of racing it with a second upload path.
- `reassignOnNewCopy` now walks the whole blocked-hold FIFO and assigns the
  first compatible candidate (skipping an incompatible head) instead of
  stopping at the first blocked hold; a same-borrower conflict on the target
  copy blocks regardless of dates. Observable also with the multiplicity
  setting off: a younger reservation can receive a returned copy while the
  head stays blocked, without losing its priority for future copies.

## [0.7.63] - 2026-08-20

### Fixed

- **#366 upgrade recovery**: `migrate_0.7.63-rc.1.sql` immediately demotes
  impossible legacy `da_ritirare` rows back to `prenotato` without cancelling
  the scheduled loan, clears their stale pickup deadline and releases the bad
  copy assignment; maintenance repeats the same repair as a runtime safety net.
  The loan-integrity trigger permits
  corrective edits to an already-existing overdue overlap (including moving
  the start date to tomorrow) while still rejecting newly introduced overlaps.
  Scheduled loans without a pinned copy are assigned a real free copy before
  being announced ready, so `confirmPickup()` cannot inherit a copy-less row.

## [0.7.62] - 2026-08-19

Overdue-loan recalls (solleciti) and emailing the loan receipt (#360).

### Features

- **Loan recalls (solleciti)**: overdue loans can now be chased beyond the
  single overdue notification. Automatic recalls repeat at a configurable
  interval up to a configurable cap (Settings → Loans → "Solleciti automatici",
  off by default; sent by the notifications cron or on admin login). Staff can
  also send a manual recall for one loan — from the loan detail page or a
  per-row action on the loans list — or for many at once from the loans list
  via the bulk action bar — manual recalls ignore
  the automatic schedule but share the same per-loan counter
  (`prestiti.recall_count` / `last_recall_at`, added by
  `migrate_0.7.62-rc.1.sql` and self-healed at runtime). New editable email
  template `loan_recall_notification` in all five locales.
- **Email the loan receipt PDF**: next to "Scarica Ricevuta PDF", the loan
  detail page now has "Invia Ricevuta via Email", which sends the same PDF as
  an attachment to the loan's user (new editable template
  `loan_receipt_email`; the mailer gained in-memory attachment support).
- **Emails in the recipient's language**: user-facing notification emails
  (loan warnings/overdue/recalls, receipt, approvals, pickups, returns,
  reservations, wishlist, registration and account emails — and per-admin for
  admin alerts) now render in the recipient's preferred language
  (`utenti.locale`, the same value that drives their UI language), including
  date formats and translated labels, falling back to the installation locale
  when the user has none. Password-reset mail already followed the
  requester's session language.
- **Language choice at registration**: on multi-language installs the
  registration form now offers a "Lingua preferita" select (defaulting to the
  language the visitor is browsing in), validated server-side against the
  shipped locales. The profile page and the admin user forms already offered
  the same choice; together they cover registration, self-service and admin.
- **Plugin ZIP updates (#358)**: uploading a plugin ZIP whose name matches an
  already-installed plugin now updates it in place — its id, settings, data and
  hooks are preserved and its files are swapped atomically — instead of failing
  on the existing directory. Updating an already-active plugin now runs its new
  lifecycle (`onActivate()`/`ensureSchema()`) on the next request via a
  pending-update marker, rolling back package, metadata and hooks if the new
  version fails to activate — so schema changes shipped in an update are applied
  instead of being silently skipped. Covered by contract and per-bundled-plugin
  integration tests.

### Fixed

- **Book announced "ready for pickup" while still on an overdue loan (#366)**:
  a reservation scheduled right after a loan that then went overdue and was
  never returned was promoted to `da_ritirare` on its date alone, emailing the
  next patron a wrong "ready for pickup" notice while the book was still out.
  Promotion is now gated on a copy being physically free (active loans below the
  copy count; a pinned copy must be on the shelf). The full reported sequence is
  covered too: rescheduling an open reservation/pickup no longer leaves a stale
  `pickup_deadline` for the expiry sweep to cull a valid loan against, the
  overdue flip now runs first in the maintenance pass so an unreturned overdue
  loan keeps holding its copy, and `renew()` refuses a date-overdue loan.
- **Concurrent circulation actions no longer corrupt state**: eight
  transactions (approve/return/reject/cancel loan and reservation) resolved the
  loan id with a plain read before taking the book lock, so under REPEATABLE
  READ their later reads were blind to a competitor that had just committed.
  A just-cancelled reservation could be promoted and emailed, and one physical
  copy could be committed to two loans. The lookups now run before the
  transaction so the first locked read fixes the snapshot, and reservation
  promotion claims the row with a state-guarded update.
- **Pickup confirmation** now refuses a copy that is still out on another loan
  (`prestato`), preventing a double issue of the same physical copy.
- **Admin reservation cancellation** now promotes the next reservation in the
  queue immediately, like every other path that frees a copy.
- **Overdue notices and automatic recalls** now fire for loans whose book has
  been archived (soft-deleted) — the chase-up mail no longer filters those out.
- **The "reservation available" email** can no longer be sent twice when the
  retry sweep races the request that promoted it.
- Admin direct loans cap the pickup deadline at the due date; bulk loan
  extension and reschedules re-check borrower eligibility; expiry audit notes
  use the same day the decision was made near midnight.

### Internal

- CI: the OWASP ZAP baseline no longer fails on the ISBN/EAN-13 PII-disclosure
  false positive, allowlisted narrowly to 13-digit codes on bibliographic pages
  (#359).
- Release verifiers now require `storage/sessions/.gitkeep` and reject every
  other entry there (files, symlinks, stray directories), and an unreadable
  plugin-update marker is retired instead of permanently blocking future
  updates of that plugin.

## [0.7.61]

Physical-copy management from the book summary, with the whole holding and
circulation lifecycle made atomic and derived from the copies.

### Features

- The book page (`/admin/books/{id}`) now shows a **Copie Fisiche** section for
  every book — even one with no copies — with an "Aggiungi copia" modal, per-copy
  status editing, and per-copy delete. Copy status covers the physical states
  (available, maintenance, under restoration, in transfer, lost, damaged); an
  out-of-circulation copy lowers the derived total on its own.
- A book can be created with zero physical copies and have them added later from
  the summary. On the edit form the copy count is read-only and delegates to the
  per-copy management, so availability is always derived from the copies.

### Fixes

- Book creation is now atomic: the book row and its initial copies are committed
  together, so a copy-creation failure can no longer leave an orphan book with no
  holdings. The bulk `increase-copies` endpoint is likewise transactional,
  allocates collision-free inventory codes, promotes the wait-list, and validates
  its input.
- Adding an available copy repairs blocked reservations and promotes the next
  wait-list entry into a physical-copy-linked loan, mirroring the loan engine.
- Copies under restoration or in transfer can now be deleted from the UI; the
  loan/reservation system keeps exclusive ownership of the `prestato`/`prenotato`
  states.
- Legacy reservations with a missing or past start date are no longer promoted
  into back-dated loans.
- Admin copy routes stay fixed English literals (not routed through the i18n
  system), inventory-code allocation escapes LIKE metacharacters, and the copy
  note is sanitised and length-capped like the inventory number.
- All new copy-management strings are translated across the five locales.

### Upgrade notes

- **Legacy availability is migrated automatically.** Books that predate copy
  tracking (only the old counters, no per-copy rows) are backfilled into real
  copies *before* availability is recalculated, so availability carries over
  from the old counter model to the new copy-derived one without being zeroed.
  Active loans and reservations are preserved, and copies already marked
  lost/damaged/maintenance/under-restoration/in-transfer are left untouched.
- A book whose only record of unavailability was the legacy counter (marked
  unavailable with no active loan) becomes available again after the upgrade:
  the new model derives availability from physical copies, and a physically
  missing book with no loan leaves no machine-readable trace to preserve.
  Re-mark those copies from the book page after upgrading.
- If you upgraded through an intermediate version that had already zeroed a
  legacy book's counters before this release, the backfill cannot reconstruct
  the lost count. Restore `libri.copie_totali` from the automatic pre-upgrade
  backup under `storage/backups/`, then re-run the availability recalculation
  from Maintenance, or re-add the copies from the book page.

## [0.7.60]

Maintenance release: UPC barcode support, a read-only availability field, and a
PHP 8.5 scraping fix, bundled with CI hardening.

### Features

- The barcode field accepts a 12-digit **UPC-A** (board games and other
  non-book items), canonicalised to its 13-digit GTIN so it validates, stores,
  searches and de-duplicates exactly like an EAN-13. CSV and TSV import both go
  through the same path. The field is relabelled EAN → EAN/UPC across all five
  locales.

### Fixes

- ISBN scraping no longer fails with "Risposta non valida dal servizio ISBN." on
  PHP 8.5: the deprecated `curl_close()` calls that leaked a notice into the JSON
  response body were removed.
- The book editor's availability field is now read-only, matching the fact that
  it is a value derived from the physical copies; editing it was a silent no-op.
- The floating scroll-to-top button no longer overlaps the Save button at the
  bottom of the book form.

### Internal

- The GitHub Actions security audit no longer breaks on upstream tag drift, and
  the NCIP CheckOut regression test provisions its own available copy so it is
  deterministic under sharded runs.

## [0.7.59]

Consolidation release: the complete integration of PRs #335, #337, #339 and
#340, hardened together with the fixes found during their combined review and a
security pass. Validated through three release candidates.

### Fixes

- Loan and reservation state, availability and date handling now use the same
  guarded production paths across the web UI, mobile API and background jobs.
- Orphan plugin hooks are disabled reversibly and the regression test creates
  its own foreign-key-safe fixture, including on a completely fresh database.
- Framework-generated error responses receive the same nonce-based Content
  Security Policy as normal pages.
- Review findings covering migration selection, fixture cleanup, SweetAlert
  confirmation, localization parity, generated assets and accessibility have
  regression coverage.

### Release verification

- The exact distributable is installed and exercised through the full E2E and
  four-shard browser regression suites.
- Chromium, Firefox and WebKit run accessibility/runtime checks; OWASP ZAP
  blocks medium/high passive-scan findings.
- PHP 8.2–8.5, MySQL 8.0/8.4 and MariaDB 10.11/11.4 are verified, together with
  fresh installs in all five bundled locales and the complete upgrade chain.
- Static analysis, dependency/secret/vulnerability scans, reproducible archive
  checks, SPDX SBOM and provenance are mandatory release gates.

---

## [0.7.53]

A performance release: the whole application answers faster, with a dramatically lighter database footprint per request.

### Improvements

- **Time-to-first-byte cut across every page** — the per-request bootstrap no longer opens a second MySQL connection for settings, re-scans bundled plugins, re-reads plugin hooks one query per plugin, or re-fetches the active theme and language list on every hit. All of it is served from a short-lived cache (APCu when available, files otherwise) that every admin write invalidates immediately. A warm home-page request dropped from ~90 database queries to under 20.
- **The home page renders instantly** — the hero counters and the "latest books" grid are now rendered server-side from a cached dataset instead of arriving via JavaScript after two extra API round-trips, so the page is complete on first paint with no spinners. The hero background image is preloaded with high priority (it is the page's LCP element).
- **Catalogue search and filters are much faster** — the total-count scan, the availability aggregate and the six sidebar-facet aggregations are cached per filter set for two minutes; book, loan and CMS changes clear them instantly.
- **Two plugins no longer rewrite their hook rows on every request** — Digital Library and GoodLib re-registered their hooks (nine `INSERT`s) on every single page view; they now self-heal only when the rows are actually missing.

### Database Changes

- New composite indexes `idx_libri_deleted_created` and `idx_libri_genere_deleted_created` on `libri` — the "newest first" sorts used by the home page and the catalogue no longer filesort the whole table (migration `migrate_0.7.53.sql`, applied automatically by the updater).

### Upgrade Notes

- Back up your database before updating (the in-app updater does this automatically).
- If your host offers APCu, enabling it gives the new caches their fastest backend; without it they transparently use `storage/cache` files.

---

## What's New in v0.7.52

A mobile alignment fix for the book page.

### Fixes
- **The book info and share cards line up with the rest of the sheet on mobile** — their contents were inset by the cards' own horizontal padding, sitting further right than the title, the description and the section headings. On phones that padding is dropped so the metadata rows and the social buttons sit flush at the same edge as everything else.

### Database Changes
- None — a view and the compiled layout CSS only.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.51

A public-catalogue and interface-polish release.

### Improvements
- **Catalogue search no longer flickers stale results** ([#317](https://github.com/fabiodalez-dev/Pinakes/pull/317)) — changing a filter or typing quickly cancels the previous in-flight request, so an earlier, slower response can't overwrite the newest results. Clicking a page number also scrolls back to the top of the book list, on mobile and desktop.

### Fixes
- **Chip and button contents are vertically centred** ([#316](https://github.com/fabiodalez-dev/Pinakes/issues/316)) — icon-and-label pills across the admin and public interface, and the keyword chips on a book page, no longer leave extra space below their contents.
- **The book info-card header lines up with its rows on mobile** — the "Informazioni Libro" label is inset with padding instead of a margin, so it stays aligned with the metadata rows regardless of stylesheet order.
- **The "Curatore" book role reads "Editor" in English** (completes [#315](https://github.com/fabiodalez-dev/Pinakes/pull/315)) — the volume-editor relator is now labelled Editor, distinct from Publisher; the unrelated staff-role example stays "curator".

### Database Changes
- None — views, translations and compiled assets only.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.50

A circulation-correctness and update-operability release.

### Improvements
- **Interlibrary (NCIP) rejections are classified precisely** — CheckOut, Renew and RequestItem map permanent refusals (unknown item, duplicate request, ineligible patron, loan/renewal limit reached, no free copy, non-renewable loan) to stable NCIP ProblemTypes instead of a generic retryable failure, so a partner system stops retrying a request that can never succeed. Only genuine transient errors stay `temporary-processing-failure`, and a concurrently cancelled loan no longer reports a false check-in.
- **Availability and notifications share the canonical circulation services** — reserved / on-loan / available state and the "has a free copy" checks are computed the same way everywhere (`CapacityService`), keeping the public catalogue, notifications and the NCIP server consistent.

### Fixes
- **Book descriptions no longer gain blank lines between paragraphs** ([#313](https://github.com/fabiodalez-dev/Pinakes/issues/313)) — editor HTML is rendered as authored instead of having a stray `<br>` inserted between every `<p>`; plain-text descriptions still keep their line breaks.
- **An interrupted in-app update can no longer lock the site out of recovery** — the maintenance page keeps the login and update routes reachable so an administrator can always sign in and finish or abort the update; a concurrent update rejected at the lock never tears down the active one; and the maintenance flag is always lifted when the request ends. The 503 maintenance page was restyled to the Pinakes identity and now shows the operator's configured name and logo.

### Database Changes
- None — application code, views and compiled assets only.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.49

A mobile-layout maintenance release for the redesigned public catalogue.

### Fixes
- **Catalogue pagination is horizontal, centred and wrapping on phones** instead of becoming a vertical stack after the Bootstrap removal ([#310](https://github.com/fabiodalez-dev/Pinakes/pull/310)).
- **Book-detail actions and information sections fit narrow screens** — source links stack at full width, content padding is corrected, and headers align with metadata rows.
- **Related books show one complete cover plus a preview of the next**, restoring the intended mobile scroll affordance.
- **Mobile browser chrome follows the active theme**, and breadcrumb separators now work consistently throughout the public frontend.

### Database Changes
- None — views and compiled layout CSS only.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.48

A search-relevance release shared by the web catalogue and companion app.

### Improvements
- **Weighted catalogue ranking** ([#309](https://github.com/fabiodalez-dev/Pinakes/pull/309)) — exact identifiers and title matches rank above author, subtitle, publisher, keywords and description matches across header autocomplete, AJAX search and the Mobile API.
- **Stable pagination and wildcard handling** — public search caps oversized queries/results, meaningless wildcard-only searches fall back to title order, and mobile browsing retains full cursor pagination.
- **Consistent mobile results** — the Mobile API uses the same normalized, entity-decoded relevance model as the web catalogue.

### Database Changes
- None — ranking is generated from existing catalogue fields.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.47

A broad interface redesign plus circulation and cataloguing corrections.

### Improvements
- **Editorial-minimal frontend and account redesign** — public, authentication, patron and admin surfaces share a cleaner typography, spacing and action system, with self-hosted fonts and rebuilt assets.
- **Configurable frontend layout variants** — theme settings can select the shipped public-layout treatment without custom CSS; the obsolete Bootstrap CSS dependency and override layer were removed.
- **Plugin actions match native book actions** — Digital Library, GoodLib and other hook-injected buttons/links follow the same layout and responsive rules as core actions.
- **Grouped authoring fields** ([#308](https://github.com/fabiodalez-dev/Pinakes/pull/308)) — contributor inputs on the book form are organized consistently; CSV import also accepts the added column alias from [#305](https://github.com/fabiodalez-dev/Pinakes/pull/305).

### Fixes
- **Overdue loans still prove reading eligibility** — `in_ritardo` is included wherever reviews and “has this book” checks evaluate current/previous borrowing, including Mobile API and NCIP mirrors.
- **Frontend and plugin responsive polish** — catalog facets, archives, Book Club, digital-content actions and email layouts were reconciled with the new design system.

### Database Changes
- None — this release changes application code, views, locale catalogues and compiled assets.

### Upgrade Notes
- Rebuild custom frontend integrations that depended directly on Bootstrap classes; Bootstrap is no longer shipped by the frontend bundle.
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.46

A circulation-policy and catalogue-count correctness release.

### Features
- **Optional automatic loan approval** ([#301](https://github.com/fabiodalez-dev/Pinakes/issues/301)) — administrators can opt in under loan settings. Eligible immediate patron requests are promoted through the same lock-safe approval pipeline used by staff, receive a physical copy and enter `da_ritirare`; failures remain pending for manual review.
- **Mobile API advertises and reports approval behavior** — health/OpenAPI responses expose whether approval is required, and request results return the actual pending or ready-for-pickup state.

### Fixes
- **Author, publisher and genre counts exclude soft-deleted books** ([#306](https://github.com/fabiodalez-dev/Pinakes/issues/306)), including list filters and bulk exports.

### Database Changes
- `migrate_0.7.46.sql` — adds the idempotent `loans.auto_approve_requests` setting, defaulting to off and preserving an existing operator choice.

### Upgrade Notes
- Automatic approval remains disabled after upgrade until an administrator explicitly enables it.
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.45

A catalogue-consistency and scraper-maintenance release.

### Improvements
- **Catalogue availability facets now reflect real circulation state** — reserved, on-loan, available and non-circulating records are shown and counted separately; records without physical copies remain visible under the complete catalogue without being mislabelled as loans ([#303](https://github.com/fabiodalez-dev/Pinakes/issues/303)).
- **Catalogue card rows stay aligned** — a card with a subtitle no longer pushes its author, publisher and "Details" out of line with neighbouring cards; the subtitle space is reserved per row only where a row actually needs it, and the layout re-aligns after AJAX filtering, on resize, and once web fonts settle ([#298](https://github.com/fabiodalez-dev/Pinakes/issues/298)).
- **Language completion statistics are derived from the Italian source catalogue** — every locale (list and edit views alike) now uses the same live denominator, so translation percentages cannot drift from the shipped keys.
- **Open Library plugin 1.0.4** — replaces the retired third-party Goodreads-cover service with a direct, timeout-bounded lookup of the public Goodreads ISBN page. Because Goodreads sits behind Cloudflare (which serves an anti-bot page to PHP's HTTP client but not to the system `curl`), the cover lookup shells out to the `curl` binary when the host allows process execution, and degrades gracefully to no cover where it doesn't. Only HTTPS cover URLs from exact Goodreads/Amazon CDN domains or their subdomains are accepted; its manifest requires Pinakes 0.7.16+ and PHP 8.2+.
- **Email templates: `{{pickup_deadline}}` now resolves**, and every placeholder token carries a localised description tooltip in the template editor ([#304](https://github.com/fabiodalez-dev/Pinakes/issues/304)).

### Database Changes
- None — catalogue availability is derived from existing circulation state and language statistics are computed from the shipped locale files.

### Upgrade Notes
- The bundled Open Library plugin is replaced atomically by the updater and its database metadata advances from 1.0.1 to 1.0.2.
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.44

A bug-fix release for email-template links.

### Fixes
- **Email template links no longer double-prefixed with `/admin`** ([#299](https://github.com/fabiodalez-dev/Pinakes/issues/299)) — the WYSIWYG editor's `convert_urls` resolved every link against the admin page, so a placeholder like `{{login_url}}` was saved as `https://host/admin/{{login_url}}` and the sent email link came out double-prefixed. `convert_urls` is now off on every editor, so URLs and placeholders are stored exactly as written. Templates already saved corrupted are repaired automatically the next time the settings page is opened (and emails render correctly even before that).

### Database Changes
- None — the repair of already-corrupted templates runs in PHP on settings open (portable across MySQL 5.7+/MariaDB); no schema or migration.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.43

A bug-fix release that completes the #292 hero-upload fix and applies the admin custom CSS on admin pages.

### Fixes
- **Hero background upload now saves and displays** ([#292](https://github.com/fabiodalez-dev/Pinakes/issues/292)) — completing the CSP fix from 0.7.42. Two further bugs were behind "the image doesn't show after upload": the file was written under `/uploads/assets` but stored with an `/assets` path that 404s, so a successfully-uploaded image never rendered; and a photo larger than the server's `upload_max_filesize` was dropped silently as a fake "saved". The stored path now matches the served path, oversized uploads show a clear error naming the `php.ini` keys to raise, and every image uploader (hero, event, book cover, author photo, site logo) now shows a preview of the picked file before saving.
- **Admin custom CSS now applies on admin pages** ([#291](https://github.com/fabiodalez-dev/Pinakes/issues/291)) — the configured custom header CSS was emitted only on the public frontend, so a rule meant to (for example) hide unused fields on the book form had no effect. It now applies to the admin interface too.

### Database Changes
- None — this release changes views, a controller and client-side assets only; no schema or translation-count changes.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.42

A security-hardening and bug-fix release. It closes a set of access-control,
SSRF, open-redirect and data-exposure issues found in a full security audit,
and fixes three OPAC/CMS regressions.

### Security
- **Access control on the internal APIs** — `GET /api/editori` and `GET /api/libri` were reachable without a session and returned every column, exposing publisher PII (email, phone, tax code, referent contacts) and internal book fields (purchase price, private notes, inventory numbers). They now require the admin session middleware, matching how they are already documented.
- **Borrower privacy on the book page** — a non-admin patron could open `/admin/books/{id}` and read every borrower's name, email and loan history. Borrower identity is now gated to admin/staff only (the reservations block already was).
- **Privilege boundary on updates** — a `staff` user could upload and install an update package, or trigger a full update/downgrade, over the application files (a route to code execution). Those actions now require an explicit `admin` role, like the backup/restore actions.
- **Public search API** no longer leaks the current borrower's name/email; it returns availability and due dates only.
- **Session-role enforcement** — admin routes now re-validate the user against the database each request, so demoting or suspending a user takes effect immediately instead of at session expiry.
- **Secrets** — the settings page no longer shows API keys or the reCAPTCHA secret to the `staff` role; API keys are accepted only via the `X-API-Key` / `Authorization: Bearer` header (never a `?api_key=` query string, which leaks into access logs).
- **Hardening** — fixed an open redirect after login and on the language switch (`/\` backslash bypass), a path-traversal on language-file upload, SSRF in the image proxy (redirect + DNS-rebinding) and the installer DB test, plaintext SMTP password storage in the installer, CSV/formula injection in two exports, reset-link Host-header poisoning, a login timing side channel, and rate-limit bypass via forged forwarding headers.

### Fixes
- **Book subtitle now shows on the public book page** ([#293](https://github.com/fabiodalez-dev/Pinakes/issues/293)) — the `sottotitolo` was never rendered in the OPAC; it now appears under the title.
- **Hero background image upload under a strict CSP** ([#292](https://github.com/fabiodalez-dev/Pinakes/issues/292)) — the uploader fetched a `blob:` URL that a strict `connect-src` policy blocks, so the image never reached the form. It now uses the file object directly and works regardless of the site's CSP.
- **Related-books count on high-resolution screens** — the related strip packed 6+ cards on high-res laptops; it now caps at 4 and adapts down to 3/2/1 as the viewport narrows, with the mobile swipe carousel unchanged.

### Database Changes
- `migrate_0.7.42.sql` — bumps the `da_DK` translation key count (6607 → 6611) for installs already on 0.7.41, after this release added 4 UI strings. Idempotent.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.41

A maintenance release bundling seven merged pull requests: a new interface
language, bulk loan extension, mobile catalogue improvements, a responsive
book-detail fix, and a batch of reviewed bug fixes.

### Features
- **Danish language (da_DK)** ([#279](https://github.com/fabiodalez-dev/Pinakes/issues/279)) — Danish is now a fully selectable interface language, contributed by [@HansUwe52](https://github.com/HansUwe52). Ships the complete UI catalogue (`locale/da_DK.json`, full key parity with the Italian source), route slugs (`locale/routes_da_DK.json`), and the fresh-install seed in Danish (`installer/database/data_da_DK.sql`: 181 genres, 22 email templates, CMS pages, settings). `da_DK` is wired into the installer language step, the email/route fallback chains, and the bundled-locale updater path. Emails fall back to English when a Danish template is missing.
- **Bulk loan extension** ([#281](https://github.com/fabiodalez-dev/Pinakes/issues/281)) — the admin loans list gains a select-all-extendable checkbox column and a bulk "extend by N days" action. Each extension runs from `max(today, current due date)`, is capacity-checked against other loans and reservations, and the whole batch rolls back atomically if any single loan would conflict. Capped at 500 loans per batch to bound the transaction.
- **Mobile catalogue language filter** ([#282](https://github.com/fabiodalez-dev/Pinakes/issues/282)) — the mobile API exposes `GET /catalog/languages` (real catalogue language values + counts) and accepts a free-text `language` filter on `/catalog/search`, matched case- and whitespace-insensitively. The mobile author sort now matches the web catalogue (principal-author surname, authorless titles last in both directions).

### Fixes
- **Danish installer seed selection** ([#279](https://github.com/fabiodalez-dev/Pinakes/issues/279)) — the installer's locale maps only knew `it/en/de/fr`, so a Danish install silently seeded the Italian catalogue (genres + emails). All four maps in `Installer.php`, plus `PrivateModeMiddleware` and the `Updater` bundled-locale list, now include `da_DK`. Restored dropped content in the Danish `loan_pickup_ready` email and added the missing `fr_FR` entry to the `I18n` fallback map.
- **Book Club external books** ([#138](https://github.com/fabiodalez-dev/Pinakes/issues/138)) — "Add to catalogue" no longer fails when a club's external book shares an ISBN already in the catalogue; acquisition reconciles against the existing record instead of hitting the unique constraint.
- **Home "Available" count and responsive related books** ([#288](https://github.com/fabiodalez-dev/Pinakes/pull/288)) — the available-copies count on the home page is now gated behind `?with_stats=1` so the live catalogue search no longer pays a discarded aggregate on every keystroke; on narrow phones the related-books strip becomes a horizontal scroll-snap carousel (previously only one card was visible), with a `<noscript>` fallback that shows all cards stacked.
- **Loan-list column sorting** ([#281](https://github.com/fabiodalez-dev/Pinakes/issues/281)) — clicking a column header on the loans table sorted the wrong column after the bulk-select checkbox was added; the server column map is realigned. Editing an unrelated loan field no longer re-arms already-sent due-date reminder emails.
- **PHP 8.5 compatibility** ([#289](https://github.com/fabiodalez-dev/Pinakes/pull/289)) — removed a `ReflectionProperty::setAccessible()` call that is a no-op since PHP 8.1 and emits a deprecation notice on PHP 8.5.

### Internal
- **Genre resolution keys renamed** ([#286](https://github.com/fabiodalez-dev/Pinakes/issues/286), [#290](https://github.com/fabiodalez-dev/Pinakes/pull/290)) — the computed per-request genre keys were renamed to a clearer `resolved_*` convention (behaviour-preserving; no schema or API change).

### Database Changes
- `migrate_0.7.41.sql` — registers the `da_DK` row in the `languages` table on existing installs (idempotent upsert), so Danish appears in the language UI after upgrading.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.40

### Features
- **Mobile API — registration-fields discovery** ([#255](https://github.com/fabiodalez-dev/Pinakes/issues/255)) — a public `GET /api/v1/auth/registration-fields` endpoint that advertises the signup form contract: `registration_enabled`, the always-required core fields (nome/email/password), the config-driven built-in toggles (cognome/telefono/indirizzo), and the admin-defined custom fields. Lets the companion app render the registration form dynamically per instance. Mobile API plugin `1.3.0 → 1.4.0`.

### Fixes
- **Related-books section responsive layout** ([#278](https://github.com/fabiodalez-dev/Pinakes/pull/278)) — the "Potrebbero interessarti" covers no longer balloon on large screens: the card is capped to a book-sane width, columns scale 1 → 2 → 3 with the viewport, and the row is grouped/centred on ultra-wide displays.

### Tests
- New reusable regression coverage: `mobile-api.spec.js` behavioural tests for the registration-fields endpoint (#277), `related-books-responsive-278.spec.js` (#278), and `accordion-book-sections-274.spec.js` for the collapsible REICAT/SBN + MAG book-form sections (#274).

### Database Changes
None — no migration in this release.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.39

### Features
- **Collapsible cataloguing sections on the book form** ([#274](https://github.com/fabiodalez-dev/Pinakes/issues/274)) — the plugin-injected **REICAT/SBN** (z39-server) and **MAG digital-copies** (oai-pmh-server) panels are now accordions: collapsed by default on a new book, and they auto-open when the record already carries that data (or on import). The REICAT/SBN section can also be removed entirely by deactivating the z39-server plugin.
- **Custom CSS now reaches the auth pages** ([#262](https://github.com/fabiodalez-dev/Pinakes/issues/262)) — `Settings → Advanced → Custom CSS` applies to login, register, forgot- and reset-password (e.g. to hide registration fields), emitted through the same render-time sanitizer the public frontend already uses.
- **Role-specific contributor help text** ([#237](https://github.com/fabiodalez-dev/Pinakes/issues/237)) — the Illustrator / Translator / Curator / Colorist pickers each show their own hint instead of a generic "author" one, translated across all four locales.

### Security
- **guzzlehttp/guzzle → 7.15.1** — fixes five advisories published against `<7.15.1` (incl. `CVE-2026-59883`): cookie disclosure/injection via IP-address domains, host-only cookie scope not preserved, URI fragments leaking in redirect `Referer` headers, unbounded response cookies (DoS), and `Proxy-Authorization` headers leaking to origin servers.

### Database Changes
None — no migration in this release.

### Upgrade Notes
- Back up your database before updating (the in-app updater does this automatically).

---

## What's New in v0.7.38

### Features
- **Race-proof author / contributor / publisher autocomplete** on the book form ([#272](https://github.com/fabiodalez-dev/Pinakes/pull/272)) — server-side-search pickers no longer hide results for partial queries, and the search input stacks below the selected chips.

### Security & Fixes
- Custom-CSS `</style>` breakout hardening and a cross-cutting XSS review ([#266](https://github.com/fabiodalez-dev/Pinakes/pull/266), [#267](https://github.com/fabiodalez-dev/Pinakes/pull/267)).
- Book-club advisory lock and rating-migration cleanup ([#271](https://github.com/fabiodalez-dev/Pinakes/pull/271)); locale action strings ([#268](https://github.com/fabiodalez-dev/Pinakes/pull/268), [#270](https://github.com/fabiodalez-dev/Pinakes/pull/270)).

---

## What's New in v0.7.37

This release bundles three feature tracks — contributor roles (#237), loan due-date & email (#238), and configurable registration (#255) — plus security and packaging hardening.

- **Illustrators, translators, curators and colorists are now real authors ([#237](https://github.com/fabiodalez-dev/Pinakes/issues/237)).**
  For comics and illustrated books, contributors other than the main author used
  to be plain free-text fields — no autocomplete, and they never showed up as
  authors. Now each role (illustrator, translator, curator, and the new
  **colorist**) is a proper author entity with the same search-as-you-type picker
  as the author field, so it can be reused, found by pseudonym, and appears on the
  contributor's author page and books. Existing free-text values are converted to
  entities automatically on the first run after upgrading — nothing to redo.
- **Find and show authors by pseudonym (#237).** The author picker now matches on
  the pen name as well as the real name (so typing "Leo" finds them), and books
  display the pseudonym as *"Pseudonym (Real name)"* instead of only the real name.
- **Loan due-date visibility ([#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238)).**
  The Loans list gains a **Due date** column and highlights due-today/overdue
  loans in red — the same rule the Physical Copies table already used — and the
  dashboard's Active Loans list matches it. "Today" is computed in the
  application timezone, not the browser's.
- **Due-today / overdue notifications (#238).** A loan due *today* — previously
  skipped by an off-by-one in the warning query — now triggers the in-app notice
  (*"scade oggi"*), the warning email, and a mobile push.
- **Send test email + robust SMTP (#238).** Settings → Email gains a **Send test
  email** button that reports the *specific* SMTP error. Both the `smtp` and
  `phpmailer` drivers now go through PHPMailer, which verifies the
  handshake/auth/recipient — fixing a latent bug where the hand-rolled SMTP
  client reported success even when the message was rejected ("settings look fine
  but no mail arrives").
- **Configurable registration fields ([#255](https://github.com/fabiodalez-dev/Pinakes/issues/255)).**
  Small communities can no longer be forced to collect personal data they don't
  need: surname, phone and address each get an admin toggle (Settings →
  Registration) deciding whether they are required at self-registration.
  Defaults keep today's behaviour, so nothing changes until you opt out.
- **Custom registration fields (#255).** Administrators can define their own
  fields (text, textarea, email, URL, number, checkbox — required or optional):
  they appear on the registration form and in the user profile, and are shown
  on the admin user detail. Ideal for community handles such as a Telegram
  username. The bundled Mobile API exposes the requirements and definitions in
  `/api/v1/health`, accepts the values during app registration/profile editing,
  and includes them in `/api/v1/me`. New migration `migrate_0.7.37-rc.1.sql` adds the two supporting tables
  (`registrazione_campi`, `utenti_campi_valori`); it is idempotent and runs
  automatically on upgrade.
- **Hardened social links.** Social profile URLs (footer + Schema.org structured
  data) now pass through a strict `http(s)` sanitizer, so a malformed or
  `javascript:` value stored for any social renders no link at all.
- **Clearer Docker "not writable" message.** The in-app updater no longer
  presumes "the Docker image" when app files aren't writable — it states the real
  condition and covers both the official read-only image (`fabiodalez/pinakes`)
  and a community image whose code volume happens to be read-only.
## What's New in v0.7.35

- **Docker-aware in-app updater.** On the official Docker image the app files are
  baked in and owned by the image, so the "Updates" button couldn't overwrite
  them and failed with a raw list of unwritable paths. The updater now detects a
  container and explains the right path instead: update by moving the container
  to the new image (`docker compose pull && docker compose up -d`) — your data in
  the database and the `storage`/`uploads` volumes stays safe. The in-app button
  remains for classic/shared-hosting installs where the web-server user owns the
  files.

## What's New in v0.7.34

More user-reported fixes on labels and physical copies, plus a hardening pass
over the loan/reservation lifecycle.

### Physical-copy numbering ([#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238))

- **Consistent, collision-free inventory codes.** Changing a book's copy count
  used to produce inconsistent suffixes (`LIB-3` + `LIB-3-C2`), trim the wrong
  end when reducing, and eventually crash with `Duplicate entry '…-C2'`. Codes
  are now a uniform `-C{N}` on every copy, reductions trim from the end (never a
  loaned/reserved copy), and additions gap-fill the lowest free index with a
  uniqueness check — so a duplicate can no longer occur. Existing codes are left
  untouched (printed labels stay valid).

### Label content scaling ([#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238))

- **Content now scales to fill the label.** Text and barcode grow with the label
  size instead of staying a fixed size amid growing margins; each line auto-fits
  its width, the barcode fills the width, and the whole layout is centered — for
  both portrait and landscape.
- **Configurable inner padding (mm)** in Settings → Labels, applied on every
  side, to fine-tune the inset for any label stock.

### Loans & reservations — reliability

- Overbooking auditor now detects periods that involve overdue loans; the
  expired-reservations cron follows the canonical lock order (no deadlock window
  vs maintenance); capacity counts legacy reservations correctly; the
  "Returned" action shows only for `in_corso`/`in_ritardo` across every admin
  surface; and the maintenance recalc no longer double-runs.

### Book Club follow-up ([#138](https://github.com/fabiodalez-dev/Pinakes/discussions/138))

- The admin club page links straight to meeting management: a **Manage
  meetings** link and a per-meeting **Edit** link (for scheduled meetings) that
  deep-links to the meeting's card.

## What's New in v0.7.33

Built from real user feedback on labels, language management and the Book Club
([#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238),
[#138](https://github.com/fabiodalez-dev/Pinakes/discussions/138)).

### Labels ([#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238))

- **Print a single copy's label**: each row in a book's physical-copies table
  now has its own print action (`?copy_id=` on the existing label endpoint).
- **Custom label size** (width × height) alongside the presets — e.g. Dymo
  89×41mm — plus **per-field content checkboxes** (app name, title, subtitle,
  author+publisher, Dewey) so a label carries only what you need.
- The publisher is no longer clipped off landscape labels (the fixed 30-char
  pre-truncation is gone; truncation is width-based only).

### Language management ([#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238))

- **Custom locales survive updates**: a user-installed translation (e.g.
  `nb_NO`) is no longer deleted by the updater's cleanup pass.
- **"Download JSON" exports every current key**, with an empty string for
  anything untranslated — so after an update you can spot and fill exactly the
  new keys. Stats now count against the current key set.

### Book Club follow-up ([#138](https://github.com/fabiodalez-dev/Pinakes/discussions/138))

- Richer "Next meeting" card (book, end time, agenda, members-only join link),
  clearer external-proposal heading, a "Proposed by" dropdown for managers,
  a "Remove" action for club books, and a PDF export of the reading list by
  workflow state.
- Admins **and the club's owner/moderators** are notified (in-app bell + email,
  de-duplicated) on join requests, new proposals and new meetings — via the
  standard notification pipeline, now guarded by the SMTP circuit-breaker so a
  down mail server can't stall user actions.

### Review hardening

- The meeting join link is members-only on every surface (it briefly leaked to
  non-members on the next-meeting card during 0.7.33's development — caught and
  fixed pre-release).
- A manually-typed translator/illustrator value always wins over scraped data
  (including the literal `"0"`), label content checkboxes persist even when the
  size field is invalid, and `notifyAdmins()` honours its never-throws contract.

No schema migration; the book-club plugin bumps to 1.4.1 (its boot self-heal
re-runs `ensureSchema()` when needed).

---

## What's New in v0.7.32

A hotfix for two bugs reported right after 0.7.31, and the systemic cause behind
the recurring "a plugin's new table is missing after an upgrade" failures.

### Plugin schema self-heals on upgrade (book-club 500 — [#138](https://github.com/fabiodalez-dev/Pinakes/discussions/138))

- Upgrading with a plugin **already active** could leave one of its new tables
  uncreated (`bookclub_external_books doesn't exist` → every Book Club page
  500'd). The cause was generic: the plugin version was marked done in the DB
  independently of its `ensureSchema()` actually running, and the "same version"
  path then skipped `ensureSchema()` forever.
- Fixed at the source: on boot the plugin manager now **re-runs `ensureSchema()`
  whenever a plugin's declared tables are missing** — a cheap read-only check
  that runs the schema DDL only when a table is actually absent, so healthy
  installs pay nothing. A broken install heals itself on the very next page
  load. One plugin's activation failure also no longer blocks the others.
- No manual step: just upgrade and open the affected page once.

### Loan list no longer leaks its script (Create Loan — [#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238))

- After creating a loan, the loans list could show a block of raw JavaScript as
  page text (an inline `<script>` was closed early by a comment). Fixed.

### Release safety

- A mandatory schema gate (`scripts/verify-schema.sh`) now runs the migration
  tests plus a per-plugin check that every plugin's declared tables match what
  it creates, and a reproduction of the upgrade-while-active bug — so this class
  of regression is caught before a release, not by a user after one.

No schema migration in this release; the fix is runtime self-heal.

---

## What's New in v0.7.31

A feature + hardening release built from real user reports on the catalogue,
loan and book-club workflows, plus a faster catalogue search.

### Faster catalogue search (denormalized FULLTEXT)

- The catalogue, autocomplete and preview searches now match on a single
  denormalized `libri.search_index` FULLTEXT column that folds title, subtitle,
  author names, publisher names, ISBN/EAN, keywords and the plain description —
  replacing a long `OR`-of-`LIKE` chain plus a per-row author subquery. Subtitles
  now appear in the results ([#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238)).

### Per-copy tracking + camera scanner in the loan workflow

- Books can carry **per-copy inventory labels**; loans and returns can be done
  **by copy code**, with an in-browser **barcode scanner** ([#243](https://github.com/fabiodalez-dev/Pinakes/pull/243)).
- The camera scanner was blocked by a `Permissions-Policy: camera=()` response
  header; it is now allowed same-origin (`camera=(self)`) so `getUserMedia`
  works ([#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238)).
- On the **create-loan** form, entering or scanning a copy inventory code now
  **identifies the book automatically** (and pins the exact copy), so the
  operator no longer has to also search the title and can't pick the wrong book.
  Subtitles show in the book search, and the **loan details** page + **receipt
  PDF** now include the physical copy inventory code and the subtitle.

### Book Club: books not (yet) in the catalogue

- Members can propose and vote on books that aren't in the library, and a
  librarian can **acquire an external book into the catalogue** in one step
  (creating the `libri` record, authors, publisher and a physical copy)
  ([#138](https://github.com/fabiodalez-dev/Pinakes/issues/138)).

### Settings that actually take effect + email i18n

- A sweep of the settings pages: previously-orphaned toggles now have a real
  effect, per-locale email templates are honoured across it/en/de/fr, and the
  cookie banner + email-verification / admin-state flows were hardened against
  an auth-bypass edge case.

### Frontend theming + accessibility + Remember me

- Hardcoded colours across the public frontend are now driven by CSS theme
  variables, with AA-contrast and 44px mobile tap targets.
- The **"Remember me"** checkbox on login is honoured again (a field-name
  mismatch had silently ignored it).

### Migration `migrate_0.7.31.sql`

Idempotent, guarded via `information_schema` probes:

- adds `libri.search_index` (MEDIUMTEXT) + `ft_libri_search_index` FULLTEXT and
  backfills every non-deleted row;
- re-adds the LibraryThing `review`/`rating`/`comment`/`private_comment` columns
  (+ `idx_lt_rating` / `chk_lt_rating`) on installs that first updated after
  0.4.7 and so had skipped them;
- backfills `oai_deleted_records.created_at` on installs created before that
  column was added to `schema.sql`, so every install converges to the canonical
  schema.

---

## What's New in v0.7.28

Follows up 0.7.27's permissions helper after a real Docker upgrade report ([#205](https://github.com/fabiodalez-dev/Pinakes/issues/205)).

### Safer, Docker-aware permissions helper (`bin/setup-permissions.sh`)

- The helper is now **"grant, never reset"**: it only *adds* the ownership and read/write bits that are missing. It no longer resets modes, no longer switches the group unless you pass `--group`, and never strips `.env`'s existing readers. (0.7.27's version could do all three, which locked PHP out of a Docker bind-mount install and produced a 500 — this cannot.)
- New **`--from-container <name>`** for Docker installs: the script reads the real uid/gid PHP runs as *inside* the container (`docker exec … id`) and chowns the host files to that numeric id — the only thing that maps correctly across a bind-mount. So the helper now works for normal hosts, NAS, cPanel **and** Docker.

No schema migration. Operational change only; upgrading from 0.7.27 only replaces the bundled helper script.

---

## What's New in v0.7.27

A follow-up to the 0.7.26 updater hardening, from a real upgrade report ([#205](https://github.com/fabiodalez-dev/Pinakes/issues/205)).

### Clearer "not writable" reporting in the updater

- The update panel's **system-requirements** list used to label each writable-permission check with only the folder's base name, so an install whose root directory is called `html` (common on NAS/QNAP web roots) showed a cryptic **"Write: html — Not writable"** with no way to tell which directory was meant. Each check now shows **what it is plus the full path** — e.g. `Write — Installation root (/share/…/html)`, `Write — Storage directory (…/storage)` — so the operator knows exactly which directory to make writable by the web-server user. New i18n keys added to all four locales.

### One-shot permissions fixer (`bin/setup-permissions.sh`)

- A CLI script that fixes **all** filesystem permissions the app and the in-app updater need, in a single run — the reliable cure for "Update failed: Unable to create directory / Not writable" on shared hosting and NAS devices. It **auto-detects the web-server (PHP) user**, hands it ownership of the **whole install tree** (the updater must create/overwrite files, so owning the root — not just a few sub-folders — is what actually matters), then applies safe modes: no `chmod 777`, executables preserved, `.env` kept non-world-readable.
- **Dry-run by default** (shows exactly what it would do); `--apply` to run; `--user` / `--group` / `--root` overrides for any host. QNAP example: `sudo bin/setup-permissions.sh --apply --user httpdusr`.

No schema migration. Both changes are operational; upgrading from 0.7.26 changes nothing but the requirements labels and adds the helper script.

---

## What's New in v0.7.26

Book reviews reach the mobile app, the loan/reservation system gets a full review pass, plus an email-template migration.

### Book reviews in the Android app ([#209](https://github.com/fabiodalez-dev/Pinakes/pull/209))

- **The mobile API now serves book reviews** (stars + text): `GET/PUT/DELETE /api/v1/catalog/books/{id}/reviews` and `GET /api/v1/me/reviews`, gated by a per-instance `reviews` feature flag (off in catalogue mode).
- **Only borrowers can review.** Writing a review requires a past or present loan of that title (`403 not_eligible` otherwise); `PUT` is an idempotent upsert (one review per user + book).
- **Moderation stays authoritative.** A new or edited review returns to *pending*; aggregates and other users' reviews count approved reviews only, while the author always sees their own. The bundled `mobile-api` plugin moves to `1.1.0`. No schema migration — the `recensioni` table has shipped since the first release.

### Loan & reservation system review ([#207](https://github.com/fabiodalez-dev/Pinakes/pull/207), #205)

- A full review pass over the loan/reservation flow: 26 findings fixed (availability recalculation, reservation-queue edge cases, return-to-repair handling) plus updater hardening (a preflight writability dry-run that aborts before touching any file, and self-healing of owned permissions).

### UI

- **Sidebar** ([#208](https://github.com/fabiodalez-dev/Pinakes/pull/208)): the logo is stacked above the title for a cleaner header.

**Migration** (`migrate_0.7.26.sql`): seeds the email templates introduced by the loan review (including the new `reservation_cancelled` template) for **all shipped locales**, adds the `loans.max_loan_duration_days` setting, and fixes a single-brace placeholder in the `loan_overdue_admin` subject. Fully idempotent — `INSERT IGNORE` never overwrites admin-customised rows, existing settings are kept, and legacy-schema installs are upgraded in place via information_schema-gated guards.

---

## What's New in v0.7.25

Six fixes integrated from the open pull requests, plus a schema migration.

### Book form field types + copy repair ([#203](https://github.com/fabiodalez-dev/Pinakes/pull/203))

- **Acquisition type is now free text.** `tipo_acquisizione` was an `enum('acquisto','donazione')`, so anything typed outside those two (e.g. "Deposito legale", "Scambio") was silently reset to the default on save. The column is now `VARCHAR(50)` and stores what the form accepts; existing values are preserved.
- **Copies can go into repair on return.** When returning a loan you can mark the copy as *in maintenance* or *in restoration*: the loan closes as returned, the copy is held out of circulation until an operator restores it, and the borrower still receives the return notification.
- **New derived availability state.** A book whose copies are all out of circulation is now labelled **non disponibile** by the availability engine instead of showing a stale flag — localised everywhere (catalogue, book/author/publisher pages) with its own badge colour.

### Reservations, scraping & i18n

- **Waitlist promotion** ([#199](https://github.com/fabiodalez-dev/Pinakes/pull/199), #157): when reservations fully subscribe a title's copies, the head of the waitlist is promoted correctly as copies free up.
- **Scraped translator/illustrator no longer overwrite manual entries** ([#200](https://github.com/fabiodalez-dev/Pinakes/pull/200)): a submit carrying both a typed and a scraped value keeps the librarian's typed value.
- **Publication date is free text** ([#202](https://github.com/fabiodalez-dev/Pinakes/pull/202), #201): the field no longer claims a fixed "Italian format"; it accepts any text, in all four locales.

### Mobile & API

- **Loan date picker on mobile** ([#198](https://github.com/fabiodalez-dev/Pinakes/pull/198)): the loan calendar forces flatpickr's own picker on phones instead of the native control.
- **Self-hosted Swagger UI** ([#197](https://github.com/fabiodalez-dev/Pinakes/pull/197)): the API docs page (`/api/v1/docs`) serves its assets locally, with no CDN dependency.

**Migration** (`migrate_0.7.25-rc.1.sql`): widens `tipo_acquisizione` to `VARCHAR(50)` and adds `non_disponibile` to the `stato` enum — idempotent, existing data preserved. The **mobile API contract is unchanged** (availability is a computed four-value state from copy counts, never the raw `stato`), so **the Android app needs no update**.

## What's New in v0.7.24

### Backend UI homogenisation ([#196](https://github.com/fabiodalez-dev/Pinakes/pull/196))

A pass over the admin interface so every page looks and behaves consistently.

- **One accent colour everywhere.** Plugin pages used a mix of blue, purple and indigo; they are now all the same blue as the core admin chrome — settings pages (Mobile API, Discogs, GoodLib) and the feature pages of Archives, NCIP and Digital Library. Tailwind now also scans plugin views, so plugin classes are compiled reliably instead of depending on overlap with app views.
- **Readable settings on mobile.** Below 640px the settings pages (app and plugin) go flat — no card boxes, a single side gutter, no doubled padding — so content is wide and legible. Desktop is unchanged. The two odd-one-out section headers in the Advanced tab (Sitemap, Public API) were aligned to the same heading style as every other section.
- **All toggles are now identical.** The oversized OFF/ON switch on the API setting, the events visibility toggle and the bulk-enrich switch were unified to the standard grey→dark switch used across the app. A new end-to-end test turns every toggle on to guard against regressions.
- **Clearer sitemap field.** The settings page now makes it obvious which value to submit to Google Search Console (the public URL, with a Copy button); the filesystem path is labelled as server-only.
- **Mobile sidebar no longer scrolls the page behind it.** Opening the mobile menu now locks the page underneath the overlay (it previously kept scrolling on iOS).

This is a **code-only release — no new migration**. The Mobile API plugin moves to **1.0.2**; no other bundled plugin changed.

## What's New in v0.7.23

### Mobile API ⇄ Android app coherence ([#194](https://github.com/fabiodalez-dev/Pinakes/pull/194))

This release closes the gaps between the bundled **Mobile API** plugin and the companion Android app so the two actually agree on every contract.

- **The "mobile access disabled" dead end is gone.** The Mobile API access flag lives in the plugin's own settings, but there was no way to reach them from the admin UI — so an upgraded site that activated the plugin still answered the app with *"mobile access is disabled on this library."* **Admin → Plugins** now renders a generic **Settings** action for any plugin that ships a settings view, and posts back to the plugin that owns it. Open **Mobile API → Settings** and flip the access toggle.
- **Loan vs. reservation now matches the website.** A request with no date (or today's date) on an available copy is booked as an **immediate loan**, exactly like the web form; a future date becomes a **reservation**. The app and the website no longer disagree about what "borrow now" does.
- **Availability calendar normalised.** Per-day availability is exposed with a consistent `free` / `partial` / `full` state so the app's date-picker colours match the real stock.
- **OpenAPI contract realigned** to the controllers it documents — including the `/messages` request schema, which now reflects the field the endpoint actually reads (`messaggio`, with the documented fallbacks) instead of a stale `subject`/`body` shape.

On the app side (the separate [Pinakes Android](https://github.com/fabiodalez-dev/Pinakes-Android) repo, [#5](https://github.com/fabiodalez-dev/Pinakes-Android/pull/5)) this ships the in-app **registration** and **password-recovery** screens, feature-flag gating that follows the instance's catalogue-only mode, and a round of security hardening (duplicate-submit guard on registration, no account-enumeration on password recovery, password-confirmation forwarded end to end).

### Security — Slim CVE-2026-48157 ([#192](https://github.com/fabiodalez-dev/Pinakes/pull/192))

Bumped `slim/slim` 4.15.1 → 4.15.2 to clear **CVE-2026-48157**, a reflected XSS affecting Slim ≤ 4.15.1. Dependency-only bump within the existing major version — no application code changes.

This is a **code-only release — no new migration**. The Mobile API plugin moves to **1.0.1**; no other bundled plugin changed.

## What's New in v0.7.22

### Series / universe / cycle autocomplete on the book form ([#179](https://github.com/fabiodalez-dev/Pinakes/issues/179))

The cycle, universe, group and series fields on the book form are now **Choices.js autocompletes**, the same shape as the author and publisher pickers. Type a couple of letters and Pinakes suggests existing values instead of making you retype them — so a single different character no longer silently spawns a duplicate universe. Brand-new values can still be created (type and confirm). On an **upgrade**, the series you already use are suggested immediately (they are backfilled into the `collane` table); universes, groups and cycles are new dimensions and populate as you organise the hierarchy — there is nothing to import for them because the data never existed on older installs. No new migration: this is a code-only release.

### Fixes

- **Enter committed the wrong series value** ([#179](https://github.com/fabiodalez-dev/Pinakes/issues/179) review): when a suggestion was highlighted but you had typed something different, pressing Enter could commit the highlighted suggestion instead of your text — the same class of bug as the author field (#74). The series fields now use the same highlighted-vs-typed guard, covered by a dedicated regression test.
- **View escaping** aligned to `htmlspecialchars(…, ENT_QUOTES, 'UTF-8')` on the new series fields, per project convention.
- **Test hardening** for the series autocomplete (real Choices.js widget interaction + complete credential skip-guards).

## What's New in v0.7.21

### Mobile API + companion Android app ([#177](https://github.com/fabiodalez-dev/Pinakes/pull/177))

Pinakes now ships a versioned REST API for mobile clients. A bundled **Mobile API** plugin exposes `/api/v1` — instance discovery (`/health`), email/password login with bearer tokens, catalog browse &amp; search, real availability, loans, reservations, wishlist, push notifications, and digital-asset streaming for ebooks/audiobooks. It is server-agnostic and adapts to each instance's settings (language, catalogue-only mode, push availability). On a **fresh install the plugin is active out of the box**, so `/api/v1` works immediately; on an **upgrade** it ships disabled — enable it from **Admin → Plugins → Mobile API** — to avoid changing behaviour on existing sites. Either way, review mobile app access in its settings.

A free, native **[Pinakes Android app](https://github.com/fabiodalez-dev/Pinakes-Android)** (Kotlin / Jetpack Compose, Material 3) consumes this API: any library can point it at its own instance URL and hand it to members. Browse the catalog, check availability, borrow &amp; reserve, read ebooks / listen to audiobooks, and manage loans from the phone. The bearer token is stored in `EncryptedSharedPreferences`; cleartext HTTP is permitted only for loopback and the emulator. A prebuilt debug APK is published on the [app's Releases page](https://github.com/fabiodalez-dev/Pinakes-Android/releases).

### Upgrade upload limits — clear error instead of a misleading CSRF page

When an upload (e.g. the ~30&nbsp;MB admin-UI upgrade ZIP) exceeds the server's `post_max_size`, PHP discards the request body — CSRF token included — before the app runs. Instead of the confusing "security check failed" page, Pinakes now returns a clear **413** that points at the real cause and how to raise `post_max_size` / `upload_max_filesize` (note: `php_value` in `.htaccess` only applies under mod_php — on php-fpm/CGI hosts the host's PHP config must be raised).

### Security — dependency CVE fixes

Bumped `guzzlehttp/guzzle` to 7.12.1 and `guzzlehttp/psr7` to 2.12.1 to clear three medium-severity advisories disclosed 2026-06-18: CVE-2026-55767 (dot-only cookie domains matching all hosts) and CVE-2026-55568 (silent HTTPS-proxy downgrade to cleartext) in guzzle, and CVE-2026-55766 (CRLF injection in HTTP start-line serialization) in psr7. No application code changes — patch/minor bumps within the existing major versions.

## What's New in v0.7.20.2

Internal release — **test coverage only, no functional change to the application**.

- **Regression suite for the cover fix ([#173](https://github.com/fabiodalez-dev/Pinakes/issues/173))** — a 5-test end-to-end suite ([#176](https://github.com/fabiodalez-dev/Pinakes/pull/176)) that locks external-cover download &amp; save (OpenLibrary → Internet Archive redirect) and the SSRF boundary on both the save path and the `/api/cover/download` endpoint, so the #173 fix can't silently regress again. Tests are excluded from the release ZIP (`tests/ export-ignore`), so this package is byte-identical to 0.7.20.1 apart from the version marker — no need to update unless you track the latest version.

## What's New in v0.7.20.1

Maintenance release — two fixes, no schema change.

### Book covers from OpenLibrary now save ([#173](https://github.com/fabiodalez-dev/Pinakes/issues/173))

OpenLibrary serves its cover images via a redirect from `covers.openlibrary.org` to the Internet Archive, which the cover downloader's strict host check blocked — so the cover previewed on the edit form but was dropped on save. Cover fetching was reworked to accept any public source while staying **SSRF-safe**: redirects are followed one hop at a time, every hop's host is resolved to a verified **public** IP and the connection is **pinned** to it (no DNS-rebind), and IPv4-mapped / NAT64 addresses are rejected. OpenLibrary (and any CDN-hosted) covers now download and are stored as a local file.

### Hardened updater — fail-closed patches ([#174](https://github.com/fabiodalez-dev/Pinakes/pull/174))

Follow-ups to the in-app updater: a present-but-broken pre-update patch now aborts the update (fail-closed) instead of silently proceeding un-patched; a post-install patch error is surfaced as a warning instead of being hidden; and the releases API fetch no longer follows redirects with the auth token. Internal-only robustness — no action needed on upgrade.

---

## What's New in v0.7.20

### Author profiles — photo & sources ([#163](https://github.com/fabiodalez-dev/Pinakes/issues/163) / [#170](https://github.com/fabiodalez-dev/Pinakes/pull/170))

Authors can now carry a **photo** (uploaded with a live preview, or an external URL) and a list of **relevant source/website links**. The author and publisher admin detail pages were restyled to match the book page: a full-width identity card, a readable book catalog that gets its own full-width row, and the same buttons/chrome across all three entity pages.

### Loans & reservations — canonical state model ([#171](https://github.com/fabiodalez-dev/Pinakes/pull/171))

The loan/reservation engine was unified around a single occupancy model, fixing **10 state bugs**: returned copies reliably become lendable again, multi-copy availability is computed correctly, the reservation waitlist occupies its promised period without false positives/negatives, and admin/dashboard/calendar displays stay consistent. A new `restituito_in_ritardo` flag records late returns.

### Security — hardened in-app updater ([#172](https://github.com/fabiodalez-dev/Pinakes/pull/172))

Update packages are now verified against GitHub's server-side **sha256 digest** (delivered over TLS) with a constant-time compare — a present-but-unverifiable asset blocks the update instead of being silently skipped. The GitHub bearer token is scoped to the API host and never follows a redirect, and pre/post-update patches go through the same digest verification. A post-install patch that can't be verified is now a non-fatal warning (the core update is already applied) rather than a misleading failure.

### Security — session & login

Five pre-existing session pitfalls that could log an admin out unexpectedly were closed: **logout is now a CSRF-protected POST** (a stray link can no longer log you out), the "remember me" auto-login no longer bounces concurrent tabs to the login screen, a CSRF token mismatch on a still-valid session shows a clear *"reload and resubmit"* page instead of a misleading *"session expired"*, and `storage/sessions` is created automatically so sessions never fall back to a `/tmp` that shared hosts purge early.

### Migration & tooling

- **`migrate_0.7.20.sql`** — author photo/link columns, the `restituito_in_ritardo` flag, and a one-time loan-state cleanup. Idempotent; applies on upgrade from any older stable (and from the `0.7.20-rc.1` prerelease).
- CodeQL analysis is now scoped to application source (vendored bundles and test fixtures excluded), so the security dashboard reflects only our code.
- Verified end-to-end: fresh-install and the real admin-UI upgrade path (`reinstall-test.sh` Test A + B) both pass, full lifecycle suite green, PHPStan level 5 clean.

---

## What's New in v0.7.19

### Faceted catalog filters ([#169](https://github.com/fabiodalez-dev/Pinakes/pull/169))

The public catalog (`/catalogo`) filters were reworked to be clear and noise-free. Choosing a value **collapses** that facet to a removable pill and re-scopes the others; options that would return zero results disappear, single-value facets are suppressed, and the year range clamps to the data's real bounds. A new **Author** facet joins genres, publishers, media type and availability, long lists scroll internally with subtle borders, and everything is **theme-aware** (driven by the app's CSS variables).

### Complete backup & restore ([#162](https://github.com/fabiodalez-dev/Pinakes/issues/162) / [#167](https://github.com/fabiodalez-dev/Pinakes/pull/167))

A full backup system from **Admin → Updates/Maintenance**: it archives the database **and** the uploaded files, with a hash-verified, streaming restore (fail-loud staging/promotion, 4xx restore errors, admin-only download/delete). Restores replace the database content with the archive's — only restore trusted archives.

### Scanner & cover fixes ([#164](https://github.com/fabiodalez-dev/Pinakes/issues/164) / [#165](https://github.com/fabiodalez-dev/Pinakes/issues/165))

The ISBN scanner now commits on **Enter** even when a partial prefix matches an existing entry, and book-cover replacement is a single step (no dead links left when an external cover can't be downloaded).

### Install & operations robustness

- **cPanel install fix:** when the document root is the project root (a very common shared-hosting layout), the installer now self-heals the root `.htaccess` so routing and all assets work even though cPanel's File Manager hides the dotfile during extraction. No manual step required.
- **`/chi-siamo` (and localized CMS pages)** resolve reliably: the CMS page lookup tolerates a row seeded with a different-locale slug.
- **Login rate limit** relaxed from 5 to **15 attempts / 5 min** — far fewer accidental lockouts during setup, still bounded against brute force.

### UI polish

The book page "Cerca su" external-search block moved to its own row (no longer crammed in with the action buttons), genre breadcrumb separators are vertically aligned, and the related-book availability/eBook badges no longer overlap. Bundled plugin **goodlib** bumped to 1.0.1 (ships to new installs and is overwritten on upgrade).

### Testing

A 32-point regression suite covers the scanner/cover and backup/restore work ([#168](https://github.com/fabiodalez-dev/Pinakes/pull/168)), plus 26 new tests for the install/CMS/rate-limit/UI fixes. The full lifecycle suite (135) is green and the real admin-UI upgrade path (`reinstall-test.sh` Test B) passes; PHPStan level 5 clean. No new migration in this release (schema baseline stays at `migrate_0.7.17.sql`).

---

## What's New in v0.7.18

### Configurable loan & reservation system (#157)

The loan lifecycle is now fully admin-configurable from **Settings → Loans**: default loan duration, maximum active loans per user (`0` = unlimited), maximum renewals, and the pickup window for approved loans. A unified, multi-copy **occupancy model** governs availability — a copy is occupied by an active loan (`in_corso` / `in_ritardo` / `da_ritirare` / `prenotato`) or by a pending request that already holds a copy, while a *bare* pending request (no copy assigned yet) does not block other users until an admin approves it and assigns a copy. Returning a copy automatically reassigns it to the next waiting reservation in the queue, deferred email notifications are flushed only **after** the transaction commits (each send isolated so one failure can't drop the others), and maintenance automations handle pickup expiry and scheduled-reservation conversion. Database changes ship in **`migrate_0.7.17.sql`** (loan settings + reworked overlap triggers, applied through a DELIMITER-aware updater step).

### Private mode — restrict the site to registered users (#158)

A new **Settings → Advanced → Private mode** switch makes the entire public site (home, catalog, book pages) require login. It is **off by default**. When enabled, unauthenticated API calls get a JSON `401`, private uploads are withheld, but public assets (book covers, branding) stay reachable, and the API-key-gated `/api/public/*` routes keep responding through their own `ApiKeyMiddleware` instead of being pre-empted by a session `401`.

### English admin routes (#145)

All `/admin/*` paths are now English literals (`/admin/books`, `/admin/loans`, `/admin/reservations`, `/admin/users`, `/admin/publishers`, `/admin/genres`, …) instead of Italian. Old Italian admin URLs keep working through legacy redirects (`301` for `GET`, `308` for `POST` so form submissions preserve their body and CSRF), so existing bookmarks and integrations don't break. Admin routes are deliberately **not** part of the i18n route system — they are fixed English paths.

### Testing

Validated end to end on the merged `main`: the full lifecycle suite (135 passing), the dedicated loan / reservation / overlap suites (35 + 26 + 21), and a new private-mode suite (10), all green, with PHPStan level 5 clean.

---

## What's New in v0.7.16

### Multi-publisher, hardened end to end (#143)

Books can have more than one publisher (the `libri_editori` junction, introduced in 0.7.15). This release closes every gap in that model: publisher **filters, counts, exports, the public publisher archive, the catalog facet, search, the admin API and bulk operations** now all match a book whether the publisher is its primary one (`libri.editore_id`) or a secondary one in the junction. Merging two publishers re-points the junction onto the survivor **before** the cascade, so no association is lost; the publisher-delete guard counts secondary links too; and CSV / LibraryThing import and bulk-enrichment now keep the junction in sync so interop exporters (OAI-PMH, BIBFRAME) never lose a publisher.

Every new junction query is **guarded for pre-migration installs** — on a database that predates the junction table the queries gracefully fall back to the primary publisher instead of erroring.

### PHP 8.2 is now the floor

The installer and the in-app updater now require **PHP 8.2+**, matching `composer.json` (`^8.2`) and the generated `platform_check.php`. Previously an 8.1 host could pass preflight and then die at the Composer bootstrap.

### Other fixes

- **Multi-character book-case codes** (#153): the legacy single-letter UNIQUE constraint on `scaffali.lettera` is dropped, so codes like `L1`, `L2` no longer collide.
- **Edit form**: the "Import from ISBN" field is pre-filled with the book's ISBN/EAN when editing.
- A reconciliation migration heals any `libri_editori` drift left by imports written before the sync landed.

### Testing

The comprehensive E2E suite grew to **132 tests**, adding a 20-test **Archives** phase (ISAD(G) CRUD, hierarchy, SQL seeding, authority records, and the JSON/XML APIs — RiC-O JSON-LD, IIIF, OAI-PMH, SRU, MARCXML/Dublin Core/EAD3/METS) and a 9-test **multi-publisher / multi-author** phase. Validated with a fresh-install + real-upgrade regression.

---

## What's New in v0.7.14

### Installer fix: wizard no longer wedges at step 6 (Configurazione Email)

Hot-fix for an install-blocking bug discovered immediately after v0.7.13: every new install on a host **without a TLD** (`localhost`, an IP literal, or any intranet hostname such as `pinakes-vm`) got stuck at step 6 of the install wizard. The default `From Email` was derived from `$_SERVER['HTTP_HOST']` and accepted when the host passed `FILTER_VALIDATE_DOMAIN` — which `localhost` does. The same value was then re-validated at submit time with `FILTER_VALIDATE_EMAIL`, which is stricter (requires a TLD), so `no-reply@localhost` was silently rejected. The form posted, the controller flagged the validation failure, the same page re-rendered, and the install never progressed.

`installer/steps/step6.php` now validates the host the same way `FILTER_VALIDATE_EMAIL` will: it only adopts the live `HTTP_HOST` if `no-reply@{host}` itself passes `FILTER_VALIDATE_EMAIL`. Otherwise the default falls back to `example.com` (RFC 2606 reserved, always a syntactically valid placeholder). The user can still override the field manually.

Verified end-to-end with a fresh install from the v0.7.14 ZIP on localhost: the wizard now advances past step 6 with the default value untouched and reaches step 7 (Installazione Completata) cleanly.

No schema migrations. No code changes outside the installer. Existing 0.7.13 installs are unaffected and don't need to re-install.

---

## What's New in v0.7.13

### Performance: HTTP compression + long-term cache for static assets

Apache (`public/.htaccess`) and the nginx example (`.nginx.conf.example`) now ship a `# === Pinakes performance block ===` that turns on gzip/brotli compression and applies `Cache-Control: public, max-age=31536000, immutable` to versioned CSS/JS/font assets. Every directive is gated by `<IfModule …>` (Apache) or feature-tested (nginx) so the file stays valid on hosts where the optional modules aren't loaded. Measured locally on the home page: `vendor.bundle.js` 3.5 MB → ~800 KB gzip, `main.css` 192 KB → 30 KB gzip (−84%), HTML home 471 KB → 91 KB (−81%). Asset URLs are already version-busted with `?v=X.Y.Z`, so the 1-year `immutable` lifetime is safe — every release rotates the URL automatically.

For nginx specifically, the `location ^~ /uploads/` block now adds a `Cache-Control: public, max-age=2592000` header (cover images, uploaded media) — without this explicit add_header, the prefix-priority of `^~ /uploads/` would short-circuit the regex location that previously set caching for static files, leaving uploads served with no cache headers at all. Apache wasn't affected because `mod_headers` applies `FilesMatch` globally.

Existing installations upgrading via the in-admin updater pick this up through `post-install-patch.php`: an idempotent search/replace injects the same performance block into the live `.htaccess` for every install on `0.4.0`–`0.7.12`. The patch is gated by `<IfModule>` and uses a stable 4-line anchor (`RewriteRule ^ index.php [QSA,L]` … `# Security Headers`) verified to exist unchanged from v0.4.9.9 through v0.7.12.

### Bulk "Scarica copertine" self-heals missing covers ([visible bug](https://github.com/fabiodalez-dev/Pinakes/pull/144))

`LibriController::fetchCover()` and `syncCovers()` used to trust `libri.copertina_url` alone when deciding whether a book already had a cover, returning `reason: already_has_cover` even when the file behind that URL had been deleted on disk (a common state after manual cleanups, partial backup restores, or failed downloads). The bulk "Scarica copertine" action would then report `Completato. Già presenti: 1` and the book stayed permanently uncovered.

Both methods now resolve the path with `realpath()` against `getCoversUploadPath()` and require the resolved file to live inside the covers directory (`str_starts_with($resolved, $baseDir . DIRECTORY_SEPARATOR)`) — a defence-in-depth tightening compared to the existing delete path. If the file is missing or unreachable, the controller logs a warning (`cover_url in DB but file missing/unreachable on disk, re-fetching`), re-runs the scrape, downloads a fresh cover, and updates the DB. Idempotent on subsequent calls.

### Minor UI fixes

- `.search-book-year` in the hero search dropdown is now explicitly left-aligned, matching the sibling `.search-book-author` line.
- `.description-content .prose` in the book-detail page gets `max-w-none` so the description fills its column instead of being capped at Tailwind Typography's default 65ch (already constrained by the page grid).

### Notes

- No schema migrations — drop-in upgrade from `v0.7.12`.
- No new bundled plugins, no breaking changes.
- The companion `post-install-patch.php` is attached to the GitHub release and applied automatically by the in-admin updater; nothing for end users to do manually.

---

## What's New in v0.7.12

### Archives: RiC-CM Phases 5 & 6 — admin UI + OAI-PMH `ric-o` ([#122](https://github.com/fabiodalez-dev/Pinakes/issues/122))

v0.7.12 closes the six-phase RiC-CM roadmap. Phases 1-4 (shipped progressively in 0.7.7 → 0.7.10) modelled the four RiC-CM entity types (Record/RecordSet, Agent, Activity, Place) and the polymorphic relations graph. Phases 5 and 6 expose them to curators and to harvesters.

**Phase 5 — native admin UI for activities, places and relations.**

- `GET/POST /admin/archives/activities` + `/new` + `/{id}` + `/{id}/edit` + `/{id}/delete` — CRUD over ISDF activities (Function/Activity/Transaction/Task/Mandate). Hierarchical parent/child with cycle detection on the application layer (the `parent_id` FK uses `ON DELETE SET NULL`, which is incompatible with a MySQL `CHECK` constraint, so the cycle guard is enforced in PHP before INSERT/UPDATE).
- `GET/POST /admin/archives/places` + `/new` + `/{id}` + `/{id}/edit` + `/{id}/delete` — CRUD over places (country/region/province/municipality/locality/building/room/geographic_feature/other) with optional latitude/longitude and GeoNames / Wikidata / Getty TGN identifiers for Linked Data linkage.
- `POST /admin/archives/relations/attach` + `POST /admin/archives/relations/{id}/detach` — manage the polymorphic relations graph from the unit/agent/activity/place detail pages.
- `GET /api/archives/entities?type=&q=` — typeahead backend for Choices.js-style autocomplete in the relation forms. Returns the four entity types validated against the ENUM definitions of `archive_relations.source_type` / `target_type`.

The chrome mirrors the existing books/archives admin views (Tailwind `p-6 max-w-4xl mx-auto`, `bg-white shadow rounded-lg p-6 space-y-5` form containers, `form-label` field labels, breadcrumb navigation, indigo-600 primary actions, red-50/red-700 destructive buttons). All 60+ user-facing strings are Italian-source `__()` wrappers with full translations added to `locale/en_US.json`, `locale/fr_FR.json`, `locale/de_DE.json`.

**Phase 6 — OAI-PMH `metadataPrefix=ric-o`.**

- The `oai-pmh-server` plugin now exposes `ric-o` (canonical RDF/XML serialisation of the same RiC-O graph emitted on `/archives/{id}/ric.json`) as a metadataPrefix for the `archives` set. `ListMetadataFormats` advertises it conditionally — only when the `archives` plugin is active AND the `archival_units` table exists.
- `GetRecord?identifier=oai:…:archival_unit:{id}&metadataPrefix=ric-o` serialises one archival unit as `<rdf:RDF>` with `ric:RecordSet` / `ric:Record` root, `rdfs:label` carrying `xml:lang`, `ric:DateRange` with `xsd:gYear` typed literals, embedded `ric:Relation` subjects for agent links, and `rdf:resource` references for parent/children. `ListRecords?set=archives&metadataPrefix=ric-o` streams the whole archival graph.
- Symmetric validation: `metadataPrefix=ric-o` on `set=books` or on a book identifier returns `cannotDisseminateFormat`; `metadataPrefix=oai_dc` keeps working on both sets unchanged.
- Re-uses `RicJsonLdBuilder::serializeToRdfXml()` (new in this release) which translates the JSON-LD compact document to canonical RDF/XML — `@id`→`rdf:about`/`rdf:resource`, `@type`→tag name (CURIE expanded against the document `@context`), language tags via `xml:lang`, typed literals via `rdf:datatype`, nested blank nodes for inline objects. 159/159 unit assertions passing on the round-trip.

The full RiC-CM journey: v0.7.7 read-only JSON-LD → v0.7.8 agents → v0.7.9 activities → v0.7.10 places + polymorphic relations → v0.7.12 admin UI + OAI-PMH RDF/XML. The application's `version.json` bumps from 0.7.10 to 0.7.12 once, at the end of the chain.

**Cleanup — dead schema column dropped (review F015).** The `archive_activities.place_id` column was introduced in 0.7.9 as a placeholder reserved for the Phase 4 `archive_places` FK, but Phase 4 (0.7.10) chose the polymorphic `archive_relations` graph instead and no application code ever read or wrote the column. Migration `migrate_0.7.12.sql` drops it with `ALTER TABLE archive_activities DROP COLUMN place_id;` so the schema reflects what the code actually uses.

---

## What's New in v0.7.10

### Archives: RiC-CM Phase 4 — Places + polymorphic Relations graph ([#122](https://github.com/fabiodalez-dev/Pinakes/issues/122))

Fourth phase of the RiC-CM roadmap. With Phases 1-3 we modelled three of the five RiC-CM entity types (Record/RecordSet, Agent, Activity). Phase 4 introduces the fourth — **Place** — and the **generic polymorphic Relations** backbone that lets any pair of entities carry a typed RiC-O predicate. The model is now complete on the entity side.

- **New table `archive_places`** — first-class Place entity (RiC-CM §3.5). `name` + `place_type` ENUM (country / region / province / municipality / locality / building / room / geographic_feature / other), self-referential `parent_id` for the place hierarchy (Catania → Sicilia → Italia), optional `latitude` / `longitude` for map display, optional `geonames_id` / `wikidata_id` / `tgn_id` for external Linked Data identifiers, optional `date_start` / `date_end` for historical places (e.g. "Regno delle Due Sicilie", 1816-1861). Full-text index on `name + description`.
- **New table `archive_relations`** — **polymorphic** N:M relations between any two RiC-CM entities. Both endpoints (`source_type`+`source_id` and `target_type`+`target_id`) reference one of four entity types: `archival_unit`, `authority_record`, `archive_activity`, `archive_place`. The `ric_predicate` column is VARCHAR so RiC-O's open vocabulary can grow without migrations. Common predicates: `ric:isOrWasLocatedAt`, `ric:isOrWasResidentAt`, `ric:isOrWasPerformedAt`, `ric:isOrWasIncludedIn`. Each row carries optional `qualifier`, `certainty` (certain/probable/uncertain), `date_start`/`date_end` for temporal validity, `source_ref` for the documentary citation, and `created_by` to track curatorial provenance.
- **Why polymorphic, not 16 specialised link tables** — RiC-O has dozens of inter-entity predicates. One link table per (source, target, predicate) triple would explode the schema and add a migration on every new predicate. Polymorphic source/target keeps the schema compact; the application-layer validator (`validateRelationEndpoints`) checks both endpoints exist and are not soft-deleted before INSERT.
- **Two new public endpoints**:
  - `GET /archives/places/{id}/ric.json` — RiC-O JSON-LD for one place. Emits `ric:Place`, `ric:CoordinateLocation` from lat/lng, `owl:sameAs` to GeoNames / Wikidata / Getty TGN, `ric:isOrWasIncludedIn` to the parent place, and `ric:isAssociatedWithDate` for historical date ranges.
  - `GET /archives/places/ric.json` — synthetic `ric:RecordSet` listing every top-level place (those with `parent_id IS NULL`), suitable for harvesting alongside the existing collection / agents / activities endpoints.
- **`RicJsonLdBuilder::buildRelationNode()`** — new method that renders any `archive_relations` row as a `ric:Relation` JSON-LD node with deterministic `@id` (`/archives/relations/{row.id}`), `ric:relationHasSource` and `ric:relationHasTarget` resolved via the central `iriForEntity()` switch. Returns `null` on malformed input — no exception — so callers can drop bad rows from the output without crashing the whole response.
- **`validateRelationEndpoints(sourceType, sourceId, targetType, targetId)`** — application-layer integrity check used by the admin form before inserting into `archive_relations`. Verifies both endpoints exist and are not soft-deleted; the polymorphic column shape makes a SQL FK impossible.
- **Migration `migrate_0.7.10.sql`** — idempotent. `archive_places.parent_id` self-cycle guards live in the application layer (MySQL forbids CHECK on a column that's part of an `ON DELETE SET NULL` FK action, same constraint encountered in Phase 3).

## What's New in v0.7.9

### Archives: RiC-CM Phase 3 — Activities as first-class entities ([#122](https://github.com/fabiodalez-dev/Pinakes/issues/122))

Third milestone of the RiC-CM roadmap. Introduces the ISDF-aligned **Activity** entity — any human or organisational activity that produced, used, or managed archival material. Phase 1 + Phase 2 already gave us records, record sets, and agents; Phase 3 closes the "what happened" side of the RiC-CM triangle.

- **New table `archive_activities`** — first-class Activity entity. Columns: `title`, `description`, `activity_type` (`function` / `activity` / `transaction` / `task` / `mandate` per ISDF terminology), self-referential `parent_id` (so a function can contain activities, an activity can contain transactions), `date_start` / `date_end` / `is_ongoing`, optional `agent_id` FK to `authority_records` (the agent that performed the activity), `place_id` reserved for Phase 4, `source_ref` for the legal/normative citation (e.g. "RD 9 ottobre 1861 n. 250"), full-text index on title + description.
- **New table `archive_unit_activities`** — M:N link between archival units and activities. The `ric_predicate` column captures the semantics of each link as a RiC-O predicate: `ric:resultsOrResultedFrom` (the unit was produced by the activity, default), `ric:isOrWasUsedBy` (the unit was used during the activity), `ric:isSubjectOf` (the activity is about this unit). Column is VARCHAR so new predicates can be added without a migration.
- **Two new public endpoints**:
  - `GET /archives/activities/{id}/ric.json` — RiC-O JSON-LD for one activity, with `ric:Activity` type, `ric:isOrWasPerformedBy` → agent, `ric:hasOrHadPartOf` → parent activity, `ric:isAssociatedWithDate` as `ric:DateRange` (`xsd:date`), and `ric:isOrWasRelatedTo` listing every unit the activity produced / used.
  - `GET /archives/activities/ric.json` — synthetic `ric:RecordSet` listing every top-level activity (those with `parent_id IS NULL`), suitable for ICA / Europeana harvesting alongside the existing collection.ric.json and agents endpoints.
- **`/archives/{id}/ric.json` now embeds activity links** — `RicJsonLdBuilder::buildUnit()` accepts a new `$activities` parameter so the unit-side serialisation lists every activity it's connected to. The relation IRI is shared between the unit side and the activity side (`/archives/unit-activity-relations/{unitId}-{activityId}-{predicate-slug}`) so a graph-merge consumer collapses both emissions into a single RDF node.
- **Migration `migrate_0.7.9.sql`** — idempotent. The CHECK constraint guarding `parent_id <> id` is intentionally absent because MySQL rejects CHECK on a column that's part of a FK referential action (`ON DELETE SET NULL`); the application-layer cycle guard in `activityWouldCreateCycle()` provides the equivalent protection.

## What's New in v0.7.8

### Archives: RiC-CM Phase 2 — Agents as first-class entities ([#122](https://github.com/fabiodalez-dev/Pinakes/issues/122))

Phase 2 of the 6-phase RiC-CM roadmap. Phase 1 (v0.7.7) was schema-free; this is the first migration in the chain that touches the DB.

- **`authority_records` extended** — four new columns:
  - `ric_type` (`ENUM('Person','CorporateBody','Family','Position','Group')`) — RiC-CM canonical type, broader than the legacy ISAAR `type` enum. The migration backfills it from existing `type` values; `Position` and `Group` are RiC-CM-only types ISAAR doesn't model.
  - `birth_date` / `death_date` — structured begin/end-of-existence dates (`xsd:date`). The RiC-O JSON-LD output now emits `ric:beginningDate` and `ric:endDate` as typed literals instead of the free-text `dates_of_existence` blob (which is preserved for back-compat and surfaces as `ric:descriptiveNote` on pre-Phase-2 rows).
  - `place_of_origin` — birthplace / founding place. Phase 4 will swap this literal for a FK to a dedicated `archive_places` table.
- **New table `archive_agent_identifiers`** — multi-scheme identifier ledger for archive authorities (VIAF, ISNI, Wikidata, GND, BNF, LCNAF, Getty ULAN, ARK, local). Each row carries scheme + value + optional precomputed URI + an `is_preferred` flag. `collectSameAsForAuthority` now merges these into `owl:sameAs` alongside the existing `viaf-authority` plugin's data; rows without a precomputed URI are synthesised from the scheme's canonical prefix (e.g. `viaf:29539` → `https://viaf.org/viaf/29539`).
- **New table `archive_agent_relations`** — Agent ↔ Agent edges typed with a RiC-O predicate (`ric:isParentOf`, `ric:isMemberOf`, `ric:isSuccessorOf`, `ric:isMarriedTo`, ...). Captures organisational hierarchies, corporate successions, and family ties that ISAAR's flat table cannot express. Each row becomes a `ric:Relation` node in the RiC-O JSON-LD output with a deterministic `@id` of the form `{base}/archives/agent-relations/{agentId}-{relatedId}-{predicate-slug}`. The schema rejects self-loops via a `CHECK` constraint (MySQL 8.0.16+).
- **Migration `migrate_0.7.8.sql`** — fully idempotent (INFORMATION_SCHEMA guards on every ALTER, `CREATE TABLE IF NOT EXISTS` on every CREATE). Re-running the migration is safe; the ric_type backfill UPDATE narrows on rows still at the default value so curator overrides survive.

## What's New in v0.7.7

### Regression hotfix for author autocomplete ([#74](https://github.com/fabiodalez-dev/Pinakes/issues/74))

- **Issue #74 regression fix** — typing a new author name in the book form and pressing Enter was once again selecting the first highlighted dropdown match (e.g. typing "Norbert Wex" picked the existing "Norbert Bauer") instead of creating the new author. The original fix in v0.4.9.4 monkey-patched `authorsChoice._onEnterKey` on the Choices.js instance; a later "cleaner" refactor (commit `e976cb1e`) replaced it with a capture-phase keydown listener, which Choices.js v11 silently bypasses via `stopImmediatePropagation()` on its own pre-registered capture-phase handler. Restored the monkey-patch with a defensive capture-phase fallback for any future Choices.js version that removes `_onEnterKey`. The override is per-instance, so publisher / genre / etc. Choices instances on the same page keep stock behaviour.

This is a patch-only release. No schema migrations, no plugin changes,
no config changes required. Drop-in upgrade from v0.7.6.

---

## What's New in v0.7.6

### French locale (fr_FR) and BNF scraping

- **Full French translation** — 4,145 translated keys (100% coverage). Select `fr_FR` during the installation wizard to run Pinakes in French; existing installations can switch the default locale from Settings → Localisation.
- **BNF SRU scraping** — the Z39 Server plugin now connects to the Bibliothèque nationale de France SRU endpoint and