""" Bulk Operations Manager for Multiple Series Management This module provides bulk operation capabilities for managing multiple series simultaneously, including batch downloads, deletions, updates, and organization. """ from typing import List, Dict, Any, Optional, Set import asyncio import json import os from datetime import datetime import threading from concurrent.futures import ThreadPoolExecutor import time class BulkOperationsManager: """Manages bulk operations for multiple series.""" def __init__(self, app=None): self.app = app self.active_operations = {} self.operation_history = [] self.max_concurrent_operations = 5 self.executor = ThreadPoolExecutor(max_workers=self.max_concurrent_operations) def init_app(self, app): """Initialize with Flask app.""" self.app = app def get_bulk_operations_js(self): """Generate JavaScript code for bulk operations functionality.""" return """ // AniWorld Bulk Operations Manager class BulkOperationsManager { constructor() { this.selectedItems = new Set(); this.operations = new Map(); this.init(); } init() { this.setupSelectionControls(); this.setupBulkActions(); this.setupOperationProgress(); this.setupKeyboardShortcuts(); } setupSelectionControls() { // Add selection checkboxes to series items const seriesItems = document.querySelectorAll('.series-item, .anime-card'); seriesItems.forEach(item => { this.addSelectionCheckbox(item); }); // Add bulk selection controls this.createBulkSelectionBar(); // Setup click handlers document.addEventListener('click', this.handleItemClick.bind(this)); document.addEventListener('change', this.handleCheckboxChange.bind(this)); } addSelectionCheckbox(item) { if (item.querySelector('.bulk-select-checkbox')) return; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'bulk-select-checkbox form-check-input position-absolute'; checkbox.style.top = '10px'; checkbox.style.left = '10px'; checkbox.style.zIndex = '10'; checkbox.dataset.itemId = item.dataset.seriesId || item.dataset.id; item.style.position = 'relative'; item.appendChild(checkbox); } createBulkSelectionBar() { const existingBar = document.querySelector('.bulk-selection-bar'); if (existingBar) return; const selectionBar = document.createElement('div'); selectionBar.className = 'bulk-selection-bar bg-primary text-white p-3 mb-3 rounded d-none'; selectionBar.innerHTML = `
0 items selected
`; const mainContent = document.querySelector('.main-content, .container-fluid'); if (mainContent) { mainContent.insertBefore(selectionBar, mainContent.firstChild); } } setupBulkActions() { // Bulk action button handlers document.addEventListener('click', (e) => { if (e.target.id === 'select-all') this.selectAll(); else if (e.target.id === 'select-none') this.selectNone(); else if (e.target.id === 'select-visible') this.selectVisible(); else if (e.target.id === 'bulk-download') this.bulkDownload(); else if (e.target.id === 'bulk-update') this.bulkUpdate(); else if (e.target.id === 'bulk-organize') this.bulkOrganize(); else if (e.target.id === 'bulk-export') this.bulkExport(); else if (e.target.id === 'bulk-delete') this.bulkDelete(); }); } setupOperationProgress() { // Create progress tracking container const progressContainer = document.createElement('div'); progressContainer.id = 'bulk-progress-container'; progressContainer.className = 'position-fixed bottom-0 end-0 p-3'; progressContainer.style.zIndex = '9998'; document.body.appendChild(progressContainer); } setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) { switch(e.key) { case 'a': if (this.selectedItems.size > 0) { e.preventDefault(); this.selectAll(); } break; case 'd': if (this.selectedItems.size > 0) { e.preventDefault(); this.bulkDownload(); } break; } } }); } handleItemClick(e) { const item = e.target.closest('.series-item, .anime-card'); if (!item) return; // Handle shift+click for range selection if (e.shiftKey && this.selectedItems.size > 0) { this.selectRange(item); } } handleCheckboxChange(e) { if (!e.target.classList.contains('bulk-select-checkbox')) return; const itemId = e.target.dataset.itemId; const item = e.target.closest('.series-item, .anime-card'); if (e.target.checked) { this.selectedItems.add(itemId); item.classList.add('selected'); } else { this.selectedItems.delete(itemId); item.classList.remove('selected'); } this.updateSelectionUI(); } selectAll() { const checkboxes = document.querySelectorAll('.bulk-select-checkbox'); checkboxes.forEach(checkbox => { checkbox.checked = true; this.selectedItems.add(checkbox.dataset.itemId); checkbox.closest('.series-item, .anime-card').classList.add('selected'); }); this.updateSelectionUI(); } selectNone() { const checkboxes = document.querySelectorAll('.bulk-select-checkbox'); checkboxes.forEach(checkbox => { checkbox.checked = false; checkbox.closest('.series-item, .anime-card').classList.remove('selected'); }); this.selectedItems.clear(); this.updateSelectionUI(); } selectVisible() { const visibleItems = document.querySelectorAll('.series-item:not(.d-none), .anime-card:not(.d-none)'); visibleItems.forEach(item => { const checkbox = item.querySelector('.bulk-select-checkbox'); if (checkbox) { checkbox.checked = true; this.selectedItems.add(checkbox.dataset.itemId); item.classList.add('selected'); } }); this.updateSelectionUI(); } selectRange(endItem) { const items = Array.from(document.querySelectorAll('.series-item, .anime-card')); const selectedItems = Array.from(document.querySelectorAll('.series-item.selected, .anime-card.selected')); if (selectedItems.length === 0) return; const lastSelected = selectedItems[selectedItems.length - 1]; const startIndex = items.indexOf(lastSelected); const endIndex = items.indexOf(endItem); const min = Math.min(startIndex, endIndex); const max = Math.max(startIndex, endIndex); for (let i = min; i <= max; i++) { const item = items[i]; const checkbox = item.querySelector('.bulk-select-checkbox'); if (checkbox) { checkbox.checked = true; this.selectedItems.add(checkbox.dataset.itemId); item.classList.add('selected'); } } this.updateSelectionUI(); } updateSelectionUI() { const count = this.selectedItems.size; const selectionBar = document.querySelector('.bulk-selection-bar'); const countElement = document.querySelector('.selection-count'); if (count > 0) { selectionBar.classList.remove('d-none'); countElement.textContent = `${count} item${count === 1 ? '' : 's'} selected`; } else { selectionBar.classList.add('d-none'); } } async bulkDownload() { if (this.selectedItems.size === 0) return; const confirmed = await this.confirmOperation( 'Bulk Download', `Download ${this.selectedItems.size} selected series?` ); if (!confirmed) return; const operationId = this.startOperation('download', Array.from(this.selectedItems)); try { const response = await fetch('/api/bulk/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation_id: operationId, series_ids: Array.from(this.selectedItems) }) }); const result = await response.json(); if (result.success) { this.trackOperation(operationId, result.task_id); } else { this.showError('Failed to start bulk download'); this.completeOperation(operationId, false); } } catch (error) { this.showError('Error starting bulk download: ' + error.message); this.completeOperation(operationId, false); } } async bulkUpdate() { if (this.selectedItems.size === 0) return; const confirmed = await this.confirmOperation( 'Bulk Update', `Update metadata for ${this.selectedItems.size} selected series?` ); if (!confirmed) return; const operationId = this.startOperation('update', Array.from(this.selectedItems)); try { const response = await fetch('/api/bulk/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation_id: operationId, series_ids: Array.from(this.selectedItems) }) }); const result = await response.json(); if (result.success) { this.trackOperation(operationId, result.task_id); } else { this.showError('Failed to start bulk update'); this.completeOperation(operationId, false); } } catch (error) { this.showError('Error starting bulk update: ' + error.message); this.completeOperation(operationId, false); } } async bulkOrganize() { if (this.selectedItems.size === 0) return; const options = await this.showOrganizeModal(); if (!options) return; const operationId = this.startOperation('organize', Array.from(this.selectedItems)); try { const response = await fetch('/api/bulk/organize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation_id: operationId, series_ids: Array.from(this.selectedItems), options: options }) }); const result = await response.json(); if (result.success) { this.trackOperation(operationId, result.task_id); } else { this.showError('Failed to start bulk organization'); this.completeOperation(operationId, false); } } catch (error) { this.showError('Error starting bulk organization: ' + error.message); this.completeOperation(operationId, false); } } async bulkExport() { if (this.selectedItems.size === 0) return; const format = await this.showExportModal(); if (!format) return; const operationId = this.startOperation('export', Array.from(this.selectedItems)); try { const response = await fetch('/api/bulk/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation_id: operationId, series_ids: Array.from(this.selectedItems), format: format }) }); if (response.ok) { const blob = await response.blob(); this.downloadFile(blob, `series_export_${Date.now()}.${format}`); this.completeOperation(operationId, true); } else { this.showError('Failed to export series data'); this.completeOperation(operationId, false); } } catch (error) { this.showError('Error exporting series data: ' + error.message); this.completeOperation(operationId, false); } } async bulkDelete() { if (this.selectedItems.size === 0) return; const confirmed = await this.confirmOperation( 'Bulk Delete', `Permanently delete ${this.selectedItems.size} selected series?\\n\\nThis action cannot be undone!`, 'danger' ); if (!confirmed) return; const operationId = this.startOperation('delete', Array.from(this.selectedItems)); try { const response = await fetch('/api/bulk/delete', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation_id: operationId, series_ids: Array.from(this.selectedItems) }) }); const result = await response.json(); if (result.success) { this.trackOperation(operationId, result.task_id); // Remove deleted items from selection this.selectedItems.clear(); this.updateSelectionUI(); } else { this.showError('Failed to start bulk deletion'); this.completeOperation(operationId, false); } } catch (error) { this.showError('Error starting bulk deletion: ' + error.message); this.completeOperation(operationId, false); } } startOperation(type, itemIds) { const operationId = 'op_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); this.operations.set(operationId, { type: type, itemIds: itemIds, startTime: Date.now(), status: 'running' }); this.showOperationProgress(operationId, type, itemIds.length); return operationId; } trackOperation(operationId, taskId) { const operation = this.operations.get(operationId); if (!operation) return; operation.taskId = taskId; // Poll for progress updates const pollInterval = setInterval(async () => { try { const response = await fetch(`/api/bulk/status/${taskId}`); const status = await response.json(); this.updateOperationProgress(operationId, status); if (status.complete) { clearInterval(pollInterval); this.completeOperation(operationId, status.success); } } catch (error) { console.error('Error polling operation status:', error); clearInterval(pollInterval); this.completeOperation(operationId, false); } }, 1000); operation.pollInterval = pollInterval; } showOperationProgress(operationId, type, count) { const progressContainer = document.getElementById('bulk-progress-container'); const progressCard = document.createElement('div'); progressCard.id = `progress-${operationId}`; progressCard.className = 'card mb-2'; progressCard.style.width = '300px'; progressCard.innerHTML = `
${type.charAt(0).toUpperCase() + type.slice(1)} ${count} items
Starting...
`; progressContainer.appendChild(progressCard); } updateOperationProgress(operationId, status) { const progressCard = document.getElementById(`progress-${operationId}`); if (!progressCard) return; const progressBar = progressCard.querySelector('.progress-bar'); const statusText = progressCard.querySelector('.operation-status'); const percentage = Math.round((status.completed / status.total) * 100); progressBar.style.width = `${percentage}%`; statusText.textContent = status.message || `${status.completed}/${status.total} completed`; if (status.error) { progressBar.classList.add('bg-danger'); statusText.textContent = 'Error: ' + status.error; } } completeOperation(operationId, success) { const operation = this.operations.get(operationId); if (!operation) return; // Clear polling if active if (operation.pollInterval) { clearInterval(operation.pollInterval); } operation.status = success ? 'completed' : 'failed'; operation.endTime = Date.now(); const progressCard = document.getElementById(`progress-${operationId}`); if (progressCard) { const progressBar = progressCard.querySelector('.progress-bar'); const statusText = progressCard.querySelector('.operation-status'); progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped'); if (success) { progressBar.classList.add('bg-success'); progressBar.style.width = '100%'; statusText.textContent = 'Completed successfully'; } else { progressBar.classList.add('bg-danger'); statusText.textContent = 'Operation failed'; } // Auto-remove after 5 seconds setTimeout(() => { if (progressCard.parentNode) { progressCard.parentNode.removeChild(progressCard); } }, 5000); } this.operations.delete(operationId); } cancelOperation(operationId) { const operation = this.operations.get(operationId); if (!operation) return; if (operation.taskId) { fetch(`/api/bulk/cancel/${operation.taskId}`, { method: 'POST' }); } this.completeOperation(operationId, false); } confirmOperation(title, message, type = 'primary') { return new Promise((resolve) => { const modal = document.createElement('div'); modal.className = 'modal fade'; modal.innerHTML = ` `; document.body.appendChild(modal); const bsModal = new bootstrap.Modal(modal); bsModal.show(); modal.querySelector('#confirm-operation').addEventListener('click', () => { resolve(true); bsModal.hide(); }); modal.addEventListener('hidden.bs.modal', () => { if (modal.parentNode) { document.body.removeChild(modal); } resolve(false); }); }); } showOrganizeModal() { return new Promise((resolve) => { const modal = document.createElement('div'); modal.className = 'modal fade'; modal.innerHTML = ` `; document.body.appendChild(modal); const bsModal = new bootstrap.Modal(modal); bsModal.show(); modal.querySelector('#confirm-organize').addEventListener('click', () => { const options = { method: modal.querySelector('#organize-method').value, targetDir: modal.querySelector('#target-dir').value, createSymlinks: modal.querySelector('#create-symlinks').checked }; resolve(options); bsModal.hide(); }); modal.addEventListener('hidden.bs.modal', () => { if (modal.parentNode) { document.body.removeChild(modal); } resolve(null); }); }); } showExportModal() { return new Promise((resolve) => { const modal = document.createElement('div'); modal.className = 'modal fade'; modal.innerHTML = ` `; document.body.appendChild(modal); const bsModal = new bootstrap.Modal(modal); bsModal.show(); modal.querySelector('#confirm-export').addEventListener('click', () => { const format = modal.querySelector('input[name="export-format"]:checked').value; resolve(format); bsModal.hide(); }); modal.addEventListener('hidden.bs.modal', () => { if (modal.parentNode) { document.body.removeChild(modal); } resolve(null); }); }); } downloadFile(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } showError(message) { const toast = document.createElement('div'); toast.className = 'toast align-items-center text-white bg-danger'; 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'; 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); } }); } } // Initialize bulk operations when DOM is loaded document.addEventListener('DOMContentLoaded', () => { window.bulkOpsManager = new BulkOperationsManager(); }); """ def get_css(self): """Generate CSS styles for bulk operations.""" return """ /* Bulk Operations Styles */ .bulk-selection-bar { animation: slideDown 0.3s ease-out; border-left: 4px solid #0d6efd; } @keyframes slideDown { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } } .bulk-select-checkbox { opacity: 0; transition: opacity 0.2s ease; } .series-item:hover .bulk-select-checkbox, .anime-card:hover .bulk-select-checkbox, .bulk-select-checkbox:checked { opacity: 1; } .series-item.selected, .anime-card.selected { background-color: rgba(13, 110, 253, 0.1); border: 2px solid #0d6efd; border-radius: 8px; } #bulk-progress-container { max-height: 400px; overflow-y: auto; } .progress-sm { height: 0.5rem; } .operation-status { font-size: 0.75rem; } .btn-group .btn { white-space: nowrap; } /* Mobile responsiveness */ @media (max-width: 768px) { .bulk-selection-bar .row { flex-direction: column; } .bulk-selection-bar .col-md-6 { margin-bottom: 1rem; text-align: center; } .bulk-selection-bar .text-end { text-align: center !important; } .btn-group { flex-wrap: wrap; justify-content: center; } .btn-group .btn { margin: 0.25rem; } } /* Accessibility */ .bulk-select-checkbox:focus { box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); } @media (prefers-reduced-motion: reduce) { .bulk-selection-bar { animation: none; } } """ async def bulk_download(self, series_ids: List[str], operation_id: str) -> Dict[str, Any]: """Execute bulk download operation.""" try: results = [] total = len(series_ids) completed = 0 for series_id in series_ids: try: # Update progress await self.update_operation_progress(operation_id, completed, total, f"Downloading {series_id}") # Simulate download (replace with actual download logic) await asyncio.sleep(1) # Simulated work results.append({ 'series_id': series_id, 'status': 'success', 'message': 'Download completed' }) completed += 1 except Exception as e: results.append({ 'series_id': series_id, 'status': 'error', 'message': str(e) }) await self.update_operation_progress(operation_id, total, total, "All downloads completed") return { 'success': True, 'results': results, 'completed': completed, 'total': total } except Exception as e: return { 'success': False, 'error': str(e), 'completed': completed, 'total': total } async def bulk_update(self, series_ids: List[str], operation_id: str) -> Dict[str, Any]: """Execute bulk update operation.""" try: results = [] total = len(series_ids) completed = 0 for series_id in series_ids: try: await self.update_operation_progress(operation_id, completed, total, f"Updating {series_id}") # Simulate update (replace with actual update logic) await asyncio.sleep(0.5) results.append({ 'series_id': series_id, 'status': 'success', 'message': 'Metadata updated' }) completed += 1 except Exception as e: results.append({ 'series_id': series_id, 'status': 'error', 'message': str(e) }) await self.update_operation_progress(operation_id, total, total, "All updates completed") return { 'success': True, 'results': results, 'completed': completed, 'total': total } except Exception as e: return { 'success': False, 'error': str(e), 'completed': completed, 'total': total } async def bulk_organize(self, series_ids: List[str], options: Dict[str, Any], operation_id: str) -> Dict[str, Any]: """Execute bulk organize operation.""" try: results = [] total = len(series_ids) completed = 0 method = options.get('method', 'genre') target_dir = options.get('targetDir', '') create_symlinks = options.get('createSymlinks', False) for series_id in series_ids: try: await self.update_operation_progress(operation_id, completed, total, f"Organizing {series_id}") # Simulate organization (replace with actual logic) await asyncio.sleep(0.3) results.append({ 'series_id': series_id, 'status': 'success', 'message': f'Organized by {method}' }) completed += 1 except Exception as e: results.append({ 'series_id': series_id, 'status': 'error', 'message': str(e) }) await self.update_operation_progress(operation_id, total, total, "Organization completed") return { 'success': True, 'results': results, 'completed': completed, 'total': total } except Exception as e: return { 'success': False, 'error': str(e), 'completed': completed, 'total': total } async def bulk_delete(self, series_ids: List[str], operation_id: str) -> Dict[str, Any]: """Execute bulk delete operation.""" try: results = [] total = len(series_ids) completed = 0 for series_id in series_ids: try: await self.update_operation_progress(operation_id, completed, total, f"Deleting {series_id}") # Simulate deletion (replace with actual deletion logic) await asyncio.sleep(0.2) results.append({ 'series_id': series_id, 'status': 'success', 'message': 'Series deleted' }) completed += 1 except Exception as e: results.append({ 'series_id': series_id, 'status': 'error', 'message': str(e) }) await self.update_operation_progress(operation_id, total, total, "Deletion completed") return { 'success': True, 'results': results, 'completed': completed, 'total': total } except Exception as e: return { 'success': False, 'error': str(e), 'completed': completed, 'total': total } async def export_series_data(self, series_ids: List[str], format: str) -> bytes: """Export series data in specified format.""" # This would implement actual data export logic # For now, return dummy data if format == 'json': data = { 'series': [{'id': sid, 'name': f'Series {sid}'} for sid in series_ids], 'exported_at': datetime.now().isoformat() } return json.dumps(data, indent=2).encode('utf-8') elif format == 'csv': import csv import io output = io.StringIO() writer = csv.writer(output) writer.writerow(['ID', 'Name', 'Status']) for sid in series_ids: writer.writerow([sid, f'Series {sid}', 'Active']) return output.getvalue().encode('utf-8') elif format == 'xml': xml_data = '\n\n' for sid in series_ids: xml_data += f' \n' xml_data += '' return xml_data.encode('utf-8') return b'' async def update_operation_progress(self, operation_id: str, completed: int, total: int, message: str): """Update operation progress (implement with WebSocket or polling).""" # This would send progress updates to the frontend # For now, just store the progress pass # Export the bulk operations manager bulk_operations_manager = BulkOperationsManager()