1337 lines
47 KiB
Python
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 |