- Scheduler (5-Minuten-Check, dedupe über last_weekly_recap-Setting) - Embed: Commits pro Tag als Balken-Grafik, Commit-/Devlog-Zahlen, Top-Projekte - Setting weekly_recap_enabled + 'Rückblick jetzt testen'-Button auf der Setup-Seite - TZ Europe/Berlin im Compose (Scheduler + Datumsformate) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
244 lines
8.8 KiB
JavaScript
244 lines
8.8 KiB
JavaScript
// 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,
|
|
};
|
|
}
|