Compare commits

...

9 Commits

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

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

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

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# Caddyfile for HXBooks # Caddyfile for HXBooks
# Replace 'localhost' with your domain for production with automatic HTTPS # Replace 'localhost' with your domain for production with automatic HTTPS
localhost { :80 {
# Serve static files directly (CSS, JS, images, etc.) # Serve static files directly (CSS, JS, images, etc.)
handle /static/* { handle /static/* {
root * /var/www root * /var/www
@@ -33,9 +33,6 @@ localhost {
# Forward real IP to app # Forward real IP to app
header_up X-Real-IP {remote} header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
} }
# Optional: Enable compression for better performance # Optional: Enable compression for better performance

View File

@@ -9,10 +9,13 @@ services:
- instance:/app/instance - instance:/app/instance
# Mount shared directory for static files that Caddy can access # Mount shared directory for static files that Caddy can access
- static:/shared/static - static:/shared/static
# Mount caddy_file for Caddy configuration
- caddy_file:/app/caddy
expose: expose:
- "5000" - "5000"
environment: environment:
- FLASK_ENV=production - FLASK_ENV=production
- GOOGLE_BOOKS_API_KEY=${GOOGLE_BOOKS_API_KEY}
networks: networks:
- hxbooks - hxbooks
# Health check to ensure app is ready # Health check to ensure app is ready
@@ -31,11 +34,10 @@ services:
image: caddy:2-alpine image: caddy:2-alpine
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "5123:80"
- "443:443"
volumes: volumes:
# Caddyfile configuration # Caddyfile configuration
- ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_file:/etc/caddy
# Media files served directly by Caddy # Media files served directly by Caddy
- media:/var/www/media:ro - media:/var/www/media:ro
# Static files served directly by Caddy (populated by app container) # Static files served directly by Caddy (populated by app container)
@@ -64,3 +66,5 @@ volumes:
driver: local driver: local
caddy_config: caddy_config:
driver: local driver: local
caddy_file:
driver: local

View File

@@ -12,6 +12,9 @@ else
echo "Static files already present in shared volume" echo "Static files already present in shared volume"
fi 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 # Initialize database if it doesn't exist or run migrations if it does
echo "Checking database status..." echo "Checking database status..."
if [ ! -f /app/instance/hxbooks.sqlite ]; then if [ ! -f /app/instance/hxbooks.sqlite ]; then

View File

@@ -0,0 +1,64 @@
"""Add unique constraint to ISBN field
Revision ID: 5584e4fd820e
Revises: 0c155d83c55b
Create Date: 2026-03-31 13:41:16.356631
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5584e4fd820e'
down_revision = '0c155d83c55b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# First, convert empty string ISBNs to a temporary placeholder
# This is needed because we can't set NULL while column is NOT NULL
op.execute("UPDATE book SET isbn = '__EMPTY_ISBN__' WHERE isbn = ''")
# Remove duplicate ISBNs, keeping only the book with the lowest ID
op.execute("""
DELETE FROM book
WHERE isbn != '__EMPTY_ISBN__'
AND isbn != ''
AND id NOT IN (
SELECT MIN(id)
FROM book
WHERE isbn != '__EMPTY_ISBN__'
AND isbn != ''
GROUP BY isbn
)
""")
with op.batch_alter_table('book', schema=None) as batch_op:
# Make the column nullable
batch_op.alter_column('isbn',
existing_type=sa.VARCHAR(length=20),
nullable=True)
# Now convert the placeholder values to NULL
op.execute("UPDATE book SET isbn = NULL WHERE isbn = '__EMPTY_ISBN__'")
with op.batch_alter_table('book', schema=None) as batch_op:
# Add the unique constraint
batch_op.create_unique_constraint('uq_book_isbn', ['isbn'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('book', schema=None) as batch_op:
batch_op.drop_constraint('uq_book_isbn', type_='unique')
batch_op.alter_column('isbn',
existing_type=sa.VARCHAR(length=20),
nullable=False)
# ### end Alembic commands ###

View File

@@ -19,7 +19,7 @@ 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

View File

@@ -16,6 +16,7 @@ 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__) logger = logging.getLogger(__name__)
@@ -129,6 +130,9 @@ def add_book(
cover_image_url=cover_url, 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) 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)
@@ -380,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 ""
@@ -459,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}")
@@ -502,8 +506,11 @@ def import_book(
fetch_cover=not no_cover, 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)

View File

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

View File

@@ -5,6 +5,7 @@ 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 import logging
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence from collections.abc import Sequence
@@ -16,6 +17,7 @@ import requests
from flask import current_app from flask import current_app
from PIL import Image 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
@@ -29,6 +31,20 @@ from .search import ComparisonOperator, Field, FieldFilter
logger = logging.getLogger(__name__) 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,
owner_id: int | None = None, owner_id: int | None = None,
@@ -52,7 +68,7 @@ def create_book(
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 "",
@@ -79,7 +95,20 @@ 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 # Handle cover image if provided
if cover_image_url: if cover_image_url:
@@ -137,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, (
@@ -208,7 +236,21 @@ def update_book(
else: else:
remove_book_cover(book) remove_book_cover(book)
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 (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
@@ -645,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
@@ -659,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
@@ -970,32 +1014,27 @@ def download_book_cover(book: Book, image_url: str) -> bool:
return False return False
# Load image directly from file # Load image directly from file
with Image.open(source_path) as image: source = source_path
processed_image = _process_cover_image(image)
# Generate filename
extension = ".jpg" # Always save as JPEG
filename = f"book_{book.id}{extension}"
cover_path = covers_dir / filename
# Save processed image
processed_image.save(cover_path, "JPEG", quality=85)
else: else:
# Handle HTTP(S) URLs # Handle HTTP(S) URLs
response = requests.get(image_url, timeout=10, stream=True) response = requests.get(image_url, timeout=10, stream=True)
response.raise_for_status() response.raise_for_status()
# Load image from response content # Load image from response content
with Image.open(response.raw) as image: source = response.raw
processed_image = _process_cover_image(image)
# Generate filename with Image.open(source) as image:
extension = ".jpg" # Always save as JPEG processed_image = _process_cover_image(image)
filename = f"book_{book.id}{extension}"
cover_path = covers_dir / filename
# Save processed image # Generate filename
processed_image.save(cover_path, "JPEG", quality=85) 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 # Update book record
book.cover_image_path = filename book.cover_image_path = filename

View File

@@ -9,7 +9,7 @@ import os
import tempfile import tempfile
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from typing import Annotated, Any, Literal from typing import Annotated, Any
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -41,6 +41,7 @@ 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__)
@@ -51,7 +52,7 @@ 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(
@@ -70,7 +71,7 @@ UrlOrNone = Annotated[AnyHttpUrl | None, BeforeValidator(lambda v: v.strip() or
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)
@@ -283,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,
@@ -300,17 +301,23 @@ def create_book() -> ResponseReturnValue:
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) 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"])
@@ -357,6 +364,9 @@ def update_book(book_id: int) -> ResponseReturnValue:
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)

View File

@@ -56,7 +56,9 @@ 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)

View File

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

View File

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

View File

@@ -45,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 %}
@@ -63,7 +63,7 @@
{% if book.genres %} {% if book.genres %}
<div class="mt-1"> <div class="mt-1">
{% for genre in book.genres[:2] %} {% for genre in book.genres[:2] %}
<span class="badge bg-light text-dark small me-1">{{ genre }}</span> <span class="badge bg-light text-dark small me-1">{{ genre|title }}</span>
{% endfor %} {% endfor %}
{% if book.genres|length > 2 %} {% if book.genres|length > 2 %}
<span class="badge bg-light text-dark small">+{{ book.genres|length - 2 }}</span> <span class="badge bg-light text-dark small">+{{ book.genres|length - 2 }}</span>

View File

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

View File

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

View File

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