From 00446c422705b76a73b56d5a37c3148a7fb57486 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:02:40 +0100 Subject: [PATCH 1/4] Query-Builder: QueryConfig zu parametrisiertem SQL Konvertiert strukturierte QueryConfig-Objekte in parametrisierte SQL-Queries mit Zeitbereich-Aufloesung, Event-/Kategorie-Filtern, Glob-to-LIKE-Pfadfiltern und GROUP BY (file/type/hour/day/session). Co-Authored-By: Claude Opus 4.6 --- src/core/query-builder.ts | 168 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/core/query-builder.ts diff --git a/src/core/query-builder.ts b/src/core/query-builder.ts new file mode 100644 index 0000000..94e6f3b --- /dev/null +++ b/src/core/query-builder.ts @@ -0,0 +1,168 @@ +import { QueryConfig, TimeRange } from '../types'; + +export interface BuiltQuery { + sql: string; + params: unknown[]; +} + +export function buildQuery(config: QueryConfig): BuiltQuery { + const conditions: string[] = []; + const params: unknown[] = []; + + // Time range + const { from, to } = resolveTimeRange(config.timeRange); + conditions.push('timestamp >= ?'); + params.push(from); + conditions.push('timestamp <= ?'); + params.push(to); + + // Event types + if (config.eventTypes && config.eventTypes.length > 0) { + const placeholders = config.eventTypes.map(() => '?').join(', '); + conditions.push(`type IN (${placeholders})`); + params.push(...config.eventTypes); + } + + // Categories + if (config.categories && config.categories.length > 0) { + const placeholders = config.categories.map(() => '?').join(', '); + conditions.push(`category IN (${placeholders})`); + params.push(...config.categories); + } + + // File paths (glob patterns -> SQL LIKE) + if (config.filePaths && config.filePaths.length > 0) { + const likeConditions = config.filePaths.map(pattern => { + params.push(globToLike(pattern)); + return 'source LIKE ?'; + }); + conditions.push(`(${likeConditions.join(' OR ')})`); + } + + // Session filter + if (config.timeRange.type === 'session' && config.timeRange.sessionId) { + conditions.push('session = ?'); + params.push(config.timeRange.sessionId); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // GROUP BY + if (config.groupBy) { + return buildGroupedQuery(config, where, params); + } + + // Simple query + const orderCol = config.orderBy === 'count' ? 'timestamp' : (config.orderBy ?? 'timestamp'); + const orderDir = config.orderDirection ?? 'desc'; + const limit = config.limit ? `LIMIT ${config.limit}` : ''; + + const sql = `SELECT * FROM events ${where} ORDER BY ${orderCol} ${orderDir} ${limit}`; + return { sql, params }; +} + +function buildGroupedQuery(config: QueryConfig, where: string, params: unknown[]): BuiltQuery { + const orderDir = config.orderDirection ?? 'desc'; + const limit = config.limit ? `LIMIT ${config.limit}` : ''; + + let groupExpr: string; + let selectExpr: string; + let orderExpr: string; + + switch (config.groupBy) { + case 'file': + groupExpr = 'source'; + selectExpr = `source as "group", COUNT(*) as count, + SUM(CASE WHEN json_extract(payload, '$.wordsAdded') IS NOT NULL THEN json_extract(payload, '$.wordsAdded') ELSE 0 END) as words_added, + SUM(CASE WHEN json_extract(payload, '$.wordsRemoved') IS NOT NULL THEN json_extract(payload, '$.wordsRemoved') ELSE 0 END) as words_removed`; + break; + case 'type': + groupExpr = 'type'; + selectExpr = `type as "group", COUNT(*) as count`; + break; + case 'hour': + groupExpr = "strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime')"; + selectExpr = `${groupExpr} as "group", COUNT(*) as count`; + break; + case 'day': + groupExpr = "strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime')"; + selectExpr = `${groupExpr} as "group", COUNT(*) as count, + SUM(CASE WHEN json_extract(payload, '$.wordsAdded') IS NOT NULL THEN json_extract(payload, '$.wordsAdded') ELSE 0 END) as words_added, + SUM(CASE WHEN json_extract(payload, '$.wordsRemoved') IS NOT NULL THEN json_extract(payload, '$.wordsRemoved') ELSE 0 END) as words_removed`; + break; + case 'session': + groupExpr = 'session'; + selectExpr = `session as "group", COUNT(*) as count, MIN(timestamp) as first_event, MAX(timestamp) as last_event`; + break; + default: + groupExpr = 'type'; + selectExpr = `type as "group", COUNT(*) as count`; + } + + switch (config.orderBy) { + case 'count': + orderExpr = `count ${orderDir}`; + break; + case 'words': + orderExpr = `words_added ${orderDir}`; + break; + default: + orderExpr = `"group" ${orderDir}`; + } + + const sql = `SELECT ${selectExpr} FROM events ${where} GROUP BY ${groupExpr} ORDER BY ${orderExpr} ${limit}`; + return { sql, params }; +} + +// --------------------------------------------------------------------------- +// Time range resolution +// --------------------------------------------------------------------------- + +function resolveTimeRange(range: TimeRange): { from: number; to: number } { + if (range.type === 'absolute') { + return { from: range.from, to: range.to }; + } + + if (range.type === 'session') { + return { from: 0, to: Date.now() }; + } + + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const msPerDay = 86400000; + + switch (range.value) { + case 'today': + return { from: startOfToday, to: Date.now() }; + case 'yesterday': + return { from: startOfToday - msPerDay, to: startOfToday - 1 }; + case 'this-week': { + const dayOfWeek = now.getDay() || 7; + const startOfWeek = startOfToday - (dayOfWeek - 1) * msPerDay; + return { from: startOfWeek, to: Date.now() }; + } + case 'this-month': { + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); + return { from: startOfMonth, to: Date.now() }; + } + case 'last-7-days': + return { from: startOfToday - 7 * msPerDay, to: Date.now() }; + case 'last-30-days': + return { from: startOfToday - 30 * msPerDay, to: Date.now() }; + default: + return { from: startOfToday, to: Date.now() }; + } +} + +// --------------------------------------------------------------------------- +// Glob to SQL LIKE +// --------------------------------------------------------------------------- + +function globToLike(pattern: string): string { + return pattern + .replace(/%/g, '\\%') + .replace(/_/g, '\\_') + .replace(/\*\*/g, '%') + .replace(/\*/g, '%') + .replace(/\?/g, '_'); +} From b2fc5b8f6be60076e1b1f25b183648a1e2cbc512 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:02:45 +0100 Subject: [PATCH 2/4] Table-Renderer: Render-Funktionen fuer Query-Ergebnisse Tabelle, Timeline, Summary, Metric, Liste, Heatmap sowie Markdown-Export und Wertformatierung (Timestamps, Zahlen). Co-Authored-By: Claude Opus 4.6 --- src/viz/table-renderer.ts | 125 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/viz/table-renderer.ts diff --git a/src/viz/table-renderer.ts b/src/viz/table-renderer.ts new file mode 100644 index 0000000..fbd9676 --- /dev/null +++ b/src/viz/table-renderer.ts @@ -0,0 +1,125 @@ +// --------------------------------------------------------------------------- +// Table +// --------------------------------------------------------------------------- + +export function renderTable(el: HTMLElement, rows: Record[], columns?: string[]): void { + const table = el.createEl('table', { cls: 'logfire-table' }); + const allKeys = columns ?? Object.keys(rows[0]); + + const thead = table.createEl('thead'); + const headerRow = thead.createEl('tr'); + for (const key of allKeys) { + headerRow.createEl('th', { text: key }); + } + + const tbody = table.createEl('tbody'); + for (const row of rows) { + const tr = tbody.createEl('tr'); + for (const key of allKeys) { + tr.createEl('td', { text: formatValue(row[key]) }); + } + } +} + +// --------------------------------------------------------------------------- +// Timeline +// --------------------------------------------------------------------------- + +export function renderTimeline(el: HTMLElement, rows: Record[]): void { + const list = el.createEl('ul', { cls: 'logfire-timeline' }); + for (const row of rows) { + const ts = typeof row.timestamp === 'number' + ? new Date(row.timestamp).toLocaleTimeString() + : ''; + const type = String(row.type ?? ''); + const source = String(row.source ?? ''); + list.createEl('li', { text: `${ts} ${type} ${source}` }); + } +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +export function renderSummary(el: HTMLElement, rows: Record[]): void { + const container = el.createDiv({ cls: 'logfire-summary' }); + for (const row of rows) { + for (const [key, value] of Object.entries(row)) { + const line = container.createDiv(); + line.createEl('strong', { text: `${key}: ` }); + line.appendText(formatValue(value)); + } + } +} + +// --------------------------------------------------------------------------- +// Metric (single big number) +// --------------------------------------------------------------------------- + +export function renderMetric(el: HTMLElement, rows: Record[]): void { + const record = rows[0]; + const values = Object.values(record); + const value = values.length > 0 ? values[values.length - 1] : 0; + const div = el.createEl('div', { + text: formatValue(value), + cls: 'logfire-metric', + }); + div.style.fontSize = '2em'; +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +export function renderList(el: HTMLElement, rows: Record[]): void { + const list = el.createEl('ul', { cls: 'logfire-list' }); + for (const row of rows) { + const text = Object.values(row).map(formatValue).join(' | '); + list.createEl('li', { text }); + } +} + +// --------------------------------------------------------------------------- +// Heatmap (text-based bar chart) +// --------------------------------------------------------------------------- + +export function renderHeatmap(el: HTMLElement, rows: Record[]): void { + const container = el.createDiv({ cls: 'logfire-heatmap' }); + + for (const row of rows) { + const group = String(row.group ?? ''); + const count = Number(row.count ?? 0); + const bar = '\u2588'.repeat(Math.min(count, 50)); + container.createDiv({ text: `${group} ${bar} ${count}` }); + } +} + +// --------------------------------------------------------------------------- +// Markdown table generation (for copy/export) +// --------------------------------------------------------------------------- + +export function toMarkdownTable(keys: string[], rows: Record[]): string { + const header = `| ${keys.join(' | ')} |`; + const separator = `| ${keys.map(() => '---').join(' | ')} |`; + const body = rows.map(row => + `| ${keys.map(k => { + const v = row[k]; + return v === null || v === undefined ? '' : String(v); + }).join(' | ')} |` + ).join('\n'); + + return `${header}\n${separator}\n${body}`; +} + +// --------------------------------------------------------------------------- +// Value formatting +// --------------------------------------------------------------------------- + +export function formatValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'number') { + if (value > 1e12) return new Date(value).toLocaleString(); + return value.toLocaleString(); + } + return String(value); +} From c90bbbf5e3767ad0a187385fa491382f1e4bd70f Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:02:52 +0100 Subject: [PATCH 3/4] 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 }; +} From c050807ad0df8f66a80481dea6c9101aec382144 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:03:00 +0100 Subject: [PATCH 4/4] SQL-Engine in main.ts verdrahtet, Query-Styles ergaenzt Code-Block-Prozessoren registriert, Query-Kommando hinzugefuegt, Refresh-Timer-Cleanup in onunload. Styles fuer Query-Modal, Datentabellen, Timeline, Summary, Metric, Heatmap, Error-States. Co-Authored-By: Claude Opus 4.6 --- src/main.ts | 19 +++ styles.css | 365 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) diff --git a/src/main.ts b/src/main.ts index ad32f96..ac85ed9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,8 @@ import { LogfireSettingTab } from './ui/settings-tab'; import { InitialScanModal } from './ui/initial-scan-modal'; import { StatusBar } from './ui/status-bar'; import { EventStreamView, EVENT_STREAM_VIEW_TYPE } from './ui/event-stream-view'; +import { registerLogfireBlock, registerLogfireSqlBlock, cleanupAllRefreshTimers } from './query/processor'; +import { QueryModal } from './query/query-modal'; export default class LogfirePlugin extends Plugin { settings!: LogfireSettings; @@ -90,6 +92,14 @@ export default class LogfirePlugin extends Plugin { this.statusBar = new StatusBar(this); this.statusBar.start(); + // Query: Code-Block-Prozessoren + registerLogfireBlock(this.db, (lang, handler) => { + this.registerMarkdownCodeBlockProcessor(lang, handler); + }); + registerLogfireSqlBlock(this.db, (lang, handler) => { + this.registerMarkdownCodeBlockProcessor(lang, handler); + }); + // Ribbon icon this.addRibbonIcon('activity', 'Logfire: Event-Stream', () => { this.activateEventStream(); @@ -125,6 +135,7 @@ export default class LogfirePlugin extends Plugin { async onunload(): Promise { console.log('[Logfire] Entlade Plugin...'); + cleanupAllRefreshTimers(); this.statusBar?.destroy(); this.stopTracking(); @@ -271,6 +282,14 @@ export default class LogfirePlugin extends Plugin { }, }); + this.addCommand({ + id: 'open-query', + name: 'Query-Editor \u00f6ffnen', + callback: () => { + new QueryModal(this.app, this.db).open(); + }, + }); + this.addCommand({ id: 'debug-info', name: 'Debug-Info', diff --git a/styles.css b/styles.css index 18f9042..6c23a8a 100644 --- a/styles.css +++ b/styles.css @@ -314,3 +314,368 @@ font-size: 12px; letter-spacing: 0.02em; } + +/* ═══════════════════════════════════════════════════════════════════════════ + Query Modal — SQL Console + ═══════════════════════════════════════════════════════════════════════════ */ + +.logfire-query-modal { + font-family: var(--font-monospace); +} + +.logfire-query-modal h2 { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.01em; + margin-bottom: 10px; + color: var(--text-normal); +} + +/* --------------------------------------------------------------------------- + Query Modal — Toolbar + --------------------------------------------------------------------------- */ + +.logfire-qm-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.logfire-qm-toolbar button { + padding: 3px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-muted); + font-family: var(--font-monospace); + font-size: 11px; + letter-spacing: 0.02em; + text-transform: uppercase; + cursor: pointer; + transition: background 100ms ease, color 100ms ease, border-color 100ms ease; +} + +.logfire-qm-toolbar button:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); + border-color: var(--text-faint); +} + +.logfire-qm-hint { + color: var(--text-faint); + font-size: 10px; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +/* --------------------------------------------------------------------------- + Query Modal — Editor + --------------------------------------------------------------------------- */ + +.logfire-qm-editor { + display: block; + width: 100%; + min-height: 100px; + max-height: 200px; + padding: 8px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-normal); + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.5; + resize: vertical; + outline: none; + tab-size: 2; + transition: border-color 120ms ease; +} + +.logfire-qm-editor:focus { + border-color: var(--interactive-accent); +} + +.logfire-qm-editor::placeholder { + color: var(--text-faint); + font-style: italic; +} + +/* --------------------------------------------------------------------------- + Query Modal — Buttons + --------------------------------------------------------------------------- */ + +.logfire-qm-buttons { + display: flex; + gap: 6px; + margin-top: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.logfire-qm-buttons button { + padding: 4px 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-muted); + font-family: var(--font-monospace); + font-size: 11px; + letter-spacing: 0.02em; + text-transform: uppercase; + cursor: pointer; + transition: background 100ms ease, color 100ms ease, border-color 100ms ease; +} + +.logfire-qm-buttons button:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); + border-color: var(--text-faint); +} + +.logfire-qm-buttons button.mod-cta { + background: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} + +.logfire-qm-buttons button.mod-cta:hover { + filter: brightness(1.1); +} + +/* --------------------------------------------------------------------------- + Query Modal — Results + --------------------------------------------------------------------------- */ + +.logfire-qm-results { + max-height: 360px; + overflow: auto; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); +} + +.logfire-qm-results:empty { + display: none; +} + +.logfire-qm-results::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.logfire-qm-results::-webkit-scrollbar-track { + background: transparent; +} + +.logfire-qm-results::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 3px; +} + +.logfire-qm-results::-webkit-scrollbar-thumb:hover { + background: var(--text-faint); +} + +.logfire-qm-truncated { + padding: 4px 10px; + font-size: 10px; + color: var(--text-faint); + letter-spacing: 0.02em; + text-transform: uppercase; + border-top: 1px solid var(--background-modifier-border); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Data Table — Inline Query Results + ═══════════════════════════════════════════════════════════════════════════ */ + +.logfire-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-monospace); + font-size: 11.5px; + line-height: 1.4; + font-variant-numeric: tabular-nums; +} + +.logfire-table thead th { + padding: 5px 10px; + text-align: left; + font-weight: 600; + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + background: var(--background-secondary); + border-bottom: 2px solid var(--background-modifier-border); + position: sticky; + top: 0; + z-index: 1; + white-space: nowrap; +} + +.logfire-table tbody td { + padding: 3px 10px; + color: var(--text-normal); + border-bottom: 1px solid color-mix(in srgb, var(--background-modifier-border) 50%, transparent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; +} + +.logfire-table tbody tr:nth-child(even) { + background: color-mix(in srgb, var(--background-secondary) 30%, transparent); +} + +.logfire-table tbody tr:hover { + background: var(--background-secondary); +} + +.logfire-table tbody tr:hover td { + border-bottom-color: var(--background-modifier-border); +} + +/* --------------------------------------------------------------------------- + Timeline + --------------------------------------------------------------------------- */ + +.logfire-timeline { + list-style: none; + padding: 0; + margin: 0; + font-family: var(--font-monospace); + font-size: 11.5px; + line-height: 1.5; +} + +.logfire-timeline li { + padding: 2px 10px; + color: var(--text-normal); + border-left: 2px solid var(--background-modifier-border); + margin-left: 6px; + transition: border-color 80ms ease; +} + +.logfire-timeline li:hover { + border-left-color: var(--interactive-accent); + background: color-mix(in srgb, var(--background-secondary) 40%, transparent); +} + +/* --------------------------------------------------------------------------- + Summary — Key-Value Readout + --------------------------------------------------------------------------- */ + +.logfire-summary { + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.6; + padding: 6px 0; +} + +.logfire-summary > div { + padding: 2px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--background-modifier-border) 40%, transparent); +} + +.logfire-summary > div:last-child { + border-bottom: none; +} + +.logfire-summary strong { + color: var(--text-muted); + font-weight: 600; + letter-spacing: 0.01em; +} + +/* --------------------------------------------------------------------------- + Metric — Single Value Readout + --------------------------------------------------------------------------- */ + +.logfire-metric { + font-family: var(--font-monospace); + font-variant-numeric: tabular-nums; + color: var(--text-accent); + font-weight: 700; + letter-spacing: -0.02em; + padding: 8px 10px; + line-height: 1; +} + +/* --------------------------------------------------------------------------- + List + --------------------------------------------------------------------------- */ + +.logfire-list { + list-style: none; + padding: 0; + margin: 0; + font-family: var(--font-monospace); + font-size: 11.5px; + line-height: 1.5; +} + +.logfire-list li { + padding: 2px 10px; + color: var(--text-normal); + border-bottom: 1px solid color-mix(in srgb, var(--background-modifier-border) 40%, transparent); +} + +.logfire-list li:last-child { + border-bottom: none; +} + +.logfire-list li:hover { + background: color-mix(in srgb, var(--background-secondary) 40%, transparent); +} + +/* --------------------------------------------------------------------------- + Heatmap — Text-Based Bar Readout + --------------------------------------------------------------------------- */ + +.logfire-heatmap { + font-family: var(--font-monospace); + font-size: 11px; + line-height: 1.6; + padding: 4px 0; + font-variant-numeric: tabular-nums; +} + +.logfire-heatmap > div { + padding: 1px 10px; + color: var(--text-normal); + white-space: pre; + transition: background 60ms ease; +} + +.logfire-heatmap > div:hover { + background: color-mix(in srgb, var(--background-secondary) 40%, transparent); +} + +/* --------------------------------------------------------------------------- + Empty State & Error + --------------------------------------------------------------------------- */ + +.logfire-empty { + padding: 12px 10px; + color: var(--text-faint); + font-family: var(--font-monospace); + font-size: 11.5px; + font-style: italic; +} + +.logfire-error { + padding: 8px 10px; + margin: 0; + font-family: var(--font-monospace); + font-size: 11.5px; + line-height: 1.5; + color: var(--text-error, #e5534b); + background: color-mix(in srgb, var(--text-error, #e5534b) 8%, var(--background-primary)); + border-left: 3px solid var(--text-error, #e5534b); + border-radius: 0 4px 4px 0; + white-space: pre-wrap; + word-break: break-word; +}