""" Undo/Redo Functionality Manager This module provides undo/redo capabilities for operations in the AniWorld web interface, including operation history, rollback functionality, and state management. """ import json import time from typing import Dict, List, Any, Optional, Callable from datetime import datetime, timedelta from flask import Blueprint, request, jsonify, session from dataclasses import dataclass, asdict from enum import Enum import threading import copy class OperationType(Enum): """Types of operations that can be undone/redone.""" DOWNLOAD = "download" DELETE = "delete" UPDATE = "update" ORGANIZE = "organize" RENAME = "rename" MOVE = "move" SETTINGS_CHANGE = "settings_change" BULK_OPERATION = "bulk_operation" SEARCH_FILTER = "search_filter" USER_PREFERENCE = "user_preference" @dataclass class UndoableOperation: """Represents an operation that can be undone/redone.""" id: str type: OperationType description: str timestamp: float user_session: str forward_action: Dict[str, Any] # Data needed to perform the operation backward_action: Dict[str, Any] # Data needed to undo the operation affected_items: List[str] # IDs of affected series/episodes success: bool = False error: Optional[str] = None metadata: Optional[Dict[str, Any]] = None class UndoRedoManager: """Manages undo/redo operations for the application.""" def __init__(self, app=None): self.app = app self.operation_history: Dict[str, List[UndoableOperation]] = {} # per session self.redo_stack: Dict[str, List[UndoableOperation]] = {} # per session self.max_history_size = 50 self.operation_timeout = 3600 # 1 hour self.operation_handlers = {} self.lock = threading.Lock() # Register default operation handlers self._register_default_handlers() def init_app(self, app): """Initialize with Flask app.""" self.app = app def _register_default_handlers(self): """Register default operation handlers.""" self.operation_handlers = { OperationType.DOWNLOAD: { 'undo': self._undo_download, 'redo': self._redo_download }, OperationType.DELETE: { 'undo': self._undo_delete, 'redo': self._redo_delete }, OperationType.UPDATE: { 'undo': self._undo_update, 'redo': self._redo_update }, OperationType.ORGANIZE: { 'undo': self._undo_organize, 'redo': self._redo_organize }, OperationType.RENAME: { 'undo': self._undo_rename, 'redo': self._redo_rename }, OperationType.MOVE: { 'undo': self._undo_move, 'redo': self._redo_move }, OperationType.SETTINGS_CHANGE: { 'undo': self._undo_settings_change, 'redo': self._redo_settings_change }, OperationType.BULK_OPERATION: { 'undo': self._undo_bulk_operation, 'redo': self._redo_bulk_operation }, OperationType.USER_PREFERENCE: { 'undo': self._undo_user_preference, 'redo': self._redo_user_preference } } def get_session_id(self) -> str: """Get current session ID.""" return session.get('session_id', 'default') def record_operation(self, operation: UndoableOperation) -> str: """Record an operation for potential undo.""" with self.lock: session_id = operation.user_session # Initialize session history if needed if session_id not in self.operation_history: self.operation_history[session_id] = [] self.redo_stack[session_id] = [] # Add to history self.operation_history[session_id].append(operation) # Clear redo stack when new operation is performed self.redo_stack[session_id].clear() # Limit history size if len(self.operation_history[session_id]) > self.max_history_size: self.operation_history[session_id].pop(0) # Clean up old operations self._cleanup_old_operations(session_id) return operation.id def can_undo(self, session_id: Optional[str] = None) -> bool: """Check if undo is possible.""" session_id = session_id or self.get_session_id() return (session_id in self.operation_history and len(self.operation_history[session_id]) > 0) def can_redo(self, session_id: Optional[str] = None) -> bool: """Check if redo is possible.""" session_id = session_id or self.get_session_id() return (session_id in self.redo_stack and len(self.redo_stack[session_id]) > 0) def get_last_operation(self, session_id: Optional[str] = None) -> Optional[UndoableOperation]: """Get the last operation that can be undone.""" session_id = session_id or self.get_session_id() if self.can_undo(session_id): return self.operation_history[session_id][-1] return None def get_next_redo_operation(self, session_id: Optional[str] = None) -> Optional[UndoableOperation]: """Get the next operation that can be redone.""" session_id = session_id or self.get_session_id() if self.can_redo(session_id): return self.redo_stack[session_id][-1] return None async def undo_last_operation(self, session_id: Optional[str] = None) -> Dict[str, Any]: """Undo the last operation.""" session_id = session_id or self.get_session_id() if not self.can_undo(session_id): return {'success': False, 'error': 'No operation to undo'} with self.lock: operation = self.operation_history[session_id].pop() try: # Execute undo operation handler = self.operation_handlers.get(operation.type, {}).get('undo') if not handler: return {'success': False, 'error': f'No undo handler for {operation.type}'} result = await handler(operation) if result.get('success', False): # Move to redo stack with self.lock: self.redo_stack[session_id].append(operation) return { 'success': True, 'operation': asdict(operation), 'message': f'Undid: {operation.description}' } else: # Put operation back if undo failed with self.lock: self.operation_history[session_id].append(operation) return {'success': False, 'error': result.get('error', 'Undo failed')} except Exception as e: # Put operation back on error with self.lock: self.operation_history[session_id].append(operation) return {'success': False, 'error': str(e)} async def redo_last_operation(self, session_id: Optional[str] = None) -> Dict[str, Any]: """Redo the last undone operation.""" session_id = session_id or self.get_session_id() if not self.can_redo(session_id): return {'success': False, 'error': 'No operation to redo'} with self.lock: operation = self.redo_stack[session_id].pop() try: # Execute redo operation handler = self.operation_handlers.get(operation.type, {}).get('redo') if not handler: return {'success': False, 'error': f'No redo handler for {operation.type}'} result = await handler(operation) if result.get('success', False): # Move back to history with self.lock: self.operation_history[session_id].append(operation) return { 'success': True, 'operation': asdict(operation), 'message': f'Redid: {operation.description}' } else: # Put operation back if redo failed with self.lock: self.redo_stack[session_id].append(operation) return {'success': False, 'error': result.get('error', 'Redo failed')} except Exception as e: # Put operation back on error with self.lock: self.redo_stack[session_id].append(operation) return {'success': False, 'error': str(e)} def get_operation_history(self, session_id: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]: """Get operation history for a session.""" session_id = session_id or self.get_session_id() if session_id not in self.operation_history: return [] history = self.operation_history[session_id][-limit:] return [asdict(op) for op in reversed(history)] def get_redo_history(self, session_id: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]: """Get redo stack for a session.""" session_id = session_id or self.get_session_id() if session_id not in self.redo_stack: return [] redo_stack = self.redo_stack[session_id][-limit:] return [asdict(op) for op in reversed(redo_stack)] def clear_history(self, session_id: Optional[str] = None): """Clear operation history for a session.""" session_id = session_id or self.get_session_id() with self.lock: if session_id in self.operation_history: self.operation_history[session_id].clear() if session_id in self.redo_stack: self.redo_stack[session_id].clear() def _cleanup_old_operations(self, session_id: str): """Clean up operations older than timeout.""" current_time = time.time() cutoff_time = current_time - self.operation_timeout # Clean history self.operation_history[session_id] = [ op for op in self.operation_history[session_id] if op.timestamp > cutoff_time ] # Clean redo stack self.redo_stack[session_id] = [ op for op in self.redo_stack[session_id] if op.timestamp > cutoff_time ] # Operation handlers async def _undo_download(self, operation: UndoableOperation) -> Dict[str, Any]: """Undo a download operation.""" try: # This would implement actual download cancellation/cleanup # For now, simulate the operation await self._simulate_operation_delay() return { 'success': True, 'message': f'Cancelled download for {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _redo_download(self, operation: UndoableOperation) -> Dict[str, Any]: """Redo a download operation.""" try: # This would implement actual download restart await self._simulate_operation_delay() return { 'success': True, 'message': f'Restarted download for {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _undo_delete(self, operation: UndoableOperation) -> Dict[str, Any]: """Undo a delete operation.""" try: # This would implement actual file/series restoration await self._simulate_operation_delay() return { 'success': True, 'message': f'Restored {len(operation.affected_items)} deleted items' } except Exception as e: return {'success': False, 'error': str(e)} async def _redo_delete(self, operation: UndoableOperation) -> Dict[str, Any]: """Redo a delete operation.""" try: # This would implement actual deletion again await self._simulate_operation_delay() return { 'success': True, 'message': f'Re-deleted {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _undo_update(self, operation: UndoableOperation) -> Dict[str, Any]: """Undo an update operation.""" try: # Restore previous metadata/information await self._simulate_operation_delay() return { 'success': True, 'message': f'Reverted updates for {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _redo_update(self, operation: UndoableOperation) -> Dict[str, Any]: """Redo an update operation.""" try: # Reapply updates await self._simulate_operation_delay() return { 'success': True, 'message': f'Reapplied updates for {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _undo_organize(self, operation: UndoableOperation) -> Dict[str, Any]: """Undo an organize operation.""" try: # Restore original organization original_paths = operation.backward_action.get('original_paths', {}) # This would implement actual file/folder restoration await self._simulate_operation_delay() return { 'success': True, 'message': f'Restored original organization for {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _redo_organize(self, operation: UndoableOperation) -> Dict[str, Any]: """Redo an organize operation.""" try: # Reapply organization await self._simulate_operation_delay() return { 'success': True, 'message': f'Reapplied organization for {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _undo_rename(self, operation: UndoableOperation) -> Dict[str, Any]: """Undo a rename operation.""" try: # Restore original names original_names = operation.backward_action.get('original_names', {}) await self._simulate_operation_delay() return { 'success': True, 'message': f'Restored original names for {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _redo_rename(self, operation: UndoableOperation) -> Dict[str, Any]: """Redo a rename operation.""" try: # Reapply renames await self._simulate_operation_delay() return { 'success': True, 'message': f'Reapplied renames for {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _undo_move(self, operation: UndoableOperation) -> Dict[str, Any]: """Undo a move operation.""" try: # Restore original locations original_locations = operation.backward_action.get('original_locations', {}) await self._simulate_operation_delay() return { 'success': True, 'message': f'Moved {len(operation.affected_items)} items back to original locations' } except Exception as e: return {'success': False, 'error': str(e)} async def _redo_move(self, operation: UndoableOperation) -> Dict[str, Any]: """Redo a move operation.""" try: # Reapply moves await self._simulate_operation_delay() return { 'success': True, 'message': f'Re-moved {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _undo_settings_change(self, operation: UndoableOperation) -> Dict[str, Any]: """Undo a settings change.""" try: # Restore previous settings previous_settings = operation.backward_action.get('previous_settings', {}) # This would implement actual settings restoration await self._simulate_operation_delay() return { 'success': True, 'message': 'Restored previous settings' } except Exception as e: return {'success': False, 'error': str(e)} async def _redo_settings_change(self, operation: UndoableOperation) -> Dict[str, Any]: """Redo a settings change.""" try: # Reapply settings await self._simulate_operation_delay() return { 'success': True, 'message': 'Reapplied settings changes' } except Exception as e: return {'success': False, 'error': str(e)} async def _undo_bulk_operation(self, operation: UndoableOperation) -> Dict[str, Any]: """Undo a bulk operation.""" try: # Undo each sub-operation sub_operations = operation.backward_action.get('sub_operations', []) await self._simulate_operation_delay() return { 'success': True, 'message': f'Undid bulk operation affecting {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _redo_bulk_operation(self, operation: UndoableOperation) -> Dict[str, Any]: """Redo a bulk operation.""" try: # Redo each sub-operation await self._simulate_operation_delay() return { 'success': True, 'message': f'Redid bulk operation affecting {len(operation.affected_items)} items' } except Exception as e: return {'success': False, 'error': str(e)} async def _undo_user_preference(self, operation: UndoableOperation) -> Dict[str, Any]: """Undo a user preference change.""" try: # Restore previous preference value previous_value = operation.backward_action.get('previous_value') preference_key = operation.backward_action.get('preference_key') # This would implement actual preference restoration await self._simulate_operation_delay() return { 'success': True, 'message': f'Restored previous value for {preference_key}' } except Exception as e: return {'success': False, 'error': str(e)} async def _redo_user_preference(self, operation: UndoableOperation) -> Dict[str, Any]: """Redo a user preference change.""" try: # Reapply preference change await self._simulate_operation_delay() return { 'success': True, 'message': 'Reapplied preference change' } except Exception as e: return {'success': False, 'error': str(e)} async def _simulate_operation_delay(self): """Simulate operation processing delay.""" import asyncio await asyncio.sleep(0.1) # Small delay to simulate work def get_undo_redo_js(self): """Generate JavaScript code for undo/redo functionality.""" return """ // AniWorld Undo/Redo Manager class UndoRedoManager { constructor() { this.isUndoing = false; this.isRedoing = false; this.historyVisible = false; this.init(); } init() { this.createUndoRedoInterface(); this.setupKeyboardShortcuts(); this.setupEventListeners(); this.updateButtonStates(); // Update states periodically setInterval(() => { this.updateButtonStates(); }, 1000); } createUndoRedoInterface() { this.createUndoRedoButtons(); this.createHistoryPanel(); } createUndoRedoButtons() { // Check if buttons already exist if (document.querySelector('.undo-redo-controls')) return; const controlsContainer = document.createElement('div'); controlsContainer.className = 'undo-redo-controls position-fixed'; controlsContainer.style.cssText = ` bottom: 20px; left: 20px; z-index: 1050; display: flex; gap: 0.5rem; `; controlsContainer.innerHTML = `
`; document.body.appendChild(controlsContainer); // Add status tooltips this.createStatusTooltips(); } createStatusTooltips() { const undoBtn = document.getElementById('undo-btn'); const redoBtn = document.getElementById('redo-btn'); // Dynamic tooltips that show operation info undoBtn.addEventListener('mouseenter', async () => { const lastOp = await this.getLastOperation(); if (lastOp) { undoBtn.title = `Undo: ${lastOp.description} (Ctrl+Z)`; } }); redoBtn.addEventListener('mouseenter', async () => { const nextOp = await this.getNextRedoOperation(); if (nextOp) { redoBtn.title = `Redo: ${nextOp.description} (Ctrl+Y)`; } }); } createHistoryPanel() { const historyPanel = document.createElement('div'); historyPanel.id = 'undo-redo-history'; historyPanel.className = 'position-fixed bg-white border rounded shadow d-none'; historyPanel.style.cssText = ` bottom: 80px; left: 20px; width: 350px; max-height: 400px; z-index: 1049; overflow-y: auto; `; historyPanel.innerHTML = `
Operation History
`; document.body.appendChild(historyPanel); } setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) { switch(e.key.toLowerCase()) { case 'z': if (e.shiftKey) { e.preventDefault(); this.redo(); } else { e.preventDefault(); this.undo(); } break; case 'y': e.preventDefault(); this.redo(); break; case 'h': e.preventDefault(); this.toggleHistory(); break; } } }); } setupEventListeners() { // Undo/Redo button clicks document.getElementById('undo-btn')?.addEventListener('click', () => this.undo()); document.getElementById('redo-btn')?.addEventListener('click', () => this.redo()); document.getElementById('history-btn')?.addEventListener('click', () => this.toggleHistory()); // History panel controls document.getElementById('clear-history-btn')?.addEventListener('click', () => this.clearHistory()); document.getElementById('close-history-btn')?.addEventListener('click', () => this.hideHistory()); // Close history when clicking outside document.addEventListener('click', (e) => { const historyPanel = document.getElementById('undo-redo-history'); const historyBtn = document.getElementById('history-btn'); if (historyPanel && !historyPanel.contains(e.target) && !historyBtn.contains(e.target)) { this.hideHistory(); } }); // Listen for operation recording events document.addEventListener('operationRecorded', () => { this.updateButtonStates(); if (this.historyVisible) { this.updateHistoryDisplay(); } }); } async undo() { if (this.isUndoing || this.isRedoing) return; this.isUndoing = true; const undoBtn = document.getElementById('undo-btn'); try { // Show loading state undoBtn.innerHTML = ' Undoing...'; undoBtn.disabled = true; const response = await fetch('/api/undo-redo/undo', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const result = await response.json(); if (result.success) { this.showToast(result.message, 'success'); this.updateButtonStates(); // Trigger page refresh or update this.notifyOperationComplete('undo', result.operation); } else { this.showToast('Undo failed: ' + result.error, 'error'); } } catch (error) { console.error('Undo error:', error); this.showToast('Undo failed: ' + error.message, 'error'); } finally { this.isUndoing = false; undoBtn.innerHTML = ' Undo'; this.updateButtonStates(); } } async redo() { if (this.isUndoing || this.isRedoing) return; this.isRedoing = true; const redoBtn = document.getElementById('redo-btn'); try { // Show loading state redoBtn.innerHTML = ' Redoing...'; redoBtn.disabled = true; const response = await fetch('/api/undo-redo/redo', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const result = await response.json(); if (result.success) { this.showToast(result.message, 'success'); this.updateButtonStates(); // Trigger page refresh or update this.notifyOperationComplete('redo', result.operation); } else { this.showToast('Redo failed: ' + result.error, 'error'); } } catch (error) { console.error('Redo error:', error); this.showToast('Redo failed: ' + error.message, 'error'); } finally { this.isRedoing = false; redoBtn.innerHTML = ' Redo'; this.updateButtonStates(); } } async updateButtonStates() { try { const response = await fetch('/api/undo-redo/status'); const status = await response.json(); const undoBtn = document.getElementById('undo-btn'); const redoBtn = document.getElementById('redo-btn'); if (undoBtn && !this.isUndoing) { undoBtn.disabled = !status.can_undo; if (status.can_undo && status.last_operation) { undoBtn.title = `Undo: ${status.last_operation.description} (Ctrl+Z)`; } else { undoBtn.title = 'Nothing to undo (Ctrl+Z)'; } } if (redoBtn && !this.isRedoing) { redoBtn.disabled = !status.can_redo; if (status.can_redo && status.next_redo_operation) { redoBtn.title = `Redo: ${status.next_redo_operation.description} (Ctrl+Y)`; } else { redoBtn.title = 'Nothing to redo (Ctrl+Y)'; } } } catch (error) { console.error('Error updating undo/redo states:', error); } } toggleHistory() { if (this.historyVisible) { this.hideHistory(); } else { this.showHistory(); } } async showHistory() { this.historyVisible = true; const historyPanel = document.getElementById('undo-redo-history'); historyPanel.classList.remove('d-none'); await this.updateHistoryDisplay(); } hideHistory() { this.historyVisible = false; const historyPanel = document.getElementById('undo-redo-history'); historyPanel.classList.add('d-none'); } async updateHistoryDisplay() { try { const response = await fetch('/api/undo-redo/history'); const data = await response.json(); const historyContent = document.getElementById('history-content'); if (data.history.length === 0 && data.redo_history.length === 0) { historyContent.innerHTML = '

No operations in history

'; return; } let html = ''; // Redo operations (future) if (data.redo_history.length > 0) { html += '
Can Redo:
'; data.redo_history.forEach(op => { html += this.createHistoryItem(op, 'redo'); }); html += '
'; } // Undo operations (past) if (data.history.length > 0) { html += '
Recent Operations:
'; data.history.forEach((op, index) => { html += this.createHistoryItem(op, index === 0 ? 'last' : 'history'); }); } historyContent.innerHTML = html; // Add click handlers historyContent.querySelectorAll('.history-item[data-action]').forEach(item => { item.addEventListener('click', () => { const action = item.dataset.action; const operationId = item.dataset.operationId; if (action === 'undo') { this.undo(); } else if (action === 'redo') { this.redo(); } }); }); } catch (error) { console.error('Error updating history display:', error); document.getElementById('history-content').innerHTML = '

Error loading history

'; } } createHistoryItem(operation, type) { const timestamp = new Date(operation.timestamp * 1000); const timeStr = this.formatTimestamp(timestamp); let iconClass = 'fa-cog'; let actionClass = ''; let actionAttr = ''; switch (operation.type) { case 'download': iconClass = 'fa-download'; break; case 'delete': iconClass = 'fa-trash'; break; case 'update': iconClass = 'fa-sync'; break; case 'organize': iconClass = 'fa-folder-open'; break; case 'rename': iconClass = 'fa-edit'; break; case 'move': iconClass = 'fa-arrows-alt'; break; case 'settings_change': iconClass = 'fa-cogs'; break; case 'bulk_operation': iconClass = 'fa-list'; break; } if (type === 'redo') { actionClass = 'text-success cursor-pointer'; actionAttr = `data-action="redo" data-operation-id="${operation.id}"`; } else if (type === 'last') { actionClass = 'text-primary cursor-pointer border-start border-primary border-3'; actionAttr = `data-action="undo" data-operation-id="${operation.id}"`; } else { actionClass = 'text-muted'; } return `
${operation.description}
${timeStr} • ${operation.affected_items.length} items ${operation.success === false ? ' • Failed' : ''}
${type === 'redo' ? '' : type === 'last' ? '' : ''}
`; } formatTimestamp(date) { const now = new Date(); const diff = now - date; if (diff < 60000) return 'Just now'; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); } async clearHistory() { if (!confirm('Clear all undo/redo history? This cannot be undone.')) return; try { const response = await fetch('/api/undo-redo/clear', { method: 'POST' }); const result = await response.json(); if (result.success) { this.showToast('History cleared', 'success'); this.updateButtonStates(); this.updateHistoryDisplay(); } else { this.showToast('Failed to clear history', 'error'); } } catch (error) { console.error('Error clearing history:', error); this.showToast('Error clearing history', 'error'); } } async getLastOperation() { try { const response = await fetch('/api/undo-redo/status'); const status = await response.json(); return status.last_operation; } catch (error) { return null; } } async getNextRedoOperation() { try { const response = await fetch('/api/undo-redo/status'); const status = await response.json(); return status.next_redo_operation; } catch (error) { return null; } } notifyOperationComplete(action, operation) { // Dispatch custom event for other components to listen to const event = new CustomEvent('undoRedoComplete', { detail: { action: action, operation: operation } }); document.dispatchEvent(event); // Update history display if visible if (this.historyVisible) { setTimeout(() => this.updateHistoryDisplay(), 100); } } recordOperation(operationData) { // Record an operation that can be undone fetch('/api/undo-redo/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(operationData) }).then(() => { // Dispatch event to update UI document.dispatchEvent(new CustomEvent('operationRecorded')); }).catch(error => { console.error('Error recording operation:', error); }); } showToast(message, type = 'info') { // Create and show a toast notification const toast = document.createElement('div'); toast.className = `toast align-items-center text-white bg-${type === 'error' ? 'danger' : type}`; toast.innerHTML = `
${message}
`; let toastContainer = document.querySelector('.toast-container'); if (!toastContainer) { toastContainer = document.createElement('div'); toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3'; toastContainer.style.zIndex = '9999'; document.body.appendChild(toastContainer); } toastContainer.appendChild(toast); const bsToast = new bootstrap.Toast(toast); bsToast.show(); toast.addEventListener('hidden.bs.toast', () => { if (toast.parentNode) { toastContainer.removeChild(toast); } }); } } // Helper function to record operations from other parts of the app function recordUndoableOperation(operationData) { if (window.undoRedoManager) { window.undoRedoManager.recordOperation(operationData); } } // Initialize undo/redo manager when DOM is loaded document.addEventListener('DOMContentLoaded', () => { window.undoRedoManager = new UndoRedoManager(); }); """ def get_css(self): """Generate CSS for undo/redo functionality.""" return """ /* Undo/Redo Styles */ .undo-redo-controls { backdrop-filter: blur(10px); } .undo-redo-controls .btn { border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.9); } .undo-redo-controls .btn:hover:not(:disabled) { background: rgba(255,255,255,1); transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); } .undo-redo-controls .btn:disabled { opacity: 0.5; cursor: not-allowed; } #undo-redo-history { backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.1) !important; } #undo-redo-history .history-item { transition: background-color 0.2s ease; } #undo-redo-history .history-item.cursor-pointer:hover { background-color: rgba(0,123,255,0.1) !important; } #undo-redo-history .history-item.text-success:hover { background-color: rgba(40,167,69,0.1) !important; } #undo-redo-history .history-item.text-primary:hover { background-color: rgba(0,123,255,0.1) !important; } /* Dark theme support */ [data-bs-theme="dark"] .undo-redo-controls .btn { background: rgba(33,37,41,0.9); border-color: rgba(255,255,255,0.1); color: #fff; } [data-bs-theme="dark"] .undo-redo-controls .btn:hover:not(:disabled) { background: rgba(33,37,41,1); } [data-bs-theme="dark"] #undo-redo-history { background: rgba(33,37,41,0.95) !important; border-color: rgba(255,255,255,0.1) !important; color: #fff; } [data-bs-theme="dark"] #undo-redo-history .bg-light { background: rgba(52,58,64,1) !important; color: #fff; } /* Animation for controls */ @keyframes slideInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .undo-redo-controls { animation: slideInUp 0.3s ease-out; } #undo-redo-history { animation: slideInUp 0.2s ease-out; } /* Mobile responsiveness */ @media (max-width: 768px) { .undo-redo-controls { bottom: 10px; left: 10px; right: 10px; justify-content: center; } #undo-redo-history { left: 10px; right: 10px; width: auto; bottom: 70px; } .undo-redo-controls .d-none.d-md-inline { display: none !important; } } /* Accessibility */ .undo-redo-controls .btn:focus { box-shadow: 0 0 0 0.2rem rgba(0,123,255,0.25); } @media (prefers-reduced-motion: reduce) { .undo-redo-controls, #undo-redo-history, .history-item { animation: none; transition: none; } .undo-redo-controls .btn:hover { transform: none; } } /* Loading states */ .undo-redo-controls .btn .fa-spinner { animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } """ # Create undo/redo API blueprint undo_redo_bp = Blueprint('undo_redo', __name__, url_prefix='/api/undo-redo') # Global undo/redo manager instance undo_redo_manager = UndoRedoManager() @undo_redo_bp.route('/undo', methods=['POST']) async def undo_operation(): """Undo the last operation.""" try: result = await undo_redo_manager.undo_last_operation() return jsonify(result) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @undo_redo_bp.route('/redo', methods=['POST']) async def redo_operation(): """Redo the last undone operation.""" try: result = await undo_redo_manager.redo_last_operation() return jsonify(result) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @undo_redo_bp.route('/status', methods=['GET']) def get_undo_redo_status(): """Get undo/redo status.""" try: session_id = undo_redo_manager.get_session_id() can_undo = undo_redo_manager.can_undo(session_id) can_redo = undo_redo_manager.can_redo(session_id) last_operation = None if can_undo: op = undo_redo_manager.get_last_operation(session_id) if op: last_operation = asdict(op) next_redo_operation = None if can_redo: op = undo_redo_manager.get_next_redo_operation(session_id) if op: next_redo_operation = asdict(op) return jsonify({ 'can_undo': can_undo, 'can_redo': can_redo, 'last_operation': last_operation, 'next_redo_operation': next_redo_operation }) except Exception as e: return jsonify({'error': str(e)}), 500 @undo_redo_bp.route('/history', methods=['GET']) def get_operation_history(): """Get operation history.""" try: session_id = undo_redo_manager.get_session_id() limit = request.args.get('limit', 20, type=int) history = undo_redo_manager.get_operation_history(session_id, limit) redo_history = undo_redo_manager.get_redo_history(session_id, limit) return jsonify({ 'history': history, 'redo_history': redo_history }) except Exception as e: return jsonify({'error': str(e)}), 500 @undo_redo_bp.route('/clear', methods=['POST']) def clear_operation_history(): """Clear operation history.""" try: session_id = undo_redo_manager.get_session_id() undo_redo_manager.clear_history(session_id) return jsonify({'success': True, 'message': 'History cleared'}) except Exception as e: return jsonify({'error': str(e)}), 500 @undo_redo_bp.route('/record', methods=['POST']) def record_operation(): """Record an operation for undo/redo.""" try: data = request.get_json() # Create operation from request data operation = UndoableOperation( id=f"op_{int(time.time() * 1000)}_{hash(str(data)) % 10000}", type=OperationType(data['type']), description=data['description'], timestamp=time.time(), user_session=undo_redo_manager.get_session_id(), forward_action=data.get('forward_action', {}), backward_action=data.get('backward_action', {}), affected_items=data.get('affected_items', []), success=data.get('success', True), metadata=data.get('metadata', {}) ) operation_id = undo_redo_manager.record_operation(operation) return jsonify({ 'success': True, 'operation_id': operation_id, 'message': 'Operation recorded' }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500