import os import uuid import base64 import io import zipfile import threading import copy import re from datetime import datetime, timedelta from flask import Blueprint, render_template, request, jsonify, current_app, url_for, send_from_directory, send_file, redirect from flask_login import login_required, current_user from werkzeug.utils import secure_filename import shlex import fitz from urllib.parse import urlparse import requests import cv2 import numpy as np from database import get_folder_tree, get_all_descendant_folder_ids from processing import ( resize_image_if_needed, call_nim_ocr_api, extract_question_number_from_ocr_result, crop_image_perspective, create_pdf_from_full_images, remove_color_from_image ) from strings import * from utils import get_db_connection, create_a4_pdf_from_images from redact import redact_pictures_in_image from resize import expand_pdf_for_notes # Global dictionary to store async processing status # Key: session_id, Value: {'status': 'processing'|'completed'|'error', 'progress': int, 'total': int, 'message': str} upload_progress = {} main_bp = Blueprint('main', __name__) @main_bp.route('/upload_progress/') @login_required def get_upload_progress(session_id): status = upload_progress.get(session_id) if not status: # Check if session exists in DB (maybe it finished and server restarted, or we missed it) conn = get_db_connection() exists = conn.execute('SELECT id FROM sessions WHERE id = ?', (session_id,)).fetchone() conn.close() if exists: return jsonify({'status': 'completed', 'progress': 100}) return jsonify({'error': 'Session not found or processing not started'}), 404 return jsonify(status) def process_pdf_background(session_id, user_id, original_filename, pdf_content, app_config): """Background task to process PDF splitting.""" upload_progress[session_id] = {'status': 'processing', 'progress': 0, 'message': 'Starting...'} try: # We need to manually create a connection since we are in a thread # And we can't use current_app context directly if not carefully managed, # but we passed app_config to reconstruct paths. # Database connection needs to be fresh. conn = get_db_connection() # This creates a new connection pdf_filename = f"{session_id}_{original_filename}" pdf_path = os.path.join(app_config['UPLOAD_FOLDER'], pdf_filename) with open(pdf_path, 'wb') as f: f.write(pdf_content) doc = fitz.open(pdf_path) total_pages = len(doc) upload_progress[session_id]['total'] = total_pages # Fetch user DPI - we need to query it since current_user proxy might not work in thread user_row = conn.execute("SELECT dpi FROM users WHERE id = ?", (user_id,)).fetchone() dpi = user_row['dpi'] if user_row else 150 for i, page in enumerate(doc): pix = page.get_pixmap(dpi=dpi) page_filename = f"{session_id}_page_{i}.png" page_path = os.path.join(app_config['UPLOAD_FOLDER'], page_filename) pix.save(page_path) conn.execute( 'INSERT INTO images (session_id, image_index, filename, original_name, image_type) VALUES (?, ?, ?, ?, ?)', (session_id, i, page_filename, f"Page {i+1}", 'original') ) # Update progress progress = int(((i + 1) / total_pages) * 100) upload_progress[session_id].update({'progress': progress, 'message': f'Processed page {i+1}/{total_pages}'}) conn.commit() conn.close() doc.close() upload_progress[session_id] = {'status': 'completed', 'progress': 100, 'message': 'Done'} except Exception as e: print(f"Async processing error: {e}") upload_progress[session_id] = {'status': 'error', 'message': str(e)} if 'conn' in locals(): conn.close() # ... existing imports ... @main_bp.route('/process_color_rm_batch', methods=['POST']) @login_required def process_color_rm_batch(): data = request.json session_id = data.get('session_id') target_colors = data.get('colors', []) threshold = data.get('threshold', 0.8) bg_mode = data.get('bg_mode', 'black') region_box = data.get('region', None) conn = get_db_connection() # Check ownership session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 # Get all original images original_images = conn.execute( "SELECT * FROM images WHERE session_id = ? AND image_type = 'original' ORDER BY image_index", (session_id,) ).fetchall() processed_count = 0 try: for img in original_images: original_path = os.path.join(current_app.config['UPLOAD_FOLDER'], img['filename']) if not os.path.exists(original_path): continue # Process processed_img_cv = remove_color_from_image(original_path, target_colors, threshold, bg_mode, region_box) # Save processed_filename = f"color_rm_{session_id}_{img['image_index']}_{datetime.now().strftime('%H%M%S')}.png" processed_path = os.path.join(current_app.config['PROCESSED_FOLDER'], processed_filename) cv2.imwrite(processed_path, processed_img_cv) # Update DB (upsert logic roughly) # Check if exists first existing = conn.execute( "SELECT id FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'color_rm'", (session_id, img['image_index']) ).fetchone() if existing: conn.execute( "UPDATE images SET processed_filename = ?, filename = ? WHERE id = ?", (processed_filename, img['filename'], existing['id']) ) else: conn.execute( 'INSERT INTO images (session_id, image_index, filename, original_name, processed_filename, image_type) VALUES (?, ?, ?, ?, ?, ?)', (session_id, img['image_index'], img['filename'], img['original_name'], processed_filename, 'color_rm') ) processed_count += 1 conn.commit() return jsonify({'success': True, 'count': processed_count}) except Exception as e: return jsonify({'error': str(e)}), 500 finally: conn.close() @main_bp.route('/generate_color_rm_pdf/') @login_required def generate_color_rm_pdf(session_id): conn = get_db_connection() # Check ownership session_data = conn.execute('SELECT user_id, original_filename FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_data or session_data['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 # Range filtering start_page = request.args.get('start', type=int) end_page = request.args.get('end', type=int) query = "SELECT * FROM images WHERE session_id = ? AND image_type = 'original'" params = [session_id] if start_page: query += " AND image_index >= ?" params.append(start_page - 1) # 0-based index if end_page: query += " AND image_index <= ?" params.append(end_page - 1) query += " ORDER BY image_index" images = conn.execute(query, params).fetchall() pdf_image_paths = [] for img in images: # Check for processed version processed = conn.execute( "SELECT processed_filename FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'color_rm'", (session_id, img['image_index']) ).fetchone() if processed and processed['processed_filename']: path = os.path.join(current_app.config['PROCESSED_FOLDER'], processed['processed_filename']) else: # Fallback to original path = os.path.join(current_app.config['UPLOAD_FOLDER'], img['filename']) if os.path.exists(path): pdf_image_paths.append(path) if not pdf_image_paths: conn.close() return "No images found to generate PDF", 404 # Generate PDF range_suffix = "" if start_page or end_page: range_suffix = f"_pg{start_page or 1}-{end_page or 'end'}" pdf_filename = f"Color_Removed_{session_data['original_filename']}{range_suffix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" if not pdf_filename.lower().endswith('.pdf'): pdf_filename += ".pdf" output_path = os.path.join(current_app.config['OUTPUT_FOLDER'], pdf_filename) # Use user's preferred color_rm_dpi for PDF resolution pdf_resolution = current_user.color_rm_dpi if hasattr(current_user, 'color_rm_dpi') else 200.0 success = create_pdf_from_full_images(pdf_image_paths, output_path, resolution=pdf_resolution) if success: conn.execute( 'INSERT INTO generated_pdfs (session_id, filename, subject, user_id) VALUES (?, ?, ?, ?)', (session_id, pdf_filename, "Color Removal Export", current_user.id) ) conn.commit() conn.close() return redirect(url_for('main.pdf_manager')) # Redirect to manager or download directly else: conn.close() return "Failed to generate PDF", 500 @main_bp.route('/tmp/') @login_required # Should still protect temp files if they are user-specific def serve_tmp_file(filename): # In a real multi-user scenario, you'd check if this temp file belongs to the user return send_from_directory(current_app.config['TEMP_FOLDER'], filename) @main_bp.route('/processed/') @login_required def serve_processed_file(filename): # This is a critical security change. Before serving a processed file, # we must check if it belongs to the current user. conn = get_db_connection() image_owner = conn.execute( "SELECT s.user_id FROM images i JOIN sessions s ON i.session_id = s.id WHERE i.processed_filename = ?", (filename,) ).fetchone() conn.close() if image_owner and image_owner['user_id'] == current_user.id: return send_from_directory(current_app.config['PROCESSED_FOLDER'], filename) else: return "Unauthorized", 403 NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY") NVIDIA_NIM_AVAILABLE = bool(NVIDIA_API_KEY) @main_bp.route('/process_final_pdf/') @login_required def process_final_pdf(pdf_id): conn = get_db_connection() # Security: Check if the PDF belongs to the current user pdf_info = conn.execute('SELECT filename FROM generated_pdfs WHERE id = ? AND user_id = ?', (pdf_id, current_user.id)).fetchone() if not pdf_info: conn.close() flash("PDF not found or you don't have permission to access it.", "warning") return redirect(url_for('main.index_v2')) original_filename = pdf_info['filename'] pdf_path = os.path.join(current_app.config['OUTPUT_FOLDER'], original_filename) if not os.path.exists(pdf_path): conn.close() flash("PDF file is missing from disk.", "danger") return redirect(url_for('main.index_v2')) session_id = str(uuid.uuid4()) # Associate new session with the current user conn.execute('INSERT INTO sessions (id, original_filename, user_id) VALUES (?, ?, ?)', (session_id, original_filename, current_user.id)) doc = fitz.open(pdf_path) for i, page in enumerate(doc): pix = page.get_pixmap(dpi=current_user.dpi) page_filename = f"{session_id}_page_{i}.png" page_path = os.path.join(current_app.config['UPLOAD_FOLDER'], page_filename) pix.save(page_path) conn.execute( 'INSERT INTO images (session_id, image_index, filename, original_name, image_type) VALUES (?, ?, ?, ?, ?)', (session_id, i, page_filename, f"Page {i+1}", 'original') ) conn.commit() conn.close() doc.close() return redirect(url_for('main.crop_interface_v2', session_id=session_id, image_index=0)) @main_bp.route(ROUTE_INDEX_V2) @login_required def index_v2(): conn = get_db_connection() pdfs = conn.execute('SELECT id, filename, subject, tags, notes, persist FROM generated_pdfs WHERE user_id = ? ORDER BY created_at DESC', (current_user.id,)).fetchall() conn.close() return render_template('indexv2.html', pdfs=[dict(row) for row in pdfs]) def _parse_curl_command(command): current_app.logger.info(f"Parsing cURL command: {command}") try: parts = shlex.split(command) except ValueError as e: current_app.logger.error(f"shlex splitting failed for command: '{command}'. Error: {e}") # Fallback to simple split for commands that might not be perfectly quoted parts = command.split() current_app.logger.info(f"Command parts: {parts}") url, output_filename = None, None # First, try to find the output filename if -o is present try: if '-o' in parts: o_index = parts.index('-o') if o_index + 1 < len(parts): output_filename = parts[o_index + 1] # shlex handles quotes except ValueError: pass # -o not found, handled below # Then, find the URL (always starts with http) for part in parts: if part.startswith('http'): url = part break # If URL found but no output filename was specified with -o, derive from URL if url and not output_filename: output_filename = os.path.basename(urlparse(url).path) if not output_filename: # Fallback if path is empty (e.g., http://example.com) output_filename = "downloaded_pdf.pdf" if not output_filename.lower().endswith('.pdf'): output_filename += '.pdf' # Ensure it has a .pdf extension current_app.logger.info(f"Parsed URL: {url}, Filename: {output_filename}") return url, output_filename def convert_google_drive_url(url): """ Converts a Google Drive view/sharing URL into a direct download URL. Returns the original URL if it's not a Google Drive URL or if conversion fails. """ try: # Regex to extract the file ID from common Google Drive URL patterns # Matches: .../file/d/FILE_ID/... or ...?id=FILE_ID... match = re.search(r'/file/d/([a-zA-Z0-9_-]+)|id=([a-zA-Z0-9_-]+)', url) if match: # group(1) matches /file/d/..., group(2) matches ?id=... file_id = match.group(1) or match.group(2) if file_id: return f'https://drive.google.com/uc?export=download&id={file_id}' except Exception as e: current_app.logger.warning(f"Error converting Google Drive URL: {e}") return url @main_bp.route('/v2/upload', methods=['POST']) @login_required def v2_upload(): session_id = str(uuid.uuid4()) pdf_content, original_filename = None, None try: # Case 1: Direct file upload if 'pdf' in request.files and request.files['pdf'].filename: file = request.files['pdf'] if file and file.filename.lower().endswith('.pdf'): original_filename = secure_filename(file.filename) pdf_content = file.read() else: return jsonify({'error': 'Invalid file type, please upload a PDF'}), 400 # Case 2: URL upload elif 'pdf_url' in request.form and request.form['pdf_url']: pdf_url = request.form['pdf_url'] # Handle Google Drive URLs pdf_url = convert_google_drive_url(pdf_url) response = requests.get(pdf_url, allow_redirects=True) response.raise_for_status() # Try to get filename from Content-Disposition header if 'Content-Disposition' in response.headers: cd = response.headers['Content-Disposition'] # Simple extraction, can be improved with cgi.parse_header or similar fname_match = re.search(r'filename="?([^";]+)"?', cd) if fname_match: original_filename = fname_match.group(1) if not original_filename: original_filename = os.path.basename(urlparse(pdf_url).path) if not original_filename or not original_filename.lower().endswith('.pdf'): original_filename = 'downloaded_document.pdf' pdf_content = response.content # Case 3: cURL command upload elif 'curl_command' in request.form and request.form['curl_command']: # For simplicity, we handle one cURL command at a time for the analysis workflow command = request.form['curl_command'].strip().split('\n')[0] url, filename = _parse_curl_command(command) if not url or not filename: return jsonify({'error': f"Could not parse cURL command: {command}"}), 400 # Handle Google Drive URLs in cURL too (though unlikely if cURL is used correctly) url = convert_google_drive_url(url) response = requests.get(url, allow_redirects=True) response.raise_for_status() original_filename = filename pdf_content = response.content else: return jsonify({'error': 'No PDF file, URL, or cURL command provided'}), 400 if not pdf_content or not original_filename: return jsonify({'error': 'Failed to retrieve PDF content or filename'}), 500 session_type = request.form.get('type', 'standard') conn = get_db_connection() conn.execute('INSERT INTO sessions (id, original_filename, name, user_id, session_type) VALUES (?, ?, ?, ?, ?)', (session_id, original_filename, original_filename, current_user.id, session_type)) conn.commit() # Commit session creation first conn.close() # Check for async request is_async = request.args.get('async') == 'true' if is_async: # Start background thread # We pass app config copy to be safe app_config = current_app.config.copy() thread = threading.Thread(target=process_pdf_background, args=(session_id, current_user.id, original_filename, pdf_content, app_config)) thread.start() return jsonify({'session_id': session_id, 'status': 'processing'}) # --- Sync processing logic --- # Re-open connection for sync processing conn = get_db_connection() pdf_filename = f"{session_id}_{original_filename}" pdf_path = os.path.join(current_app.config['UPLOAD_FOLDER'], pdf_filename) with open(pdf_path, 'wb') as f: f.write(pdf_content) doc = fitz.open(pdf_path) page_files = [] for i, page in enumerate(doc): pix = page.get_pixmap(dpi=current_user.dpi) page_filename = f"{session_id}_page_{i}.png" page_path = os.path.join(current_app.config['UPLOAD_FOLDER'], page_filename) pix.save(page_path) conn.execute( 'INSERT INTO images (session_id, image_index, filename, original_name, image_type) VALUES (?, ?, ?, ?, ?)', (session_id, i, page_filename, f"Page {i+1}", 'original') ) page_files.append({'filename': page_filename, 'original_name': f"Page {i+1}", 'index': i}) conn.commit() conn.close() doc.close() return jsonify({'session_id': session_id, 'files': page_files}) except requests.RequestException as e: return jsonify({'error': f"Failed to download PDF from URL: {e}"}), 500 except Exception as e: # Ensure connection is closed on error if 'conn' in locals() and conn: conn.rollback() conn.close() current_app.logger.error(f"An error occurred during v2 upload: {e}") return jsonify({'error': "An internal error occurred while processing the PDF."}), 500 @main_bp.route(ROUTE_IMAGES) @login_required def image_upload(): return render_template('image_upload.html') @main_bp.route(ROUTE_UPLOAD_IMAGES, methods=[METHOD_POST]) @login_required def upload_images(): session_id = str(uuid.uuid4()) if 'images' not in request.files: return jsonify({'error': 'No image files part'}), 400 files = request.files.getlist('images') if not files or all(f.filename == '' for f in files): return jsonify({'error': 'No selected files'}), 400 valid_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp'} for file in files: if not file or not any(file.filename.lower().endswith(ext) for ext in valid_extensions): return jsonify({'error': 'Invalid file type. Please upload only image files (PNG, JPG, JPEG, GIF, BMP)'}), 400 session_type = request.form.get('type', 'standard') conn = get_db_connection() original_filename = f"{len(files)} images" if len(files) > 1 else secure_filename(files[0].filename) if files else "images" conn.execute('INSERT INTO sessions (id, original_filename, name, user_id, session_type) VALUES (?, ?, ?, ?, ?)', (session_id, original_filename, original_filename, current_user.id, session_type)) uploaded_files = [] for i, file in enumerate(files): if file and file.filename != '': filename = f"{session_id}_{secure_filename(file.filename)}" file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) file.save(file_path) conn.execute( 'INSERT INTO images (session_id, image_index, filename, original_name, image_type) VALUES (?, ?, ?, ?, ?)', (session_id, i, filename, secure_filename(file.filename), 'original') ) uploaded_files.append({'filename': filename, 'original_name': secure_filename(file.filename), 'index': i}) conn.commit() conn.close() return jsonify({'session_id': session_id, 'files': uploaded_files}) @main_bp.route('/api/session_images/') @login_required def get_session_images(session_id): conn = get_db_connection() # Security check session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 # Get all original images originals = conn.execute( "SELECT image_index, filename FROM images WHERE session_id = ? AND image_type = 'original' ORDER BY image_index", (session_id,) ).fetchall() # Get processed status processed = conn.execute( "SELECT image_index, processed_filename FROM images WHERE session_id = ? AND image_type = 'color_rm'", (session_id,) ).fetchall() processed_map = {row['image_index']: row['processed_filename'] for row in processed} images_list = [] for img in originals: idx = img['image_index'] p_filename = processed_map.get(idx) images_list.append({ 'index': idx, 'page_number': idx + 1, 'original_url': url_for('main.serve_image', folder='uploads', filename=img['filename']), 'processed_url': url_for('main.serve_processed_file', filename=p_filename) if p_filename else None, 'is_processed': bool(p_filename) }) conn.close() return jsonify({'images': images_list}) @main_bp.route('/cropv2//') @login_required def crop_interface_v2(session_id, image_index): conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return "Unauthorized", 403 image_info = conn.execute( "SELECT * FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'original'", (session_id, image_index) ).fetchone() if not image_info: conn.close() return "Original page/image not found for this session and index.", 404 total_pages_result = conn.execute( "SELECT COUNT(*) FROM images WHERE session_id = ? AND image_type = 'original'", (session_id,) ).fetchone() total_pages = total_pages_result[0] if total_pages_result else 0 # Fetch all pages for the slider all_pages_rows = conn.execute( "SELECT image_index, filename FROM images WHERE session_id = ? AND image_type = 'original' ORDER BY image_index ASC", (session_id,) ).fetchall() all_pages = [{'image_index': row['image_index'], 'filename': row['filename']} for row in all_pages_rows] conn.close() return render_template( 'cropv2.html', session_id=session_id, user_id=current_user.id, # Pass user ID to template image_index=image_index, image_info=image_info, total_pages=total_pages, all_pages=all_pages # Pass all pages for the slider ) @main_bp.route(ROUTE_PROCESS_CROP_V2, methods=[METHOD_POST]) @login_required def process_crop_v2(): data = request.json session_id, page_index, boxes_data, image_data_url = data['session_id'], data['image_index'], data['boxes'], data.get('imageData') conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 page_info = conn.execute( "SELECT filename FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'original'", (session_id, page_index) ).fetchone() if not page_info: conn.close() return jsonify({'error': 'Original page not found in session'}), 404 try: header, encoded = image_data_url.split(",", 1) image_data = base64.b64decode(encoded) temp_filename = f"temp_filtered_{page_info['filename']}" temp_path = os.path.join(current_app.config['PROCESSED_FOLDER'], temp_filename) with open(temp_path, "wb") as f: f.write(image_data) existing_cropped = conn.execute( "SELECT id, processed_filename FROM images WHERE session_id = ? AND filename = ? AND image_type = 'cropped'", (session_id, page_info['filename']) ).fetchall() for cropped_img in existing_cropped: if cropped_img['processed_filename']: try: os.remove(os.path.join(current_app.config['PROCESSED_FOLDER'], cropped_img['processed_filename'])) except OSError: pass conn.execute("DELETE FROM questions WHERE session_id = ? AND image_id = ?", (session_id, cropped_img['id'])) conn.execute( "DELETE FROM images WHERE session_id = ? AND filename = ? AND image_type = 'cropped'", (session_id, page_info['filename']) ) # Identify boxes on the current page that are acting as sources for other boxes # This prevents them from being saved as standalone questions if they are merged local_source_ids = set() for box in boxes_data: if box.get('remote_stitch_source'): src = box['remote_stitch_source'] if src.get('page_index') == page_index: # The source box is on this page. Add its ID to the ignore list. # Note: src['box'] might handle ID as string or int, ensure consistency if needed local_source_ids.add(src['box']['id']) primary_boxes = [box for box in boxes_data if not box.get('stitch_to')] processed_boxes = [] for i, primary_box in enumerate(primary_boxes): # Skip if this box is being consumed by another box on the same page if primary_box['id'] in local_source_ids: continue # --- CROSS-PAGE STITCHING LOGIC --- if primary_box.get('remote_stitch_source'): source_info = primary_box['remote_stitch_source'] source_page_index = source_info['page_index'] source_box = source_info['box'] # Attempt to delete the original source image/question to prevent duplicates # We use the unique box ID provided by the frontend if 'id' in source_box: source_box_id = str(source_box['id']) # Find the image entry source_img_row = conn.execute( "SELECT id, processed_filename FROM images WHERE session_id = ? AND box_id = ?", (session_id, source_box_id) ).fetchone() if source_img_row: # Delete associated question if any conn.execute("DELETE FROM questions WHERE image_id = ?", (source_img_row['id'],)) # Delete the image entry conn.execute("DELETE FROM images WHERE id = ?", (source_img_row['id'],)) # Optionally delete the file, but might be risky if logic is flawed. # Leaving file cleanup to general cleanup or overwrite. # Fetch source page filename source_page_db = conn.execute( "SELECT filename FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'original'", (session_id, source_page_index) ).fetchone() if source_page_db: source_filename = source_page_db['filename'] source_path = os.path.join(current_app.config['UPLOAD_FOLDER'], source_filename) if os.path.exists(source_path): # Crop Source (Parent) src_points = [ {'x': source_box['x'], 'y': source_box['y']}, {'x': source_box['x'] + source_box['w'], 'y': source_box['y']}, {'x': source_box['x'] + source_box['w'], 'y': source_box['y'] + source_box['h']}, {'x': source_box['x'], 'y': source_box['y'] + source_box['h']} ] # We use the original source file for the parent crop parent_crop = crop_image_perspective(source_path, src_points) # Crop Current (Child) child_points = [ {'x': primary_box['x'], 'y': primary_box['y']}, {'x': primary_box['x'] + primary_box['w'], 'y': primary_box['y']}, {'x': primary_box['x'] + primary_box['w'], 'y': primary_box['y'] + primary_box['h']}, {'x': primary_box['x'], 'y': primary_box['y'] + primary_box['h']} ] child_crop = crop_image_perspective(temp_path, child_points) # Stitch (Parent Top, Child Bottom) h1, w1 = parent_crop.shape[:2] h2, w2 = child_crop.shape[:2] max_width = max(w1, w2) stitched_image = np.full((h1 + h2, max_width, 3), 255, dtype=np.uint8) x_offset1 = (max_width - w1) // 2 stitched_image[0:h1, x_offset1:x_offset1 + w1] = parent_crop x_offset2 = (max_width - w2) // 2 stitched_image[h1:h1 + h2, x_offset2:x_offset2 + w2] = child_crop else: # Fallback if source file missing current_app.logger.error(f"Source file missing for stitch: {source_path}") # Just crop the child points = [ {'x': primary_box['x'], 'y': primary_box['y']}, {'x': primary_box['x'] + primary_box['w'], 'y': primary_box['y']}, {'x': primary_box['x'] + primary_box['w'], 'y': primary_box['y'] + primary_box['h']}, {'x': primary_box['x'], 'y': primary_box['y'] + primary_box['h']} ] stitched_image = crop_image_perspective(temp_path, points) else: # Fallback if db lookup fails current_app.logger.error(f"Source page DB record missing: session {session_id} index {source_page_index}") points = [ {'x': primary_box['x'], 'y': primary_box['y']}, {'x': primary_box['x'] + primary_box['w'], 'y': primary_box['y']}, {'x': primary_box['x'] + primary_box['w'], 'y': primary_box['y'] + primary_box['h']}, {'x': primary_box['x'], 'y': primary_box['y'] + primary_box['h']} ] stitched_image = crop_image_perspective(temp_path, points) # --- STANDARD LOCAL STITCHING OR SINGLE BOX LOGIC --- else: children = [box for box in boxes_data if box.get('stitch_to') == primary_box['id']] points = [ {'x': primary_box['x'], 'y': primary_box['y']}, {'x': primary_box['x'] + primary_box['w'], 'y': primary_box['y']}, {'x': primary_box['x'] + primary_box['w'], 'y': primary_box['y'] + primary_box['h']}, {'x': primary_box['x'], 'y': primary_box['y'] + primary_box['h']} ] primary_crop = crop_image_perspective(temp_path, points) stitched_image = primary_crop if children: child = children[0] child_points = [ {'x': child['x'], 'y': child['y']}, {'x': child['x'] + child['w'], 'y': child['y']}, {'x': child['x'] + child['w'], 'y': child['y'] + child['h']}, {'x': child['x'], 'y': child['y'] + child['h']} ] child_crop = crop_image_perspective(temp_path, child_points) h1, w1 = primary_crop.shape[:2] h2, w2 = child_crop.shape[:2] max_width = max(w1, w2) stitched_image = np.full((h1 + h2, max_width, 3), 255, dtype=np.uint8) x_offset1 = (max_width - w1) // 2 stitched_image[0:h1, x_offset1:x_offset1 + w1] = primary_crop x_offset2 = (max_width - w2) // 2 stitched_image[h1:h1 + h2, x_offset2:x_offset2 + w2] = child_crop crop_filename = f"processed_{session_id}_page{page_index}_crop{i}.jpg" crop_path = os.path.join(current_app.config['PROCESSED_FOLDER'], crop_filename) cv2.imwrite(crop_path, stitched_image) processed_boxes.append({ 'original_filename': page_info['filename'], 'original_name': f"Page {page_index + 1} - Q{i + 1}", 'processed_filename': crop_filename, 'box_id': str(primary_box['id']), # Store box ID for future stitching reference 'question_number': primary_box.get('question_number'), 'status': primary_box.get('status'), 'marked_solution': primary_box.get('marked_solution'), 'actual_solution': primary_box.get('actual_solution') }) max_index_result = conn.execute('SELECT MAX(image_index) FROM images WHERE session_id = ?', (session_id,)).fetchone() next_index = (max_index_result[0] if max_index_result[0] is not None else -1) + 1 for i, p_box in enumerate(processed_boxes): conn.execute( 'INSERT INTO images (session_id, image_index, filename, original_name, processed_filename, image_type, box_id) VALUES (?, ?, ?, ?, ?, ?, ?)', (session_id, next_index + i, p_box['original_filename'], p_box['original_name'], p_box['processed_filename'], 'cropped', p_box['box_id']) ) image_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0] conn.execute( """INSERT INTO questions (session_id, image_id, question_number, status, marked_solution, actual_solution) VALUES (?, ?, ?, ?, ?, ?)""", ( session_id, image_id, p_box.get('question_number'), p_box.get('status', 'unattempted'), p_box.get('marked_solution'), p_box.get('actual_solution') ) ) conn.commit() conn.close() os.remove(temp_path) return jsonify({'success': True, 'processed_count': len(processed_boxes)}) except Exception as e: conn.rollback() conn.close() print(f"V2 Processing error: {e}") return jsonify({'error': f'Processing failed: {str(e)}'}), 500 @main_bp.route('/color_rm') @login_required def color_rm_entry(): return render_template('color_rm_upload.html') @main_bp.route('/color_rm_interface//') @login_required def color_rm_interface(session_id, image_index): conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return "Unauthorized", 403 image_info = conn.execute( "SELECT * FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'original'", (session_id, image_index) ).fetchone() if not image_info: conn.close() return "Original page/image not found for this session and index.", 404 total_pages_result = conn.execute( "SELECT COUNT(*) FROM images WHERE session_id = ? AND image_type = 'original'", (session_id,) ).fetchone() total_pages = total_pages_result[0] if total_pages_result else 0 conn.close() return render_template( 'color_rm.html', session_id=session_id, user_id=current_user.id, # Pass user ID to template image_index=image_index, image_info=dict(image_info), total_pages=total_pages ) @main_bp.route('/process_color_rm', methods=['POST']) @login_required def process_color_rm(): data = request.json session_id = data.get('session_id') image_index = data.get('image_index') image_data_url = data.get('imageData') conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 page_info = conn.execute( "SELECT filename, original_name FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'original'", (session_id, image_index) ).fetchone() if not page_info: conn.close() return jsonify({'error': 'Original page not found'}), 404 try: header, encoded = image_data_url.split(",", 1) image_data = base64.b64decode(encoded) processed_filename = f"color_rm_{session_id}_{image_index}_{datetime.now().strftime('%H%M%S')}.png" processed_path = os.path.join(current_app.config['PROCESSED_FOLDER'], processed_filename) with open(processed_path, "wb") as f: f.write(image_data) # Insert into DB so serve_processed_file allows access conn.execute( 'INSERT INTO images (session_id, image_index, filename, original_name, processed_filename, image_type) VALUES (?, ?, ?, ?, ?, ?)', (session_id, image_index, page_info['filename'], page_info['original_name'], processed_filename, 'color_rm') ) conn.commit() return jsonify({ 'success': True, 'filename': processed_filename, 'url': url_for('main.serve_processed_file', filename=processed_filename) }) except Exception as e: return jsonify({'error': str(e)}), 500 finally: conn.close() @main_bp.route('/question_entry_v2/') @login_required def question_entry_v2(session_id): conn = get_db_connection() # Fetch session metadata, ensuring it belongs to the current user session_data = conn.execute( 'SELECT original_filename, subject, tags, notes FROM sessions WHERE id = ? AND user_id = ?', (session_id, current_user.id) ).fetchone() if not session_data: conn.close() flash("Session not found or you don't have permission to access it.", "warning") return redirect(url_for('dashboard.dashboard')) # Fetch images and associated questions images = conn.execute( """SELECT i.id, i.processed_filename, q.question_number, q.status, q.marked_solution, q.actual_solution FROM images i LEFT JOIN questions q ON i.id = q.image_id WHERE i.session_id = ? AND i.image_type = 'cropped' ORDER BY i.id""", (session_id,) ).fetchall() # Count classified questions (those with both subject and chapter) classification_count = conn.execute( """SELECT COUNT(*) as count FROM images i LEFT JOIN questions q ON i.id = q.image_id WHERE i.session_id = ? AND i.image_type = 'cropped' AND q.subject IS NOT NULL AND q.chapter IS NOT NULL""", (session_id,) ).fetchone()['count'] classified_count = classification_count conn.close() if not images: return "No questions were created from the PDF. Please go back and draw crop boxes.", 404 return render_template('question_entry_v2.html', session_id=session_id, images=[dict(img) for img in images], session_data=dict(session_data) if session_data else {}, classified_count=classified_count, total_questions=len(images), nvidia_nim_available=NVIDIA_NIM_AVAILABLE) @main_bp.route('/old/dashboard') @login_required def old_dashboard(): # Redirect to the main dashboard to avoid duplicate code return redirect(url_for('dashboard.dashboard')) @main_bp.route('/delete_session/', methods=[METHOD_DELETE]) @login_required def delete_session(session_id): try: conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 images_to_delete = conn.execute('SELECT filename, processed_filename FROM images WHERE session_id = ?', (session_id,)).fetchall() for img in images_to_delete: if img['filename']: try: os.remove(os.path.join(current_app.config['UPLOAD_FOLDER'], img['filename'])) except OSError: pass if img['processed_filename']: try: os.remove(os.path.join(current_app.config['PROCESSED_FOLDER'], img['processed_filename'])) except OSError: pass conn.execute('DELETE FROM questions WHERE session_id = ?', (session_id,)) conn.execute('DELETE FROM images WHERE session_id = ?', (session_id,)) conn.execute('DELETE FROM sessions WHERE id = ?', (session_id,)) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 @main_bp.route('/toggle_persist/', methods=[METHOD_POST]) @login_required def toggle_persist(session_id): try: conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 current_status_res = conn.execute('SELECT persist FROM sessions WHERE id = ?', (session_id,)).fetchone() if not current_status_res: conn.close() return jsonify({'error': 'Session not found'}), 404 new_status = 1 - current_status_res['persist'] conn.execute('UPDATE sessions SET persist = ? WHERE id = ?', (new_status, session_id)) pdf_res = conn.execute('SELECT id FROM generated_pdfs WHERE session_id = ?', (session_id,)).fetchone() if pdf_res: conn.execute('UPDATE generated_pdfs SET persist = ? WHERE id = ?', (new_status, pdf_res['id'])) conn.commit() conn.close() return jsonify({'success': True, 'status': 'persisted' if new_status == 1 else 'not_persisted'}) except Exception as e: print(f"Error in toggle_persist: {e}") conn.rollback() conn.close() return jsonify({'error': str(e)}), 500 @main_bp.route('/rename_session/', methods=['POST']) @login_required def rename_session(session_id): data = request.json new_name = data.get('new_name') if not new_name: return jsonify({'error': 'New name is required'}), 400 try: conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 conn.execute('UPDATE sessions SET name = ? WHERE id = ?', (new_name, session_id)) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 @main_bp.route('/delete_question/', methods=[METHOD_DELETE]) @login_required def delete_question(image_id): try: conn = get_db_connection() # Security: Check ownership of the image via the session image_owner = conn.execute(""" SELECT s.user_id FROM images i JOIN sessions s ON i.session_id = s.id WHERE i.id = ? """, (image_id,)).fetchone() if not image_owner or image_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 image_info = conn.execute( 'SELECT session_id, filename, processed_filename FROM images WHERE id = ?', (image_id,) ).fetchone() if not image_info: conn.close() return jsonify({'error': 'Question not found'}), 404 conn.execute('DELETE FROM questions WHERE image_id = ?', (image_id,)) conn.execute('DELETE FROM images WHERE id = ?', (image_id,)) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 from rich.table import Table from rich.console import Console @main_bp.route(ROUTE_SAVE_QUESTIONS, methods=[METHOD_POST]) @login_required def save_questions(): data = request.json session_id = data['session_id'] questions = data['questions'] pdf_subject = data.get('pdf_subject', '') pdf_tags = data.get('pdf_tags', '') pdf_notes = data.get('pdf_notes', '') conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 # Update session metadata conn.execute( 'UPDATE sessions SET subject = ?, tags = ?, notes = ? WHERE id = ?', (pdf_subject, pdf_tags, pdf_notes, session_id) ) # Delete and re-insert questions conn.execute('DELETE FROM questions WHERE session_id = ?', (session_id,)) questions_to_insert = [] for q in questions: questions_to_insert.append(( session_id, q['image_id'], q['question_number'], "", # subject column in questions table - can be removed later q['status'], q.get('marked_solution', ""), q.get('actual_solution', ""), q.get('time_taken', ""), pdf_tags # Save tags with each question too )) conn.executemany( 'INSERT INTO questions (session_id, image_id, question_number, subject, status, marked_solution, actual_solution, time_taken, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', questions_to_insert ) conn.commit() conn.close() return jsonify({'success': True, 'message': 'Questions saved successfully.'}) @main_bp.route(ROUTE_EXTRACT_QUESTION_NUMBER, methods=[METHOD_POST]) @login_required def extract_question_number(): if not NVIDIA_NIM_AVAILABLE: return jsonify({'error': 'NVIDIA NIM feature is not available. Please set the NVIDIA_API_KEY environment variable.'}), 400 data = request.json image_id = data.get('image_id') if not image_id: return jsonify({'error': 'Missing image_id parameter'}), 400 try: conn = get_db_connection() # Security: Check ownership of the image via the session image_owner = conn.execute(""" SELECT s.user_id FROM images i JOIN sessions s ON i.session_id = s.id WHERE i.id = ? """, (image_id,)).fetchone() if not image_owner or image_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 image_info = conn.execute( 'SELECT processed_filename FROM images WHERE id = ?', (image_id,) ).fetchone() conn.close() if not image_info or not image_info['processed_filename']: return jsonify({'error': 'Image not found or not processed'}), 404 image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], image_info['processed_filename']) if not os.path.exists(image_path): return jsonify({'error': 'Image file not found on disk'}), 404 image_bytes = resize_image_if_needed(image_path) ocr_result = call_nim_ocr_api(image_bytes) question_number = extract_question_number_from_ocr_result(ocr_result) return jsonify({ 'success': True, 'question_number': question_number, 'image_id': image_id }) except Exception as e: return jsonify({'error': f'Failed to extract question number: {str(e)}'}), 500 @main_bp.route(ROUTE_EXTRACT_ALL_QUESTION_NUMBERS, methods=[METHOD_POST]) @login_required def extract_all_question_numbers(): if not NVIDIA_NIM_AVAILABLE: return jsonify({'error': 'NVIDIA NIM feature is not available.'}), 400 data = request.json session_id = data.get('session_id') if not session_id: return jsonify({'error': 'Missing session_id parameter'}), 400 try: conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 images = conn.execute( "SELECT id, processed_filename FROM images WHERE session_id = ? AND image_type = 'cropped' ORDER BY id", (session_id,) ).fetchall() conn.close() if not images: return jsonify({'error': 'No cropped images found in session'}), 404 results = [] errors = [] MAX_CONCURRENT_REQUESTS = 5 processed_count = 0 for image in images: if processed_count >= MAX_CONCURRENT_REQUESTS: import time time.sleep(1) processed_count = 0 try: image_id = image['id'] processed_filename = image['processed_filename'] if not processed_filename: errors.append({'image_id': image_id, 'error': 'Image not processed'}) continue image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], processed_filename) if not os.path.exists(image_path): errors.append({'image_id': image_id, 'error': 'Image file not found on disk'}) continue image_bytes = resize_image_if_needed(image_path) ocr_result = call_nim_ocr_api(image_bytes) question_number = extract_question_number_from_ocr_result(ocr_result) results.append({ 'image_id': image_id, 'question_number': question_number }) processed_count += 1 except Exception as e: errors.append({'image_id': image['id'], 'error': str(e)}) return jsonify({ 'success': True, 'results': results, 'errors': errors }) except Exception as e: return jsonify({'error': f'Failed to extract question numbers: {str(e)}'}), 500 @main_bp.route('/get_all_subjects_and_tags') @login_required def get_all_subjects_and_tags(): conn = get_db_connection() subjects = [row['subject'] for row in conn.execute('SELECT DISTINCT subject FROM generated_pdfs WHERE subject IS NOT NULL AND user_id = ?', (current_user.id,)).fetchall()] tags_query = conn.execute('SELECT DISTINCT tags FROM generated_pdfs WHERE tags IS NOT NULL AND tags != \'\' AND user_id = ?', (current_user.id,)).fetchall() all_tags = set() for row in tags_query: tags = [tag.strip() for tag in row['tags'].split(',')] all_tags.update(tags) conn.close() return jsonify({ 'subjects': sorted(subjects), 'tags': sorted(list(all_tags)) }) @main_bp.route('/get_metadata_suggestions') @login_required def get_metadata_suggestions(): conn = get_db_connection() subjects = [row['subject'] for row in conn.execute('SELECT DISTINCT subject FROM generated_pdfs WHERE subject IS NOT NULL AND user_id = ?', (current_user.id,)).fetchall()] tags_query = conn.execute('SELECT DISTINCT tags FROM generated_pdfs WHERE tags IS NOT NULL AND tags != \'\' AND user_id = ?', (current_user.id,)).fetchall() all_tags = set() for row in tags_query: tags = [tag.strip() for tag in row['tags'].split(',')] all_tags.update(tags) conn.close() return jsonify({ 'subjects': sorted(subjects), 'tags': sorted(list(all_tags)) }) @main_bp.route('/generate_preview', methods=[METHOD_POST]) @login_required def generate_preview(): data = request.json session_id = data['session_id'] conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 query = """ SELECT q.*, i.filename, i.processed_filename FROM questions q JOIN images i ON q.image_id = i.id WHERE q.session_id = ? ORDER BY i.id """ all_questions = [dict(row) for row in conn.execute(query, (session_id,)).fetchall()] conn.close() miscellaneous_questions = data.get('miscellaneous_questions', []) all_questions.extend(miscellaneous_questions) filter_type = data.get('filter_type', 'all') filtered_questions = [ q for q in all_questions if filter_type == 'all' or q['status'] == filter_type ] if not filtered_questions: return jsonify({'error': 'No questions match the filter criteria'}), 400 # For preview, we only need the first page images_per_page = int(data.get('images_per_page', 4)) preview_questions = filtered_questions[:images_per_page] practice_mode = data.get('practice_mode', 'none') practice_mode_settings = { 'portrait_2': {'images_per_page': 2, 'orientation': 'portrait', 'grid_rows': 2, 'grid_cols': 1}, 'portrait_3': {'images_per_page': 3, 'orientation': 'portrait', 'grid_rows': 3, 'grid_cols': 1}, 'landscape_2': {'images_per_page': 2, 'orientation': 'landscape', 'grid_rows': 2, 'grid_cols': 1}, 'portrait_2_spacious': {'images_per_page': 2, 'orientation': 'portrait', 'grid_rows': 2, 'grid_cols': 1} } if practice_mode in practice_mode_settings: settings = practice_mode_settings[practice_mode] images_per_page = settings['images_per_page'] orientation = settings['orientation'] grid_rows = settings['grid_rows'] grid_cols = settings['grid_cols'] else: images_per_page = int(data.get('images_per_page', 4)) orientation = data.get('orientation', 'portrait') grid_rows = int(data.get('grid_rows')) if data.get('grid_rows') else None grid_cols = int(data.get('grid_cols')) if data.get('grid_cols') else None font_size_scale = float(data.get('font_size_scale', 1.0)) pdf_bytes = create_a4_pdf_from_images( preview_questions, current_app.config['PROCESSED_FOLDER'], output_filename=None, images_per_page=images_per_page, output_folder=None, orientation=orientation, grid_rows=grid_rows, grid_cols=grid_cols, practice_mode=practice_mode, return_bytes=True, font_size_scale=font_size_scale ) if pdf_bytes: # Convert PDF bytes to image for preview try: pdf_document = fitz.open(stream=pdf_bytes, filetype="pdf") first_page = pdf_document.load_page(0) pix = first_page.get_pixmap(dpi=150) # Lower DPI for faster preview img_bytes = pix.tobytes("png") # Use tobytes() instead of save() img_base64 = base64.b64encode(img_bytes).decode('utf-8') return jsonify({'success': True, 'preview_image': f'data:image/png;base64,{img_base64}'}) except Exception as e: return jsonify({'error': f'Failed to generate preview image: {str(e)}'}), 500 finally: if 'pdf_document' in locals() and pdf_document: pdf_document.close() else: return jsonify({'error': 'PDF generation for preview failed'}), 500 @main_bp.route(ROUTE_GENERATE_PDF, methods=[METHOD_POST]) @login_required def generate_pdf(): data = request.json session_id = data['session_id'] conn = get_db_connection() # Security: Check ownership of the session session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 query = """ SELECT q.*, i.filename, i.processed_filename FROM questions q JOIN images i ON q.image_id = i.id WHERE q.session_id = ? ORDER BY i.id """ all_questions = [dict(row) for row in conn.execute(query, (session_id,)).fetchall()] miscellaneous_questions = data.get('miscellaneous_questions', []) all_questions.extend(miscellaneous_questions) filter_type = data.get('filter_type', 'all') filtered_questions = [ q for q in all_questions if filter_type == 'all' or q['status'] == filter_type ] if not filtered_questions: conn.close() return jsonify({'error': 'No questions match the filter criteria'}), 400 pdf_filename = f"{secure_filename(data.get('pdf_name', 'analysis'))}_{datetime.now().strftime('%Y%m%d_%H%M')}.pdf" practice_mode = data.get('practice_mode', 'none') practice_mode_settings = { 'portrait_2': {'images_per_page': 2, 'orientation': 'portrait', 'grid_rows': 2, 'grid_cols': 1}, 'portrait_3': {'images_per_page': 3, 'orientation': 'portrait', 'grid_rows': 3, 'grid_cols': 1}, 'landscape_2': {'images_per_page': 2, 'orientation': 'landscape', 'grid_rows': 2, 'grid_cols': 1}, 'portrait_2_spacious': {'images_per_page': 2, 'orientation': 'portrait', 'grid_rows': 2, 'grid_cols': 1} } if practice_mode in practice_mode_settings: settings = practice_mode_settings[practice_mode] images_per_page = settings['images_per_page'] orientation = settings['orientation'] grid_rows = settings['grid_rows'] grid_cols = settings['grid_cols'] else: images_per_page = int(data.get('images_per_page', 4)) orientation = data.get('orientation', 'portrait') grid_rows = int(data.get('grid_rows')) if data.get('grid_rows') else None grid_cols = int(data.get('grid_cols')) if data.get('grid_cols') else None font_size_scale = float(data.get('font_size_scale', 1.0)) if create_a4_pdf_from_images(filtered_questions, current_app.config['PROCESSED_FOLDER'], pdf_filename, images_per_page, current_app.config['OUTPUT_FOLDER'], orientation, grid_rows, grid_cols, practice_mode, font_size_scale=font_size_scale): session_info = conn.execute('SELECT original_filename FROM sessions WHERE id = ?', (session_id,)).fetchone() source_filename = session_info['original_filename'] if session_info else 'Unknown' conn.execute( 'INSERT INTO generated_pdfs (session_id, filename, subject, tags, notes, source_filename, user_id) VALUES (?, ?, ?, ?, ?, ?, ?)', (session_id, pdf_filename, data.get('subject'), data.get('tags'), data.get('notes'), source_filename, current_user.id) ) conn.commit() conn.close() return jsonify({'success': True, 'pdf_filename': pdf_filename}) else: conn.close() return jsonify({'error': 'PDF generation failed'}), 500 @main_bp.route('/download/') def download_file(filename): return send_file(os.path.join(current_app.config['OUTPUT_FOLDER'], filename), as_attachment=True) @main_bp.route('/view_pdf/') def view_pdf(filename): return send_file(os.path.join(current_app.config['OUTPUT_FOLDER'], filename), as_attachment=False) @main_bp.route('/view_pdf_v2/') def view_pdf_v2(filename): return render_template('pdfjs_viewer.html', pdf_url=url_for('main.view_pdf', filename=filename), pdf_title=filename) @main_bp.route('/viewpdflegacy/') def view_pdf_legacy(pdf_id): conn = get_db_connection() pdf_info = conn.execute('SELECT filename, subject FROM generated_pdfs WHERE id = ?', (pdf_id,)).fetchone() conn.close() if not pdf_info: return "PDF not found", 404 pdf_filename = pdf_info['filename'] pdf_subject = pdf_info['subject'] pdf_path = os.path.join(current_app.config['OUTPUT_FOLDER'], pdf_filename) if not os.path.exists(pdf_path): return "PDF file not found on disk", 404 image_paths = [] try: doc = fitz.open(pdf_path) for i in range(0, doc.page_count, 2): # Get first page page1 = doc.load_page(i) pix1 = page1.get_pixmap(dpi=current_user.dpi) # Check for second page if i + 1 < doc.page_count: page2 = doc.load_page(i + 1) pix2 = page2.get_pixmap(dpi=current_user.dpi) # Convert pixmaps to numpy arrays for easier manipulation img1 = np.frombuffer(pix1.samples, dtype=np.uint8).reshape(pix1.h, pix1.w, pix1.n) img2 = np.frombuffer(pix2.samples, dtype=np.uint8).reshape(pix2.h, pix2.w, pix2.n) # Ensure both images have 3 channels (RGB) for consistent stacking if img1.shape[2] == 4: # RGBA img1 = cv2.cvtColor(img1, cv2.COLOR_RGBA2RGB) if img2.shape[2] == 4: # RGBA img2 = cv2.cvtColor(img2, cv2.COLOR_RGBA2RGB) # Pad images to have the same height if necessary max_h = max(img1.shape[0], img2.shape[0]) if img1.shape[0] < max_h: img1 = np.pad(img1, ((0, max_h - img1.shape[0]), (0, 0), (0, 0)), mode='constant', constant_values=255) if img2.shape[0] < max_h: img2 = np.pad(img2, ((0, max_h - img2.shape[0]), (0, 0), (0, 0)), mode='constant', constant_values=255) # Combine images horizontally combined_img = np.hstack((img1, img2)) # Convert back to pixmap for saving combined_pix = fitz.Pixmap(fitz.csRGB, combined_img.shape[1], combined_img.shape[0], combined_img.tobytes()) else: # Only one page, use pix1 directly combined_pix = pix1 temp_image_filename = f"legacy_view_{uuid.uuid4()}_page_{i}_{i+1}.png" temp_image_path = os.path.join(current_app.config['PROCESSED_FOLDER'], temp_image_filename) combined_pix.save(temp_image_path) image_paths.append(url_for('main.serve_image', folder='processed', filename=temp_image_filename)) doc.close() except Exception as e: print(f"Error converting PDF to images: {e}") return f"Error processing PDF: {str(e)}", 500 return render_template('simple_viewer.html', image_urls=image_paths, pdf_title=pdf_subject or pdf_filename) @main_bp.route('/image//') def serve_image(folder, filename): current_app.logger.info(f"Attempting to serve image from folder '{folder}' with filename '{filename}'") folder_map = { 'uploads': 'UPLOAD_FOLDER', 'processed': 'PROCESSED_FOLDER', 'output': 'OUTPUT_FOLDER' } config_key = folder_map.get(folder) if not config_key: config_key = f'{folder.upper()}_FOLDER' base_folder_path = current_app.config.get(config_key) if not base_folder_path: current_app.logger.error(f"Configuration key '{config_key}' not found.") return "Not found", 404 # The filename can now be either 'session_id/image.jpg' (new) or just 'image.jpg' (old) # Let's construct the full path and check for its existence full_path = os.path.abspath(os.path.join(base_folder_path, filename)) current_app.logger.info(f"Checking for file at primary path: {full_path}") # Security check: ensure the path is within the intended directory if not full_path.startswith(os.path.abspath(base_folder_path)): current_app.logger.warning(f"Potential directory traversal attempt: {filename}") return "Forbidden", 403 if os.path.exists(full_path): current_app.logger.info(f"File found at primary path. Serving {filename} from {base_folder_path}") return send_from_directory(base_folder_path, filename) else: # Fallback for old structure: check if the filename itself exists in the root of the processed folder # This handles cases where filename might be 'session_id/q_1.png' but the file is actually at 'processed/q_1.png' # or other legacy paths. parts = filename.split('/') if len(parts) > 1: fallback_filename = parts[-1] fallback_full_path = os.path.abspath(os.path.join(base_folder_path, fallback_filename)) current_app.logger.info(f"Primary path not found. Checking fallback path: {fallback_full_path}") if os.path.exists(fallback_full_path): current_app.logger.info(f"File found at fallback path. Serving {fallback_filename} from {base_folder_path}") return send_from_directory(base_folder_path, fallback_filename) current_app.logger.error(f"File not found at primary or fallback paths for filename: {filename}") return "Not found", 404 @main_bp.route(ROUTE_INDEX) def index(): if current_user.is_authenticated: """ if getattr(current_user, 'v2_default', 0): return redirect(url_for('main.upload_final_pdf')) """ return redirect(url_for('dashboard.dashboard')) return redirect(url_for('auth.login')) @main_bp.route('/pdf_manager') @main_bp.route('/pdf_manager/browse/') @login_required def pdf_manager(folder_path=''): conn = get_db_connection() view_mode = request.args.get('view', 'default') search_query = request.args.get('search', '') is_recursive = request.args.get('recursive') == 'true' query_params = [current_user.id] base_query = 'SELECT * FROM generated_pdfs WHERE user_id = ?' where_clauses = [] if search_query: where_clauses.append('(subject LIKE ? OR tags LIKE ? OR notes LIKE ?)') search_term = f'%{search_query}%' query_params.extend([search_term, search_term, search_term]) all_view = view_mode == 'all' folder_id = None subfolders = [] breadcrumbs = [] if not all_view: if folder_path: parts = folder_path.split('/') parent_id = None for i, part in enumerate(parts): res = conn.execute("SELECT id FROM folders WHERE name = ? AND user_id = ? AND (parent_id = ? OR (? IS NULL AND parent_id IS NULL))", (part, current_user.id, parent_id, parent_id)).fetchone() if not res: return redirect(url_for('main.pdf_manager')) parent_id = res['id'] breadcrumbs.append({'name': part, 'path': '/'.join(parts[:i+1])}) folder_id = parent_id if is_recursive and search_query: if folder_id: # Note: get_all_descendant_folder_ids needs to be made user-aware if folders can be nested deeply # For now, we assume it gets all children regardless of user, but the main query is user-filtered. descendant_ids = get_all_descendant_folder_ids(conn, folder_id) all_folder_ids = [folder_id] + descendant_ids if all_folder_ids: placeholders = ', '.join('?' * len(all_folder_ids)) where_clauses.append(f'folder_id IN ({placeholders})') query_params.extend(all_folder_ids) else: if folder_id: where_clauses.append('folder_id = ?') query_params.append(folder_id) else: where_clauses.append('folder_id IS NULL') if folder_id: subfolders = conn.execute('SELECT * FROM folders WHERE parent_id = ? AND user_id = ? ORDER BY name', (folder_id, current_user.id)).fetchall() else: subfolders = conn.execute('SELECT * FROM folders WHERE parent_id IS NULL AND user_id = ? ORDER BY name', (current_user.id,)).fetchall() if where_clauses: base_query += ' AND ' + ' AND '.join(where_clauses) base_query += ' ORDER BY created_at DESC' pdfs = conn.execute(base_query, query_params).fetchall() pdfs_list = [dict(row) for row in pdfs] subfolders_list = [dict(row) for row in subfolders] for pdf in pdfs_list: if isinstance(pdf['created_at'], str): try: pdf['created_at'] = datetime.strptime(pdf['created_at'], '%Y-%m-%d %H:%M:%S') except ValueError: pass for folder in subfolders_list: if isinstance(folder['created_at'], str): try: folder['created_at'] = datetime.strptime(folder['created_at'], '%Y-%m-%d %H:%M:%S') except ValueError: pass # get_folder_tree also needs to be user-aware folder_tree = get_folder_tree(user_id=current_user.id) conn.close() return render_template('pdf_manager.html', pdfs=pdfs_list, subfolders=subfolders_list, current_folder_id=folder_id, breadcrumbs=breadcrumbs, all_view=all_view, folder_tree=folder_tree, search_query=search_query, recursive=is_recursive) @main_bp.route('/get_pdf_details/') @login_required def get_pdf_details(pdf_id): conn = get_db_connection() pdf = conn.execute('SELECT * FROM generated_pdfs WHERE id = ? AND user_id = ?', (pdf_id, current_user.id)).fetchone() conn.close() if pdf: return jsonify(dict(pdf)) return jsonify({'error': 'PDF not found'}), 404 @main_bp.route('/update_pdf_details/', methods=[METHOD_POST]) @login_required def update_pdf_details(pdf_id): data = request.json try: conn = get_db_connection() pdf_owner = conn.execute('SELECT user_id FROM generated_pdfs WHERE id = ?', (pdf_id,)).fetchone() if not pdf_owner or pdf_owner['user_id'] != current_user.id: conn.close() return jsonify({'error': 'Unauthorized'}), 403 conn.execute( 'UPDATE generated_pdfs SET subject = ?, tags = ?, notes = ? WHERE id = ?', (data.get('subject'), data.get('tags'), data.get('notes'), pdf_id) ) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 @main_bp.route('/rename_item', methods=[METHOD_POST]) @login_required def rename_item(): data = request.json item_type, item_id, new_name = data.get('item_type'), data.get('item_id'), data.get('new_name') if not all([item_type, item_id, new_name]): return jsonify({'error': 'Missing parameters'}), 400 conn = get_db_connection() if item_type == 'folder': folder_owner = conn.execute('SELECT user_id FROM folders WHERE id = ?', (item_id,)).fetchone() if not folder_owner or folder_owner['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized'}), 403 conn.execute('UPDATE folders SET name = ? WHERE id = ?', (new_name, item_id)) elif item_type == 'pdf': pdf_owner = conn.execute('SELECT user_id, filename FROM generated_pdfs WHERE id = ?', (item_id,)).fetchone() if not pdf_owner or pdf_owner['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized'}), 403 old_filename = pdf_owner['filename'] if not new_name.lower().endswith('.pdf'): new_name += '.pdf' new_filename = secure_filename(new_name) old_filepath = os.path.join(current_app.config['OUTPUT_FOLDER'], old_filename) new_filepath = os.path.join(current_app.config['OUTPUT_FOLDER'], new_filename) if os.path.exists(new_filepath): conn.close(); return jsonify({'error': 'A file with this name already exists'}), 400 try: os.rename(old_filepath, new_filepath) conn.execute('UPDATE generated_pdfs SET filename = ? WHERE id = ?', (new_filename, item_id)) except OSError as e: conn.close(); return jsonify({'error': f'Failed to rename file on disk: {e}'}), 500 else: conn.close(); return jsonify({'error': 'Invalid item type'}), 400 conn.commit() conn.close() return jsonify({'success': True}) @main_bp.route('/delete_folder/', methods=[METHOD_DELETE]) @login_required def delete_folder(folder_id): conn = get_db_connection() folder_owner = conn.execute('SELECT user_id FROM folders WHERE id = ?', (folder_id,)).fetchone() if not folder_owner or folder_owner['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized'}), 403 folder_ids_to_delete = [folder_id] + get_all_descendant_folder_ids(conn, folder_id, current_user.id) placeholders = ', '.join('?' * len(folder_ids_to_delete)) pdfs_to_delete = conn.execute(f'SELECT id, filename FROM generated_pdfs WHERE folder_id IN ({placeholders}) AND user_id = ?', (*folder_ids_to_delete, current_user.id)).fetchall() for pdf in pdfs_to_delete: try: os.remove(os.path.join(current_app.config['OUTPUT_FOLDER'], pdf['filename'])) except OSError: pass pdf_ids = [p['id'] for p in pdfs_to_delete] if pdf_ids: placeholders = ','.join(map(str, pdf_ids)) conn.execute(f'DELETE FROM generated_pdfs WHERE id IN ({placeholders})') conn.execute(f'DELETE FROM folders WHERE id IN ({placeholders})', folder_ids_to_delete) conn.commit() conn.close() return jsonify({'success': True}) @main_bp.route('/delete_generated_pdf/', methods=[METHOD_DELETE]) @login_required def delete_generated_pdf(pdf_id): try: conn = get_db_connection() pdf_info = conn.execute('SELECT filename, user_id FROM generated_pdfs WHERE id = ?', (pdf_id,)).fetchone() if pdf_info and pdf_info['user_id'] == current_user.id: try: os.remove(os.path.join(current_app.config['OUTPUT_FOLDER'], pdf_info['filename'])) except OSError: pass conn.execute('DELETE FROM generated_pdfs WHERE id = ?', (pdf_id,)) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 @main_bp.route('/toggle_persist_generated_pdf/', methods=[METHOD_POST]) @login_required def toggle_persist_generated_pdf(pdf_id): try: conn = get_db_connection() pdf_info = conn.execute('SELECT persist, session_id, user_id FROM generated_pdfs WHERE id = ?', (pdf_id,)).fetchone() if not pdf_info or pdf_info['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized'}), 403 new_status = 1 - pdf_info['persist'] session_id = pdf_info['session_id'] conn.execute('UPDATE generated_pdfs SET persist = ? WHERE id = ?', (new_status, pdf_id)) if session_id: session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if session_owner and session_owner['user_id'] == current_user.id: conn.execute('UPDATE sessions SET persist = ? WHERE id = ?', (new_status, session_id)) conn.commit() conn.close() return jsonify({'success': True, 'status': 'persisted' if new_status == 1 else 'not_persisted'}) except Exception as e: print(f"Error in toggle_persist_generated_pdf: {e}") conn.rollback(); conn.close(); return jsonify({'error': str(e)}), 500 @main_bp.route('/bulk_delete_pdfs', methods=[METHOD_POST]) @login_required def bulk_delete_pdfs(): data = request.json pdf_ids = data.get('ids', []) if not pdf_ids: return jsonify({'error': 'No PDF IDs provided'}), 400 try: conn = get_db_connection() placeholders = ','.join('?' for _ in pdf_ids) owned_pdfs = conn.execute(f'SELECT id, filename FROM generated_pdfs WHERE id IN ({placeholders}) AND user_id = ?', (*pdf_ids, current_user.id)).fetchall() owned_pdf_ids = [pdf['id'] for pdf in owned_pdfs] if not owned_pdf_ids: conn.close() return jsonify({'success': True, 'message': 'No owned PDFs to delete.'}) for pdf in owned_pdfs: try: os.remove(os.path.join(current_app.config['OUTPUT_FOLDER'], pdf['filename'])) except OSError: pass delete_placeholders = ','.join('?' for _ in owned_pdf_ids) conn.execute(f'DELETE FROM generated_pdfs WHERE id IN ({delete_placeholders})', owned_pdf_ids) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 @main_bp.route('/bulk_toggle_persist', methods=[METHOD_POST]) @login_required def bulk_toggle_persist(): data = request.json pdf_ids = data.get('ids', []) if not pdf_ids: return jsonify({'error': 'No PDF IDs provided'}), 400 try: conn = get_db_connection() placeholders = ','.join('?' for _ in pdf_ids) owned_pdfs = conn.execute(f'SELECT id, persist, session_id FROM generated_pdfs WHERE id IN ({placeholders}) AND user_id = ?', (*pdf_ids, current_user.id)).fetchall() for pdf in owned_pdfs: new_status = 1 - pdf['persist'] session_id = pdf['session_id'] conn.execute('UPDATE generated_pdfs SET persist = ? WHERE id = ?', (new_status, pdf['id'])) if session_id: conn.execute('UPDATE sessions SET persist = ? WHERE id = ? AND user_id = ?', (new_status, session_id, current_user.id)) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: print(f"Error in bulk_toggle_persist: {e}") conn.rollback(); conn.close(); return jsonify({'error': str(e)}), 500 @main_bp.route('/bulk_download_pdfs', methods=[METHOD_POST]) @login_required def bulk_download_pdfs(): data = request.json pdf_ids = data.get('ids', []) if not pdf_ids: return jsonify({'error': 'No PDF IDs provided'}), 400 memory_file = io.BytesIO() try: with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf: conn = get_db_connection() placeholders = ','.join('?' for _ in pdf_ids) owned_pdfs = conn.execute(f'SELECT filename FROM generated_pdfs WHERE id IN ({placeholders}) AND user_id = ?', (*pdf_ids, current_user.id)).fetchall() for pdf_info in owned_pdfs: pdf_path = os.path.join(current_app.config['OUTPUT_FOLDER'], pdf_info['filename']) if os.path.exists(pdf_path): zf.write(pdf_path, os.path.basename(pdf_path)) conn.close() except Exception as e: return jsonify({'error': str(e)}), 500 memory_file.seek(0) return send_file(memory_file, mimetype='application/zip', as_attachment=True, download_name='pdfs.zip') @main_bp.route('/create_folder', methods=[METHOD_POST]) @login_required def create_folder(): data = request.json name, parent_id = data.get('new_folder_name'), data.get('parent_id') if not name: return jsonify({'error': 'Folder name is required'}), 400 try: conn = get_db_connection() cursor = conn.cursor() cursor.execute("INSERT INTO folders (name, parent_id, user_id) VALUES (?, ?, ?)", (name, parent_id, current_user.id)) new_folder_id = cursor.lastrowid conn.commit() conn.close() return jsonify({'success': True, 'id': new_folder_id, 'name': name, 'parent_id': parent_id}) except Exception as e: return jsonify({'error': str(e)}), 500 @main_bp.route('/bulk_move_pdfs', methods=[METHOD_POST]) @login_required def bulk_move_pdfs(): data = request.json pdf_ids, target_folder_id = data.get('ids', []), data.get('target_folder_id') if not pdf_ids: return jsonify({'error': 'No PDF IDs provided'}), 400 try: conn = get_db_connection() if target_folder_id: folder_owner = conn.execute('SELECT user_id FROM folders WHERE id = ?', (target_folder_id,)).fetchone() if not folder_owner or folder_owner['user_id'] != current_user.id: conn.close(); return jsonify({'error': 'Unauthorized target folder'}), 403 placeholders = ', '.join('?' * len(pdf_ids)) conn.execute(f'UPDATE generated_pdfs SET folder_id = ? WHERE id IN ({placeholders}) AND user_id = ?', (target_folder_id, *pdf_ids, current_user.id)) conn.commit() conn.close() return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 @main_bp.route('/merge_pdfs', methods=[METHOD_POST]) @login_required def merge_pdfs(): data = request.json pdf_ids = data.get('pdf_ids', []) if len(pdf_ids) < 2: return jsonify({'error': 'Please select at least two PDFs to merge.'}), 400 try: conn = get_db_connection() safe_pdf_ids = [int(pid) for pid in pdf_ids] placeholders = ', '.join('?' * len(safe_pdf_ids)) query = f"SELECT filename FROM generated_pdfs WHERE id IN ({placeholders}) AND user_id = ?" pdfs_to_merge = conn.execute(query, (*safe_pdf_ids, current_user.id)).fetchall() if len(pdfs_to_merge) != len(safe_pdf_ids): conn.close(); return jsonify({'error': 'One or more selected PDFs not found or are unauthorized.'}), 404 merged_doc = fitz.open() source_filenames = [] for pdf_row in pdfs_to_merge: filename = pdf_row['filename'] source_filenames.append(filename) pdf_path = os.path.join(current_app.config['OUTPUT_FOLDER'], filename) if os.path.exists(pdf_path): doc_to_merge = fitz.open(pdf_path) merged_doc.insert_pdf(doc_to_merge) doc_to_merge.close() new_filename = f"merged_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" merged_doc.save(os.path.join(current_app.config['OUTPUT_FOLDER'], new_filename)) merged_doc.close() session_id = str(uuid.uuid4()) conn.execute('INSERT INTO sessions (id, original_filename, user_id) VALUES (?, ?, ?)', (session_id, f"Merged from {len(source_filenames)} files", current_user.id)) subject = "Merged Document" notes = f"This document was created by merging the following files:\n" + "\n".join(source_filenames) conn.execute( 'INSERT INTO generated_pdfs (session_id, filename, subject, tags, notes, source_filename, user_id) VALUES (?, ?, ?, ?, ?, ?, ?)', (session_id, new_filename, subject, 'merged', notes, ", ".join(source_filenames), current_user.id) ) conn.commit() conn.close() return jsonify({'success': True, 'new_filename': new_filename}) except Exception as e: if 'conn' in locals() and conn: conn.close() print(f"Error merging PDFs: {e}") return jsonify({'error': str(e)}), 500 @main_bp.route('/upload_final_pdf') @login_required def upload_final_pdf(): return render_template('upload_final_pdf.html') @main_bp.route('/handle_final_pdf_upload', methods=[METHOD_POST]) @login_required def handle_final_pdf_upload(): subject = request.form.get('subject') if not subject: return 'Subject is required', 400 tags, notes = request.form.get('tags'), request.form.get('notes') conn = get_db_connection() def process_and_save_pdf(file_content, original_filename): session_id = str(uuid.uuid4()) # Associate session with user conn.execute('INSERT INTO sessions (id, original_filename, user_id) VALUES (?, ?, ?)', (session_id, original_filename, current_user.id)) secure_name = secure_filename(original_filename) output_filename = f"{session_id}_{secure_name}" output_path = os.path.join(current_app.config['OUTPUT_FOLDER'], output_filename) with open(output_path, 'wb') as f: f.write(file_content) # Associate generated PDF with user conn.execute( 'INSERT INTO generated_pdfs (session_id, filename, subject, tags, notes, source_filename, user_id) VALUES (?, ?, ?, ?, ?, ?, ?)', (session_id, output_filename, subject, tags, notes, original_filename, current_user.id) ) conn.commit() try: if 'pdf' in request.files and request.files['pdf'].filename: file = request.files['pdf'] if file and file.filename.lower().endswith('.pdf'): process_and_save_pdf(file.read(), file.filename) else: return 'Invalid file type', 400 elif 'pdf_url' in request.form and request.form['pdf_url']: pdf_url = request.form['pdf_url'] # Handle Google Drive URLs pdf_url = convert_google_drive_url(pdf_url) response = requests.get(pdf_url, allow_redirects=True) response.raise_for_status() original_filename = None # Try to get filename from Content-Disposition header if 'Content-Disposition' in response.headers: cd = response.headers['Content-Disposition'] fname_match = re.search(r'filename="?([^";]+)"?', cd) if fname_match: original_filename = fname_match.group(1) if not original_filename: original_filename = os.path.basename(urlparse(pdf_url).path) if not original_filename or not original_filename.lower().endswith('.pdf'): original_filename = 'downloaded_document.pdf' process_and_save_pdf(response.content, original_filename) elif 'curl_command' in request.form and request.form['curl_command']: curl_input = request.form['curl_command'].strip().replace('\n', ',') curl_commands = [cmd.strip() for cmd in curl_input.split(',') if cmd.strip()] for command in curl_commands: url, filename = _parse_curl_command(command) if not url or not filename: current_app.logger.warning(f"Could not parse cURL command: {command}") continue # Handle Google Drive URLs in cURL url = convert_google_drive_url(url) response = requests.get(url, allow_redirects=True) response.raise_for_status() process_and_save_pdf(response.content, filename) else: return 'No PDF file, URL, or cURL command provided', 400 except requests.RequestException as e: current_app.logger.error(f"Failed to download PDF from URL: {e}") return f"Failed to download PDF: {e}", 500 except Exception as e: current_app.logger.error(f"An error occurred during PDF upload: {e}") return "An internal error occurred.", 500 finally: conn.close() return redirect(url_for('main.pdf_manager')) @main_bp.route('/resize/', defaults={'folder_path': ''}, methods=['GET', 'POST']) @main_bp.route('/resize/browse/', methods=['GET', 'POST']) @login_required def resize_pdf_route(folder_path): if request.method == 'POST': input_pdf_name, output_pdf_name = request.form.get('input_pdf'), request.form.get('output_pdf') bg_color_hex, pattern = request.form.get('bg_color', '#FFFFFF'), request.form.get('pattern') pattern_color_hex = request.form.get('pattern_color', '#CCCCCC') mode, stitch_direction = request.form.get('mode', 'notes_only'), request.form.get('stitch_direction', 'horizontal') add_space = 'add_space' in request.form if not input_pdf_name or not output_pdf_name: return "Missing input or output PDF name", 400 conn = get_db_connection() pdf_owner = conn.execute('SELECT user_id FROM generated_pdfs WHERE filename = ?', (input_pdf_name,)).fetchone() if not pdf_owner or pdf_owner['user_id'] != current_user.id: conn.close() return "Unauthorized", 403 def hex_to_rgb(h): h = h.lstrip('#') return tuple(int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4)) input_pdf_path = os.path.join(current_app.config['OUTPUT_FOLDER'], input_pdf_name) output_pdf_path = os.path.join(current_app.config['OUTPUT_FOLDER'], output_pdf_name) bg_color, pattern_color = hex_to_rgb(bg_color_hex), hex_to_rgb(pattern_color_hex) try: expand_pdf_for_notes( input_pdf_path, output_pdf_path, bg_color=bg_color, mode=mode, stitch_direction=stitch_direction, add_space=add_space, pattern=pattern, pattern_color=pattern_color ) session_id = str(uuid.uuid4()) conn.execute('INSERT INTO sessions (id, original_filename, user_id) VALUES (?, ?, ?)', (session_id, f"Resized from {input_pdf_name}", current_user.id)) subject = f"Resized - {os.path.basename(input_pdf_name)}" notes = f"Resized with options: mode={mode}, stitch_direction={stitch_direction}, add_space={add_space}, bg_color={bg_color_hex}, pattern={pattern}" conn.execute( 'INSERT INTO generated_pdfs (session_id, filename, subject, tags, notes, source_filename, user_id) VALUES (?, ?, ?, ?, ?, ?, ?)', (session_id, output_pdf_name, subject, 'resized', notes, input_pdf_name, current_user.id) ) conn.commit() conn.close() return redirect(url_for('main.pdf_manager')) except Exception as e: conn.close() return f"Error during resizing or database update: {e}", 500 else: # GET request conn = get_db_connection() search_query, is_recursive = request.args.get('search', ''), request.args.get('recursive') == 'true' query_params, where_clauses = [current_user.id], ['user_id = ?'] if search_query: where_clauses.append('(filename LIKE ? OR subject LIKE ? OR tags LIKE ?)') search_term = f'%{search_query}%' query_params.extend([search_term, search_term, search_term]) folder_id, subfolders, breadcrumbs = None, [], [] if folder_path: parts = folder_path.split('/') parent_id = None for i, part in enumerate(parts): res = conn.execute("SELECT id FROM folders WHERE name = ? AND user_id = ? AND (parent_id = ? OR (? IS NULL AND parent_id IS NULL))", (part, current_user.id, parent_id, parent_id)).fetchone() if not res: return redirect(url_for('main.resize_pdf_route')) parent_id = res['id'] breadcrumbs.append({'name': part, 'path': '/'.join(parts[:i+1])}) folder_id = parent_id if is_recursive and search_query: if folder_id: descendant_ids = get_all_descendant_folder_ids(conn, folder_id, current_user.id) all_folder_ids = [folder_id] + descendant_ids if all_folder_ids: placeholders = ', '.join('?' * len(all_folder_ids)) where_clauses.append(f'folder_id IN ({placeholders})') query_params.extend(all_folder_ids) else: if folder_id: where_clauses.append('folder_id = ?'); query_params.append(folder_id) else: where_clauses.append('folder_id IS NULL') if folder_id: subfolders = conn.execute('SELECT * FROM folders WHERE parent_id = ? AND user_id = ? ORDER BY name', (folder_id, current_user.id)).fetchall() else: subfolders = conn.execute('SELECT * FROM folders WHERE parent_id IS NULL AND user_id = ? ORDER BY name', (current_user.id,)).fetchall() base_query = 'SELECT * FROM generated_pdfs WHERE ' + ' AND '.join(where_clauses) base_query += ' ORDER BY created_at DESC' pdfs = conn.execute(base_query, query_params).fetchall() folder_tree = get_folder_tree(user_id=current_user.id) conn.close() return render_template('resize.html', pdfs=[dict(row) for row in pdfs], subfolders=[dict(row) for row in subfolders], current_folder_id=folder_id, breadcrumbs=breadcrumbs, folder_tree=folder_tree, search_query=search_query, recursive=is_recursive) @main_bp.route('/print_pdfs', methods=['POST']) @login_required def print_pdfs(): pdf_ids = request.form.getlist('pdf_ids') current_app.logger.info(f"User {current_user.id} printing PDFs with IDs: {pdf_ids}") if not pdf_ids: return jsonify({'error': 'No PDFs selected'}), 400 conn = get_db_connection() placeholders = ','.join('?' for _ in pdf_ids) query = f"SELECT filename, subject FROM generated_pdfs WHERE id IN ({placeholders}) AND user_id = ?" pdfs_info = conn.execute(query, (*pdf_ids, current_user.id)).fetchall() conn.close() current_app.logger.info(f"Found {len(pdfs_info)} owned PDFs to print.") if not pdfs_info: return jsonify({'error': 'No valid PDFs found for the given IDs'}), 404 merged_pdf = fitz.open() font_path = "arial.ttf" for i, pdf_info in enumerate(pdfs_info): pdf_path = os.path.join(current_app.config['OUTPUT_FOLDER'], pdf_info['filename']) if os.path.exists(pdf_path): try: doc = fitz.open(pdf_path) if len(doc) > 0: first_page = doc[0] rect = first_page.rect text = f"Subject: {pdf_info['subject']}\nFilename: {pdf_info['filename']}" text_rect = fitz.Rect(rect.width * 0.02, 0, rect.width * 0.98, rect.height * 0.05) first_page.insert_textbox(text_rect, text, fontsize=5, fontname="Arial", fontfile=font_path, color=(0, 0, 0), overlay=True, align=fitz.TEXT_ALIGN_CENTER) merged_pdf.insert_pdf(doc) doc.close() except Exception as e: current_app.logger.error(f"ERROR processing PDF '{pdf_info['filename']}': {e}") else: current_app.logger.warning(f"PDF file not found at '{pdf_path}'") timestamp = datetime.now().strftime("%Y%m%d%H%M%S") temp_filename = f'printed_documents_{timestamp}.pdf' temp_filepath = os.path.join(current_app.config['TEMP_FOLDER'], temp_filename) os.makedirs(current_app.config['TEMP_FOLDER'], exist_ok=True) merged_pdf.save(temp_filepath) merged_pdf.close() return jsonify({'success': True, 'url': url_for('main.view_generated_pdf', filename=temp_filename)}) @main_bp.route('/view_generated_pdf/') def view_generated_pdf(filename): """Serves a generated PDF from the temporary folder.""" safe_filename = secure_filename(filename) filepath = os.path.join(current_app.config['TEMP_FOLDER'], safe_filename) if not os.path.exists(filepath): return "File not found.", 404 return send_file(filepath, mimetype='application/pdf', as_attachment=False) @main_bp.route('/redact_status/') @login_required def redact_status(session_id): conn = get_db_connection() session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() conn.close() if not session_owner or session_owner['user_id'] != current_user.id: return "Unauthorized", 403 return render_template('redact_status.html', session_id=session_id) @main_bp.route('/redaction_stream/') @login_required def redaction_stream(session_id): def generate(): conn = get_db_connection() session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone() if not session_owner or session_owner['user_id'] != current_user.id: conn.close() yield f"data: {json.dumps({'error': 'Unauthorized'})}\n\n" return if not NVIDIA_NIM_AVAILABLE: yield f"data: {json.dumps({'error': 'NVIDIA API Key is not configured.'})}\n\n"; return images = conn.execute("SELECT id, filename FROM images WHERE session_id = ? AND image_type = 'original' ORDER BY image_index", (session_id,)).fetchall() if not images: conn.close() yield f"data: {json.dumps({'error': 'No images found for this session.'})}\n\n" return redacted_image_paths, source_filenames_for_notes = [], [] total_images = len(images) try: for i, image_row in enumerate(images): progress = int(((i + 1) / total_images) * 100) yield f"data: {json.dumps({'progress': progress, 'message': f'Redacting page {i + 1} of {total_images}...'})}\n\n" original_filename = image_row['filename'] source_filenames_for_notes.append(original_filename) original_path = os.path.join(current_app.config['UPLOAD_FOLDER'], original_filename) if not os.path.exists(original_path): continue redacted_image = redact_pictures_in_image(original_path, NVIDIA_API_KEY) processed_filename = f"redacted_{original_filename}" processed_path = os.path.join(current_app.config['PROCESSED_FOLDER'], processed_filename) redacted_image.save(processed_path, 'PNG') redacted_image_paths.append(processed_path) conn.execute("UPDATE images SET processed_filename = ? WHERE id = ?", (processed_filename, image_row['id'])) conn.commit() yield f"data: {json.dumps({'progress': 100, 'message': 'Assembling final PDF...'})}\n\n" final_pdf_filename = f"redacted_document_{session_id}.pdf" final_pdf_path = os.path.join(current_app.config['OUTPUT_FOLDER'], final_pdf_filename) if not create_pdf_from_full_images(redacted_image_paths, final_pdf_path): raise Exception("Failed to create the final PDF.") session_info = conn.execute('SELECT original_filename FROM sessions WHERE id = ?', (session_id,)).fetchone() subject = f"Redacted - {session_info['original_filename'] if session_info else 'Document'}" notes = f"This document was automatically redacted." conn.execute( 'INSERT INTO generated_pdfs (session_id, filename, subject, tags, notes, source_filename, user_id) VALUES (?, ?, ?, ?, ?, ?, ?)', (session_id, final_pdf_filename, subject, 'redacted', notes, ", ".join(source_filenames_for_notes), current_user.id) ) conn.commit() download_url = url_for('main.download_file', filename=final_pdf_filename) yield f"data: {json.dumps({'complete': True, 'download_url': download_url})}\n\n" except Exception as e: yield f"data: {json.dumps({'error': str(e)})}\n\n" finally: conn.close() return Response(generate(), mimetype='text/event-stream') @main_bp.route('/chart') @login_required def chart(): conn = get_db_connection() total_sessions = conn.execute('SELECT COUNT(*) FROM sessions WHERE user_id = ?', (current_user.id,)).fetchone()[0] total_pdfs = conn.execute('SELECT COUNT(*) FROM generated_pdfs WHERE user_id = ?', (current_user.id,)).fetchone()[0] total_questions = conn.execute(""" SELECT COUNT(q.id) FROM questions q JOIN sessions s ON q.session_id = s.id WHERE s.user_id = ? """, (current_user.id,)).fetchone()[0] total_classified_questions = conn.execute(""" SELECT COUNT(q.id) FROM questions q JOIN sessions s ON q.session_id = s.id WHERE s.user_id = ? AND q.subject IS NOT NULL AND q.chapter IS NOT NULL """, (current_user.id,)).fetchone()[0] conn.close() return render_template('chart.html', total_sessions=total_sessions, total_pdfs=total_pdfs, total_questions=total_questions, total_classified_questions=total_classified_questions)