From bf81d046e674e7a50cfed33183e52c21f375357d Mon Sep 17 00:00:00 2001 From: th Date: Sun, 12 Apr 2026 11:39:50 +0200 Subject: [PATCH] =?UTF-8?q?PDF=20upload,=20gallery=20=E2=86=92=20assets=20?= =?UTF-8?q?with=20type=20tabs,=20orphan=20detection=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF upload support: * index.php: application/pdf added to $mimeMap + $extMap in the /api/upload handler. PDFs are stored in uploads/ like images/audio. * editor.js uploadFile(): detects PDF via MIME or .pdf extension. Inserts a markdown link [๐Ÿ“„ filename](uploads/...) instead of an image embed. Paste + drop handlers updated to accept PDFs. * index.php: new toolbar button #md-upload-pdf with ph-file-pdf icon next to the extract button. Triggers a hidden file input (accept=".pdf"). Wired in editor.js. Gallery โ†’ Assets rename: * index.php footer button: "Images" โ†’ "Assets", icon ph-folder-open. * index.php gallery overlay header: "images" โ†’ "assets", icon ph-folder-open, upload input accept extended to include .pdf. * Empty state text: "no images" โ†’ "no assets". File-type tabs in the assets overlay: * index.php: new #gallery-tabs div with 4 buttons (All / Images / Audio / PDF), each with data-filter attribute. * gallery.js: fileType(url) helper returns 'image'|'audio'|'pdf'| 'other'. Tab clicks toggle .active class and filter gallery-items by data-url + fileType. activeFilter persists across re-opens. * CSS: .gallery-tab styling with hover + .active accent fill. PDF rendering in the assets grid: * gallery.js mediaTile(): PDFs render as ph-file-pdf icon + filename instead of a thumbnail. Orphaned PDFs show the same icon tile. * Click on an orphaned PDF opens it in a new tab (window.open). * "New card" from an orphaned PDF inserts a markdown link (not image embed). Orphan detection fix (gallery.js): * Root cause: loadCards() in app.js uses two-phase lazy loading โ€” first /api/cards/meta (body=null on all cards), then batches of 25 with full bodies. Opening the gallery before the last batch completes meant most cards had body=null, getReferencedUrls() found no URLs, and every file appeared orphaned. * Fix: openGallery() now fetches its own card list with full bodies via api('api/cards') in parallel with api('api/uploads'), stored in a module-local galleryCards array. Both getReferencedUrls() and findCardForUrl() read from galleryCards instead of state.cards. * Bonus: added a fallback regex uploads/[hex]{32}.[ext] that catches references in any format the specific patterns might miss (HTML attributes, custom blocks, inline text). chore: bump version to 0.9.18 Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- public/css/style.css | 5 ++ public/index.php | 22 +++++--- public/js/modules/editor.js | 25 ++++++--- public/js/modules/gallery.js | 103 ++++++++++++++++++++++++++++++----- 5 files changed, 128 insertions(+), 29 deletions(-) diff --git a/config.php b/config.php index 4995867..2ff0140 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg']; - $extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a']; + $mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg', 'application/pdf' => 'pdf']; + $extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a', 'pdf' => 'pdf']; $finfo = new finfo(FILEINFO_MIME_TYPE); $realMime = $finfo->file($file['tmp_name']); $ext = $mimeMap[$realMime] ?? null; @@ -1236,7 +1236,7 @@ if (str_starts_with($route, '/api/')) { Calendar + + + +
- + @@ -2140,6 +2146,8 @@ if (str_starts_with($route, '/api/')) {
+ + slot('md-toolbar') ?>
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index 334323e..6b70bae 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -994,10 +994,11 @@ body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hi async function uploadFile(file) { const isAudio = file.type.startsWith('audio/'); const isImage = file.type.startsWith('image/'); - if (!isAudio && !isImage) return; + const isPdf = file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf'); + if (!isAudio && !isImage && !isPdf) return; const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin'; - const placeholder = isAudio ? '[uploading audio...]' : '![uploading...]()'; + const placeholder = isPdf ? '[uploading pdf...]' : isAudio ? '[uploading audio...]' : '![uploading...]()'; const pos = body.selectionStart; body.setRangeText(placeholder, pos, body.selectionEnd, 'end'); updatePreview(); @@ -1010,9 +1011,11 @@ async function uploadFile(file) { const data = JSON.parse(text); if (data.error) throw new Error(data.error); if (data.url) { - const md = isAudio - ? `` - : `![${data.name}](${data.url})`; + const md = isPdf + ? `[๐Ÿ“„ ${file.name || data.name}](${data.url})` + : isAudio + ? `` + : `![${data.name}](${data.url})`; const idx = body.value.indexOf(placeholder); if (idx !== -1) { body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length); @@ -1033,7 +1036,7 @@ body.addEventListener('paste', e => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { - if (item.type.startsWith('image/') || item.type.startsWith('audio/')) { + if (item.type.startsWith('image/') || item.type.startsWith('audio/') || item.type === 'application/pdf') { e.preventDefault(); const file = item.getAsFile(); if (file) uploadFile(file); @@ -1047,7 +1050,7 @@ body.addEventListener('drop', e => { const files = e.dataTransfer?.files; if (!files?.length) return; for (const file of files) { - if (file.type.startsWith('image/') || file.type.startsWith('audio/')) { + if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type === 'application/pdf') { e.preventDefault(); uploadFile(file); return; @@ -1055,6 +1058,14 @@ body.addEventListener('drop', e => { } }); +// PDF upload toolbar button +$('md-upload-pdf')?.addEventListener('click', () => $('md-upload-pdf-file')?.click()); +$('md-upload-pdf-file')?.addEventListener('change', e => { + const file = e.target.files?.[0]; + if (file) uploadFile(file); + e.target.value = ''; +}); + // โ”€โ”€ Image resize โ”€โ”€ document.addEventListener('mousedown', e => { diff --git a/public/js/modules/gallery.js b/public/js/modules/gallery.js index 3334e51..d9aa251 100644 --- a/public/js/modules/gallery.js +++ b/public/js/modules/gallery.js @@ -1,44 +1,79 @@ -// โ”€โ”€ Image gallery โ”€โ”€ +// โ”€โ”€ Assets (images, audio, PDFs) โ”€โ”€ import { $, esc, api, getCsrf } from './util.js'; const AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i; +const PDF_EXT = /\.pdf$/i; +const IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i; + +function fileType(url) { + if (IMAGE_EXT.test(url)) return 'image'; + if (AUDIO_EXT.test(url)) return 'audio'; + if (PDF_EXT.test(url)) return 'pdf'; + return 'other'; +} function isAudio(url) { return AUDIO_EXT.test(url); } function mediaTile(url, extraClass = '') { - if (isAudio(url)) { + const type = fileType(url); + if (type === 'audio') { return `
`; } + if (type === 'pdf') { + const name = url.split('/').pop(); + return `
+ + ${esc(name)} +
`; + } return `
`; } + +let activeFilter = 'all'; import { state } from './state.js'; import { showDialog } from './dialog.js'; +// Cards with full bodies โ€” fetched fresh by openGallery so we don't +// depend on state.cards which may have body=null during the two-phase +// lazy load (loadCards loads /api/cards/meta first, then batches of 25 +// with bodies). Without this, most cards look body-less and every file +// appears orphaned until the last batch completes. +let galleryCards = []; + function getReferencedUrls() { const urls = new Set(); - for (const card of state.cards) { + for (const card of galleryCards) { const body = card.body || ''; let m; + // Markdown images: ![alt](url) const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g; while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]); + // HTML images const htmlRe = /]+src=["']([^"']+)["'][^>]*>/gi; while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]); + // Slide-image blocks const slideRe = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g; while ((m = slideRe.exec(body)) !== null) { const srcMatch = m[1].match(/^\s*src\s*:\s*([^\n]+)/m); if (srcMatch) urls.add(srcMatch[1].trim()); } + // Markdown links to uploads (PDFs, other assets): [text](uploads/...) + const linkRe = /\[[^\]]*\]\((uploads\/[^)]+)\)/g; + while ((m = linkRe.exec(body)) !== null) urls.add(m[1]); + // Plain URL mentions (catches any format we missed above) + const plainRe = /uploads\/[a-f0-9]{32}\.[a-z0-9]+/gi; + while ((m = plainRe.exec(body)) !== null) urls.add(m[0]); } return urls; } function findCardForUrl(url) { - for (const card of state.cards) { + for (const card of galleryCards) { const body = card.body || ''; if (body.includes(url)) return card; } @@ -49,7 +84,15 @@ export async function openGallery() { const el = $('gallery'); if (!el) return; - const files = await api('api/uploads'); + // Fetch both file list and full card bodies in parallel. Using + // api('api/cards') (not state.cards) guarantees bodies are present + // โ€” state.cards may still be in the two-phase lazy-load with + // body=null on most entries. + const [files, cards] = await Promise.all([ + api('api/uploads'), + api('api/cards'), + ]); + galleryCards = cards || []; const referenced = getReferencedUrls(); const grid = $('gallery-grid'); const count = $('gallery-count'); @@ -66,21 +109,43 @@ export async function openGallery() { } const total = linked.length + orphaned.length; - if (count) count.textContent = `${total} image${total !== 1 ? 's' : ''}${orphaned.length ? ` ยท ${orphaned.length} orphaned` : ''}`; + if (count) count.textContent = `${total} asset${total !== 1 ? 's' : ''}${orphaned.length ? ` ยท ${orphaned.length} orphaned` : ''}`; + + // Tab filter + const allFiles = [...orphaned, ...linked]; + const applyFilter = (filter) => { + activeFilter = filter; + document.querySelectorAll('#gallery-tabs .gallery-tab').forEach(t => { + t.classList.toggle('active', t.dataset.filter === filter); + }); + grid.querySelectorAll('.gallery-item').forEach(item => { + const url = item.dataset.url || ''; + const type = fileType(url); + const show = filter === 'all' || type === filter; + item.style.display = show ? '' : 'none'; + }); + }; + document.querySelectorAll('#gallery-tabs .gallery-tab').forEach(t => { + t.onclick = () => applyFilter(t.dataset.filter); + }); if (!total) { grid.innerHTML = `
- -

no images

`; + + `; } else { let html = ''; if (orphaned.length) { html += `
orphaned (not used by any card)
`; html += orphaned.map(f => ` -