obsidian-logfire/src/management/templates.ts
tolvitty b9732810f9 Templates: Built-in und Custom Query-Templates mit Parametern
TemplateManager mit 9 Built-in Templates fuer Logfire-Schema
(Events, Sessions, Stats, Dateien, Tags, verwaiste Notizen,
defekte Links). Parameter-Substitution via {{name:default}}.

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

356 lines
11 KiB
TypeScript

import { Modal, Setting, Notice } from 'obsidian';
import type LogfirePlugin from '../main';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface QueryTemplate {
id: string;
name: string;
description: string;
sql: string;
parameters: TemplateParameter[];
builtIn: boolean;
}
export interface TemplateParameter {
name: string;
label: string;
defaultValue: string;
type: 'text' | 'number' | 'date';
}
const STORAGE_KEY = 'logfire-templates';
// ---------------------------------------------------------------------------
// TemplateManager
// ---------------------------------------------------------------------------
export class TemplateManager {
private templates = new Map<string, QueryTemplate>();
constructor() {
this.load();
}
private load(): void {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
try {
const arr: QueryTemplate[] = JSON.parse(raw);
for (const t of arr) this.templates.set(t.id, t);
} catch { /* ignore */ }
}
// Immer Built-in Templates sicherstellen
for (const t of BUILTIN_TEMPLATES) {
if (!this.templates.has(t.id)) this.templates.set(t.id, t);
}
this.save();
}
private save(): void {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(Array.from(this.templates.values())),
);
}
getAll(): QueryTemplate[] {
return Array.from(this.templates.values());
}
get(id: string): QueryTemplate | undefined {
return this.templates.get(id);
}
addCustom(name: string, description: string, sql: string): QueryTemplate {
const t: QueryTemplate = {
id: `tpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name,
description,
sql,
parameters: parseParameters(sql),
builtIn: false,
};
this.templates.set(t.id, t);
this.save();
return t;
}
delete(id: string): void {
const t = this.templates.get(id);
if (t && !t.builtIn) {
this.templates.delete(id);
this.save();
}
}
// ---------------------------------------------------------------------------
// Parameter handling
// ---------------------------------------------------------------------------
static applyParameters(sql: string, values: Record<string, string>): string {
return sql.replace(/\{\{(\w+)(?::([^}]*))?\}\}/g, (_match, name, defaultVal) => {
const v = values[name];
if (v !== undefined && v !== '') return v;
return defaultVal ?? '';
});
}
}
// ---------------------------------------------------------------------------
// Parameter detection
// ---------------------------------------------------------------------------
function parseParameters(sql: string): TemplateParameter[] {
const params: TemplateParameter[] = [];
const seen = new Set<string>();
const re = /\{\{(\w+)(?::([^}]*))?\}\}/g;
let m: RegExpExecArray | null;
while ((m = re.exec(sql)) !== null) {
if (seen.has(m[1])) continue;
seen.add(m[1]);
params.push({
name: m[1],
label: m[1].charAt(0).toUpperCase() + m[1].slice(1).replace(/_/g, ' '),
defaultValue: m[2] ?? '',
type: 'text',
});
}
return params;
}
// ---------------------------------------------------------------------------
// Built-in Templates (angepasst an Logfire-Schema)
// ---------------------------------------------------------------------------
const BUILTIN_TEMPLATES: QueryTemplate[] = [
{
id: 'builtin-events-today',
name: 'Events heute',
description: 'Alle Events seit Mitternacht',
sql: `SELECT type, source, datetime(timestamp/1000, 'unixepoch', 'localtime') as zeit
FROM events
WHERE timestamp > (strftime('%s', 'now', 'start of day') * 1000)
ORDER BY timestamp DESC
LIMIT {{limit:100}}`,
parameters: [{ name: 'limit', label: 'Limit', defaultValue: '100', type: 'number' }],
builtIn: true,
},
{
id: 'builtin-active-files',
name: 'Aktivste Dateien',
description: 'Dateien mit den meisten Events',
sql: `SELECT source as datei, COUNT(*) as events
FROM events
WHERE source IS NOT NULL
GROUP BY source
ORDER BY events DESC
LIMIT {{limit:20}}`,
parameters: [{ name: 'limit', label: 'Limit', defaultValue: '20', type: 'number' }],
builtIn: true,
},
{
id: 'builtin-session-overview',
name: 'Sessions',
description: 'Letzte Sessions mit Dauer',
sql: `SELECT
id,
datetime(start_time/1000, 'unixepoch', 'localtime') as start,
CASE WHEN end_time IS NOT NULL
THEN ROUND((end_time - start_time) / 60000.0, 1) || ' min'
ELSE 'aktiv'
END as dauer
FROM sessions
ORDER BY start_time DESC
LIMIT {{limit:10}}`,
parameters: [{ name: 'limit', label: 'Limit', defaultValue: '10', type: 'number' }],
builtIn: true,
},
{
id: 'builtin-daily-stats',
name: 'Tagesstatistik',
description: 'Aggregierte Statistiken pro Tag',
sql: `SELECT date as tag, SUM(events_count) as events,
SUM(words_added) as woerter_hinzu, SUM(words_removed) as woerter_entfernt
FROM daily_stats
GROUP BY date
ORDER BY date DESC
LIMIT {{limit:14}}`,
parameters: [{ name: 'limit', label: 'Tage', defaultValue: '14', type: 'number' }],
builtIn: true,
},
{
id: 'builtin-recent-files',
name: 'Zuletzt geaenderte Dateien',
description: 'Dateien nach Aenderungsdatum',
sql: `SELECT name, extension,
datetime(modified/1000, 'unixepoch', 'localtime') as geaendert,
folder
FROM _files
WHERE extension = 'md'
ORDER BY modified DESC
LIMIT {{limit:20}}`,
parameters: [{ name: 'limit', label: 'Limit', defaultValue: '20', type: 'number' }],
builtIn: true,
},
{
id: 'builtin-tag-stats',
name: 'Tag-Statistiken',
description: 'Notizen pro Tag zaehlen',
sql: `SELECT tag, COUNT(DISTINCT path) as notizen
FROM _tags
GROUP BY tag
ORDER BY notizen DESC
LIMIT {{limit:30}}`,
parameters: [{ name: 'limit', label: 'Limit', defaultValue: '30', type: 'number' }],
builtIn: true,
},
{
id: 'builtin-orphan-notes',
name: 'Verwaiste Notizen',
description: 'Notizen ohne eingehende Links',
sql: `SELECT f.path, f.name,
datetime(f.modified/1000, 'unixepoch', 'localtime') as geaendert
FROM _files f
WHERE f.extension = 'md'
AND f.path NOT IN (SELECT DISTINCT to_path FROM _links)
ORDER BY f.modified DESC`,
parameters: [],
builtIn: true,
},
{
id: 'builtin-broken-links',
name: 'Defekte Links',
description: 'Links zu nicht-existierenden Notizen',
sql: `SELECT l.from_path as von, l.to_path as nach, l.display_text
FROM _links l
WHERE l.to_path NOT IN (SELECT path FROM _files)
AND l.link_type = 'link'`,
parameters: [],
builtIn: true,
},
{
id: 'builtin-event-types',
name: 'Event-Typen',
description: 'Verteilung der Event-Typen',
sql: `SELECT type as typ, category as kategorie, COUNT(*) as anzahl
FROM events
GROUP BY type, category
ORDER BY anzahl DESC`,
parameters: [],
builtIn: true,
},
];
// ---------------------------------------------------------------------------
// TemplatePickerModal
// ---------------------------------------------------------------------------
export class TemplatePickerModal extends Modal {
private manager: TemplateManager;
private onSelect: (sql: string) => void;
constructor(plugin: LogfirePlugin, manager: TemplateManager, onSelect: (sql: string) => void) {
super(plugin.app);
this.manager = manager;
this.onSelect = onSelect;
}
onOpen(): void {
const { contentEl } = this;
contentEl.addClass('logfire-template-picker');
contentEl.createEl('h2', { text: 'Query-Templates' });
const list = contentEl.createDiv({ cls: 'logfire-template-list' });
for (const tpl of this.manager.getAll()) {
const item = list.createDiv({ cls: 'logfire-template-item' });
const header = item.createDiv({ cls: 'logfire-template-header' });
header.createSpan({ cls: 'logfire-template-name', text: tpl.name });
if (tpl.builtIn) {
header.createSpan({ cls: 'logfire-template-badge', text: 'Built-in' });
}
if (tpl.description) {
item.createDiv({ cls: 'logfire-template-desc', text: tpl.description });
}
item.createEl('pre', {
cls: 'logfire-template-preview',
text: tpl.sql.length > 120 ? tpl.sql.slice(0, 120) + '...' : tpl.sql,
});
const actions = item.createDiv({ cls: 'logfire-template-actions' });
const useBtn = actions.createEl('button', { text: 'Verwenden', cls: 'mod-cta' });
useBtn.addEventListener('click', () => {
if (tpl.parameters.length > 0) {
this.close();
new ParameterModal(this.app, tpl, values => {
this.onSelect(TemplateManager.applyParameters(tpl.sql, values));
}).open();
} else {
this.onSelect(tpl.sql);
this.close();
}
});
if (!tpl.builtIn) {
const delBtn = actions.createEl('button', { text: 'Entfernen' });
delBtn.addEventListener('click', () => {
this.manager.delete(tpl.id);
item.remove();
new Notice('Template entfernt.');
});
}
}
}
onClose(): void { this.contentEl.empty(); }
}
// ---------------------------------------------------------------------------
// ParameterModal
// ---------------------------------------------------------------------------
class ParameterModal extends Modal {
private template: QueryTemplate;
private onSubmit: (values: Record<string, string>) => void;
private values: Record<string, string> = {};
constructor(
app: import('obsidian').App,
template: QueryTemplate,
onSubmit: (values: Record<string, string>) => void,
) {
super(app);
this.template = template;
this.onSubmit = onSubmit;
for (const p of template.parameters) this.values[p.name] = p.defaultValue;
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl('h3', { text: `Parameter: ${this.template.name}` });
for (const param of this.template.parameters) {
new Setting(contentEl).setName(param.label).addText(text => {
text.setValue(param.defaultValue).setPlaceholder(param.defaultValue)
.onChange(v => { this.values[param.name] = v; });
if (param.type === 'number') text.inputEl.type = 'number';
if (param.type === 'date') text.inputEl.type = 'date';
});
}
const btns = contentEl.createDiv({ cls: 'logfire-modal-buttons' });
const runBtn = btns.createEl('button', { text: 'Ausfuehren', cls: 'mod-cta' });
runBtn.addEventListener('click', () => { this.onSubmit(this.values); this.close(); });
const cancelBtn = btns.createEl('button', { text: 'Abbrechen' });
cancelBtn.addEventListener('click', () => this.close());
}
onClose(): void { this.contentEl.empty(); }
}