Compare commits

...

12 Commits

Author SHA1 Message Date
1262aa3c37 Fix #4: readings switch to dropped after rating
All checks were successful
CI / quality-checks (push) Successful in 52s
The hidden input for "dropped" in the completed reading form was always
setting the value to "1", which caused the reading to be marked as
dropped when the rating was updated (hidden inputs do not honor the
"checked" attribute).

Additionally, the book rating section is now swapped OOB when any
reading is updated, to ensure the changes are reflected in the hidden
inputs. This was causing the latest changes to a reading to be lost when
the rating was updated immediately after.
2026-04-28 23:08:58 +02:00
9429d203bd Fix #2: Focus jumps to genre after status change
All checks were successful
CI / quality-checks (push) Successful in 43s
No idea why this is happening, but I solved it by making htmx swap only
the user status containers instead of the whole page. This keeps the
toggle status intact and prevents the jump.
2026-04-01 11:37:27 +02:00
206b2a9d5b Fix #3: Old cover shown after updating book cover
All checks were successful
CI / quality-checks (push) Successful in 1m10s
Caddy is set up to serve static files with aggressive caching, which is
great for performance but can cause issues when updating book covers. To
ensure users see the updated cover immediately, we need implement a
cache-busting strategy. This involves generating a unique filename for
each cover image based on its content, using a hash of the image data.
By doing this, when a cover is updated, it will have a new filename,
prompting browsers to fetch the new image instead of using the cached
version.
2026-04-01 11:03:36 +02:00
dc73de6799 Fix #1: 500 error when creating first book
All checks were successful
CI / quality-checks (push) Successful in 43s
The 500 error occurred because the create template was missing the
genres, authors, and locations data needed to render the form.
2026-03-31 20:00:31 +02:00
03a5b3803e Add CI and deployment workflows for Gitea
All checks were successful
CI / quality-checks (push) Successful in 42s
2026-03-31 19:38:19 +02:00
9f73077207 Fix deployment issues 2026-03-31 17:26:00 +02:00
4d64db45c8 Fix case sensitivity in tests 2026-03-31 16:54:19 +02:00
c97f4c7d38 Make ISBN field nullable and add unique constraint 2026-03-31 16:42:34 +02:00
da0924eb41 Handle genres and authors casing 2026-03-31 13:31:50 +02:00
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
30 changed files with 1328 additions and 101 deletions

View File

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

35
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: ["*"]
pull_request:
branches: ["*"]
jobs:
quality-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.14"
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "0.11.1"
enable-cache: true
- name: Run pre-commit hooks
run: uv run pre-commit run --all-files
- name: Run type checking with ty
run: uv run ty check
- name: Run tests with pytest
run: uv run pytest

View File

@@ -0,0 +1,55 @@
name: Deploy
on:
workflow_run:
workflows: ["CI"]
types:
- completed
branches: ["main"]
jobs:
deploy:
runs-on: ubuntu-latest
# Only deploy if CI workflow succeeded
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Stop existing containers
run: |
# Stop and remove existing containers if they exist
docker compose down --remove-orphans || true
- name: Build and deploy with Docker Compose
run: |
# Build images
docker compose build
# Deploy the stack in detached mode
export GOOGLE_BOOKS_API_KEY="${{ secrets.GOOGLE_BOOKS_API_KEY }}"
docker compose up -d
# Wait for health checks to pass
echo "Waiting for application to be healthy..."
timeout 300 sh -c 'until docker compose ps | grep -q "healthy"; do sleep 5; done'
- name: Verify deployment
run: |
# Check if all services are running
docker compose ps
# Test if the application responds
sleep 10
wget --spider http://172.17.0.1:5123 || exit 1
echo "Deployment successful!"
- name: Cleanup old images
run: |
# Remove dangling images to save space
docker image prune -f

3
.gitignore vendored
View File

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

View File

@@ -1,7 +1,7 @@
repos: repos:
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
# uv version. # uv version.
rev: 0.10.10 rev: 0.11.1
hooks: hooks:
- id: uv-lock - id: uv-lock

99
Caddyfile Normal file
View File

@@ -0,0 +1,99 @@
# Caddyfile for HXBooks
# Replace 'localhost' with your domain for production with automatic HTTPS
:80 {
# 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}
}
# 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"]

70
docker-compose.yml Normal file
View File

@@ -0,0 +1,70 @@
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
# Mount caddy_file for Caddy configuration
- caddy_file:/app/caddy
expose:
- "5000"
environment:
- FLASK_ENV=production
- GOOGLE_BOOKS_API_KEY=${GOOGLE_BOOKS_API_KEY}
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:
- "5123:80"
volumes:
# Caddyfile configuration
- caddy_file:/etc/caddy
# 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
caddy_file:
driver: local

34
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,34 @@
#!/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
# Copy Caddyfile to shared volume
cp /app/Caddyfile /app/caddy/Caddyfile
# 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

@@ -0,0 +1,64 @@
"""Add unique constraint to ISBN field
Revision ID: 5584e4fd820e
Revises: 0c155d83c55b
Create Date: 2026-03-31 13:41:16.356631
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5584e4fd820e'
down_revision = '0c155d83c55b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# First, convert empty string ISBNs to a temporary placeholder
# This is needed because we can't set NULL while column is NOT NULL
op.execute("UPDATE book SET isbn = '__EMPTY_ISBN__' WHERE isbn = ''")
# Remove duplicate ISBNs, keeping only the book with the lowest ID
op.execute("""
DELETE FROM book
WHERE isbn != '__EMPTY_ISBN__'
AND isbn != ''
AND id NOT IN (
SELECT MIN(id)
FROM book
WHERE isbn != '__EMPTY_ISBN__'
AND isbn != ''
GROUP BY isbn
)
""")
with op.batch_alter_table('book', schema=None) as batch_op:
# Make the column nullable
batch_op.alter_column('isbn',
existing_type=sa.VARCHAR(length=20),
nullable=True)
# Now convert the placeholder values to NULL
op.execute("UPDATE book SET isbn = NULL WHERE isbn = '__EMPTY_ISBN__'")
with op.batch_alter_table('book', schema=None) as batch_op:
# Add the unique constraint
batch_op.create_unique_constraint('uq_book_isbn', ['isbn'])
# ### 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_constraint('uq_book_isbn', type_='unique')
batch_op.alter_column('isbn',
existing_type=sa.VARCHAR(length=20),
nullable=False)
# ### 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
@@ -18,9 +19,12 @@ def create_app(test_config: dict | None = None) -> Flask:
app.config.from_mapping( app.config.from_mapping(
SECRET_KEY="dev", SECRET_KEY="dev",
# Put database in project root # Put database in project root
SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'hxbooks.sqlite'}", SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'instance/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
@@ -14,8 +16,11 @@ from flask import Flask
from . import library from . import library
from .app import create_app from .app import create_app
from .db import db from .db import db
from .library import DuplicateISBNError
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 +89,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 +103,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 +127,14 @@ 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 DuplicateISBNError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
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 +200,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 +219,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")
@@ -267,7 +384,7 @@ def list_books(
click.echo("-" * 75) click.echo("-" * 75)
for book in books: for book in books:
authors_str = ", ".join(a.name for a in book.authors)[:22] authors_str = ", ".join(a.name.title() for a in book.authors)[:22]
if len(authors_str) == 22: if len(authors_str) == 22:
authors_str += "..." authors_str += "..."
owner_str = book.owner.username if book.owner else "" owner_str = book.owner.username if book.owner else ""
@@ -276,6 +393,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)
@@ -345,7 +463,7 @@ def search_books(
click.echo("-" * 72) click.echo("-" * 72)
for book in books: for book in books:
authors_str = ", ".join(a.name for a in book.authors)[:27] authors_str = ", ".join(a.name.title() for a in book.authors)[:27]
if len(authors_str) == 27: if len(authors_str) == 27:
authors_str += "..." authors_str += "..."
click.echo(f"{book.id:<4} {book.title[:32]:<35} {authors_str:<30}") click.echo(f"{book.id:<4} {book.title[:32]:<35} {authors_str:<30}")
@@ -361,12 +479,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,10 +503,14 @@ 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.title() for a in book.authors)} (ID: {book.id})"
) )
except DuplicateISBNError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except Exception as e: except Exception as e:
click.echo(f"Error importing book: {e}", err=True) click.echo(f"Error importing book: {e}", err=True)
sys.exit(1) sys.exit(1)
@@ -716,16 +840,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."""
import livereload # noqa: PLC0415 if debug:
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

@@ -6,7 +6,7 @@ from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): ... class Base(DeclarativeBase): ...
db = SQLAlchemy(model_class=Base) db = SQLAlchemy(model_class=Base, session_options={"autoflush": False})
def init_app(app: Flask) -> None: def init_app(app: Flask) -> None:

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:
api_key = environ.get("GOOGLE_BOOKS_API_KEY") """Fetch book data from Google Books API by ISBN."""
req = requests.get( try:
"https://www.googleapis.com/books/v1/volumes", params={"q": f"isbn:{isbn}", "key": api_key} api_key = environ.get("GOOGLE_BOOKS_API_KEY")
) logger.info(f"Fetching Google Books data for ISBN: {isbn}")
req.raise_for_status()
data = req.json() req = requests.get(
if data["totalItems"] == 0: "https://www.googleapis.com/books/v1/volumes",
raise ValueError(f"Book with ISBN {isbn} not found") params={"q": f"isbn:{isbn}", "key": api_key},
return GoogleBook.model_validate(data["items"][0]["volumeInfo"]) 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

View File

@@ -5,12 +5,19 @@ 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 hashlib
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.exc import IntegrityError
from sqlalchemy.orm import InstrumentedAttribute, joinedload from sqlalchemy.orm import InstrumentedAttribute, joinedload
from hxbooks.search import IsOperatorValue, QueryParser, SortDirection, ValueT from hxbooks.search import IsOperatorValue, QueryParser, SortDirection, ValueT
@@ -20,6 +27,23 @@ 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__)
class DuplicateISBNError(ValueError):
"""Raised when attempting to create or update a book with a duplicate ISBN."""
def __init__(
self, isbn: str, existing_book_id: int, existing_book_title: str
) -> None:
self.isbn = isbn
self.existing_book_id = existing_book_id
self.existing_book_title = existing_book_title
super().__init__(
f"ISBN '{isbn}' is already used by book '{existing_book_title}' (ID: {existing_book_id})"
)
def create_book( def create_book(
title: str, title: str,
@@ -38,12 +62,13 @@ 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(
title=title, title=title,
owner_id=owner_id, owner_id=owner_id,
isbn=isbn or "", isbn=isbn,
publisher=publisher or "", publisher=publisher or "",
edition=edition or "", edition=edition or "",
description=description or "", description=description or "",
@@ -70,7 +95,25 @@ def create_book(
genre = _get_or_create_genre(genre_name) genre = _get_or_create_genre(genre_name)
book.genres.append(genre) book.genres.append(genre)
db.session.commit() try:
db.session.commit()
except IntegrityError as e:
db.session.rollback()
if "book.isbn" in str(e.orig).lower():
# Find the existing book with this ISBN
existing_book = db.session.execute(
select(Book).filter(Book.isbn == isbn)
).scalar_one_or_none()
if existing_book:
raise DuplicateISBNError(
isbn or "", existing_book.id, existing_book.title
) from e
raise
# Handle cover image if provided
if cover_image_url:
download_book_cover(book, cover_image_url)
return book return book
@@ -111,6 +154,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)
@@ -122,7 +166,6 @@ def update_book(
assert title is not None, "Title is required when set_all_fields is True" assert title is not None, "Title is required when set_all_fields is True"
book.title = title book.title = title
if isbn is not None or set_all_fields: if isbn is not None or set_all_fields:
assert isbn is not None, "ISBN is required when set_all_fields is True"
book.isbn = isbn book.isbn = isbn
if publisher is not None or set_all_fields: if publisher is not None or set_all_fields:
assert publisher is not None, ( assert publisher is not None, (
@@ -185,7 +228,29 @@ 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
db.session.commit() 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)
try:
db.session.commit()
except IntegrityError as e:
db.session.rollback()
if "book.isbn" in str(e.orig).lower():
# Find the existing book with this ISBN (excluding current book)
existing_book = db.session.execute(
select(Book).filter(Book.isbn == isbn, Book.id != book.id)
).scalar_one_or_none()
if existing_book:
raise DuplicateISBNError(
isbn or "", existing_book.id, existing_book.title
) from e
raise
return book return book
@@ -557,8 +622,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 +645,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 +659,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
@@ -607,13 +687,14 @@ def get_books_by_location(
def _get_or_create_author(name: str) -> Author: def _get_or_create_author(name: str) -> Author:
"""Get existing author or create a new one.""" """Get existing author or create a new one. Always store as lowercase."""
normalized = name.strip().lower()
author = db.session.execute( author = db.session.execute(
select(Author).filter(Author.name == name) select(Author).filter(Author.name == normalized)
).scalar_one_or_none() ).scalar_one_or_none()
if author is None: if author is None:
author = Author(name=name) author = Author(name=normalized)
db.session.add(author) db.session.add(author)
# Don't commit here - let the caller handle the transaction # Don't commit here - let the caller handle the transaction
@@ -621,13 +702,14 @@ def _get_or_create_author(name: str) -> Author:
def _get_or_create_genre(name: str) -> Genre: def _get_or_create_genre(name: str) -> Genre:
"""Get existing genre or create a new one.""" """Get existing genre or create a new one. Always store as lowercase."""
normalized = name.strip().lower()
genre = db.session.execute( genre = db.session.execute(
select(Genre).filter(Genre.name == name) select(Genre).filter(Genre.name == normalized)
).scalar_one_or_none() ).scalar_one_or_none()
if genre is None: if genre is None:
genre = Genre(name=name) genre = Genre(name=normalized)
db.session.add(genre) db.session.add(genre)
# Don't commit here - let the caller handle the transaction # Don't commit here - let the caller handle the transaction
@@ -910,3 +992,102 @@ 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
source = source_path
else:
# Handle HTTP(S) URLs
response = requests.get(image_url, timeout=10, stream=True)
response.raise_for_status()
# Load image from response content
source = response.raw
with Image.open(source) as image:
processed_image = _process_cover_image(image)
# Generate filename
extension = ".jpg" # Always save as JPEG
# Hash the image to create a unique filename based on content
image_hash = hashlib.md5(processed_image.tobytes()).hexdigest()
filename = f"book_{book.id}_{image_hash}{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 typing import Annotated, Any, Literal from pathlib import Path
from typing import Annotated, Any
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,
@@ -35,14 +41,18 @@ from hxbooks.search import IsOperatorValue, SortDirection
from . import library from . import library
from .db import db from .db import db
from .library import DuplicateISBNError
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)]
StripStr = Annotated[str, StringConstraints(strip_whitespace=True)] StripStr = Annotated[str, StringConstraints(strip_whitespace=True)]
ISBNOrEmpty = Annotated[ISBN | Literal[""], BeforeValidator(lambda v: v.strip() or "")] ISBNOrNone = Annotated[ISBN | None, BeforeValidator(lambda v: v.strip() or None)]
TextareaList = Annotated[ TextareaList = Annotated[
list[str], list[str],
BeforeValidator( BeforeValidator(
@@ -55,12 +65,13 @@ 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):
title: StripStr = Field(min_length=1) title: StripStr = Field(min_length=1)
owner: StrOrNone = None owner: StrOrNone = None
isbn: ISBNOrEmpty = "" isbn: ISBNOrNone = None
authors: TextareaList = Field(default_factory=list) authors: TextareaList = Field(default_factory=list)
genres: TextareaList = Field(default_factory=list) genres: TextareaList = Field(default_factory=list)
first_published: IntOrNone = Field(default=None, le=2030) first_published: IntOrNone = Field(default=None, le=2030)
@@ -73,6 +84,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 +101,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 +187,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 +243,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 +272,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
@@ -247,7 +284,7 @@ def create_book() -> ResponseReturnValue:
owner_id=owner_id, owner_id=owner_id,
authors=form_data.authors, authors=form_data.authors,
genres=form_data.genres, genres=form_data.genres,
isbn=str(form_data.isbn), isbn=form_data.isbn,
publisher=form_data.publisher, publisher=form_data.publisher,
edition=form_data.edition, edition=form_data.edition,
description=form_data.description, description=form_data.description,
@@ -258,21 +295,29 @@ 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")
return redirect(url_for("main.book_detail", book_id=book.id)) return redirect(url_for("main.book_detail", book_id=book.id))
except DuplicateISBNError as e:
flash(f"Error: {e}", "error")
except ValidationError as e: except ValidationError as e:
_flash_validation_errors(e) _flash_validation_errors(e)
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") return render_template(
"book/create.html.j2",
form_data=request.form,
genres=library.list_genres(),
authors=library.list_authors(),
locations=library.list_locations(),
)
@bp.route("/book/<int:book_id>/edit", methods=["POST"]) @bp.route("/book/<int:book_id>/edit", methods=["POST"])
@@ -287,6 +332,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,15 +359,20 @@ 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")
except DuplicateISBNError as e:
flash(f"Error: {e}", "error")
except ValidationError as e: except ValidationError as e:
# Format validation errors for display # Format validation errors for display
_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 +393,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 +423,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 +489,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 +510,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 +541,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 +572,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 +593,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 +617,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 +660,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 +689,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 +721,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 +734,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

@@ -56,10 +56,13 @@ class Book(db.Model): # ty:ignore[unsupported-base]
first_published: Mapped[int | None] = mapped_column(default=None) first_published: Mapped[int | None] = mapped_column(default=None)
edition: Mapped[str] = mapped_column(String(200), default="") edition: Mapped[str] = mapped_column(String(200), default="")
publisher: Mapped[str] = mapped_column(String(200), default="") publisher: Mapped[str] = mapped_column(String(200), default="")
isbn: Mapped[str] = mapped_column(String(20), default="") isbn: Mapped[str | None] = mapped_column(
String(20), unique=True, nullable=True, default=None
)
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

@@ -22,7 +22,7 @@
<div class="bg-light p-3 rounded mb-4"> <div class="bg-light p-3 rounded mb-4">
<h6 class="fw-bold">{{ book.title }}</h6> <h6 class="fw-bold">{{ book.title }}</h6>
{% if book.authors %} {% if book.authors %}
<p class="text-muted small mb-1">by {{ book.authors | join(', ') }}</p> <p class="text-muted small mb-1">by {{ book.authors | map('title') | join(', ') }}</p>
{% endif %} {% endif %}
{% if book.isbn %} {% if book.isbn %}
<p class="text-muted small mb-0">ISBN: {{ book.isbn }}</p> <p class="text-muted small mb-0">ISBN: {{ book.isbn }}</p>

View File

@@ -23,7 +23,7 @@
{% import 'components/user_book_vars.html.j2' as vars with context %} {% import 'components/user_book_vars.html.j2' as vars with context %}
<div class="col-12 d-lg-none mb-3"> <div class="col-12 d-lg-none mb-3">
<input type="checkbox" id="status-toggle" class="status-toggle-checkbox" hidden> <input type="checkbox" id="status-toggle" class="status-toggle-checkbox" hidden>
<div class="user-status-card"> <div class="user-status-card" id="user-status-card">
<label for="status-toggle" class="status-bar"> <label for="status-toggle" class="status-bar">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
@@ -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,12 +90,27 @@
<!-- 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">
<h6 class="mb-0">{{ session.get('viewing_as_user').title() }}'s Data</h6> <h6 class="mb-0">{{ session.get('viewing_as_user').title() }}'s Data</h6>
</div> </div>
<div class="card-body"> <div class="card-body" id="user-status">
{% include 'components/reading_status.html.j2' %} {% include 'components/reading_status.html.j2' %}
{% include 'components/wishlist_status.html.j2' %} {% include 'components/wishlist_status.html.j2' %}
</div> </div>

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">
@@ -37,7 +45,7 @@
{% if book.authors %} {% if book.authors %}
<p class="card-text text-muted small text-truncate mb-2"> <p class="card-text text-muted small text-truncate mb-2">
by {{ book.authors | join(', ') }} by {{ book.authors | map('title') | join(', ') }}
</p> </p>
{% endif %} {% endif %}
@@ -55,7 +63,7 @@
{% if book.genres %} {% if book.genres %}
<div class="mt-1"> <div class="mt-1">
{% for genre in book.genres[:2] %} {% for genre in book.genres[:2] %}
<span class="badge bg-light text-dark small me-1">{{ genre }}</span> <span class="badge bg-light text-dark small me-1">{{ genre|title }}</span>
{% endfor %} {% endfor %}
{% if book.genres|length > 2 %} {% if book.genres|length > 2 %}
<span class="badge bg-light text-dark small">+{{ book.genres|length - 2 }}</span> <span class="badge bg-light text-dark small">+{{ book.genres|length - 2 }}</span>

View File

@@ -12,7 +12,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label for="isbn" class="form-label">ISBN</label> <label for="isbn" class="form-label">ISBN</label>
<input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn if book else '' }}"> <input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn if book and book.isbn else '' }}">
</div> </div>
</div> </div>
@@ -21,12 +21,12 @@
<div class="col-md-6"> <div class="col-md-6">
<label for="authors" class="form-label">Authors</label> <label for="authors" class="form-label">Authors</label>
<textarea class="form-control" id="authors" name="authors" rows="2" <textarea class="form-control" id="authors" name="authors" rows="2"
placeholder="One author per line">{% if book and book.authors %}{{ book.authors | join('\n') }}{% endif %}</textarea> placeholder="One author per line">{% if book and book.authors %}{{ book.authors | map('title') | join('\n') }}{% endif %}</textarea>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="genres" class="form-label">Genres</label> <label for="genres" class="form-label">Genres</label>
<textarea class="form-control" id="genres" name="genres" rows="2" <textarea class="form-control" id="genres" name="genres" rows="2"
placeholder="One genre per line">{% if book and book.genres %}{{ book.genres | join('\n') }}{% endif %}</textarea> placeholder="One genre per line">{% if book and book.genres %}{{ book.genres | map('title') | join('\n') }}{% endif %}</textarea>
</div> </div>
</div> </div>
@@ -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">
</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">
@@ -119,12 +155,12 @@
}; };
new Tagify(document.querySelector('#genres'), { new Tagify(document.querySelector('#genres'), {
...commmon_settings, ...commmon_settings,
whitelist: {{ genres | map(attribute = 'name') | list | pprint }}, whitelist: {{ genres | map(attribute = 'name') | map('title') | list | pprint }},
dropdown: { enabled: 0, closeOnSelect: false } dropdown: { enabled: 0, closeOnSelect: false }
}); });
new Tagify(document.querySelector('#authors'), { new Tagify(document.querySelector('#authors'), {
...commmon_settings, ...commmon_settings,
whitelist: {{ authors | map(attribute = 'name') | list | pprint }}, whitelist: {{ authors | map(attribute = 'name') | map('title') | list | pprint }},
dropdown: { enabled: 0, closeOnSelect: false } dropdown: { enabled: 0, closeOnSelect: false }
}); });
</script> </script>

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,28 +50,27 @@
<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"> <input class="form-check-input" type="checkbox" id="set-owner" name="set_owner" checked>
<input class="form-check-input" type="checkbox" id="set-owner" name="set_owner" checked> <label class="form-check-label" for="set-owner">
<label class="form-check-label" for="set-owner"> Set {{ session.get('viewing_as_user').title() }} as owner
Set {{ session.get('viewing_as_user').title() }} as owner </label>
</label> </div>
</div> </div>
{% endif %}
</div> </div>
{% endif %} <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-download me-1"></i> Import Book
</button>
</div>
</form>
</div> </div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-download me-1"></i> Import Book
</button>
</div>
</form>
</div> </div>
</div> </div>
</div>
<style> <style>
/* QuaggaJS viewport styling - overlay video and canvas */ /* QuaggaJS viewport styling - overlay video and canvas */

View File

@@ -6,7 +6,7 @@
<h6 class="text-muted mb-2">📖 Reading Status</h6> <h6 class="text-muted mb-2">📖 Reading Status</h6>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="mb-3"> <div class="mb-3" hx-select-oob="#flash-messages-container,#user-status-card,#user-status" hx-swap="none show:none">
{% if vars.current_reading %} {% if vars.current_reading %}
<form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline"> <form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline">
<button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button> <button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button>
@@ -26,15 +26,15 @@
<!-- Current Book Rating (if any completed readings) --> <!-- Current Book Rating (if any completed readings) -->
{% if vars.completed_readings %} {% if vars.completed_readings %}
<div class="alert alert-light border py-2 mb-3"> <div id="book-rating" class="alert alert-light border py-2 mb-3">
<form action="/book/{{ book.id }}/reading/{{ vars.completed_readings[0].id }}/update" method="POST" <form action="/book/{{ book.id }}/reading/{{ vars.completed_readings[0].id }}/update" method="POST"
class="row align-items-center g-2" hx-trigger="change,submit" hx-swap="none show:none" class="row align-items-center g-2" hx-trigger="change,submit" hx-swap="none show:none"
hx-select-oob="#flash-messages-container:outerHTML" hx-target="this"> hx-select-oob="#flash-messages-container" hx-target="this">
<!-- Hidden fields to preserve other reading data --> <!-- Hidden fields to preserve other reading data -->
<input type="hidden" name="start_date" value="{{ vars.completed_readings[0].start_date.strftime('%Y-%m-%d') }}"> <input type="hidden" name="start_date" value="{{ vars.completed_readings[0].start_date.strftime('%Y-%m-%d') }}">
<input type="hidden" name="end_date" <input type="hidden" name="end_date"
value="{{ vars.completed_readings[0].end_date.strftime('%Y-%m-%d') if vars.completed_readings[0].end_date else '' }}"> value="{{ vars.completed_readings[0].end_date.strftime('%Y-%m-%d') if vars.completed_readings[0].end_date else '' }}">
<input type="hidden" name="dropped" value="1" {{ 'checked' if vars.completed_readings[0].dropped else '' }}> <input type="hidden" name="dropped" value="{{ '1' if vars.completed_readings[0].dropped else '0' }}">
<input type="hidden" name="comments" value="{{ vars.completed_readings[0].comments or '' }}"> <input type="hidden" name="comments" value="{{ vars.completed_readings[0].comments or '' }}">
<div class="col-auto"> <div class="col-auto">
@@ -61,7 +61,8 @@
{% for reading in vars.user_readings | sort(attribute='start_date', reverse=true) %} {% for reading in vars.user_readings | sort(attribute='start_date', reverse=true) %}
<div class="border rounded p-3 mb-2 {% if reading == vars.current_reading %}border-primary bg-light{% endif %}"> <div class="border rounded p-3 mb-2 {% if reading == vars.current_reading %}border-primary bg-light{% endif %}">
<form action="/book/{{ book.id }}/reading/{{ reading.id }}/update" method="POST" hx-trigger="change,submit" <form action="/book/{{ book.id }}/reading/{{ reading.id }}/update" method="POST" hx-trigger="change,submit"
hx-swap="none show:none" hx-select-oob="#flash-messages-container:outerHTML" hx-target="this"> hx-swap="none show:none" hx-select-oob="#flash-messages-container:outerHTML,#book-rating:outerHTML"
hx-target="this">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label-sm">Start Date</label> <label class="form-label-sm">Start Date</label>

View File

@@ -92,7 +92,7 @@ class TestBookAddCommand:
authors = db.session.execute(db.select(Author)).scalars().all() authors = db.session.execute(db.select(Author)).scalars().all()
assert len(authors) == 1 assert len(authors) == 1
author = authors[0] author = authors[0]
assert author.name == "J.R.R. Tolkien" assert author.name == "j.r.r. tolkien"
assert book in author.books assert book in author.books
assert author in book.authors assert author in book.authors
@@ -100,7 +100,7 @@ class TestBookAddCommand:
genres = db.session.execute(db.select(Genre)).scalars().all() genres = db.session.execute(db.select(Genre)).scalars().all()
assert len(genres) == 2 assert len(genres) == 2
genre_names = {genre.name for genre in genres} genre_names = {genre.name for genre in genres}
assert genre_names == {"Fantasy", "Adventure"} assert genre_names == {"fantasy", "adventure"}
for genre in genres: for genre in genres:
assert book in genre.books assert book in genre.books
@@ -118,7 +118,7 @@ class TestBookAddCommand:
with app.app_context(): with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one() book = db.session.execute(db.select(Book)).scalar_one()
assert book.title == "Minimal Book" assert book.title == "Minimal Book"
assert book.isbn == "" # Default empty string assert book.isbn is None
assert book.publisher == "" assert book.publisher == ""
assert book.location_shelf is None # Default None assert book.location_shelf is None # Default None
assert len(book.authors) == 0 # No authors provided assert len(book.authors) == 0 # No authors provided
@@ -181,7 +181,7 @@ class TestBookListCommand:
assert len(books_data) == 1 assert len(books_data) == 1
book = books_data[0] book = books_data[0]
assert book["title"] == "Test Book" assert book["title"] == "Test Book"
assert book["authors"] == ["Test Author"] assert book["authors"] == ["test author"]
assert book["owner"] == "alice" assert book["owner"] == "alice"
assert book["isbn"] == "1234567890" assert book["isbn"] == "1234567890"
@@ -819,8 +819,8 @@ class TestWishlistCommands:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Wished Book 1" in result.output assert "Wished Book 1" in result.output
assert "Wished Book 2" in result.output assert "Wished Book 2" in result.output
assert "Author One" in result.output assert "author one" in result.output
assert "Author Two" in result.output assert "author two" in result.output
def test_wishlist_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None: def test_wishlist_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing wishlist in JSON format.""" """Test listing wishlist in JSON format."""
@@ -852,7 +852,7 @@ class TestWishlistCommands:
assert len(wishlist_data) == 1 assert len(wishlist_data) == 1
item = wishlist_data[0] item = wishlist_data[0]
assert item["title"] == "JSON Wished Book" assert item["title"] == "JSON Wished Book"
assert item["authors"] == ["JSON Author"] assert item["authors"] == ["json author"]
class TestDatabaseCommands: class TestDatabaseCommands:

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"