From c90bbbf5e3767ad0a187385fa491382f1e4bd70f Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:02:52 +0100 Subject: [PATCH] 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 --- src/query/processor.ts | 268 +++++++++++++++++++++++++++++++++++++++ src/query/query-modal.ts | 225 ++++++++++++++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 src/query/processor.ts create mode 100644 src/query/query-modal.ts diff --git a/src/query/processor.ts b/src/query/processor.ts new file mode 100644 index 0000000..2395931 --- /dev/null +++ b/src/query/processor.ts @@ -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>(); + +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[]; + 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[]; + 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[]; + 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[]; + 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 = {}; + + 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[], 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); +} diff --git a/src/query/query-modal.ts b/src/query/query-modal.ts new file mode 100644 index 0000000..e399a5e --- /dev/null +++ b/src/query/query-modal.ts @@ -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[] = []; + private lastKeys: string[] = []; + + private executeQuery(): void { + const input = this.editorEl.value.trim(); + if (!input) return; + + this.resultEl.empty(); + + try { + let rows: Record[]; + + 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[]; + } else { + // Shorthand mode + const config = parseShorthand(input); + const { sql, params } = buildQuery(config); + rows = this.db.queryReadOnly(sql, params) as Record[]; + } + + 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[]): 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 = { + '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 }; +}