source: Klonkt/src/routes/search.js@ 49f698b

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

fix(zoeken): translate page labels in suggest route (res.locals.t doesn't exist there)

searchSite used res.locals.t (undefined in the JSON route) → labels remained the
raw key ('search.page_downloads') and 'archief' didn't match (key is English). Now
via resolveLang(req)+i18n: labels correctly translated and matching works per language.

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

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