Translates all German user-facing strings (command names, notices, settings, modal labels, template names/descriptions, error messages, status bar, and code comments) to English. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
238 lines
7.9 KiB
TypeScript
238 lines
7.9 KiB
TypeScript
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 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<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 };
|
|
|
|
// 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<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}`;
|
|
|
|
// 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<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} projection(s) executed.`);
|
|
}
|
|
|
|
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: '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();
|
|
}
|
|
}
|