1122 lines
42 KiB
Python
1122 lines
42 KiB
Python
"""
|
|
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 = `
|
|
<div class="row align-items-center">
|
|
<div class="col-md-6">
|
|
<span class="selection-count">0 items selected</span>
|
|
<div class="btn-group ms-3">
|
|
<button class="btn btn-sm btn-outline-light" id="select-all">Select All</button>
|
|
<button class="btn btn-sm btn-outline-light" id="select-none">Clear</button>
|
|
<button class="btn btn-sm btn-outline-light" id="select-visible">Select Visible</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<div class="btn-group">
|
|
<button class="btn btn-sm btn-success" id="bulk-download">
|
|
<i class="fas fa-download"></i> Download
|
|
</button>
|
|
<button class="btn btn-sm btn-warning" id="bulk-update">
|
|
<i class="fas fa-sync"></i> Update
|
|
</button>
|
|
<button class="btn btn-sm btn-info" id="bulk-organize">
|
|
<i class="fas fa-folder-open"></i> Organize
|
|
</button>
|
|
<button class="btn btn-sm btn-secondary" id="bulk-export">
|
|
<i class="fas fa-file-export"></i> Export
|
|
</button>
|
|
<button class="btn btn-sm btn-danger" id="bulk-delete">
|
|
<i class="fas fa-trash"></i> Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="card-body p-2">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<small class="text-muted">${type.charAt(0).toUpperCase() + type.slice(1)} ${count} items</small>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="bulkOpsManager.cancelOperation('${operationId}')">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div class="progress progress-sm">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%"></div>
|
|
</div>
|
|
<small class="operation-status text-muted">Starting...</small>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">${title}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>${message.replace(/\\n/g, '<br>')}</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-${type}" id="confirm-operation">Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Organize Series</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Organization Method</label>
|
|
<select class="form-select" id="organize-method">
|
|
<option value="genre">By Genre</option>
|
|
<option value="year">By Year</option>
|
|
<option value="status">By Status</option>
|
|
<option value="custom">Custom Folders</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Target Directory</label>
|
|
<input type="text" class="form-control" id="target-dir" placeholder="Leave empty for default">
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="create-symlinks">
|
|
<label class="form-check-label" for="create-symlinks">
|
|
Create symbolic links instead of moving files
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="confirm-organize">Organize</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Export Series Data</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Export Format</label>
|
|
<div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="export-format" id="format-json" value="json" checked>
|
|
<label class="form-check-label" for="format-json">JSON</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="export-format" id="format-csv" value="csv">
|
|
<label class="form-check-label" for="format-csv">CSV</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="export-format" id="format-xml" value="xml">
|
|
<label class="form-check-label" for="format-xml">XML</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="confirm-export">Export</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<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';
|
|
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 = '<?xml version="1.0" encoding="UTF-8"?>\n<series>\n'
|
|
for sid in series_ids:
|
|
xml_data += f' <item id="{sid}" name="Series {sid}" />\n'
|
|
xml_data += '</series>'
|
|
|
|
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() |