Changeset 097da22 in Klonkt for src/routes


Ignore:
Timestamp:
06/21/2026 05:26:47 PM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
fefb80c
Parents:
e579a98
Message:

feat(zoeken): live results while typing + search in events & pages

  • /search/suggest JSON endpoint; overlay shows live (debounced) grouped results while typing, with "all results →" to the full page.
  • Sources expanded: in addition to posts + tracks now also calendar events (shows: city/location/country/note, if the calendar is enabled) and site pages (Agenda/Downloads/Links/Press Kit/Archive, only those available). Full search page shows those sections too. NL/EN/DE.
  • Overlay placeholder now translated (was hardcoded NL).

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/routes/search.js

    re579a98 r097da22  
    11/**
    2  * GET /search?q=...
     2 * GET /search?q=...          -> volledige resultatenpagina
     3 * GET /search/suggest?q=...  -> compacte JSON voor live resultaten in de overlay
    34 *
    4  * Doorzoekt de huidige site op twee dingen:
    5  *   1. Posts via de posts_fts virtuele tabel (FTS5) — published only.
     5 * Doorzoekt de huidige site op:
     6 *   1. Posts via posts_fts (FTS5, prefix-matching) — published only.
    67 *   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.
    711 *
    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.
     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.
    1814 */
    1915
     
    2218import { renderPage } from '../middleware/render.js';
    2319import { audioUrl } from '../services/AudioStreamService.js';
     20import { getSetting } from '../services/SettingsService.js';
     21import { premiumUnlocked } from '../services/PatreonService.js';
    2422
    2523const router = express.Router();
    2624
    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.
    3025function buildFtsQuery(q) {
    3126  const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
     
    3429}
    3530
    36 // Escape LIKE-wildcards in user-input zodat % en _ letterlijk matchen.
    3731function likeArg(q) {
    3832  return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
    3933}
    4034
    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.
    4435function cleanSnippet(html, excerpt) {
    4536  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    4637  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
     38    .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
     39    .replace(/\[\[[^\]]*?\]\]/g, ' ')
     40    .replace(/\[\[|\]\]/g, ' ')
     41    .replace(/[#>*_`~]+/g, ' ')
    5142    .replace(/\s+/g, ' ')
    5243    .trim();
     
    5546}
    5647
     48// ── De kern: alle bronnen doorzoeken voor één site. `lim` begrenst per groep
     49//    (klein voor de live-suggesties, ruim voor de volle pagina). ──────────────
     50function searchSite(req, res, rawQ, lim) {
     51  const site = res.locals.site;
     52  const isHub = res.locals.tenancy === 'hub';
     53  const base = res.locals.siteUrlBase || '';
     54  const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
     55  const t = res.locals.t || ((k) => k);
     56
     57  const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
     58  if (!site || !rawQ) return out;
     59  const like = likeArg(rawQ);
     60
     61  // 1. Posts (FTS5)
     62  const ftsQuery = buildFtsQuery(rawQ);
     63  if (ftsQuery) {
     64    try {
     65      out.results = db.prepare(`
     66        SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
     67               snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
     68        FROM posts_fts
     69        JOIN posts p ON p.id = posts_fts.post_id
     70        JOIN users u ON u.id = p.author_id
     71        WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
     72        ORDER BY score ASC LIMIT ?
     73      `).all(ftsQuery, site.id, lim.posts);
     74      out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
     75    } catch (err) { out.queryError = err.message; }
     76  }
     77
     78  // 2. Nummers
     79  try {
     80    const trackRows = db.prepare(`
     81      SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
     82      FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
     83      WHERE t.site_id = @site
     84        AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
     85      ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
     86    `).all({ site: site.id, like, lim: lim.tracks });
     87    const playable = trackRows.filter((t) => t.filename);
     88    let posts = [];
     89    if (playable.length) {
     90      posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
     91    }
     92    const postUrlForTrack = (tr) => {
     93      let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
     94      if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
     95      if (!hit) {
     96        const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
     97        if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
     98      }
     99      return hit ? urlFor(hit.slug) : null;
     100    };
     101    out.tracks = playable.map((tr) => ({
     102      id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
     103      cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
     104    }));
     105  } catch (err) { if (!out.queryError) out.queryError = err.message; }
     106
     107  // 3. Evenementen (agenda) — alleen als de agenda publiek aan staat.
     108  if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
     109    try {
     110      out.events = db.prepare(`
     111        SELECT date, time, city, venue, country FROM shows
     112        WHERE site_id = @site
     113          AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
     114             OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
     115        ORDER BY date ASC LIMIT @lim
     116      `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
     117        date: e.date, time: e.time || '',
     118        where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
     119        url: base + '/shows',
     120      }));
     121    } catch (err) { if (!out.queryError) out.queryError = err.message; }
     122  }
     123
     124  // 4. Pagina's — curated, alleen de beschikbare; match op de (vertaalde) naam.
     125  const ql = rawQ.toLowerCase();
     126  const candidates = [
     127    { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
     128    { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
     129    { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
     130    { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
     131    { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
     132  ];
     133  out.pages = candidates
     134    .filter((c) => c.on)
     135    .map((c) => ({ label: t(c.key), url: c.url }))
     136    .filter((c) => c.label.toLowerCase().includes(ql))
     137    .slice(0, lim.pages);
     138
     139  return out;
     140}
     141
     142// ── Volledige resultatenpagina ───────────────────────────────────────────────
    57143router.get('/', (req, res) => {
    58144  const site = res.locals.site;
     145  if (!site) return res.status(404).send('No site');
    59146  const rawQ = (req.query.q || '').toString().trim();
    60   const isHub = res.locals.tenancy === 'hub';
    61   const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
    62147
    63   if (!site) return res.status(404).send('No site');
    64 
    65   // Lege query — toon het formulier zonder resultaten.
    66148  if (!rawQ) {
    67149    return renderPage(req, res, 'pages/search', {
    68       pageTitle: 'Zoeken',
    69       bodyClass: 'on-special',
    70       query: '',
    71       results: [],
    72       tracks: [],
    73       total: 0,
     150      pageTitle: 'Zoeken', bodyClass: 'on-special', query: '',
     151      results: [], tracks: [], events: [], pages: [], total: 0,
    74152    });
    75153  }
    76154
    77   let results = [];
    78   let tracks = [];
    79   let queryError = null;
     155  const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
     156  const total = r.results.length + r.tracks.length + r.events.length + r.pages.length;
     157  renderPage(req, res, 'pages/search', {
     158    pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ,
     159    results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
     160    total, queryError: r.queryError,
     161  });
     162});
    80163
    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   }
     164// ── Live suggesties (JSON) ───────────────────────────────────────────────────
     165router.get('/suggest', (req, res) => {
     166  const site = res.locals.site;
     167  if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });
     168  const rawQ = (req.query.q || '').toString().trim().slice(0, 100);
     169  if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
    108170
    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,
     171  const isHub = res.locals.tenancy === 'hub';
     172  const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
     173  const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
     174  res.json({
     175    posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),
     176    tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),
     177    events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),
     178    pages: r.pages,
    173179  });
    174180});
Note: See TracChangeset for help on using the changeset viewer.