// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt import Database from 'better-sqlite3'; import { mkdirSync, statSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { config } from './config.js'; const dbFile = resolve(config.dbPath); mkdirSync(dirname(dbFile), { recursive: true }); export const db = new Database(dbFile); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS commits ( sha TEXT PRIMARY KEY, repo TEXT NOT NULL, branch TEXT NOT NULL, message TEXT NOT NULL, author_name TEXT, author_user TEXT, url TEXT, committed_at TEXT NOT NULL, received_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_commits_repo_time ON commits (repo, committed_at DESC); CREATE TABLE IF NOT EXISTS devlogs ( message_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, content TEXT NOT NULL, author_name TEXT, posted_at TEXT NOT NULL, archived_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_devlogs_posted ON devlogs (posted_at DESC); `); // Migration: images-Spalte (JSON-Array lokaler Dateinamen) für bestehende DBs const devlogCols = db.prepare('PRAGMA table_info(devlogs)').all(); if (!devlogCols.some((c) => c.name === 'images')) { db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`); } // Releases (Gitea-Release-Webhook) — für Ankündigungen + Changelog-Seite db.exec(` CREATE TABLE IF NOT EXISTS releases ( repo TEXT NOT NULL, tag TEXT NOT NULL, name TEXT, body TEXT, url TEXT, prerelease INTEGER NOT NULL DEFAULT 0, published_at TEXT NOT NULL, PRIMARY KEY (repo, tag) ); `); // 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 = ?'); const setSettingStmt = db.prepare(` INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `); export function getSetting(key) { return getSettingStmt.get(key)?.value ?? null; } export function setSetting(key, value) { setSettingStmt.run(key, value); } const insertCommit = db.prepare(` INSERT OR IGNORE INTO commits (sha, repo, branch, message, author_name, author_user, url, committed_at) VALUES (@sha, @repo, @branch, @message, @author_name, @author_user, @url, @committed_at) `); /** Commits eines Pushes speichern — bereits bekannte SHAs werden ignoriert. Gibt Anzahl neuer Zeilen zurück. */ export const saveCommits = db.transaction((commits) => { let inserted = 0; for (const commit of commits) { inserted += insertCommit.run(commit).changes; } return inserted; }); const insertDevlog = db.prepare(` INSERT OR IGNORE INTO devlogs (message_id, channel_id, content, author_name, posted_at, images) VALUES (@message_id, @channel_id, @content, @author_name, @posted_at, @images) `); const hasDevlogStmt = db.prepare('SELECT 1 FROM devlogs WHERE message_id = ?'); /** Devlog speichern — bereits bekannte Message-IDs werden ignoriert. Gibt true zurück, wenn neu. */ export function saveDevlog(devlog) { return insertDevlog.run({ images: '[]', ...devlog }).changes > 0; } /** true, wenn die Message schon archiviert ist (spart z. B. erneute Bild-Downloads) */ export function hasDevlog(messageId) { return Boolean(hasDevlogStmt.get(messageId)); } const getDevlogImagesStmt = db.prepare('SELECT images FROM devlogs WHERE message_id = ?'); const deleteDevlogStmt = db.prepare('DELETE FROM devlogs WHERE message_id = ?'); /** Devlog löschen; gibt { deleted, images } zurück (Dateinamen fürs Aufräumen) */ export function deleteDevlog(messageId) { const row = getDevlogImagesStmt.get(messageId); if (!row) return { deleted: false, images: [] }; deleteDevlogStmt.run(messageId); return { deleted: true, images: JSON.parse(row.images || '[]') }; } // --- Lese-Queries für die Web-API (Feature 4) --- const selectDevlogs = db.prepare(` SELECT message_id, content, author_name, posted_at, images FROM devlogs ORDER BY posted_at DESC LIMIT ? OFFSET ? `); const countDevlogsStmt = db.prepare('SELECT COUNT(*) AS n FROM devlogs'); 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 ? `); const countCommitsStmt = db.prepare('SELECT COUNT(*) AS n FROM commits'); export function listCommits(limit, offset) { return { items: selectCommits.all(limit, offset), total: countCommitsStmt.get().n }; } const upsertRelease = db.prepare(` INSERT INTO releases (repo, tag, name, body, url, prerelease, published_at) VALUES (@repo, @tag, @name, @body, @url, @prerelease, @published_at) ON CONFLICT(repo, tag) DO UPDATE SET name = excluded.name, body = excluded.body, url = excluded.url, prerelease = excluded.prerelease `); /** Release speichern/aktualisieren. Gibt true zurück, wenn neu (→ ankündigen). */ export function saveRelease(release) { const existed = db .prepare('SELECT 1 FROM releases WHERE repo = ? AND tag = ?') .get(release.repo, release.tag); upsertRelease.run(release); return !existed; } const selectReleases = db.prepare(` SELECT repo, tag, name, body, url, prerelease, published_at FROM releases ORDER BY published_at DESC LIMIT ? OFFSET ? `); const countReleasesStmt = db.prepare('SELECT count(*) AS n FROM releases'); export function listReleases(limit, offset) { return { items: selectReleases.all(limit, offset), total: countReleasesStmt.get().n }; } /** Zahlen der letzten 7 Tage für den Wochen-Rückblick */ export function weeklyStats() { const perDay = db .prepare(` SELECT date(committed_at) AS day, count(*) AS n FROM commits WHERE committed_at >= datetime('now', '-7 days') GROUP BY day `) .all(); const topRepos = db .prepare(` SELECT repo, count(*) AS n FROM commits WHERE committed_at >= datetime('now', '-7 days') GROUP BY repo ORDER BY n DESC LIMIT 3 `) .all(); const devlogs = db .prepare(`SELECT count(*) AS n FROM devlogs WHERE posted_at >= datetime('now', '-7 days')`) .get().n; return { perDay, topRepos, devlogs }; } /** Statistiken fürs Status-Panel der Settings-Seite */ export function archiveStats() { return { devlogs: countDevlogsStmt.get().n, commits: countCommitsStmt.get().n, dbSizeBytes: statSync(dbFile).size, }; }