Add pagination
This commit is contained in:
79
.github/copilot-instructions.md
vendored
79
.github/copilot-instructions.md
vendored
@@ -2,26 +2,29 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
HXBooks is a personal book library management application built with Flask, HTMX, and SQLAlchemy. It provides dynamic book searching, reading tracking, and library management without heavy JavaScript frameworks.
|
HXBooks is a personal book library management application with both web (Flask + HTMX) and CLI interfaces. It provides advanced book searching, reading tracking, and library management without heavy JavaScript frameworks.
|
||||||
|
|
||||||
**Core Technologies:**
|
**Core Technologies:**
|
||||||
- **Backend**: Flask 3.1+ with SQLAlchemy 2.0 (modern `Mapped[]` annotations)
|
- **Backend**: Flask 3.1+ with SQLAlchemy 2.0 (modern `Mapped[]` annotations)
|
||||||
- **Frontend**: HTMX + Alpine.js + Bootstrap (minimal JavaScript approach)
|
- **Frontend**: HTMX + Alpine.js + Bootstrap (minimal JavaScript approach)
|
||||||
- **Validation**: Pydantic 2.x schemas for request/response validation
|
- **Validation**: Pydantic 2.x with modern `Annotated` types for composable validation
|
||||||
- **Templates**: Jinja2 with fragments for partial page updates
|
- **Templates**: Jinja2 with fragments for partial page updates
|
||||||
- **Database**: SQLite with JSON columns for flexible arrays
|
- **Database**: SQLite with proper normalized schema (no JSON columns for core entities)
|
||||||
- **Package Manager**: UV with Python 3.14
|
- **Package Manager**: UV with Python 3.14
|
||||||
|
- **CLI**: Click-based comprehensive command interface
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Application Factory Pattern:**
|
**Service Layer Pattern:**
|
||||||
- `create_app()` in [src/hxbooks/__init__.py](src/hxbooks/__init__.py)
|
- Flask app factory in [src/hxbooks/app.py](src/hxbooks/app.py)
|
||||||
- Blueprint organization: `auth.py` (authentication), `book.py` (main features)
|
- Service layer: [src/hxbooks/library.py](src/hxbooks/library.py) - business logic shared between web and CLI
|
||||||
- Models: User, Book, Reading, Wishlist with cascade relationships
|
- Web routes: [src/hxbooks/main.py](src/hxbooks/main.py) - Flask blueprint handling HTTP requests
|
||||||
|
- CLI commands: [src/hxbooks/cli.py](src/hxbooks/cli.py) - Click command groups
|
||||||
|
|
||||||
**Key Components:**
|
**Key Components:**
|
||||||
- [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with modern `Mapped[]` syntax
|
- [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with proper normalization and relationships
|
||||||
- [src/hxbooks/book.py](src/hxbooks/book.py): Complex search with Pydantic validation
|
- [src/hxbooks/library.py](src/hxbooks/library.py): Core business logic (create_book, search_books_advanced, etc.)
|
||||||
|
- [src/hxbooks/search.py](src/hxbooks/search.py): Advanced query parser with pyparsing
|
||||||
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
|
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
|
||||||
- [src/hxbooks/templates/](src/hxbooks/templates/): Jinja2 templates with HTMX fragments
|
- [src/hxbooks/templates/](src/hxbooks/templates/): Jinja2 templates with HTMX fragments
|
||||||
|
|
||||||
@@ -31,13 +34,30 @@ HXBooks is a personal book library management application built with Flask, HTMX
|
|||||||
```bash
|
```bash
|
||||||
uv sync # Install dependencies
|
uv sync # Install dependencies
|
||||||
python -m hxbooks # Dev server with livereload (port 5000)
|
python -m hxbooks # Dev server with livereload (port 5000)
|
||||||
|
hxbooks --help # CLI interface (after uv sync)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI Commands:**
|
||||||
|
```bash
|
||||||
|
hxbooks book add "Title" --authors "Author1,Author2" --genres "Fiction"
|
||||||
|
hxbooks book search "author:tolkien genre:fantasy rating>=4"
|
||||||
|
hxbooks reading start <book_id>
|
||||||
|
hxbooks reading finish <book_id> --rating 5 --comments "Excellent!"
|
||||||
|
hxbooks wishlist add <book_id>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Development Server:**
|
**Development Server:**
|
||||||
- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Livereload server watching templates
|
- CLI command `hxbooks serve`: Livereload server watching templates
|
||||||
- VS Code debugging: Use "Python Debugger: Flask" launch configuration
|
- VS Code debugging: Flask app factory in [src/hxbooks/app.py](src/hxbooks/app.py)
|
||||||
- Config: Instance folder pattern (`instance/config.py` for local overrides)
|
- Config: Instance folder pattern (`instance/config.py` for local overrides)
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
```bash
|
||||||
|
pytest # Run all tests with verbose output
|
||||||
|
pytest tests/test_cli.py # Test CLI commands
|
||||||
|
pytest tests/test_search.py # Test query parser
|
||||||
|
```
|
||||||
|
|
||||||
**Production Deployment:**
|
**Production Deployment:**
|
||||||
- Dockerfile uses Gunicorn on port 8080
|
- Dockerfile uses Gunicorn on port 8080
|
||||||
- **Note**: Current Dockerfile expects `requirements.txt` but project uses `pyproject.toml`
|
- **Note**: Current Dockerfile expects `requirements.txt` but project uses `pyproject.toml`
|
||||||
@@ -48,7 +68,13 @@ python -m hxbooks # Dev server with livereload (port 5000)
|
|||||||
- **Types**: Modern annotations (`str | Response`, `Mapped[str]`, `Optional[Type]`)
|
- **Types**: Modern annotations (`str | Response`, `Mapped[str]`, `Optional[Type]`)
|
||||||
- **Naming**: snake_case functions/vars, PascalCase classes, UPPERCASE constants
|
- **Naming**: snake_case functions/vars, PascalCase classes, UPPERCASE constants
|
||||||
- **SQLAlchemy**: Use `mapped_column()` and `relationship()` with `back_populates`
|
- **SQLAlchemy**: Use `mapped_column()` and `relationship()` with `back_populates`
|
||||||
- **Validation**: Pydantic schemas with `{Entity}RequestSchema`/`{Entity}ResultSchema` pattern
|
- **Validation**: Pydantic schemas with `{Entity}FormData` pattern
|
||||||
|
|
||||||
|
**Pydantic 2.x Patterns:**
|
||||||
|
- **Composable types**: `Annotated[str, StringConstraints(strip_whitespace=True)]`
|
||||||
|
- **Custom validators**: `BeforeValidator()` for preprocessing (strip, empty-to-None)
|
||||||
|
- **Reusable aliases**: `StrOrNone`, `TextareaList`, `ISBNOrNone` in [src/hxbooks/main.py](src/hxbooks/main.py)
|
||||||
|
- **Modern syntax**: `str | None` not `Optional[str]`, `model_validate()` not `parse_obj()`
|
||||||
|
|
||||||
**Flask Patterns:**
|
**Flask Patterns:**
|
||||||
- Blueprints with `@bp.route()` decorators
|
- Blueprints with `@bp.route()` decorators
|
||||||
@@ -56,6 +82,12 @@ python -m hxbooks # Dev server with livereload (port 5000)
|
|||||||
- Response types: `str | Response` (template string or redirect)
|
- Response types: `str | Response` (template string or redirect)
|
||||||
- Error handling: Dict mapping for template display `{field: error_msg}`
|
- Error handling: Dict mapping for template display `{field: error_msg}`
|
||||||
|
|
||||||
|
**Testing Conventions:**
|
||||||
|
- **pytest**: Class-based organization (`TestBookAddCommand`, `TestFieldFilters`)
|
||||||
|
- **Parametrized tests**: `@pytest.mark.parametrize` for multiple scenario testing
|
||||||
|
- **Type hints**: Full annotations on test methods and fixtures
|
||||||
|
- **Assertions**: Include context with f-strings for debugging
|
||||||
|
|
||||||
**Frontend (HTMX + Alpine.js):**
|
**Frontend (HTMX + Alpine.js):**
|
||||||
- Templates: `.j2` extension, fragments for partial updates
|
- Templates: `.j2` extension, fragments for partial updates
|
||||||
- HTMX: Dynamic updates with `hx-get`, `hx-post`, `hx-target`
|
- HTMX: Dynamic updates with `hx-get`, `hx-post`, `hx-target`
|
||||||
@@ -70,26 +102,33 @@ python -m hxbooks # Dev server with livereload (port 5000)
|
|||||||
- [.vscode/launch.json](.vscode/launch.json): Debug configuration
|
- [.vscode/launch.json](.vscode/launch.json): Debug configuration
|
||||||
|
|
||||||
**Core Application:**
|
**Core Application:**
|
||||||
- [src/hxbooks/__init__.py](src/hxbooks/__init__.py): Flask app factory
|
- [src/hxbooks/app.py](src/hxbooks/app.py): Flask app factory
|
||||||
- [src/hxbooks/db.py](src/hxbooks/db.py): SQLAlchemy setup with auto-table creation
|
- [src/hxbooks/db.py](src/hxbooks/db.py): SQLAlchemy setup with auto-table creation
|
||||||
- [src/hxbooks/models.py](src/hxbooks/models.py): Database models
|
- [src/hxbooks/models.py](src/hxbooks/models.py): Database models
|
||||||
|
|
||||||
**Feature Modules:**
|
**Feature Modules:**
|
||||||
- [src/hxbooks/auth.py](src/hxbooks/auth.py): User authentication (username-based)
|
- [src/hxbooks/main.py](src/hxbooks/main.py): Web routes and form handling
|
||||||
- [src/hxbooks/book.py](src/hxbooks/book.py): Book CRUD, search, filtering
|
- [src/hxbooks/library.py](src/hxbooks/library.py): Business logic service layer
|
||||||
|
- [src/hxbooks/cli.py](src/hxbooks/cli.py): Click CLI commands
|
||||||
|
- [src/hxbooks/search.py](src/hxbooks/search.py): Query parser and search logic
|
||||||
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
|
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
|
||||||
|
|
||||||
|
**Testing Infrastructure:**
|
||||||
|
- [tests/conftest.py](tests/conftest.py): Pytest fixtures and test configuration
|
||||||
|
- [tests/test_cli.py](tests/test_cli.py): CLI command tests
|
||||||
|
- [tests/test_search.py](tests/test_search.py): Query parser and search tests
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
**Database Patterns:**
|
**Database Patterns:**
|
||||||
- JSON columns for arrays: `authors: list`, `genres: list`, `saved_searches: dict`
|
- Normalized schema: `Author` and `Genre` as separate entities with many-to-many relationships
|
||||||
- Cascade deletes for dependent entities
|
- Cascade deletes for dependent entities
|
||||||
- Foreign key constraints explicitly defined
|
- Foreign key constraints explicitly defined
|
||||||
|
|
||||||
**Search Implementation:**
|
**Search Implementation:**
|
||||||
- Full-text search using SQLite FTS with `func.match()`
|
- Advanced query parser using pyparsing with field filters (`author:tolkien`, `rating>=4`)
|
||||||
- Complex query builder returning `Select` statements
|
- Complex query builder returning `Select` statements
|
||||||
- Saved searches stored as JSON in User model
|
- Support for negation (`-genre:romance`) and special operators (`is:reading`)
|
||||||
|
|
||||||
**HTMX Integration:**
|
**HTMX Integration:**
|
||||||
- Partial page updates using Jinja2-Fragments
|
- Partial page updates using Jinja2-Fragments
|
||||||
@@ -97,7 +136,7 @@ python -m hxbooks # Dev server with livereload (port 5000)
|
|||||||
- Form validation errors returned as template fragments
|
- Form validation errors returned as template fragments
|
||||||
|
|
||||||
**Development Notes:**
|
**Development Notes:**
|
||||||
- No testing framework configured yet
|
- Testing framework: pytest with parametrized tests for comprehensive coverage
|
||||||
- No linting/formatting tools setup
|
- Linting/formatting: ruff configured with strict rules
|
||||||
- Instance folder for environment-specific config (gitignored)
|
- Instance folder for environment-specific config (gitignored)
|
||||||
- Production requires either `requirements.txt` generation or Dockerfile updates for UV
|
- Production requires either `requirements.txt` generation or Dockerfile updates for UV
|
||||||
@@ -304,7 +304,7 @@ def search_books(
|
|||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
try:
|
try:
|
||||||
books = library.search_books_advanced(
|
books, _ = library.search_books_advanced(
|
||||||
query_string=query, limit=limit, username=username
|
query_string=query, limit=limit, username=username
|
||||||
)
|
)
|
||||||
if output_format == "json":
|
if output_format == "json":
|
||||||
|
|||||||
@@ -276,9 +276,13 @@ query_parser = QueryParser()
|
|||||||
|
|
||||||
|
|
||||||
def search_books_advanced(
|
def search_books_advanced(
|
||||||
query_string: str, limit: int = 50, username: str | None = None
|
query_string: str, limit: int = 50, offset: int = 0, username: str | None = None
|
||||||
) -> Sequence[Book]:
|
) -> tuple[Sequence[Book], int]:
|
||||||
"""Advanced search with field filters supporting comparison operators."""
|
"""Advanced search with field filters supporting comparison operators.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (books, total_count)
|
||||||
|
"""
|
||||||
parsed_query = query_parser.parse(query_string)
|
parsed_query = query_parser.parse(query_string)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
@@ -332,10 +336,17 @@ def search_books_advanced(
|
|||||||
sort_columns.append(Book.added_date.desc())
|
sort_columns.append(Book.added_date.desc())
|
||||||
query = query.order_by(*sort_columns)
|
query = query.order_by(*sort_columns)
|
||||||
|
|
||||||
query = query.distinct().limit(limit)
|
# Get total count before applying limit/offset
|
||||||
|
count_query = select(func.count()).select_from(query.distinct().subquery())
|
||||||
|
total_count = db.session.execute(count_query).scalar() or 0
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
query = query.distinct().limit(limit).offset(offset)
|
||||||
|
|
||||||
result = db.session.execute(query)
|
result = db.session.execute(query)
|
||||||
return result.scalars().unique().all()
|
books = result.scalars().unique().all()
|
||||||
|
|
||||||
|
return books, total_count
|
||||||
|
|
||||||
|
|
||||||
def _build_field_condition(
|
def _build_field_condition(
|
||||||
|
|||||||
@@ -116,26 +116,55 @@ def inject_template_vars() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RESULTS_PER_PAGE = 10
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def index() -> ResponseReturnValue:
|
def index() -> ResponseReturnValue:
|
||||||
"""Book list view - main application page."""
|
"""Book list view - main application page."""
|
||||||
# Get search parameters
|
# Get search parameters
|
||||||
query = request.args.get("q", "")
|
query = request.args.get("q", "")
|
||||||
|
page = request.args.get("page", 1, type=int)
|
||||||
|
|
||||||
|
# Ensure valid pagination values
|
||||||
|
page = max(1, page)
|
||||||
|
offset = (page - 1) * RESULTS_PER_PAGE
|
||||||
|
|
||||||
# Get current viewing user
|
# Get current viewing user
|
||||||
viewing_user = session.get("viewing_as_user")
|
viewing_user = session.get("viewing_as_user")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
books = library.search_books_advanced(query, limit=100, username=viewing_user)
|
books, total_count = library.search_books_advanced(
|
||||||
|
query, limit=RESULTS_PER_PAGE, offset=offset, username=viewing_user
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f"Search error: {e}", "error")
|
flash(f"Search error: {e}", "error")
|
||||||
# print traceback for debugging
|
# print traceback for debugging
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
books, total_count = [], 0
|
||||||
|
|
||||||
books = []
|
# Calculate pagination info
|
||||||
|
total_pages = (total_count + RESULTS_PER_PAGE - 1) // RESULTS_PER_PAGE
|
||||||
|
has_prev = page > 1
|
||||||
|
has_next = page < total_pages
|
||||||
|
prev_num = page - 1 if has_prev else None
|
||||||
|
next_num = page + 1 if has_next else None
|
||||||
|
|
||||||
return render_template("book/list.html.j2", books=books, query=query)
|
return render_template(
|
||||||
|
"book/list.html.j2",
|
||||||
|
books=books,
|
||||||
|
query=query,
|
||||||
|
pagination={
|
||||||
|
"page": page,
|
||||||
|
"per_page": RESULTS_PER_PAGE,
|
||||||
|
"total": total_count,
|
||||||
|
"pages": total_pages,
|
||||||
|
"has_prev": has_prev,
|
||||||
|
"has_next": has_next,
|
||||||
|
"prev_num": prev_num,
|
||||||
|
"next_num": next_num,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/book/<int:book_id>")
|
@bp.route("/book/<int:book_id>")
|
||||||
|
|||||||
@@ -40,6 +40,76 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<nav aria-label="Book pagination" class="mt-4">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<!-- Previous Page -->
|
||||||
|
<li class="page-item {{ 'disabled' if not pagination.has_prev }}">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<a class="page-link" href="{{ url_for('main.index', q=query, page=pagination.prev_num) }}">«
|
||||||
|
Previous</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="page-link">« Previous</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Page Numbers -->
|
||||||
|
{% set start_page = [1, pagination.page - 2]|max %}
|
||||||
|
{% set end_page = [pagination.pages, pagination.page + 2]|min %}
|
||||||
|
|
||||||
|
{% if start_page > 1 %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('main.index', q=query, page=1) }}">1</a>
|
||||||
|
</li>
|
||||||
|
{% if start_page > 2 %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">...</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for page_num in range(start_page, end_page + 1) %}
|
||||||
|
<li class="page-item {{ 'active' if page_num == pagination.page }}">
|
||||||
|
{% if page_num == pagination.page %}
|
||||||
|
<span class="page-link">{{ page_num }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a class="page-link" href="{{ url_for('main.index', q=query, page=page_num) }}">{{ page_num }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if end_page < pagination.pages %} {% if end_page < pagination.pages - 1 %} <li class="page-item disabled">
|
||||||
|
<span class="page-link">...</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('main.index', q=query, page=pagination.pages) }}">{{
|
||||||
|
pagination.pages }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Next Page -->
|
||||||
|
<li class="page-item {{ 'disabled' if not pagination.has_next }}">
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a class="page-link" href="{{ url_for('main.index', q=query, page=pagination.next_num) }}">Next
|
||||||
|
»</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="page-link">Next »</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Results Info -->
|
||||||
|
<div class="text-center text-muted mt-2">
|
||||||
|
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) if books else 0 }}
|
||||||
|
to {{ [pagination.page * pagination.per_page, pagination.total]|min }}
|
||||||
|
of {{ pagination.total }} books
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user