Changeset 1720482 in Klonkt for src/routes/search.js
- Timestamp:
- 06/20/2026 02:33:25 AM (3 months ago)
- Branches:
- main
- Children:
- 5f47671
- Parents:
- 1ee967d
- File:
-
- 1 edited
-
src/routes/search.js (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/search.js
r1ee967d r1720482 2 2 * GET /search?q=... 3 3 * 4 * Queries the posts_fts virtual table (FTS5) for the current site. 5 * Search is restricted to published posts of the resolved site. 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. 6 7 * 7 * FTS5 quirks handled: 8 * - Empty / whitespace-only query: render the form with no results. 9 * - User input is wrapped in double quotes so FTS5 treats it as a phrase 10 * (avoids syntax errors from special chars like "OR", parentheses, etc.). 11 * - Snippet() builds the highlighted excerpt; we keep markup minimal so 12 * the EJS view can wrap the matches in <mark>. 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. 13 18 */ 14 19 … … 16 21 import db from '../config/database.js'; 17 22 import { renderPage } from '../middleware/render.js'; 23 import { audioUrl } from '../services/AudioStreamService.js'; 18 24 19 25 const router = express.Router(); 20 26 21 // Wrap user input as a single FTS5 phrase. Strip embedded double-quotes so 22 // the wrapping stays balanced. FTS5 phrase queries are forgiving and avoid 23 // the operator-soup pitfalls of bare user input. 24 function asPhrase(q) { 25 return '"' + q.replace(/"/g, '') + '"'; 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. 30 function 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. 37 function likeArg(q) { 38 return '%' + q.replace(/[%_\\]/g, '\\$&') + '%'; 26 39 } 27 40 … … 29 42 const site = res.locals.site; 30 43 const rawQ = (req.query.q || '').toString().trim(); 44 const isHub = res.locals.tenancy === 'hub'; 45 const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`); 31 46 32 47 if (!site) return res.status(404).send('No site'); 33 48 34 // Empty query — render the page with the form and no results.49 // Lege query — toon het formulier zonder resultaten. 35 50 if (!rawQ) { 36 51 return renderPage(req, res, 'pages/search', { 37 pageTitle: ' Search',52 pageTitle: 'Zoeken', 38 53 bodyClass: 'on-special', 39 54 query: '', 40 55 results: [], 56 tracks: [], 41 57 total: 0, 42 58 }); … … 44 60 45 61 let results = []; 46 let t otal = 0;62 let tracks = []; 47 63 let queryError = null; 48 64 49 try { 50 // FTS5 join → posts table, filter by site + published 51 results = db.prepare(` 52 SELECT 53 p.slug, 54 p.title, 55 p.excerpt, 56 p.published_at, 57 u.username AS author_username, 58 snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, 59 bm25(posts_fts) AS score 60 FROM posts_fts 61 JOIN posts p ON p.id = posts_fts.post_id 62 JOIN users u ON u.id = p.author_id 63 WHERE posts_fts MATCH ? 64 AND p.site_id = ? 65 AND p.status = 'published' 66 ORDER BY score ASC 67 LIMIT 50 68 `).all(asPhrase(rawQ), site.id); 69 70 total = results.length; 71 } catch (err) { 72 queryError = err.message; 65 // ── Posts (FTS5, prefix) ─────────────────────────────────────────── 66 const ftsQuery = buildFtsQuery(rawQ); 67 if (ftsQuery) { 68 try { 69 results = db.prepare(` 70 SELECT 71 p.slug, 72 p.title, 73 p.excerpt, 74 p.published_at, 75 u.username AS author_username, 76 snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, 77 bm25(posts_fts) AS score 78 FROM posts_fts 79 JOIN posts p ON p.id = posts_fts.post_id 80 JOIN users u ON u.id = p.author_id 81 WHERE posts_fts MATCH ? 82 AND p.site_id = ? 83 AND p.status = 'published' 84 ORDER BY score ASC 85 LIMIT 50 86 `).all(ftsQuery, site.id); 87 } catch (err) { 88 queryError = err.message; 89 } 73 90 } 74 91 92 // ── Nummers (audio_tracks: titel / artiest / album) ──────────────── 93 try { 94 const like = likeArg(rawQ); 95 const trackRows = db.prepare(` 96 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename 97 FROM audio_tracks t 98 LEFT JOIN media m ON m.id = t.media_id 99 WHERE t.site_id = @site 100 AND ( t.title LIKE @like ESCAPE '\\' 101 OR t.artist LIKE @like ESCAPE '\\' 102 OR t.album LIKE @like ESCAPE '\\' ) 103 ORDER BY t.play_count DESC, t.title ASC 104 LIMIT 25 105 `).all({ site: site.id, like }); 106 107 // Eén keer alle published posts van de site ophalen om per nummer de 108 // post/album/playlist-pagina te vinden waarin 'ie voorkomt (in-memory match). 109 const playable = trackRows.filter((t) => t.filename); 110 let posts = []; 111 if (playable.length) { 112 posts = db.prepare(` 113 SELECT slug, content FROM posts 114 WHERE site_id = ? AND status = 'published' 115 ORDER BY published_at DESC 116 `).all(site.id); 117 } 118 const postUrlForTrack = (t) => { 119 let hit = posts.find((p) => p.content && p.content.includes('[[track:' + t.id + ']]')); 120 if (!hit && t.album) { 121 hit = posts.find((p) => p.content && p.content.includes('[[album:' + t.album + ']]')); 122 } 123 if (!hit) { 124 const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?') 125 .all(t.id).map((r) => r.playlist_id); 126 if (plids.length) { 127 hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]'))); 128 } 129 } 130 return hit ? urlFor(hit.slug) : null; 131 }; 132 133 tracks = playable.map((t) => ({ 134 id: t.id, 135 title: t.title || 'Untitled', 136 artist: t.artist || '', 137 album: t.album || '', 138 cover: t.cover_url || '', 139 url: audioUrl(t.filename), 140 postUrl: postUrlForTrack(t), 141 })); 142 } catch (err) { 143 if (!queryError) queryError = err.message; 144 } 145 146 const total = results.length + tracks.length; 147 75 148 renderPage(req, res, 'pages/search', { 76 pageTitle: ` Search: ${rawQ}`,149 pageTitle: `Zoeken: ${rawQ}`, 77 150 bodyClass: 'on-special', 78 151 query: rawQ, 79 152 results, 153 tracks, 80 154 total, 81 155 queryError,
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)