Initial commit - LEGO Instructions Manager v1.5.0
This commit is contained in:
317
app/services/file_handler.py
Normal file
317
app/services/file_handler.py
Normal file
@@ -0,0 +1,317 @@
|
||||
import os
|
||||
import uuid
|
||||
from werkzeug.utils import secure_filename
|
||||
from flask import current_app
|
||||
from PIL import Image
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
class FileHandler:
|
||||
"""Service for handling file uploads and storage."""
|
||||
|
||||
@staticmethod
|
||||
def allowed_file(filename: str) -> bool:
|
||||
"""Check if file extension is allowed."""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
|
||||
|
||||
@staticmethod
|
||||
def get_file_type(filename: str) -> str:
|
||||
"""Determine file type based on extension."""
|
||||
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
return 'PDF' if ext == 'pdf' else 'IMAGE'
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_filename(original_filename: str) -> str:
|
||||
"""Generate a unique filename while preserving the extension."""
|
||||
ext = secure_filename(original_filename).rsplit('.', 1)[1].lower()
|
||||
unique_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
return unique_name
|
||||
|
||||
@staticmethod
|
||||
def save_cover_image(file, set_number: str) -> Tuple[str, int]:
|
||||
"""
|
||||
Save cover image for a set.
|
||||
|
||||
Args:
|
||||
file: FileStorage object from request
|
||||
set_number: Set number for organizing files
|
||||
|
||||
Returns:
|
||||
Tuple of (relative_path, file_size_bytes)
|
||||
"""
|
||||
# Create covers directory if it doesn't exist
|
||||
covers_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'covers')
|
||||
os.makedirs(covers_dir, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
filename = FileHandler.generate_unique_filename(file.filename)
|
||||
|
||||
# Create set-specific subdirectory
|
||||
set_dir = os.path.join(covers_dir, secure_filename(set_number))
|
||||
os.makedirs(set_dir, exist_ok=True)
|
||||
|
||||
# Save file
|
||||
file_path = os.path.join(set_dir, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Create thumbnail and optimize
|
||||
try:
|
||||
img = Image.open(file_path)
|
||||
|
||||
# Convert RGBA to RGB if necessary
|
||||
if img.mode == 'RGBA':
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
background.paste(img, mask=img.split()[3])
|
||||
img = background
|
||||
|
||||
# Resize if too large (max 800px on longest side)
|
||||
max_size = 800
|
||||
if max(img.size) > max_size:
|
||||
ratio = max_size / max(img.size)
|
||||
new_size = tuple(int(dim * ratio) for dim in img.size)
|
||||
img = img.resize(new_size, Image.Resampling.LANCZOS)
|
||||
img.save(file_path, optimize=True, quality=85)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error processing cover image: {e}")
|
||||
|
||||
# Return relative path from uploads folder
|
||||
relative_path = os.path.join('covers', secure_filename(set_number), filename)
|
||||
return relative_path.replace('\\', '/'), file_size
|
||||
|
||||
@staticmethod
|
||||
def save_file(file, set_number: str, file_type: str) -> Tuple[str, int, Optional[str]]:
|
||||
"""
|
||||
Save uploaded file to appropriate directory.
|
||||
|
||||
Args:
|
||||
file: FileStorage object from request
|
||||
set_number: LEGO set number for organizing files
|
||||
file_type: 'PDF' or 'IMAGE'
|
||||
|
||||
Returns:
|
||||
Tuple of (relative_path, file_size, thumbnail_path)
|
||||
"""
|
||||
# Generate unique filename
|
||||
unique_filename = FileHandler.generate_unique_filename(file.filename)
|
||||
|
||||
# Determine subdirectory
|
||||
subdir = 'pdfs' if file_type == 'PDF' else 'images'
|
||||
|
||||
# Create set-specific directory
|
||||
set_dir = os.path.join(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
subdir,
|
||||
secure_filename(set_number)
|
||||
)
|
||||
os.makedirs(set_dir, exist_ok=True)
|
||||
|
||||
# Full file path
|
||||
file_path = os.path.join(set_dir, unique_filename)
|
||||
|
||||
# Save the file
|
||||
file.save(file_path)
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Generate thumbnail
|
||||
thumbnail_rel_path = None
|
||||
if file_type == 'IMAGE':
|
||||
thumb_path = FileHandler.create_thumbnail(file_path)
|
||||
if thumb_path:
|
||||
# Get relative path for thumbnail
|
||||
thumbnail_rel_path = os.path.join(subdir, secure_filename(set_number),
|
||||
os.path.basename(thumb_path))
|
||||
thumbnail_rel_path = thumbnail_rel_path.replace('\\', '/')
|
||||
elif file_type == 'PDF':
|
||||
thumb_path = FileHandler.create_pdf_thumbnail(file_path, set_number)
|
||||
if thumb_path:
|
||||
thumbnail_rel_path = thumb_path.replace('\\', '/')
|
||||
|
||||
# Return relative path for database storage
|
||||
relative_path = os.path.join(subdir, secure_filename(set_number), unique_filename)
|
||||
|
||||
return relative_path.replace('\\', '/'), file_size, thumbnail_rel_path
|
||||
|
||||
@staticmethod
|
||||
def create_thumbnail(image_path: str, size: Tuple[int, int] = (300, 300)) -> Optional[str]:
|
||||
"""
|
||||
Create a thumbnail for an image.
|
||||
|
||||
Args:
|
||||
image_path: Path to the original image
|
||||
size: Thumbnail size (width, height)
|
||||
|
||||
Returns:
|
||||
Path to thumbnail or None if failed
|
||||
"""
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
|
||||
# Generate thumbnail filename
|
||||
base, ext = os.path.splitext(image_path)
|
||||
thumb_path = f"{base}_thumb{ext}"
|
||||
|
||||
img.save(thumb_path, optimize=True)
|
||||
return thumb_path
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Thumbnail creation failed: {str(e)}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def create_pdf_thumbnail(pdf_path: str, set_number: str, size: Tuple[int, int] = (300, 400)) -> Optional[str]:
|
||||
"""
|
||||
Create a thumbnail from the first page of a PDF.
|
||||
|
||||
Args:
|
||||
pdf_path: Path to the PDF file
|
||||
set_number: Set number for organizing thumbnails
|
||||
size: Thumbnail size (width, height)
|
||||
|
||||
Returns:
|
||||
Relative path to thumbnail or None if failed
|
||||
"""
|
||||
try:
|
||||
# Try using PyMuPDF (fitz) first
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
|
||||
# Open PDF
|
||||
doc = fitz.open(pdf_path)
|
||||
|
||||
# Get first page
|
||||
page = doc[0]
|
||||
|
||||
# Render page to pixmap (image)
|
||||
# Use matrix for scaling to desired size
|
||||
zoom = 2 # Higher zoom for better quality
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
|
||||
# Create thumbnails directory
|
||||
thumbnails_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'thumbnails', secure_filename(set_number))
|
||||
os.makedirs(thumbnails_dir, exist_ok=True)
|
||||
|
||||
# Generate thumbnail filename
|
||||
pdf_filename = os.path.basename(pdf_path)
|
||||
thumb_filename = os.path.splitext(pdf_filename)[0] + '_thumb.png'
|
||||
thumb_path = os.path.join(thumbnails_dir, thumb_filename)
|
||||
|
||||
# Save as PNG
|
||||
pix.save(thumb_path)
|
||||
|
||||
# Close document
|
||||
doc.close()
|
||||
|
||||
# Resize to target size using PIL
|
||||
img = Image.open(thumb_path)
|
||||
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
img.save(thumb_path, optimize=True)
|
||||
|
||||
# Return relative path
|
||||
rel_path = os.path.join('thumbnails', secure_filename(set_number), thumb_filename)
|
||||
return rel_path
|
||||
|
||||
except ImportError:
|
||||
# Try pdf2image as fallback
|
||||
try:
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
# Convert first page only
|
||||
images = convert_from_path(pdf_path, first_page=1, last_page=1, dpi=150)
|
||||
|
||||
if images:
|
||||
# Create thumbnails directory
|
||||
thumbnails_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'thumbnails', secure_filename(set_number))
|
||||
os.makedirs(thumbnails_dir, exist_ok=True)
|
||||
|
||||
# Generate thumbnail filename
|
||||
pdf_filename = os.path.basename(pdf_path)
|
||||
thumb_filename = os.path.splitext(pdf_filename)[0] + '_thumb.png'
|
||||
thumb_path = os.path.join(thumbnails_dir, thumb_filename)
|
||||
|
||||
# Resize and save
|
||||
img = images[0]
|
||||
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
img.save(thumb_path, 'PNG', optimize=True)
|
||||
|
||||
# Return relative path
|
||||
rel_path = os.path.join('thumbnails', secure_filename(set_number), thumb_filename)
|
||||
return rel_path
|
||||
|
||||
except ImportError:
|
||||
current_app.logger.warning('Neither PyMuPDF nor pdf2image available for PDF thumbnails')
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'PDF thumbnail creation failed: {str(e)}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def delete_file(file_path: str) -> bool:
|
||||
"""
|
||||
Delete a file from storage.
|
||||
|
||||
Args:
|
||||
file_path: Relative path to the file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
full_path = os.path.join(current_app.config['UPLOAD_FOLDER'], file_path)
|
||||
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
|
||||
# Also delete thumbnail if it exists
|
||||
base, ext = os.path.splitext(full_path)
|
||||
thumb_path = f"{base}_thumb{ext}"
|
||||
if os.path.exists(thumb_path):
|
||||
os.remove(thumb_path)
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'File deletion failed: {str(e)}')
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_directory_size(set_number: str) -> Tuple[int, int]:
|
||||
"""
|
||||
Get total size of all files for a specific set.
|
||||
|
||||
Args:
|
||||
set_number: LEGO set number
|
||||
|
||||
Returns:
|
||||
Tuple of (total_size_bytes, file_count)
|
||||
"""
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
|
||||
for subdir in ['pdfs', 'images']:
|
||||
set_dir = os.path.join(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
subdir,
|
||||
secure_filename(set_number)
|
||||
)
|
||||
|
||||
if os.path.exists(set_dir):
|
||||
for filename in os.listdir(set_dir):
|
||||
if not filename.endswith('_thumb'):
|
||||
filepath = os.path.join(set_dir, filename)
|
||||
if os.path.isfile(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
file_count += 1
|
||||
|
||||
return total_size, file_count
|
||||
Reference in New Issue
Block a user