diff --git a/README.md b/README.md index 22b16de0..55f178ed 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and private collections. It focuses on automation, extensibility, and a usable public catalog without requiring a web team. -[![Version](https://img.shields.io/badge/version-0.4.9.8-0ea5e9?style=for-the-badge)](version.json) +[![Version](https://img.shields.io/badge/version-0.4.9.9-0ea5e9?style=for-the-badge)](version.json) [![Installer Ready](https://img.shields.io/badge/one--click_install-ready-22c55e?style=for-the-badge&logo=azurepipelines&logoColor=white)](installer) [![License](https://img.shields.io/badge/License-GPL--3.0-orange?style=for-the-badge)](LICENSE) @@ -24,7 +24,41 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri --- -## What's New in v0.4.9.8 +## What's New in v0.4.9.9 + +### 📖 Inline PDF Viewer, Search Improvements & Bug Fixes + +**Digital Library Plugin v1.3.0 — Inline PDF Viewer (Issue #80):** +- **Inline PDF reader** — Browser-native ` + + +
+ + + + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + + + + diff --git a/tests/smoke-install.spec.js b/tests/smoke-install.spec.js index 5455db8d..e738f23e 100644 --- a/tests/smoke-install.spec.js +++ b/tests/smoke-install.spec.js @@ -40,7 +40,7 @@ test.describe.serial('Smoke: clean install + core operations', () => { // ── Step 0: Language Selection ────────────────────────────────────── test('Installer step 0: select Italian language', async () => { await page.goto(`${BASE}/installer/?step=0`); - await page.locator('input[name="language"][value="it"]').check(); + await page.locator('input[name="language"][value="it_IT"]').check(); await page.locator('button[type="submit"]').click(); await page.waitForURL(/step=1/); }); diff --git a/tests/social-sharing.spec.js b/tests/social-sharing.spec.js new file mode 100644 index 00000000..afa5294f --- /dev/null +++ b/tests/social-sharing.spec.js @@ -0,0 +1,355 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +const BASE = process.env.BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; + +test.skip( + !ADMIN_EMAIL || !ADMIN_PASS, + 'E2E credentials not configured (set E2E_ADMIN_EMAIL, E2E_ADMIN_PASS)', +); + +/** + * Social Sharing E2E Tests + * + * Tests the sharing feature end-to-end: + * 1. Admin settings: sharing tab, toggle providers, save + * 2. Frontend: verify share buttons render on book detail + * 3. OG meta tags on book detail page + */ + +test.describe.serial('Social Sharing', () => { + /** @type {import('@playwright/test').BrowserContext} */ + let context; + /** @type {import('@playwright/test').Page} */ + let page; + /** @type {string[] | null} Original checked provider slugs — null until capture completes */ + let originalProviders = null; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + // Login as admin + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/admin/, { timeout: 15000 }); + + // Capture original sharing config so afterAll can restore it + await page.goto(`${BASE}/admin/settings?tab=sharing`); + await page.waitForLoadState('networkidle'); + const captured = []; + const inputs = page.locator('[data-settings-panel="sharing"] input[type="checkbox"][name="sharing_providers[]"]'); + const count = await inputs.count(); + for (let i = 0; i < count; i++) { + if (await inputs.nth(i).isChecked()) { + const value = await inputs.nth(i).getAttribute('value'); + if (value) captured.push(value); + } + } + originalProviders = captured; + }); + + test.afterAll(async () => { + // Restore original sharing providers only if capture completed + if (page && originalProviders !== null) { + try { + await page.goto(`${BASE}/admin/settings?tab=sharing`); + await page.waitForLoadState('networkidle'); + const panel = page.locator('[data-settings-panel="sharing"]'); + const inputs = panel.locator('input[type="checkbox"][name="sharing_providers[]"]'); + const count = await inputs.count(); + for (let i = 0; i < count; i++) { + const isChecked = await inputs.nth(i).isChecked(); + const value = await inputs.nth(i).getAttribute('value'); + const shouldBeChecked = value ? originalProviders.includes(value) : false; + if (isChecked && !shouldBeChecked) await inputs.nth(i).uncheck(); + if (!isChecked && shouldBeChecked) await inputs.nth(i).check(); + } + await panel.locator('button[type="submit"]').click(); + await page.waitForLoadState('networkidle'); + } catch (error) { + console.error('Failed to restore sharing providers:', error); + } + } + await context?.close(); + }); + + test('1. Settings: sharing tab loads with provider checkboxes', async () => { + await page.goto(`${BASE}/admin/settings?tab=sharing`); + await page.waitForLoadState('networkidle'); + + // The sharing tab panel should be visible + const sharingPanel = page.locator('[data-settings-panel="sharing"]'); + await expect(sharingPanel).toBeVisible(); + + // Should have provider checkboxes + const checkboxes = sharingPanel.locator('input[type="checkbox"][name="sharing_providers[]"]'); + const count = await checkboxes.count(); + expect(count).toBeGreaterThanOrEqual(10); // We have 16 providers + + // Facebook should be present + await expect(sharingPanel.locator('input[value="facebook"]')).toBeVisible(); + + // WhatsApp should be present + await expect(sharingPanel.locator('input[value="whatsapp"]')).toBeVisible(); + + // Threads should be present (new provider) + await expect(sharingPanel.locator('input[value="threads"]')).toBeVisible(); + + // Bluesky should be present (new provider) + await expect(sharingPanel.locator('input[value="bluesky"]')).toBeVisible(); + }); + + test('2. Settings: save sharing providers', async () => { + await page.goto(`${BASE}/admin/settings?tab=sharing`); + await page.waitForLoadState('networkidle'); + + const panel = page.locator('[data-settings-panel="sharing"]'); + + // Uncheck all first + const allCheckboxes = panel.locator('input[type="checkbox"][name="sharing_providers[]"]'); + const count = await allCheckboxes.count(); + for (let i = 0; i < count; i++) { + if (await allCheckboxes.nth(i).isChecked()) { + await allCheckboxes.nth(i).uncheck(); + } + } + + // Enable specific providers: facebook, x, whatsapp, telegram, email + for (const slug of ['facebook', 'x', 'whatsapp', 'telegram', 'email']) { + await panel.locator(`input[value="${slug}"]`).check(); + } + + // Save + await panel.locator('button[type="submit"]').click(); + await page.waitForLoadState('networkidle'); + + // Should redirect back with success + expect(page.url()).toContain('tab=sharing'); + + // Verify the saved state: checked providers should remain checked + await page.goto(`${BASE}/admin/settings?tab=sharing`); + await page.waitForLoadState('networkidle'); + + const panelAfter = page.locator('[data-settings-panel="sharing"]'); + await expect(panelAfter.locator('input[value="facebook"]')).toBeChecked(); + await expect(panelAfter.locator('input[value="x"]')).toBeChecked(); + await expect(panelAfter.locator('input[value="whatsapp"]')).toBeChecked(); + await expect(panelAfter.locator('input[value="telegram"]')).toBeChecked(); + await expect(panelAfter.locator('input[value="email"]')).toBeChecked(); + + // linkedin should NOT be checked + await expect(panelAfter.locator('input[value="linkedin"]')).not.toBeChecked(); + }); + + test('3. Settings: preview updates live', async () => { + await page.goto(`${BASE}/admin/settings?tab=sharing`); + await page.waitForLoadState('networkidle'); + + const panel = page.locator('[data-settings-panel="sharing"]'); + + // The preview area should exist + const preview = panel.locator('#sharing-preview'); + await expect(preview).toBeVisible(); + + // Toggle linkedin on and verify preview adds the icon + const linkedin = panel.locator('input[value="linkedin"]'); + await linkedin.check(); + await expect(preview.locator('.fa-linkedin-in')).toBeVisible(); + + // Toggle linkedin off and verify preview removes the icon + await linkedin.uncheck(); + await expect(preview.locator('.fa-linkedin-in')).toHaveCount(0); + }); + + test('4. Frontend: share buttons appear on book detail page', async () => { + // Find a book to check — go to catalogue and pick the first one + await page.goto(`${BASE}/catalogo`); + await page.waitForLoadState('networkidle'); + + // Click first book link + const bookLink = page.locator('.book-card a, .card a').filter({ hasText: /.+/ }).first(); + await bookLink.click(); + await page.waitForLoadState('networkidle'); + + // Share card should be visible + const shareCard = page.locator('#book-share-card'); + await expect(shareCard).toBeVisible(); + + // Should have share buttons for our enabled providers + const shareButtons = shareCard.locator('.social-share-btn'); + const btnCount = await shareButtons.count(); + expect(btnCount).toBeGreaterThanOrEqual(5); // 5 providers + possibly Web Share + + // Facebook share button + await expect(shareCard.locator('.fa-facebook-f').first()).toBeVisible(); + + // X/Twitter share button + await expect(shareCard.locator('.fa-x-twitter').first()).toBeVisible(); + + // WhatsApp share button + await expect(shareCard.locator('.fa-whatsapp').first()).toBeVisible(); + + // Telegram share button + await expect(shareCard.locator('.fa-telegram').first()).toBeVisible(); + + // Email share button + await expect(shareCard.locator('.fa-envelope').first()).toBeVisible(); + + // LinkedIn should NOT appear (we unchecked it) + await expect(shareCard.locator('.fa-linkedin-in')).toHaveCount(0); + }); + + test('5. Frontend: share links have correct URLs', async () => { + // Navigate to book detail if not already there (test isolation) + if (!page.url().match(/\/[^/]+\/[^/]+\/\d+$/)) { + await page.goto(`${BASE}/catalogo`); + await page.waitForLoadState('networkidle'); + await page.locator('.book-card a, .card a').filter({ hasText: /.+/ }).first().click(); + await page.waitForLoadState('networkidle'); + } + const shareCard = page.locator('#book-share-card'); + + // The encoded page URL must appear in every share link (works for subdir/subdomain installs) + const pageUrl = page.url(); + const encodedPageUrl = encodeURIComponent(pageUrl); + + // Facebook link should point to facebook sharer and contain current page URL + const fbLink = shareCard.locator('a').filter({ has: page.locator('.fa-facebook-f') }); + const fbHref = await fbLink.getAttribute('href'); + expect(fbHref).toContain('facebook.com/sharer'); + expect(fbHref).toContain(encodedPageUrl); + + // X link should point to twitter intent and contain current page URL + const xLink = shareCard.locator('a').filter({ has: page.locator('.fa-x-twitter') }); + const xHref = await xLink.getAttribute('href'); + expect(xHref).toContain('twitter.com/intent/tweet'); + expect(xHref).toContain(encodedPageUrl); + + // WhatsApp link + const waLink = shareCard.locator('a').filter({ has: page.locator('.fa-whatsapp') }); + const waHref = await waLink.getAttribute('href'); + expect(waHref).toContain('wa.me'); + expect(waHref).toContain(encodedPageUrl); + + // Telegram link + const tgLink = shareCard.locator('a').filter({ has: page.locator('.fa-telegram') }); + const tgHref = await tgLink.getAttribute('href'); + expect(tgHref).toContain('t.me/share'); + expect(tgHref).toContain(encodedPageUrl); + + // Email link should be mailto: with page URL in body + const emailLink = shareCard.locator('a').filter({ has: page.locator('.fa-envelope') }); + const emailHref = await emailLink.getAttribute('href'); + expect(emailHref).toContain('mailto:'); + expect(emailHref).toContain(encodedPageUrl); + + // All external links should have target="_blank" and rel="noopener noreferrer" + const externalLinks = shareCard.locator('a[target="_blank"]'); + const extCount = await externalLinks.count(); + for (let i = 0; i < extCount; i++) { + const rel = await externalLinks.nth(i).getAttribute('rel'); + expect(rel).toContain('noopener'); + expect(rel).toContain('noreferrer'); + } + }); + + test('6. Frontend: OG meta tags present on book page', async () => { + // Navigate to book detail (don't rely on prior test state) + if (!page.url().match(/\/[^/]+\/[^/]+\/\d+$/)) { + await page.goto(`${BASE}/catalogo`); + await page.waitForLoadState('networkidle'); + await page.locator('.book-card a, .card a').filter({ hasText: /.+/ }).first().click(); + await page.waitForLoadState('networkidle'); + } + const ogTitle = await page.locator('meta[property="og:title"]').getAttribute('content'); + expect(ogTitle).toBeTruthy(); + expect(ogTitle.length).toBeGreaterThan(0); + + const ogUrl = await page.locator('meta[property="og:url"]').getAttribute('content'); + expect(ogUrl).toContain('http'); + + const ogType = await page.locator('meta[property="og:type"]').getAttribute('content'); + expect(ogType).toBe('book'); + + const ogImage = await page.locator('meta[property="og:image"]').getAttribute('content'); + expect(ogImage).toBeTruthy(); + + // Twitter card + const twitterCard = await page.locator('meta[name="twitter:card"]').getAttribute('content'); + expect(twitterCard).toBe('summary_large_image'); + + const twitterTitle = await page.locator('meta[name="twitter:title"]').getAttribute('content'); + expect(twitterTitle).toBeTruthy(); + }); + + test('7. Settings: add more providers and verify frontend', async () => { + // Go back to settings and add threads + bluesky + await page.goto(`${BASE}/admin/settings?tab=sharing`); + await page.waitForLoadState('networkidle'); + + const panel = page.locator('[data-settings-panel="sharing"]'); + await panel.locator('input[value="threads"]').check(); + await panel.locator('input[value="bluesky"]').check(); + await panel.locator('input[value="copylink"]').check(); + + await panel.locator('button[type="submit"]').click(); + await page.waitForLoadState('networkidle'); + + // Go to a book page and verify new providers appear + await page.goto(`${BASE}/catalogo`); + await page.waitForLoadState('networkidle'); + await page.locator('.book-card a, .card a').filter({ hasText: /.+/ }).first().click(); + await page.waitForLoadState('networkidle'); + + const shareCard = page.locator('#book-share-card'); + + // Threads icon + await expect(shareCard.locator('.fa-threads')).toBeVisible(); + + // Bluesky icon + await expect(shareCard.locator('.fa-bluesky')).toBeVisible(); + + // Copy link button + await expect(shareCard.locator('[data-share-copy]')).toBeVisible(); + + // Threads link should point to threads.com (not threads.net) + const threadsLink = shareCard.locator('a').filter({ has: page.locator('.fa-threads') }); + const threadsHref = await threadsLink.getAttribute('href'); + expect(threadsHref).toContain('threads.com'); + + // Bluesky link should point to bsky.app + const bskyLink = shareCard.locator('a').filter({ has: page.locator('.fa-bluesky') }); + const bskyHref = await bskyLink.getAttribute('href'); + expect(bskyHref).toContain('bsky.app'); + }); + + test('8. Settings: disable all providers hides share card', async () => { + // Uncheck all providers + await page.goto(`${BASE}/admin/settings?tab=sharing`); + await page.waitForLoadState('networkidle'); + + const panel = page.locator('[data-settings-panel="sharing"]'); + const allCheckboxes = panel.locator('input[type="checkbox"][name="sharing_providers[]"]'); + const count = await allCheckboxes.count(); + for (let i = 0; i < count; i++) { + if (await allCheckboxes.nth(i).isChecked()) { + await allCheckboxes.nth(i).uncheck(); + } + } + await panel.locator('button[type="submit"]').click(); + await page.waitForLoadState('networkidle'); + + // Go to book page — share card should NOT be visible + await page.goto(`${BASE}/catalogo`); + await page.waitForLoadState('networkidle'); + await page.locator('.book-card a, .card a').filter({ hasText: /.+/ }).first().click(); + await page.waitForLoadState('networkidle'); + + const shareCard = page.locator('#book-share-card'); + await expect(shareCard).toHaveCount(0); + }); +}); diff --git a/updater.md b/updater.md index 2dc25685..19ab85b7 100644 --- a/updater.md +++ b/updater.md @@ -426,6 +426,8 @@ migrate_0.4.6.sql # LibraryThing missing fields (dewey_wording, barcode, entr migrate_0.4.7.sql # LibraryThing comprehensive migration (25+ fields, indexes, constraints) migrate_0.4.8.1.sql # Import logs tracking system (import_logs table + composite index) migrate_0.4.8.2.sql # Illustratore field, lingua expansion, language normalization, anno_pubblicazione signed +migrate_0.4.9.9.sql # descrizione_plain column (HTML-free search) +migrate_0.5.0.sql # Social sharing settings + descrizione_plain safety net ``` See `installer/database/migrations/README.md` for detailed migration documentation. @@ -471,10 +473,24 @@ Pinakes uses **3 different SQL parsers** depending on the context. Each has diff ```sql -- The inner string uses '' for escaped quotes, parser handles correctly SET @sql = IF(@exists = 0, 'CREATE TABLE t ( - col ENUM(''a'', ''b'') COMMENT ''Description'' + col ENUM(''a'', ''b'') )', 'SELECT 1'); ``` +5. **NEVER use backslash escapes (`\\`) inside PREPARE strings** — `splitSqlStatements()` only tracks `''` pairs for quote state. A `\\` inside a quoted string (e.g. a REGEXP pattern like `''^[89]\\.'') confuses the parser's state machine, causing it to split mid-statement. This was discovered during v0.4.9.9 upgrade testing. + + ```sql + -- WRONG: backslash in PREPARE string breaks splitSqlStatements() + SET @sql = IF(@check, 'SELECT @ver REGEXP ''^[89]\\.''', 'SELECT 1'); + + -- CORRECT: move complex logic to PHP (BookRepository, Updater, etc.) + -- Keep migrations simple: DDL only, let application code handle data transforms + ``` + + > **Rule of thumb:** If your migration needs REGEXP, REPLACE(), stored procedures, or any complex string manipulation — do it in PHP instead. Migrations should only handle schema changes (ADD COLUMN, CREATE TABLE, ALTER TABLE). Data backfill/transforms belong in application code. + +6. **Avoid COMMENT clauses with escaped quotes in PREPARE strings** — deeply nested quoting like `COMMENT ''description with ''''quotes''''` is fragile across parsers. Omit COMMENT in migrations; document columns in the migration file's SQL comments instead. + ### Data File Format Requirements (data_*.sql) The installer's data import uses split on `;\n` (semicolon + newline) as PDO fallback. @@ -725,6 +741,37 @@ For users on v0.4.1-0.4.3 with broken updater: 2. Access via browser: `https://yoursite.com/manual-update.php` 3. Delete the script after update completes +### Option 3: `manual-upgrade.php` (v0.4.9.8+) + +Standalone single-file upgrade script (`scripts/manual-upgrade.php`) for users who cannot use the admin auto-updater (e.g., restricted hosting, no outbound HTTP). + +**Usage:** +1. Copy `scripts/manual-upgrade.php` to `public/` directory +2. Access via browser: `https://yoursite.com/manual-upgrade.php` +3. Enter the upgrade password (set via `UPGRADE_PASSWORD` constant in the script) +4. Upload the release ZIP file +5. Delete the script after upgrade completes + +**How it works:** +- Password-protected with CSRF token validation +- Creates a mysqldump backup before applying changes +- Extracts ZIP and copies files, respecting `preservePaths` (same list as Updater.php) +- Runs pending database migrations via `splitSqlStatements()` + +**⚠️ IMPORTANT: Bundled plugins are NOT updated by `manual-upgrade.php`** + +Unlike `Updater.php` which has `updateBundledPlugins()`, `manual-upgrade.php` preserves the entire `storage/plugins/` directory without updating bundled plugins. This means: +- New bundled plugin features won't be available after a manual upgrade +- New hooks registered by updated plugins won't activate +- To update bundled plugins manually, extract `storage/plugins//` from the release ZIP and copy over the existing plugin directory + +This is a known gap between the two upgrade paths. The auto-updater (`Updater.php`) always updates bundled plugins; the manual script does not. + +**PHP built-in server caveats:** +- The script must be in `public/` — the built-in server routes everything through Slim's router otherwise +- PHP CLI doesn't read `.user.ini`, so for large ZIPs use: `php -d upload_max_filesize=512M -d post_max_size=512M -S localhost:8082 -t public` +- `mysqldump` may fail with exit code 2 if the DB user lacks FLUSH privilege — the backup still works but with a warning + --- ## Security Considerations @@ -780,7 +827,7 @@ Each plugin has a `plugin.json` file with these version fields: |--------|---------|------| | api-book-scraper | 1.1.0 | api-book-scraper-v1.1.0.zip | | dewey-editor | 1.0.0 | dewey-editor-v1.0.0.zip | - | digital-library | 1.0.0 | digital-library-v1.0.0.zip | + | digital-library | 1.3.0 | digital-library-v1.3.0.zip | | open-library | 1.0.0 | open-library-v1.0.0.zip | | scraping-pro | 1.4.1 | scraping-pro-v1.4.1.zip | | z39-server | 1.2.1 | z39-server-v1.2.1.zip | @@ -988,6 +1035,8 @@ Or when a patch is applied: | Version | Changes | |---------|---------| +| 0.5.0 | Migration: Social sharing default setting (INSERT IGNORE), `descrizione_plain` safety net for fresh installs missing the column. Feature: Configurable social share buttons on book detail page (Facebook, X, WhatsApp, Telegram, LinkedIn, Reddit, Pinterest, Email, Copy Link, Web Share API). Admin Settings > Sharing tab with live preview. | +| 0.4.9.9 | Migration: `descrizione_plain` column for HTML-free search (strip_tags backfill via PHP, not SQL). Digital Library plugin v1.3.0: inline PDF viewer (iframe-based, zero deps), ePub download fix (target=_blank). New migration rule: no `\\` backslash escapes in PREPARE strings — breaks `splitSqlStatements()` | | 0.4.9.7 | Re-release of 0.4.9.6 to ensure bundled plugin updates propagate to installations that updated from pre-0.4.9.6 (older Updater lacked updateBundledPlugins) | | 0.4.9.6 | Comprehensive codebase review: URL scheme validation, proxy-aware HTTPS in installer, bcrypt 72-byte limit, atomic RateLimiter with flock, guarded recalculateBookAvailability/RELEASE_LOCK calls, DashboardStats cache failure throw, language-switcher logging, config charset in SET NAMES | | 0.4.9.4 | Audiobook MP3 player, Z39.50/SRU Nordic sources, global keyboard shortcuts, scroll-to-top, rate-limit bypass fix, German installer support |