source: Klonkt/src/routes/search.js@ 34b126b

main
Last change on this file since 34b126b was 5f47671, checked in by roboburr <roboburr@…>, 3 months ago

fix: tablet opens full-player + clean search snippet

  • audio-player: the mini-player click only jumps to the post on true desktop (≥1200, no full-player). Tablet + phone now open the full-player (sheet), like mobile. (Was: ≥768 counted as desktop → tablet jumped to the post.) buster audio-player.js?v=20.
  • search: FTS snippet is cleaned up — shortcodes ([[playlist:…]]) and markdown removed, <mark> highlights kept; falls back to the excerpt if nothing readable remains.

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

  • Property mode set to 100644
File size: 6.1 KB
Line 
1/**
2 * GET /search?q=...
3 *
4 * Doorzoekt de huidige site op twee dingen:
5 * 1. Posts via de posts_fts virtuele tabel (FTS5) — published only.
6 * 2. Nummers (audio_tracks) op titel / artiest / album.
7 *
8 * Verbeteringen t.o.v. de oude versie:
9 * - Prefix-matching: elk woord wordt als prefix-term gezocht ("astr"* vindt
10 * "astra"), met AND tussen de woorden — typen-terwijl-je-zoekt werkt nu.
11 * - Nummers zijn doorzoekbaar en direct afspeelbaar in de resultatenlijst,
12 * met een link naar de post/album/playlist waarin het nummer voorkomt.
13 *
14 * FTS5-randgevallen:
15 * - Lege / whitespace-only query: form zonder resultaten.
16 * - User-input wordt getokeniseerd op niet-letter/cijfer en elk token tussen
17 * dubbele quotes + `*` gezet → geen operator-soup, geen syntax-errors.
18 */
19
20import express from 'express';
21import db from '../config/database.js';
22import { renderPage } from '../middleware/render.js';
23import { audioUrl } from '../services/AudioStreamService.js';
24
25const router = express.Router();
26
27// Bouw een veilige FTS5-prefix-query: tokeniseer op alles wat geen letter/cijfer
28// is, en maak van elk token een prefix-term. Spatie = impliciete AND.
29// Bv. 'rock astr' -> '"rock"* "astr"*'. Geeft null als er geen bruikbaar token is.
30function buildFtsQuery(q) {
31 const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
32 if (!terms.length) return null;
33 return terms.map((t) => '"' + t + '"*').join(' ');
34}
35
36// Escape LIKE-wildcards in user-input zodat % en _ letterlijk matchen.
37function likeArg(q) {
38 return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
39}
40
41// Maak de FTS-snippet leesbaar: shortcodes ([[...]]) en markdown-tekens eruit.
42// De <mark>…</mark>-highlights van FTS blijven staan. Valt terug op de excerpt
43// (geëscaped) als er na het schoonmaken niets leesbaars overblijft.
44function cleanSnippet(html, excerpt) {
45 const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
46 const s = (html || '')
47 .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // markdown-afbeeldingen
48 .replace(/\[\[[^\]]*?\]\]/g, ' ') // shortcodes (incl. <mark> erin)
49 .replace(/\[\[|\]\]/g, ' ') // losse haakjes door truncatie
50 .replace(/[#>*_`~]+/g, ' ') // markdown-opmaaktekens
51 .replace(/\s+/g, ' ')
52 .trim();
53 if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
54 return s;
55}
56
57router.get('/', (req, res) => {
58 const site = res.locals.site;
59 const rawQ = (req.query.q || '').toString().trim();
60 const isHub = res.locals.tenancy === 'hub';
61 const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
62
63 if (!site) return res.status(404).send('No site');
64
65 // Lege query — toon het formulier zonder resultaten.
66 if (!rawQ) {
67 return renderPage(req, res, 'pages/search', {
68 pageTitle: 'Zoeken',
69 bodyClass: 'on-special',
70 query: '',
71 results: [],
72 tracks: [],
73 total: 0,
74 });
75 }
76
77 let results = [];
78 let tracks = [];
79 let queryError = null;
80
81 // ── Posts (FTS5, prefix) ───────────────────────────────────────────
82 const ftsQuery = buildFtsQuery(rawQ);
83 if (ftsQuery) {
84 try {
85 results = db.prepare(`
86 SELECT
87 p.slug,
88 p.title,
89 p.excerpt,
90 p.published_at,
91 u.username AS author_username,
92 snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet,
93 bm25(posts_fts) AS score
94 FROM posts_fts
95 JOIN posts p ON p.id = posts_fts.post_id
96 JOIN users u ON u.id = p.author_id
97 WHERE posts_fts MATCH ?
98 AND p.site_id = ?
99 AND p.status = 'published'
100 ORDER BY score ASC
101 LIMIT 50
102 `).all(ftsQuery, site.id);
103 results = results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
104 } catch (err) {
105 queryError = err.message;
106 }
107 }
108
109 // ── Nummers (audio_tracks: titel / artiest / album) ────────────────
110 try {
111 const like = likeArg(rawQ);
112 const trackRows = db.prepare(`
113 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
114 FROM audio_tracks t
115 LEFT JOIN media m ON m.id = t.media_id
116 WHERE t.site_id = @site
117 AND ( t.title LIKE @like ESCAPE '\\'
118 OR t.artist LIKE @like ESCAPE '\\'
119 OR t.album LIKE @like ESCAPE '\\' )
120 ORDER BY t.play_count DESC, t.title ASC
121 LIMIT 25
122 `).all({ site: site.id, like });
123
124 // Eén keer alle published posts van de site ophalen om per nummer de
125 // post/album/playlist-pagina te vinden waarin 'ie voorkomt (in-memory match).
126 const playable = trackRows.filter((t) => t.filename);
127 let posts = [];
128 if (playable.length) {
129 posts = db.prepare(`
130 SELECT slug, content FROM posts
131 WHERE site_id = ? AND status = 'published'
132 ORDER BY published_at DESC
133 `).all(site.id);
134 }
135 const postUrlForTrack = (t) => {
136 let hit = posts.find((p) => p.content && p.content.includes('[[track:' + t.id + ']]'));
137 if (!hit && t.album) {
138 hit = posts.find((p) => p.content && p.content.includes('[[album:' + t.album + ']]'));
139 }
140 if (!hit) {
141 const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?')
142 .all(t.id).map((r) => r.playlist_id);
143 if (plids.length) {
144 hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
145 }
146 }
147 return hit ? urlFor(hit.slug) : null;
148 };
149
150 tracks = playable.map((t) => ({
151 id: t.id,
152 title: t.title || 'Untitled',
153 artist: t.artist || '',
154 album: t.album || '',
155 cover: t.cover_url || '',
156 url: audioUrl(t.filename),
157 postUrl: postUrlForTrack(t),
158 }));
159 } catch (err) {
160 if (!queryError) queryError = err.message;
161 }
162
163 const total = results.length + tracks.length;
164
165 renderPage(req, res, 'pages/search', {
166 pageTitle: `Zoeken: ${rawQ}`,
167 bodyClass: 'on-special',
168 query: rawQ,
169 results,
170 tracks,
171 total,
172 queryError,
173 });
174});
175
176export default router;
Note: See TracBrowser for help on using the repository browser.