Add batch processing
This commit is contained in:
parent
3faf61d4f0
commit
306c134f74
6 changed files with 2504 additions and 389 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -2,3 +2,7 @@
|
|||
document_output
|
||||
results
|
||||
handbook.pdf
|
||||
ariston.pdf
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
perplexity.pdf
|
||||
354
app.py
354
app.py
|
|
@ -5,6 +5,9 @@ import time
|
|||
import threading
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import uuid
|
||||
import pdf2image
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
|
|
@ -19,6 +22,14 @@ app = Flask(__name__,
|
|||
# Dictionary to store background task results
|
||||
task_results = {}
|
||||
|
||||
# Import batch processor if available
|
||||
try:
|
||||
from batch_processor import start_batch_processing, get_batch_processor, cleanup_old_batches
|
||||
batch_processing_available = True
|
||||
except ImportError:
|
||||
logger.warning("batch_processor.py not found. Batch processing features will be disabled.")
|
||||
batch_processing_available = False
|
||||
|
||||
# Ensure results folder exists
|
||||
def ensure_results_folder():
|
||||
results_dir = Path("results")
|
||||
|
|
@ -44,6 +55,11 @@ def ensure_frontend_folders():
|
|||
def index():
|
||||
return send_file('frontend/index.html')
|
||||
|
||||
@app.route('/batch')
|
||||
def batch_interface():
|
||||
"""Serve the batch processing interface"""
|
||||
return send_file('frontend/batch.html')
|
||||
|
||||
@app.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
return send_file(os.path.join('frontend/static', filename))
|
||||
|
|
@ -59,6 +75,46 @@ def pdf_files():
|
|||
logger.error(f"Error listing PDF files: {str(e)}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/pdf-info/<path:pdf_file>')
|
||||
def pdf_info(pdf_file):
|
||||
"""Get information about a PDF file (page count, etc.)"""
|
||||
try:
|
||||
if not os.path.exists(pdf_file):
|
||||
return jsonify({'error': f'PDF file not found: {pdf_file}'}), 404
|
||||
|
||||
# Get page count using pdf2image
|
||||
try:
|
||||
from pdf2image.pdf2image import pdfinfo_from_path
|
||||
info = pdfinfo_from_path(pdf_file)
|
||||
page_count = info["Pages"]
|
||||
except Exception as e:
|
||||
# Fallback method
|
||||
logger.warning(f"pdfinfo failed, using fallback: {e}")
|
||||
images = pdf2image.convert_from_path(pdf_file, dpi=72, first_page=1, last_page=1)
|
||||
page_count = 1 # At least one page if we got here
|
||||
|
||||
# Try to get actual count by checking last pages
|
||||
for i in range(2, 1000): # Reasonable upper limit
|
||||
try:
|
||||
images = pdf2image.convert_from_path(pdf_file, dpi=72, first_page=i, last_page=i)
|
||||
if not images:
|
||||
page_count = i - 1
|
||||
break
|
||||
page_count = i
|
||||
except:
|
||||
page_count = i - 1
|
||||
break
|
||||
|
||||
return jsonify({
|
||||
'pageCount': page_count,
|
||||
'filename': os.path.basename(pdf_file),
|
||||
'size': os.path.getsize(pdf_file)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting PDF info: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
def run_command(task_id, command):
|
||||
"""Run a command in a background thread and store result"""
|
||||
logger.info(f"Running command: {command}")
|
||||
|
|
@ -383,7 +439,8 @@ def check_environment():
|
|||
'pdf_files': pdf_files,
|
||||
'results_dir_exists': results_dir_exists,
|
||||
'results_dir_writable': results_writable,
|
||||
'python_version': python_version
|
||||
'python_version': python_version,
|
||||
'batch_processing_available': batch_processing_available
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
|
@ -440,8 +497,303 @@ def run_manual_command():
|
|||
logger.error(f"Error running manual command: {str(e)}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# Batch processing endpoints
|
||||
@app.route('/run-batch-processor', methods=['POST'])
|
||||
def run_batch_processor():
|
||||
"""Start a new batch processing job"""
|
||||
if not batch_processing_available:
|
||||
return jsonify({'success': False, 'error': 'Batch processing not available'}), 503
|
||||
|
||||
try:
|
||||
pdf_file = request.form.get('pdf_file')
|
||||
start_page = int(request.form.get('start_page', 1))
|
||||
end_page = int(request.form.get('end_page', 1))
|
||||
|
||||
if not pdf_file:
|
||||
return jsonify({'success': False, 'error': 'PDF file not specified'}), 400
|
||||
|
||||
if not os.path.exists(pdf_file):
|
||||
return jsonify({'success': False, 'error': f'PDF file not found: {pdf_file}'}), 404
|
||||
|
||||
# Processing options
|
||||
options = {
|
||||
'adjust': request.form.get('adjust') == 'true',
|
||||
'parallel': request.form.get('parallel') == 'true',
|
||||
'generate_report': request.form.get('generate_report') == 'true'
|
||||
}
|
||||
|
||||
# Generate batch ID
|
||||
batch_id = str(uuid.uuid4())[:8]
|
||||
|
||||
# Start batch processing
|
||||
if start_batch_processing(batch_id, pdf_file, start_page, end_page, options):
|
||||
logger.info(f"Started batch processing with ID: {batch_id}")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'batch_id': batch_id,
|
||||
'message': f'Batch processing started for {end_page - start_page + 1} pages'
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Failed to start batch processing'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting batch processor: {str(e)}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/batch-status/<batch_id>')
|
||||
def batch_status(batch_id):
|
||||
"""Get the status of a batch processing job"""
|
||||
if not batch_processing_available:
|
||||
return jsonify({'error': 'Batch processing not available'}), 503
|
||||
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
state = processor.get_state()
|
||||
|
||||
# Only return recent logs (last 20)
|
||||
state['logs'] = state['logs'][-20:]
|
||||
|
||||
return jsonify(state)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting batch status: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/pause-batch/<batch_id>', methods=['POST'])
|
||||
def pause_batch(batch_id):
|
||||
"""Pause a batch processing job"""
|
||||
if not batch_processing_available:
|
||||
return jsonify({'error': 'Batch processing not available'}), 503
|
||||
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
processor.pause()
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error pausing batch: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/resume-batch/<batch_id>', methods=['POST'])
|
||||
def resume_batch(batch_id):
|
||||
"""Resume a paused batch processing job"""
|
||||
if not batch_processing_available:
|
||||
return jsonify({'error': 'Batch processing not available'}), 503
|
||||
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
processor.resume()
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error resuming batch: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/cancel-batch/<batch_id>', methods=['POST'])
|
||||
def cancel_batch(batch_id):
|
||||
"""Cancel a batch processing job"""
|
||||
if not batch_processing_available:
|
||||
return jsonify({'error': 'Batch processing not available'}), 503
|
||||
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
processor.cancel()
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling batch: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/retry-page', methods=['POST'])
|
||||
def retry_page():
|
||||
"""Retry processing a single failed page"""
|
||||
if not batch_processing_available:
|
||||
return jsonify({'success': False, 'error': 'Batch processing not available'}), 503
|
||||
|
||||
try:
|
||||
pdf_file = request.form.get('pdf_file')
|
||||
page_num = int(request.form.get('page_num'))
|
||||
adjust = request.form.get('adjust') == 'true'
|
||||
|
||||
if not pdf_file or not page_num:
|
||||
return jsonify({'success': False, 'error': 'Missing parameters'}), 400
|
||||
|
||||
# Create a single-page batch for retry
|
||||
batch_id = f"retry_{uuid.uuid4().hex[:8]}"
|
||||
options = {'adjust': adjust, 'parallel': False, 'generate_report': False}
|
||||
|
||||
if start_batch_processing(batch_id, pdf_file, page_num, page_num, options):
|
||||
# Wait for completion (since it's just one page)
|
||||
processor = get_batch_processor(batch_id)
|
||||
timeout = 60 # 60 seconds timeout
|
||||
start_time = time.time()
|
||||
|
||||
while not processor.state['completed'] and (time.time() - start_time) < timeout:
|
||||
time.sleep(0.5)
|
||||
|
||||
if processor.state['results']['successful'] > 0:
|
||||
return jsonify({'success': True})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Page processing failed'})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Failed to start retry'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrying page: {str(e)}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/download-batch-results/<batch_id>')
|
||||
def download_batch_results(batch_id):
|
||||
"""Download all batch results as a ZIP file"""
|
||||
if not batch_processing_available:
|
||||
return jsonify({'error': 'Batch processing not available'}), 503
|
||||
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
# Create ZIP archive
|
||||
zip_path = processor.create_zip_archive()
|
||||
|
||||
if zip_path and os.path.exists(zip_path):
|
||||
return send_file(
|
||||
zip_path,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=f'batch_results_{batch_id}.zip'
|
||||
)
|
||||
else:
|
||||
return jsonify({'error': 'Failed to create archive'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading batch results: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/batch-report/<batch_id>')
|
||||
def batch_report(batch_id):
|
||||
"""View the batch processing report"""
|
||||
if not batch_processing_available:
|
||||
return jsonify({'error': 'Batch processing not available'}), 503
|
||||
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
report_path = processor.results_dir / "report.html"
|
||||
|
||||
if report_path.exists():
|
||||
# Read and modify the HTML to fix image paths
|
||||
with open(report_path, 'r') as f:
|
||||
html_content = f.read()
|
||||
|
||||
# Replace relative image paths with absolute Flask routes
|
||||
html_content = html_content.replace(
|
||||
'src="visualization_page_',
|
||||
f'src="/batch-report-image/{batch_id}/visualization_page_'
|
||||
)
|
||||
html_content = html_content.replace(
|
||||
'href="visualization_page_',
|
||||
f'href="/batch-report-image/{batch_id}/visualization_page_'
|
||||
)
|
||||
|
||||
return html_content
|
||||
else:
|
||||
return jsonify({'error': 'Report not found'}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error viewing batch report: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/batch-report-image/<batch_id>/<path:filename>')
|
||||
def batch_report_image(batch_id, filename):
|
||||
"""Serve images from batch report directory"""
|
||||
if not batch_processing_available:
|
||||
return jsonify({'error': 'Batch processing not available'}), 503
|
||||
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
image_path = processor.results_dir / filename
|
||||
|
||||
if image_path.exists() and image_path.is_file():
|
||||
return send_file(image_path)
|
||||
else:
|
||||
return jsonify({'error': f'Image not found: {filename}'}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving batch report image: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/open-results-folder', methods=['POST'])
|
||||
def open_results_folder():
|
||||
"""Open the results folder in the system file explorer"""
|
||||
try:
|
||||
results_path = os.path.abspath("results")
|
||||
|
||||
if sys.platform == 'darwin': # macOS
|
||||
subprocess.run(['open', results_path])
|
||||
elif sys.platform == 'win32': # Windows
|
||||
os.startfile(results_path)
|
||||
else: # Linux
|
||||
subprocess.run(['xdg-open', results_path])
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening results folder: {str(e)}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# Cleanup task for batch processors
|
||||
def cleanup_task():
|
||||
"""Periodic cleanup of old batch processors"""
|
||||
if not batch_processing_available:
|
||||
return
|
||||
|
||||
while True:
|
||||
time.sleep(3600) # Run every hour
|
||||
try:
|
||||
cleanup_old_batches(24) # Clean up batches older than 24 hours
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cleanup task: {str(e)}")
|
||||
|
||||
# Start cleanup thread when app starts (only if batch processing is available)
|
||||
if batch_processing_available:
|
||||
cleanup_thread = threading.Thread(target=cleanup_task)
|
||||
cleanup_thread.daemon = True
|
||||
cleanup_thread.start()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Ensure folders exist
|
||||
ensure_results_folder()
|
||||
ensure_frontend_folders()
|
||||
|
||||
# Check environment
|
||||
if not batch_processing_available:
|
||||
logger.warning("batch_processor.py not found. Batch processing features will be disabled.")
|
||||
else:
|
||||
logger.info("Batch processing features enabled.")
|
||||
|
||||
app.run(debug=True, host='127.0.0.1', port=5000)
|
||||
File diff suppressed because it is too large
Load diff
251
frontend/batch.html
Normal file
251
frontend/batch.html
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocTags Batch Processor</title>
|
||||
<link rel="stylesheet" href="static/styles.css">
|
||||
<link rel="stylesheet" href="static/batch-styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>DocTags Batch Processor</h1>
|
||||
|
||||
<!-- Back to Single Page Processor -->
|
||||
<div class="nav-link">
|
||||
<a href="/">← Back to Single Page Processor</a>
|
||||
</div>
|
||||
|
||||
<!-- Settings Panel -->
|
||||
<div class="settings-panel">
|
||||
<h3>Batch Processing Configuration</h3>
|
||||
|
||||
<!-- PDF Selection -->
|
||||
<div class="batch-settings-grid">
|
||||
<div class="form-group">
|
||||
<label for="batch_pdf_file">PDF Document</label>
|
||||
<select id="batch_pdf_file" name="batch_pdf_file">
|
||||
<option value="">Select a PDF file...</option>
|
||||
</select>
|
||||
<div id="batch-pdf-info" class="pdf-info hidden">
|
||||
<span class="info-label">Total pages:</span>
|
||||
<span id="total-pages">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Range Selection -->
|
||||
<div class="form-group">
|
||||
<label>Page Range</label>
|
||||
<div class="page-range-controls">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="page_range" value="all" checked>
|
||||
All pages
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="page_range" value="range">
|
||||
Custom range
|
||||
</label>
|
||||
</div>
|
||||
<div id="custom-range" class="custom-range hidden">
|
||||
<input type="number" id="start_page" min="1" placeholder="Start">
|
||||
<span>to</span>
|
||||
<input type="number" id="end_page" min="1" placeholder="End">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Options -->
|
||||
<div class="form-group">
|
||||
<label>Processing Options</label>
|
||||
<div class="processing-options">
|
||||
<label>
|
||||
<input type="checkbox" id="batch_adjust" checked>
|
||||
Auto-adjust coordinates
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="parallel_processing" checked>
|
||||
Parallel processing
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="generate_report" checked>
|
||||
Generate summary report
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Progress Overview -->
|
||||
<div class="batch-progress-panel hidden" id="batch-progress-panel">
|
||||
<h3>Processing Progress</h3>
|
||||
|
||||
<!-- Overall Progress -->
|
||||
<div class="overall-progress">
|
||||
<div class="progress-header">
|
||||
<span>Overall Progress</span>
|
||||
<span id="overall-percentage">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="overall-progress-fill"></div>
|
||||
</div>
|
||||
<div class="progress-stats">
|
||||
<span id="pages-completed">0</span> / <span id="pages-total">0</span> pages completed
|
||||
<span class="separator">•</span>
|
||||
<span>Elapsed: <span id="elapsed-time">00:00</span></span>
|
||||
<span class="separator">•</span>
|
||||
<span>ETA: <span id="eta-time">--:--</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage Progress -->
|
||||
<div class="stage-progress">
|
||||
<div class="stage-item">
|
||||
<div class="stage-header">
|
||||
<span class="stage-icon">📄</span>
|
||||
<span class="stage-name">Analysis</span>
|
||||
<span class="stage-percentage" id="analysis-percentage">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar small">
|
||||
<div class="progress-fill analysis" id="analysis-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-item">
|
||||
<div class="stage-header">
|
||||
<span class="stage-icon">🎨</span>
|
||||
<span class="stage-name">Visualization</span>
|
||||
<span class="stage-percentage" id="visualization-percentage">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar small">
|
||||
<div class="progress-fill visualization" id="visualization-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-item">
|
||||
<div class="stage-header">
|
||||
<span class="stage-icon">🖼️</span>
|
||||
<span class="stage-name">Extraction</span>
|
||||
<span class="stage-percentage" id="extraction-percentage">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar small">
|
||||
<div class="progress-fill extraction" id="extraction-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="batch-controls">
|
||||
<button id="start-batch-btn" class="primary-btn" onclick="startBatchProcessing()">
|
||||
Start Batch Processing
|
||||
</button>
|
||||
<button id="pause-batch-btn" class="secondary-btn hidden" onclick="pauseBatchProcessing()">
|
||||
Pause
|
||||
</button>
|
||||
<button id="resume-batch-btn" class="secondary-btn hidden" onclick="resumeBatchProcessing()">
|
||||
Resume
|
||||
</button>
|
||||
<button id="cancel-batch-btn" class="danger-btn hidden" onclick="cancelBatchProcessing()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Page Status Grid -->
|
||||
<div class="page-status-grid hidden" id="page-status-grid">
|
||||
<h3>Page Processing Status</h3>
|
||||
<div class="page-grid" id="page-grid">
|
||||
<!-- Page status items will be dynamically added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="batch-results hidden" id="batch-results">
|
||||
<h3>Batch Processing Results</h3>
|
||||
|
||||
<!-- Summary Statistics -->
|
||||
<div class="results-summary">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-success">0</div>
|
||||
<div class="stat-label">Successful</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-failed">0</div>
|
||||
<div class="stat-label">Failed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-duration">00:00</div>
|
||||
<div class="stat-label">Total Time</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-images">0</div>
|
||||
<div class="stat-label">Images Extracted</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="results-actions">
|
||||
<button class="action-btn" onclick="downloadResults()">
|
||||
📥 Download All Results
|
||||
</button>
|
||||
<button class="action-btn" onclick="viewReport()">
|
||||
📊 View Detailed Report
|
||||
</button>
|
||||
<button class="action-btn" onclick="openResultsFolder()">
|
||||
📁 Open Results Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Failed Pages (if any) -->
|
||||
<div class="failed-pages hidden" id="failed-pages">
|
||||
<h4>Failed Pages</h4>
|
||||
<div class="failed-list" id="failed-list">
|
||||
<!-- Failed page items will be listed here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console Output -->
|
||||
<div class="console-output hidden" id="console-output">
|
||||
<div class="console-header">
|
||||
<h4>Processing Log</h4>
|
||||
<button class="console-btn" onclick="clearConsole()">Clear</button>
|
||||
<button class="console-btn" onclick="toggleAutoScroll()">
|
||||
<span id="autoscroll-status">Auto-scroll: ON</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="console-content" id="console-content">
|
||||
<!-- Log messages will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Check -->
|
||||
<div id="batch-environment-check" class="hidden">
|
||||
<h3>System Environment</h3>
|
||||
<div id="batch-env-details"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Preview Modal -->
|
||||
<div id="batch-image-modal" class="image-modal">
|
||||
<div class="modal-content">
|
||||
<span class="modal-close" onclick="closeBatchImageModal()">×</span>
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab active" onclick="switchModalTab('visualization')">Visualization</button>
|
||||
<button class="modal-tab" onclick="switchModalTab('extracted')">Extracted Images</button>
|
||||
</div>
|
||||
<div id="modal-visualization-content" class="modal-tab-content active">
|
||||
<img id="modal-visualization-image" src="" alt="Page Visualization">
|
||||
</div>
|
||||
<div id="modal-extracted-content" class="modal-tab-content">
|
||||
<div id="modal-extracted-gallery" class="modal-gallery">
|
||||
<!-- Extracted images will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-info">
|
||||
<p id="modal-page-info"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="static/batch-processor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
718
frontend/static/batch-processor.js
Normal file
718
frontend/static/batch-processor.js
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
// Batch Processor State Management
|
||||
const batchState = {
|
||||
isProcessing: false,
|
||||
isPaused: false,
|
||||
currentBatchId: null,
|
||||
totalPages: 0,
|
||||
processedPages: 0,
|
||||
startTime: null,
|
||||
pageStatuses: {},
|
||||
results: {
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
totalImages: 0,
|
||||
failedPages: []
|
||||
},
|
||||
autoScroll: true
|
||||
};
|
||||
|
||||
// Timer for elapsed time
|
||||
let elapsedTimer = null;
|
||||
|
||||
// Initialize on page load
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
loadPDFFiles();
|
||||
setupEventListeners();
|
||||
checkBatchEnvironment();
|
||||
});
|
||||
|
||||
// Load available PDF files
|
||||
function loadPDFFiles() {
|
||||
fetch('/pdf-files')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const select = document.getElementById('batch_pdf_file');
|
||||
data.forEach(file => {
|
||||
const option = document.createElement('option');
|
||||
option.value = file;
|
||||
option.textContent = file;
|
||||
select.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading PDFs:', error);
|
||||
addConsoleMessage('Error loading PDF files: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
function setupEventListeners() {
|
||||
// PDF selection change
|
||||
document.getElementById('batch_pdf_file').addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
fetchPDFInfo(this.value);
|
||||
} else {
|
||||
document.getElementById('batch-pdf-info').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Page range radio buttons
|
||||
document.querySelectorAll('input[name="page_range"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
const customRange = document.getElementById('custom-range');
|
||||
if (this.value === 'range') {
|
||||
customRange.classList.remove('hidden');
|
||||
} else {
|
||||
customRange.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch PDF information (page count)
|
||||
function fetchPDFInfo(pdfFile) {
|
||||
fetch(`/pdf-info/${encodeURIComponent(pdfFile)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('total-pages').textContent = data.pageCount;
|
||||
document.getElementById('batch-pdf-info').classList.remove('hidden');
|
||||
|
||||
// Update max values for custom range
|
||||
document.getElementById('start_page').max = data.pageCount;
|
||||
document.getElementById('end_page').max = data.pageCount;
|
||||
document.getElementById('end_page').value = data.pageCount;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching PDF info:', error);
|
||||
addConsoleMessage('Error fetching PDF info: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Start batch processing
|
||||
function startBatchProcessing() {
|
||||
const pdfFile = document.getElementById('batch_pdf_file').value;
|
||||
if (!pdfFile) {
|
||||
alert('Please select a PDF file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get page range
|
||||
const pageRangeType = document.querySelector('input[name="page_range"]:checked').value;
|
||||
let startPage = 1;
|
||||
let endPage = parseInt(document.getElementById('total-pages').textContent);
|
||||
|
||||
if (pageRangeType === 'range') {
|
||||
startPage = parseInt(document.getElementById('start_page').value) || 1;
|
||||
endPage = parseInt(document.getElementById('end_page').value) || endPage;
|
||||
|
||||
if (startPage > endPage) {
|
||||
alert('Invalid page range: start page must be less than or equal to end page');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state
|
||||
resetBatchState();
|
||||
batchState.isProcessing = true;
|
||||
batchState.totalPages = endPage - startPage + 1;
|
||||
batchState.startTime = Date.now();
|
||||
|
||||
// Update UI
|
||||
updateUIForProcessing(true);
|
||||
initializePageGrid(startPage, endPage);
|
||||
|
||||
// Start elapsed timer
|
||||
startElapsedTimer();
|
||||
|
||||
// Show console
|
||||
document.getElementById('console-output').classList.remove('hidden');
|
||||
addConsoleMessage(`Starting batch processing for ${pdfFile} (pages ${startPage}-${endPage})`, 'info');
|
||||
|
||||
// Prepare request data
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_file', pdfFile);
|
||||
formData.append('start_page', startPage);
|
||||
formData.append('end_page', endPage);
|
||||
formData.append('adjust', document.getElementById('batch_adjust').checked);
|
||||
formData.append('parallel', document.getElementById('parallel_processing').checked);
|
||||
formData.append('generate_report', document.getElementById('generate_report').checked);
|
||||
|
||||
// Start batch processing
|
||||
fetch('/run-batch-processor', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
batchState.currentBatchId = data.batch_id;
|
||||
addConsoleMessage(`Batch processing started with ID: ${data.batch_id}`, 'success');
|
||||
|
||||
// Start polling for updates
|
||||
pollBatchStatus();
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to start batch processing');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error starting batch processing:', error);
|
||||
addConsoleMessage('Error: ' + error.message, 'error');
|
||||
finishBatchProcessing(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Poll for batch status updates
|
||||
function pollBatchStatus() {
|
||||
if (!batchState.isProcessing || !batchState.currentBatchId) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/batch-status/${batchState.currentBatchId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateBatchProgress(data);
|
||||
|
||||
if (data.completed) {
|
||||
finishBatchProcessing(true);
|
||||
} else if (!batchState.isPaused) {
|
||||
// Continue polling
|
||||
setTimeout(pollBatchStatus, 1000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error polling batch status:', error);
|
||||
if (batchState.isProcessing) {
|
||||
setTimeout(pollBatchStatus, 2000); // Retry with longer delay
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update batch progress
|
||||
function updateBatchProgress(data) {
|
||||
// Update overall progress
|
||||
const percentage = Math.round((data.processed / data.total) * 100);
|
||||
document.getElementById('overall-percentage').textContent = percentage + '%';
|
||||
document.getElementById('overall-progress-fill').style.width = percentage + '%';
|
||||
document.getElementById('pages-completed').textContent = data.processed;
|
||||
document.getElementById('pages-total').textContent = data.total;
|
||||
|
||||
// Update stage progress
|
||||
if (data.stages) {
|
||||
updateStageProgress('analysis', data.stages.analysis);
|
||||
updateStageProgress('visualization', data.stages.visualization);
|
||||
updateStageProgress('extraction', data.stages.extraction);
|
||||
}
|
||||
|
||||
// Update page statuses
|
||||
if (data.page_statuses) {
|
||||
for (const [pageNum, status] of Object.entries(data.page_statuses)) {
|
||||
updatePageStatus(pageNum, status);
|
||||
}
|
||||
}
|
||||
|
||||
// Update results
|
||||
if (data.results) {
|
||||
batchState.results = data.results;
|
||||
updateResultsSummary();
|
||||
}
|
||||
|
||||
// Add log messages
|
||||
if (data.logs && data.logs.length > 0) {
|
||||
data.logs.forEach(log => {
|
||||
addConsoleMessage(log.message, log.level);
|
||||
});
|
||||
}
|
||||
|
||||
// Update ETA
|
||||
if (data.eta) {
|
||||
document.getElementById('eta-time').textContent = formatTime(data.eta);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stage progress
|
||||
function updateStageProgress(stage, data) {
|
||||
if (!data) return;
|
||||
|
||||
const percentage = Math.round((data.completed / data.total) * 100);
|
||||
document.getElementById(`${stage}-percentage`).textContent = percentage + '%';
|
||||
document.getElementById(`${stage}-progress`).style.width = percentage + '%';
|
||||
}
|
||||
|
||||
// Initialize page grid
|
||||
function initializePageGrid(startPage, endPage) {
|
||||
const grid = document.getElementById('page-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
document.getElementById('batch-progress-panel').classList.remove('hidden');
|
||||
document.getElementById('page-status-grid').classList.remove('hidden');
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const pageItem = createPageStatusItem(i);
|
||||
grid.appendChild(pageItem);
|
||||
batchState.pageStatuses[i] = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
// Create page status item
|
||||
function createPageStatusItem(pageNum) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'page-status-item pending';
|
||||
item.id = `page-status-${pageNum}`;
|
||||
item.innerHTML = `
|
||||
<div class="page-number">Page ${pageNum}</div>
|
||||
<div class="page-status-icon">⏳</div>
|
||||
<div class="page-actions hidden">
|
||||
<button class="mini-btn" onclick="viewPageResults(${pageNum})" title="View results">
|
||||
👁️
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
item.onclick = () => viewPageResults(pageNum);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Update page status
|
||||
function updatePageStatus(pageNum, status) {
|
||||
const item = document.getElementById(`page-status-${pageNum}`);
|
||||
if (!item) return;
|
||||
|
||||
// Remove all status classes
|
||||
item.classList.remove('pending', 'processing', 'completed', 'failed');
|
||||
|
||||
// Add new status class
|
||||
item.classList.add(status.toLowerCase());
|
||||
|
||||
// Update icon
|
||||
const icons = {
|
||||
'pending': '⏳',
|
||||
'processing': '🔄',
|
||||
'completed': '✅',
|
||||
'failed': '❌'
|
||||
};
|
||||
|
||||
const iconElement = item.querySelector('.page-status-icon');
|
||||
iconElement.textContent = icons[status.toLowerCase()] || '❓';
|
||||
|
||||
// Show actions for completed pages
|
||||
if (status.toLowerCase() === 'completed') {
|
||||
item.querySelector('.page-actions').classList.remove('hidden');
|
||||
}
|
||||
|
||||
batchState.pageStatuses[pageNum] = status.toLowerCase();
|
||||
}
|
||||
|
||||
// View page results
|
||||
function viewPageResults(pageNum) {
|
||||
// Check if we have a current batch ID
|
||||
if (!batchState.currentBatchId) {
|
||||
addConsoleMessage('No batch ID available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Open modal with page visualization and extracted images
|
||||
const modal = document.getElementById('batch-image-modal');
|
||||
modal.classList.add('active');
|
||||
|
||||
// Load visualization from the batch report directory
|
||||
const vizImage = document.getElementById('modal-visualization-image');
|
||||
vizImage.src = `/batch-report-image/${batchState.currentBatchId}/visualization_page_${pageNum}.png?t=${Date.now()}`;
|
||||
|
||||
// Handle loading errors
|
||||
vizImage.onerror = function() {
|
||||
// Try the regular results directory as fallback
|
||||
this.src = `/results/visualization_page_${pageNum}.png?t=${Date.now()}`;
|
||||
|
||||
this.onerror = function() {
|
||||
this.alt = 'Visualization not found';
|
||||
console.error('Failed to load visualization');
|
||||
};
|
||||
};
|
||||
|
||||
// Load extracted images
|
||||
loadExtractedImagesForPage(pageNum);
|
||||
|
||||
// Update page info
|
||||
document.getElementById('modal-page-info').textContent = `Page ${pageNum} Results`;
|
||||
}
|
||||
|
||||
// Load extracted images for a specific page
|
||||
function loadExtractedImagesForPage(pageNum) {
|
||||
const gallery = document.getElementById('modal-extracted-gallery');
|
||||
gallery.innerHTML = '<div class="loader"></div> Loading extracted images...';
|
||||
|
||||
// Fetch extracted images for this page
|
||||
fetch(`/results/pictures_page_${pageNum}/index.html`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('No extracted images');
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
// Parse and display images
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const images = doc.querySelectorAll('.picture-card img');
|
||||
|
||||
if (images.length === 0) {
|
||||
gallery.innerHTML = '<p class="no-images">No images extracted from this page</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let galleryHTML = '';
|
||||
images.forEach(img => {
|
||||
const src = img.getAttribute('src');
|
||||
galleryHTML += `
|
||||
<div class="modal-gallery-item">
|
||||
<img src="/results/pictures_page_${pageNum}/${src}"
|
||||
alt="Extracted image"
|
||||
onclick="window.open('/results/pictures_page_${pageNum}/${src}', '_blank')">
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
gallery.innerHTML = galleryHTML;
|
||||
})
|
||||
.catch(error => {
|
||||
gallery.innerHTML = '<p class="no-images">No extracted images available</p>';
|
||||
});
|
||||
}
|
||||
|
||||
// Switch modal tabs
|
||||
function switchModalTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.modal-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Update content
|
||||
document.querySelectorAll('.modal-tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`modal-${tabName}-content`).classList.add('active');
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeBatchImageModal() {
|
||||
document.getElementById('batch-image-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
// Pause batch processing
|
||||
function pauseBatchProcessing() {
|
||||
if (!batchState.isProcessing || batchState.isPaused) return;
|
||||
|
||||
batchState.isPaused = true;
|
||||
|
||||
fetch(`/pause-batch/${batchState.currentBatchId}`, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
addConsoleMessage('Batch processing paused', 'info');
|
||||
document.getElementById('pause-batch-btn').classList.add('hidden');
|
||||
document.getElementById('resume-batch-btn').classList.remove('hidden');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error pausing batch:', error);
|
||||
addConsoleMessage('Error pausing batch: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Resume batch processing
|
||||
function resumeBatchProcessing() {
|
||||
if (!batchState.isProcessing || !batchState.isPaused) return;
|
||||
|
||||
batchState.isPaused = false;
|
||||
|
||||
fetch(`/resume-batch/${batchState.currentBatchId}`, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
addConsoleMessage('Batch processing resumed', 'info');
|
||||
document.getElementById('resume-batch-btn').classList.add('hidden');
|
||||
document.getElementById('pause-batch-btn').classList.remove('hidden');
|
||||
|
||||
// Resume polling
|
||||
pollBatchStatus();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error resuming batch:', error);
|
||||
addConsoleMessage('Error resuming batch: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel batch processing
|
||||
function cancelBatchProcessing() {
|
||||
if (!batchState.isProcessing) return;
|
||||
|
||||
if (!confirm('Are you sure you want to cancel the batch processing?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/cancel-batch/${batchState.currentBatchId}`, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
addConsoleMessage('Batch processing cancelled', 'warning');
|
||||
finishBatchProcessing(false);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error cancelling batch:', error);
|
||||
addConsoleMessage('Error cancelling batch: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Finish batch processing
|
||||
function finishBatchProcessing(success) {
|
||||
batchState.isProcessing = false;
|
||||
stopElapsedTimer();
|
||||
|
||||
// Update UI
|
||||
updateUIForProcessing(false);
|
||||
|
||||
// Show results
|
||||
document.getElementById('batch-results').classList.remove('hidden');
|
||||
updateResultsSummary();
|
||||
|
||||
if (success) {
|
||||
addConsoleMessage('Batch processing completed successfully!', 'success');
|
||||
} else {
|
||||
addConsoleMessage('Batch processing stopped', 'warning');
|
||||
}
|
||||
|
||||
// Update failed pages list if any
|
||||
if (batchState.results.failedPages.length > 0) {
|
||||
showFailedPages();
|
||||
}
|
||||
}
|
||||
|
||||
// Update results summary
|
||||
function updateResultsSummary() {
|
||||
document.getElementById('stat-success').textContent = batchState.results.successful;
|
||||
document.getElementById('stat-failed').textContent = batchState.results.failed;
|
||||
document.getElementById('stat-duration').textContent = formatTime(Date.now() - batchState.startTime);
|
||||
document.getElementById('stat-images').textContent = batchState.results.totalImages;
|
||||
}
|
||||
|
||||
// Show failed pages
|
||||
function showFailedPages() {
|
||||
const failedSection = document.getElementById('failed-pages');
|
||||
const failedList = document.getElementById('failed-list');
|
||||
|
||||
failedSection.classList.remove('hidden');
|
||||
failedList.innerHTML = '';
|
||||
|
||||
batchState.results.failedPages.forEach(page => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'failed-item';
|
||||
item.innerHTML = `
|
||||
<span>Page ${page.pageNum}</span>
|
||||
<span class="failed-reason">${page.reason}</span>
|
||||
<button class="retry-btn" onclick="retryPage(${page.pageNum})">Retry</button>
|
||||
`;
|
||||
failedList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// Retry failed page
|
||||
function retryPage(pageNum) {
|
||||
addConsoleMessage(`Retrying page ${pageNum}...`, 'info');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_file', document.getElementById('batch_pdf_file').value);
|
||||
formData.append('page_num', pageNum);
|
||||
formData.append('adjust', document.getElementById('batch_adjust').checked);
|
||||
|
||||
// Update page status
|
||||
updatePageStatus(pageNum, 'processing');
|
||||
|
||||
fetch('/retry-page', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
updatePageStatus(pageNum, 'completed');
|
||||
addConsoleMessage(`Page ${pageNum} processed successfully`, 'success');
|
||||
|
||||
// Update results
|
||||
batchState.results.successful++;
|
||||
batchState.results.failed--;
|
||||
batchState.results.failedPages = batchState.results.failedPages.filter(p => p.pageNum !== pageNum);
|
||||
updateResultsSummary();
|
||||
|
||||
if (batchState.results.failedPages.length === 0) {
|
||||
document.getElementById('failed-pages').classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
updatePageStatus(pageNum, 'failed');
|
||||
addConsoleMessage(`Failed to process page ${pageNum}: ${data.error}`, 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
updatePageStatus(pageNum, 'failed');
|
||||
addConsoleMessage(`Error retrying page ${pageNum}: ${error.message}`, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Download all results
|
||||
function downloadResults() {
|
||||
addConsoleMessage('Preparing results for download...', 'info');
|
||||
|
||||
fetch(`/download-batch-results/${batchState.currentBatchId}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to prepare download');
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `doctags_batch_results_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
addConsoleMessage('Download started', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
addConsoleMessage('Error downloading results: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// View detailed report
|
||||
function viewReport() {
|
||||
window.open(`/batch-report/${batchState.currentBatchId}`, '_blank');
|
||||
}
|
||||
|
||||
// Open results folder
|
||||
function openResultsFolder() {
|
||||
addConsoleMessage('Opening results folder...', 'info');
|
||||
|
||||
fetch('/open-results-folder', { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
addConsoleMessage('Results folder opened', 'success');
|
||||
} else {
|
||||
addConsoleMessage('Could not open results folder: ' + data.error, 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
addConsoleMessage('Error: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Console functions
|
||||
function addConsoleMessage(message, level = 'info') {
|
||||
const console = document.getElementById('console-content');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `console-message ${level}`;
|
||||
messageDiv.innerHTML = `<span class="timestamp">[${timestamp}]</span> ${message}`;
|
||||
|
||||
console.appendChild(messageDiv);
|
||||
|
||||
if (batchState.autoScroll) {
|
||||
console.scrollTop = console.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function clearConsole() {
|
||||
document.getElementById('console-content').innerHTML = '';
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
batchState.autoScroll = !batchState.autoScroll;
|
||||
document.getElementById('autoscroll-status').textContent =
|
||||
`Auto-scroll: ${batchState.autoScroll ? 'ON' : 'OFF'}`;
|
||||
}
|
||||
|
||||
// Timer functions
|
||||
function startElapsedTimer() {
|
||||
elapsedTimer = setInterval(() => {
|
||||
const elapsed = Date.now() - batchState.startTime;
|
||||
document.getElementById('elapsed-time').textContent = formatTime(elapsed);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopElapsedTimer() {
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer);
|
||||
elapsedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatTime(milliseconds) {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetBatchState() {
|
||||
batchState.isProcessing = false;
|
||||
batchState.isPaused = false;
|
||||
batchState.currentBatchId = null;
|
||||
batchState.totalPages = 0;
|
||||
batchState.processedPages = 0;
|
||||
batchState.startTime = null;
|
||||
batchState.pageStatuses = {};
|
||||
batchState.results = {
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
totalImages: 0,
|
||||
failedPages: []
|
||||
};
|
||||
}
|
||||
|
||||
function updateUIForProcessing(isProcessing) {
|
||||
// Toggle button visibility
|
||||
document.getElementById('start-batch-btn').classList.toggle('hidden', isProcessing);
|
||||
document.getElementById('pause-batch-btn').classList.toggle('hidden', !isProcessing);
|
||||
document.getElementById('cancel-batch-btn').classList.toggle('hidden', !isProcessing);
|
||||
|
||||
// Disable form inputs during processing
|
||||
const inputs = document.querySelectorAll('.settings-panel input, .settings-panel select');
|
||||
inputs.forEach(input => {
|
||||
input.disabled = isProcessing;
|
||||
});
|
||||
}
|
||||
|
||||
// Check batch environment
|
||||
function checkBatchEnvironment() {
|
||||
fetch('/check-environment')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const envDiv = document.getElementById('batch-environment-check');
|
||||
const envDetails = document.getElementById('batch-env-details');
|
||||
|
||||
if (data.missing_scripts.length > 0 || data.pdf_files.length === 0) {
|
||||
envDiv.classList.remove('hidden');
|
||||
|
||||
let html = '<ul>';
|
||||
if (data.missing_scripts.length > 0) {
|
||||
html += '<li class="env-error">Missing scripts: ' + data.missing_scripts.join(', ') + '</li>';
|
||||
}
|
||||
if (data.pdf_files.length === 0) {
|
||||
html += '<li class="env-error">No PDF files found in working directory</li>';
|
||||
}
|
||||
html += '</ul>';
|
||||
|
||||
envDetails.innerHTML = html;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking environment:', error);
|
||||
});
|
||||
}
|
||||
616
frontend/static/batch-styles.css
Normal file
616
frontend/static/batch-styles.css
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
/* Batch Processor Specific Styles */
|
||||
|
||||
/* Navigation link */
|
||||
.nav-link {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.nav-link a {
|
||||
color: var(--primary-medium);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link a:hover {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Batch Settings Grid */
|
||||
.batch-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.pdf-info {
|
||||
margin-top: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Page Range Controls */
|
||||
.page-range-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
color: var(--text-medium);
|
||||
}
|
||||
|
||||
.radio-label input[type="radio"] {
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.custom-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.custom-range input {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
/* Processing Options */
|
||||
.processing-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Batch Progress Panel */
|
||||
.batch-progress-panel {
|
||||
background: var(--surface-white);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-xl) 0;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* Overall Progress */
|
||||
.overall-progress {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.progress-header span:first-child {
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
#overall-percentage {
|
||||
font-weight: 600;
|
||||
color: var(--primary-medium);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 24px;
|
||||
background: var(--surface-light);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.progress-bar.small {
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-medium), var(--primary-dark));
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.2) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
rgba(255, 255, 255, 0.2) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 30px 30px;
|
||||
animation: progress-stripes 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-stripes {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 30px 0; }
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--border-light);
|
||||
}
|
||||
|
||||
/* Stage Progress */
|
||||
.stage-progress {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stage-item {
|
||||
padding: var(--spacing-md);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.stage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stage-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stage-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.stage-percentage {
|
||||
font-weight: 600;
|
||||
color: var(--text-medium);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Stage-specific progress colors */
|
||||
.progress-fill.analysis {
|
||||
background: linear-gradient(90deg, #3498db, #2980b9);
|
||||
}
|
||||
|
||||
.progress-fill.visualization {
|
||||
background: linear-gradient(90deg, #e74c3c, #c0392b);
|
||||
}
|
||||
|
||||
.progress-fill.extraction {
|
||||
background: linear-gradient(90deg, #2ecc71, #27ae60);
|
||||
}
|
||||
|
||||
/* Batch Controls */
|
||||
.batch-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
margin: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--primary-medium);
|
||||
color: white;
|
||||
padding: var(--spacing-md) var(--spacing-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--accent-amber);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #f0c674;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* Page Status Grid */
|
||||
.page-status-grid {
|
||||
background: var(--surface-white);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-xl) 0;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.page-status-item {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-status-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.page-status-item.pending {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.page-status-item.processing {
|
||||
border-color: var(--accent-amber);
|
||||
background: linear-gradient(135deg, var(--accent-amber) 0%, transparent 100%);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.page-status-item.completed {
|
||||
border-color: var(--success);
|
||||
background: var(--accent-mint);
|
||||
}
|
||||
|
||||
.page-status-item.failed {
|
||||
border-color: var(--error);
|
||||
background: var(--accent-rose);
|
||||
}
|
||||
|
||||
.page-number {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.page-status-icon {
|
||||
font-size: 1.5rem;
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
padding: 2px 4px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--surface-white);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Batch Results */
|
||||
.batch-results {
|
||||
background: var(--surface-white);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-xl) 0;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
margin: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--primary-medium);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Failed Pages */
|
||||
.failed-pages {
|
||||
margin-top: var(--spacing-xl);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--accent-rose);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
.failed-pages h4 {
|
||||
color: var(--error);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.failed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.failed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--surface-white);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.failed-reason {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-medium);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: var(--warning);
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Console Output */
|
||||
.console-output {
|
||||
background: var(--text-dark);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-xl) 0;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.console-header h4 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.console-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.console-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.console-content {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.console-message {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.console-message.info {
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
.console-message.success {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.console-message.warning {
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.console-message.error {
|
||||
color: #e57373;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #90a4ae;
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Modal Enhancements */
|
||||
.modal-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--surface-light);
|
||||
}
|
||||
|
||||
.modal-tab {
|
||||
flex: 1;
|
||||
padding: var(--spacing-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--text-medium);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-tab.active {
|
||||
color: var(--primary-dark);
|
||||
border-bottom: 2px solid var(--primary-medium);
|
||||
}
|
||||
|
||||
.modal-tab-content {
|
||||
display: none;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-gallery-item {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-gallery-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal-gallery-item img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.batch-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stage-progress {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.batch-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.console-content {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue