Compare commits
13 Commits
2de7aee8b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1262aa3c37 | |||
| 9429d203bd | |||
| 206b2a9d5b | |||
| dc73de6799 | |||
| 03a5b3803e | |||
| 9f73077207 | |||
| 4d64db45c8 | |||
| c97f4c7d38 | |||
| da0924eb41 | |||
| 39fb2edd71 | |||
| f9d6662467 | |||
| 93e3397553 | |||
| be43fe8a0a |
@@ -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
35
.gitea/workflows/ci.yml
Normal 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
|
||||||
55
.gitea/workflows/deploy.yml
Normal file
55
.gitea/workflows/deploy.yml
Normal 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
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
instance/
|
instance/
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
media/
|
||||||
@@ -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
99
Caddyfile
Normal 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"
|
||||||
|
# }
|
||||||
|
# }
|
||||||
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"]
|
||||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal 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
34
docker-entrypoint.sh
Normal 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
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 ###
|
||||||
@@ -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 ###
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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="")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
type="text/css" />
|
type="text/css" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.min.js"></script>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -6,19 +6,50 @@
|
|||||||
<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 -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
<div class="btn-group w-100" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="input-mode" id="manual-mode" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="manual-mode">Manual Entry</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="input-mode" id="scan-mode">
|
||||||
|
<label class="btn btn-outline-primary" for="scan-mode">Scan Barcode</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Entry -->
|
||||||
|
<div id="manual-entry" class="mb-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"
|
<input type="text" class="form-control" id="isbn" name="isbn" placeholder="Enter ISBN-10 or ISBN-13"
|
||||||
placeholder="Enter ISBN-10 or ISBN-13"
|
pattern="[0-9X\-]{10,17}" title="Enter a valid ISBN (10 or 13 digits, may contain hyphens)" required>
|
||||||
pattern="[0-9X\-]{10,17}"
|
|
||||||
title="Enter a valid ISBN (10 or 13 digits, may contain hyphens)"
|
|
||||||
required>
|
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
Enter the ISBN (International Standard Book Number) to automatically fetch book details from Google Books.
|
Enter the ISBN (International Standard Book Number) to automatically fetch book details from Google Books.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Barcode Scanner -->
|
||||||
|
<div id="scanner-section" class="mb-3" style="display: none;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="camera-select" class="form-label">Camera</label>
|
||||||
|
<select id="camera-select" class="form-select form-select-sm">
|
||||||
|
<option value="">Loading cameras...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<div id="interactive" class="viewport"
|
||||||
|
style="position: relative; width: 100%; max-width: 320px; height: 240px; margin: 0 auto; border: 1px solid #dee2e6; border-radius: 0.375rem; overflow: hidden;">
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="button" id="start-scan-btn" class="btn btn-success btn-sm">Start Scanning</button>
|
||||||
|
<button type="button" id="stop-scan-btn" class="btn btn-danger btn-sm" style="display: none;">Stop
|
||||||
|
Scanning</button>
|
||||||
|
</div>
|
||||||
|
<div id="scan-status" class="text-muted small mt-2"></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">
|
||||||
@@ -39,4 +70,239 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* QuaggaJS viewport styling - overlay video and canvas */
|
||||||
|
#interactive.viewport video,
|
||||||
|
#interactive.viewport canvas {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
#interactive.viewport canvas {
|
||||||
|
pointer-events: none;
|
||||||
|
/* Allow interactions with video underneath */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Simplified Barcode Scanner based on QuaggaJS example
|
||||||
|
(function () {
|
||||||
|
var isScanning = false;
|
||||||
|
var currentDeviceId = null;
|
||||||
|
|
||||||
|
var manualMode = document.getElementById('manual-mode');
|
||||||
|
var scanMode = document.getElementById('scan-mode');
|
||||||
|
var manualEntry = document.getElementById('manual-entry');
|
||||||
|
var scannerSection = document.getElementById('scanner-section');
|
||||||
|
var cameraSelect = document.getElementById('camera-select');
|
||||||
|
var startBtn = document.getElementById('start-scan-btn');
|
||||||
|
var stopBtn = document.getElementById('stop-scan-btn');
|
||||||
|
var status = document.getElementById('scan-status');
|
||||||
|
var isbnInput = document.getElementById('isbn');
|
||||||
|
|
||||||
|
// QuaggaJS state - simplified from example
|
||||||
|
var state = {
|
||||||
|
inputStream: {
|
||||||
|
type: "LiveStream",
|
||||||
|
//target: document.querySelector('#scanner-canvas'),
|
||||||
|
constraints: {
|
||||||
|
width: { min: 320 },
|
||||||
|
height: { min: 240 },
|
||||||
|
facingMode: "environment"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locator: {
|
||||||
|
patchSize: "x-large",
|
||||||
|
halfSample: true
|
||||||
|
},
|
||||||
|
frequency: 10,
|
||||||
|
decoder: {
|
||||||
|
readers: ["ean_reader"]
|
||||||
|
},
|
||||||
|
locate: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize camera list when scanner section is shown
|
||||||
|
function initCameras() {
|
||||||
|
console.log('Initializing camera list...');
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
status.textContent = 'Camera not supported on this browser';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Quagga.CameraAccess.enumerateVideoDevices()
|
||||||
|
.then(function (devices) {
|
||||||
|
var currentDeviceId = cameraSelect.value;
|
||||||
|
cameraSelect.innerHTML = '';
|
||||||
|
if (devices.length === 0) {
|
||||||
|
cameraSelect.innerHTML = '<option>No cameras found</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.forEach(function (device) {
|
||||||
|
var option = document.createElement('option');
|
||||||
|
option.value = device.deviceId || device.id;
|
||||||
|
var label = device.label || ('Camera ' + (cameraSelect.children.length + 1));
|
||||||
|
option.textContent = label.length > 30 ? label.substr(0, 30) + '...' : label;
|
||||||
|
cameraSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default to current or back camera if available
|
||||||
|
if (currentDeviceId) {
|
||||||
|
cameraSelect.value = currentDeviceId;
|
||||||
|
} else {
|
||||||
|
var backCamera = devices.find(d => d.label.toLowerCase().includes('back'));
|
||||||
|
if (backCamera) {
|
||||||
|
cameraSelect.value = backCamera.deviceId || backCamera.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.error('Camera enumeration failed:', err);
|
||||||
|
cameraSelect.innerHTML = '<option>Camera access failed</option>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startScanning() {
|
||||||
|
if (isScanning) return;
|
||||||
|
|
||||||
|
var selectedDeviceId = cameraSelect.value;
|
||||||
|
//if (!selectedDeviceId) {
|
||||||
|
// status.textContent = 'Please select a camera';
|
||||||
|
// return;
|
||||||
|
//}
|
||||||
|
|
||||||
|
status.textContent = 'Starting camera...';
|
||||||
|
|
||||||
|
// Update constraints with selected camera
|
||||||
|
if (selectedDeviceId) {
|
||||||
|
state.inputStream.constraints.deviceId = { exact: selectedDeviceId };
|
||||||
|
}
|
||||||
|
console.log('Starting Quagga with state:', state);
|
||||||
|
|
||||||
|
Quagga.init(state, function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Quagga init error:', err);
|
||||||
|
status.textContent = 'Failed to start camera. Try a different camera.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isScanning = true;
|
||||||
|
startBtn.style.display = 'none';
|
||||||
|
stopBtn.style.display = 'inline-block';
|
||||||
|
status.textContent = 'Point camera at barcode...';
|
||||||
|
|
||||||
|
initCameras();
|
||||||
|
Quagga.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanning() {
|
||||||
|
if (!isScanning) return;
|
||||||
|
|
||||||
|
isScanning = false;
|
||||||
|
startBtn.style.display = 'inline-block';
|
||||||
|
stopBtn.style.display = 'none';
|
||||||
|
status.textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
Quagga.stop();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error stopping Quagga:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode switching
|
||||||
|
function switchToManual() {
|
||||||
|
manualEntry.style.display = 'block';
|
||||||
|
scannerSection.style.display = 'none';
|
||||||
|
stopScanning();
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToScanner() {
|
||||||
|
manualEntry.style.display = 'none';
|
||||||
|
scannerSection.style.display = 'block';
|
||||||
|
//if (cameraSelect.children.length === 1 && cameraSelect.children[0].textContent === 'Loading cameras...') {
|
||||||
|
// initCameras();
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
manualMode.addEventListener('change', switchToManual);
|
||||||
|
scanMode.addEventListener('change', switchToScanner);
|
||||||
|
startBtn.addEventListener('click', startScanning);
|
||||||
|
stopBtn.addEventListener('click', stopScanning);
|
||||||
|
|
||||||
|
// Camera selection change - restart scanning if active
|
||||||
|
cameraSelect.addEventListener('change', function () {
|
||||||
|
if (isScanning) {
|
||||||
|
stopScanning();
|
||||||
|
setTimeout(startScanning, 250);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Barcode detection - simplified from example
|
||||||
|
Quagga.onDetected(function (result) {
|
||||||
|
var code = result.codeResult.code;
|
||||||
|
console.log('Barcode detected:', code);
|
||||||
|
|
||||||
|
// Simple ISBN validation
|
||||||
|
if (code && /^[0-9X]{10,13}$/.test(code)) {
|
||||||
|
isbnInput.value = code;
|
||||||
|
status.textContent = 'ISBN detected: ' + code;
|
||||||
|
stopScanning();
|
||||||
|
|
||||||
|
// Switch back to manual mode
|
||||||
|
manualMode.checked = true;
|
||||||
|
switchToManual();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var processedCount = 0;
|
||||||
|
Quagga.onProcessed(function (result) {
|
||||||
|
processedCount++;
|
||||||
|
//console.log('onProcessed #' + processedCount + ':', result ? {
|
||||||
|
// hasBox: !!result.box,
|
||||||
|
// boxLength: result.box ? result.box.length : 0,
|
||||||
|
// hasBoxes: !!result.boxes,
|
||||||
|
// boxesLength: result.boxes ? result.boxes.length : 0,
|
||||||
|
// codeResult: !!result.codeResult
|
||||||
|
//} : 'null result');
|
||||||
|
var drawingCtx = Quagga.canvas.ctx.overlay,
|
||||||
|
drawingCanvas = Quagga.canvas.dom.overlay;
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
if (result.boxes) {
|
||||||
|
drawingCtx.clearRect(0, 0, parseInt(drawingCanvas.getAttribute("width")), parseInt(drawingCanvas.getAttribute("height")));
|
||||||
|
result.boxes.filter(function (box) {
|
||||||
|
return box !== result.box;
|
||||||
|
}).forEach(function (box) {
|
||||||
|
Quagga.ImageDebug.drawPath(box, { x: 0, y: 1 }, drawingCtx, { color: "orange", lineWidth: 2 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.box) {
|
||||||
|
Quagga.ImageDebug.drawPath(result.box, { x: 0, y: 1 }, drawingCtx, { color: "#00F", lineWidth: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.codeResult && result.codeResult.code) {
|
||||||
|
Quagga.ImageDebug.drawPath(result.line, { x: 'x', y: 'y' }, drawingCtx, { color: 'red', lineWidth: 3 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up on modal close
|
||||||
|
document.getElementById('import-modal').addEventListener('hidden.bs.modal', function () {
|
||||||
|
stopScanning();
|
||||||
|
manualMode.checked = true;
|
||||||
|
switchToManual();
|
||||||
|
isbnInput.value = '';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
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