Feature 8: Projections & Reports — Automatische Markdown-Reports

- types.ts: projections-Settings erweitert (enabled, outputFolder, dailyLog, sessionLog, weeklyDigest), heatmap zu BuiltinFormat
- formatters.ts: Query-Results → Markdown (Timeline, Table, Summary, Metric, Heatmap, Custom)
- Presets: daily-log (Tagesprotokoll), session-log (Session-Protokoll), weekly-digest (Wochenuebersicht)
- template-registry.ts: Built-in + Custom ProjectionTemplate Verwaltung
- projection-engine.ts: Kern-Engine mit Scheduling, Session-End-Listener, ProjectionPickerModal
- main.ts: ProjectionEngine Integration, 2 neue Commands (run-projection, run-all-projections)
- settings-tab.ts: Neue Sektion "Projektionen" mit Toggles und Konfiguration
- styles.css: ProjectionPickerModal Styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-12 11:52:06 +01:00
parent c30320a7db
commit ee599f42e7
10 changed files with 885 additions and 1 deletions

View file

@ -21,6 +21,7 @@ import { HistoryManager } from './management/history';
import { FavoritesManager, SaveFavoriteModal } from './management/favorites'; import { FavoritesManager, SaveFavoriteModal } from './management/favorites';
import { TemplateManager, TemplatePickerModal } from './management/templates'; import { TemplateManager, TemplatePickerModal } from './management/templates';
import { SchemaView, SCHEMA_VIEW_TYPE } from './ui/schema-view'; import { SchemaView, SCHEMA_VIEW_TYPE } from './ui/schema-view';
import { ProjectionEngine, ProjectionPickerModal } from './projection/projection-engine';
export default class LogfirePlugin extends Plugin { export default class LogfirePlugin extends Plugin {
settings!: LogfireSettings; settings!: LogfireSettings;
@ -31,6 +32,7 @@ export default class LogfirePlugin extends Plugin {
historyManager!: HistoryManager; historyManager!: HistoryManager;
favoritesManager!: FavoritesManager; favoritesManager!: FavoritesManager;
templateManager!: TemplateManager; templateManager!: TemplateManager;
projectionEngine!: ProjectionEngine;
private fileCollector!: FileCollector; private fileCollector!: FileCollector;
private contentCollector!: ContentCollector; private contentCollector!: ContentCollector;
@ -164,6 +166,10 @@ export default class LogfirePlugin extends Plugin {
// Virtual Tables // Virtual Tables
this.virtualTables = new VirtualTableManager(this.app, this.db); this.virtualTables = new VirtualTableManager(this.app, this.db);
this.virtualTables.initialize(); this.virtualTables.initialize();
// Projection Engine
this.projectionEngine = new ProjectionEngine(this.app, this.db, this.eventBus, this.settings);
this.projectionEngine.start();
}); });
console.log('[Logfire] Plugin geladen. Session:', this.sessionManager.currentSessionId); console.log('[Logfire] Plugin geladen. Session:', this.sessionManager.currentSessionId);
@ -173,6 +179,7 @@ export default class LogfirePlugin extends Plugin {
console.log('[Logfire] Entlade Plugin...'); console.log('[Logfire] Entlade Plugin...');
cleanupAllRefreshTimers(); cleanupAllRefreshTimers();
this.projectionEngine?.destroy();
this.virtualTables?.destroy(); this.virtualTables?.destroy();
this.statusBar?.destroy(); this.statusBar?.destroy();
this.stopTracking(); this.stopTracking();
@ -382,6 +389,22 @@ export default class LogfirePlugin extends Plugin {
}); });
}, },
}); });
this.addCommand({
id: 'run-projection',
name: 'Projektion manuell ausführen',
callback: () => {
new ProjectionPickerModal(this.app, this.projectionEngine).open();
},
});
this.addCommand({
id: 'run-all-projections',
name: 'Alle Projektionen ausführen',
callback: () => {
this.projectionEngine.runAllProjections();
},
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -0,0 +1,180 @@
import { SectionConfig, BuiltinFormat } from '../types';
import { toMarkdownTable } from '../viz/table-renderer';
// ---------------------------------------------------------------------------
// Section → Markdown
// ---------------------------------------------------------------------------
export function formatSection(rows: Record<string, unknown>[], section: SectionConfig): string {
if (rows.length === 0) return `### ${section.heading}\n\n*Keine Daten.*\n`;
const heading = `### ${section.heading}\n\n`;
if (section.format.type === 'custom' && section.format.customTemplate) {
return heading + applyCustomTemplate(rows, section.format.customTemplate) + '\n';
}
const fmt = section.format.builtin;
if (!fmt) return heading + formatAsTable(rows) + '\n';
switch (fmt.name) {
case 'timeline':
return heading + formatAsTimeline(rows, fmt.showTimestamp, fmt.showPayload) + '\n';
case 'table':
return heading + formatAsTableWithColumns(rows, fmt.columns) + '\n';
case 'summary':
return heading + formatAsSummary(rows, fmt.metrics) + '\n';
case 'metric':
return heading + formatAsMetric(rows, fmt) + '\n';
case 'heatmap':
return heading + formatAsHeatmap(rows, fmt.labelField, fmt.valueField) + '\n';
default:
return heading + formatAsTable(rows) + '\n';
}
}
// ---------------------------------------------------------------------------
// Timeline
// ---------------------------------------------------------------------------
function formatAsTimeline(rows: Record<string, unknown>[], showTs: boolean, showPayload: boolean): string {
const lines: string[] = [];
for (const row of rows) {
const parts: string[] = [];
if (showTs && row.timestamp != null) {
const ts = typeof row.timestamp === 'number'
? new Date(row.timestamp).toLocaleTimeString()
: String(row.timestamp);
parts.push(`**${ts}**`);
}
parts.push(String(row.type ?? ''));
if (row.source) parts.push(`\`${row.source}\``);
if (showPayload && row.payload) {
const p = typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload);
if (p !== '{}') parts.push(`${p}`);
}
lines.push(`- ${parts.join(' ')}`);
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Table
// ---------------------------------------------------------------------------
function formatAsTable(rows: Record<string, unknown>[]): string {
const keys = Object.keys(rows[0]);
return toMarkdownTable(keys, rows);
}
function formatAsTableWithColumns(
rows: Record<string, unknown>[],
columns: { header: string; value: string; align?: string }[],
): string {
const headers = columns.map(c => c.header);
const mappedRows = rows.map(row => {
const mapped: Record<string, unknown> = {};
for (const col of columns) {
mapped[col.header] = row[col.value] ?? '';
}
return mapped;
});
return toMarkdownTable(headers, mappedRows);
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
function formatAsSummary(
rows: Record<string, unknown>[],
metrics: { label: string; aggregate: string; field?: string }[],
): string {
const lines: string[] = [];
for (const m of metrics) {
const values = rows.map(r => Number(r[m.field ?? 'count'] ?? 0));
let result: number;
switch (m.aggregate) {
case 'sum': result = values.reduce((a, b) => a + b, 0); break;
case 'avg': result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; break;
case 'min': result = values.length > 0 ? Math.min(...values) : 0; break;
case 'max': result = values.length > 0 ? Math.max(...values) : 0; break;
default: result = rows.length;
}
lines.push(`- **${m.label}**: ${result.toLocaleString()}`);
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Metric
// ---------------------------------------------------------------------------
function formatAsMetric(
rows: Record<string, unknown>[],
fmt: { aggregate: string; field?: string },
): string {
const values = rows.map(r => Number(r[fmt.field ?? Object.keys(r).pop()!] ?? 0));
let result: number;
switch (fmt.aggregate) {
case 'sum': result = values.reduce((a, b) => a + b, 0); break;
case 'avg': result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; break;
case 'min': result = values.length > 0 ? Math.min(...values) : 0; break;
case 'max': result = values.length > 0 ? Math.max(...values) : 0; break;
default: result = rows.length;
}
return `**${result.toLocaleString()}**`;
}
// ---------------------------------------------------------------------------
// Heatmap
// ---------------------------------------------------------------------------
function formatAsHeatmap(rows: Record<string, unknown>[], labelField: string, valueField: string): string {
const maxVal = Math.max(...rows.map(r => Number(r[valueField] ?? 0)), 1);
const lines: string[] = [];
for (const row of rows) {
const label = String(row[labelField] ?? '');
const val = Number(row[valueField] ?? 0);
const barLen = Math.round((val / maxVal) * 30);
const bar = '\u2588'.repeat(barLen);
lines.push(`\`${label.padEnd(12)}\` ${bar} ${val}`);
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Custom template
// ---------------------------------------------------------------------------
function applyCustomTemplate(rows: Record<string, unknown>[], template: string): string {
return template.replace(/\{\{rows\}\}/g, JSON.stringify(rows, null, 2));
}
// ---------------------------------------------------------------------------
// Frontmatter
// ---------------------------------------------------------------------------
export function buildFrontmatter(data: Record<string, string>): string {
if (Object.keys(data).length === 0) return '';
const lines = ['---'];
for (const [key, value] of Object.entries(data)) {
lines.push(`${key}: ${value}`);
}
lines.push('---', '');
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Date placeholders
// ---------------------------------------------------------------------------
export function resolvePlaceholders(pattern: string, context: { date?: Date; sessionId?: string }): string {
const d = context.date ?? new Date();
return pattern
.replace(/\{\{date\}\}/g, d.toISOString().substring(0, 10))
.replace(/\{\{year\}\}/g, String(d.getFullYear()))
.replace(/\{\{month\}\}/g, String(d.getMonth() + 1).padStart(2, '0'))
.replace(/\{\{day\}\}/g, String(d.getDate()).padStart(2, '0'))
.replace(/\{\{sessionId\}\}/g, context.sessionId ?? '');
}

View file

@ -0,0 +1,102 @@
import { ProjectionTemplate } from '../../types';
export const dailyLogTemplate: ProjectionTemplate = {
id: 'builtin:daily-log',
name: 'Tagesprotokoll',
description: 'Sessions, aktive Dateien, Events und Zeitleiste des Tages.',
enabled: false,
trigger: { type: 'manual' },
output: {
folder: '',
filePattern: '{{date}}-daily-log.md',
mode: 'overwrite',
frontmatter: {
type: 'logfire/daily-log',
date: '{{date}}',
},
},
sections: [
{
id: 'sessions',
heading: 'Sessions',
type: 'table',
query: {
timeRange: { type: 'relative', value: 'today' },
eventTypes: ['system:session-start', 'system:session-end'],
},
format: {
type: 'builtin',
builtin: {
name: 'table',
columns: [
{ header: 'Session', value: 'session' },
{ header: 'Typ', value: 'type' },
{ header: 'Zeit', value: 'timestamp' },
],
},
},
enabled: true,
},
{
id: 'active-files',
heading: 'Aktive Dateien',
type: 'table',
query: {
timeRange: { type: 'relative', value: 'today' },
groupBy: 'file',
orderBy: 'count',
orderDirection: 'desc',
limit: 20,
},
format: {
type: 'builtin',
builtin: {
name: 'table',
columns: [
{ header: 'Datei', value: 'group' },
{ header: 'Events', value: 'count' },
{ header: 'Woerter+', value: 'words_added' },
{ header: 'Woerter-', value: 'words_removed' },
],
},
},
enabled: true,
},
{
id: 'event-summary',
heading: 'Event-Uebersicht',
type: 'table',
query: {
timeRange: { type: 'relative', value: 'today' },
groupBy: 'type',
orderBy: 'count',
orderDirection: 'desc',
},
format: {
type: 'builtin',
builtin: {
name: 'table',
columns: [
{ header: 'Typ', value: 'group' },
{ header: 'Anzahl', value: 'count' },
],
},
},
enabled: true,
},
{
id: 'timeline',
heading: 'Zeitleiste',
type: 'timeline',
query: {
timeRange: { type: 'relative', value: 'today' },
limit: 100,
},
format: {
type: 'builtin',
builtin: { name: 'timeline', showTimestamp: true, showPayload: false },
},
enabled: true,
},
],
};

View file

@ -0,0 +1,85 @@
import { ProjectionTemplate } from '../../types';
export const sessionLogTemplate: ProjectionTemplate = {
id: 'builtin:session-log',
name: 'Session-Protokoll',
description: 'Einzelne Session: Dauer, bearbeitete Dateien, ausgefuehrte Befehle.',
enabled: false,
trigger: { type: 'on-session-end' },
output: {
folder: '',
filePattern: '{{date}}-session-{{sessionId}}.md',
mode: 'overwrite',
frontmatter: {
type: 'logfire/session-log',
date: '{{date}}',
session: '{{sessionId}}',
},
},
sections: [
{
id: 'overview',
heading: 'Session-Uebersicht',
type: 'summary',
query: {
timeRange: { type: 'session' },
},
format: {
type: 'builtin',
builtin: {
name: 'summary',
metrics: [
{ label: 'Events gesamt', aggregate: 'count' },
],
},
},
enabled: true,
},
{
id: 'files',
heading: 'Bearbeitete Dateien',
type: 'table',
query: {
timeRange: { type: 'session' },
groupBy: 'file',
orderBy: 'count',
orderDirection: 'desc',
limit: 20,
},
format: {
type: 'builtin',
builtin: {
name: 'table',
columns: [
{ header: 'Datei', value: 'group' },
{ header: 'Events', value: 'count' },
{ header: 'Woerter+', value: 'words_added' },
{ header: 'Woerter-', value: 'words_removed' },
],
},
},
enabled: true,
},
{
id: 'commands',
heading: 'Ausgefuehrte Befehle',
type: 'table',
query: {
timeRange: { type: 'session' },
eventTypes: ['plugin:command-executed'],
limit: 50,
},
format: {
type: 'builtin',
builtin: {
name: 'table',
columns: [
{ header: 'Zeit', value: 'timestamp' },
{ header: 'Befehl', value: 'source' },
],
},
},
enabled: true,
},
],
};

View file

@ -0,0 +1,105 @@
import { ProjectionTemplate } from '../../types';
export const weeklyDigestTemplate: ProjectionTemplate = {
id: 'builtin:weekly-digest',
name: 'Wochen-Digest',
description: 'Wochenuebersicht: Tages-Summary, Top-Dateien, Schreib-Statistiken, Heatmap.',
enabled: false,
trigger: { type: 'manual' },
output: {
folder: '',
filePattern: '{{date}}-weekly-digest.md',
mode: 'overwrite',
frontmatter: {
type: 'logfire/weekly-digest',
date: '{{date}}',
},
},
sections: [
{
id: 'daily-summary',
heading: 'Tages-Uebersicht',
type: 'table',
query: {
timeRange: { type: 'relative', value: 'last-7-days' },
groupBy: 'day',
orderBy: 'timestamp',
orderDirection: 'asc',
},
format: {
type: 'builtin',
builtin: {
name: 'table',
columns: [
{ header: 'Tag', value: 'group' },
{ header: 'Events', value: 'count' },
{ header: 'Woerter+', value: 'words_added' },
{ header: 'Woerter-', value: 'words_removed' },
],
},
},
enabled: true,
},
{
id: 'top-files',
heading: 'Top-Dateien',
type: 'table',
query: {
timeRange: { type: 'relative', value: 'last-7-days' },
groupBy: 'file',
orderBy: 'count',
orderDirection: 'desc',
limit: 15,
},
format: {
type: 'builtin',
builtin: {
name: 'table',
columns: [
{ header: 'Datei', value: 'group' },
{ header: 'Events', value: 'count' },
{ header: 'Woerter+', value: 'words_added' },
],
},
},
enabled: true,
},
{
id: 'write-stats',
heading: 'Schreib-Statistiken',
type: 'summary',
query: {
timeRange: { type: 'relative', value: 'last-7-days' },
groupBy: 'day',
},
format: {
type: 'builtin',
builtin: {
name: 'summary',
metrics: [
{ label: 'Tage aktiv', aggregate: 'count' },
{ label: 'Woerter geschrieben', aggregate: 'sum', field: 'words_added' },
{ label: 'Woerter geloescht', aggregate: 'sum', field: 'words_removed' },
],
},
},
enabled: true,
},
{
id: 'activity-heatmap',
heading: 'Aktivitaets-Heatmap',
type: 'chart-data',
query: {
timeRange: { type: 'relative', value: 'last-7-days' },
groupBy: 'hour',
orderBy: 'timestamp',
orderDirection: 'asc',
},
format: {
type: 'builtin',
builtin: { name: 'heatmap', labelField: 'group', valueField: 'count' },
},
enabled: true,
},
],
};

View file

@ -0,0 +1,238 @@
import { App, Modal, Notice } from 'obsidian';
import { ProjectionTemplate, LogfireSettings } from '../types';
import { DatabaseManager } from '../core/database';
import { EventBus } from '../core/event-bus';
import { buildQuery } from '../core/query-builder';
import { ProjectionTemplateRegistry } from './template-registry';
import { formatSection, buildFrontmatter, resolvePlaceholders } from './formatters';
export class ProjectionEngine {
private registry: ProjectionTemplateRegistry;
private schedulerTimer: ReturnType<typeof setInterval> | null = null;
private sessionEndUnsub: (() => void) | null = null;
private lastDailyRun = '';
constructor(
private app: App,
private db: DatabaseManager,
private eventBus: EventBus,
private settings: LogfireSettings,
) {
this.registry = new ProjectionTemplateRegistry(settings.projections.templates);
}
getRegistry(): ProjectionTemplateRegistry {
return this.registry;
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
start(): void {
if (!this.settings.projections.enabled) return;
// Session-End-Listener fuer session-log
if (this.settings.projections.sessionLog.enabled) {
this.sessionEndUnsub = this.eventBus.onEvent('system:session-end', (event) => {
const templates = this.registry.getByTrigger('on-session-end');
for (const t of templates) {
this.runProjection(t, { sessionId: event.session });
}
});
}
// Scheduler fuer daily + weekly (prueft alle 60s)
this.schedulerTimer = setInterval(() => this.checkSchedule(), 60_000);
}
destroy(): void {
if (this.schedulerTimer !== null) {
clearInterval(this.schedulerTimer);
this.schedulerTimer = null;
}
this.sessionEndUnsub?.();
this.sessionEndUnsub = null;
}
// ---------------------------------------------------------------------------
// Schedule check
// ---------------------------------------------------------------------------
private checkSchedule(): void {
const now = new Date();
const todayStr = now.toISOString().substring(0, 10);
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
// Daily Log
if (
this.settings.projections.dailyLog.enabled &&
currentTime === this.settings.projections.dailyLog.time &&
this.lastDailyRun !== todayStr
) {
this.lastDailyRun = todayStr;
const template = this.registry.getById('builtin:daily-log');
if (template) this.runProjection(template);
}
// Weekly Digest (dayOfWeek: 0=So, 1=Mo, ...)
if (
this.settings.projections.weeklyDigest.enabled &&
now.getDay() === this.settings.projections.weeklyDigest.dayOfWeek &&
currentTime === '08:00' &&
this.lastDailyRun !== `week-${todayStr}`
) {
this.lastDailyRun = `week-${todayStr}`;
const template = this.registry.getById('builtin:weekly-digest');
if (template) this.runProjection(template);
}
}
// ---------------------------------------------------------------------------
// Run projection
// ---------------------------------------------------------------------------
async runProjection(
template: ProjectionTemplate,
context?: { sessionId?: string },
): Promise<string | null> {
try {
const sections: string[] = [];
// Frontmatter
const fmData: Record<string, string> = {};
for (const [key, value] of Object.entries(template.output.frontmatter)) {
fmData[key] = resolvePlaceholders(value, { date: new Date(), sessionId: context?.sessionId });
}
sections.push(buildFrontmatter(fmData));
// Title
sections.push(`# ${template.name}\n`);
// Sections
for (const section of template.sections) {
if (!section.enabled) continue;
const queryConfig = { ...section.query };
// Session-Kontext einsetzen
if (queryConfig.timeRange.type === 'session' && context?.sessionId) {
queryConfig.timeRange = { ...queryConfig.timeRange, sessionId: context.sessionId };
}
const { sql, params } = buildQuery(queryConfig);
const rows = this.db.queryReadOnly(sql, params) as Record<string, unknown>[];
sections.push(formatSection(rows, section));
}
const content = sections.join('\n');
const outputFolder = template.output.folder || this.settings.projections.outputFolder;
const fileName = resolvePlaceholders(template.output.filePattern, {
date: new Date(),
sessionId: context?.sessionId,
});
const filePath = `${outputFolder}/${fileName}`;
// Ordner erstellen falls noetig
await this.ensureFolder(outputFolder);
// Datei schreiben
const existing = this.app.vault.getAbstractFileByPath(filePath);
if (existing) {
if (template.output.mode === 'append') {
const current = await this.app.vault.read(existing as any);
await this.app.vault.modify(existing as any, current + '\n' + content);
} else {
await this.app.vault.modify(existing as any, content);
}
} else {
await this.app.vault.create(filePath, content);
}
return filePath;
} catch (err) {
console.error('[Logfire] Projektion fehlgeschlagen:', err);
new Notice(`Logfire: Projektion "${template.name}" fehlgeschlagen.`);
return null;
}
}
async runAllProjections(): Promise<void> {
const templates = this.registry.getEnabled();
let count = 0;
for (const t of templates) {
if (t.trigger.type !== 'on-session-end') {
const result = await this.runProjection(t);
if (result) count++;
}
}
new Notice(`Logfire: ${count} Projektion(en) ausgefuehrt.`);
}
private async ensureFolder(path: string): Promise<void> {
const parts = path.split('/');
let current = '';
for (const part of parts) {
current = current ? `${current}/${part}` : part;
if (!this.app.vault.getAbstractFileByPath(current)) {
await this.app.vault.createFolder(current);
}
}
}
}
// ---------------------------------------------------------------------------
// Projection Picker Modal
// ---------------------------------------------------------------------------
export class ProjectionPickerModal extends Modal {
constructor(
app: App,
private engine: ProjectionEngine,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('logfire-projection-picker');
contentEl.createEl('h2', { text: 'Projektion ausfuehren' });
const list = contentEl.createDiv({ cls: 'logfire-template-list' });
const templates = this.engine.getRegistry().getAll();
for (const template of templates) {
const item = list.createDiv({ cls: 'logfire-template-item' });
const header = item.createDiv({ cls: 'logfire-template-header' });
header.createEl('span', { text: template.name, cls: 'logfire-template-name' });
if (template.id.startsWith('builtin:')) {
header.createEl('span', { text: 'BUILTIN', cls: 'logfire-template-badge' });
}
item.createEl('p', { text: template.description, cls: 'logfire-template-desc' });
const triggerInfo = item.createEl('p', {
text: `Trigger: ${template.trigger.type} | Output: ${template.output.filePattern}`,
cls: 'logfire-template-desc',
});
const actions = item.createDiv({ cls: 'logfire-template-actions' });
const runBtn = actions.createEl('button', { text: 'Ausfuehren', cls: 'mod-cta' });
runBtn.addEventListener('click', async () => {
const path = await this.engine.runProjection(template);
if (path) {
new Notice(`Projektion geschrieben: ${path}`);
}
this.close();
});
}
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,54 @@
import { ProjectionTemplate } from '../types';
import { dailyLogTemplate } from './presets/daily-log';
import { sessionLogTemplate } from './presets/session-log';
import { weeklyDigestTemplate } from './presets/weekly-digest';
const BUILTIN_TEMPLATES: ProjectionTemplate[] = [
dailyLogTemplate,
sessionLogTemplate,
weeklyDigestTemplate,
];
export class ProjectionTemplateRegistry {
private customTemplates: ProjectionTemplate[] = [];
constructor(customTemplates: ProjectionTemplate[] = []) {
this.customTemplates = customTemplates;
}
getAll(): ProjectionTemplate[] {
return [...BUILTIN_TEMPLATES, ...this.customTemplates];
}
getBuiltin(): ProjectionTemplate[] {
return BUILTIN_TEMPLATES;
}
getCustom(): ProjectionTemplate[] {
return this.customTemplates;
}
getById(id: string): ProjectionTemplate | undefined {
return this.getAll().find(t => t.id === id);
}
getEnabled(): ProjectionTemplate[] {
return this.getAll().filter(t => t.enabled);
}
getByTrigger(type: ProjectionTemplate['trigger']['type']): ProjectionTemplate[] {
return this.getEnabled().filter(t => t.trigger.type === type);
}
addCustom(template: ProjectionTemplate): void {
this.customTemplates.push(template);
}
removeCustom(id: string): void {
this.customTemplates = this.customTemplates.filter(t => t.id !== id);
}
updateCustomTemplates(templates: ProjectionTemplate[]): void {
this.customTemplates = templates;
}
}

View file

@ -180,7 +180,8 @@ export type BuiltinFormat =
| { name: 'timeline'; showTimestamp: boolean; showPayload: boolean } | { name: 'timeline'; showTimestamp: boolean; showPayload: boolean }
| { name: 'table'; columns: ColumnDef[] } | { name: 'table'; columns: ColumnDef[] }
| { name: 'summary'; metrics: MetricDef[] } | { name: 'summary'; metrics: MetricDef[] }
| { name: 'metric'; aggregate: 'count' | 'sum' | 'avg' | 'min' | 'max'; field?: string }; | { name: 'metric'; aggregate: 'count' | 'sum' | 'avg' | 'min' | 'max'; field?: string }
| { name: 'heatmap'; labelField: string; valueField: string };
export interface ColumnDef { export interface ColumnDef {
header: string; header: string;
@ -219,6 +220,11 @@ export interface LogfireSettings {
}; };
projections: { projections: {
enabled: boolean;
outputFolder: string;
dailyLog: { enabled: boolean; time: string };
sessionLog: { enabled: boolean };
weeklyDigest: { enabled: boolean; dayOfWeek: number };
templates: ProjectionTemplate[]; templates: ProjectionTemplate[];
}; };
@ -265,6 +271,11 @@ export const DEFAULT_SETTINGS: LogfireSettings = {
}, },
projections: { projections: {
enabled: false,
outputFolder: 'Logfire',
dailyLog: { enabled: false, time: '23:00' },
sessionLog: { enabled: false },
weeklyDigest: { enabled: false, dayOfWeek: 0 },
templates: [], templates: [],
}, },

View file

@ -125,6 +125,79 @@ export class LogfireSettingTab extends PluginSettingTab {
await this.plugin.saveSettings(); await this.plugin.saveSettings();
})); }));
// ----- Projections -----
containerEl.createEl('h2', { text: 'Projektionen' });
new Setting(containerEl)
.setName('Projektionen aktiviert')
.setDesc('Automatische Markdown-Reports aus Event-Daten.')
.addToggle(t => t
.setValue(this.plugin.settings.projections.enabled)
.onChange(async (v) => {
this.plugin.settings.projections.enabled = v;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Output-Ordner')
.setDesc('Ordner für generierte Projektions-Dateien.')
.addText(t => t
.setPlaceholder('Logfire')
.setValue(this.plugin.settings.projections.outputFolder)
.onChange(async (v) => {
this.plugin.settings.projections.outputFolder = v || 'Logfire';
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Tagesprotokoll')
.setDesc('Automatisches Tagesprotokoll generieren.')
.addToggle(t => t
.setValue(this.plugin.settings.projections.dailyLog.enabled)
.onChange(async (v) => {
this.plugin.settings.projections.dailyLog.enabled = v;
await this.plugin.saveSettings();
}))
.addText(t => t
.setPlaceholder('23:00')
.setValue(this.plugin.settings.projections.dailyLog.time)
.onChange(async (v) => {
if (/^\d{2}:\d{2}$/.test(v)) {
this.plugin.settings.projections.dailyLog.time = v;
await this.plugin.saveSettings();
}
}));
new Setting(containerEl)
.setName('Session-Protokoll')
.setDesc('Protokoll bei Session-Ende generieren.')
.addToggle(t => t
.setValue(this.plugin.settings.projections.sessionLog.enabled)
.onChange(async (v) => {
this.plugin.settings.projections.sessionLog.enabled = v;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Wochen-Digest')
.setDesc('Wöchentliche Zusammenfassung generieren.')
.addToggle(t => t
.setValue(this.plugin.settings.projections.weeklyDigest.enabled)
.onChange(async (v) => {
this.plugin.settings.projections.weeklyDigest.enabled = v;
await this.plugin.saveSettings();
}))
.addDropdown(d => d
.addOptions({
'0': 'Sonntag', '1': 'Montag', '2': 'Dienstag', '3': 'Mittwoch',
'4': 'Donnerstag', '5': 'Freitag', '6': 'Samstag',
})
.setValue(String(this.plugin.settings.projections.weeklyDigest.dayOfWeek))
.onChange(async (v) => {
this.plugin.settings.projections.weeklyDigest.dayOfWeek = parseInt(v, 10);
await this.plugin.saveSettings();
}));
// ----- Advanced (collapsible) ----- // ----- Advanced (collapsible) -----
const advancedHeader = containerEl.createEl('details'); const advancedHeader = containerEl.createEl('details');
advancedHeader.createEl('summary', { text: 'Erweiterte Einstellungen' }) advancedHeader.createEl('summary', { text: 'Erweiterte Einstellungen' })

View file

@ -1197,3 +1197,16 @@
max-height: 120px; max-height: 120px;
overflow: auto; overflow: auto;
} }
/*
Projection Picker Modal
*/
.logfire-projection-picker h2 {
font-family: var(--font-monospace);
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
margin-bottom: 10px;
color: var(--text-normal);
}