source: Klonkt/src/routes/search.js@ 7df47b6

main
Last change on this file since 7df47b6 was 72ec6a4, checked in by Robin <roboburr@…>, 6 weeks ago

Hub-modus en guardian-lite eruit

Robins besluit (31-7): een instance is een eigenaar. Twee dingen weg.

HUB-MODUS was al dood: getTenancy() gaf sinds 24-6 hardcoded 'solo'
terug, dus elke tenancy === 'hub'-tak was onbereikbaar. Nu ook echt
verwijderd: getTenancy/setTenancy zelf, de /user/:slug-routing in
resolveSite, de hub-takken in admin, zoeken, audio, posts (neighbours
en related over alle sites), download, en de push-prefix. In de views
verdwijnen de hub-tagline, de hub-navigatie, de sites- en
users-tabellen (die kwamen alleen in hub-modus gevuld en verwezen nu
naar locals die niemand meer meegeeft), de eigenaar-toewijzing bij een
site, de /user/-slugprefix, het hub-brandblok en de hub-thuisknop.

GUARDIAN-LITE was de laatste multi-user-rest: /guardian/invite gaf een
link waarmee iemand via /guardian/join een echte user plus een site met
guardian_only=1 aanmaakte. Dat zette andermans wachtwoordhash, sessie
en PRIVATE actor-sleutel in jouw database, waardoor een verhuizing of
export nooit netjes kon (shaer-qw6q). Routes, formulier, kolom en
uitnodigingstabel zijn weg. Het guardian-DASHBOARD blijft: dat is
FEP-633c en werkt voor guardians met een eigen Klonkt. Bestaande
installaties houden kolom en tabel ongebruikt; nieuwe krijgen ze niet.

Changed files:
src/services/SettingsService.js

  • getTenancy/setTenancy verwijderd; kop herschreven

src/middleware/site.js, src/middleware/render.js

  • /user/:slug-routing weg; tenancy en hubTitle uit de locals

src/routes/admin.js, admin-settings.js, audio.js, download.js,
src/routes/posts.js, search.js

  • hub-takken en hub-queries weg; postNeighbors zonder isHub

src/services/ActivityPubService.js

  • pushPrefix is nu gewoon ; getTenancy-import weg

src/routes/guardian.js

  • /invite en /join verwijderd (dashboard blijft), imports opgeschoond

src/config/database.js

  • guardian_only-kolom en ap_guardian_invites-tabel niet meer aangemaakt

src/views/pages/admin.ejs, admin-users.ejs, admin-site-edit.ejs,
src/views/pages/guardian.ejs, partials/topnav.ejs, chrome.ejs, bottom-tab.ejs

  • alle hub-takken en de uitnodigingsknop weg

remarks: 320 regels weg, 63 erbij. Suite 372 groen; alle 85 templates
compileren; en met een wegwerp-kopie van de database daadwerkelijk
gedraaid en ingelogd: /, /admin, /admin/users, /admin/sites,
/admin/settings, /admin/sites/demo/edit, /admin/media en /guardian
geven alle 200 zonder fouten in het log, en /guardian/invite en
/guardian/join geven nu 404.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 8.2 KB
RevLine 
[7bc636b]1/**
[834bcc3]2 * GET /search?q=... -> full results page
3 * GET /search/suggest?q=... -> compact JSON for live results in the overlay
[7bc636b]4 *
[834bcc3]5 * Searches the current site across:
[097da22]6 * 1. Posts via posts_fts (FTS5, prefix-matching) — published only.
[834bcc3]7 * 2. Tracks (audio_tracks) on title / artist / album.
8 * 3. Events (shows) on city / venue / country / notes — when the agenda is enabled.
9 * 4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only
10 * the available ones.
[7bc636b]11 *
[834bcc3]12 * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped
13 * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors.
[7bc636b]14 */
15
16import express from 'express';
17import db from '../config/database.js';
18import { renderPage } from '../middleware/render.js';
[1720482]19import { audioUrl } from '../services/AudioStreamService.js';
[097da22]20import { getSetting } from '../services/SettingsService.js';
21import { premiumUnlocked } from '../services/PatreonService.js';
[fefb80c]22import { t as i18nT, resolveLang } from '../services/i18n.js';
[7bc636b]23
24const router = express.Router();
25
[1720482]26function buildFtsQuery(q) {
27 const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
28 if (!terms.length) return null;
29 return terms.map((t) => '"' + t + '"*').join(' ');
30}
31
32function likeArg(q) {
33 return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
[7bc636b]34}
35
[5f47671]36function cleanSnippet(html, excerpt) {
37 const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
38 const s = (html || '')
[097da22]39 .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
40 .replace(/\[\[[^\]]*?\]\]/g, ' ')
41 .replace(/\[\[|\]\]/g, ' ')
42 .replace(/[#>*_`~]+/g, ' ')
[5f47671]43 .replace(/\s+/g, ' ')
44 .trim();
45 if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
46 return s;
47}
48
[834bcc3]49// ── Core: search all sources for one site. `lim` caps results per group
50// (small for live suggestions, large for the full page). ──────────────────
[097da22]51function searchSite(req, res, rawQ, lim) {
[7bc636b]52 const site = res.locals.site;
[097da22]53 const base = res.locals.siteUrlBase || '';
[72ec6a4]54 const urlFor = (slug) => `/${slug}`;
[fefb80c]55 // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat).
56 const lang = resolveLang(req);
57 const t = (k) => i18nT(lang, k);
[7bc636b]58
[097da22]59 const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
60 if (!site || !rawQ) return out;
61 const like = likeArg(rawQ);
[7bc636b]62
[097da22]63 // 1. Posts (FTS5)
[1720482]64 const ftsQuery = buildFtsQuery(rawQ);
65 if (ftsQuery) {
66 try {
[097da22]67 out.results = db.prepare(`
68 SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
69 snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
[1720482]70 FROM posts_fts
71 JOIN posts p ON p.id = posts_fts.post_id
72 JOIN users u ON u.id = p.author_id
[097da22]73 WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
74 ORDER BY score ASC LIMIT ?
75 `).all(ftsQuery, site.id, lim.posts);
76 out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
77 } catch (err) { out.queryError = err.message; }
[1720482]78 }
79
[834bcc3]80 // 2. Tracks
[7bc636b]81 try {
[1720482]82 const trackRows = db.prepare(`
83 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
[097da22]84 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
[1720482]85 WHERE t.site_id = @site
[097da22]86 AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
87 ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
88 `).all({ site: site.id, like, lim: lim.tracks });
[1720482]89 const playable = trackRows.filter((t) => t.filename);
90 let posts = [];
91 if (playable.length) {
[097da22]92 posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
[1720482]93 }
[097da22]94 const postUrlForTrack = (tr) => {
95 let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
96 if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
[1720482]97 if (!hit) {
[097da22]98 const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
99 if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
[1720482]100 }
101 return hit ? urlFor(hit.slug) : null;
102 };
[097da22]103 out.tracks = playable.map((tr) => ({
104 id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
105 cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
[1720482]106 }));
[097da22]107 } catch (err) { if (!out.queryError) out.queryError = err.message; }
108
[834bcc3]109 // 3. Events (agenda) — only when the agenda is publicly enabled.
[097da22]110 if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
111 try {
112 out.events = db.prepare(`
113 SELECT date, time, city, venue, country FROM shows
114 WHERE site_id = @site
115 AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
116 OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
117 ORDER BY date ASC LIMIT @lim
118 `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
119 date: e.date, time: e.time || '',
120 where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
121 url: base + '/shows',
122 }));
123 } catch (err) { if (!out.queryError) out.queryError = err.message; }
[7bc636b]124 }
125
[834bcc3]126 // 4. Pages — curated, available ones only; matched against the (translated) name.
[097da22]127 const ql = rawQ.toLowerCase();
128 const candidates = [
129 { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
130 { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
131 { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
132 { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
133 { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
134 ];
135 out.pages = candidates
136 .filter((c) => c.on)
137 .map((c) => ({ label: t(c.key), url: c.url }))
138 .filter((c) => c.label.toLowerCase().includes(ql))
139 .slice(0, lim.pages);
140
141 return out;
142}
[1720482]143
[834bcc3]144// ── Full results page ────────────────────────────────────────────────────────
[097da22]145router.get('/', (req, res) => {
146 const site = res.locals.site;
147 if (!site) return res.status(404).send('No site');
148 const rawQ = (req.query.q || '').toString().trim();
149
150 if (!rawQ) {
151 return renderPage(req, res, 'pages/search', {
152 pageTitle: 'Zoeken', bodyClass: 'on-special', query: '',
153 results: [], tracks: [], events: [], pages: [], total: 0,
154 });
155 }
156
157 const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
158 const total = r.results.length + r.tracks.length + r.events.length + r.pages.length;
[7bc636b]159 renderPage(req, res, 'pages/search', {
[097da22]160 pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ,
161 results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
162 total, queryError: r.queryError,
163 });
164});
165
[834bcc3]166// ── Live suggestions (JSON) ──────────────────────────────────────────────────
[097da22]167router.get('/suggest', (req, res) => {
168 const site = res.locals.site;
169 if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });
170 const rawQ = (req.query.q || '').toString().trim().slice(0, 100);
171 if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
172
[72ec6a4]173 const urlFor = (slug) => `/${slug}`;
[097da22]174 const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
175 res.json({
176 posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),
177 tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),
178 events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),
179 pages: r.pages,
[7bc636b]180 });
181});
182
183export default router;
Note: See TracBrowser for help on using the repository browser.