obsidian-logfire/src/query/autocomplete.ts
tolvitty c2b7918ce4 Feature 9: Polish & Extras — Vim-Navigation, Autocomplete, Export, erweiterte Settings
- keyboard-nav.ts: Vim-Navigation fuer Tabellen (j/k/h/l, gg/G, /, y, Enter, Esc)
- autocomplete.ts: SQL-Autocomplete mit Kontext-Erkennung (Tabellen, Spalten, Keywords, Funktionen, Event-Typen)
- query-modal.ts: CSV/JSON-Export-Buttons, Autocomplete-Integration (Dropdown, Arrow-Nav, Tab/Enter Accept)
- settings-tab.ts: Erweiterte DB-Settings (WAL-Toggle, Cache-Slider, MMap-Slider), geschuetzte Event-Typen, Info-Sektion
- main.ts: SqlAutocomplete + KeyboardNavigator Integration, 3 neue Commands (export-csv, export-json, toggle-vim-nav)
- styles.css: Vim-Navigation (.logfire-vim-*), Autocomplete-Dropdown (.logfire-ac-*), Info-Sektion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:55:44 +01:00

228 lines
7.1 KiB
TypeScript

import { DatabaseManager } from '../core/database';
export interface Suggestion {
text: string;
type: 'table' | 'column' | 'keyword' | 'function' | 'event-type' | 'category';
detail?: string;
}
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN',
'ORDER BY', 'GROUP BY', 'HAVING', 'LIMIT', 'OFFSET', 'AS', 'ON', 'JOIN',
'LEFT JOIN', 'INNER JOIN', 'DISTINCT', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'IS', 'NULL', 'ASC', 'DESC',
'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'WITH', 'UNION', 'EXCEPT', 'INTERSECT',
];
const SQL_FUNCTIONS = [
'COUNT(', 'SUM(', 'AVG(', 'MIN(', 'MAX(',
'COALESCE(', 'IFNULL(', 'NULLIF(',
'LENGTH(', 'LOWER(', 'UPPER(', 'TRIM(', 'SUBSTR(',
'DATE(', 'TIME(', 'DATETIME(', 'STRFTIME(',
'JSON_EXTRACT(', 'JSON_ARRAY_LENGTH(',
'TYPEOF(', 'CAST(', 'ABS(', 'ROUND(',
'REPLACE(', 'INSTR(', 'PRINTF(',
'GROUP_CONCAT(', 'TOTAL(',
];
const EVENT_TYPES = [
'file:create', 'file:delete', 'file:rename', 'file:move', 'file:modify',
'content:words-changed', 'content:link-added', 'content:link-removed',
'content:tag-added', 'content:tag-removed', 'content:frontmatter-changed',
'content:heading-changed', 'content:embed-added', 'content:embed-removed',
'nav:file-open', 'nav:file-close', 'nav:active-leaf-change',
'editor:change', 'editor:selection', 'editor:paste',
'vault:folder-create', 'vault:folder-delete', 'vault:folder-rename',
'system:session-start', 'system:session-end', 'system:baseline-scan',
'system:rescan', 'system:maintenance',
'plugin:command-executed',
];
const CATEGORIES = [
'file', 'content', 'navigation', 'editor', 'vault', 'plugin', 'system',
];
const TABLE_NAMES = [
'events', 'sessions', 'baseline', 'daily_stats', 'monthly_stats',
'retention_log', 'schema_version',
'_files', '_links', '_tags', '_headings',
];
export class SqlAutocomplete {
private columnCache = new Map<string, string[]>();
constructor(private db: DatabaseManager) {
this.preloadColumns();
}
private preloadColumns(): void {
for (const table of TABLE_NAMES) {
try {
const rows = this.db.queryReadOnly(
`PRAGMA table_info('${table}')`,
) as { name: string }[];
this.columnCache.set(table, rows.map(r => r.name));
} catch {
// Virtual tables oder nicht existente Tabellen ueberspringen
}
}
}
getSuggestions(sql: string, cursorPos: number): Suggestion[] {
const textBefore = sql.substring(0, cursorPos);
const lastWord = getLastWord(textBefore);
if (!lastWord) return [];
const context = detectContext(textBefore);
const suggestions: Suggestion[] = [];
const lw = lastWord.toLowerCase();
switch (context) {
case 'from':
case 'join':
for (const t of TABLE_NAMES) {
if (t.toLowerCase().startsWith(lw)) {
suggestions.push({ text: t, type: 'table' });
}
}
break;
case 'select':
case 'where':
case 'group-by':
case 'order-by': {
// Spalten aus aktiven Tabellen
const tables = extractTables(textBefore);
for (const table of tables) {
const cols = this.columnCache.get(table) ?? [];
for (const col of cols) {
if (col.toLowerCase().startsWith(lw)) {
suggestions.push({ text: col, type: 'column', detail: table });
}
}
}
// Funktionen
for (const fn of SQL_FUNCTIONS) {
if (fn.toLowerCase().startsWith(lw)) {
suggestions.push({ text: fn, type: 'function' });
}
}
// Keywords
for (const kw of SQL_KEYWORDS) {
if (kw.toLowerCase().startsWith(lw)) {
suggestions.push({ text: kw, type: 'keyword' });
}
}
break;
}
case 'string':
// Event-Typen und Kategorien
for (const et of EVENT_TYPES) {
if (et.toLowerCase().startsWith(lw)) {
suggestions.push({ text: et, type: 'event-type' });
}
}
for (const cat of CATEGORIES) {
if (cat.toLowerCase().startsWith(lw)) {
suggestions.push({ text: cat, type: 'category' });
}
}
break;
default:
// Generisch: Alle Typen
for (const t of TABLE_NAMES) {
if (t.toLowerCase().startsWith(lw)) {
suggestions.push({ text: t, type: 'table' });
}
}
for (const kw of SQL_KEYWORDS) {
if (kw.toLowerCase().startsWith(lw)) {
suggestions.push({ text: kw, type: 'keyword' });
}
}
}
// Sortieren: exakte Prefix-Matches zuerst, dann alphabetisch
suggestions.sort((a, b) => {
const aExact = a.text.toLowerCase().startsWith(lw) ? 0 : 1;
const bExact = b.text.toLowerCase().startsWith(lw) ? 0 : 1;
if (aExact !== bExact) return aExact - bExact;
return a.text.localeCompare(b.text);
});
return suggestions.slice(0, 20);
}
getColumns(table: string): string[] {
return this.columnCache.get(table) ?? [];
}
}
// ---------------------------------------------------------------------------
// Context detection
// ---------------------------------------------------------------------------
type SqlContext = 'select' | 'from' | 'join' | 'where' | 'group-by' | 'order-by' | 'string' | 'unknown';
function detectContext(textBefore: string): SqlContext {
// In String-Kontext? (ungerade Anzahl an einfachen Anführungszeichen)
const quoteCount = (textBefore.match(/'/g) || []).length;
if (quoteCount % 2 === 1) return 'string';
const upper = textBefore.toUpperCase();
const lastKeyword = findLastKeyword(upper);
switch (lastKeyword) {
case 'SELECT': return 'select';
case 'FROM': return 'from';
case 'JOIN': return 'join';
case 'WHERE': case 'AND': case 'OR': case 'ON': case 'HAVING': return 'where';
case 'GROUP BY': return 'group-by';
case 'ORDER BY': return 'order-by';
default: return 'unknown';
}
}
function findLastKeyword(upper: string): string {
const keywords = [
'ORDER BY', 'GROUP BY', 'LEFT JOIN', 'INNER JOIN', 'JOIN',
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'ON', 'HAVING',
];
let lastPos = -1;
let lastKw = '';
for (const kw of keywords) {
const pos = upper.lastIndexOf(kw);
if (pos > lastPos) {
lastPos = pos;
lastKw = kw;
}
}
return lastKw;
}
function getLastWord(text: string): string {
const match = text.match(/[\w._:-]+$/);
return match ? match[0] : '';
}
function extractTables(text: string): string[] {
const upper = text.toUpperCase();
const tables: string[] = [];
const fromMatch = upper.match(/FROM\s+([\w_]+)/g);
if (fromMatch) {
for (const m of fromMatch) {
const name = m.replace(/FROM\s+/i, '').trim().toLowerCase();
if (TABLE_NAMES.includes(name)) tables.push(name);
}
}
const joinMatch = upper.match(/JOIN\s+([\w_]+)/g);
if (joinMatch) {
for (const m of joinMatch) {
const name = m.replace(/.*JOIN\s+/i, '').trim().toLowerCase();
if (TABLE_NAMES.includes(name)) tables.push(name);
}
}
return tables.length > 0 ? tables : ['events'];
}