Devlog-Suche (FTS5) + öffentlicher RSS-Feed

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 10:34:19 +02:00
co-authored by Claude Opus 4.8
parent f2cae33b8f
commit 5a57108230
4 changed files with 141 additions and 6 deletions
+50
View File
@@ -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 ?
+39 -3
View File
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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 ` <item>
<title>${esc(title)}</title>
<link>${config.publicUrl}/devlogs</link>
<guid isPermaLink="false">${esc(d.message_id)}</guid>
<pubDate>${new Date(d.posted_at).toUTCString()}</pubDate>
<description>${esc(d.content)}</description>
</item>`;
})
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>D4RKST3R // DEVLOG</title>
<link>${config.publicUrl}/devlogs</link>
<description>Entwicklungs-Updates, automatisch archiviert.</description>
<language>de</language>
${rssItems}
</channel>
</rss>`;
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;