- 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>
228 lines
7.1 KiB
TypeScript
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'];
|
|
}
|