# 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_` 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:`). 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:`), 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.` 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 `` verification tags exactly as Google Search Console, Bing, Yandex, Pinterest or Facebook hand them out and prints them in the `` 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 `