From 4e27ec2a90e63c5b402150b8dfe821604685170b Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 10:50:33 +0100 Subject: [PATCH 1/4] Event-Bus, Session-Manager und Content-Analyzer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventBus: Circular Buffer mit Pub/Sub, Auto-Flush bei Threshold oder Intervall, Error-Recovery. SessionManager: Session-Lifecycle mit UUID, Dauer-Tracking. ContentAnalyzer: Snapshot-Cache, semantische Diffs für Wörter, Links, Tags, Headings, Frontmatter, Embeds. Co-Authored-By: Claude Opus 4.6 --- src/core/content-analyzer.ts | 171 +++++++++++++++++++++++++++++++++++ src/core/event-bus.ts | 100 ++++++++++++++++++++ src/core/session-manager.ts | 60 ++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 src/core/content-analyzer.ts create mode 100644 src/core/event-bus.ts create mode 100644 src/core/session-manager.ts diff --git a/src/core/content-analyzer.ts b/src/core/content-analyzer.ts new file mode 100644 index 0000000..a497e87 --- /dev/null +++ b/src/core/content-analyzer.ts @@ -0,0 +1,171 @@ +import { CachedMetadata } from 'obsidian'; +import { ContentSnapshot, ContentDelta } from '../types'; + +export class ContentAnalyzer { + private cache = new Map(); + + loadBaseline(baseline: Map): void { + this.cache = new Map(baseline); + } + + getSnapshot(path: string): ContentSnapshot | undefined { + return this.cache.get(path); + } + + removeSnapshot(path: string): void { + this.cache.delete(path); + } + + renameSnapshot(oldPath: string, newPath: string): void { + const snap = this.cache.get(oldPath); + if (snap) { + this.cache.delete(oldPath); + snap.path = newPath; + this.cache.set(newPath, snap); + } + } + + buildSnapshot(path: string, content: string, metadata: CachedMetadata | null): ContentSnapshot { + const snapshot: ContentSnapshot = { + path, + wordCount: countWords(content), + charCount: content.length, + links: extractLinks(metadata), + tags: extractTags(metadata), + headings: extractHeadings(metadata), + frontmatter: extractFrontmatter(metadata), + embeds: extractEmbeds(metadata), + }; + this.cache.set(path, snapshot); + return snapshot; + } + + analyze(path: string, content: string, metadata: CachedMetadata | null): ContentDelta | null { + const prev = this.cache.get(path); + const next = this.buildSnapshot(path, content, metadata); + + if (!prev) return null; + + const wordsAdded = Math.max(0, next.wordCount - prev.wordCount); + const wordsRemoved = Math.max(0, prev.wordCount - next.wordCount); + + const linksAdded = diff(next.links, prev.links); + const linksRemoved = diff(prev.links, next.links); + + const tagsAdded = diff(next.tags, prev.tags); + const tagsRemoved = diff(prev.tags, next.tags); + + const headingsAdded = diffHeadings(next.headings, prev.headings); + const headingsRemoved = diffHeadings(prev.headings, next.headings); + + const embedsAdded = diff(next.embeds, prev.embeds); + const embedsRemoved = diff(prev.embeds, next.embeds); + + const { changed: frontmatterChanged, changes: frontmatterChanges } = + diffFrontmatter(prev.frontmatter, next.frontmatter); + + return { + wordsAdded, + wordsRemoved, + linksAdded, + linksRemoved, + tagsAdded, + tagsRemoved, + headingsAdded, + headingsRemoved, + embedsAdded, + embedsRemoved, + frontmatterChanged, + frontmatterChanges, + }; + } +} + +// --------------------------------------------------------------------------- +// Extraction helpers +// --------------------------------------------------------------------------- + +function countWords(content: string): number { + const stripped = content.replace(/^---[\s\S]*?---\n?/, ''); + const words = stripped.split(/\s+/).filter(w => w.length > 0); + return words.length; +} + +function extractLinks(meta: CachedMetadata | null): string[] { + if (!meta?.links) return []; + return meta.links.map(l => l.link); +} + +function extractTags(meta: CachedMetadata | null): string[] { + const tags: string[] = []; + if (meta?.tags) { + for (const t of meta.tags) { + tags.push(t.tag); + } + } + if (meta?.frontmatter?.tags) { + const fmTags = meta.frontmatter.tags; + if (Array.isArray(fmTags)) { + for (const t of fmTags) { + const normalized = typeof t === 'string' && t.startsWith('#') ? t : `#${t}`; + if (!tags.includes(normalized)) tags.push(normalized); + } + } + } + return tags; +} + +function extractHeadings(meta: CachedMetadata | null): { level: number; text: string }[] { + if (!meta?.headings) return []; + return meta.headings.map(h => ({ level: h.level, text: h.heading })); +} + +function extractFrontmatter(meta: CachedMetadata | null): Record { + if (!meta?.frontmatter) return {}; + const fm: Record = {}; + for (const [key, value] of Object.entries(meta.frontmatter)) { + if (key === 'position') continue; + fm[key] = value; + } + return fm; +} + +function extractEmbeds(meta: CachedMetadata | null): string[] { + if (!meta?.embeds) return []; + return meta.embeds.map(e => e.link); +} + +// --------------------------------------------------------------------------- +// Diff helpers +// --------------------------------------------------------------------------- + +function diff(a: string[], b: string[]): string[] { + const bSet = new Set(b); + return a.filter(x => !bSet.has(x)); +} + +function diffHeadings( + a: { level: number; text: string }[], + b: { level: number; text: string }[], +): { level: number; text: string }[] { + const bKeys = new Set(b.map(h => `${h.level}:${h.text}`)); + return a.filter(h => !bKeys.has(`${h.level}:${h.text}`)); +} + +function diffFrontmatter( + prev: Record, + next: Record, +): { changed: boolean; changes: Record } { + const changes: Record = {}; + + const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]); + for (const key of allKeys) { + const oldVal = prev[key]; + const newVal = next[key]; + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + changes[key] = { old: oldVal, new: newVal }; + } + } + + return { changed: Object.keys(changes).length > 0, changes }; +} diff --git a/src/core/event-bus.ts b/src/core/event-bus.ts new file mode 100644 index 0000000..595655f --- /dev/null +++ b/src/core/event-bus.ts @@ -0,0 +1,100 @@ +import { LogfireEvent, EventType } from '../types'; +import { DatabaseManager } from './database'; + +export class EventBus { + private buffer: LogfireEvent[] = []; + private subscribers = new Map void>>(); + private flushTimer: ReturnType | null = null; + + constructor( + private db: DatabaseManager, + private flushIntervalMs: number, + private flushThreshold: number, + ) { + this.startFlushCycle(); + } + + // --------------------------------------------------------------------------- + // Emit & subscribe + // --------------------------------------------------------------------------- + + emit(event: LogfireEvent): void { + this.buffer.push(event); + this.notifySubscribers(event); + + if (this.buffer.length >= this.flushThreshold) { + this.flush(); + } + } + + onEvent(type: EventType | '*', callback: (event: LogfireEvent) => void): () => void { + if (!this.subscribers.has(type)) { + this.subscribers.set(type, new Set()); + } + this.subscribers.get(type)!.add(callback); + + return () => { + this.subscribers.get(type)?.delete(callback); + }; + } + + // --------------------------------------------------------------------------- + // Flush + // --------------------------------------------------------------------------- + + flush(): void { + if (this.buffer.length === 0) return; + + const batch = this.buffer.splice(0); + try { + this.db.insertEvents(batch); + } catch (err) { + console.error('[Logfire] Flush fehlgeschlagen:', err); + this.buffer.unshift(...batch); + } + } + + getBufferSize(): number { + return this.buffer.length; + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + destroy(): void { + if (this.flushTimer !== null) { + clearInterval(this.flushTimer); + this.flushTimer = null; + } + this.flush(); + this.subscribers.clear(); + } + + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + private startFlushCycle(): void { + this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs); + } + + private notifySubscribers(event: LogfireEvent): void { + this.notifySet(this.subscribers.get(event.type), event); + this.notifySet(this.subscribers.get('*'), event); + } + + private notifySet( + subs: Set<(event: LogfireEvent) => void> | undefined, + event: LogfireEvent, + ): void { + if (!subs) return; + for (const cb of subs) { + try { + cb(event); + } catch (err) { + console.error('[Logfire] Subscriber-Fehler:', err); + } + } + } +} diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts new file mode 100644 index 0000000..1f78cd0 --- /dev/null +++ b/src/core/session-manager.ts @@ -0,0 +1,60 @@ +import { LogfireEvent, categoryForType } from '../types'; +import { EventBus } from './event-bus'; +import { DatabaseManager } from './database'; + +export class SessionManager { + private _sessionId = ''; + private _startTime = 0; + + get currentSessionId(): string { + return this._sessionId; + } + + get sessionDurationMs(): number { + if (this._startTime === 0) return 0; + return Date.now() - this._startTime; + } + + constructor( + private eventBus: EventBus, + private db: DatabaseManager, + private vaultName: string, + ) {} + + startSession(): void { + this._sessionId = crypto.randomUUID(); + this._startTime = Date.now(); + + this.db.startSession(this._sessionId, this.vaultName); + + this.eventBus.emit(this.createEvent('system:session-start', { + vaultName: this.vaultName, + })); + } + + endSession(): void { + if (!this._sessionId) return; + + this.eventBus.emit(this.createEvent('system:session-end', { + vaultName: this.vaultName, + duration: this.sessionDurationMs, + })); + + this.db.endSession(this._sessionId); + } + + private createEvent( + type: 'system:session-start' | 'system:session-end', + payload: Record, + ): LogfireEvent { + return { + id: crypto.randomUUID(), + timestamp: Date.now(), + type, + category: categoryForType(type), + source: this.vaultName, + payload, + session: this._sessionId, + }; + } +} From e0d9f301d671a64e01b86d5846dae3e9c4cb2573 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 10:50:41 +0100 Subject: [PATCH 2/4] Alle Event-Collectors implementiert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileCollector: create, delete, rename, move, modify Events. ContentCollector: Semantische Analyse bei file:modify (Wörter, Links, Tags, Headings, Embeds, Frontmatter). NavCollector: file-open/close mit Dauer, active-leaf-change. EditorCollector: CM6 ViewPlugin mit Debouncing. SystemCollector: Command-Patching für Kommando-Tracking. Co-Authored-By: Claude Opus 4.6 --- src/collectors/content-collector.ts | 118 +++++++++++++++++++++++++ src/collectors/editor-collector.ts | 108 +++++++++++++++++++++++ src/collectors/file-collector.ts | 129 ++++++++++++++++++++++++++++ src/collectors/nav-collector.ts | 89 +++++++++++++++++++ src/collectors/system-collector.ts | 88 +++++++++++++++++++ 5 files changed, 532 insertions(+) create mode 100644 src/collectors/content-collector.ts create mode 100644 src/collectors/editor-collector.ts create mode 100644 src/collectors/file-collector.ts create mode 100644 src/collectors/nav-collector.ts create mode 100644 src/collectors/system-collector.ts diff --git a/src/collectors/content-collector.ts b/src/collectors/content-collector.ts new file mode 100644 index 0000000..25cb4fc --- /dev/null +++ b/src/collectors/content-collector.ts @@ -0,0 +1,118 @@ +import { App, TFile } from 'obsidian'; +import { LogfireEvent, EventType, categoryForType, LogfireSettings } from '../types'; +import { EventBus } from '../core/event-bus'; +import { SessionManager } from '../core/session-manager'; +import { ContentAnalyzer } from '../core/content-analyzer'; + +export class ContentCollector { + private unsubscribe: (() => void) | null = null; + + constructor( + private app: App, + private eventBus: EventBus, + private sessionManager: SessionManager, + private settings: LogfireSettings, + private analyzer: ContentAnalyzer, + private shouldTrack: (path: string) => boolean, + ) {} + + register(): void { + this.unsubscribe = this.eventBus.onEvent('file:modify', (event) => { + this.onFileModify(event); + }); + } + + unregister(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + } + + private async onFileModify(event: LogfireEvent): Promise { + if (!this.settings.tracking.contentAnalysis) return; + + const path = event.source; + if (!this.shouldTrack(path)) return; + + const file = this.app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) return; + if (file.extension !== 'md') return; + + try { + const content = await this.app.vault.cachedRead(file); + const metadata = this.app.metadataCache.getFileCache(file); + const delta = this.analyzer.analyze(path, content, metadata ?? null); + if (!delta) return; + + if (delta.wordsAdded > 0 || delta.wordsRemoved > 0) { + this.emit('content:words-changed', path, { + wordsAdded: delta.wordsAdded, + wordsRemoved: delta.wordsRemoved, + totalWords: this.analyzer.getSnapshot(path)?.wordCount ?? 0, + }); + } + + for (const link of delta.linksAdded) { + this.emit('content:link-added', path, { link, isEmbed: false }); + } + for (const link of delta.linksRemoved) { + this.emit('content:link-removed', path, { link, isEmbed: false }); + } + + for (const tag of delta.tagsAdded) { + this.emit('content:tag-added', path, { tag }); + } + for (const tag of delta.tagsRemoved) { + this.emit('content:tag-removed', path, { tag }); + } + + if (delta.headingsAdded.length > 0 || delta.headingsRemoved.length > 0) { + this.emit('content:heading-changed', path, { + added: delta.headingsAdded, + removed: delta.headingsRemoved, + }); + } + + for (const embed of delta.embedsAdded) { + this.emit('content:embed-added', path, { + embed, + embedType: classifyEmbed(embed), + }); + } + for (const embed of delta.embedsRemoved) { + this.emit('content:embed-removed', path, { + embed, + embedType: classifyEmbed(embed), + }); + } + + if (delta.frontmatterChanged) { + this.emit('content:frontmatter-changed', path, { + keys: Object.keys(delta.frontmatterChanges), + changes: delta.frontmatterChanges, + }); + } + } catch (err) { + console.error('[Logfire] ContentCollector-Fehler:', err); + } + } + + private emit(type: EventType, source: string, payload: Record): void { + this.eventBus.emit({ + id: crypto.randomUUID(), + timestamp: Date.now(), + type, + category: categoryForType(type), + source, + payload, + session: this.sessionManager.currentSessionId, + }); + } +} + +function classifyEmbed(link: string): 'image' | 'note' | 'pdf' | 'other' { + const ext = link.split('.').pop()?.toLowerCase() ?? ''; + if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp'].includes(ext)) return 'image'; + if (ext === 'pdf') return 'pdf'; + if (ext === 'md' || ext === '') return 'note'; + return 'other'; +} diff --git a/src/collectors/editor-collector.ts b/src/collectors/editor-collector.ts new file mode 100644 index 0000000..3d38557 --- /dev/null +++ b/src/collectors/editor-collector.ts @@ -0,0 +1,108 @@ +import { ViewPlugin, ViewUpdate } from '@codemirror/view'; +import { Extension } from '@codemirror/state'; +import { categoryForType, LogfireSettings } from '../types'; +import { EventBus } from '../core/event-bus'; +import { SessionManager } from '../core/session-manager'; + +interface PendingChange { + inserted: number; + deleted: number; + from: number; + to: number; +} + +export class EditorCollector { + constructor( + private eventBus: EventBus, + private sessionManager: SessionManager, + private settings: LogfireSettings, + private shouldTrack: (path: string) => boolean, + private getActiveFilePath: () => string | null, + ) {} + + createExtension(): Extension { + const collector = this; + + return ViewPlugin.fromClass( + class { + private pendingChanges: PendingChange[] = []; + private debounceTimer: ReturnType | null = null; + private batchStartTime = 0; + + update(update: ViewUpdate): void { + if (!update.docChanged) return; + if (!collector.settings.tracking.editorChanges) return; + + const filePath = collector.getActiveFilePath(); + if (!filePath || !collector.shouldTrack(filePath)) return; + + if (this.pendingChanges.length === 0) { + this.batchStartTime = Date.now(); + } + + update.changes.iterChanges((fromA, toA, _fromB, toB, inserted) => { + this.pendingChanges.push({ + inserted: inserted.length, + deleted: toA - fromA, + from: fromA, + to: toB, + }); + }); + + if (this.debounceTimer !== null) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout( + () => this.flush(filePath), + collector.settings.advanced.debounceMs, + ); + } + + private flush(filePath: string): void { + if (this.pendingChanges.length === 0) return; + + const changes = this.pendingChanges.splice(0); + const duration = Date.now() - this.batchStartTime; + + let insertedChars = 0; + let deletedChars = 0; + let rangeStart = Infinity; + let rangeEnd = 0; + + for (const c of changes) { + insertedChars += c.inserted; + deletedChars += c.deleted; + if (c.from < rangeStart) rangeStart = c.from; + if (c.to > rangeEnd) rangeEnd = c.to; + } + + collector.emitChange(filePath, { + insertedChars, + deletedChars, + rangeStart: rangeStart === Infinity ? 0 : rangeStart, + rangeEnd, + duration, + }); + } + + destroy(): void { + if (this.debounceTimer !== null) { + clearTimeout(this.debounceTimer); + } + } + }, + ); + } + + private emitChange(source: string, payload: Record): void { + this.eventBus.emit({ + id: crypto.randomUUID(), + timestamp: Date.now(), + type: 'editor:change', + category: categoryForType('editor:change'), + source, + payload, + session: this.sessionManager.currentSessionId, + }); + } +} diff --git a/src/collectors/file-collector.ts b/src/collectors/file-collector.ts new file mode 100644 index 0000000..48e016a --- /dev/null +++ b/src/collectors/file-collector.ts @@ -0,0 +1,129 @@ +import { App, TFile, TFolder, TAbstractFile, EventRef } from 'obsidian'; +import { LogfireEvent, EventType, categoryForType, LogfireSettings } from '../types'; +import { EventBus } from '../core/event-bus'; +import { SessionManager } from '../core/session-manager'; + +export class FileCollector { + private refs: EventRef[] = []; + + constructor( + private app: App, + private eventBus: EventBus, + private sessionManager: SessionManager, + private settings: LogfireSettings, + private shouldTrack: (path: string) => boolean, + ) {} + + register(): void { + this.refs.push( + this.app.vault.on('create', (file) => this.onCreate(file)), + this.app.vault.on('delete', (file) => this.onDelete(file)), + this.app.vault.on('rename', (file, oldPath) => this.onRename(file, oldPath)), + this.app.vault.on('modify', (file) => this.onModify(file)), + ); + } + + unregister(): void { + for (const ref of this.refs) { + this.app.vault.offref(ref); + } + this.refs = []; + } + + // --------------------------------------------------------------------------- + // Event handlers + // --------------------------------------------------------------------------- + + private onCreate(file: TAbstractFile): void { + if (file instanceof TFolder) { + if (!this.shouldTrack(file.path)) return; + this.emit('vault:folder-create', file.path, {}); + return; + } + if (!(file instanceof TFile)) return; + if (!this.shouldTrack(file.path)) return; + if (!this.settings.tracking.fileEvents) return; + + this.emit('file:create', file.path, { + fileType: file.extension, + size: file.stat.size, + }); + } + + private onDelete(file: TAbstractFile): void { + if (file instanceof TFolder) { + if (!this.shouldTrack(file.path)) return; + this.emit('vault:folder-delete', file.path, {}); + return; + } + if (!(file instanceof TFile)) return; + if (!this.shouldTrack(file.path)) return; + if (!this.settings.tracking.fileEvents) return; + + this.emit('file:delete', file.path, { + fileType: file.extension, + size: file.stat.size, + }); + } + + private onRename(file: TAbstractFile, oldPath: string): void { + if (file instanceof TFolder) { + if (!this.shouldTrack(file.path) && !this.shouldTrack(oldPath)) return; + this.emit('vault:folder-rename', oldPath, { target: file.path }); + return; + } + if (!(file instanceof TFile)) return; + if (!this.shouldTrack(file.path) && !this.shouldTrack(oldPath)) return; + if (!this.settings.tracking.fileEvents) return; + + const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/')); + const newDir = file.path.substring(0, file.path.lastIndexOf('/')); + + if (oldDir !== newDir) { + this.emit('file:move', oldPath, { + oldFolder: oldDir || '/', + newFolder: newDir || '/', + }, file.path); + } else { + const oldName = oldPath.substring(oldPath.lastIndexOf('/') + 1); + const newName = file.name; + this.emit('file:rename', oldPath, { + oldName, + newName, + }, file.path); + } + } + + private onModify(file: TAbstractFile): void { + if (!(file instanceof TFile)) return; + if (!this.shouldTrack(file.path)) return; + if (!this.settings.tracking.fileEvents) return; + + this.emit('file:modify', file.path, { + size: file.stat.size, + }); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private emit( + type: EventType, + source: string, + payload: Record, + target?: string, + ): void { + const event: LogfireEvent = { + id: crypto.randomUUID(), + timestamp: Date.now(), + type, + category: categoryForType(type), + source, + target, + payload, + session: this.sessionManager.currentSessionId, + }; + this.eventBus.emit(event); + } +} diff --git a/src/collectors/nav-collector.ts b/src/collectors/nav-collector.ts new file mode 100644 index 0000000..e88b64d --- /dev/null +++ b/src/collectors/nav-collector.ts @@ -0,0 +1,89 @@ +import { App, WorkspaceLeaf, EventRef, MarkdownView } from 'obsidian'; +import { EventType, categoryForType, LogfireSettings } from '../types'; +import { EventBus } from '../core/event-bus'; +import { SessionManager } from '../core/session-manager'; + +export class NavCollector { + private refs: EventRef[] = []; + private activeFile: string | null = null; + private activeOpenedAt = 0; + + constructor( + private app: App, + private eventBus: EventBus, + private sessionManager: SessionManager, + private settings: LogfireSettings, + private shouldTrack: (path: string) => boolean, + ) {} + + register(): void { + this.refs.push( + this.app.workspace.on('active-leaf-change', (leaf) => this.onActiveLeafChange(leaf)), + ); + + const active = this.app.workspace.getActiveFile(); + if (active) { + this.activeFile = active.path; + this.activeOpenedAt = Date.now(); + } + } + + unregister(): void { + this.closeCurrentFile(); + + for (const ref of this.refs) { + this.app.workspace.offref(ref); + } + this.refs = []; + } + + private onActiveLeafChange(leaf: WorkspaceLeaf | null): void { + if (!this.settings.tracking.navigation) return; + + const prevFile = this.activeFile; + + this.closeCurrentFile(); + + let newFile: string | null = null; + if (leaf?.view instanceof MarkdownView) { + newFile = leaf.view.file?.path ?? null; + } + + this.emit('nav:active-leaf-change', prevFile ?? '', { + from: prevFile ?? undefined, + to: newFile ?? '', + }); + + if (newFile && this.shouldTrack(newFile)) { + this.activeFile = newFile; + this.activeOpenedAt = Date.now(); + this.emit('nav:file-open', newFile, { + pane: leaf?.getRoot()?.toString() ?? 'main', + }); + } else { + this.activeFile = null; + this.activeOpenedAt = 0; + } + } + + private closeCurrentFile(): void { + if (this.activeFile && this.shouldTrack(this.activeFile) && this.activeOpenedAt > 0) { + const duration = Date.now() - this.activeOpenedAt; + this.emit('nav:file-close', this.activeFile, { duration }); + } + this.activeFile = null; + this.activeOpenedAt = 0; + } + + private emit(type: EventType, source: string, payload: Record): void { + this.eventBus.emit({ + id: crypto.randomUUID(), + timestamp: Date.now(), + type, + category: categoryForType(type), + source, + payload, + session: this.sessionManager.currentSessionId, + }); + } +} diff --git a/src/collectors/system-collector.ts b/src/collectors/system-collector.ts new file mode 100644 index 0000000..fbf5910 --- /dev/null +++ b/src/collectors/system-collector.ts @@ -0,0 +1,88 @@ +import { App, EventRef, Command } from 'obsidian'; +import { categoryForType, LogfireSettings } from '../types'; +import { EventBus } from '../core/event-bus'; +import { SessionManager } from '../core/session-manager'; + +interface CommandManager { + executeCommandById(id: string): boolean; + commands: Record; +} + +export class SystemCollector { + private refs: EventRef[] = []; + private originalExecuteCommandById: ((id: string) => boolean) | null = null; + private beforeUnloadHandler: (() => void) | null = null; + + constructor( + private app: App, + private eventBus: EventBus, + private sessionManager: SessionManager, + private settings: LogfireSettings, + ) {} + + register(): void { + if (!this.settings.tracking.commandTracking) return; + + this.patchCommandExecution(); + + this.beforeUnloadHandler = () => { + this.sessionManager.endSession(); + this.eventBus.flush(); + }; + window.addEventListener('beforeunload', this.beforeUnloadHandler); + } + + unregister(): void { + this.unpatchCommandExecution(); + + if (this.beforeUnloadHandler) { + window.removeEventListener('beforeunload', this.beforeUnloadHandler); + this.beforeUnloadHandler = null; + } + + for (const ref of this.refs) { + this.app.workspace.offref(ref); + } + this.refs = []; + } + + private patchCommandExecution(): void { + const commands = (this.app as App & { commands: CommandManager }).commands; + + this.originalExecuteCommandById = commands.executeCommandById.bind(commands); + + commands.executeCommandById = (id: string): boolean => { + const result = this.originalExecuteCommandById!(id); + + if (result) { + const cmd = commands.commands[id]; + this.emit('plugin:command-executed', id, { + commandId: id, + commandName: cmd?.name ?? id, + }); + } + + return result; + }; + } + + private unpatchCommandExecution(): void { + if (this.originalExecuteCommandById) { + const commands = (this.app as App & { commands: CommandManager }).commands; + commands.executeCommandById = this.originalExecuteCommandById; + this.originalExecuteCommandById = null; + } + } + + private emit(type: 'plugin:command-executed', source: string, payload: Record): void { + this.eventBus.emit({ + id: crypto.randomUUID(), + timestamp: Date.now(), + type, + category: categoryForType(type), + source, + payload, + session: this.sessionManager.currentSessionId, + }); + } +} From 91cc22c3e5967dcd76f442bce27578e5de8b3ed7 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 10:50:48 +0100 Subject: [PATCH 3/4] Initial-Scan-Modal mit Fortschrittsanzeige MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch-Processing (50 Dateien pro Batch) mit UI-Yield, Fortschrittsbalken, Datei-/Wort-/Link-/Tag-Zähler, Abbruch-Möglichkeit, Baseline-Event nach Scan. Co-Authored-By: Claude Opus 4.6 --- src/ui/initial-scan-modal.ts | 153 +++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/ui/initial-scan-modal.ts diff --git a/src/ui/initial-scan-modal.ts b/src/ui/initial-scan-modal.ts new file mode 100644 index 0000000..65df147 --- /dev/null +++ b/src/ui/initial-scan-modal.ts @@ -0,0 +1,153 @@ +import { App, Modal, TFile } from 'obsidian'; +import { LogfireSettings, categoryForType } from '../types'; +import { ContentAnalyzer } from '../core/content-analyzer'; +import { DatabaseManager } from '../core/database'; +import { EventBus } from '../core/event-bus'; +import { SessionManager } from '../core/session-manager'; + +const BATCH_SIZE = 50; +const BATCH_DELAY_MS = 50; + +export class InitialScanModal extends Modal { + private cancelled = false; + private resolve!: (scanned: boolean) => void; + + constructor( + app: App, + private settings: LogfireSettings, + private analyzer: ContentAnalyzer, + private db: DatabaseManager, + private eventBus: EventBus, + private sessionManager: SessionManager, + private shouldTrack: (path: string) => boolean, + ) { + super(app); + } + + open(): Promise { + return new Promise((resolve) => { + this.resolve = resolve; + super.open(); + }); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('logfire-scan-modal'); + + const files = this.app.vault.getMarkdownFiles() + .filter(f => this.shouldTrack(f.path)); + + contentEl.createEl('h2', { text: 'Logfire – Vault-Scan' }); + contentEl.createEl('p', { + text: `${files.length} Markdown-Dateien gefunden. Der Scan erstellt die Baseline für Content-Tracking.`, + }); + + const progressContainer = contentEl.createDiv({ cls: 'logfire-scan-progress' }); + const progressBar = progressContainer.createEl('progress', { + attr: { max: String(files.length), value: '0' }, + }); + progressBar.style.width = '100%'; + + const statusEl = contentEl.createDiv({ cls: 'logfire-scan-status' }); + const currentFileEl = statusEl.createDiv(); + const totalsEl = statusEl.createDiv(); + totalsEl.style.marginTop = '8px'; + totalsEl.style.opacity = '0.8'; + + const buttonContainer = contentEl.createDiv({ cls: 'logfire-scan-buttons' }); + buttonContainer.style.display = 'flex'; + buttonContainer.style.justifyContent = 'flex-end'; + buttonContainer.style.gap = '8px'; + buttonContainer.style.marginTop = '16px'; + + const cancelBtn = buttonContainer.createEl('button', { text: 'Abbrechen' }); + cancelBtn.addEventListener('click', () => { + this.cancelled = true; + }); + + const startBtn = buttonContainer.createEl('button', { text: 'Scan starten', cls: 'mod-cta' }); + startBtn.addEventListener('click', async () => { + startBtn.disabled = true; + cancelBtn.textContent = 'Stoppen'; + + let scanned = 0; + let totalWords = 0; + let totalLinks = 0; + let totalTags = 0; + + for (let i = 0; i < files.length; i += BATCH_SIZE) { + if (this.cancelled) break; + + const batch = files.slice(i, i + BATCH_SIZE); + for (const file of batch) { + if (this.cancelled) break; + + try { + const content = await this.app.vault.cachedRead(file); + const metadata = this.app.metadataCache.getFileCache(file); + const snapshot = this.analyzer.buildSnapshot(file.path, content, metadata ?? null); + + this.db.upsertBaseline( + snapshot, + file.stat.ctime, + file.stat.mtime, + file.stat.size, + ); + + totalWords += snapshot.wordCount; + totalLinks += snapshot.links.length; + totalTags += snapshot.tags.length; + scanned++; + + progressBar.value = scanned; + currentFileEl.textContent = file.path; + totalsEl.textContent = `${scanned}/${files.length} Dateien | ${totalWords.toLocaleString()} Wörter | ${totalLinks} Links | ${totalTags} Tags`; + } catch (err) { + console.error(`[Logfire] Scan-Fehler bei ${file.path}:`, err); + } + } + + await sleep(BATCH_DELAY_MS); + } + + this.eventBus.emit({ + id: crypto.randomUUID(), + timestamp: Date.now(), + type: this.cancelled ? 'system:rescan' : 'system:baseline-scan', + category: categoryForType('system:baseline-scan'), + source: this.app.vault.getName(), + payload: { + wordCount: totalWords, + linkCount: totalLinks, + tagCount: totalTags, + filesScanned: scanned, + filesTotal: files.length, + }, + session: this.sessionManager.currentSessionId, + }); + + currentFileEl.textContent = this.cancelled + ? `Scan gestoppt. ${scanned} von ${files.length} Dateien gescannt.` + : `Scan abgeschlossen! ${scanned} Dateien gescannt.`; + cancelBtn.style.display = 'none'; + + const closeBtn = buttonContainer.createEl('button', { text: 'Schließen', cls: 'mod-cta' }); + closeBtn.addEventListener('click', () => { + this.close(); + this.resolve(true); + }); + }); + } + + onClose(): void { + this.cancelled = true; + this.contentEl.empty(); + this.resolve(false); + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} From 64d43dfab2aa52c5b3508a4f13cd096a61c52be7 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 10:50:55 +0100 Subject: [PATCH 4/4] Event-System in main.ts verdrahtet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventBus, SessionManager, ContentAnalyzer, alle 5 Collectors und CM6-Extension integriert. Tracking-Lifecycle mit Pause/Resume, Initial-Scan bei fehlendem Baseline, Wartung beim Start. Kommandos für Toggle, Rescan, Wartung und Debug-Info. Co-Authored-By: Claude Opus 4.6 --- src/main.ts | 211 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index 15cb9e2..ceb0da5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,28 +1,122 @@ -import { Plugin, FileSystemAdapter, TFile } from 'obsidian'; +import { Plugin, FileSystemAdapter, TFile, MarkdownView, Notice } from 'obsidian'; import { LogfireSettings, DEFAULT_SETTINGS, deepMerge } from './types'; import { DatabaseManager } from './core/database'; +import { EventBus } from './core/event-bus'; +import { SessionManager } from './core/session-manager'; +import { ContentAnalyzer } from './core/content-analyzer'; +import { FileCollector } from './collectors/file-collector'; +import { ContentCollector } from './collectors/content-collector'; +import { NavCollector } from './collectors/nav-collector'; +import { EditorCollector } from './collectors/editor-collector'; +import { SystemCollector } from './collectors/system-collector'; import { LogfireSettingTab } from './ui/settings-tab'; +import { InitialScanModal } from './ui/initial-scan-modal'; export default class LogfirePlugin extends Plugin { settings!: LogfireSettings; db!: DatabaseManager; + eventBus!: EventBus; + sessionManager!: SessionManager; + contentAnalyzer!: ContentAnalyzer; + + private fileCollector!: FileCollector; + private contentCollector!: ContentCollector; + private navCollector!: NavCollector; + private editorCollector!: EditorCollector; + private systemCollector!: SystemCollector; + + private paused = false; async onload(): Promise { console.log('[Logfire] Lade Plugin...'); await this.loadSettings(); + // Core infrastructure const dbPath = this.getDatabasePath(); this.db = new DatabaseManager(dbPath, this.settings); + this.eventBus = new EventBus( + this.db, + this.settings.advanced.flushIntervalMs, + this.settings.advanced.flushThreshold, + ); + + const vaultName = this.app.vault.getName(); + this.sessionManager = new SessionManager(this.eventBus, this.db, vaultName); + + // Content analyzer + this.contentAnalyzer = new ContentAnalyzer(); + if (this.db.hasBaseline()) { + this.contentAnalyzer.loadBaseline(this.db.loadBaseline()); + } + + // Collectors + const shouldTrack = (path: string) => this.shouldTrack(path); + + this.fileCollector = new FileCollector( + this.app, this.eventBus, this.sessionManager, this.settings, shouldTrack, + ); + this.contentCollector = new ContentCollector( + this.app, this.eventBus, this.sessionManager, this.settings, this.contentAnalyzer, shouldTrack, + ); + this.navCollector = new NavCollector( + this.app, this.eventBus, this.sessionManager, this.settings, shouldTrack, + ); + this.editorCollector = new EditorCollector( + this.eventBus, this.sessionManager, this.settings, shouldTrack, + () => this.getActiveFilePath(), + ); + this.systemCollector = new SystemCollector( + this.app, this.eventBus, this.sessionManager, this.settings, + ); + + // Register CM6 editor extension + this.registerEditorExtension(this.editorCollector.createExtension()); + + // UI: Settings tab this.addSettingTab(new LogfireSettingTab(this.app, this)); - console.log('[Logfire] Plugin geladen.'); + // Commands + this.registerCommands(); + + // Start tracking + if (!this.settings.general.pauseOnStartup) { + this.startTracking(); + } else { + this.paused = true; + } + + // Initial scan + maintenance on startup (after layout ready) + this.app.workspace.onLayoutReady(() => { + if (!this.db.hasBaseline()) { + this.runInitialScan(); + } + if (this.settings.advanced.retention.maintenanceOnStartup) { + try { + this.db.runMaintenance(this.settings.advanced.retention); + } catch (err) { + console.error('[Logfire] Wartung beim Start fehlgeschlagen:', err); + } + } + }); + + console.log('[Logfire] Plugin geladen. Session:', this.sessionManager.currentSessionId); } async onunload(): Promise { console.log('[Logfire] Entlade Plugin...'); + this.stopTracking(); + + if (this.sessionManager) { + this.sessionManager.endSession(); + } + + if (this.eventBus) { + this.eventBus.destroy(); + } + if (this.db) { this.db.close(); } @@ -31,10 +125,65 @@ export default class LogfirePlugin extends Plugin { } // --------------------------------------------------------------------------- - // Track filtering (used by collectors in later features) + // Tracking lifecycle + // --------------------------------------------------------------------------- + + private startTracking(): void { + this.sessionManager.startSession(); + this.fileCollector.register(); + this.contentCollector.register(); + this.navCollector.register(); + this.systemCollector.register(); + } + + private stopTracking(): void { + this.fileCollector?.unregister(); + this.contentCollector?.unregister(); + this.navCollector?.unregister(); + this.systemCollector?.unregister(); + } + + isPaused(): boolean { + return this.paused; + } + + pause(): void { + if (!this.paused) { + this.paused = true; + this.stopTracking(); + } + } + + resume(): void { + if (this.paused) { + this.paused = false; + this.startTracking(); + } + } + + // --------------------------------------------------------------------------- + // Initial scan + // --------------------------------------------------------------------------- + + private async runInitialScan(): Promise { + const modal = new InitialScanModal( + this.app, + this.settings, + this.contentAnalyzer, + this.db, + this.eventBus, + this.sessionManager, + (path) => this.shouldTrack(path), + ); + await modal.open(); + } + + // --------------------------------------------------------------------------- + // Track filtering // --------------------------------------------------------------------------- shouldTrack(path: string): boolean { + if (this.paused) return false; if (!this.settings.general.enabled) return false; const logFolder = this.settings.general.logFolder; @@ -58,6 +207,62 @@ export default class LogfirePlugin extends Plugin { return true; } + private getActiveFilePath(): string | null { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + return view?.file?.path ?? null; + } + + // --------------------------------------------------------------------------- + // Commands + // --------------------------------------------------------------------------- + + private registerCommands(): void { + this.addCommand({ + id: 'toggle-tracking', + name: 'Tracking pausieren/fortsetzen', + callback: () => { + if (this.paused) { + this.resume(); + new Notice('Logfire: Tracking fortgesetzt.'); + } else { + this.pause(); + new Notice('Logfire: Tracking pausiert.'); + } + }, + }); + + this.addCommand({ + id: 'rescan-vault', + name: 'Vault erneut scannen', + callback: () => this.runInitialScan(), + }); + + this.addCommand({ + id: 'run-maintenance', + name: 'Wartung ausführen', + callback: () => { + this.db.runMaintenance(this.settings.advanced.retention); + new Notice('Logfire: Wartung abgeschlossen.'); + }, + }); + + this.addCommand({ + id: 'debug-info', + name: 'Debug-Info', + callback: () => { + console.log('[Logfire] Debug-Info:', { + paused: this.paused, + sessionId: this.sessionManager.currentSessionId, + sessionDuration: this.sessionManager.sessionDurationMs, + bufferSize: this.eventBus.getBufferSize(), + eventCount: this.db.getEventCount(), + hasBaseline: this.db.hasBaseline(), + dbSize: this.db.getDatabaseSizeBytes(), + }); + }, + }); + } + // --------------------------------------------------------------------------- // Settings // ---------------------------------------------------------------------------