Alle Event-Collectors implementiert
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 <noreply@anthropic.com>
This commit is contained in:
parent
4e27ec2a90
commit
e0d9f301d6
5 changed files with 532 additions and 0 deletions
118
src/collectors/content-collector.ts
Normal file
118
src/collectors/content-collector.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string, unknown>): 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';
|
||||
}
|
||||
108
src/collectors/editor-collector.ts
Normal file
108
src/collectors/editor-collector.ts
Normal file
|
|
@ -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<typeof setTimeout> | 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<string, unknown>): void {
|
||||
this.eventBus.emit({
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
type: 'editor:change',
|
||||
category: categoryForType('editor:change'),
|
||||
source,
|
||||
payload,
|
||||
session: this.sessionManager.currentSessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
129
src/collectors/file-collector.ts
Normal file
129
src/collectors/file-collector.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
89
src/collectors/nav-collector.ts
Normal file
89
src/collectors/nav-collector.ts
Normal file
|
|
@ -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<string, unknown>): void {
|
||||
this.eventBus.emit({
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
type,
|
||||
category: categoryForType(type),
|
||||
source,
|
||||
payload,
|
||||
session: this.sessionManager.currentSessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
88
src/collectors/system-collector.ts
Normal file
88
src/collectors/system-collector.ts
Normal file
|
|
@ -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<string, Command>;
|
||||
}
|
||||
|
||||
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<string, unknown>): void {
|
||||
this.eventBus.emit({
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
type,
|
||||
category: categoryForType(type),
|
||||
source,
|
||||
payload,
|
||||
session: this.sessionManager.currentSessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue