Changeset 1720482 in Klonkt


Ignore:
Timestamp:
06/20/2026 02:33:25 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
5f47671
Parents:
1ee967d
Message:

feat(search): tracks searchable + prefix-matching for posts

  • Posts: FTS5 query is now prefix-AND ("astr"* finds "astra") instead of a single strict phrase — nicer search-as-you-type; title/author/content are searched.
  • Tracks: new search on audio_tracks (title/artist/album, LIKE), as playable rows (audio-player picks up data-pcms-track) + "in post →" link to the post/album/playlist the track appears in (in-memory match over the site's published posts).
  • View updated to two-section layout (Tracks / Posts).

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

Files:
3 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.md

    r1ee967d r1720482  
    1414## [Unreleased]
    1515
     16### Toegevoegd
     17- Zoeken doorzoekt nu ook **nummers** (op titel, artiest én album), niet alleen posts.
     18  Gevonden nummers zijn direct afspeelbaar in de resultatenlijst, met een
     19  "in post →"-link naar de post/album/playlist waarin het nummer voorkomt.
     20- Posts-zoek gebruikt nu **prefix-matching**: typ "astr" en je vindt "astra"
     21  (elk woord als prefix, AND tussen de woorden) — fijner zoeken-terwijl-je-typt.
     22
    1623### Opgelost
     24- Mini-speler: spring + scroll naar de spelende track werkt nu ook vanuit een
     25  **album of playlist** (de track-id zat niet in de afspeel-queue).
     26- De track die nu speelt krijgt een **blijvende highlight** in de post.
     27- Lege **album/playlist-covers** hersteld (een gemiste verwijzing in de
     28  WebP-conversie) + ze vallen nu terug op de cover van de eerste track.
     29- Kapotte **avatar/profielfoto** in de kop na de WebP-conversie: de viewer-avatar
     30  (en rol) wordt nu vers uit de database gelezen, dus een oude sessie herstelt
     31  zichzelf zonder opnieuw inloggen.
     32
     33### Opgelost (eerder)
    1734- Inline content-layout op touch versimpeld: geen opmaak-toolbar/rand meer inline
    1835  (je bewerkt toch fullscreen) — alleen de content-preview + de tik-om-te-bewerken-pill.
  • src/routes/search.js

    r1ee967d r1720482  
    22 * GET /search?q=...
    33 *
    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.
    67 *
    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.
    1318 */
    1419
     
    1621import db from '../config/database.js';
    1722import { renderPage } from '../middleware/render.js';
     23import { audioUrl } from '../services/AudioStreamService.js';
    1824
    1925const router = express.Router();
    2026
    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.
     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, '\\$&') + '%';
    2639}
    2740
     
    2942  const site = res.locals.site;
    3043  const rawQ = (req.query.q || '').toString().trim();
     44  const isHub = res.locals.tenancy === 'hub';
     45  const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
    3146
    3247  if (!site) return res.status(404).send('No site');
    3348
    34   // Empty query — render the page with the form and no results.
     49  // Lege query — toon het formulier zonder resultaten.
    3550  if (!rawQ) {
    3651    return renderPage(req, res, 'pages/search', {
    37       pageTitle: 'Search',
     52      pageTitle: 'Zoeken',
    3853      bodyClass: 'on-special',
    3954      query: '',
    4055      results: [],
     56      tracks: [],
    4157      total: 0,
    4258    });
     
    4460
    4561  let results = [];
    46   let total = 0;
     62  let tracks = [];
    4763  let queryError = null;
    4864
    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    }
    7390  }
    7491
     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
    75148  renderPage(req, res, 'pages/search', {
    76     pageTitle: `Search: ${rawQ}`,
     149    pageTitle: `Zoeken: ${rawQ}`,
    77150    bodyClass: 'on-special',
    78151    query: rawQ,
    79152    results,
     153    tracks,
    80154    total,
    81155    queryError,
  • src/views/pages/search.ejs

    r1ee967d r1720482  
    11<% const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; %>
     2<% const _tracks = (typeof tracks !== 'undefined' && tracks) ? tracks : []; %>
    23<div class="container search-page">
    3   <h1>Search</h1>
     4  <h1>Zoeken</h1>
    45
    56  <form method="get" action="<%= _base %>/search" class="search-page-form">
     
    89      name="q"
    910      value="<%= query %>"
    10       placeholder="Search posts…"
     11      placeholder="Zoek posts en nummers…"
    1112      autocomplete="off"
    1213      autofocus
    13       aria-label="Search query">
    14     <button type="submit" class="btn btn-primary">Search</button>
     14      aria-label="Zoekopdracht">
     15    <button type="submit" class="btn btn-primary">Zoek</button>
    1516  </form>
    1617
    1718  <% if (queryError) { %>
    18     <p class="search-error">Couldn't run that query. Try a simpler term.</p>
     19    <p class="search-error">Kon die zoekopdracht niet uitvoeren. Probeer een eenvoudiger term.</p>
    1920  <% } %>
    2021
    2122  <% if (query && !queryError) { %>
    2223    <p class="search-meta">
    23       <%= total %> result<%= total === 1 ? '' : 's' %> for &ldquo;<%= query %>&rdquo;
     24      <%= total %> resulta<%= total === 1 ? 'at' : 'ten' %> voor &ldquo;<%= query %>&rdquo;
    2425    </p>
    2526  <% } %>
    2627
     28  <% if (_tracks.length) { %>
     29    <h2 class="search-section-title">Nummers</h2>
     30    <div class="search-tracks">
     31      <% _tracks.forEach(function(t) {
     32           const tj = JSON.stringify({ id: t.id, url: t.url, title: t.title, artist: t.artist || '', cover: t.cover || '' })
     33             .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;'); %>
     34        <div class="post-audio-track search-track" id="track-<%= t.id %>"
     35             data-pcms-track-id="<%= t.id %>" data-pcms-track-url="<%= t.url %>"
     36             data-pcms-track='<%- tj %>'>
     37          <button type="button" class="pat-play" aria-label="Speel <%= t.title %>">
     38            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
     39          </button>
     40          <% if (t.cover) { %>
     41            <span class="search-track-cover" style="background-image:url('<%= t.cover %>')" aria-hidden="true"></span>
     42          <% } %>
     43          <div class="pat-info">
     44            <div class="pat-title"><%= t.title %></div>
     45            <% if (t.artist || t.album) { %>
     46              <div class="pat-artist">
     47                <%= t.artist %><% if (t.artist && t.album) { %> &middot; <% } %><%= t.album %>
     48              </div>
     49            <% } %>
     50          </div>
     51          <% if (t.postUrl) { %>
     52            <a class="search-track-link"
     53               href="<%= t.postUrl %>"
     54               hx-get="<%= t.postUrl %>?partial=1"
     55               hx-target="#pcms-main"
     56               hx-swap="innerHTML"
     57               hx-push-url="<%= t.postUrl %>"
     58               aria-label="Open in post">in post →</a>
     59          <% } %>
     60        </div>
     61      <% }); %>
     62    </div>
     63  <% } %>
     64
    2765  <% if (results && results.length) { %>
     66    <% if (_tracks.length) { %><h2 class="search-section-title">Posts</h2><% } %>
    2867    <ol class="search-results">
    2968      <% results.forEach(function(r) { %>
    3069        <li class="search-result">
    31           <h2 class="search-result-title">
    32             <a href="/<%= r.slug %>"
    33                hx-get="/<%= r.slug %>?partial=1"
     70          <h3 class="search-result-title">
     71            <a href="<%= _base %>/<%= r.slug %>"
     72               hx-get="<%= _base %>/<%= r.slug %>?partial=1"
    3473               hx-target="#pcms-main"
    3574               hx-swap="innerHTML"
    36                hx-push-url="/<%= r.slug %>"
     75               hx-push-url="<%= _base %>/<%= r.slug %>"
    3776               hx-indicator="#pcms-loading">
    38               <%= r.title || '(untitled)' %>
     77              <%= r.title || '(zonder titel)' %>
    3978            </a>
    40           </h2>
     79          </h3>
    4180          <p class="search-result-meta">
    4281            <%= r.author_username %>
     
    4786      <% }); %>
    4887    </ol>
    49   <% } else if (query && !queryError) { %>
    50     <p class="search-empty">No posts found.</p>
     88  <% } %>
     89
     90  <% if (query && !queryError && !results.length && !_tracks.length) { %>
     91    <p class="search-empty">Niets gevonden.</p>
    5192  <% } %>
    5293</div>
     
    80121  margin: 0 0 1rem;
    81122}
     123.search-section-title {
     124  font-family: var(--font-display, serif);
     125  font-size: 1.1rem;
     126  margin: 1.75rem 0 0.75rem;
     127  color: var(--ink-soft);
     128  text-transform: uppercase;
     129  letter-spacing: 0.05em;
     130}
    82131.search-error {
    83132  background: rgba(200, 60, 60, 0.15);
     
    93142}
    94143
     144/* Nummer-resultaten */
     145.search-tracks { display: flex; flex-direction: column; gap: 0.25rem; }
     146.search-track.post-audio-track {
     147  display: flex;
     148  align-items: center;
     149  gap: 0.75rem;
     150  padding: 0.5rem 0.6rem;
     151  border-radius: 8px;
     152}
     153.search-track .search-track-cover {
     154  width: 38px; height: 38px;
     155  flex: 0 0 38px;
     156  border-radius: 5px;
     157  background-size: cover;
     158  background-position: center;
     159  background-color: var(--paper-2);
     160}
     161.search-track .pat-info { flex: 1; min-width: 0; }
     162.search-track .pat-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
     163.search-track .pat-artist { font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
     164.search-track-link {
     165  flex: 0 0 auto;
     166  font-size: 0.82rem;
     167  color: var(--accent);
     168  text-decoration: none;
     169  white-space: nowrap;
     170}
     171.search-track-link:hover { text-decoration: underline; }
     172
    95173.search-results {
    96174  list-style: none;
    97175  margin: 0;
    98176  padding: 0;
    99   counter-reset: search-result;
    100177}
    101178.search-result {
Note: See TracChangeset for help on using the changeset viewer.