Virtual Tables: _files, _links, _tags, _headings

Auto-generierte Tabellen aus Vault-Metadaten mit prepared statements
und Transaktionen. Inkrementelle Updates bei Datei-Aenderungen und
Metadata-Cache-Events. Unterstuetzt manuellen Rebuild.

Co-Authored-By: tolvitty <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-12 11:09:48 +01:00
parent c2f0270f1e
commit 3f12824170

225
src/query/virtual-tables.ts Normal file
View file

@ -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);
}
}