source: Klonkt/src/routes/search.js@ 7b04d3b

main
Last change on this file since 7b04d3b was 834bcc3, checked in by Robin Genis <roboburr@…>, 3 months ago

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

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

  • Property mode set to 100644
File size: 8.4 KB
Line 
1/**
2 * GET /search?q=... -> full results page
3 * GET /search/suggest?q=... -> compact JSON for live results in the overlay
4 *
5 * Searches the current site across:
6 * 1. Posts via posts_fts (FTS5, prefix-matching) — published only.
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.
11 *
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.
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// ── Core: search all sources for one site. `lim` caps results per group
50// (small for live suggestions, large for the full page). ──────────────────
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. Tracks
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. Events (agenda) — only when the agenda is publicly enabled.
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. Pages — curated, available ones only; matched against the (translated) name.
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// ── Full results page ────────────────────────────────────────────────────────
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 suggestions (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.