Code-Block-Prozessoren und Query-Modal

logfire-Block (YAML-Config), logfire-sql-Block (Raw SQL) mit
Auto-Refresh-Timern. Query-Modal mit Shorthand- und SQL-Modus,
Kopieren-als-Markdown und In-Notiz-einfuegen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-12 11:02:52 +01:00
parent b2fc5b8f6b
commit c90bbbf5e3
2 changed files with 493 additions and 0 deletions

268
src/query/processor.ts Normal file
View file

@ -0,0 +1,268 @@
import { MarkdownPostProcessorContext } from 'obsidian';
import { QueryConfig, TimeRange, EventType, EventCategory } from '../types';
import { buildQuery } from '../core/query-builder';
import { DatabaseManager } from '../core/database';
import { renderTable, renderTimeline, renderSummary, renderMetric, renderList, renderHeatmap, formatValue } from '../viz/table-renderer';
// ---------------------------------------------------------------------------
// Refresh timer management
// ---------------------------------------------------------------------------
const refreshTimers = new Map<HTMLElement, ReturnType<typeof setInterval>>();
function cleanupRefreshTimer(el: HTMLElement): void {
const timer = refreshTimers.get(el);
if (timer) {
clearInterval(timer);
refreshTimers.delete(el);
}
}
export function cleanupAllRefreshTimers(): void {
for (const timer of refreshTimers.values()) {
clearInterval(timer);
}
refreshTimers.clear();
}
// ---------------------------------------------------------------------------
// `logfire` block — YAML-config-basierte Queries
// ---------------------------------------------------------------------------
export function registerLogfireBlock(
db: DatabaseManager,
registerFn: (language: string, handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void) => void,
): void {
registerFn('logfire', (source, el, ctx) => {
cleanupRefreshTimer(el);
try {
const config = parseYamlConfig(source);
const { sql, params } = buildQuery(config.query);
const rows = db.queryReadOnly(sql, params) as Record<string, unknown>[];
renderResult(el, rows, config.format, config.columns);
if (config.refresh && config.refresh > 0) {
setupRefreshTimer(el, () => {
el.empty();
try {
const freshRows = db.queryReadOnly(sql, params) as Record<string, unknown>[];
renderResult(el, freshRows, config.format, config.columns);
} catch (err) {
renderError(el, err);
}
}, config.refresh);
}
} catch (err) {
renderError(el, err);
}
});
}
// ---------------------------------------------------------------------------
// `logfire-sql` block — Raw SQL Queries
// ---------------------------------------------------------------------------
export function registerLogfireSqlBlock(
db: DatabaseManager,
registerFn: (language: string, handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void) => void,
): void {
registerFn('logfire-sql', (source, el, ctx) => {
cleanupRefreshTimer(el);
const { sql, refresh } = parseSqlBlock(source);
if (!sql) {
renderError(el, new Error('Leere Query.'));
return;
}
// Safety: only SELECT/WITH
const firstWord = sql.split(/\s+/)[0].toUpperCase();
if (firstWord !== 'SELECT' && firstWord !== 'WITH') {
renderError(el, new Error('Nur SELECT- und WITH-Queries sind erlaubt.'));
return;
}
try {
const rows = db.queryReadOnly(sql) as Record<string, unknown>[];
if (!Array.isArray(rows) || rows.length === 0) {
el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' });
return;
}
renderTable(el, rows);
if (refresh && refresh > 0) {
setupRefreshTimer(el, () => {
el.empty();
try {
const freshRows = db.queryReadOnly(sql) as Record<string, unknown>[];
if (!Array.isArray(freshRows) || freshRows.length === 0) {
el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' });
return;
}
renderTable(el, freshRows);
} catch (err) {
renderError(el, err);
}
}, refresh);
}
} catch (err) {
renderError(el, err);
}
});
}
// ---------------------------------------------------------------------------
// YAML config parsing
// ---------------------------------------------------------------------------
interface BlockConfig {
query: QueryConfig;
format: 'table' | 'timeline' | 'summary' | 'metric' | 'list' | 'heatmap';
columns?: string[];
refresh?: number;
}
function parseYamlConfig(source: string): BlockConfig {
const lines = source.trim().split('\n');
const kv: Record<string, string> = {};
for (const line of lines) {
const colonIdx = line.indexOf(':');
if (colonIdx < 0) continue;
const key = line.substring(0, colonIdx).trim();
const value = line.substring(colonIdx + 1).trim();
kv[key] = value;
}
const rangeValue = kv['range'] ?? 'today';
const timeRange = parseTimeRange(rangeValue);
const eventTypes = kv['events']
? parseArrayValue(kv['events']) as EventType[]
: undefined;
const categories = kv['categories']
? parseArrayValue(kv['categories']) as EventCategory[]
: undefined;
const filePaths = kv['files']
? parseArrayValue(kv['files'])
: undefined;
const groupBy = kv['group'] as QueryConfig['groupBy'] | undefined;
const orderBy = kv['order'] as QueryConfig['orderBy'] | undefined;
const orderDirection = kv['direction'] as QueryConfig['orderDirection'] | undefined;
const limit = kv['limit'] ? parseInt(kv['limit'], 10) : undefined;
const format = (kv['format'] ?? 'table') as BlockConfig['format'];
const columns = kv['columns'] ? parseArrayValue(kv['columns']) : undefined;
const refresh = kv['refresh'] ? parseInt(kv['refresh'], 10) : undefined;
return {
query: {
timeRange,
eventTypes,
categories,
filePaths,
groupBy,
orderBy,
orderDirection,
limit,
},
format,
columns,
refresh,
};
}
function parseTimeRange(value: string): TimeRange {
const relative = ['today', 'yesterday', 'this-week', 'this-month', 'last-7-days', 'last-30-days'];
if (relative.includes(value)) {
return { type: 'relative', value: value as TimeRange & { type: 'relative' } extends { value: infer V } ? V : never };
}
return { type: 'relative', value: 'today' };
}
function parseArrayValue(value: string): string[] {
const cleaned = value.replace(/^\[/, '').replace(/\]$/, '');
return cleaned.split(',').map(s => s.trim()).filter(s => s.length > 0);
}
// ---------------------------------------------------------------------------
// SQL block parsing (with comment-based config)
// ---------------------------------------------------------------------------
function parseSqlBlock(source: string): { sql: string; refresh?: number } {
const lines = source.trim().split('\n');
let refresh: number | undefined;
const sqlLines: string[] = [];
for (const line of lines) {
const refreshMatch = line.match(/^--\s*refresh:\s*(\d+)$/i);
if (refreshMatch) {
refresh = parseInt(refreshMatch[1], 10);
} else {
sqlLines.push(line);
}
}
return { sql: sqlLines.join('\n').trim(), refresh };
}
// ---------------------------------------------------------------------------
// Render dispatch
// ---------------------------------------------------------------------------
function renderResult(el: HTMLElement, rows: Record<string, unknown>[], format: string, columns?: string[]): void {
if (rows.length === 0) {
el.createEl('p', { text: 'Keine Events gefunden.', cls: 'logfire-empty' });
return;
}
switch (format) {
case 'table':
renderTable(el, rows, columns);
break;
case 'timeline':
renderTimeline(el, rows);
break;
case 'summary':
renderSummary(el, rows);
break;
case 'metric':
renderMetric(el, rows);
break;
case 'list':
renderList(el, rows);
break;
case 'heatmap':
renderHeatmap(el, rows);
break;
default:
renderTable(el, rows, columns);
}
}
function renderError(el: HTMLElement, err: unknown): void {
el.createEl('pre', {
text: `Logfire Error: ${err instanceof Error ? err.message : String(err)}`,
cls: 'logfire-error',
});
}
// ---------------------------------------------------------------------------
// Auto-refresh
// ---------------------------------------------------------------------------
function setupRefreshTimer(el: HTMLElement, refreshFn: () => void, intervalSeconds: number): void {
const timer = setInterval(() => {
if (!document.contains(el)) {
cleanupRefreshTimer(el);
return;
}
refreshFn();
}, intervalSeconds * 1000);
refreshTimers.set(el, timer);
}

225
src/query/query-modal.ts Normal file
View file

@ -0,0 +1,225 @@
import { App, Modal, MarkdownView, Notice } from 'obsidian';
import { QueryConfig, TimeRange, EventType } from '../types';
import { buildQuery } from '../core/query-builder';
import { DatabaseManager } from '../core/database';
import { renderTable, toMarkdownTable } from '../viz/table-renderer';
export class QueryModal extends Modal {
private editorEl!: HTMLTextAreaElement;
private resultEl!: HTMLElement;
private modeToggle!: HTMLButtonElement;
private mode: 'shorthand' | 'sql' = 'shorthand';
constructor(app: App, private db: DatabaseManager) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('logfire-query-modal');
contentEl.createEl('h2', { text: 'Logfire Query' });
// Mode toggle
const toolbar = contentEl.createDiv({ cls: 'logfire-qm-toolbar' });
this.modeToggle = toolbar.createEl('button', { text: 'Modus: Shorthand' });
this.modeToggle.addEventListener('click', () => {
this.mode = this.mode === 'shorthand' ? 'sql' : 'shorthand';
this.modeToggle.textContent = this.mode === 'shorthand' ? 'Modus: Shorthand' : 'Modus: SQL';
this.editorEl.placeholder = this.mode === 'shorthand'
? 'events today\nstats this-week group by file\nfiles modified yesterday'
: 'SELECT * FROM events\nWHERE type = \'file:create\'\nORDER BY timestamp DESC\nLIMIT 50';
});
const helpSpan = toolbar.createEl('span', {
text: 'Ctrl+Enter: Ausf\u00fchren',
cls: 'logfire-qm-hint',
});
// Editor
this.editorEl = contentEl.createEl('textarea', {
cls: 'logfire-qm-editor',
attr: {
placeholder: 'events today\nstats this-week group by file\nfiles modified yesterday',
spellcheck: 'false',
},
});
this.editorEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.executeQuery();
}
});
// Buttons
const buttonRow = contentEl.createDiv({ cls: 'logfire-qm-buttons' });
const runBtn = buttonRow.createEl('button', { text: 'Ausf\u00fchren', cls: 'mod-cta' });
runBtn.addEventListener('click', () => this.executeQuery());
const copyBtn = buttonRow.createEl('button', { text: 'Als Markdown kopieren' });
copyBtn.addEventListener('click', () => this.copyAsMarkdown());
const insertBtn = buttonRow.createEl('button', { text: 'In Notiz einf\u00fcgen' });
insertBtn.addEventListener('click', () => this.insertInNote());
const clearBtn = buttonRow.createEl('button', { text: 'Leeren' });
clearBtn.addEventListener('click', () => {
this.editorEl.value = '';
this.resultEl.empty();
this.lastRows = [];
this.lastKeys = [];
});
// Results
this.resultEl = contentEl.createDiv({ cls: 'logfire-qm-results' });
}
onClose(): void {
this.contentEl.empty();
}
private lastRows: Record<string, unknown>[] = [];
private lastKeys: string[] = [];
private executeQuery(): void {
const input = this.editorEl.value.trim();
if (!input) return;
this.resultEl.empty();
try {
let rows: Record<string, unknown>[];
if (this.mode === 'sql') {
// Raw SQL mode
const firstWord = input.split(/\s+/)[0].toUpperCase();
if (firstWord !== 'SELECT' && firstWord !== 'WITH') {
this.resultEl.createEl('pre', {
text: 'Nur SELECT- und WITH-Queries sind erlaubt.',
cls: 'logfire-error',
});
return;
}
rows = this.db.queryReadOnly(input) as Record<string, unknown>[];
} else {
// Shorthand mode
const config = parseShorthand(input);
const { sql, params } = buildQuery(config);
rows = this.db.queryReadOnly(sql, params) as Record<string, unknown>[];
}
this.lastRows = rows;
this.lastKeys = rows.length > 0 ? Object.keys(rows[0]) : [];
this.renderResults(rows);
} catch (err) {
this.resultEl.createEl('pre', {
text: `Fehler: ${err instanceof Error ? err.message : String(err)}`,
cls: 'logfire-error',
});
}
}
private renderResults(rows: Record<string, unknown>[]): void {
this.resultEl.empty();
if (rows.length === 0) {
this.resultEl.createEl('p', { text: 'Keine Ergebnisse.' });
return;
}
const displayRows = rows.slice(0, 200);
renderTable(this.resultEl, displayRows);
if (rows.length > 200) {
this.resultEl.createEl('p', {
text: `${rows.length} Ergebnisse, 200 angezeigt.`,
cls: 'logfire-qm-truncated',
});
}
}
private copyAsMarkdown(): void {
if (this.lastRows.length === 0) {
new Notice('Keine Ergebnisse zum Kopieren.');
return;
}
const md = toMarkdownTable(this.lastKeys, this.lastRows);
navigator.clipboard.writeText(md);
new Notice('In Zwischenablage kopiert.');
}
private insertInNote(): void {
if (this.lastRows.length === 0) {
new Notice('Keine Ergebnisse zum Einf\u00fcgen.');
return;
}
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
new Notice('Kein aktiver Editor zum Einf\u00fcgen.');
return;
}
const md = toMarkdownTable(this.lastKeys, this.lastRows);
view.editor.replaceSelection(md + '\n');
this.close();
}
}
// ---------------------------------------------------------------------------
// Shorthand parser
// ---------------------------------------------------------------------------
function parseShorthand(input: string): QueryConfig {
const lower = input.toLowerCase().trim();
let timeRange: TimeRange = { type: 'relative', value: 'today' };
let eventTypes: EventType[] | undefined;
let groupBy: QueryConfig['groupBy'];
let limit = 50;
const timeRanges: Record<string, TimeRange> = {
'today': { type: 'relative', value: 'today' },
'yesterday': { type: 'relative', value: 'yesterday' },
'this-week': { type: 'relative', value: 'this-week' },
'this week': { type: 'relative', value: 'this-week' },
'this-month': { type: 'relative', value: 'this-month' },
'this month': { type: 'relative', value: 'this-month' },
'last-7-days': { type: 'relative', value: 'last-7-days' },
'last 7 days': { type: 'relative', value: 'last-7-days' },
'last-30-days': { type: 'relative', value: 'last-30-days' },
'last 30 days': { type: 'relative', value: 'last-30-days' },
};
for (const [key, range] of Object.entries(timeRanges)) {
if (lower.includes(key)) {
timeRange = range;
break;
}
}
const groupMatch = lower.match(/group\s+by\s+(\w+)/);
if (groupMatch) {
groupBy = groupMatch[1] as QueryConfig['groupBy'];
}
const limitMatch = lower.match(/limit\s+(\d+)/);
if (limitMatch) {
limit = parseInt(limitMatch[1], 10);
}
if (lower.includes('stats')) {
groupBy = groupBy ?? 'day';
}
if (lower.includes('files modified')) {
eventTypes = ['file:modify'];
}
if (lower.includes('files created')) {
eventTypes = ['file:create'];
}
return { timeRange, eventTypes, groupBy, limit };
}