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:
parent
b2fc5b8f6b
commit
c90bbbf5e3
2 changed files with 493 additions and 0 deletions
268
src/query/processor.ts
Normal file
268
src/query/processor.ts
Normal 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
225
src/query/query-modal.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue