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 | 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 for 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 for daily + weekly (checks every 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=Sun, 1=Mon, ...) 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 { try { const sections: string[] = []; // Frontmatter const fmData: Record = {}; 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 }; // Inject session context 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[]; 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}`; // Create folder if needed await this.ensureFolder(outputFolder); // Write file 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] Projection failed:', err); new Notice(`Logfire: Projection "${template.name}" failed.`); return null; } } async runAllProjections(): Promise { 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} projection(s) executed.`); } private async ensureFolder(path: string): Promise { 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: 'Run Projection' }); 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: 'Execute', cls: 'mod-cta' }); runBtn.addEventListener('click', async () => { const path = await this.engine.runProjection(template); if (path) { new Notice(`Projection written: ${path}`); } this.close(); }); } } onClose(): void { this.contentEl.empty(); } }