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:
@@ -44,11 +44,23 @@ export default function Devlogs({ me }) {
|
|||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [error, setError] = useState(false);
|
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(() => {
|
const load = useCallback(() => {
|
||||||
setError(false);
|
setError(false);
|
||||||
apiGet(`/api/devlogs?page=${page}`).then(setData).catch(() => setError(true));
|
const search = q ? `&q=${encodeURIComponent(q)}` : '';
|
||||||
}, [page]);
|
apiGet(`/api/devlogs?page=${page}${search}`).then(setData).catch(() => setError(true));
|
||||||
|
}, [page, q]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -86,9 +98,22 @@ export default function Devlogs({ me }) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="content">
|
<section className="content">
|
||||||
|
<div className="search">
|
||||||
|
<span className="search-icon">⌕</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Devlogs durchsuchen …"
|
||||||
|
value={qInput}
|
||||||
|
onChange={(e) => setQInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
{q && data && <span className="search-count">{data.total} Treffer</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="notice">Devlogs konnten nicht geladen werden.</p>}
|
{error && <p className="notice">Devlogs konnten nicht geladen werden.</p>}
|
||||||
{!error && !data && <p className="notice">Lade …</p>}
|
{!error && !data && <p className="notice">Lade …</p>}
|
||||||
{data?.total === 0 && <p className="notice">Noch keine Devlogs archiviert.</p>}
|
{data?.total === 0 && (
|
||||||
|
<p className="notice">{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{data?.total > 0 && (
|
{data?.total > 0 && (
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
|
|||||||
@@ -182,6 +182,30 @@ body::after {
|
|||||||
.notice.center { text-align: center; padding: 3.5rem 0; }
|
.notice.center { text-align: center; padding: 3.5rem 0; }
|
||||||
.notice.center p { margin-bottom: 1.25rem; }
|
.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 ───────────────────────────────── */
|
/* ── DEVLOG-TIMELINE ───────────────────────────────── */
|
||||||
.timeline {
|
.timeline {
|
||||||
display: flex; flex-direction: column; gap: 1.5rem;
|
display: flex; flex-direction: column; gap: 1.5rem;
|
||||||
|
|||||||
@@ -41,6 +41,30 @@ if (!devlogCols.some((c) => c.name === 'images')) {
|
|||||||
db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`);
|
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
|
// 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)');
|
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 = ?');
|
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 };
|
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(`
|
const selectCommits = db.prepare(`
|
||||||
SELECT sha, repo, branch, message, author_name, url, committed_at
|
SELECT sha, repo, branch, message, author_name, url, committed_at
|
||||||
FROM commits ORDER BY committed_at DESC LIMIT ? OFFSET ?
|
FROM commits ORDER BY committed_at DESC LIMIT ? OFFSET ?
|
||||||
|
|||||||
+39
-3
@@ -1,6 +1,7 @@
|
|||||||
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
|
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
|
||||||
import { EmbedBuilder } from 'discord.js';
|
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 { removeDevlog } from '../bot/devlog-archive.js';
|
||||||
import { commitChannelId, devlogChannelId } from '../runtime-settings.js';
|
import { commitChannelId, devlogChannelId } from '../runtime-settings.js';
|
||||||
import { getSessionUser, isAdmin } from './auth.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 };
|
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) => {
|
app.get('/api/devlogs', async (request) => {
|
||||||
const { limit, offset, page } = paging(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
|
// images: JSON-Spalte → fertige URLs fürs Frontend
|
||||||
const mapped = items.map(({ images, ...rest }) => ({
|
const mapped = items.map(({ images, ...rest }) => ({
|
||||||
...rest,
|
...rest,
|
||||||
@@ -40,6 +42,40 @@ export function registerApiRoutes(app, client) {
|
|||||||
return { items: mapped, total, page, pageSize: PAGE_SIZE };
|
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 ` <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)
|
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
|
||||||
app.delete('/api/devlogs/:id', async (request, reply) => {
|
app.delete('/api/devlogs/:id', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireAdmin(request, reply)) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user