Compare commits
3 Commits
be43fe8a0a
...
39fb2edd71
| Author | SHA1 | Date | |
|---|---|---|---|
| 39fb2edd71 | |||
| f9d6662467 | |||
| 93e3397553 |
@@ -6,3 +6,5 @@
|
||||
*.egg-info
|
||||
**/__pycache__
|
||||
**/__mypy_cache__
|
||||
**/media
|
||||
*.sqlite
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
instance/
|
||||
*.sqlite
|
||||
media/
|
||||
102
Caddyfile
Normal file
102
Caddyfile
Normal file
@@ -0,0 +1,102 @@
|
||||
# Caddyfile for HXBooks
|
||||
# Replace 'localhost' with your domain for production with automatic HTTPS
|
||||
localhost {
|
||||
# Serve static files directly (CSS, JS, images, etc.)
|
||||
handle /static/* {
|
||||
root * /var/www
|
||||
file_server
|
||||
|
||||
# Cache static assets for 7 days (good balance of performance vs update flexibility)
|
||||
header {
|
||||
Cache-Control "public, max-age=604800"
|
||||
# ETag support is enabled by default in file_server
|
||||
}
|
||||
}
|
||||
|
||||
# Serve book cover images directly
|
||||
handle /media/covers/* {
|
||||
root * /var/www
|
||||
file_server
|
||||
|
||||
# Cache cover images for 30 days (they may be updated occasionally)
|
||||
header {
|
||||
Cache-Control "public, max-age=2592000"
|
||||
}
|
||||
}
|
||||
|
||||
# Proxy all other requests to the Flask app
|
||||
reverse_proxy app:5000 {
|
||||
# Health check endpoint
|
||||
health_uri /
|
||||
health_interval 30s
|
||||
health_timeout 10s
|
||||
|
||||
# Forward real IP to app
|
||||
header_up X-Real-IP {remote}
|
||||
header_up X-Forwarded-For {remote}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-Host {host}
|
||||
}
|
||||
|
||||
# Optional: Enable compression for better performance
|
||||
encode gzip
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
# Remove server identification
|
||||
-Server
|
||||
|
||||
# Security headers
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
X-XSS-Protection "1; mode=block"
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
|
||||
# Logging
|
||||
log {
|
||||
output file /var/log/caddy/access.log
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
# Production example - uncomment and modify for your domain
|
||||
# your-domain.com {
|
||||
# handle /static/* {
|
||||
# root * /var/www
|
||||
# file_server
|
||||
#
|
||||
# # Cache static assets for 7 days
|
||||
# header {
|
||||
# Cache-Control "public, max-age=604800"
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# handle /media/covers/* {
|
||||
# root * /var/www
|
||||
# file_server
|
||||
#
|
||||
# # Cache cover images for 30 days
|
||||
# header {
|
||||
# Cache-Control "public, max-age=2592000"
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# reverse_proxy app:5000 {
|
||||
# header_up X-Real-IP {remote}
|
||||
# header_up X-Forwarded-For {remote}
|
||||
# header_up X-Forwarded-Proto {scheme}
|
||||
# header_up X-Forwarded-Host {host}
|
||||
# }
|
||||
#
|
||||
# encode gzip
|
||||
#
|
||||
# header {
|
||||
# -Server
|
||||
# X-Content-Type-Options nosniff
|
||||
# X-Frame-Options DENY
|
||||
# X-XSS-Protection "1; mode=block"
|
||||
# Referrer-Policy strict-origin-when-cross-origin
|
||||
# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
# }
|
||||
# }
|
||||
53
Dockerfile
53
Dockerfile
@@ -1,11 +1,50 @@
|
||||
FROM python:3.11-alpine
|
||||
# Multi-stage build example for HXBooks
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-alpine AS builder
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
|
||||
# Omit development dependencies
|
||||
ENV UV_NO_DEV=1
|
||||
|
||||
# Disable Python downloads, because we want to use the system interpreter
|
||||
# across both images. If using a managed Python version, it needs to be
|
||||
# copied from the build image into the final image; see `standalone.Dockerfile`
|
||||
# for an example.
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
COPY . .
|
||||
RUN pip install -e .
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project
|
||||
COPY . /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked
|
||||
|
||||
EXPOSE 8080
|
||||
# Then, use a final image without uv
|
||||
FROM python:3.14-alpine
|
||||
# It is important to use the image that matches the builder, as the path to the
|
||||
# Python executable must be the same, e.g., using `python:3.11-slim-bookworm`
|
||||
# will fail.
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "hxbooks:create_app()"]
|
||||
# Copy the application from the builder
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# Copy and setup entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Create shared directories for volumes
|
||||
RUN mkdir -p /shared/static
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Set Flask app for migrations
|
||||
ENV FLASK_APP="src.hxbooks.app:create_app()"
|
||||
|
||||
# Use `/app` as the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set entrypoint and default command
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["hxbooks", "serve", "--host", "0.0.0.0", "--port", "5000"]
|
||||
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Mount media directory for book covers
|
||||
- media:/app/media
|
||||
# Mount instance directory for config and database persistence
|
||||
- instance:/app/instance
|
||||
# Mount shared directory for static files that Caddy can access
|
||||
- static:/shared/static
|
||||
expose:
|
||||
- "5000"
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
networks:
|
||||
- hxbooks
|
||||
# Health check to ensure app is ready
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --spider http://127.0.0.1:5000/ || exit 1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
# Caddyfile configuration
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
# Media files served directly by Caddy
|
||||
- media:/var/www/media:ro
|
||||
# Static files served directly by Caddy (populated by app container)
|
||||
- static:/var/www/static:ro
|
||||
# Caddy data for TLS certificates
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- hxbooks
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
hxbooks:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
media:
|
||||
driver: local
|
||||
instance:
|
||||
driver: local
|
||||
static:
|
||||
driver: local
|
||||
caddy_data:
|
||||
driver: local
|
||||
caddy_config:
|
||||
driver: local
|
||||
31
docker-entrypoint.sh
Normal file
31
docker-entrypoint.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Copy static files to shared volume if they don't exist
|
||||
# This allows Caddy to serve static files directly
|
||||
if [ ! -f /shared/static/.static-files-ready ]; then
|
||||
echo "Copying static files to shared volume..."
|
||||
cp -r /app/src/hxbooks/static/* /shared/static/
|
||||
touch /shared/static/.static-files-ready
|
||||
echo "Static files copied successfully"
|
||||
else
|
||||
echo "Static files already present in shared volume"
|
||||
fi
|
||||
|
||||
# Initialize database if it doesn't exist or run migrations if it does
|
||||
echo "Checking database status..."
|
||||
if [ ! -f /app/instance/hxbooks.sqlite ]; then
|
||||
echo "Database not found. Initializing database..."
|
||||
hxbooks db init
|
||||
echo "Database initialized successfully"
|
||||
else
|
||||
echo "Database exists. Running migrations..."
|
||||
# Run Flask-Migrate migrations
|
||||
flask db upgrade 2>/dev/null || {
|
||||
echo "No migrations to run or migration failed. Database ready."
|
||||
}
|
||||
fi
|
||||
|
||||
# Start the application
|
||||
echo "Starting HXBooks application..."
|
||||
exec "$@"
|
||||
123
docs/DEPLOYMENT.md
Normal file
123
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# HXBooks Docker Deployment
|
||||
|
||||
This directory contains Docker Compose configuration for deploying HXBooks with Caddy as a reverse proxy.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **App Container**: Runs the Flask application on port 5000
|
||||
- **Caddy Container**: Reverse proxy with automatic HTTPS, serves static files directly
|
||||
- **Database Initialization**: Automatic on first startup (creates SQLite database and runs migrations)
|
||||
- **Volumes**:
|
||||
- `media`: Book cover images (shared between app and Caddy)
|
||||
- `static`: CSS/JS files (copied from app to volume on startup, served by Caddy)
|
||||
- `instance`: Application configuration and SQLite database
|
||||
- `caddy_data`: TLS certificates
|
||||
- `caddy_config`: Caddy configuration cache
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Development (localhost)**:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
Access the app at http://localhost
|
||||
|
||||
2. **Production with your domain**:
|
||||
- Edit `Caddyfile` and replace `localhost` with your domain
|
||||
- Run: `docker-compose up -d`
|
||||
- Caddy will automatically obtain TLS certificates
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these in your `docker-compose.yml` or create a `.env` file:
|
||||
|
||||
```bash
|
||||
# Flask configuration
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your-secret-key-here
|
||||
|
||||
# Optional: Google Books API key
|
||||
GOOGLE_BOOKS_API_KEY=your-api-key
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
Create `instance/config.py` for local overrides:
|
||||
|
||||
```python
|
||||
SECRET_KEY = "your-production-secret-key"
|
||||
GOOGLE_BOOKS_API_KEY = "your-api-key"
|
||||
# Add other Flask config as needed
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f app
|
||||
docker-compose logs -f caddy
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose up --build -d
|
||||
|
||||
# Remove everything including volumes (⚠️ destroys data)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
## File Serving Strategy
|
||||
|
||||
- **Static files** (CSS/JS): Copied from app container to shared volume on startup, served directly by Caddy
|
||||
- **Media files** (book covers): Stored in shared volume, served directly by Caddy
|
||||
- **Application routes**: Proxied to Flask app
|
||||
|
||||
This setup provides optimal performance by having Caddy serve static assets while the Flask app handles dynamic content.
|
||||
|
||||
## Database Initialization
|
||||
|
||||
The application handles database setup automatically on first startup:
|
||||
|
||||
1. **Fresh Install**: Creates SQLite database and initializes tables using `hxbooks db init`
|
||||
2. **Existing Database**: Runs Flask-Migrate migrations with `flask db upgrade`
|
||||
3. **Database Location**: `/app/instance/hxbooks.sqlite` (persisted in `instance` volume)
|
||||
|
||||
**Manual Database Operations** (if needed):
|
||||
```bash
|
||||
# Connect to running container
|
||||
docker-compose exec app sh
|
||||
|
||||
# Initialize database manually
|
||||
hxbooks db init
|
||||
|
||||
# Create new migration
|
||||
flask db migrate -m "description"
|
||||
|
||||
# Apply migrations
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
## Production Notes
|
||||
|
||||
1. **Database**: Currently uses SQLite with volume persistence. For high-traffic sites, consider PostgreSQL.
|
||||
|
||||
2. **Backups**: The important data is in the `instance` and `media` volumes:
|
||||
```bash
|
||||
# Backup
|
||||
docker run --rm -v hxbooks_instance:/source -v $(pwd):/backup alpine tar czf /backup/instance-backup.tar.gz -C /source .
|
||||
docker run --rm -v hxbooks_media:/source -v $(pwd):/backup alpine tar czf /backup/media-backup.tar.gz -C /source .
|
||||
```
|
||||
|
||||
3. **Updates**: To update the application:
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up --build -d
|
||||
```
|
||||
|
||||
4. **Monitoring**: Consider adding health check endpoints and monitoring services to the stack.
|
||||
72
docs/cover-images.md
Normal file
72
docs/cover-images.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Book Cover Images Setup
|
||||
|
||||
HXBooks now supports book cover images using static file storage with nginx/caddy for production.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Development**: Flask serves covers via `/media/covers/<filename>` route
|
||||
**Production**: nginx serves covers directly from filesystem for optimal performance
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/app/
|
||||
├── src/hxbooks/static/ # App assets (CSS, JS) -> served at /static/
|
||||
├── media/covers/ # Book covers -> served at /media/
|
||||
└── instance/ # Database, config
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Docker Setup
|
||||
```bash
|
||||
# Build and run with nginx + gunicorn
|
||||
docker-compose up --build
|
||||
|
||||
# Or build manually
|
||||
docker build -f Dockerfile.production -t hxbooks .
|
||||
docker run -p 8080:80 -v ./media:/app/media -v ./instance:/app/instance hxbooks
|
||||
```
|
||||
|
||||
### nginx Configuration
|
||||
- Static files: nginx serves `/static/` and `/media/` directly
|
||||
- Dynamic requests: proxied to gunicorn on port 8080
|
||||
- Proper caching headers for performance
|
||||
|
||||
## Cover Image Features
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
# Import with cover download (default)
|
||||
hxbooks book import "9780123456789"
|
||||
|
||||
# Skip cover download
|
||||
hxbooks book import "9780123456789" --no-cover
|
||||
```
|
||||
|
||||
### Web Interface
|
||||
- Book cards display covers when available
|
||||
- Fallback to book emoji for books without covers
|
||||
- Google Books API integration provides cover URLs
|
||||
|
||||
## Storage Details
|
||||
|
||||
- **Images downloaded from**: Google Books API (thumbnail/smallThumbnail)
|
||||
- **Filename format**: `book_{id}.{extension}`
|
||||
- **Security**: HTTP URLs converted to HTTPS
|
||||
- **Fallback**: graceful handling if download fails
|
||||
- **Database**: `cover_image_path` field stores relative filename
|
||||
|
||||
## Performance
|
||||
|
||||
**nginx serves static files directly** (bypassing Python completely):
|
||||
- ✅ ~1ms response time for images
|
||||
- ✅ Proper browser caching
|
||||
- ✅ Gzip compression
|
||||
- ✅ No Python memory usage for images
|
||||
|
||||
vs. database BLOB storage through Python:
|
||||
- ❌ ~50-200ms response time
|
||||
- ❌ Limited caching options
|
||||
- ❌ High memory usage
|
||||
- ❌ Database file bloat
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add cover_image_path to Book model
|
||||
|
||||
Revision ID: 0c155d83c55b
|
||||
Revises: 75e81e4ab7b6
|
||||
Create Date: 2026-03-24 20:45:34.613875
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0c155d83c55b'
|
||||
down_revision = '75e81e4ab7b6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('book', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('cover_image_path', sa.String(length=200), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('book', schema=None) as batch_op:
|
||||
batch_op.drop_column('cover_image_path')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -13,11 +13,13 @@ dependencies = [
|
||||
"flask-sqlalchemy>=3.1.1",
|
||||
"gunicorn>=25.1.0",
|
||||
"jinja2-fragments>=1.11.0",
|
||||
"pillow>=12.1.1",
|
||||
"pydantic>=2.12.5",
|
||||
"pydantic-extra-types>=2.11.1",
|
||||
"pyparsing>=3.3.2",
|
||||
"requests>=2.32.5",
|
||||
"sqlalchemy>=2.0.48",
|
||||
"waitress>=3.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,6 +22,9 @@ def create_app(test_config: dict | None = None) -> Flask:
|
||||
SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'hxbooks.sqlite'}",
|
||||
)
|
||||
|
||||
# Setup logging
|
||||
_setup_logging(app, test_config is not None)
|
||||
|
||||
if test_config is None:
|
||||
# load the instance config, if it exists, when not testing
|
||||
app.config.from_pyfile("config.py", silent=True)
|
||||
@@ -31,6 +35,8 @@ def create_app(test_config: dict | None = None) -> Flask:
|
||||
# ensure the instance folder exists
|
||||
try:
|
||||
os.makedirs(app.instance_path)
|
||||
# Also create media directories
|
||||
os.makedirs(os.path.join(PROJECT_ROOT, "media", "covers"), exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -43,3 +49,37 @@ def create_app(test_config: dict | None = None) -> Flask:
|
||||
app.register_blueprint(main_bp)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _setup_logging(app: Flask, is_testing: bool = False) -> None:
|
||||
"""Setup application logging with terminal output."""
|
||||
if is_testing:
|
||||
# Disable logging during tests to reduce noise
|
||||
logging.getLogger("hxbooks").setLevel(logging.CRITICAL)
|
||||
return
|
||||
|
||||
# Create logger for the app
|
||||
logger = logging.getLogger("hxbooks")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# Avoid duplicate handlers if create_app is called multiple times
|
||||
if logger.handlers:
|
||||
return
|
||||
|
||||
# Create console handler
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
# Create formatter
|
||||
formatter = logging.Formatter(
|
||||
"[%(asctime)s] %(levelname)s in %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
# Add handler to logger
|
||||
logger.addHandler(handler)
|
||||
|
||||
# Also configure the root Flask logger for this app
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.addHandler(handler)
|
||||
|
||||
@@ -6,7 +6,9 @@ while keeping business logic separate from web interface concerns.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import click
|
||||
from flask import Flask
|
||||
@@ -16,6 +18,8 @@ from .app import create_app
|
||||
from .db import db
|
||||
from .models import Author, Book, Genre, Reading, User, Wishlist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_app() -> Flask:
|
||||
"""Create and configure Flask app for CLI operations."""
|
||||
@@ -84,6 +88,7 @@ def db_group() -> None:
|
||||
@click.option("--shelf", type=int, help="Shelf number")
|
||||
@click.option("--description", help="Book description")
|
||||
@click.option("--notes", help="Personal notes")
|
||||
@click.option("--cover-url", help="Cover image URL (http/https/file://)")
|
||||
def add_book(
|
||||
title: str,
|
||||
owner: str,
|
||||
@@ -97,6 +102,7 @@ def add_book(
|
||||
shelf: int | None = None,
|
||||
description: str | None = None,
|
||||
notes: str | None = None,
|
||||
cover_url: str | None = None,
|
||||
) -> None:
|
||||
"""Add a new book to the library."""
|
||||
app = get_app()
|
||||
@@ -120,9 +126,11 @@ def add_book(
|
||||
location_shelf=shelf,
|
||||
description=description,
|
||||
notes=notes,
|
||||
cover_image_url=cover_url,
|
||||
)
|
||||
click.echo(f"Added book: {book.title} (ID: {book.id})")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding book '{title}': {e}", exc_info=True)
|
||||
click.echo(f"Error adding book: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -188,6 +196,7 @@ def get_book(book_id: int) -> None:
|
||||
click.echo(json.dumps(book_info, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting book {book_id}: {e}", exc_info=True)
|
||||
click.echo(f"Error getting book: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -206,10 +215,114 @@ def delete_book(book_id: int) -> None:
|
||||
click.echo(f"Book with ID {book_id} not found.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting book {book_id}: {e}", exc_info=True)
|
||||
click.echo(f"Error deleting book: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@book.command("edit")
|
||||
@click.argument("book_id", type=int)
|
||||
@click.option("--title", help="Book title")
|
||||
@click.option("--authors", help="Comma-separated list of authors")
|
||||
@click.option("--genres", help="Comma-separated list of genres")
|
||||
@click.option("--isbn", help="ISBN number")
|
||||
@click.option("--publisher", help="Publisher name")
|
||||
@click.option("--edition", help="Edition information")
|
||||
@click.option("--description", help="Book description")
|
||||
@click.option("--notes", help="Personal notes")
|
||||
@click.option("--cover-url", help="Cover image URL (http/https/file://)")
|
||||
@click.option("--owner", help="Username of book owner")
|
||||
@click.option("--place", help="Location place")
|
||||
@click.option("--bookshelf", help="Bookshelf name")
|
||||
@click.option("--shelf", type=int, help="Shelf number")
|
||||
@click.option("--year", type=int, help="Publication year")
|
||||
@click.option("--loaned-to", help="Person book is loaned to")
|
||||
@click.option(
|
||||
"--bought-date", type=click.DateTime(), help="Date book was bought (YYYY-MM-DD)"
|
||||
)
|
||||
@click.option(
|
||||
"--loaned-date", type=click.DateTime(), help="Date book was loaned (YYYY-MM-DD)"
|
||||
)
|
||||
def edit_book(
|
||||
book_id: int,
|
||||
title: str | None = None,
|
||||
authors: str | None = None,
|
||||
genres: str | None = None,
|
||||
isbn: str | None = None,
|
||||
publisher: str | None = None,
|
||||
edition: str | None = None,
|
||||
description: str | None = None,
|
||||
notes: str | None = None,
|
||||
cover_url: str | None = None,
|
||||
owner: str | None = None,
|
||||
place: str | None = None,
|
||||
bookshelf: str | None = None,
|
||||
shelf: int | None = None,
|
||||
year: int | None = None,
|
||||
loaned_to: str | None = None,
|
||||
bought_date: datetime | None = None,
|
||||
loaned_date: datetime | None = None,
|
||||
) -> None:
|
||||
"""Edit an existing book's details."""
|
||||
app = get_app()
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
# Check if book exists
|
||||
book = library.get_book(book_id)
|
||||
if not book:
|
||||
click.echo(f"Book with ID {book_id} not found.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Get owner ID if provided
|
||||
owner_id = None
|
||||
if owner:
|
||||
owner_id = ensure_user_exists(app, owner)
|
||||
|
||||
# Parse authors and genres
|
||||
authors_list = [a.strip() for a in authors.split(",")] if authors else None
|
||||
genres_list = [g.strip() for g in genres.split(",")] if genres else None
|
||||
|
||||
# Convert dates
|
||||
bought_date_obj = bought_date.date() if bought_date else None
|
||||
loaned_date_obj = loaned_date.date() if loaned_date else None
|
||||
|
||||
# Update book
|
||||
updated_book = library.update_book(
|
||||
book_id=book_id,
|
||||
title=title,
|
||||
owner_id=owner_id,
|
||||
authors=authors_list,
|
||||
genres=genres_list,
|
||||
isbn=isbn,
|
||||
publisher=publisher,
|
||||
edition=edition,
|
||||
description=description,
|
||||
notes=notes,
|
||||
cover_image_url=cover_url,
|
||||
location_place=place,
|
||||
location_bookshelf=bookshelf,
|
||||
location_shelf=shelf,
|
||||
first_published=year,
|
||||
loaned_to=loaned_to,
|
||||
bought_date=bought_date_obj,
|
||||
loaned_date=loaned_date_obj,
|
||||
)
|
||||
|
||||
if updated_book:
|
||||
click.echo(
|
||||
f"Updated book: {updated_book.title} (ID: {updated_book.id})"
|
||||
)
|
||||
else:
|
||||
click.echo(f"Failed to update book with ID {book_id}")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing book {book_id}: {e}", exc_info=True)
|
||||
click.echo(f"Error editing book: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@book.command("list")
|
||||
@click.option("--owner", help="Filter by owner username")
|
||||
@click.option("--place", help="Filter by location place")
|
||||
@@ -276,6 +389,7 @@ def list_books(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing books: {e}", exc_info=True)
|
||||
click.echo(f"Error listing books: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -361,12 +475,14 @@ def search_books(
|
||||
@click.option("--place", help="Location place")
|
||||
@click.option("--bookshelf", help="Bookshelf name")
|
||||
@click.option("--shelf", type=int, help="Shelf number")
|
||||
@click.option("--no-cover", is_flag=True, help="Skip downloading cover image")
|
||||
def import_book(
|
||||
isbn: str,
|
||||
owner: str | None = None,
|
||||
place: str | None = None,
|
||||
bookshelf: str | None = None,
|
||||
shelf: int | None = None,
|
||||
no_cover: bool = False,
|
||||
) -> None:
|
||||
"""Import book data from ISBN using Google Books API."""
|
||||
app = get_app()
|
||||
@@ -383,6 +499,7 @@ def import_book(
|
||||
location_place=place,
|
||||
location_bookshelf=bookshelf,
|
||||
location_shelf=shelf,
|
||||
fetch_cover=not no_cover,
|
||||
)
|
||||
click.echo(
|
||||
f"Imported book: {book.title} by {', '.join(a.name for a in book.authors)} (ID: {book.id})"
|
||||
@@ -716,16 +833,25 @@ def db_status() -> None:
|
||||
|
||||
|
||||
@cli.command()
|
||||
def serve() -> None:
|
||||
@click.option("--port", default=5000, help="Port to run the web server on")
|
||||
@click.option("--host", default="0.0.0.0", help="Host to run the web server on")
|
||||
@click.option("--debug", is_flag=True, help="Run in debug mode with auto-reload")
|
||||
def serve(port: int, host: str, debug: bool) -> None:
|
||||
"""Start the web server."""
|
||||
if debug:
|
||||
import livereload # noqa: PLC0415
|
||||
|
||||
app = get_app()
|
||||
app.debug = True
|
||||
app.debug = debug
|
||||
# app.run()
|
||||
server = livereload.Server(app.wsgi_app)
|
||||
server.watch("hxbooks/templates/**")
|
||||
server.serve(port=5000, host="0.0.0.0")
|
||||
server.serve(port=port, host=host)
|
||||
else:
|
||||
import waitress # noqa: PLC0415
|
||||
|
||||
app = get_app()
|
||||
waitress.serve(app, port=port, host=host)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from os import environ
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
@@ -5,6 +6,9 @@ from typing import Any
|
||||
import requests
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
# Get logger for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GoogleBook(BaseModel):
|
||||
title: str
|
||||
@@ -40,12 +44,29 @@ class GoogleBook(BaseModel):
|
||||
|
||||
|
||||
def fetch_google_book_data(isbn: str) -> GoogleBook:
|
||||
"""Fetch book data from Google Books API by ISBN."""
|
||||
try:
|
||||
api_key = environ.get("GOOGLE_BOOKS_API_KEY")
|
||||
logger.info(f"Fetching Google Books data for ISBN: {isbn}")
|
||||
|
||||
req = requests.get(
|
||||
"https://www.googleapis.com/books/v1/volumes", params={"q": f"isbn:{isbn}", "key": api_key}
|
||||
"https://www.googleapis.com/books/v1/volumes",
|
||||
params={"q": f"isbn:{isbn}", "key": api_key},
|
||||
timeout=10
|
||||
)
|
||||
req.raise_for_status()
|
||||
data = req.json()
|
||||
|
||||
if data["totalItems"] == 0:
|
||||
logger.warning(f"No Google Books data found for ISBN: {isbn}")
|
||||
raise ValueError(f"Book with ISBN {isbn} not found")
|
||||
|
||||
logger.info(f"Successfully fetched Google Books data for ISBN: {isbn}")
|
||||
return GoogleBook.model_validate(data["items"][0]["volumeInfo"])
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"HTTP error fetching Google Books data for ISBN {isbn}: {e}", exc_info=True)
|
||||
raise ValueError(f"Failed to fetch book data from Google Books: {e}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error fetching Google Books data for ISBN {isbn}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
@@ -5,11 +5,16 @@ Clean service layer for book management, reading tracking, and wishlist operatio
|
||||
Separated from web interface concerns to enable both CLI and web access.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import assert_never
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
from PIL import Image
|
||||
from sqlalchemy import ColumnElement, Select, and_, func, or_, select
|
||||
from sqlalchemy.orm import InstrumentedAttribute, joinedload
|
||||
|
||||
@@ -20,6 +25,9 @@ from .gbooks import fetch_google_book_data
|
||||
from .models import Author, Book, Genre, Reading, User, Wishlist
|
||||
from .search import ComparisonOperator, Field, FieldFilter
|
||||
|
||||
# Get logger for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_book(
|
||||
title: str,
|
||||
@@ -38,6 +46,7 @@ def create_book(
|
||||
bought_date: date | None = None,
|
||||
loaned_to: str | None = None,
|
||||
loaned_date: date | None = None,
|
||||
cover_image_url: str | None = None,
|
||||
) -> Book:
|
||||
"""Create a new book with the given details."""
|
||||
book = Book(
|
||||
@@ -71,6 +80,11 @@ def create_book(
|
||||
book.genres.append(genre)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Handle cover image if provided
|
||||
if cover_image_url:
|
||||
download_book_cover(book, cover_image_url)
|
||||
|
||||
return book
|
||||
|
||||
|
||||
@@ -111,6 +125,7 @@ def update_book(
|
||||
bought_date: date | None = None,
|
||||
loaned_to: str | None = None,
|
||||
loaned_date: date | None = None,
|
||||
cover_image_url: str | None = None,
|
||||
) -> Book | None:
|
||||
"""Update a book with new details."""
|
||||
book = get_book(book_id)
|
||||
@@ -185,6 +200,14 @@ def update_book(
|
||||
if owner_id is not None or set_all_fields:
|
||||
book.owner_id = owner_id
|
||||
|
||||
if cover_image_url is not None or set_all_fields:
|
||||
if cover_image_url is not None and (stripped := cover_image_url.strip()):
|
||||
# Only download if URL is different from current
|
||||
if stripped != book.cover_image_path:
|
||||
download_book_cover(book, cover_image_url)
|
||||
else:
|
||||
remove_book_cover(book)
|
||||
|
||||
db.session.commit()
|
||||
return book
|
||||
|
||||
@@ -557,8 +580,9 @@ def import_book_from_isbn(
|
||||
location_place: str | None = None,
|
||||
location_bookshelf: str | None = None,
|
||||
location_shelf: int | None = None,
|
||||
fetch_cover: bool = True,
|
||||
) -> Book:
|
||||
"""Import book data from Google Books API using ISBN."""
|
||||
"""Import book data from Google Books API using ISBN with optional cover download."""
|
||||
google_book_data = fetch_google_book_data(isbn)
|
||||
if not google_book_data:
|
||||
raise ValueError(f"No book data found for ISBN: {isbn}")
|
||||
@@ -579,7 +603,7 @@ def import_book_from_isbn(
|
||||
elif isinstance(google_book_data.publishedDate, int):
|
||||
first_published = google_book_data.publishedDate
|
||||
|
||||
return create_book(
|
||||
book = create_book(
|
||||
title=google_book_data.title,
|
||||
owner_id=owner_id,
|
||||
authors=authors,
|
||||
@@ -593,6 +617,20 @@ def import_book_from_isbn(
|
||||
location_shelf=location_shelf,
|
||||
)
|
||||
|
||||
# Download cover if available and requested
|
||||
if fetch_cover and google_book_data.imageLinks:
|
||||
# Try thumbnail first, fallback to small image
|
||||
image_url = google_book_data.imageLinks.get(
|
||||
"thumbnail"
|
||||
) or google_book_data.imageLinks.get("smallThumbnail")
|
||||
if image_url:
|
||||
# Replace http with https for security
|
||||
if image_url.startswith("http://"):
|
||||
image_url = image_url.replace("http://", "https://", 1)
|
||||
download_book_cover(book, image_url)
|
||||
|
||||
return book
|
||||
|
||||
|
||||
def get_books_by_location(
|
||||
place: str, bookshelf: str | None = None, shelf: int | None = None
|
||||
@@ -910,3 +948,107 @@ def list_locations() -> dict[str, list[str]]:
|
||||
for location_place, location_bookshelf in result:
|
||||
ret[location_place].append(location_bookshelf)
|
||||
return ret
|
||||
|
||||
|
||||
def download_book_cover(book: Book, image_url: str) -> bool:
|
||||
"""Download and save a book cover image from URL or file:// path.
|
||||
|
||||
Supports both HTTP(S) URLs and file:// URLs for local files.
|
||||
Images larger than 200px vertically are scaled down.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Create covers directory if it doesn't exist
|
||||
covers_dir = Path(current_app.instance_path).parent / "media" / "covers"
|
||||
covers_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Handle file:// URLs
|
||||
if image_url.startswith("file://"):
|
||||
source_path = Path(image_url.replace("file://", ""))
|
||||
if not source_path.exists():
|
||||
return False
|
||||
|
||||
# Load image directly from file
|
||||
with Image.open(source_path) as image:
|
||||
processed_image = _process_cover_image(image)
|
||||
|
||||
# Generate filename
|
||||
extension = ".jpg" # Always save as JPEG
|
||||
filename = f"book_{book.id}{extension}"
|
||||
cover_path = covers_dir / filename
|
||||
|
||||
# Save processed image
|
||||
processed_image.save(cover_path, "JPEG", quality=85)
|
||||
else:
|
||||
# Handle HTTP(S) URLs
|
||||
response = requests.get(image_url, timeout=10, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
# Load image from response content
|
||||
with Image.open(response.raw) as image:
|
||||
processed_image = _process_cover_image(image)
|
||||
|
||||
# Generate filename
|
||||
extension = ".jpg" # Always save as JPEG
|
||||
filename = f"book_{book.id}{extension}"
|
||||
cover_path = covers_dir / filename
|
||||
|
||||
# Save processed image
|
||||
processed_image.save(cover_path, "JPEG", quality=85)
|
||||
|
||||
# Update book record
|
||||
book.cover_image_path = filename
|
||||
db.session.commit()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Log error details but don't fail the book creation
|
||||
logger.error(
|
||||
f"Failed to download cover image from {image_url} for book {book.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _process_cover_image(image: Image.Image) -> Image.Image:
|
||||
"""Process a cover image: convert to RGB and scale if needed."""
|
||||
# Convert to RGB (handles RGBA, grayscale, etc.)
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
|
||||
# Scale down if height > 200px
|
||||
width, height = image.size
|
||||
if height > 200:
|
||||
# Calculate new dimensions maintaining aspect ratio
|
||||
new_height = 200
|
||||
new_width = int(width * (new_height / height))
|
||||
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def remove_book_cover(book: Book) -> bool:
|
||||
"""Remove a book's cover image file and database reference."""
|
||||
try:
|
||||
if not book.cover_image_path:
|
||||
return True
|
||||
|
||||
# Remove file
|
||||
covers_dir = Path(current_app.instance_path).parent / "media" / "covers"
|
||||
cover_path = covers_dir / book.cover_image_path
|
||||
if cover_path.exists():
|
||||
cover_path.unlink()
|
||||
|
||||
# Clear database reference
|
||||
book.cover_image_path = None
|
||||
db.session.commit()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to remove cover image {book.cover_image_path} for book {book.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -4,23 +4,29 @@ Main application routes for HXBooks frontend.
|
||||
Provides clean URL structure and integrates with library.py business logic.
|
||||
"""
|
||||
|
||||
import traceback
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
current_app,
|
||||
flash,
|
||||
g,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from flask.typing import ResponseReturnValue
|
||||
from pydantic import (
|
||||
AnyHttpUrl,
|
||||
BaseModel,
|
||||
BeforeValidator,
|
||||
Field,
|
||||
@@ -38,6 +44,9 @@ from .db import db
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
|
||||
# Get logger for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Pydantic validation models
|
||||
StrOrNone = Annotated[str | None, BeforeValidator(lambda v: v.strip() or None)]
|
||||
@@ -55,6 +64,7 @@ TextareaList = Annotated[
|
||||
]
|
||||
DateOrNone = Annotated[date | None, BeforeValidator(lambda v: v.strip() or None)]
|
||||
IntOrNone = Annotated[int | None, BeforeValidator(lambda v: v.strip() or None)]
|
||||
UrlOrNone = Annotated[AnyHttpUrl | None, BeforeValidator(lambda v: v.strip() or None)]
|
||||
|
||||
|
||||
class BookFormData(BaseModel):
|
||||
@@ -73,6 +83,8 @@ class BookFormData(BaseModel):
|
||||
location_shelf: IntOrNone = Field(default=None, ge=1)
|
||||
loaned_to: StripStr = Field(default="")
|
||||
loaned_date: DateOrNone = None
|
||||
cover_image_url: UrlOrNone = None
|
||||
delete_cover: bool = Field(default=False)
|
||||
|
||||
|
||||
class ReadingFormData(BaseModel):
|
||||
@@ -88,6 +100,7 @@ def _flash_validation_errors(e: ValidationError) -> None:
|
||||
error = e.errors()[0]
|
||||
loc = " -> ".join(str(v) for v in error.get("loc", []))
|
||||
msg = error.get("msg", "Invalid input")
|
||||
logger.warning(f"Validation error in '{loc}': {msg} | Full errors: {e.errors()}")
|
||||
flash(f"Validation error in '{loc}': {msg}", "error")
|
||||
|
||||
|
||||
@@ -173,9 +186,8 @@ def index() -> ResponseReturnValue:
|
||||
query, limit=RESULTS_PER_PAGE, offset=offset, username=viewing_user
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Search error for query '{query}': {e}", exc_info=True)
|
||||
flash(f"Search error: {e}", "error")
|
||||
# print traceback for debugging
|
||||
traceback.print_exc()
|
||||
books, total_count = [], 0
|
||||
|
||||
# Calculate pagination info
|
||||
@@ -230,6 +242,27 @@ def _get_or_create_user(username: str) -> int:
|
||||
return owner.id
|
||||
|
||||
|
||||
def _handle_cover_image(form_data: BookFormData) -> str | None:
|
||||
uploaded_file = request.files.get("cover_image_file")
|
||||
cover_image_url = None
|
||||
|
||||
if uploaded_file and uploaded_file.filename and uploaded_file.filename.strip():
|
||||
# User uploaded a file - replace cover
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=os.path.splitext(uploaded_file.filename)[1]
|
||||
) as tmp_file:
|
||||
uploaded_file.save(tmp_file.name)
|
||||
cover_image_url = f"file://{tmp_file.name}"
|
||||
elif form_data.cover_image_url:
|
||||
# User entered URL - replace cover
|
||||
cover_image_url = str(form_data.cover_image_url)
|
||||
elif form_data.delete_cover:
|
||||
# User wants to delete cover
|
||||
cover_image_url = ""
|
||||
|
||||
return cover_image_url
|
||||
|
||||
|
||||
@bp.route("/book/new", methods=["GET", "POST"])
|
||||
def create_book() -> ResponseReturnValue:
|
||||
"""Create a new book."""
|
||||
@@ -238,6 +271,9 @@ def create_book() -> ResponseReturnValue:
|
||||
# Validate form data with Pydantic
|
||||
form_data = BookFormData.model_validate(dict(request.form))
|
||||
|
||||
# Handle cover image from file upload or URL
|
||||
cover_image_url = _handle_cover_image(form_data)
|
||||
|
||||
# Get owner ID if provided
|
||||
owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None
|
||||
|
||||
@@ -258,6 +294,7 @@ def create_book() -> ResponseReturnValue:
|
||||
first_published=form_data.first_published,
|
||||
loaned_to=form_data.loaned_to,
|
||||
loaned_date=form_data.loaned_date,
|
||||
cover_image_url=cover_image_url,
|
||||
)
|
||||
|
||||
flash(f"Book '{form_data.title}' created successfully!", "success")
|
||||
@@ -269,6 +306,7 @@ def create_book() -> ResponseReturnValue:
|
||||
return render_template("book/create.html.j2", form_data=request.form)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating book '{form_data.title}': {e}", exc_info=True)
|
||||
flash(f"Error creating book: {e}", "error")
|
||||
return render_template("book/create.html.j2", form_data=request.form)
|
||||
|
||||
@@ -287,6 +325,11 @@ def update_book(book_id: int) -> ResponseReturnValue:
|
||||
# Validate form data with Pydantic
|
||||
form_data = BookFormData.model_validate(dict(request.form))
|
||||
|
||||
# Handle cover image from file upload or URL
|
||||
cover_image_url = _handle_cover_image(form_data)
|
||||
if cover_image_url is None:
|
||||
cover_image_url = book.cover_image_path # Keep existing if no new input
|
||||
|
||||
# Get owner ID if provided
|
||||
owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None
|
||||
|
||||
@@ -309,6 +352,7 @@ def update_book(book_id: int) -> ResponseReturnValue:
|
||||
first_published=form_data.first_published,
|
||||
loaned_to=form_data.loaned_to,
|
||||
loaned_date=form_data.loaned_date,
|
||||
cover_image_url=cover_image_url,
|
||||
)
|
||||
|
||||
flash("Book updated successfully!", "success")
|
||||
@@ -318,6 +362,7 @@ def update_book(book_id: int) -> ResponseReturnValue:
|
||||
_flash_validation_errors(e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating book '{book_id}': {e}", exc_info=True)
|
||||
flash(f"Error updating book: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||
@@ -338,6 +383,7 @@ def delete_book(book_id: int) -> ResponseReturnValue:
|
||||
library.delete_book(book_id)
|
||||
flash(f"Book '{title}' deleted successfully!", "success")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting book '{book_id}': {e}", exc_info=True)
|
||||
flash(f"Error deleting book: {e}", "error")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
@@ -367,6 +413,7 @@ def import_book() -> ResponseReturnValue:
|
||||
return redirect(url_for("main.book_detail", book_id=book.id))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing book with ISBN '{isbn}': {e}", exc_info=True)
|
||||
flash(f"Import error: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
@@ -432,6 +479,10 @@ def save_search_route() -> ResponseReturnValue:
|
||||
else:
|
||||
flash("Error saving search", "error")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error saving search '{search_name}' for user '{viewing_user.username}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
flash(f"Error saving search: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.index", q=query_params))
|
||||
@@ -449,6 +500,10 @@ def start_reading_route(book_id: int) -> ResponseReturnValue:
|
||||
library.start_reading(book_id=book_id, user_id=viewing_user.id)
|
||||
flash("Started reading!", "success")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error starting reading for book '{book_id}' and user '{viewing_user.username}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
flash(f"Error starting reading: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||
@@ -476,6 +531,10 @@ def finish_reading_route(book_id: int) -> ResponseReturnValue:
|
||||
library.finish_reading(reading_id=current_reading.id)
|
||||
flash("Finished reading!", "success")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error finishing reading for book '{book_id}' and user '{viewing_user.username}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
flash(f"Error finishing reading: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||
@@ -503,6 +562,10 @@ def drop_reading_route(book_id: int) -> ResponseReturnValue:
|
||||
library.drop_reading(reading_id=current_reading.id)
|
||||
flash("Dropped reading", "info")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error dropping reading for book '{book_id}' and user '{viewing_user.username}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
flash(f"Error dropping reading: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||
@@ -520,6 +583,10 @@ def add_to_wishlist_route(book_id: int) -> ResponseReturnValue:
|
||||
library.add_to_wishlist(book_id=book_id, user_id=viewing_user.id)
|
||||
flash("Added to wishlist!", "success")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error adding book '{book_id}' to wishlist for user '{viewing_user.username}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
flash(f"Error adding to wishlist: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||
@@ -540,6 +607,10 @@ def remove_from_wishlist_route(book_id: int) -> ResponseReturnValue:
|
||||
else:
|
||||
flash("Book was not in wishlist", "warning")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error removing book '{book_id}' from wishlist for user '{viewing_user.username}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
flash(f"Error removing from wishlist: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||
@@ -579,6 +650,10 @@ def update_reading_route(book_id: int, reading_id: int) -> ResponseReturnValue:
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback() # Rollback any partial changes on general error
|
||||
logger.error(
|
||||
f"Error updating reading for book '{book_id}' and user '{viewing_user.username}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
flash(f"Error updating reading: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||
@@ -604,6 +679,10 @@ def delete_reading_route(book_id: int, reading_id: int) -> ResponseReturnValue:
|
||||
else:
|
||||
flash("Reading session not found or not yours to delete", "error")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error deleting reading for book '{book_id}' and user '{viewing_user.username}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
flash(f"Error deleting reading: {e}", "error")
|
||||
|
||||
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||
@@ -632,6 +711,10 @@ def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
|
||||
else:
|
||||
flash("Error deleting saved search", "error")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error deleting saved search '{search_name}' for user '{viewing_user.username}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
flash(f"Error deleting saved search: {e}", "error")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
@@ -641,3 +724,10 @@ def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
|
||||
search_name=search_name,
|
||||
search_params=saved_searches[search_name],
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/media/covers/<filename>")
|
||||
def serve_cover(filename: str) -> ResponseReturnValue:
|
||||
"""Serve book cover images from media directory."""
|
||||
media_path = Path(current_app.instance_path).parent / "media" / "covers"
|
||||
return send_from_directory(media_path, filename)
|
||||
|
||||
@@ -60,6 +60,7 @@ class Book(db.Model): # ty:ignore[unsupported-base]
|
||||
notes: Mapped[str] = mapped_column(default="")
|
||||
added_date: Mapped[datetime] = mapped_column(default=datetime.now)
|
||||
bought_date: Mapped[date | None] = mapped_column(default=None)
|
||||
cover_image_path: Mapped[str | None] = mapped_column(String(200), default=None)
|
||||
|
||||
# Location hierarchy
|
||||
location_place: Mapped[str] = mapped_column(String(100), default="")
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form action="/book/new" method="POST">
|
||||
<form action="/book/new" method="POST" enctype="multipart/form-data">
|
||||
{% include 'components/book_form.html.j2' %}
|
||||
|
||||
<div class="row mt-4">
|
||||
|
||||
@@ -57,10 +57,26 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mobile Cover Image -->
|
||||
<div id="cover-image-mobile">
|
||||
{% if book.cover_image_path %}
|
||||
<div class="col-12 d-lg-none mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<img src="{{ url_for('main.serve_cover', filename=book.cover_image_path) }}" alt="{{ book.title }} cover"
|
||||
class="img-fluid" style="max-width: 100%; height: auto; max-height: 250px; object-fit: contain;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Book Details Form -->
|
||||
<div class="col-lg-8">
|
||||
<form id="book-form" action="/book/{{ book.id }}/edit" method="POST" hx-trigger="change,submit"
|
||||
hx-swap="none show:none" hx-target="this" hx-select-oob="#flash-messages-container,#bookshelf-list">
|
||||
<form id="book-form" action="/book/{{ book.id }}/edit" method="POST" enctype="multipart/form-data"
|
||||
hx-trigger="change,submit" hx-swap="none show:none" hx-target="this"
|
||||
hx-select-oob="#flash-messages-container,#bookshelf-list,#cover-image-section,#cover-image-sidebar,#cover-image-mobile">
|
||||
{% include 'components/book_form.html.j2' %}
|
||||
|
||||
<div class="row mt-4">
|
||||
@@ -74,6 +90,21 @@
|
||||
|
||||
<!-- User-Specific Data Sidebar (not shown on mobile) -->
|
||||
<div class="col-lg-4 d-none d-lg-block">
|
||||
<!-- Cover Image -->
|
||||
<div id="cover-image-sidebar">
|
||||
{% if book.cover_image_path %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Cover Image</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<img src="{{ url_for('main.serve_cover', filename=book.cover_image_path) }}" alt="{{ book.title }} cover"
|
||||
class="img-fluid" style="max-width: 100%; height: auto; max-height: 300px; object-fit: contain;">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if session.get('viewing_as_user') %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<a href="/book/{{ book.id }}" class="card book-card h-100 text-decoration-none text-reset">
|
||||
<div class="position-relative">
|
||||
<!-- TODO: Book cover image -->
|
||||
<div class="card-img-top bg-light d-flex align-items-center justify-content-center text-muted">
|
||||
<!-- Book cover image -->
|
||||
{% if book.cover_image_path %}
|
||||
<div class="d-flex justify-content-center align-items-center bg-light" style="height: 200px;">
|
||||
<img src="{{ url_for('main.serve_cover', filename=book.cover_image_path) }}" alt="{{ book.title }} cover"
|
||||
class="img-fluid" style="max-height: 200px; max-width: 100%; object-fit: contain;">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-img-top bg-light d-flex align-items-center justify-content-center text-muted"
|
||||
style="height: 200px;">
|
||||
📖
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Status Badges -->
|
||||
<div class="book-status-badges">
|
||||
|
||||
@@ -55,6 +55,42 @@
|
||||
rows="3">{{ book.description if book else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Cover Image -->
|
||||
<div class="row mb-3" id="cover-image-section">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Cover Image</label>
|
||||
|
||||
{% if book and book.cover_image_path %}
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="delete_cover" name="delete_cover" value="1">
|
||||
<label class="form-check-label text-danger" for="delete_cover">
|
||||
🗑️ Delete current cover image
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="cover_image_url" class="form-label">Replace with URL</label>
|
||||
<input type="url" class="form-control" id="cover_image_url" name="cover_image_url" value=""
|
||||
placeholder="https://example.com/cover.jpg or file:///path/to/image.jpg">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="cover_image_file" class="form-label">Replace with Upload</label>
|
||||
<input type="file" class="form-control" id="cover_image_file" name="cover_image_file" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-text mt-2">
|
||||
{% if book and book.cover_image_path %}
|
||||
Leave both fields empty to keep current cover, or check delete to remove it.
|
||||
{% else %}
|
||||
Enter a URL or upload a file to add a cover image.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Information -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<h5 class="modal-title">Import Book from ISBN</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form action="/import" method="POST">
|
||||
<form action="/import" method="POST" hx-boost="false">
|
||||
<div class="modal-body">
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-3">
|
||||
@@ -50,7 +50,6 @@
|
||||
<div id="scan-status" class="text-muted small mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if session.get('viewing_as_user') %}
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
@@ -70,7 +69,7 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
46
uv.lock
generated
46
uv.lock
generated
@@ -220,11 +220,13 @@ dependencies = [
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "jinja2-fragments" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-extra-types" },
|
||||
{ name = "pyparsing" },
|
||||
{ name = "requests" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "waitress" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -246,11 +248,13 @@ requires-dist = [
|
||||
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" },
|
||||
{ name = "gunicorn", specifier = ">=25.1.0" },
|
||||
{ name = "jinja2-fragments", specifier = ">=1.11.0" },
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||
{ name = "pydantic-extra-types", specifier = ">=2.11.1" },
|
||||
{ name = "pyparsing", specifier = ">=3.3.2" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.48" },
|
||||
{ name = "waitress", specifier = ">=3.0.2" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -394,6 +398,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.4"
|
||||
@@ -720,6 +757,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "waitress"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.6"
|
||||
|
||||
Reference in New Issue
Block a user