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:
parent
c30320a7db
commit
ee599f42e7
10 changed files with 885 additions and 1 deletions
23
src/main.ts
23
src/main.ts
|
|
@ -21,6 +21,7 @@ import { HistoryManager } from './management/history';
|
|||
import { FavoritesManager, SaveFavoriteModal } from './management/favorites';
|
||||
import { TemplateManager, TemplatePickerModal } from './management/templates';
|
||||
import { SchemaView, SCHEMA_VIEW_TYPE } from './ui/schema-view';
|
||||
import { ProjectionEngine, ProjectionPickerModal } from './projection/projection-engine';
|
||||
|
||||
export default class LogfirePlugin extends Plugin {
|
||||
settings!: LogfireSettings;
|
||||
|
|
@ -31,6 +32,7 @@ export default class LogfirePlugin extends Plugin {
|
|||
historyManager!: HistoryManager;
|
||||
favoritesManager!: FavoritesManager;
|
||||
templateManager!: TemplateManager;
|
||||
projectionEngine!: ProjectionEngine;
|
||||
|
||||
private fileCollector!: FileCollector;
|
||||
private contentCollector!: ContentCollector;
|
||||
|
|
@ -164,6 +166,10 @@ export default class LogfirePlugin extends Plugin {
|
|||
// Virtual Tables
|
||||
this.virtualTables = new VirtualTableManager(this.app, this.db);
|
||||
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);
|
||||
|
|
@ -173,6 +179,7 @@ export default class LogfirePlugin extends Plugin {
|
|||
console.log('[Logfire] Entlade Plugin...');
|
||||
|
||||
cleanupAllRefreshTimers();
|
||||
this.projectionEngine?.destroy();
|
||||
this.virtualTables?.destroy();
|
||||
this.statusBar?.destroy();
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
180
src/projection/formatters.ts
Normal file
180
src/projection/formatters.ts
Normal 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 ?? '');
|
||||
}
|
||||
102
src/projection/presets/daily-log.ts
Normal file
102
src/projection/presets/daily-log.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
85
src/projection/presets/session-log.ts
Normal file
85
src/projection/presets/session-log.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
105
src/projection/presets/weekly-digest.ts
Normal file
105
src/projection/presets/weekly-digest.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
238
src/projection/projection-engine.ts
Normal file
238
src/projection/projection-engine.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
54
src/projection/template-registry.ts
Normal file
54
src/projection/template-registry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/types.ts
13
src/types.ts
|
|
@ -180,7 +180,8 @@ export type BuiltinFormat =
|
|||
| { name: 'timeline'; showTimestamp: boolean; showPayload: boolean }
|
||||
| { name: 'table'; columns: ColumnDef[] }
|
||||
| { 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 {
|
||||
header: string;
|
||||
|
|
@ -219,6 +220,11 @@ export interface LogfireSettings {
|
|||
};
|
||||
|
||||
projections: {
|
||||
enabled: boolean;
|
||||
outputFolder: string;
|
||||
dailyLog: { enabled: boolean; time: string };
|
||||
sessionLog: { enabled: boolean };
|
||||
weeklyDigest: { enabled: boolean; dayOfWeek: number };
|
||||
templates: ProjectionTemplate[];
|
||||
};
|
||||
|
||||
|
|
@ -265,6 +271,11 @@ export const DEFAULT_SETTINGS: LogfireSettings = {
|
|||
},
|
||||
|
||||
projections: {
|
||||
enabled: false,
|
||||
outputFolder: 'Logfire',
|
||||
dailyLog: { enabled: false, time: '23:00' },
|
||||
sessionLog: { enabled: false },
|
||||
weeklyDigest: { enabled: false, dayOfWeek: 0 },
|
||||
templates: [],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,79 @@ export class LogfireSettingTab extends PluginSettingTab {
|
|||
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) -----
|
||||
const advancedHeader = containerEl.createEl('details');
|
||||
advancedHeader.createEl('summary', { text: 'Erweiterte Einstellungen' })
|
||||
|
|
|
|||
13
styles.css
13
styles.css
|
|
@ -1197,3 +1197,16 @@
|
|||
max-height: 120px;
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue