Aniworld/src/server/undo_redo_manager.py

1337 lines
47 KiB
Python

"""
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 = `
<div class="btn-group shadow">
<button class="btn btn-outline-secondary" id="undo-btn" disabled title="Undo (Ctrl+Z)">
<i class="fas fa-undo"></i>
<span class="d-none d-md-inline ms-1">Undo</span>
</button>
<button class="btn btn-outline-secondary" id="redo-btn" disabled title="Redo (Ctrl+Y)">
<i class="fas fa-redo"></i>
<span class="d-none d-md-inline ms-1">Redo</span>
</button>
<button class="btn btn-outline-info" id="history-btn" title="View History (Ctrl+H)">
<i class="fas fa-history"></i>
</button>
</div>
`;
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 = `
<div class="p-3 border-bottom bg-light">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Operation History</h6>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-danger btn-sm" id="clear-history-btn">
<i class="fas fa-trash"></i> Clear
</button>
<button class="btn btn-outline-secondary btn-sm" id="close-history-btn">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<div class="history-content p-2" id="history-content">
<!-- History items will be populated here -->
</div>
`;
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 = '<i class="fas fa-spinner fa-spin"></i> <span class="d-none d-md-inline ms-1">Undoing...</span>';
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 = '<i class="fas fa-undo"></i> <span class="d-none d-md-inline ms-1">Undo</span>';
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 = '<i class="fas fa-spinner fa-spin"></i> <span class="d-none d-md-inline ms-1">Redoing...</span>';
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 = '<i class="fas fa-redo"></i> <span class="d-none d-md-inline ms-1">Redo</span>';
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 = '<p class="text-muted text-center py-3">No operations in history</p>';
return;
}
let html = '';
// Redo operations (future)
if (data.redo_history.length > 0) {
html += '<div class="mb-2"><small class="text-muted fw-bold">Can Redo:</small></div>';
data.redo_history.forEach(op => {
html += this.createHistoryItem(op, 'redo');
});
html += '<hr class="my-2">';
}
// Undo operations (past)
if (data.history.length > 0) {
html += '<div class="mb-2"><small class="text-muted fw-bold">Recent Operations:</small></div>';
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 =
'<p class="text-danger text-center py-3">Error loading history</p>';
}
}
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 `
<div class="history-item d-flex align-items-center p-2 rounded mb-1 ${actionClass}" ${actionAttr}>
<div class="me-2">
<i class="fas ${iconClass}"></i>
</div>
<div class="flex-grow-1">
<div class="fw-bold small">${operation.description}</div>
<div class="text-muted" style="font-size: 0.75rem;">
${timeStr} • ${operation.affected_items.length} items
${operation.success === false ? ' • <span class="text-danger">Failed</span>' : ''}
</div>
</div>
${type === 'redo' ? '<i class="fas fa-chevron-right text-success"></i>' :
type === 'last' ? '<i class="fas fa-chevron-left text-primary"></i>' : ''}
</div>
`;
}
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 = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
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