| [7bc636b] | 1 | /**
|
|---|
| [834bcc3] | 2 | * GET /search?q=... -> full results page
|
|---|
| 3 | * GET /search/suggest?q=... -> compact JSON for live results in the overlay
|
|---|
| [7bc636b] | 4 | *
|
|---|
| [834bcc3] | 5 | * Searches the current site across:
|
|---|
| [097da22] | 6 | * 1. Posts via posts_fts (FTS5, prefix-matching) — published only.
|
|---|
| [834bcc3] | 7 | * 2. Tracks (audio_tracks) on title / artist / album.
|
|---|
| 8 | * 3. Events (shows) on city / venue / country / notes — when the agenda is enabled.
|
|---|
| 9 | * 4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only
|
|---|
| 10 | * the available ones.
|
|---|
| [7bc636b] | 11 | *
|
|---|
| [834bcc3] | 12 | * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped
|
|---|
| 13 | * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors.
|
|---|
| [7bc636b] | 14 | */
|
|---|
| 15 |
|
|---|
| 16 | import express from 'express';
|
|---|
| 17 | import db from '../config/database.js';
|
|---|
| 18 | import { renderPage } from '../middleware/render.js';
|
|---|
| [1720482] | 19 | import { audioUrl } from '../services/AudioStreamService.js';
|
|---|
| [097da22] | 20 | import { getSetting } from '../services/SettingsService.js';
|
|---|
| 21 | import { premiumUnlocked } from '../services/PatreonService.js';
|
|---|
| [fefb80c] | 22 | import { t as i18nT, resolveLang } from '../services/i18n.js';
|
|---|
| [7bc636b] | 23 |
|
|---|
| 24 | const router = express.Router();
|
|---|
| 25 |
|
|---|
| [1720482] | 26 | function buildFtsQuery(q) {
|
|---|
| 27 | const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|---|
| 28 | if (!terms.length) return null;
|
|---|
| 29 | return terms.map((t) => '"' + t + '"*').join(' ');
|
|---|
| 30 | }
|
|---|
| 31 |
|
|---|
| 32 | function likeArg(q) {
|
|---|
| 33 | return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
|
|---|
| [7bc636b] | 34 | }
|
|---|
| 35 |
|
|---|
| [5f47671] | 36 | function cleanSnippet(html, excerpt) {
|
|---|
| 37 | const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|---|
| 38 | const s = (html || '')
|
|---|
| [097da22] | 39 | .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|---|
| 40 | .replace(/\[\[[^\]]*?\]\]/g, ' ')
|
|---|
| 41 | .replace(/\[\[|\]\]/g, ' ')
|
|---|
| 42 | .replace(/[#>*_`~]+/g, ' ')
|
|---|
| [5f47671] | 43 | .replace(/\s+/g, ' ')
|
|---|
| 44 | .trim();
|
|---|
| 45 | if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
|
|---|
| 46 | return s;
|
|---|
| 47 | }
|
|---|
| 48 |
|
|---|
| [834bcc3] | 49 | // ── Core: search all sources for one site. `lim` caps results per group
|
|---|
| 50 | // (small for live suggestions, large for the full page). ──────────────────
|
|---|
| [097da22] | 51 | function searchSite(req, res, rawQ, lim) {
|
|---|
| [7bc636b] | 52 | const site = res.locals.site;
|
|---|
| [097da22] | 53 | const base = res.locals.siteUrlBase || '';
|
|---|
| [72ec6a4] | 54 | const urlFor = (slug) => `/${slug}`;
|
|---|
| [fefb80c] | 55 | // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat).
|
|---|
| 56 | const lang = resolveLang(req);
|
|---|
| 57 | const t = (k) => i18nT(lang, k);
|
|---|
| [7bc636b] | 58 |
|
|---|
| [097da22] | 59 | const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
|
|---|
| 60 | if (!site || !rawQ) return out;
|
|---|
| 61 | const like = likeArg(rawQ);
|
|---|
| [7bc636b] | 62 |
|
|---|
| [097da22] | 63 | // 1. Posts (FTS5)
|
|---|
| [1720482] | 64 | const ftsQuery = buildFtsQuery(rawQ);
|
|---|
| 65 | if (ftsQuery) {
|
|---|
| 66 | try {
|
|---|
| [097da22] | 67 | out.results = db.prepare(`
|
|---|
| 68 | SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
|
|---|
| 69 | snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
|
|---|
| [1720482] | 70 | FROM posts_fts
|
|---|
| 71 | JOIN posts p ON p.id = posts_fts.post_id
|
|---|
| 72 | JOIN users u ON u.id = p.author_id
|
|---|
| [097da22] | 73 | WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
|
|---|
| 74 | ORDER BY score ASC LIMIT ?
|
|---|
| 75 | `).all(ftsQuery, site.id, lim.posts);
|
|---|
| 76 | out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
|
|---|
| 77 | } catch (err) { out.queryError = err.message; }
|
|---|
| [1720482] | 78 | }
|
|---|
| 79 |
|
|---|
| [834bcc3] | 80 | // 2. Tracks
|
|---|
| [7bc636b] | 81 | try {
|
|---|
| [1720482] | 82 | const trackRows = db.prepare(`
|
|---|
| 83 | SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
|
|---|
| [097da22] | 84 | FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
|
|---|
| [1720482] | 85 | WHERE t.site_id = @site
|
|---|
| [097da22] | 86 | AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
|
|---|
| 87 | ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
|
|---|
| 88 | `).all({ site: site.id, like, lim: lim.tracks });
|
|---|
| [1720482] | 89 | const playable = trackRows.filter((t) => t.filename);
|
|---|
| 90 | let posts = [];
|
|---|
| 91 | if (playable.length) {
|
|---|
| [097da22] | 92 | posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
|
|---|
| [1720482] | 93 | }
|
|---|
| [097da22] | 94 | const postUrlForTrack = (tr) => {
|
|---|
| 95 | let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
|
|---|
| 96 | if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
|
|---|
| [1720482] | 97 | if (!hit) {
|
|---|
| [097da22] | 98 | const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
|
|---|
| 99 | if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
|
|---|
| [1720482] | 100 | }
|
|---|
| 101 | return hit ? urlFor(hit.slug) : null;
|
|---|
| 102 | };
|
|---|
| [097da22] | 103 | out.tracks = playable.map((tr) => ({
|
|---|
| 104 | id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
|
|---|
| 105 | cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
|
|---|
| [1720482] | 106 | }));
|
|---|
| [097da22] | 107 | } catch (err) { if (!out.queryError) out.queryError = err.message; }
|
|---|
| 108 |
|
|---|
| [834bcc3] | 109 | // 3. Events (agenda) — only when the agenda is publicly enabled.
|
|---|
| [097da22] | 110 | if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
|
|---|
| 111 | try {
|
|---|
| 112 | out.events = db.prepare(`
|
|---|
| 113 | SELECT date, time, city, venue, country FROM shows
|
|---|
| 114 | WHERE site_id = @site
|
|---|
| 115 | AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
|
|---|
| 116 | OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
|
|---|
| 117 | ORDER BY date ASC LIMIT @lim
|
|---|
| 118 | `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
|
|---|
| 119 | date: e.date, time: e.time || '',
|
|---|
| 120 | where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
|
|---|
| 121 | url: base + '/shows',
|
|---|
| 122 | }));
|
|---|
| 123 | } catch (err) { if (!out.queryError) out.queryError = err.message; }
|
|---|
| [7bc636b] | 124 | }
|
|---|
| 125 |
|
|---|
| [834bcc3] | 126 | // 4. Pages — curated, available ones only; matched against the (translated) name.
|
|---|
| [097da22] | 127 | const ql = rawQ.toLowerCase();
|
|---|
| 128 | const candidates = [
|
|---|
| 129 | { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
|
|---|
| 130 | { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
|
|---|
| 131 | { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
|
|---|
| 132 | { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
|
|---|
| 133 | { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
|
|---|
| 134 | ];
|
|---|
| 135 | out.pages = candidates
|
|---|
| 136 | .filter((c) => c.on)
|
|---|
| 137 | .map((c) => ({ label: t(c.key), url: c.url }))
|
|---|
| 138 | .filter((c) => c.label.toLowerCase().includes(ql))
|
|---|
| 139 | .slice(0, lim.pages);
|
|---|
| 140 |
|
|---|
| 141 | return out;
|
|---|
| 142 | }
|
|---|
| [1720482] | 143 |
|
|---|
| [834bcc3] | 144 | // ── Full results page ────────────────────────────────────────────────────────
|
|---|
| [097da22] | 145 | router.get('/', (req, res) => {
|
|---|
| 146 | const site = res.locals.site;
|
|---|
| 147 | if (!site) return res.status(404).send('No site');
|
|---|
| 148 | const rawQ = (req.query.q || '').toString().trim();
|
|---|
| 149 |
|
|---|
| 150 | if (!rawQ) {
|
|---|
| 151 | return renderPage(req, res, 'pages/search', {
|
|---|
| 152 | pageTitle: 'Zoeken', bodyClass: 'on-special', query: '',
|
|---|
| 153 | results: [], tracks: [], events: [], pages: [], total: 0,
|
|---|
| 154 | });
|
|---|
| 155 | }
|
|---|
| 156 |
|
|---|
| 157 | const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
|
|---|
| 158 | const total = r.results.length + r.tracks.length + r.events.length + r.pages.length;
|
|---|
| [7bc636b] | 159 | renderPage(req, res, 'pages/search', {
|
|---|
| [097da22] | 160 | pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ,
|
|---|
| 161 | results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
|
|---|
| 162 | total, queryError: r.queryError,
|
|---|
| 163 | });
|
|---|
| 164 | });
|
|---|
| 165 |
|
|---|
| [834bcc3] | 166 | // ── Live suggestions (JSON) ──────────────────────────────────────────────────
|
|---|
| [097da22] | 167 | router.get('/suggest', (req, res) => {
|
|---|
| 168 | const site = res.locals.site;
|
|---|
| 169 | if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });
|
|---|
| 170 | const rawQ = (req.query.q || '').toString().trim().slice(0, 100);
|
|---|
| 171 | if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
|
|---|
| 172 |
|
|---|
| [72ec6a4] | 173 | const urlFor = (slug) => `/${slug}`;
|
|---|
| [097da22] | 174 | const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
|
|---|
| 175 | res.json({
|
|---|
| 176 | posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),
|
|---|
| 177 | tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),
|
|---|
| 178 | events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),
|
|---|
| 179 | pages: r.pages,
|
|---|
| [7bc636b] | 180 | });
|
|---|
| 181 | });
|
|---|
| 182 |
|
|---|
| 183 | export default router;
|
|---|