From 5a57108230ed6c0e03f54712c14dfae77b9acbcd Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Thu, 23 Jul 2026 10:34:19 +0200 Subject: [PATCH] =?UTF-8?q?Devlog-Suche=20(FTS5)=20+=20=C3=B6ffentlicher?= =?UTF-8?q?=20RSS-Feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FTS5-Volltextindex mit Trigger-Sync und einmaligem Backfill bestehender Devlogs - /api/devlogs?q= — Präfix-Suche, injection-sicher (Phrasen-Quoting) - Suchfeld mit Entprellung und Treffer-Zähler auf der Devlog-Seite - /feed.xml — RSS 2.0 der letzten 20 Devlogs (escaped, deutsche Titel) Co-Authored-By: Claude Opus 4.8 --- frontend/src/pages/Devlogs.jsx | 31 +++++++++++++++++++-- frontend/src/style.css | 24 ++++++++++++++++ src/db.js | 50 ++++++++++++++++++++++++++++++++++ src/web/api.js | 42 ++++++++++++++++++++++++++-- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/Devlogs.jsx b/frontend/src/pages/Devlogs.jsx index 4e4d5f2..c6a7d66 100644 --- a/frontend/src/pages/Devlogs.jsx +++ b/frontend/src/pages/Devlogs.jsx @@ -44,11 +44,23 @@ export default function Devlogs({ me }) { const [data, setData] = useState(null); const [page, setPage] = useState(1); const [error, setError] = useState(false); + const [qInput, setQInput] = useState(''); + const [q, setQ] = useState(''); + + // Sucheingabe entprellen (300 ms), bei neuer Suche zurück auf Seite 1 + useEffect(() => { + const t = setTimeout(() => { + setQ(qInput.trim()); + setPage(1); + }, 300); + return () => clearTimeout(t); + }, [qInput]); const load = useCallback(() => { setError(false); - apiGet(`/api/devlogs?page=${page}`).then(setData).catch(() => setError(true)); - }, [page]); + const search = q ? `&q=${encodeURIComponent(q)}` : ''; + apiGet(`/api/devlogs?page=${page}${search}`).then(setData).catch(() => setError(true)); + }, [page, q]); useEffect(() => { load(); @@ -86,9 +98,22 @@ export default function Devlogs({ me }) {
+
+ + setQInput(e.target.value)} + /> + {q && data && {data.total} Treffer} +
+ {error &&

Devlogs konnten nicht geladen werden.

} {!error && !data &&

Lade …

} - {data?.total === 0 &&

Noch keine Devlogs archiviert.

} + {data?.total === 0 && ( +

{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}

+ )} {data?.total > 0 && (
diff --git a/frontend/src/style.css b/frontend/src/style.css index aa8e49e..984ef56 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -182,6 +182,30 @@ body::after { .notice.center { text-align: center; padding: 3.5rem 0; } .notice.center p { margin-bottom: 1.25rem; } +/* ── SUCHE ─────────────────────────────────────────── */ +.search { + display: flex; align-items: center; gap: .6rem; + background: var(--bg2); + border: 1px solid rgba(255, 255, 255, .1); + padding: .45rem .9rem; + margin-bottom: 1.75rem; + transition: border-color .2s; +} +.search:focus-within { border-color: var(--neon); } +.search-icon { color: var(--muted2); font-size: 1.1rem; } +.search input { + flex: 1; + background: none; border: none; outline: none; + color: var(--text); + font-family: var(--body); font-size: 1rem; +} +.search input::placeholder { color: var(--muted2); } +.search-count { + font-family: var(--mono); + font-size: .65rem; letter-spacing: .15em; + color: var(--neon); white-space: nowrap; +} + /* ── DEVLOG-TIMELINE ───────────────────────────────── */ .timeline { display: flex; flex-direction: column; gap: 1.5rem; diff --git a/src/db.js b/src/db.js index 29587ea..a56a94d 100644 --- a/src/db.js +++ b/src/db.js @@ -41,6 +41,30 @@ if (!devlogCols.some((c) => c.name === 'images')) { db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`); } +// Volltextsuche (FTS5) über Devlogs — Trigger halten den Index synchron +db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS devlogs_fts USING fts5(content, message_id UNINDEXED); + CREATE TRIGGER IF NOT EXISTS devlogs_fts_ai AFTER INSERT ON devlogs BEGIN + INSERT INTO devlogs_fts (rowid, content, message_id) + VALUES (new.rowid, new.content, new.message_id); + END; + CREATE TRIGGER IF NOT EXISTS devlogs_fts_ad AFTER DELETE ON devlogs BEGIN + DELETE FROM devlogs_fts WHERE rowid = old.rowid; + END; +`); +// Bestehende Zeilen nachindexieren (einmalig nach der Migration) +{ + const ftsCount = db.prepare('SELECT count(*) AS n FROM devlogs_fts').get().n; + const devlogCount = db.prepare('SELECT count(*) AS n FROM devlogs').get().n; + if (ftsCount !== devlogCount) { + db.exec(` + DELETE FROM devlogs_fts; + INSERT INTO devlogs_fts (rowid, content, message_id) + SELECT rowid, content, message_id FROM devlogs; + `); + } +} + // Laufzeit-Einstellungen (Settings-Seite im Webinterface) — überschreiben Env-Defaults db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); const getSettingStmt = db.prepare('SELECT value FROM settings WHERE key = ?'); @@ -110,6 +134,32 @@ export function listDevlogs(limit, offset) { return { items: selectDevlogs.all(limit, offset), total: countDevlogsStmt.get().n }; } +const searchDevlogsStmt = db.prepare(` + SELECT d.message_id, d.content, d.author_name, d.posted_at, d.images + FROM devlogs_fts f + JOIN devlogs d ON d.rowid = f.rowid + WHERE devlogs_fts MATCH ? + ORDER BY d.posted_at DESC LIMIT ? OFFSET ? +`); +const searchDevlogsCountStmt = db.prepare( + 'SELECT count(*) AS n FROM devlogs_fts WHERE devlogs_fts MATCH ?' +); + +/** Suchbegriff in eine sichere FTS5-Query übersetzen: jede Eingabe als Präfix-Phrase */ +function ftsQuery(input) { + const terms = input.split(/\s+/).filter(Boolean).slice(0, 8); + return terms.map((t) => `"${t.replaceAll('"', '')}"*`).join(' '); +} + +export function searchDevlogs(input, limit, offset) { + const query = ftsQuery(input); + if (!query) return { items: [], total: 0 }; + return { + items: searchDevlogsStmt.all(query, limit, offset), + total: searchDevlogsCountStmt.get(query).n, + }; +} + const selectCommits = db.prepare(` SELECT sha, repo, branch, message, author_name, url, committed_at FROM commits ORDER BY committed_at DESC LIMIT ? OFFSET ? diff --git a/src/web/api.js b/src/web/api.js index 930d56a..d41624d 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -1,6 +1,7 @@ // REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin import { EmbedBuilder } from 'discord.js'; -import { listDevlogs, listCommits, archiveStats, getSetting, setSetting } from '../db.js'; +import { listDevlogs, searchDevlogs, listCommits, archiveStats, getSetting, setSetting } from '../db.js'; +import { config } from '../config.js'; import { removeDevlog } from '../bot/devlog-archive.js'; import { commitChannelId, devlogChannelId } from '../runtime-settings.js'; import { getSessionUser, isAdmin } from './auth.js'; @@ -28,10 +29,11 @@ export function registerApiRoutes(app, client) { return user ? { user, admin: isAdmin(user) } : { user: null, admin: false }; }); - // Devlog-Archiv — öffentlich (wie der Discord-Kanal) + // Devlog-Archiv — öffentlich (wie der Discord-Kanal); ?q= für Volltextsuche app.get('/api/devlogs', async (request) => { const { limit, offset, page } = paging(request); - const { items, total } = listDevlogs(limit, offset); + const q = (request.query.q ?? '').trim(); + const { items, total } = q ? searchDevlogs(q, limit, offset) : listDevlogs(limit, offset); // images: JSON-Spalte → fertige URLs fürs Frontend const mapped = items.map(({ images, ...rest }) => ({ ...rest, @@ -40,6 +42,40 @@ export function registerApiRoutes(app, client) { return { items: mapped, total, page, pageSize: PAGE_SIZE }; }); + // RSS-Feed der Devlogs — öffentlich abonnierbar + app.get('/feed.xml', async (request, reply) => { + const { items } = listDevlogs(20, 0); + const esc = (s) => + String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }); + + const rssItems = items + .map((d) => { + const project = d.content.match(/^Devlog\s*[—–-]\s*(.+)$/im)?.[1]?.trim(); + const title = `Devlog ${dateFmt.format(new Date(d.posted_at))}${project ? ` — ${project}` : ''}`; + return ` + ${esc(title)} + ${config.publicUrl}/devlogs + ${esc(d.message_id)} + ${new Date(d.posted_at).toUTCString()} + ${esc(d.content)} + `; + }) + .join('\n'); + + const xml = ` + + + D4RKST3R // DEVLOG + ${config.publicUrl}/devlogs + Entwicklungs-Updates, automatisch archiviert. + de +${rssItems} + +`; + return reply.type('application/rss+xml; charset=utf-8').send(xml); + }); + // Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts) app.delete('/api/devlogs/:id', async (request, reply) => { if (requireAdmin(request, reply)) return;