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