Initial commit - LEGO Instructions Manager v1.5.0

This commit is contained in:
2025-12-09 17:20:41 +11:00
commit 63496b1ccd
68 changed files with 9131 additions and 0 deletions

View 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