/** * Merge Logic for collaborative screenplay editing * Handles complex merge scenarios and screenplay-specific rules */ import { Doc, Text } from 'yjs'; import { DocumentChange } from './change-tracker'; export type MergeStrategy = 'accept-local' | 'accept-remote' | 'manual' | 'auto-merge'; export interface MergeResult { success: boolean; strategy: MergeStrategy; conflicts: Conflict[]; appliedChanges: DocumentChange[]; } export interface Conflict { id: string; type: 'concurrent-edit' | 'format-conflict' | 'structure-conflict'; localChange: DocumentChange; remoteChange: DocumentChange; resolution?: Resolution; } export interface Resolution { strategy: MergeStrategy; result: 'local' | 'remote' | 'merged'; resolvedAt: Date; resolvedBy: string; } export interface ServerChange { id: string; userId: string; timestamp: Date; type: 'insert' | 'delete' | 'format'; position: number; content?: string; length: number; } export class MergeLogic { private doc: Doc; private userId: string; private pendingConflicts: Conflict[] = []; constructor(doc: Doc, userId: string) { this.doc = doc; this.userId = userId; } /** * Apply a server change to the local document */ applyServerChange(change: ServerChange): MergeResult { const conflicts: Conflict[] = []; const appliedChanges: DocumentChange[] = []; try { this.doc.transact(() => { const text = this.doc.getText('main'); // Check for conflicts with local changes const hasConflict = this.detectConflict(change); if (hasConflict) { const localChange = this.getLastLocalChange(); if (!localChange) { // No local change to conflict with, apply remote change this.applyChange(text, change); return; } const conflict: Conflict = { id: this.generateConflictId(), type: 'concurrent-edit', localChange, remoteChange: this.convertServerToChange(change), }; conflicts.push(conflict); this.pendingConflicts.push(conflict); // Auto-resolve simple conflicts const resolution = this.autoResolveConflict(conflict); if (resolution) { conflict.resolution = resolution; if (resolution.result === 'local') { // Keep local change, ignore remote return; } else if (resolution.result === 'remote') { // Apply remote change this.applyChange(text, change); } else { // Merged - apply both this.applyChange(text, change); } } } else { // No conflict, apply change directly this.applyChange(text, change); } }, 'server-change'); return { success: conflicts.length === 0, strategy: conflicts.length > 0 ? 'auto-merge' : 'accept-remote', conflicts, appliedChanges, }; } catch (error) { console.error('Failed to apply server change:', error); return { success: false, strategy: 'manual', conflicts, appliedChanges, }; } } /** * Apply a change to the text document */ private applyChange(text: Text, change: ServerChange): void { switch (change.type) { case 'insert': if (change.content) { text.insert(change.position, change.content); } break; case 'delete': text.delete(change.position, change.length); break; case 'format': // Format changes would be handled separately break; } } /** * Detect if a server change conflicts with local changes */ private detectConflict(change: ServerChange): boolean { // Simplified conflict detection // In production, would check against pending local changes const lastChange = this.getLastLocalChange(); if (!lastChange) { return false; } // Check if positions overlap const positionOverlap = change.position >= lastChange.position && change.position < lastChange.position + lastChange.length; return positionOverlap; } /** * Get the last local change */ private getLastLocalChange(): DocumentChange | null { // In production, would retrieve from ChangeTracker return null; } /** * Convert server change to DocumentChange format */ private convertServerToChange(serverChange: ServerChange): DocumentChange { return { id: serverChange.id, userId: serverChange.userId, userName: 'Remote User', timestamp: serverChange.timestamp, type: serverChange.type, position: serverChange.position, length: serverChange.length, content: serverChange.content, accepted: true, }; } /** * Auto-resolve simple conflicts */ private autoResolveConflict(conflict: Conflict): Resolution | null { // Auto-resolve non-overlapping edits if (conflict.type === 'concurrent-edit') { const local = conflict.localChange; const remote = conflict.remoteChange; // If edits are far apart, no conflict const distance = Math.abs(local.position - remote.position); if (distance > 10) { return { strategy: 'auto-merge', result: 'merged', resolvedAt: new Date(), resolvedBy: 'auto', }; } // If same user made both changes, accept remote if (local.userId === remote.userId) { return { strategy: 'accept-remote', result: 'remote', resolvedAt: new Date(), resolvedBy: 'auto', }; } } // Can't auto-resolve, needs manual intervention return null; } /** * Handle concurrent edits to the same region */ handleConcurrentEdit(localChange: DocumentChange, remoteChange: DocumentChange): MergeStrategy { // Screenplay-specific merge rules // Rule 1: If both changes are in the same scene, prefer structured edits if (this.sameScene(localChange, remoteChange)) { // If one is formatting and one is content, accept both if (localChange.type !== remoteChange.type) { return 'auto-merge'; } // If both are content edits, need manual resolution return 'manual'; } // Rule 2: If changes are in different scenes, auto-merge return 'auto-merge'; } /** * Check if two changes are in the same scene */ private sameScene(change1: DocumentChange, change2: DocumentChange): boolean { // In production, would check scene boundaries in the document // For now, assume changes within 500 chars are in the same scene return Math.abs(change1.position - change2.position) < 500; } /** * Resolve a conflict manually */ resolveConflict(conflict: Conflict, strategy: MergeStrategy, resolverId: string): boolean { const resolution: Resolution = { strategy, result: strategy === 'accept-local' ? 'local' : strategy === 'accept-remote' ? 'remote' : 'merged', resolvedAt: new Date(), resolvedBy: resolverId, }; conflict.resolution = resolution; // Remove from pending conflicts const index = this.pendingConflicts.indexOf(conflict); if (index > -1) { this.pendingConflicts.splice(index, 1); } // Apply resolution if (resolution.result === 'remote') { const text = this.doc.getText('main'); this.applyChange(text, { id: conflict.remoteChange.id, userId: conflict.remoteChange.userId, timestamp: conflict.remoteChange.timestamp, type: conflict.remoteChange.type as 'insert' | 'delete' | 'format', position: conflict.remoteChange.position, length: conflict.remoteChange.length, content: conflict.remoteChange.content, }); } return true; } /** * Validate a merge result */ validateMerge(result: MergeResult): boolean { // Check document integrity try { const text = this.doc.getText('main'); const content = text.toString(); // Basic validation: document should not be empty if (content.length === 0) { return false; } // Check for corrupted UTF-8 sequences try { new TextDecoder().decode(new TextEncoder().encode(content)); return true; } catch { return false; } } catch { return false; } } /** * Get pending conflicts */ getPendingConflicts(): Conflict[] { return [...this.pendingConflicts]; } /** * Generate unique conflict ID */ private generateConflictId(): string { return `conflict-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } }