diff --git a/src/core/content-analyzer.ts b/src/core/content-analyzer.ts new file mode 100644 index 0000000..a497e87 --- /dev/null +++ b/src/core/content-analyzer.ts @@ -0,0 +1,171 @@ +import { CachedMetadata } from 'obsidian'; +import { ContentSnapshot, ContentDelta } from '../types'; + +export class ContentAnalyzer { + private cache = new Map(); + + loadBaseline(baseline: Map): void { + this.cache = new Map(baseline); + } + + getSnapshot(path: string): ContentSnapshot | undefined { + return this.cache.get(path); + } + + removeSnapshot(path: string): void { + this.cache.delete(path); + } + + renameSnapshot(oldPath: string, newPath: string): void { + const snap = this.cache.get(oldPath); + if (snap) { + this.cache.delete(oldPath); + snap.path = newPath; + this.cache.set(newPath, snap); + } + } + + buildSnapshot(path: string, content: string, metadata: CachedMetadata | null): ContentSnapshot { + const snapshot: ContentSnapshot = { + path, + wordCount: countWords(content), + charCount: content.length, + links: extractLinks(metadata), + tags: extractTags(metadata), + headings: extractHeadings(metadata), + frontmatter: extractFrontmatter(metadata), + embeds: extractEmbeds(metadata), + }; + this.cache.set(path, snapshot); + return snapshot; + } + + analyze(path: string, content: string, metadata: CachedMetadata | null): ContentDelta | null { + const prev = this.cache.get(path); + const next = this.buildSnapshot(path, content, metadata); + + if (!prev) return null; + + const wordsAdded = Math.max(0, next.wordCount - prev.wordCount); + const wordsRemoved = Math.max(0, prev.wordCount - next.wordCount); + + const linksAdded = diff(next.links, prev.links); + const linksRemoved = diff(prev.links, next.links); + + const tagsAdded = diff(next.tags, prev.tags); + const tagsRemoved = diff(prev.tags, next.tags); + + const headingsAdded = diffHeadings(next.headings, prev.headings); + const headingsRemoved = diffHeadings(prev.headings, next.headings); + + const embedsAdded = diff(next.embeds, prev.embeds); + const embedsRemoved = diff(prev.embeds, next.embeds); + + const { changed: frontmatterChanged, changes: frontmatterChanges } = + diffFrontmatter(prev.frontmatter, next.frontmatter); + + return { + wordsAdded, + wordsRemoved, + linksAdded, + linksRemoved, + tagsAdded, + tagsRemoved, + headingsAdded, + headingsRemoved, + embedsAdded, + embedsRemoved, + frontmatterChanged, + frontmatterChanges, + }; + } +} + +// --------------------------------------------------------------------------- +// Extraction helpers +// --------------------------------------------------------------------------- + +function countWords(content: string): number { + const stripped = content.replace(/^---[\s\S]*?---\n?/, ''); + const words = stripped.split(/\s+/).filter(w => w.length > 0); + return words.length; +} + +function extractLinks(meta: CachedMetadata | null): string[] { + if (!meta?.links) return []; + return meta.links.map(l => l.link); +} + +function extractTags(meta: CachedMetadata | null): string[] { + const tags: string[] = []; + if (meta?.tags) { + for (const t of meta.tags) { + tags.push(t.tag); + } + } + if (meta?.frontmatter?.tags) { + const fmTags = meta.frontmatter.tags; + if (Array.isArray(fmTags)) { + for (const t of fmTags) { + const normalized = typeof t === 'string' && t.startsWith('#') ? t : `#${t}`; + if (!tags.includes(normalized)) tags.push(normalized); + } + } + } + return tags; +} + +function extractHeadings(meta: CachedMetadata | null): { level: number; text: string }[] { + if (!meta?.headings) return []; + return meta.headings.map(h => ({ level: h.level, text: h.heading })); +} + +function extractFrontmatter(meta: CachedMetadata | null): Record { + if (!meta?.frontmatter) return {}; + const fm: Record = {}; + for (const [key, value] of Object.entries(meta.frontmatter)) { + if (key === 'position') continue; + fm[key] = value; + } + return fm; +} + +function extractEmbeds(meta: CachedMetadata | null): string[] { + if (!meta?.embeds) return []; + return meta.embeds.map(e => e.link); +} + +// --------------------------------------------------------------------------- +// Diff helpers +// --------------------------------------------------------------------------- + +function diff(a: string[], b: string[]): string[] { + const bSet = new Set(b); + return a.filter(x => !bSet.has(x)); +} + +function diffHeadings( + a: { level: number; text: string }[], + b: { level: number; text: string }[], +): { level: number; text: string }[] { + const bKeys = new Set(b.map(h => `${h.level}:${h.text}`)); + return a.filter(h => !bKeys.has(`${h.level}:${h.text}`)); +} + +function diffFrontmatter( + prev: Record, + next: Record, +): { changed: boolean; changes: Record } { + const changes: Record = {}; + + const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]); + for (const key of allKeys) { + const oldVal = prev[key]; + const newVal = next[key]; + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + changes[key] = { old: oldVal, new: newVal }; + } + } + + return { changed: Object.keys(changes).length > 0, changes }; +} diff --git a/src/core/event-bus.ts b/src/core/event-bus.ts new file mode 100644 index 0000000..595655f --- /dev/null +++ b/src/core/event-bus.ts @@ -0,0 +1,100 @@ +import { LogfireEvent, EventType } from '../types'; +import { DatabaseManager } from './database'; + +export class EventBus { + private buffer: LogfireEvent[] = []; + private subscribers = new Map void>>(); + private flushTimer: ReturnType | null = null; + + constructor( + private db: DatabaseManager, + private flushIntervalMs: number, + private flushThreshold: number, + ) { + this.startFlushCycle(); + } + + // --------------------------------------------------------------------------- + // Emit & subscribe + // --------------------------------------------------------------------------- + + emit(event: LogfireEvent): void { + this.buffer.push(event); + this.notifySubscribers(event); + + if (this.buffer.length >= this.flushThreshold) { + this.flush(); + } + } + + onEvent(type: EventType | '*', callback: (event: LogfireEvent) => void): () => void { + if (!this.subscribers.has(type)) { + this.subscribers.set(type, new Set()); + } + this.subscribers.get(type)!.add(callback); + + return () => { + this.subscribers.get(type)?.delete(callback); + }; + } + + // --------------------------------------------------------------------------- + // Flush + // --------------------------------------------------------------------------- + + flush(): void { + if (this.buffer.length === 0) return; + + const batch = this.buffer.splice(0); + try { + this.db.insertEvents(batch); + } catch (err) { + console.error('[Logfire] Flush fehlgeschlagen:', err); + this.buffer.unshift(...batch); + } + } + + getBufferSize(): number { + return this.buffer.length; + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + destroy(): void { + if (this.flushTimer !== null) { + clearInterval(this.flushTimer); + this.flushTimer = null; + } + this.flush(); + this.subscribers.clear(); + } + + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + private startFlushCycle(): void { + this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs); + } + + private notifySubscribers(event: LogfireEvent): void { + this.notifySet(this.subscribers.get(event.type), event); + this.notifySet(this.subscribers.get('*'), event); + } + + private notifySet( + subs: Set<(event: LogfireEvent) => void> | undefined, + event: LogfireEvent, + ): void { + if (!subs) return; + for (const cb of subs) { + try { + cb(event); + } catch (err) { + console.error('[Logfire] Subscriber-Fehler:', err); + } + } + } +} diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts new file mode 100644 index 0000000..1f78cd0 --- /dev/null +++ b/src/core/session-manager.ts @@ -0,0 +1,60 @@ +import { LogfireEvent, categoryForType } from '../types'; +import { EventBus } from './event-bus'; +import { DatabaseManager } from './database'; + +export class SessionManager { + private _sessionId = ''; + private _startTime = 0; + + get currentSessionId(): string { + return this._sessionId; + } + + get sessionDurationMs(): number { + if (this._startTime === 0) return 0; + return Date.now() - this._startTime; + } + + constructor( + private eventBus: EventBus, + private db: DatabaseManager, + private vaultName: string, + ) {} + + startSession(): void { + this._sessionId = crypto.randomUUID(); + this._startTime = Date.now(); + + this.db.startSession(this._sessionId, this.vaultName); + + this.eventBus.emit(this.createEvent('system:session-start', { + vaultName: this.vaultName, + })); + } + + endSession(): void { + if (!this._sessionId) return; + + this.eventBus.emit(this.createEvent('system:session-end', { + vaultName: this.vaultName, + duration: this.sessionDurationMs, + })); + + this.db.endSession(this._sessionId); + } + + private createEvent( + type: 'system:session-start' | 'system:session-end', + payload: Record, + ): LogfireEvent { + return { + id: crypto.randomUUID(), + timestamp: Date.now(), + type, + category: categoryForType(type), + source: this.vaultName, + payload, + session: this._sessionId, + }; + } +}