/** * Change Tracker for collaborative screenplay editing * Records all changes with metadata and supports version history */ import { Doc, UndoManager, Transaction, encodeStateAsUpdate, applyUpdate } from 'yjs'; export type ChangeType = 'insert' | 'delete' | 'format' | 'move'; export interface DocumentChange { id: string; userId: string; userName: string; timestamp: Date; type: ChangeType; position: number; length: number; content?: string; accepted: boolean; metadata?: Record; } export interface Snapshot { id: string; timestamp: Date; userId: string; userName: string; description?: string; state: Uint8Array; changes: DocumentChange[]; } export interface ChangeDiff { additions: number; deletions: number; changes: DocumentChange[]; } export class ChangeTracker { private doc: Doc; private changes: DocumentChange[] = []; private snapshots: Snapshot[] = []; private changeListeners: Set<(change: DocumentChange) => void> = new Set(); private userId: string; private userName: string; private currentTransaction: Transaction | null = null; constructor(doc: Doc, userId: string, userName: string) { this.doc = doc; this.userId = userId; this.userName = userName; // Listen to document updates this.doc.on('update', (update, origin) => { if (origin !== 'snapshot-restore') { this.recordTransaction(update, origin); } }); } /** * Record a change from a transaction */ private recordTransaction(update: Uint8Array, origin: any): void { const change: DocumentChange = { id: this.generateChangeId(), userId: this.userId, userName: this.userName, timestamp: new Date(), type: this.detectChangeType(update), position: 0, // Would need to calculate from update length: update.length, accepted: true, metadata: { origin, updateSize: update.length, }, }; this.changes.push(change); this.changeListeners.forEach(listener => listener(change)); } /** * Detect the type of change from the update */ private detectChangeType(update: Uint8Array): ChangeType { // Simplified detection - in production would parse Yjs update format if (update.length > 100) { return 'insert'; } else if (update.length < 10) { return 'format'; } return 'insert'; } /** * Generate unique change ID */ private generateChangeId(): string { return `change-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Record a manual change */ recordChange(change: Omit): void { const fullChange: DocumentChange = { ...change, id: this.generateChangeId(), userId: this.userId, userName: this.userName, timestamp: new Date(), accepted: true, }; this.changes.push(fullChange); this.changeListeners.forEach(listener => listener(fullChange)); } /** * Get changes within a range */ getChangesInRange(start: number, end: number): DocumentChange[] { return this.changes.filter((change, index) => { return index >= start && index < end; }); } /** * Get all changes */ getAllChanges(): DocumentChange[] { return [...this.changes]; } /** * Accept a change */ acceptChange(changeId: string): void { const change = this.changes.find(c => c.id === changeId); if (change) { change.accepted = true; } } /** * Reject a change */ rejectChange(changeId: string): void { const change = this.changes.find(c => c.id === changeId); if (change) { change.accepted = false; // In production, would revert the change } } /** * Create a snapshot of the current document state */ createSnapshot(description?: string): Snapshot { const state = encodeStateAsUpdate(this.doc); const snapshot: Snapshot = { id: this.generateSnapshotId(), timestamp: new Date(), userId: this.userId, userName: this.userName, description, state, changes: [...this.changes], }; this.snapshots.push(snapshot); return snapshot; } /** * Generate unique snapshot ID */ private generateSnapshotId(): string { return `snapshot-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Restore a snapshot */ restoreSnapshot(snapshot: Snapshot): void { // Apply the snapshot state to the document this.doc.transact(() => { applyUpdate(this.doc, snapshot.state, 'snapshot-restore'); }, 'snapshot-restore'); } /** * Get all snapshots */ getSnapshots(): Snapshot[] { return [...this.snapshots]; } /** * Generate diff between two snapshots */ generateDiff(snapshot1: Snapshot, snapshot2: Snapshot): ChangeDiff { // In production, would use Yjs diffing algorithm const changes = snapshot2.changes.filter( change => change.timestamp > snapshot1.timestamp ); const additions = changes.filter(c => c.type === 'insert').length; const deletions = changes.filter(c => c.type === 'delete').length; return { additions, deletions, changes, }; } /** * Listen for new changes */ onChange(callback: (change: DocumentChange) => void): void { this.changeListeners.add(callback); } /** * Remove change listener */ removeChangeListener(callback: (change: DocumentChange) => void): void { this.changeListeners.delete(callback); } /** * Get change statistics */ getStats(): { totalChanges: number; totalSnapshots: number; lastChangeAt: Date | null } { const lastChange = this.changes[this.changes.length - 1]; return { totalChanges: this.changes.length, totalSnapshots: this.snapshots.length, lastChangeAt: lastChange?.timestamp ?? null, }; } }