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'; import { HistoryManager } from '../management/history'; import type { SqlAutocomplete, Suggestion } from './autocomplete'; export class QueryModal extends Modal { private editorEl!: HTMLTextAreaElement; private resultEl!: HTMLElement; private modeToggle!: HTMLButtonElement; private mode: 'shorthand' | 'sql' = 'shorthand'; private historyManager?: HistoryManager; private initialSql?: string; private autocomplete?: SqlAutocomplete; private acDropdown: HTMLElement | null = null; private acSuggestions: Suggestion[] = []; private acIndex = -1; constructor(app: App, private db: DatabaseManager, historyManager?: HistoryManager, initialSql?: string, autocomplete?: SqlAutocomplete) { super(app); this.historyManager = historyManager; this.initialSql = initialSql; this.autocomplete = autocomplete; } 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 (wrap in relative container for autocomplete dropdown) const editorWrap = contentEl.createDiv({ cls: 'logfire-qm-editor-wrap' }); this.editorEl = editorWrap.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) => { // Autocomplete navigation if (this.acDropdown) { if (e.key === 'ArrowDown') { e.preventDefault(); this.acNav(1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); this.acNav(-1); return; } if (e.key === 'Tab' || (e.key === 'Enter' && !e.ctrlKey && !e.metaKey)) { if (this.acIndex >= 0) { e.preventDefault(); this.acAccept(); return; } } if (e.key === 'Escape') { this.acClose(); return; } } if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.acClose(); this.executeQuery(); } }); this.editorEl.addEventListener('input', () => { if (this.mode === 'sql' && this.autocomplete) { this.showAutocomplete(); } }); // 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 csvBtn = buttonRow.createEl('button', { text: 'CSV Export' }); csvBtn.addEventListener('click', () => this.exportCsv()); const jsonBtn = buttonRow.createEl('button', { text: 'JSON Export' }); jsonBtn.addEventListener('click', () => this.exportJson()); 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' }); // Initial SQL setzen if (this.initialSql) { this.mode = 'sql'; this.modeToggle.textContent = 'Modus: SQL'; this.editorEl.value = this.initialSql; this.editorEl.placeholder = 'SELECT * FROM events\nWHERE type = \'file:create\'\nORDER BY timestamp DESC\nLIMIT 50'; } } onClose(): void { this.acClose(); this.contentEl.empty(); } private lastRows: Record[] = []; private lastKeys: string[] = []; private executeQuery(): void { const input = this.editorEl.value.trim(); if (!input) return; this.resultEl.empty(); const startTime = Date.now(); try { let rows: Record[]; let executedSql = input; 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); executedSql = sql; rows = this.db.queryReadOnly(sql, params) as Record[]; } const elapsed = Date.now() - startTime; this.historyManager?.addEntry(executedSql, elapsed, rows.length); 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(); } // --------------------------------------------------------------------------- // CSV / JSON Export // --------------------------------------------------------------------------- private exportCsv(): void { if (this.lastRows.length === 0) { new Notice('Keine Ergebnisse zum Exportieren.'); return; } const bom = '\uFEFF'; const header = this.lastKeys.map(csvEscape).join(','); const body = this.lastRows.map(row => this.lastKeys.map(k => csvEscape(row[k])).join(',') ).join('\n'); downloadBlob(bom + header + '\n' + body, 'logfire-export.csv', 'text/csv;charset=utf-8'); new Notice('CSV exportiert.'); } private exportJson(): void { if (this.lastRows.length === 0) { new Notice('Keine Ergebnisse zum Exportieren.'); return; } const json = JSON.stringify(this.lastRows, null, 2); downloadBlob(json, 'logfire-export.json', 'application/json'); new Notice('JSON exportiert.'); } // --------------------------------------------------------------------------- // Autocomplete // --------------------------------------------------------------------------- private showAutocomplete(): void { if (!this.autocomplete) return; const cursorPos = this.editorEl.selectionStart; const suggestions = this.autocomplete.getSuggestions(this.editorEl.value, cursorPos); if (suggestions.length === 0) { this.acClose(); return; } this.acSuggestions = suggestions; this.acIndex = -1; if (!this.acDropdown) { this.acDropdown = this.editorEl.parentElement!.createDiv({ cls: 'logfire-ac-dropdown' }); } this.acDropdown.empty(); for (let i = 0; i < suggestions.length; i++) { const s = suggestions[i]; const item = this.acDropdown.createDiv({ cls: 'logfire-ac-item' }); item.createEl('span', { text: s.text, cls: 'logfire-ac-text' }); item.createEl('span', { text: s.type, cls: `logfire-ac-type logfire-ac-type-${s.type}` }); if (s.detail) { item.createEl('span', { text: s.detail, cls: 'logfire-ac-detail' }); } item.addEventListener('mousedown', (e) => { e.preventDefault(); this.acIndex = i; this.acAccept(); }); } } private acNav(dir: number): void { if (!this.acDropdown || this.acSuggestions.length === 0) return; this.acIndex = Math.max(-1, Math.min(this.acSuggestions.length - 1, this.acIndex + dir)); const items = this.acDropdown.querySelectorAll('.logfire-ac-item'); items.forEach((el, i) => el.classList.toggle('is-selected', i === this.acIndex)); } private acAccept(): void { if (this.acIndex < 0 || this.acIndex >= this.acSuggestions.length) return; const suggestion = this.acSuggestions[this.acIndex]; const cursorPos = this.editorEl.selectionStart; const text = this.editorEl.value; const before = text.substring(0, cursorPos); const after = text.substring(cursorPos); // Letztes Wort ersetzen const lastWordMatch = before.match(/[\w._:-]+$/); const replaceStart = lastWordMatch ? cursorPos - lastWordMatch[0].length : cursorPos; const newBefore = text.substring(0, replaceStart) + suggestion.text; this.editorEl.value = newBefore + after; this.editorEl.selectionStart = this.editorEl.selectionEnd = newBefore.length; this.editorEl.focus(); this.acClose(); } private acClose(): void { this.acDropdown?.remove(); this.acDropdown = null; this.acSuggestions = []; this.acIndex = -1; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function csvEscape(value: unknown): string { if (value === null || value === undefined) return ''; const s = String(value); if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) { return `"${s.replace(/"/g, '""')}"`; } return s; } function downloadBlob(content: string, filename: string, mimeType: string): void { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // --------------------------------------------------------------------------- // 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 }; }