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, + }); + } +}