diff --git a/manifest.json b/manifest.json index 03ddc2a..67298cb 100644 --- a/manifest.json +++ b/manifest.json @@ -4,6 +4,6 @@ "version": "0.1.0", "minAppVersion": "1.5.0", "description": "Track all vault activity, query with SQL, visualize with charts and dashboards — all backed by SQLite.", - "author": "Luca", + "author": "tolvitty", "isDesktopOnly": true } diff --git a/src/core/database.ts b/src/core/database.ts index 1d62bf6..bd73110 100644 --- a/src/core/database.ts +++ b/src/core/database.ts @@ -262,6 +262,18 @@ export class DatabaseManager { return stmt.all(...params); } + exec(sql: string): void { + this.db.exec(sql); + } + + run(sql: string, ...params: unknown[]): void { + this.db.prepare(sql).run(...params); + } + + transaction(fn: () => T): T { + return this.db.transaction(fn)(); + } + // --------------------------------------------------------------------------- // Retention & Maintenance // --------------------------------------------------------------------------- diff --git a/src/main.ts b/src/main.ts index ac85ed9..a1809c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ import { StatusBar } from './ui/status-bar'; import { EventStreamView, EVENT_STREAM_VIEW_TYPE } from './ui/event-stream-view'; import { registerLogfireBlock, registerLogfireSqlBlock, cleanupAllRefreshTimers } from './query/processor'; import { QueryModal } from './query/query-modal'; +import { VirtualTableManager } from './query/virtual-tables'; export default class LogfirePlugin extends Plugin { settings!: LogfireSettings; @@ -29,6 +30,7 @@ export default class LogfirePlugin extends Plugin { private editorCollector!: EditorCollector; private systemCollector!: SystemCollector; private statusBar!: StatusBar; + private virtualTables!: VirtualTableManager; private paused = false; @@ -115,7 +117,7 @@ export default class LogfirePlugin extends Plugin { this.paused = true; } - // Initial scan + maintenance on startup (after layout ready) + // Initial scan + maintenance + virtual tables on startup (after layout ready) this.app.workspace.onLayoutReady(() => { if (!this.db.hasBaseline()) { this.runInitialScan(); @@ -127,6 +129,10 @@ export default class LogfirePlugin extends Plugin { console.error('[Logfire] Wartung beim Start fehlgeschlagen:', err); } } + + // Virtual Tables + this.virtualTables = new VirtualTableManager(this.app, this.db); + this.virtualTables.initialize(); }); console.log('[Logfire] Plugin geladen. Session:', this.sessionManager.currentSessionId); @@ -136,6 +142,7 @@ export default class LogfirePlugin extends Plugin { console.log('[Logfire] Entlade Plugin...'); cleanupAllRefreshTimers(); + this.virtualTables?.destroy(); this.statusBar?.destroy(); this.stopTracking(); @@ -282,6 +289,15 @@ export default class LogfirePlugin extends Plugin { }, }); + this.addCommand({ + id: 'refresh-virtual-tables', + name: 'Virtual Tables neu aufbauen', + callback: () => { + this.virtualTables?.rebuild(); + new Notice('Logfire: Virtual Tables aktualisiert.'); + }, + }); + this.addCommand({ id: 'open-query', name: 'Query-Editor \u00f6ffnen', diff --git a/src/query/virtual-tables.ts b/src/query/virtual-tables.ts new file mode 100644 index 0000000..930db52 --- /dev/null +++ b/src/query/virtual-tables.ts @@ -0,0 +1,225 @@ +import { App, TFile, CachedMetadata, TAbstractFile, EventRef } from 'obsidian'; +import { DatabaseManager } from '../core/database'; + +const VIRTUAL_TABLE_NAMES = ['_files', '_links', '_tags', '_headings'] as const; + +export class VirtualTableManager { + private initialized = false; + private eventRefs: EventRef[] = []; + + constructor( + private app: App, + private db: DatabaseManager, + ) {} + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + initialize(): void { + this.createSchema(); + this.rebuild(); + this.registerListeners(); + this.initialized = true; + } + + destroy(): void { + for (const ref of this.eventRefs) { + this.app.metadataCache.offref(ref); + } + this.eventRefs = []; + } + + rebuild(): void { + this.db.transaction(() => { + this.db.exec('DELETE FROM _files'); + this.db.exec('DELETE FROM _links'); + this.db.exec('DELETE FROM _tags'); + this.db.exec('DELETE FROM _headings'); + + for (const file of this.app.vault.getFiles()) { + this.indexFile(file); + } + }); + } + + // --------------------------------------------------------------------------- + // Schema + // --------------------------------------------------------------------------- + + private createSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS _files ( + path TEXT PRIMARY KEY, + name TEXT, + basename TEXT, + extension TEXT, + size INTEGER, + created INTEGER, + modified INTEGER, + folder TEXT + ); + + CREATE TABLE IF NOT EXISTS _links ( + from_path TEXT NOT NULL, + to_path TEXT NOT NULL, + display_text TEXT, + link_type TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS _tags ( + path TEXT NOT NULL, + tag TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS _headings ( + path TEXT NOT NULL, + level INTEGER NOT NULL, + heading TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_vt_files_folder ON _files(folder); + CREATE INDEX IF NOT EXISTS idx_vt_files_ext ON _files(extension); + CREATE INDEX IF NOT EXISTS idx_vt_links_from ON _links(from_path); + CREATE INDEX IF NOT EXISTS idx_vt_links_to ON _links(to_path); + CREATE INDEX IF NOT EXISTS idx_vt_tags_path ON _tags(path); + CREATE INDEX IF NOT EXISTS idx_vt_tags_tag ON _tags(tag); + CREATE INDEX IF NOT EXISTS idx_vt_headings_path ON _headings(path); + `); + } + + // --------------------------------------------------------------------------- + // Indexing + // --------------------------------------------------------------------------- + + private indexFile(file: TFile): void { + this.db.run( + `INSERT OR REPLACE INTO _files (path, name, basename, extension, size, created, modified, folder) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + file.path, + file.name, + file.basename, + file.extension, + file.stat.size, + file.stat.ctime, + file.stat.mtime, + file.parent?.path ?? '', + ); + + if (file.extension !== 'md') return; + + const cache = this.app.metadataCache.getFileCache(file); + if (!cache) return; + + this.indexMetadata(file.path, cache); + } + + private indexMetadata(filePath: string, cache: CachedMetadata): void { + // Links + for (const link of cache.links ?? []) { + const resolved = this.app.metadataCache.getFirstLinkpathDest(link.link, filePath)?.path ?? link.link; + this.db.run( + 'INSERT INTO _links (from_path, to_path, display_text, link_type) VALUES (?, ?, ?, ?)', + filePath, resolved, link.displayText ?? link.link, 'link', + ); + } + + // Embeds + for (const embed of cache.embeds ?? []) { + const resolved = this.app.metadataCache.getFirstLinkpathDest(embed.link, filePath)?.path ?? embed.link; + this.db.run( + 'INSERT INTO _links (from_path, to_path, display_text, link_type) VALUES (?, ?, ?, ?)', + filePath, resolved, embed.displayText ?? embed.link, 'embed', + ); + } + + // Tags (inline) + for (const tag of cache.tags ?? []) { + this.db.run('INSERT INTO _tags (path, tag) VALUES (?, ?)', filePath, tag.tag); + } + + // Tags (frontmatter) + const fmTags = cache.frontmatter?.tags; + if (Array.isArray(fmTags)) { + for (const tag of fmTags) { + const normalized = String(tag).startsWith('#') ? String(tag) : `#${tag}`; + this.db.run('INSERT INTO _tags (path, tag) VALUES (?, ?)', filePath, normalized); + } + } + + // Headings + for (const h of cache.headings ?? []) { + this.db.run( + 'INSERT INTO _headings (path, level, heading) VALUES (?, ?, ?)', + filePath, h.level, h.heading, + ); + } + } + + // --------------------------------------------------------------------------- + // Incremental updates + // --------------------------------------------------------------------------- + + private registerListeners(): void { + // File created + this.app.vault.on('create', (file) => { + if (file instanceof TFile) { + this.db.transaction(() => this.indexFile(file)); + } + }); + + // File deleted + this.app.vault.on('delete', (file) => { + if (file instanceof TFile) { + this.removeFile(file.path); + } + }); + + // File renamed/moved + this.app.vault.on('rename', (file, oldPath) => { + if (file instanceof TFile) { + this.db.transaction(() => { + this.removeFile(oldPath); + this.indexFile(file); + }); + } + }); + + // Metadata changed (content edits, frontmatter changes) + const metaRef = this.app.metadataCache.on('changed', (file, _data, cache) => { + this.db.transaction(() => { + this.removeMetadata(file.path); + // Update file stats + this.db.run( + `UPDATE _files SET size = ?, modified = ? WHERE path = ?`, + file.stat.size, file.stat.mtime, file.path, + ); + this.indexMetadata(file.path, cache); + }); + }); + this.eventRefs.push(metaRef); + } + + private removeFile(path: string): void { + this.db.run('DELETE FROM _files WHERE path = ?', path); + this.removeMetadata(path); + } + + private removeMetadata(path: string): void { + this.db.run('DELETE FROM _links WHERE from_path = ?', path); + this.db.run('DELETE FROM _tags WHERE path = ?', path); + this.db.run('DELETE FROM _headings WHERE path = ?', path); + } + + // --------------------------------------------------------------------------- + // API + // --------------------------------------------------------------------------- + + getTableNames(): readonly string[] { + return VIRTUAL_TABLE_NAMES; + } + + isVirtualTable(name: string): boolean { + return (VIRTUAL_TABLE_NAMES as readonly string[]).includes(name); + } +}