Merge feature/event-system: Event-System & Collectors
EventBus mit Buffer/Flush, SessionManager, ContentAnalyzer, 5 Collectors (File, Content, Nav, Editor, System), Initial-Scan-Modal, Pause/Resume, Kommandos. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
afc8c8281d
10 changed files with 1224 additions and 3 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/core/content-analyzer.ts
Normal file
171
src/core/content-analyzer.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { CachedMetadata } from 'obsidian';
|
||||||
|
import { ContentSnapshot, ContentDelta } from '../types';
|
||||||
|
|
||||||
|
export class ContentAnalyzer {
|
||||||
|
private cache = new Map<string, ContentSnapshot>();
|
||||||
|
|
||||||
|
loadBaseline(baseline: Map<string, ContentSnapshot>): 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<string, unknown> {
|
||||||
|
if (!meta?.frontmatter) return {};
|
||||||
|
const fm: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>,
|
||||||
|
next: Record<string, unknown>,
|
||||||
|
): { changed: boolean; changes: Record<string, { old: unknown; new: unknown }> } {
|
||||||
|
const changes: Record<string, { old: unknown; new: unknown }> = {};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
100
src/core/event-bus.ts
Normal file
100
src/core/event-bus.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { LogfireEvent, EventType } from '../types';
|
||||||
|
import { DatabaseManager } from './database';
|
||||||
|
|
||||||
|
export class EventBus {
|
||||||
|
private buffer: LogfireEvent[] = [];
|
||||||
|
private subscribers = new Map<string, Set<(event: LogfireEvent) => void>>();
|
||||||
|
private flushTimer: ReturnType<typeof setInterval> | 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/core/session-manager.ts
Normal file
60
src/core/session-manager.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||||
|
): LogfireEvent {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type,
|
||||||
|
category: categoryForType(type),
|
||||||
|
source: this.vaultName,
|
||||||
|
payload,
|
||||||
|
session: this._sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/main.ts
211
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 { LogfireSettings, DEFAULT_SETTINGS, deepMerge } from './types';
|
||||||
import { DatabaseManager } from './core/database';
|
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 { LogfireSettingTab } from './ui/settings-tab';
|
||||||
|
import { InitialScanModal } from './ui/initial-scan-modal';
|
||||||
|
|
||||||
export default class LogfirePlugin extends Plugin {
|
export default class LogfirePlugin extends Plugin {
|
||||||
settings!: LogfireSettings;
|
settings!: LogfireSettings;
|
||||||
db!: DatabaseManager;
|
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<void> {
|
async onload(): Promise<void> {
|
||||||
console.log('[Logfire] Lade Plugin...');
|
console.log('[Logfire] Lade Plugin...');
|
||||||
|
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
|
|
||||||
|
// Core infrastructure
|
||||||
const dbPath = this.getDatabasePath();
|
const dbPath = this.getDatabasePath();
|
||||||
this.db = new DatabaseManager(dbPath, this.settings);
|
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));
|
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<void> {
|
async onunload(): Promise<void> {
|
||||||
console.log('[Logfire] Entlade Plugin...');
|
console.log('[Logfire] Entlade Plugin...');
|
||||||
|
|
||||||
|
this.stopTracking();
|
||||||
|
|
||||||
|
if (this.sessionManager) {
|
||||||
|
this.sessionManager.endSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.eventBus) {
|
||||||
|
this.eventBus.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.db) {
|
if (this.db) {
|
||||||
this.db.close();
|
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<void> {
|
||||||
|
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 {
|
shouldTrack(path: string): boolean {
|
||||||
|
if (this.paused) return false;
|
||||||
if (!this.settings.general.enabled) return false;
|
if (!this.settings.general.enabled) return false;
|
||||||
|
|
||||||
const logFolder = this.settings.general.logFolder;
|
const logFolder = this.settings.general.logFolder;
|
||||||
|
|
@ -58,6 +207,62 @@ export default class LogfirePlugin extends Plugin {
|
||||||
return true;
|
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
|
// Settings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
153
src/ui/initial-scan-modal.ts
Normal file
153
src/ui/initial-scan-modal.ts
Normal file
|
|
@ -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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue