Compare commits

...

3 Commits

Author SHA1 Message Date
39fb2edd71 Add waitress + caddy deployment 2026-03-30 22:46:04 +02:00
f9d6662467 Fix import modal 2026-03-30 22:45:16 +02:00
93e3397553 Add book covers 2026-03-30 19:37:51 +02:00
22 changed files with 1064 additions and 54 deletions

View File

@@ -6,3 +6,5 @@
*.egg-info *.egg-info
**/__pycache__ **/__pycache__
**/__mypy_cache__ **/__mypy_cache__
**/media
*.sqlite

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
instance/ instance/
*.sqlite *.sqlite
media/

102
Caddyfile Normal file
View 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"
# }
# }

View File

@@ -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 WORKDIR /app
COPY requirements.txt . RUN --mount=type=cache,target=/root/.cache/uv \
RUN pip install -r requirements.txt --mount=type=bind,source=uv.lock,target=uv.lock \
COPY . . --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
RUN pip install -e . 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
View 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
View 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
View 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
View 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

View File

@@ -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 ###

View File

@@ -13,11 +13,13 @@ dependencies = [
"flask-sqlalchemy>=3.1.1", "flask-sqlalchemy>=3.1.1",
"gunicorn>=25.1.0", "gunicorn>=25.1.0",
"jinja2-fragments>=1.11.0", "jinja2-fragments>=1.11.0",
"pillow>=12.1.1",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"pydantic-extra-types>=2.11.1", "pydantic-extra-types>=2.11.1",
"pyparsing>=3.3.2", "pyparsing>=3.3.2",
"requests>=2.32.5", "requests>=2.32.5",
"sqlalchemy>=2.0.48", "sqlalchemy>=2.0.48",
"waitress>=3.0.2",
] ]
[project.scripts] [project.scripts]

View File

@@ -1,3 +1,4 @@
import logging
import os import os
from pathlib import Path 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'}", SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'hxbooks.sqlite'}",
) )
# Setup logging
_setup_logging(app, test_config is not None)
if test_config is None: if test_config is None:
# load the instance config, if it exists, when not testing # load the instance config, if it exists, when not testing
app.config.from_pyfile("config.py", silent=True) 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 # ensure the instance folder exists
try: try:
os.makedirs(app.instance_path) os.makedirs(app.instance_path)
# Also create media directories
os.makedirs(os.path.join(PROJECT_ROOT, "media", "covers"), exist_ok=True)
except OSError: except OSError:
pass pass
@@ -43,3 +49,37 @@ def create_app(test_config: dict | None = None) -> Flask:
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
return app 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)

View File

@@ -6,7 +6,9 @@ while keeping business logic separate from web interface concerns.
""" """
import json import json
import logging
import sys import sys
from datetime import datetime
import click import click
from flask import Flask from flask import Flask
@@ -16,6 +18,8 @@ from .app import create_app
from .db import db from .db import db
from .models import Author, Book, Genre, Reading, User, Wishlist from .models import Author, Book, Genre, Reading, User, Wishlist
logger = logging.getLogger(__name__)
def get_app() -> Flask: def get_app() -> Flask:
"""Create and configure Flask app for CLI operations.""" """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("--shelf", type=int, help="Shelf number")
@click.option("--description", help="Book description") @click.option("--description", help="Book description")
@click.option("--notes", help="Personal notes") @click.option("--notes", help="Personal notes")
@click.option("--cover-url", help="Cover image URL (http/https/file://)")
def add_book( def add_book(
title: str, title: str,
owner: str, owner: str,
@@ -97,6 +102,7 @@ def add_book(
shelf: int | None = None, shelf: int | None = None,
description: str | None = None, description: str | None = None,
notes: str | None = None, notes: str | None = None,
cover_url: str | None = None,
) -> None: ) -> None:
"""Add a new book to the library.""" """Add a new book to the library."""
app = get_app() app = get_app()
@@ -120,9 +126,11 @@ def add_book(
location_shelf=shelf, location_shelf=shelf,
description=description, description=description,
notes=notes, notes=notes,
cover_image_url=cover_url,
) )
click.echo(f"Added book: {book.title} (ID: {book.id})") click.echo(f"Added book: {book.title} (ID: {book.id})")
except Exception as e: except Exception as e:
logger.error(f"Error adding book '{title}': {e}", exc_info=True)
click.echo(f"Error adding book: {e}", err=True) click.echo(f"Error adding book: {e}", err=True)
sys.exit(1) sys.exit(1)
@@ -188,6 +196,7 @@ def get_book(book_id: int) -> None:
click.echo(json.dumps(book_info, indent=2)) click.echo(json.dumps(book_info, indent=2))
except Exception as e: 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) click.echo(f"Error getting book: {e}", err=True)
sys.exit(1) sys.exit(1)
@@ -206,10 +215,114 @@ def delete_book(book_id: int) -> None:
click.echo(f"Book with ID {book_id} not found.") click.echo(f"Book with ID {book_id} not found.")
sys.exit(1) sys.exit(1)
except Exception as e: 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) click.echo(f"Error deleting book: {e}", err=True)
sys.exit(1) 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") @book.command("list")
@click.option("--owner", help="Filter by owner username") @click.option("--owner", help="Filter by owner username")
@click.option("--place", help="Filter by location place") @click.option("--place", help="Filter by location place")
@@ -276,6 +389,7 @@ def list_books(
) )
except Exception as e: except Exception as e:
logger.error(f"Error listing books: {e}", exc_info=True)
click.echo(f"Error listing books: {e}", err=True) click.echo(f"Error listing books: {e}", err=True)
sys.exit(1) sys.exit(1)
@@ -361,12 +475,14 @@ def search_books(
@click.option("--place", help="Location place") @click.option("--place", help="Location place")
@click.option("--bookshelf", help="Bookshelf name") @click.option("--bookshelf", help="Bookshelf name")
@click.option("--shelf", type=int, help="Shelf number") @click.option("--shelf", type=int, help="Shelf number")
@click.option("--no-cover", is_flag=True, help="Skip downloading cover image")
def import_book( def import_book(
isbn: str, isbn: str,
owner: str | None = None, owner: str | None = None,
place: str | None = None, place: str | None = None,
bookshelf: str | None = None, bookshelf: str | None = None,
shelf: int | None = None, shelf: int | None = None,
no_cover: bool = False,
) -> None: ) -> None:
"""Import book data from ISBN using Google Books API.""" """Import book data from ISBN using Google Books API."""
app = get_app() app = get_app()
@@ -383,6 +499,7 @@ def import_book(
location_place=place, location_place=place,
location_bookshelf=bookshelf, location_bookshelf=bookshelf,
location_shelf=shelf, location_shelf=shelf,
fetch_cover=not no_cover,
) )
click.echo( click.echo(
f"Imported book: {book.title} by {', '.join(a.name for a in book.authors)} (ID: {book.id})" 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() @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.""" """Start the web server."""
if debug:
import livereload # noqa: PLC0415 import livereload # noqa: PLC0415
app = get_app() app = get_app()
app.debug = True app.debug = debug
# app.run() # app.run()
server = livereload.Server(app.wsgi_app) server = livereload.Server(app.wsgi_app)
server.watch("hxbooks/templates/**") 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__": if __name__ == "__main__":

View File

@@ -1,3 +1,4 @@
import logging
from os import environ from os import environ
from datetime import date, datetime from datetime import date, datetime
from typing import Any from typing import Any
@@ -5,6 +6,9 @@ from typing import Any
import requests import requests
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
# Get logger for this module
logger = logging.getLogger(__name__)
class GoogleBook(BaseModel): class GoogleBook(BaseModel):
title: str title: str
@@ -40,12 +44,29 @@ class GoogleBook(BaseModel):
def fetch_google_book_data(isbn: str) -> GoogleBook: 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") api_key = environ.get("GOOGLE_BOOKS_API_KEY")
logger.info(f"Fetching Google Books data for ISBN: {isbn}")
req = requests.get( 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() req.raise_for_status()
data = req.json() data = req.json()
if data["totalItems"] == 0: if data["totalItems"] == 0:
logger.warning(f"No Google Books data found for ISBN: {isbn}")
raise ValueError(f"Book with ISBN {isbn} not found") 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"]) 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

View File

@@ -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. Separated from web interface concerns to enable both CLI and web access.
""" """
import logging
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence from collections.abc import Sequence
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path
from typing import assert_never 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 import ColumnElement, Select, and_, func, or_, select
from sqlalchemy.orm import InstrumentedAttribute, joinedload 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 .models import Author, Book, Genre, Reading, User, Wishlist
from .search import ComparisonOperator, Field, FieldFilter from .search import ComparisonOperator, Field, FieldFilter
# Get logger for this module
logger = logging.getLogger(__name__)
def create_book( def create_book(
title: str, title: str,
@@ -38,6 +46,7 @@ def create_book(
bought_date: date | None = None, bought_date: date | None = None,
loaned_to: str | None = None, loaned_to: str | None = None,
loaned_date: date | None = None, loaned_date: date | None = None,
cover_image_url: str | None = None,
) -> Book: ) -> Book:
"""Create a new book with the given details.""" """Create a new book with the given details."""
book = Book( book = Book(
@@ -71,6 +80,11 @@ def create_book(
book.genres.append(genre) book.genres.append(genre)
db.session.commit() db.session.commit()
# Handle cover image if provided
if cover_image_url:
download_book_cover(book, cover_image_url)
return book return book
@@ -111,6 +125,7 @@ def update_book(
bought_date: date | None = None, bought_date: date | None = None,
loaned_to: str | None = None, loaned_to: str | None = None,
loaned_date: date | None = None, loaned_date: date | None = None,
cover_image_url: str | None = None,
) -> Book | None: ) -> Book | None:
"""Update a book with new details.""" """Update a book with new details."""
book = get_book(book_id) book = get_book(book_id)
@@ -185,6 +200,14 @@ def update_book(
if owner_id is not None or set_all_fields: if owner_id is not None or set_all_fields:
book.owner_id = owner_id 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() db.session.commit()
return book return book
@@ -557,8 +580,9 @@ def import_book_from_isbn(
location_place: str | None = None, location_place: str | None = None,
location_bookshelf: str | None = None, location_bookshelf: str | None = None,
location_shelf: int | None = None, location_shelf: int | None = None,
fetch_cover: bool = True,
) -> Book: ) -> 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) google_book_data = fetch_google_book_data(isbn)
if not google_book_data: if not google_book_data:
raise ValueError(f"No book data found for ISBN: {isbn}") 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): elif isinstance(google_book_data.publishedDate, int):
first_published = google_book_data.publishedDate first_published = google_book_data.publishedDate
return create_book( book = create_book(
title=google_book_data.title, title=google_book_data.title,
owner_id=owner_id, owner_id=owner_id,
authors=authors, authors=authors,
@@ -593,6 +617,20 @@ def import_book_from_isbn(
location_shelf=location_shelf, 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( def get_books_by_location(
place: str, bookshelf: str | None = None, shelf: int | None = None 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: for location_place, location_bookshelf in result:
ret[location_place].append(location_bookshelf) ret[location_place].append(location_bookshelf)
return ret 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

View File

@@ -4,23 +4,29 @@ Main application routes for HXBooks frontend.
Provides clean URL structure and integrates with library.py business logic. Provides clean URL structure and integrates with library.py business logic.
""" """
import traceback import logging
import os
import tempfile
from datetime import date from datetime import date
from pathlib import Path
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from flask import ( from flask import (
Blueprint, Blueprint,
Response, Response,
current_app,
flash, flash,
g, g,
redirect, redirect,
render_template, render_template,
request, request,
send_from_directory,
session, session,
url_for, url_for,
) )
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
from pydantic import ( from pydantic import (
AnyHttpUrl,
BaseModel, BaseModel,
BeforeValidator, BeforeValidator,
Field, Field,
@@ -38,6 +44,9 @@ from .db import db
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
# Get logger for this module
logger = logging.getLogger(__name__)
# Pydantic validation models # Pydantic validation models
StrOrNone = Annotated[str | None, BeforeValidator(lambda v: v.strip() or None)] 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)] DateOrNone = Annotated[date | None, BeforeValidator(lambda v: v.strip() or None)]
IntOrNone = Annotated[int | 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): class BookFormData(BaseModel):
@@ -73,6 +83,8 @@ class BookFormData(BaseModel):
location_shelf: IntOrNone = Field(default=None, ge=1) location_shelf: IntOrNone = Field(default=None, ge=1)
loaned_to: StripStr = Field(default="") loaned_to: StripStr = Field(default="")
loaned_date: DateOrNone = None loaned_date: DateOrNone = None
cover_image_url: UrlOrNone = None
delete_cover: bool = Field(default=False)
class ReadingFormData(BaseModel): class ReadingFormData(BaseModel):
@@ -88,6 +100,7 @@ def _flash_validation_errors(e: ValidationError) -> None:
error = e.errors()[0] error = e.errors()[0]
loc = " -> ".join(str(v) for v in error.get("loc", [])) loc = " -> ".join(str(v) for v in error.get("loc", []))
msg = error.get("msg", "Invalid input") 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") 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 query, limit=RESULTS_PER_PAGE, offset=offset, username=viewing_user
) )
except Exception as e: except Exception as e:
logger.error(f"Search error for query '{query}': {e}", exc_info=True)
flash(f"Search error: {e}", "error") flash(f"Search error: {e}", "error")
# print traceback for debugging
traceback.print_exc()
books, total_count = [], 0 books, total_count = [], 0
# Calculate pagination info # Calculate pagination info
@@ -230,6 +242,27 @@ def _get_or_create_user(username: str) -> int:
return owner.id 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"]) @bp.route("/book/new", methods=["GET", "POST"])
def create_book() -> ResponseReturnValue: def create_book() -> ResponseReturnValue:
"""Create a new book.""" """Create a new book."""
@@ -238,6 +271,9 @@ def create_book() -> ResponseReturnValue:
# Validate form data with Pydantic # Validate form data with Pydantic
form_data = BookFormData.model_validate(dict(request.form)) 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 # Get owner ID if provided
owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None 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, first_published=form_data.first_published,
loaned_to=form_data.loaned_to, loaned_to=form_data.loaned_to,
loaned_date=form_data.loaned_date, loaned_date=form_data.loaned_date,
cover_image_url=cover_image_url,
) )
flash(f"Book '{form_data.title}' created successfully!", "success") 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) return render_template("book/create.html.j2", form_data=request.form)
except Exception as e: except Exception as e:
logger.error(f"Error creating book '{form_data.title}': {e}", exc_info=True)
flash(f"Error creating book: {e}", "error") flash(f"Error creating book: {e}", "error")
return render_template("book/create.html.j2", form_data=request.form) 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 # Validate form data with Pydantic
form_data = BookFormData.model_validate(dict(request.form)) 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 # Get owner ID if provided
owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None 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, first_published=form_data.first_published,
loaned_to=form_data.loaned_to, loaned_to=form_data.loaned_to,
loaned_date=form_data.loaned_date, loaned_date=form_data.loaned_date,
cover_image_url=cover_image_url,
) )
flash("Book updated successfully!", "success") flash("Book updated successfully!", "success")
@@ -318,6 +362,7 @@ def update_book(book_id: int) -> ResponseReturnValue:
_flash_validation_errors(e) _flash_validation_errors(e)
except Exception as e: except Exception as e:
logger.error(f"Error updating book '{book_id}': {e}", exc_info=True)
flash(f"Error updating book: {e}", "error") flash(f"Error updating book: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id)) 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) library.delete_book(book_id)
flash(f"Book '{title}' deleted successfully!", "success") flash(f"Book '{title}' deleted successfully!", "success")
except Exception as e: except Exception as e:
logger.error(f"Error deleting book '{book_id}': {e}", exc_info=True)
flash(f"Error deleting book: {e}", "error") flash(f"Error deleting book: {e}", "error")
return redirect(url_for("main.index")) 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)) return redirect(url_for("main.book_detail", book_id=book.id))
except Exception as e: except Exception as e:
logger.error(f"Error importing book with ISBN '{isbn}': {e}", exc_info=True)
flash(f"Import error: {e}", "error") flash(f"Import error: {e}", "error")
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
@@ -432,6 +479,10 @@ def save_search_route() -> ResponseReturnValue:
else: else:
flash("Error saving search", "error") flash("Error saving search", "error")
except Exception as e: 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") flash(f"Error saving search: {e}", "error")
return redirect(url_for("main.index", q=query_params)) 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) library.start_reading(book_id=book_id, user_id=viewing_user.id)
flash("Started reading!", "success") flash("Started reading!", "success")
except Exception as e: 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") flash(f"Error starting reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id)) 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) library.finish_reading(reading_id=current_reading.id)
flash("Finished reading!", "success") flash("Finished reading!", "success")
except Exception as e: 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") flash(f"Error finishing reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id)) 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) library.drop_reading(reading_id=current_reading.id)
flash("Dropped reading", "info") flash("Dropped reading", "info")
except Exception as e: 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") flash(f"Error dropping reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id)) 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) library.add_to_wishlist(book_id=book_id, user_id=viewing_user.id)
flash("Added to wishlist!", "success") flash("Added to wishlist!", "success")
except Exception as e: 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") flash(f"Error adding to wishlist: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id)) 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: else:
flash("Book was not in wishlist", "warning") flash("Book was not in wishlist", "warning")
except Exception as e: 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") flash(f"Error removing from wishlist: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id)) 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: except Exception as e:
db.session.rollback() # Rollback any partial changes on general error 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") flash(f"Error updating reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id)) 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: else:
flash("Reading session not found or not yours to delete", "error") flash("Reading session not found or not yours to delete", "error")
except Exception as e: 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") flash(f"Error deleting reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id)) 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: else:
flash("Error deleting saved search", "error") flash("Error deleting saved search", "error")
except Exception as e: 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") flash(f"Error deleting saved search: {e}", "error")
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
@@ -641,3 +724,10 @@ def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
search_name=search_name, search_name=search_name,
search_params=saved_searches[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)

View File

@@ -60,6 +60,7 @@ class Book(db.Model): # ty:ignore[unsupported-base]
notes: Mapped[str] = mapped_column(default="") notes: Mapped[str] = mapped_column(default="")
added_date: Mapped[datetime] = mapped_column(default=datetime.now) added_date: Mapped[datetime] = mapped_column(default=datetime.now)
bought_date: Mapped[date | None] = mapped_column(default=None) bought_date: Mapped[date | None] = mapped_column(default=None)
cover_image_path: Mapped[str | None] = mapped_column(String(200), default=None)
# Location hierarchy # Location hierarchy
location_place: Mapped[str] = mapped_column(String(100), default="") location_place: Mapped[str] = mapped_column(String(100), default="")

View File

@@ -16,7 +16,7 @@
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card"> <div class="card">
<div class="card-body"> <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' %} {% include 'components/book_form.html.j2' %}
<div class="row mt-4"> <div class="row mt-4">

View File

@@ -57,10 +57,26 @@
</div> </div>
</div> </div>
{% endif %} {% 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 --> <!-- Book Details Form -->
<div class="col-lg-8"> <div class="col-lg-8">
<form id="book-form" action="/book/{{ book.id }}/edit" method="POST" hx-trigger="change,submit" <form id="book-form" action="/book/{{ book.id }}/edit" method="POST" enctype="multipart/form-data"
hx-swap="none show:none" hx-target="this" hx-select-oob="#flash-messages-container,#bookshelf-list"> 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' %} {% include 'components/book_form.html.j2' %}
<div class="row mt-4"> <div class="row mt-4">
@@ -74,6 +90,21 @@
<!-- User-Specific Data Sidebar (not shown on mobile) --> <!-- User-Specific Data Sidebar (not shown on mobile) -->
<div class="col-lg-4 d-none d-lg-block"> <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') %} {% if session.get('viewing_as_user') %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">

View File

@@ -1,9 +1,17 @@
<a href="/book/{{ book.id }}" class="card book-card h-100 text-decoration-none text-reset"> <a href="/book/{{ book.id }}" class="card book-card h-100 text-decoration-none text-reset">
<div class="position-relative"> <div class="position-relative">
<!-- TODO: Book cover image --> <!-- Book cover image -->
<div class="card-img-top bg-light d-flex align-items-center justify-content-center text-muted"> {% 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> </div>
{% endif %}
<!-- Status Badges --> <!-- Status Badges -->
<div class="book-status-badges"> <div class="book-status-badges">

View File

@@ -55,6 +55,42 @@
rows="3">{{ book.description if book else '' }}</textarea> rows="3">{{ book.description if book else '' }}</textarea>
</div> </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 --> <!-- Location Information -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-4"> <div class="col-md-4">

View File

@@ -6,7 +6,7 @@
<h5 class="modal-title">Import Book from ISBN</h5> <h5 class="modal-title">Import Book from ISBN</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<form action="/import" method="POST"> <form action="/import" method="POST" hx-boost="false">
<div class="modal-body"> <div class="modal-body">
<!-- Mode Toggle --> <!-- Mode Toggle -->
<div class="mb-3"> <div class="mb-3">
@@ -50,7 +50,6 @@
<div id="scan-status" class="text-muted small mt-2"></div> <div id="scan-status" class="text-muted small mt-2"></div>
</div> </div>
</div> </div>
</div>
{% if session.get('viewing_as_user') %} {% if session.get('viewing_as_user') %}
<div class="mb-3"> <div class="mb-3">
<div class="form-check"> <div class="form-check">

46
uv.lock generated
View File

@@ -220,11 +220,13 @@ dependencies = [
{ name = "flask-sqlalchemy" }, { name = "flask-sqlalchemy" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "jinja2-fragments" }, { name = "jinja2-fragments" },
{ name = "pillow" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-extra-types" }, { name = "pydantic-extra-types" },
{ name = "pyparsing" }, { name = "pyparsing" },
{ name = "requests" }, { name = "requests" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "waitress" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -246,11 +248,13 @@ requires-dist = [
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" }, { name = "flask-sqlalchemy", specifier = ">=3.1.1" },
{ name = "gunicorn", specifier = ">=25.1.0" }, { name = "gunicorn", specifier = ">=25.1.0" },
{ name = "jinja2-fragments", specifier = ">=1.11.0" }, { name = "jinja2-fragments", specifier = ">=1.11.0" },
{ name = "pillow", specifier = ">=12.1.1" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-extra-types", specifier = ">=2.11.1" }, { name = "pydantic-extra-types", specifier = ">=2.11.1" },
{ name = "pyparsing", specifier = ">=3.3.2" }, { name = "pyparsing", specifier = ">=3.3.2" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
{ name = "sqlalchemy", specifier = ">=2.0.48" }, { name = "sqlalchemy", specifier = ">=2.0.48" },
{ name = "waitress", specifier = ">=3.0.2" },
] ]
[package.metadata.requires-dev] [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" }, { 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]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.9.4" 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" }, { 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]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.6" version = "3.1.6"