Event-Bus, Session-Manager und Content-Analyzer
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 <noreply@anthropic.com>
This commit is contained in:
parent
36b25b321b
commit
4e27ec2a90
3 changed files with 331 additions and 0 deletions
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue