diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d25b4449..ca1bf53a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [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 diff --git a/README.md b/README.md index 928416c29..0ba0d152b 100644 --- a/README.md +++ b/README.md @@ -39,31 +39,29 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri ## What's New -Highlights of the latest release are below. The full version-by-version history (v0.7.58 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**. +Highlights of the latest release are below. The full version-by-version history (v0.7.59 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.7.59 — latest +### v0.7.60 — latest -A consolidation release: four feature/fix branches integrated and hardened together, with a security pass on top. +A maintenance release: UPC barcode support, a read-only availability field, and a PHP 8.5 scraping fix. ### New -- **"Complete series" indicator** — admins can mark a series as complete; the flag shows on the series list and detail pages (#338). + +- **UPC-A barcodes are supported** — the barcode field now accepts a 12-digit UPC-A (board games and other non-book items) and stores it as its 13-digit GTIN, so it validates, searches and de-duplicates like an EAN-13. CSV and TSV import both handle it, and the field reads **EAN/UPC** in every locale (#348). ### Fixes -- **Loan editing from the admin page works again** — the availability re-check no longer bounces every edit, and dates are validated strictly (#336). -- **Loan status no longer shows "Unknown"** — cancelled and expired loans render their real state through canonical status/label helpers (#333). -- **The notifications panel is no longer overlapped** in the admin during scroll (#334). -- **Loan & reservation coherence** — clocks, availability and date handling share the same guarded paths across the web UI, mobile API and background jobs; auto-approval now honours the setting on the book-detail request path too (#301). -- **The barcode scanner keeps focus** on the loan form and copies sort in natural order (#238). -- **Related books are reachable on narrow and tablet screens** — the strip now shows a scroll affordance instead of silently clipping cards. -### Security -- Framework-generated error responses now receive the same nonce-based Content Security Policy as normal pages. +- **ISBN scraping works on PHP 8.5 again** — a deprecated `curl_close()` notice was leaking into the JSON response and breaking the import with "Risposta non valida dal servizio ISBN.". +- **The availability field in the book editor is now read-only** — it is derived from the physical copies, so editing it was a silent no-op (#351). Set a copy's status to make it unavailable. +- **The scroll-to-top button no longer covers the Save button** at the bottom of the book form. + +### Internal -### Database Changes -- Adds a `collane.is_completa` flag; the in-app updater applies the migration automatically on upgrade. +- The GitHub Actions security audit no longer breaks on upstream action tag drift, and the NCIP CheckOut regression test is now deterministic under sharded runs. ### Upgrade Notes -- Back up your database before updating (the in-app updater does this automatically). + +- No database changes. Back up your database before updating anyway (the in-app updater does this automatically). > Older releases → **[CHANGELOG.md](CHANGELOG.md)**. diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 0a0905905..0bf9e43b9 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -1157,13 +1157,21 @@ private function normalizeIsbn(string $isbn): ?string } /** - * Validate and normalize EAN-13 barcode value + * Validate and normalize an EAN-13 or UPC-A barcode value * - * Unlike normalizeIsbn(), this only validates format and length (13 digits) - * without ISBN checksum checks, since valid EAN-13 barcodes may not be ISBNs. + * Unlike normalizeIsbn(), this only validates format and length without + * ISBN checksum checks, since valid EAN-13 barcodes may not be ISBNs. * - * @param string $ean Raw EAN value from CSV - * @return string|null Normalized EAN or null if invalid + * A UPC-A (GTIN-12, e.g. board games) is a GTIN-13/EAN-13 with a leading + * zero, and the zero-padding preserves its check digit (the EAN-13 weight + * pattern aligns once the leading zero occupies an odd position). So a + * 12-digit UPC-A is canonicalised to its 13-digit GTIN and then flows + * through the same EAN-13 validation, storage, dedup and search — the same + * barcode scanned as UPC-A or EAN-13 normalises to one value. (issue #348) + * Both CSV and TSV imports share this path (delimiter is auto-detected). + * + * @param string $ean Raw EAN/UPC value from CSV/TSV + * @return string|null Normalized 13-digit GTIN or null if invalid */ private function normalizeEan(string $ean): ?string { @@ -1171,13 +1179,24 @@ private function normalizeEan(string $ean): ?string return null; } - // Remove all non-digit characters - $normalized = preg_replace('/[^0-9]/', '', trim($ean)); + // Strip only the separators a barcode may carry: ASCII space and dash. + // Not \s and not trim() — those also remove TAB/CR/LF, which would let a + // field with a stray control character collapse into a "valid" barcode. + // Any character left after this means the cell is not a bare barcode + // (e.g. "ABC036000291452", "036000\n291452", "\t036000291452"), so + // reject it via the ctype_digit guard rather than extracting digits. + $normalized = str_replace([' ', '-'], '', $ean); - if (empty($normalized)) { + if ($normalized === '' || !ctype_digit($normalized)) { return null; } + // UPC-A (12 digits) is EAN-13 with a leading zero — canonicalise it so + // the shared EAN-13 checksum below validates it unchanged. + if (strlen($normalized) === 12) { + $normalized = '0' . $normalized; + } + // EAN-13 must be exactly 13 digits if (strlen($normalized) !== 13) { return null; diff --git a/app/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php index dcf7fb3ff..6e7f4f7b6 100644 --- a/app/Views/libri/partials/book_form.php +++ b/app/Views/libri/partials/book_form.php @@ -314,7 +314,7 @@
- @@ -1003,7 +1003,7 @@ class="w-4 h-4 rounded border-gray-300 text-gray-900 focus:ring-gray-500" \App\Support\Hooks::do('book.form.fields', [$bookData, $bookId]); ?> -
+
+ + + diff --git a/locale/da_DK.json b/locale/da_DK.json index ed7522568..bba957723 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -1264,8 +1264,8 @@ "Dry-run (analizza senza inserire)": "Tørkørsel (analyser uden indsættelse)", "Dry-run:": "Tørkørsel:", "Durata": "Varighed", - "EAN": "EAN", - "EAN:": "EAN:", + "EAN": "EAN/UPC", + "EAN:": "EAN/UPC:", "ERRORE:": "FEJL:", "Eccellente": "Fremragende", "Eccezione creazione %s su %s:": "Undtagelse ved oprettelse af %s på %s:", @@ -1712,7 +1712,7 @@ "Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "\"Paraply\"-mærkat for spin-offs (f.eks. hele Fairy Tail-franchiset).", "Etichette": "Etiketter", "Etichette interne grandi (Herma 4630, Avery 3490)": "Store interne etiketter (Herma 4630, Avery 3490)", - "European Article Number (opzionale)": "European Article Number (valgfrit)", + "European Article Number (opzionale)": "European Article Number/Universal Product Code (valgfrit)", "Eventi": "Begivenheder", "Eventi Recenti": "Seneste begivenheder", "Eventi e Incontri": "Begivenheder og møder", diff --git a/locale/de_DE.json b/locale/de_DE.json index 9319ea766..cd1845fe3 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -1264,8 +1264,8 @@ "Dry-run (analizza senza inserire)": "Testlauf (analysieren ohne einzufügen)", "Dry-run:": "Testlauf:", "Durata": "Dauer", - "EAN": "EAN", - "EAN:": "EAN:", + "EAN": "EAN/UPC", + "EAN:": "EAN/UPC:", "ERRORE:": "FEHLER:", "Eccellente": "Ausgezeichnet", "Eccezione creazione %s su %s:": "Ausnahme beim Erstellen von %s auf %s:", @@ -1712,7 +1712,7 @@ "Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "\"Dach\"-Bezeichnung für Spin-offs (z.B. das gesamte Fairy-Tail-Franchise).", "Etichette": "Etiketten", "Etichette interne grandi (Herma 4630, Avery 3490)": "Große interne Etiketten (Herma 4630, Avery 3490)", - "European Article Number (opzionale)": "European Article Number (optional)", + "European Article Number (opzionale)": "European Article Number/Universal Product Code (optional)", "Eventi": "Veranstaltungen", "Eventi Recenti": "Aktuelle Veranstaltungen", "Eventi e Incontri": "Veranstaltungen und Treffen", diff --git a/locale/en_US.json b/locale/en_US.json index f0c531b79..ad7eaf75c 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -1264,8 +1264,8 @@ "Dry-run (analizza senza inserire)": "Dry-run (parse without inserting)", "Dry-run:": "Dry-run:", "Durata": "Duration", - "EAN": "EAN", - "EAN:": "EAN:", + "EAN": "EAN/UPC", + "EAN:": "EAN/UPC:", "ERRORE:": "ERROR:", "Eccellente": "Excellent", "Eccezione creazione %s su %s:": "Exception creating %s on %s:", @@ -1712,7 +1712,7 @@ "Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "\"Umbrella\" label for spin-offs (e.g. the entire Fairy Tail franchise).", "Etichette": "Labels", "Etichette interne grandi (Herma 4630, Avery 3490)": "Large internal labels (Herma 4630, Avery 3490)", - "European Article Number (opzionale)": "European Article Number (optional)", + "European Article Number (opzionale)": "European Article Number/Universal Product Code (optional)", "Eventi": "Events", "Eventi Recenti": "Recent Events", "Eventi e Incontri": "Events and Meetings", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 90634065c..c7cdb0b8e 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -1264,8 +1264,8 @@ "Dry-run (analizza senza inserire)": "Dry-run (analyser sans insérer)", "Dry-run:": "Dry-run :", "Durata": "Durée", - "EAN": "EAN", - "EAN:": "EAN :", + "EAN": "EAN/UPC", + "EAN:": "EAN/UPC :", "ERRORE:": "ERREUR :", "Eccellente": "Excellent", "Eccezione creazione %s su %s:": "Exception de création de %s sur %s :", @@ -1712,7 +1712,7 @@ "Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "Étiquette \"parapluie\" pour les spin-offs (ex. toute la franchise Fairy Tail).", "Etichette": "Étiquettes", "Etichette interne grandi (Herma 4630, Avery 3490)": "Grandes étiquettes internes (Herma 4630, Avery 3490)", - "European Article Number (opzionale)": "Numéro d'article européen (optionnel)", + "European Article Number (opzionale)": "Numéro d'article européen/Code universel des produits (optionnel)", "Eventi": "Événements", "Eventi Recenti": "Événements récents", "Eventi e Incontri": "Événements et rencontres", diff --git a/locale/it_IT.json b/locale/it_IT.json index 7403b5f2a..b7b8c1676 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -1264,8 +1264,8 @@ "Dry-run (analizza senza inserire)": "Dry-run (analizza senza inserire)", "Dry-run:": "Dry-run:", "Durata": "Durata", - "EAN": "EAN", - "EAN:": "EAN:", + "EAN": "EAN/UPC", + "EAN:": "EAN/UPC:", "ERRORE:": "ERRORE:", "Eccellente": "Eccellente", "Eccezione creazione %s su %s:": "Eccezione creazione %s su %s:", @@ -1712,7 +1712,7 @@ "Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).", "Etichette": "Etichette", "Etichette interne grandi (Herma 4630, Avery 3490)": "Etichette interne grandi (Herma 4630, Avery 3490)", - "European Article Number (opzionale)": "European Article Number (opzionale)", + "European Article Number (opzionale)": "European Article Number/Universal Product Code (opzionale)", "Eventi": "Eventi", "Eventi Recenti": "Eventi Recenti", "Eventi e Incontri": "Eventi e Incontri", diff --git a/tests/book-create-edit-351.spec.js b/tests/book-create-edit-351.spec.js new file mode 100644 index 000000000..704ed6a14 --- /dev/null +++ b/tests/book-create-edit-351.spec.js @@ -0,0 +1,171 @@ +// Behavioral E2E: create a book, edit it, and verify the fix for issue #351 +// (the "Availability" / #stato select in the book editor is read-only because +// availability is derived from physical copies, not user input). +// +// Form under test: app/Views/libri/partials/book_form.php +// - create: GET /admin/books/create → POST (LibriController::store) +// - edit: GET /admin/books/edit/ → POST (LibriController::update) +// - #stato is rendered with `disabled aria-readonly="true"` and update() +// unsets $fields['stato'] server-side. +// +// Requires an installed Pinakes instance and admin login. Skips when env is +// incomplete to avoid silent failures. + +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8082'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_HOST = process.env.E2E_DB_HOST || 'localhost'; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const CREATE_BOOK_URL = `${BASE}/admin/books/create`; + +const TAG = Date.now(); +const CREATE_TITLE = `E2E CreateEdit351 ${TAG}`; +const EDITED_SUBTITLE = `E2E Edited Subtitle 351 ${TAG}`; + +test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, + 'book-create-edit-351 requires E2E_ADMIN_EMAIL/PASS + DB_USER/NAME', +); + +function dbQuery(sql) { + const args = ['-N', '-B', '-e', sql]; + if (DB_HOST) args.push('-h', DB_HOST); + if (DB_SOCKET) args.push('-S', DB_SOCKET); + args.push('-u', DB_USER); + if (DB_PASS !== '') args.push(`-p${DB_PASS}`); + args.push(DB_NAME); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }).trim(); +} + +async function loginAsAdmin(page) { + await page.goto(`${BASE}/admin`); + if (page.url().includes('admin') && !page.url().match(/login|accedi|anmelden/)) return; + for (const slug of ['accedi', 'login', 'anmelden']) { + const resp = await page.goto(`${BASE}/${slug}`).catch(() => null); + if (resp && resp.status() === 200 && (await page.locator('input[name="email"]').count()) > 0) break; + } + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await Promise.all([ + page.waitForURL(/admin/, { timeout: 15000 }), + page.locator('button[type="submit"]').click(), + ]); +} + +// Submits #bookForm and clicks through the SweetAlert confirm if one appears +// (duplicate-check / info dialogs use SweetAlert2, not native dialogs). +async function submitBookForm(page) { + await page.locator('#bookForm button[type="submit"]').click(); + const swalConfirm = page.locator('.swal2-confirm'); + if (await swalConfirm.isVisible({ timeout: 5000 }).catch(() => false)) { + await swalConfirm.click(); + } +} + +test.describe.serial('book create + edit — #351 read-only availability', () => { + let context; + let page; + let bookId = 0; + let statoBeforeEdit = ''; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + await loginAsAdmin(page); + }); + + test.afterAll(async () => { + if (bookId > 0) { + // FK-safe order: copies first, then the book. Hard delete — this is + // isolated test data tagged with a timestamp. + try { dbQuery(`DELETE FROM copie WHERE libro_id = ${bookId}`); } catch (_) { /* test cleanup */ } + try { dbQuery(`DELETE FROM libri WHERE id = ${bookId}`); } catch (_) { /* test cleanup */ } + } + await context?.close(); + }); + + test('1. Create a book with minimal required fields and verify it in the DB', async () => { + test.setTimeout(60000); + await page.goto(CREATE_BOOK_URL); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + + // titolo is the only field store() enforces; anno/lingua keep the record realistic. + await page.fill('#titolo', CREATE_TITLE); + await page.fill('#anno_pubblicazione', '2024'); + await page.fill('#lingua', 'Italiano'); + + await submitBookForm(page); + + await expect.poll( + () => dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo = '${CREATE_TITLE}' AND deleted_at IS NULL`), + { timeout: 30000 }, + ).toBe('1'); + await page.waitForURL(/\/admin\/books(?:\/\d+)?(?:\?.*)?$/, { timeout: 30000 }); + + const row = dbQuery(`SELECT id, titolo FROM libri WHERE titolo = '${CREATE_TITLE}' AND deleted_at IS NULL`); + expect(row, 'created book must exist exactly once in DB').toContain(CREATE_TITLE); + bookId = parseInt(row.split('\t')[0], 10); + expect(bookId).toBeGreaterThan(0); + + // Baseline for test 5: the availability value the disabled field must not alter. + statoBeforeEdit = dbQuery(`SELECT stato FROM libri WHERE id = ${bookId}`); + expect(statoBeforeEdit.length).toBeGreaterThan(0); + }); + + test('2. Editor loads the created book with its fields populated', async () => { + test.skip(bookId === 0, 'requires test 1 to have created a book'); + await page.goto(`${BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + + const dataMode = await page.locator('#bookForm').getAttribute('data-mode'); + expect(['edit', 'modifica']).toContain(String(dataMode || '').toLowerCase()); + await expect(page.locator('#titolo')).toHaveValue(CREATE_TITLE); + await expect(page.locator('#anno_pubblicazione')).toHaveValue('2024'); + }); + + test('3. #351 — Availability (#stato) select is disabled (read-only)', async () => { + test.skip(bookId === 0, 'requires test 1 to have created a book'); + await page.goto(`${BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + + const stato = page.locator('#stato'); + await expect(stato).toHaveCount(1); + await expect(stato).toBeDisabled(); + await expect(stato).toHaveAttribute('aria-readonly', 'true'); + + // It must also be disabled on the create form — availability is derived + // from copies in both modes. + await page.goto(CREATE_BOOK_URL); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#stato')).toBeDisabled(); + }); + + test('4. Editing the subtitle persists to the DB', async () => { + test.skip(bookId === 0, 'requires test 1 to have created a book'); + test.setTimeout(60000); + await page.goto(`${BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + + await page.fill('#sottotitolo', EDITED_SUBTITLE); + await submitBookForm(page); + + await expect.poll( + () => dbQuery(`SELECT sottotitolo FROM libri WHERE id = ${bookId}`), + { timeout: 30000 }, + ).toBe(EDITED_SUBTITLE); + // Title untouched by the edit. + expect(dbQuery(`SELECT titolo FROM libri WHERE id = ${bookId}`)).toBe(CREATE_TITLE); + }); + + test('5. #351 — the disabled availability field did not alter libri.stato', async () => { + test.skip(bookId === 0 || statoBeforeEdit === '', 'requires tests 1 and 4'); + const statoAfterEdit = dbQuery(`SELECT stato FROM libri WHERE id = ${bookId}`); + expect(statoAfterEdit, 'stato must be unchanged after an edit-save with the disabled select').toBe(statoBeforeEdit); + }); +}); diff --git a/tests/full-test.spec.js b/tests/full-test.spec.js index d98ca3193..73b9c34d1 100644 --- a/tests/full-test.spec.js +++ b/tests/full-test.spec.js @@ -2851,7 +2851,12 @@ test.describe.serial('Phase 18: Issue Regressions', () => { await page.waitForLoadState('domcontentloaded'); const statoField = page.locator('#stato, select[name="stato"]'); - if (await statoField.isVisible({ timeout: 2000 }).catch(() => false)) { + // #stato (availability) is a derived value and read-only by design (#351): + // the