source: Klonkt/src/routes/search.js@ 097da22

main
Last change on this file since 097da22 was 097da22, checked in by roboburr <roboburr@…>, 3 months ago

feat(zoeken): live results while typing + search in events & pages

  • /search/suggest JSON endpoint; overlay shows live (debounced) grouped results while typing, with "all results →" to the full page.
  • Sources expanded: in addition to posts + tracks now also calendar events (shows: city/location/country/note, if the calendar is enabled) and site pages (Agenda/Downloads/Links/Press Kit/Archive, only those available). Full search page shows those sections too. NL/EN/DE.
  • Overlay placeholder now translated (was hardcoded NL).

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 8.2 KB
Line 
1/**
2 * GET /search?q=... -> volledige resultatenpagina
3 * GET /search/suggest?q=... -> compacte JSON voor live resultaten in de overlay
4 *
5 * Doorzoekt de huidige site op:
6 * 1. Posts via posts_fts (FTS5, prefix-matching) — published only.
7 * 2. Nummers (audio_tracks) op titel / artiest / album.
8 * 3. Evenementen (shows) op plaats / locatie / land / notitie — als de agenda aan staat.
9 * 4. Pagina's (Agenda / Downloads / Links / Perskit / Archief) op naam — alleen
10 * de beschikbare.
11 *
12 * FTS5: user-input wordt getokeniseerd op niet-letter/cijfer en elk token tussen
13 * dubbele quotes + `*` gezet → prefix-match, geen operator-soup/syntax-errors.
14 */
15
16import express from 'express';
17import db from '../config/database.js';
18import { renderPage } from '../middleware/render.js';
19import { audioUrl } from '../services/AudioStreamService.js';
20import { getSetting } from '../services/SettingsService.js';
21import { premiumUnlocked } from '../services/PatreonService.js';
22
23const router = express.Router();
24
25function buildFtsQuery(q) {
26 const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
27 if (!terms.length) return null;
28 return terms.map((t) => '"' + t + '"*').join(' ');
29}
30
31function likeArg(q) {
32 return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
33}
34
35function cleanSnippet(html, excerpt) {
36 const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
37 const s = (html || '')
38 .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
39 .replace(/\[\[[^\]]*?\]\]/g, ' ')
40 .replace(/\[\[|\]\]/g, ' ')
41 .replace(/[#>*_`~]+/g, ' ')
42 .replace(/\s+/g, ' ')
43 .trim();
44 if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
45 return s;
46}
47
48// ── De kern: alle bronnen doorzoeken voor één site. `lim` begrenst per groep
49// (klein voor de live-suggesties, ruim voor de volle pagina). ──────────────
50function searchSite(req, res, rawQ, lim) {
51 const site = res.locals.site;
52 const isHub = res.locals.tenancy === 'hub';
53 const base = res.locals.siteUrlBase || '';
54 const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
55 const t = res.locals.t || ((k) => k);
56
57 const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
58 if (!site || !rawQ) return out;
59 const like = likeArg(rawQ);
60
61 // 1. Posts (FTS5)
62 const ftsQuery = buildFtsQuery(rawQ);
63 if (ftsQuery) {
64 try {
65 out.results = db.prepare(`
66 SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
67 snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
68 FROM posts_fts
69 JOIN posts p ON p.id = posts_fts.post_id
70 JOIN users u ON u.id = p.author_id
71 WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
72 ORDER BY score ASC LIMIT ?
73 `).all(ftsQuery, site.id, lim.posts);
74 out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
75 } catch (err) { out.queryError = err.message; }
76 }
77
78 // 2. Nummers
79 try {
80 const trackRows = db.prepare(`
81 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
82 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
83 WHERE t.site_id = @site
84 AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
85 ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
86 `).all({ site: site.id, like, lim: lim.tracks });
87 const playable = trackRows.filter((t) => t.filename);
88 let posts = [];
89 if (playable.length) {
90 posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
91 }
92 const postUrlForTrack = (tr) => {
93 let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
94 if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
95 if (!hit) {
96 const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
97 if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
98 }
99 return hit ? urlFor(hit.slug) : null;
100 };
101 out.tracks = playable.map((tr) => ({
102 id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
103 cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
104 }));
105 } catch (err) { if (!out.queryError) out.queryError = err.message; }
106
107 // 3. Evenementen (agenda) — alleen als de agenda publiek aan staat.
108 if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
109 try {
110 out.events = db.prepare(`
111 SELECT date, time, city, venue, country FROM shows
112 WHERE site_id = @site
113 AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
114 OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
115 ORDER BY date ASC LIMIT @lim
116 `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
117 date: e.date, time: e.time || '',
118 where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
119 url: base + '/shows',
120 }));
121 } catch (err) { if (!out.queryError) out.queryError = err.message; }
122 }
123
124 // 4. Pagina's — curated, alleen de beschikbare; match op de (vertaalde) naam.
125 const ql = rawQ.toLowerCase();
126 const candidates = [
127 { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
128 { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
129 { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
130 { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
131 { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
132 ];
133 out.pages = candidates
134 .filter((c) => c.on)
135 .map((c) => ({ label: t(c.key), url: c.url }))
136 .filter((c) => c.label.toLowerCase().includes(ql))
137 .slice(0, lim.pages);
138
139 return out;
140}
141
142// ── Volledige resultatenpagina ───────────────────────────────────────────────
143router.get('/', (req, res) => {
144 const site = res.locals.site;
145 if (!site) return res.status(404).send('No site');
146 const rawQ = (req.query.q || '').toString().trim();
147
148 if (!rawQ) {
149 return renderPage(req, res, 'pages/search', {
150 pageTitle: 'Zoeken', bodyClass: 'on-special', query: '',
151 results: [], tracks: [], events: [], pages: [], total: 0,
152 });
153 }
154
155 const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
156 const total = r.results.length + r.tracks.length + r.events.length + r.pages.length;
157 renderPage(req, res, 'pages/search', {
158 pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ,
159 results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
160 total, queryError: r.queryError,
161 });
162});
163
164// ── Live suggesties (JSON) ───────────────────────────────────────────────────
165router.get('/suggest', (req, res) => {
166 const site = res.locals.site;
167 if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });
168 const rawQ = (req.query.q || '').toString().trim().slice(0, 100);
169 if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
170
171 const isHub = res.locals.tenancy === 'hub';
172 const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
173 const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
174 res.json({
175 posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),
176 tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),
177 events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),
178 pages: r.pages,
179 });
180});
181
182export default router;
Note: See TracBrowser for help on using the repository browser.