/**
* GET /search?q=...
*
* Doorzoekt de huidige site op twee dingen:
* 1. Posts via de posts_fts virtuele tabel (FTS5) — published only.
* 2. Nummers (audio_tracks) op titel / artiest / album.
*
* Verbeteringen t.o.v. de oude versie:
* - Prefix-matching: elk woord wordt als prefix-term gezocht ("astr"* vindt
* "astra"), met AND tussen de woorden — typen-terwijl-je-zoekt werkt nu.
* - Nummers zijn doorzoekbaar en direct afspeelbaar in de resultatenlijst,
* met een link naar de post/album/playlist waarin het nummer voorkomt.
*
* FTS5-randgevallen:
* - Lege / whitespace-only query: form zonder resultaten.
* - User-input wordt getokeniseerd op niet-letter/cijfer en elk token tussen
* dubbele quotes + `*` gezet → geen operator-soup, geen syntax-errors.
*/
import express from 'express';
import db from '../config/database.js';
import { renderPage } from '../middleware/render.js';
import { audioUrl } from '../services/AudioStreamService.js';
const router = express.Router();
// Bouw een veilige FTS5-prefix-query: tokeniseer op alles wat geen letter/cijfer
// is, en maak van elk token een prefix-term. Spatie = impliciete AND.
// Bv. 'rock astr' -> '"rock"* "astr"*'. Geeft null als er geen bruikbaar token is.
function buildFtsQuery(q) {
const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
if (!terms.length) return null;
return terms.map((t) => '"' + t + '"*').join(' ');
}
// Escape LIKE-wildcards in user-input zodat % en _ letterlijk matchen.
function likeArg(q) {
return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
}
// Maak de FTS-snippet leesbaar: shortcodes ([[...]]) en markdown-tekens eruit.
// De …-highlights van FTS blijven staan. Valt terug op de excerpt
// (geëscaped) als er na het schoonmaken niets leesbaars overblijft.
function cleanSnippet(html, excerpt) {
const esc = (s) => s.replace(/&/g, '&').replace(//g, '>');
const s = (html || '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // markdown-afbeeldingen
.replace(/\[\[[^\]]*?\]\]/g, ' ') // shortcodes (incl. erin)
.replace(/\[\[|\]\]/g, ' ') // losse haakjes door truncatie
.replace(/[#>*_`~]+/g, ' ') // markdown-opmaaktekens
.replace(/\s+/g, ' ')
.trim();
if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
return s;
}
router.get('/', (req, res) => {
const site = res.locals.site;
const rawQ = (req.query.q || '').toString().trim();
const isHub = res.locals.tenancy === 'hub';
const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
if (!site) return res.status(404).send('No site');
// Lege query — toon het formulier zonder resultaten.
if (!rawQ) {
return renderPage(req, res, 'pages/search', {
pageTitle: 'Zoeken',
bodyClass: 'on-special',
query: '',
results: [],
tracks: [],
total: 0,
});
}
let results = [];
let tracks = [];
let queryError = null;
// ── Posts (FTS5, prefix) ───────────────────────────────────────────
const ftsQuery = buildFtsQuery(rawQ);
if (ftsQuery) {
try {
results = db.prepare(`
SELECT
p.slug,
p.title,
p.excerpt,
p.published_at,
u.username AS author_username,
snippet(posts_fts, 0, '', '', '…', 18) AS snippet,
bm25(posts_fts) AS score
FROM posts_fts
JOIN posts p ON p.id = posts_fts.post_id
JOIN users u ON u.id = p.author_id
WHERE posts_fts MATCH ?
AND p.site_id = ?
AND p.status = 'published'
ORDER BY score ASC
LIMIT 50
`).all(ftsQuery, site.id);
results = results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
} catch (err) {
queryError = err.message;
}
}
// ── Nummers (audio_tracks: titel / artiest / album) ────────────────
try {
const like = likeArg(rawQ);
const trackRows = db.prepare(`
SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
FROM audio_tracks t
LEFT JOIN media m ON m.id = t.media_id
WHERE t.site_id = @site
AND ( t.title LIKE @like ESCAPE '\\'
OR t.artist LIKE @like ESCAPE '\\'
OR t.album LIKE @like ESCAPE '\\' )
ORDER BY t.play_count DESC, t.title ASC
LIMIT 25
`).all({ site: site.id, like });
// Eén keer alle published posts van de site ophalen om per nummer de
// post/album/playlist-pagina te vinden waarin 'ie voorkomt (in-memory match).
const playable = trackRows.filter((t) => t.filename);
let posts = [];
if (playable.length) {
posts = db.prepare(`
SELECT slug, content FROM posts
WHERE site_id = ? AND status = 'published'
ORDER BY published_at DESC
`).all(site.id);
}
const postUrlForTrack = (t) => {
let hit = posts.find((p) => p.content && p.content.includes('[[track:' + t.id + ']]'));
if (!hit && t.album) {
hit = posts.find((p) => p.content && p.content.includes('[[album:' + t.album + ']]'));
}
if (!hit) {
const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?')
.all(t.id).map((r) => r.playlist_id);
if (plids.length) {
hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
}
}
return hit ? urlFor(hit.slug) : null;
};
tracks = playable.map((t) => ({
id: t.id,
title: t.title || 'Untitled',
artist: t.artist || '',
album: t.album || '',
cover: t.cover_url || '',
url: audioUrl(t.filename),
postUrl: postUrlForTrack(t),
}));
} catch (err) {
if (!queryError) queryError = err.message;
}
const total = results.length + tracks.length;
renderPage(req, res, 'pages/search', {
pageTitle: `Zoeken: ${rawQ}`,
bodyClass: 'on-special',
query: rawQ,
results,
tracks,
total,
queryError,
});
});
export default router;