Compare commits
5 Commits
95e434a750
...
2de7aee8b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2de7aee8b1 | |||
| f58c55a1ff | |||
| c47a01df22 | |||
| 3ac792720e | |||
| 452567d0c4 |
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
|
||||||
@@ -4,8 +4,7 @@ from pathlib import Path
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
|
|
||||||
from . import auth, db
|
from . import db
|
||||||
from .htmx import htmx
|
|
||||||
from .main import bp as main_bp
|
from .main import bp as main_bp
|
||||||
|
|
||||||
# Get the project root (parent of src/)
|
# Get the project root (parent of src/)
|
||||||
@@ -36,13 +35,11 @@ def create_app(test_config: dict | None = None) -> Flask:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
htmx.init_app(app)
|
|
||||||
|
|
||||||
# Initialize migrations
|
# Initialize migrations
|
||||||
Migrate(app, db.db)
|
Migrate(app, db.db)
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
app.register_blueprint(auth.bp)
|
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
from functools import wraps
|
|
||||||
from typing import Annotated, Any
|
|
||||||
|
|
||||||
from flask import (
|
|
||||||
Blueprint,
|
|
||||||
g,
|
|
||||||
redirect,
|
|
||||||
render_template,
|
|
||||||
request,
|
|
||||||
session,
|
|
||||||
url_for,
|
|
||||||
)
|
|
||||||
from flask.typing import RouteCallable
|
|
||||||
from pydantic import (
|
|
||||||
BaseModel,
|
|
||||||
StringConstraints,
|
|
||||||
ValidationError,
|
|
||||||
)
|
|
||||||
from werkzeug import Response
|
|
||||||
|
|
||||||
from hxbooks.db import db
|
|
||||||
from hxbooks.models import User
|
|
||||||
from hxbooks.util import flatten_form_data
|
|
||||||
|
|
||||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
|
||||||
|
|
||||||
|
|
||||||
class UserRequestSchema(BaseModel):
|
|
||||||
username: Annotated[str, StringConstraints(min_length=1, to_lower=True)]
|
|
||||||
|
|
||||||
|
|
||||||
# register endpoint
|
|
||||||
@bp.route("/register", methods=["GET", "POST"])
|
|
||||||
def register() -> str | Response:
|
|
||||||
errors = {}
|
|
||||||
if request.method == "POST":
|
|
||||||
form_data = flatten_form_data(request.form)
|
|
||||||
try:
|
|
||||||
user_req = UserRequestSchema.model_validate(form_data)
|
|
||||||
except ValidationError as e:
|
|
||||||
errors = {err["loc"][0]: err["msg"] for err in e.errors()}
|
|
||||||
else:
|
|
||||||
if (
|
|
||||||
User.query.filter(User.username == user_req.username).first()
|
|
||||||
is not None
|
|
||||||
):
|
|
||||||
return render_template(
|
|
||||||
"auth/register.html.j2",
|
|
||||||
errors={"username": "Username already exists"},
|
|
||||||
)
|
|
||||||
user = User(username=user_req.username)
|
|
||||||
db.session.add(user)
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for(".login"))
|
|
||||||
return render_template("auth/register.html.j2", errors=errors)
|
|
||||||
|
|
||||||
|
|
||||||
# login endpoint
|
|
||||||
@bp.route("/login", methods=["GET", "POST"])
|
|
||||||
def login() -> str | Response:
|
|
||||||
errors = {}
|
|
||||||
users = User.query.all()
|
|
||||||
if request.method == "POST":
|
|
||||||
form_data = flatten_form_data(request.form)
|
|
||||||
try:
|
|
||||||
user_req = UserRequestSchema.model_validate(form_data)
|
|
||||||
except ValidationError as e:
|
|
||||||
errors = {err["loc"][0]: err["msg"] for err in e.errors()}
|
|
||||||
else:
|
|
||||||
user = User.query.filter(User.username == user_req.username).first()
|
|
||||||
if user is None:
|
|
||||||
return render_template(
|
|
||||||
"auth/login.html.j2",
|
|
||||||
errors={"username": "User not found"},
|
|
||||||
users=users,
|
|
||||||
)
|
|
||||||
session.clear()
|
|
||||||
session["user_id"] = user.id
|
|
||||||
return redirect(url_for("books.books"))
|
|
||||||
if g.user is not None:
|
|
||||||
return redirect(url_for("books.books"))
|
|
||||||
return render_template("auth/login.html.j2", errors=errors, users=users)
|
|
||||||
|
|
||||||
|
|
||||||
# logout endpoint
|
|
||||||
@bp.route("/logout", methods=["GET"])
|
|
||||||
def logout() -> Response:
|
|
||||||
session.clear()
|
|
||||||
return redirect(url_for(".login"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.before_app_request
|
|
||||||
def load_logged_in_user() -> None:
|
|
||||||
if (user_id := session.get("user_id")) is None:
|
|
||||||
g.user = None
|
|
||||||
else:
|
|
||||||
g.user = User.query.get(user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def login_required(view: RouteCallable) -> RouteCallable:
|
|
||||||
@wraps(view)
|
|
||||||
def wrapped_view(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
if g.user is None:
|
|
||||||
return redirect(url_for("auth.login"))
|
|
||||||
return view(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapped_view
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
import re
|
|
||||||
from datetime import date, datetime
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Any, Literal, Optional, cast, get_args
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from flask import (
|
|
||||||
Blueprint,
|
|
||||||
abort,
|
|
||||||
g,
|
|
||||||
redirect,
|
|
||||||
render_template,
|
|
||||||
request,
|
|
||||||
url_for,
|
|
||||||
)
|
|
||||||
from jinja2_fragments.flask import render_block
|
|
||||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
|
||||||
from sqlalchemy import Column, Select, Subquery, exists, func, or_, select
|
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
||||||
from werkzeug import Response
|
|
||||||
|
|
||||||
from hxbooks.auth import login_required
|
|
||||||
from hxbooks.db import db
|
|
||||||
from hxbooks.gbooks import fetch_google_book_data
|
|
||||||
from hxbooks.htmx import htmx
|
|
||||||
from hxbooks.models import Book, Reading, User, Wishlist
|
|
||||||
from hxbooks.util import flatten_form_data
|
|
||||||
|
|
||||||
bp = Blueprint("books", __name__, url_prefix="/books")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.before_request
|
|
||||||
@login_required
|
|
||||||
def before_request():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
FTS_FIELDS = ["title", "description", "notes", "genres", "authors", "publisher"]
|
|
||||||
|
|
||||||
ResultColumn = Literal[
|
|
||||||
"title",
|
|
||||||
"first_published",
|
|
||||||
"edition",
|
|
||||||
"added",
|
|
||||||
"description",
|
|
||||||
"notes",
|
|
||||||
"isbn",
|
|
||||||
"authors",
|
|
||||||
"genres",
|
|
||||||
"publisher",
|
|
||||||
"owner",
|
|
||||||
"bought",
|
|
||||||
"location",
|
|
||||||
"loaned_to",
|
|
||||||
"loaned_from",
|
|
||||||
"wishlisted",
|
|
||||||
"read",
|
|
||||||
"reading",
|
|
||||||
"dropped",
|
|
||||||
"started_reading",
|
|
||||||
"finished_reading",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class SearchRequestSchema(BaseModel, extra="forbid"):
|
|
||||||
q: str = ""
|
|
||||||
wishlisted: bool | None = None
|
|
||||||
read: bool | None = None
|
|
||||||
reading: bool | None = None
|
|
||||||
dropped: bool | None = None
|
|
||||||
bought_start: date | None = None
|
|
||||||
bought_end: date | None = None
|
|
||||||
started_reading_start: date | None = None
|
|
||||||
started_reading_end: date | None = None
|
|
||||||
finished_reading_start: date | None = None
|
|
||||||
finished_reading_end: date | None = None
|
|
||||||
sort_by: ResultColumn = "title"
|
|
||||||
sort_order: Literal["asc", "desc"] = "asc"
|
|
||||||
saved_search: str | None = None
|
|
||||||
|
|
||||||
@field_validator(
|
|
||||||
"wishlisted",
|
|
||||||
"read",
|
|
||||||
"reading",
|
|
||||||
"dropped",
|
|
||||||
"bought_start",
|
|
||||||
"bought_end",
|
|
||||||
"started_reading_start",
|
|
||||||
"started_reading_end",
|
|
||||||
"finished_reading_start",
|
|
||||||
"finished_reading_end",
|
|
||||||
mode="before",
|
|
||||||
)
|
|
||||||
@classmethod
|
|
||||||
def coerce_empty_to_none(cls, v: Any) -> Any:
|
|
||||||
if v == "":
|
|
||||||
return None
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class BookResultSchema(BaseModel):
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
authors: list[str]
|
|
||||||
genres: list[str]
|
|
||||||
publisher: str
|
|
||||||
first_published: int | None
|
|
||||||
edition: str
|
|
||||||
added: datetime
|
|
||||||
description: str
|
|
||||||
notes: str
|
|
||||||
isbn: str
|
|
||||||
owner: str | None
|
|
||||||
bought: date
|
|
||||||
location: str
|
|
||||||
loaned_to: str
|
|
||||||
loaned_from: str
|
|
||||||
wishlisted: bool
|
|
||||||
read: bool
|
|
||||||
reading: bool
|
|
||||||
dropped: bool
|
|
||||||
started_reading: date | None
|
|
||||||
finished_reading: date | None
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("", methods=["GET"])
|
|
||||||
def books():
|
|
||||||
if len(request.args) == 0:
|
|
||||||
search_req = SearchRequestSchema(q="")
|
|
||||||
else:
|
|
||||||
args_data = flatten_form_data(request.args)
|
|
||||||
search_req = SearchRequestSchema.model_validate(args_data)
|
|
||||||
|
|
||||||
saved_searches = get_saved_searches(g.user)
|
|
||||||
if search_req.saved_search is not None:
|
|
||||||
search_req = saved_searches.get(search_req.saved_search, search_req)
|
|
||||||
|
|
||||||
query = build_query_from_req(search_req)
|
|
||||||
|
|
||||||
books = [
|
|
||||||
BookResultSchema.model_validate(book._mapping)
|
|
||||||
for book in db.session.execute(query).all()
|
|
||||||
]
|
|
||||||
|
|
||||||
if htmx.target == "search-results":
|
|
||||||
return render_block(
|
|
||||||
"books/index.html.j2",
|
|
||||||
"search_results",
|
|
||||||
books=books,
|
|
||||||
search_req=search_req,
|
|
||||||
)
|
|
||||||
return render_template(
|
|
||||||
"books/index.html.j2",
|
|
||||||
books=books,
|
|
||||||
search_req=search_req,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/new", methods=["GET"])
|
|
||||||
def books_new() -> Response:
|
|
||||||
book = Book(owner_id=g.user.id)
|
|
||||||
db.session.add(book)
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for(".book", id=book.id), 303)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/import", methods=["POST"])
|
|
||||||
def books_import() -> Response:
|
|
||||||
isbn = request.form.get("isbn")
|
|
||||||
if not isbn:
|
|
||||||
abort(400)
|
|
||||||
try:
|
|
||||||
book_data = fetch_google_book_data(isbn)
|
|
||||||
except ValueError as e:
|
|
||||||
abort(400, str(e))
|
|
||||||
except requests.RequestException:
|
|
||||||
abort(500, "Error fetching book data")
|
|
||||||
|
|
||||||
book = Book(
|
|
||||||
owner_id=g.user.id,
|
|
||||||
title=book_data.title,
|
|
||||||
description=book_data.description,
|
|
||||||
isbn=isbn,
|
|
||||||
authors=book_data.authors,
|
|
||||||
publisher=book_data.publisher,
|
|
||||||
first_published=(
|
|
||||||
book_data.publishedDate.year
|
|
||||||
if isinstance(book_data.publishedDate, date)
|
|
||||||
else book_data.publishedDate
|
|
||||||
),
|
|
||||||
genres=book_data.categories,
|
|
||||||
)
|
|
||||||
db.session.add(book)
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for(".book", id=book.id), 303)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/saved_search", methods=["POST", "DELETE"])
|
|
||||||
def saved_search():
|
|
||||||
form_data = flatten_form_data(request.form)
|
|
||||||
search_req = SearchRequestSchema.model_validate(form_data)
|
|
||||||
if request.method == "DELETE":
|
|
||||||
name_req = htmx.prompt or search_req.saved_search
|
|
||||||
if name_req is None:
|
|
||||||
abort(400)
|
|
||||||
saved_searches = {
|
|
||||||
name: search
|
|
||||||
for name, search in g.user.saved_searches.items()
|
|
||||||
if name.lower() != name_req.lower()
|
|
||||||
}
|
|
||||||
g.user.saved_searches = saved_searches
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for(".books"), 303)
|
|
||||||
|
|
||||||
if (name := htmx.prompt) is None or name == "":
|
|
||||||
abort(400)
|
|
||||||
|
|
||||||
saved_searches = {name: search for name, search in g.user.saved_searches.items()}
|
|
||||||
saved_searches[name] = search_req.model_dump()
|
|
||||||
g.user.saved_searches = saved_searches
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for(".books", saved_search=name), 303)
|
|
||||||
|
|
||||||
|
|
||||||
def book_results_subquery(user: User) -> Subquery:
|
|
||||||
# Subqueries to check if a book is wishlisted, read, reading, or dropped by the user
|
|
||||||
wishlisted = (
|
|
||||||
exists()
|
|
||||||
.where(Wishlist.user_id == user.id, Book.id == Wishlist.book_id)
|
|
||||||
.correlate(Book)
|
|
||||||
)
|
|
||||||
read = (
|
|
||||||
exists()
|
|
||||||
.where(
|
|
||||||
Reading.user_id == user.id,
|
|
||||||
Book.id == Reading.book_id,
|
|
||||||
Reading.finished,
|
|
||||||
)
|
|
||||||
.correlate(Book)
|
|
||||||
)
|
|
||||||
reading = (
|
|
||||||
exists()
|
|
||||||
.where(
|
|
||||||
Reading.user_id == user.id,
|
|
||||||
Book.id == Reading.book_id,
|
|
||||||
~Reading.finished,
|
|
||||||
~Reading.dropped,
|
|
||||||
)
|
|
||||||
.correlate(Book)
|
|
||||||
)
|
|
||||||
dropped = (
|
|
||||||
exists()
|
|
||||||
.where(
|
|
||||||
Reading.user_id == user.id,
|
|
||||||
Book.id == Reading.book_id,
|
|
||||||
Reading.dropped,
|
|
||||||
)
|
|
||||||
.correlate(Book)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get the maximum id for each book_id and user_id combination in Reading
|
|
||||||
max_reading_id_subquery = (
|
|
||||||
select(Reading.book_id, Reading.user_id, func.max(Reading.id).label("max_id"))
|
|
||||||
.where(Reading.user_id == user.id)
|
|
||||||
.group_by(Reading.book_id, Reading.user_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
# Get the last reading for each book_id and user_id combination
|
|
||||||
last_readings = (
|
|
||||||
select(Reading)
|
|
||||||
.join(max_reading_id_subquery, Reading.id == max_reading_id_subquery.c.max_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Join Book to User to obtain the owner's username and join with the subqueries.
|
|
||||||
# The join with last_readings is an outer join to allow for books that have not been
|
|
||||||
# read
|
|
||||||
query = (
|
|
||||||
select(
|
|
||||||
Book,
|
|
||||||
User.username.label("owner"),
|
|
||||||
wishlisted.label("wishlisted"),
|
|
||||||
read.label("read"),
|
|
||||||
reading.label("reading"),
|
|
||||||
dropped.label("dropped"),
|
|
||||||
last_readings.c.start_date.label("started_reading"),
|
|
||||||
last_readings.c.end_date.label("finished_reading"),
|
|
||||||
)
|
|
||||||
.join_from(Book, User, isouter=True)
|
|
||||||
.outerjoin(
|
|
||||||
last_readings,
|
|
||||||
(Book.id == last_readings.c.book_id)
|
|
||||||
and (last_readings.c.user_id == user.id),
|
|
||||||
)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
return query
|
|
||||||
|
|
||||||
|
|
||||||
def build_query_from_req(search_req: SearchRequestSchema) -> Select:
|
|
||||||
subq = book_results_subquery(g.user)
|
|
||||||
query = build_query_from_str(subq, search_req.q)
|
|
||||||
|
|
||||||
# date filters
|
|
||||||
if search_req.bought_start is not None:
|
|
||||||
query = query.where(subq.c.bought >= search_req.bought_start)
|
|
||||||
if search_req.bought_end is not None:
|
|
||||||
query = query.where(subq.c.bought <= search_req.bought_end)
|
|
||||||
if search_req.started_reading_start is not None:
|
|
||||||
query = query.where(subq.c.started_reading >= search_req.started_reading_start)
|
|
||||||
if search_req.started_reading_end is not None:
|
|
||||||
query = query.where(subq.c.started_reading <= search_req.started_reading_end)
|
|
||||||
if search_req.finished_reading_start is not None:
|
|
||||||
query = query.where(
|
|
||||||
subq.c.finished_reading >= search_req.finished_reading_start
|
|
||||||
)
|
|
||||||
if search_req.finished_reading_end is not None:
|
|
||||||
query = query.where(subq.c.finished_reading <= search_req.finished_reading_end)
|
|
||||||
|
|
||||||
# boolean filters
|
|
||||||
if search_req.wishlisted is not None:
|
|
||||||
query = query.where(subq.c.wishlisted == search_req.wishlisted)
|
|
||||||
if search_req.read is not None:
|
|
||||||
query = query.where(subq.c.read == search_req.read)
|
|
||||||
if search_req.reading is not None:
|
|
||||||
query = query.where(subq.c.reading == search_req.reading)
|
|
||||||
if search_req.dropped is not None:
|
|
||||||
query = query.where(subq.c.dropped == search_req.dropped)
|
|
||||||
|
|
||||||
# sorting
|
|
||||||
query = query.order_by(
|
|
||||||
getattr(getattr(subq.c, search_req.sort_by), search_req.sort_order)()
|
|
||||||
)
|
|
||||||
|
|
||||||
return query
|
|
||||||
|
|
||||||
|
|
||||||
# regex to split query string by spaces, but not inside quotes
|
|
||||||
SPLIT_REGEX = re.compile(r"([^\s\"]+)|\"([^\"]*)\"")
|
|
||||||
|
|
||||||
|
|
||||||
def build_query_from_str(subq: Subquery, query_str: str) -> Select:
|
|
||||||
query = select(subq)
|
|
||||||
while match := re.search(SPLIT_REGEX, query_str):
|
|
||||||
query_str = query_str[match.end() :]
|
|
||||||
token = match.group(1) or match.group(2)
|
|
||||||
if ":" in token:
|
|
||||||
field_name, value = token.split(":")
|
|
||||||
field: Column = getattr(subq.c, field_name)
|
|
||||||
schema_field = BookResultSchema.model_fields[field_name]
|
|
||||||
if schema_field.annotation == str:
|
|
||||||
query = query.where(field.contains(value))
|
|
||||||
elif schema_field.annotation == list[str]:
|
|
||||||
fn = func.json_each(field).table_valued("value")
|
|
||||||
query = query.where(fn.c.value.contains(value))
|
|
||||||
elif schema_field.annotation == Optional[int]:
|
|
||||||
query = query.where(field == int(value))
|
|
||||||
else:
|
|
||||||
query = query.where(
|
|
||||||
or_(*(getattr(subq.c, field).contains(token) for field in FTS_FIELDS))
|
|
||||||
)
|
|
||||||
|
|
||||||
return query
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_default_searches(username: str) -> dict[str, SearchRequestSchema]:
|
|
||||||
return {
|
|
||||||
"all": SearchRequestSchema(),
|
|
||||||
"owned": SearchRequestSchema(q=f"owner:{username}"),
|
|
||||||
"wishlisted": SearchRequestSchema(wishlisted=True),
|
|
||||||
"read": SearchRequestSchema(read=True),
|
|
||||||
"reading": SearchRequestSchema(reading=True),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_saved_searches(user: User) -> dict[str, SearchRequestSchema]:
|
|
||||||
searches = get_default_searches(user.username).copy()
|
|
||||||
searches.update({
|
|
||||||
name: SearchRequestSchema.model_validate(value)
|
|
||||||
for name, value in user.saved_searches.items()
|
|
||||||
})
|
|
||||||
for name, search in searches.items():
|
|
||||||
search.saved_search = name
|
|
||||||
return searches
|
|
||||||
|
|
||||||
|
|
||||||
class BookRequestSchema(BaseModel):
|
|
||||||
title: str = Field(min_length=1)
|
|
||||||
first_published: int | None = None
|
|
||||||
edition: str = ""
|
|
||||||
notes: str = ""
|
|
||||||
isbn: str = ""
|
|
||||||
authors: list[str] = []
|
|
||||||
genres: list[str] = []
|
|
||||||
publisher: str = ""
|
|
||||||
owner_id: int | None = None
|
|
||||||
bought: date = Field(default_factory=datetime.today)
|
|
||||||
location: str = "billy salon"
|
|
||||||
loaned_to: str = ""
|
|
||||||
loaned_from: str = ""
|
|
||||||
wishlisted: bool = False
|
|
||||||
|
|
||||||
@field_validator("first_published", "owner_id", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def coerce_empty_to_none(cls, v: Any) -> Any:
|
|
||||||
if v == "":
|
|
||||||
return None
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:id>", methods=["GET", "PUT", "POST", "DELETE"])
|
|
||||||
def book(id: int) -> str | Response:
|
|
||||||
book = db.session.execute(select(Book).filter(Book.id == id)).scalar_one_or_none()
|
|
||||||
if book is None:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if request.method == "DELETE":
|
|
||||||
db.session.delete(book)
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for(".books"), 303)
|
|
||||||
|
|
||||||
errors = {}
|
|
||||||
if request.method in ("PUT", "POST"):
|
|
||||||
try:
|
|
||||||
book_req = BookRequestSchema.model_validate(flatten_form_data(request.form))
|
|
||||||
except ValidationError as e:
|
|
||||||
errors = {err["loc"][0]: err["msg"] for err in e.errors()}
|
|
||||||
else:
|
|
||||||
form_data = book_req.model_dump()
|
|
||||||
wishlisted = form_data.pop("wishlisted")
|
|
||||||
|
|
||||||
for key, value in book_req.model_dump().items():
|
|
||||||
setattr(book, key, value)
|
|
||||||
|
|
||||||
wishlist = db.session.execute(
|
|
||||||
select(Wishlist).filter(
|
|
||||||
Wishlist.user_id == g.user.id, Wishlist.book_id == id
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if wishlist is None and wishlisted:
|
|
||||||
wishlist = Wishlist(user_id=g.user.id, book_id=book.id)
|
|
||||||
db.session.add(wishlist)
|
|
||||||
elif wishlist is not None and not wishlisted:
|
|
||||||
db.session.delete(wishlist)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
if request.method == "POST":
|
|
||||||
return redirect(url_for(".books"), 303)
|
|
||||||
|
|
||||||
template_args = {
|
|
||||||
"book": book,
|
|
||||||
"users": db.session.execute(select(User)).scalars().all(),
|
|
||||||
"genres": get_distinct_json_list_values(Book.genres),
|
|
||||||
"authors": get_distinct_json_list_values(Book.authors),
|
|
||||||
"locations": db.session
|
|
||||||
.execute(select(Book.location).distinct())
|
|
||||||
.scalars()
|
|
||||||
.all(),
|
|
||||||
"wished_by": [wishlist.user.username for wishlist in book.wished_by],
|
|
||||||
"errors": errors,
|
|
||||||
}
|
|
||||||
if request.method == "PUT":
|
|
||||||
return render_block("books/book.html.j2", "form", **template_args)
|
|
||||||
return render_template("books/book.html.j2", **template_args)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:id>/readings/new", methods=["POST"])
|
|
||||||
def readings_new(id: int) -> str:
|
|
||||||
book = db.session.execute(select(Book).filter(Book.id == id)).scalar_one_or_none()
|
|
||||||
if book is None:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
reading = Reading(book_id=book.id, user_id=g.user.id)
|
|
||||||
db.session.add(reading)
|
|
||||||
db.session.commit()
|
|
||||||
return render_block("books/book.html.j2", "reading_row", reading=reading)
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingRequestSchema(BaseModel):
|
|
||||||
start_date: date = Field(default_factory=datetime.today)
|
|
||||||
end_date: date | None = None
|
|
||||||
finished: bool = False
|
|
||||||
dropped: bool = False
|
|
||||||
rating: int | None = None
|
|
||||||
comments: str = ""
|
|
||||||
user_id: int
|
|
||||||
book_id: int
|
|
||||||
|
|
||||||
@field_validator("end_date", "rating", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def coerce_empty_to_none(cls, v: Any) -> Any:
|
|
||||||
if v == "":
|
|
||||||
return None
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route(
|
|
||||||
"/<int:book_id>/readings/<int:reading_id>", methods=["PUT", "PATCH", "DELETE"]
|
|
||||||
)
|
|
||||||
def reading(book_id: int, reading_id: int) -> str:
|
|
||||||
reading = db.session.execute(
|
|
||||||
select(Reading).filter(Reading.id == reading_id)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if reading is None:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if request.method == "DELETE":
|
|
||||||
db.session.delete(reading)
|
|
||||||
db.session.commit()
|
|
||||||
return ""
|
|
||||||
else:
|
|
||||||
form_data = flatten_form_data(request.form)
|
|
||||||
form_data["book_id"] = str(book_id)
|
|
||||||
form_data["user_id"] = str(g.user.id)
|
|
||||||
try:
|
|
||||||
reading_req = ReadingRequestSchema.model_validate(form_data)
|
|
||||||
except ValidationError as e:
|
|
||||||
errors = {err["loc"][0]: err["msg"] for err in e.errors()}
|
|
||||||
else:
|
|
||||||
errors = {}
|
|
||||||
if reading_req.rating is not None:
|
|
||||||
reading_req.rating = min(max(reading_req.rating, 1), 5)
|
|
||||||
if reading_req.end_date is None and (
|
|
||||||
reading_req.finished or reading_req.dropped
|
|
||||||
):
|
|
||||||
reading_req.end_date = datetime.today()
|
|
||||||
for key, value in reading_req.model_dump().items():
|
|
||||||
setattr(reading, key, value)
|
|
||||||
db.session.commit()
|
|
||||||
finally:
|
|
||||||
return render_block(
|
|
||||||
"books/book.html.j2",
|
|
||||||
"reading_row",
|
|
||||||
reading=reading,
|
|
||||||
edit_reading_id=reading_id,
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<int:book_id>/readings/<int:reading_id>/edit", methods=["GET"])
|
|
||||||
def reading_edit(book_id: int, reading_id: int) -> str:
|
|
||||||
reading = db.session.execute(
|
|
||||||
select(Reading).filter(Reading.id == reading_id)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if reading is None:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if reading.user_id != g.user.id:
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
return render_block(
|
|
||||||
"books/book.html.j2",
|
|
||||||
"reading_row",
|
|
||||||
reading=reading,
|
|
||||||
edit_reading_id=reading_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_distinct_json_list_values(column: InstrumentedAttribute) -> list[str]:
|
|
||||||
stmt = (
|
|
||||||
select(func.json_each(column).table_valued("value"))
|
|
||||||
.select_from(column.parent)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
values = cast(list[str], db.session.execute(stmt).scalars().all())
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
@bp.context_processor
|
|
||||||
def inject_aux_functions():
|
|
||||||
return {
|
|
||||||
"all_columns": get_args(ResultColumn),
|
|
||||||
"get_default_searches": get_default_searches,
|
|
||||||
}
|
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from flask_htmx import HTMX # type: ignore
|
|
||||||
|
|
||||||
htmx = HTMX()
|
|
||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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 typing import assert_never
|
from typing import assert_never
|
||||||
@@ -276,9 +277,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 +337,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(
|
||||||
@@ -872,3 +884,29 @@ def get_user_by_username(username: str) -> User | None:
|
|||||||
def list_users() -> Sequence[User]:
|
def list_users() -> Sequence[User]:
|
||||||
"""List all users."""
|
"""List all users."""
|
||||||
return db.session.execute(select(User).order_by(User.username)).scalars().all()
|
return db.session.execute(select(User).order_by(User.username)).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
def list_genres() -> Sequence[Genre]:
|
||||||
|
"""List all genres."""
|
||||||
|
return db.session.execute(select(Genre).order_by(Genre.name)).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
def list_authors() -> Sequence[Author]:
|
||||||
|
"""List all authors."""
|
||||||
|
return db.session.execute(select(Author).order_by(Author.name)).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
def list_locations() -> dict[str, list[str]]:
|
||||||
|
"""List all unique locations."""
|
||||||
|
result = db.session.execute(
|
||||||
|
select(
|
||||||
|
Book.location_place,
|
||||||
|
Book.location_bookshelf,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.order_by(Book.location_place, Book.location_bookshelf)
|
||||||
|
).all()
|
||||||
|
ret = defaultdict(list)
|
||||||
|
for location_place, location_bookshelf in result:
|
||||||
|
ret[location_place].append(location_bookshelf)
|
||||||
|
return ret
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ Provides clean URL structure and integrates with library.py business logic.
|
|||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
|
Response,
|
||||||
flash,
|
flash,
|
||||||
g,
|
g,
|
||||||
redirect,
|
redirect,
|
||||||
@@ -29,6 +30,8 @@ from pydantic import (
|
|||||||
from pydantic_extra_types.isbn import ISBN
|
from pydantic_extra_types.isbn import ISBN
|
||||||
|
|
||||||
from hxbooks.models import Reading, User
|
from hxbooks.models import Reading, User
|
||||||
|
from hxbooks.search import Field as SearchField
|
||||||
|
from hxbooks.search import IsOperatorValue, SortDirection
|
||||||
|
|
||||||
from . import library
|
from . import library
|
||||||
from .db import db
|
from .db import db
|
||||||
@@ -39,7 +42,7 @@ bp = Blueprint("main", __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)]
|
||||||
ISBNOrNone = Annotated[ISBN | None, BeforeValidator(lambda v: v.strip() or None)]
|
ISBNOrEmpty = Annotated[ISBN | Literal[""], BeforeValidator(lambda v: v.strip() or "")]
|
||||||
TextareaList = Annotated[
|
TextareaList = Annotated[
|
||||||
list[str],
|
list[str],
|
||||||
BeforeValidator(
|
BeforeValidator(
|
||||||
@@ -57,7 +60,7 @@ IntOrNone = Annotated[int | 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: ISBNOrNone = None
|
isbn: ISBNOrEmpty = ""
|
||||||
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)
|
||||||
@@ -107,6 +110,14 @@ def load_users() -> None:
|
|||||||
g.saved_searches = g.viewing_user.saved_searches or {}
|
g.saved_searches = g.viewing_user.saved_searches or {}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.after_request
|
||||||
|
def add_header(response: ResponseReturnValue) -> ResponseReturnValue:
|
||||||
|
# response.cache_control.no_store = True
|
||||||
|
if isinstance(response, Response) and "Cache-Control" not in response.headers:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# Template context processor to make users and searches available in templates
|
# Template context processor to make users and searches available in templates
|
||||||
@bp.app_context_processor
|
@bp.app_context_processor
|
||||||
def inject_template_vars() -> dict[str, Any]:
|
def inject_template_vars() -> dict[str, Any]:
|
||||||
@@ -116,26 +127,80 @@ def inject_template_vars() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RESULTS_PER_PAGE = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _search_suggestions() -> dict[str, list[str]]:
|
||||||
|
"""Get suggestions for search autocomplete."""
|
||||||
|
s: dict[SearchField, list[str]] = {f: [] for f in SearchField}
|
||||||
|
s[SearchField.IS] = [
|
||||||
|
str(v) for v in IsOperatorValue if v != IsOperatorValue.UNKNOWN
|
||||||
|
]
|
||||||
|
s[SearchField.SORT] = [
|
||||||
|
f"{f}-{d}"
|
||||||
|
for f in SearchField
|
||||||
|
if f not in {SearchField.IS, SearchField.SORT}
|
||||||
|
for d in SortDirection
|
||||||
|
]
|
||||||
|
s[SearchField.GENRE] = [g.name for g in library.list_genres()]
|
||||||
|
s[SearchField.AUTHOR] = [a.name for a in library.list_authors()]
|
||||||
|
s[SearchField.PLACE] = [p for p in library.list_locations().keys()]
|
||||||
|
s[SearchField.BOOKSHELF] = list([
|
||||||
|
bs for shelves in library.list_locations().values() for bs in shelves
|
||||||
|
])
|
||||||
|
s[SearchField.OWNER] = [u.username for u in library.list_users()]
|
||||||
|
return {
|
||||||
|
str(k): v for k, v in s.items()
|
||||||
|
} # Convert Enum keys to strings for template use
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
|
},
|
||||||
|
search_suggestions=_search_suggestions(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/book/<int:book_id>")
|
@bp.route("/book/<int:book_id>")
|
||||||
@@ -146,7 +211,13 @@ def book_detail(book_id: int) -> ResponseReturnValue:
|
|||||||
flash("Book not found", "error")
|
flash("Book not found", "error")
|
||||||
return redirect(url_for("main.index"))
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
return render_template("book/detail.html.j2", book=book)
|
return render_template(
|
||||||
|
"book/detail.html.j2",
|
||||||
|
book=book,
|
||||||
|
genres=library.list_genres(),
|
||||||
|
authors=library.list_authors(),
|
||||||
|
locations=library.list_locations(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_user(username: str) -> int:
|
def _get_or_create_user(username: str) -> int:
|
||||||
@@ -176,7 +247,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) if form_data.isbn else None,
|
isbn=str(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,
|
||||||
@@ -227,7 +298,7 @@ def update_book(book_id: int) -> 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) if form_data.isbn else None,
|
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,
|
||||||
|
|||||||
@@ -45,6 +45,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flash Messages Floating */
|
||||||
|
.flash-messages-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1060;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message {
|
||||||
|
pointer-events: auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-dismiss animation for non-error messages */
|
||||||
|
.flash-message:not(.alert-danger) {
|
||||||
|
animation:
|
||||||
|
flashFadeOut 1s ease-in-out 5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flashFadeOut {
|
||||||
|
0% {}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -224,3 +258,8 @@
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* awesomplete Custom Styles */
|
||||||
|
.awesomplete {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{% extends "base.html.j2" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h1>{% block title %}Login{% endblock title %}</h1>
|
|
||||||
{% endblock header %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% from "error_feedback.html.j2" import validation_error %}
|
|
||||||
<form hx-trigger="change" hx-post="/auth/login" method="post">
|
|
||||||
<div class="row pb-2">
|
|
||||||
<label class="col form-label col-form-label" for="username">Username:</label>
|
|
||||||
<div class="col">
|
|
||||||
<select class="form-select" aria-describedby="username-error" id="username" name="username"
|
|
||||||
placeholder="Username" autocomplete="off">
|
|
||||||
<option value=""></option>
|
|
||||||
{% for user in users %}
|
|
||||||
<option value="{{ user.username }}">{{ user.username }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{validation_error("username", errors)}}
|
|
||||||
|
|
||||||
<input class="col btn btn-primary" type="submit" value="Login">
|
|
||||||
|
|
||||||
</form>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{% extends "base.html.j2" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h1>{% block title %}Register new user{% endblock title %}</h1>
|
|
||||||
{% endblock header %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% from "error_feedback.html.j2" import validation_error %}
|
|
||||||
|
|
||||||
<form hx-post="/auth/register" method="post">
|
|
||||||
|
|
||||||
<div class="row pb-2">
|
|
||||||
<label class="col form-label col-form-label" for="username">Username:</label>
|
|
||||||
<div class="col">
|
|
||||||
<input class="form-control" type="text" id="username" name="username"
|
|
||||||
value="{{ request.form.get('username', '') }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{validation_error('username', errors)}}
|
|
||||||
|
|
||||||
<div class="col">
|
|
||||||
<input class="btn btn-primary" type="submit" value="Register">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en"
|
|
||||||
x-init="$el.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{% block title %}{% endblock %} - hxbooks</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static',
|
|
||||||
filename='favicon-32x32.png') }}">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static',
|
|
||||||
filename='favicon-16x16.png') }}">
|
|
||||||
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='htmx.min.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='alpine.min.js') }}" defer></script>
|
|
||||||
<script src="{{ url_for('static', filename='tom-select.complete.min.js') }}"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
htmx.on('htmx:beforeHistorySave', function () {
|
|
||||||
// find all TomSelect elements
|
|
||||||
document.querySelectorAll('.tomselect')
|
|
||||||
.forEach(elt => elt.tomselect ? elt.tomselect.destroy() : null) // and call destroy() on them
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('htmx:beforeSwap', function (evt) {
|
|
||||||
// alert on errors
|
|
||||||
if (evt.detail.xhr.status >= 400) {
|
|
||||||
error_dialog = document.querySelector('#error-alert');
|
|
||||||
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
|
|
||||||
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
|
|
||||||
error_dialog.showModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body hx-boost="true" hx-push-url="true" hx-target="body">
|
|
||||||
<header class="container-sm">
|
|
||||||
<nav class="navbar navbar-expand-sm bg-primary">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="{{ url_for('books.books') }}">
|
|
||||||
<img src="{{ url_for('static', filename='favicon-32x32.png') }}" alt="hxbooks" width="32" height="32"> hxBooks
|
|
||||||
</a>
|
|
||||||
<ul class="navbar-nav ms-auto">
|
|
||||||
{% if g.user %}
|
|
||||||
<li><span class="navbar-item pe-2">{{ g.user.username.title() }}</span></li>
|
|
||||||
<li><a class="navbar-item" href="{{ url_for('auth.logout') }}">Log Out</a></li>
|
|
||||||
{% else %}
|
|
||||||
<li><a class="navbar-item pe-2" href="{{ url_for('auth.register') }}">Register</a></li>
|
|
||||||
<li><a class="navbar-item" href="{{ url_for('auth.login') }}">Log In</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main class="content container-sm">
|
|
||||||
<header class="row">
|
|
||||||
{% block header %}{% endblock %}
|
|
||||||
</header>
|
|
||||||
{% for message in get_flashed_messages() %}
|
|
||||||
<div class="flash row">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
|
|
||||||
<dialog id="error-alert">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"></h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary"
|
|
||||||
onClick="document.querySelector('#error-alert').close()">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
{% extends "base.html.j2" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h1>{% block title %}Edit book{% endblock title %}</h1>
|
|
||||||
{% endblock header %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{% block form %}
|
|
||||||
<form class="row row-cols-1 row-cols-xxl-2" hx-put="/books/{{ book.id }}" hx-trigger="change" hx-push-url="false"
|
|
||||||
hx-swap="none" method="post">
|
|
||||||
{% from "error_feedback.html.j2" import validation_error %}
|
|
||||||
{% macro simple_field(field, name, value, type) %}
|
|
||||||
<label class="form-label" for="{{ field }}">{{ name }}:</label>
|
|
||||||
{% if type == "textarea" %}
|
|
||||||
<textarea class="form-control" id="{{ field }}" name="{{ field }}" rows="5">{{ value }}</textarea>
|
|
||||||
{% else %}
|
|
||||||
<input class="form-control" aria-describedby="{{ field }}-error" type="{{ type }}" id="{{ field }}"
|
|
||||||
name="{{ field }}" value="{{ value }}">
|
|
||||||
{% endif %}
|
|
||||||
{{ validation_error(field, errors) }}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
<div class="col">
|
|
||||||
{{ simple_field("title", "Title", book.title, "text") }}
|
|
||||||
<label class="form-label" for="authors">Authors:</label>
|
|
||||||
<select class="form-select tomselect" id="authors" aria-describedby="authors-error" name="authors[]"
|
|
||||||
multiple placeholder="Add new authors" autocomplete="off"
|
|
||||||
x-init="if (!$el.tomselect) new TomSelect($el, {create: true, persist: false, plugins: ['remove_button']})">
|
|
||||||
{% for author in authors %}
|
|
||||||
<option value="{{ author }}" {% if author in book.authors %} selected {% endif %}>{{ author }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{{ validation_error("authors", errors) }}
|
|
||||||
|
|
||||||
<label class="form-label" for="genres">Genres:</label>
|
|
||||||
<select class="form-select tomselect" id="genres" aria-describedby="genres-error" name="genres[]" multiple
|
|
||||||
placeholder="Add new genres" autocomplete="off"
|
|
||||||
x-init="if (!$el.tomselect) new TomSelect($el, {create: true, persist: false, plugins: ['remove_button']})">
|
|
||||||
{% for genre in genres %}
|
|
||||||
<option value="{{ genre }}" {% if genre in book.genres %} selected {% endif %}>{{ genre }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{{ validation_error("genres", errors) }}
|
|
||||||
|
|
||||||
<label class="form-label" for="owner_id">Owner:</label>
|
|
||||||
<select class="form-select" id="owner_id" name="owner_id">
|
|
||||||
<option value="" {% if book.owner_id is none %}selected{% endif %}></option>
|
|
||||||
{% for user in users %}
|
|
||||||
<option value="{{ user.id }}" {% if user.id==book.owner_id %}selected{% endif %}>{{
|
|
||||||
user.username.title() }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{{ simple_field("bought", "Bought", book.bought.strftime("%Y-%m-%d") if book.bought else "", "date")
|
|
||||||
}}
|
|
||||||
|
|
||||||
<label class="form-label" for="location">Location:</label>
|
|
||||||
<select class="form-select tomselect" id="location" aria-describedby="location-error" name="location"
|
|
||||||
placeholder="New location" autocomplete="off"
|
|
||||||
x-init="if (!$el.tomselect) new TomSelect($el, {create: true, persist: false})">
|
|
||||||
<option value=""></option>
|
|
||||||
{% for location in locations %}
|
|
||||||
<option value="{{ location }}" {% if location==book.location %} selected {% endif %}>{{ location
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{{ validation_error("location", errors) }}
|
|
||||||
|
|
||||||
<div class="py-2 d-flex align-items-center">
|
|
||||||
<label class="btn btn-secondary" for="wishlisted">Wishlisted</label>
|
|
||||||
<input class="btn-check" hx-put="/books/{{ book.id }}" hx-trigger="change consume"
|
|
||||||
hx-target="#wished_by" hx-swap="outerHTML" hx-select="#wished_by" type="checkbox" id="wishlisted"
|
|
||||||
name="wishlisted" {% if g.user.username in wished_by %}checked{% endif %} autocomplete="off">
|
|
||||||
|
|
||||||
<div id="wished_by" class="ps-2">Wished by: {{ wished_by|join(", ") }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ simple_field("description", "Description", book.description, "textarea") }}
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
{{ simple_field("publisher", "Publisher", book.publisher, "text") }}
|
|
||||||
{{ simple_field("first_published", "First Published", book.first_published or "", "text") }}
|
|
||||||
{{ simple_field("edition", "Edition", book.edition, "text") }}
|
|
||||||
{{ simple_field("isbn", "ISBN", book.isbn, "text") }}
|
|
||||||
{{ simple_field("loaned_to", "Loaned To", book.loaned_to, "text") }}
|
|
||||||
{{ simple_field("loaned_from", "Loaned From", book.loaned_from, "text") }}
|
|
||||||
{{ simple_field("notes", "Notes", book.notes, "textarea") }}
|
|
||||||
|
|
||||||
<div class="pt-2">
|
|
||||||
<input class="btn btn-primary" type="submit" hx-post="/books/{{ book.id }}" hx-target="body"
|
|
||||||
hx-swap="innerHTML" hx-push-url="true" value="Submit">
|
|
||||||
<button class="btn btn-danger" hx-delete="/books/{{ book.id }}" hx-target="body" hx-swap="innerHTML"
|
|
||||||
hx-confirm="Are you sure?" hx-push-url="true">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock form %}
|
|
||||||
|
|
||||||
{# readings table #}
|
|
||||||
<section hx-boost="false" hx-push-url="false">
|
|
||||||
<h2 class="py-2">Readings</h2>
|
|
||||||
<button class="btn btn-primary mb-2" hx-post="/books/{{book.id}}/readings/new" hx-trigger="click"
|
|
||||||
hx-swap="beforeend" hx-target="#readings-tbody">Start reading</button>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped collapse-rows">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>User</th>
|
|
||||||
<th>Started</th>
|
|
||||||
<th>Finished</th>
|
|
||||||
<th>Rating</th>
|
|
||||||
<th>Finished?</th>
|
|
||||||
<th>Dropped?</th>
|
|
||||||
<th>Comments</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="readings-tbody">
|
|
||||||
{% for reading in book.readings %}
|
|
||||||
{% block reading_row scoped %}
|
|
||||||
{% if edit_reading_id != reading.id %}
|
|
||||||
<tr hx-get="/books/{{reading.book.id}}/readings/{{reading.id}}/edit" hx-target="this"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
<td data-label="User">{{ reading.user.username }}</td>
|
|
||||||
<td data-label="Started">{{ reading.start_date.strftime("%d-%m-%Y") }}</td>
|
|
||||||
<td data-label="Finished">{{ reading.end_date and reading.end_date.strftime("%d-%m-%Y") or "-"
|
|
||||||
}}</td>
|
|
||||||
<td data-label="Rating">{{ reading.rating or "-" }}</td>
|
|
||||||
<td data-label="Finished?">{{ reading.finished and "Yes" or "No" }}</td>
|
|
||||||
<td data-label="Dropped?">{{ reading.dropped and "Yes" or "No" }}</td>
|
|
||||||
<td data-label="Comments">{{ reading.comments or "-" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr hx-put="/books/{{reading.book.id}}/readings/{{reading.id}}" hx-trigger="change"
|
|
||||||
hx-swap="outerHTML" hx-include="this" hx-target="this">
|
|
||||||
<td data-label="User">{{ reading.user.username }}</td>
|
|
||||||
<td data-label="Started"><input class="form-control" type="date" id="reading-start_date"
|
|
||||||
name="start_date" value="{{ reading.start_date.strftime('%Y-%m-%d') }}"></td>
|
|
||||||
<td data-label="Finished"><input class="form-control" type="date" id="reading-end_date"
|
|
||||||
name="end_date"
|
|
||||||
value="{{ reading.end_date and reading.end_date.strftime('%Y-%m-%d') }}"></td>
|
|
||||||
<td data-label="Rating"><input class="form-control" type="number" id="reading-rating"
|
|
||||||
name="rating" value="{{ reading.rating }}">
|
|
||||||
</td>
|
|
||||||
<td data-label="Finished?"><input class="form-check-input" type="checkbox" id="reading-finished"
|
|
||||||
name="finished" {% if reading.finished %}checked{% endif %}></td>
|
|
||||||
<td data-label="Dropped?"><input class="form-check-input" type="checkbox" id="reading-dropped"
|
|
||||||
name="dropped" {% if reading.dropped %}checked{% endif %}></td>
|
|
||||||
<td data-label="Comments"><textarea rows="3" cols="20" class="form-control"
|
|
||||||
id="reading-comments" name="comments">{{ reading.comments }}</textarea></td>
|
|
||||||
<td data-label="Options">
|
|
||||||
<button class="btn btn-danger"
|
|
||||||
hx-delete="/books/{{reading.book.id}}/readings/{{reading.id}}"
|
|
||||||
hx-confirm="Are you sure?" hx-target="closest tr" hx-swap="outerHTML">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock reading_row %}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
{# booklist main view #}
|
|
||||||
{% extends "base.html.j2" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h1>{% block title %}Books{% endblock title %}</h1>
|
|
||||||
{% endblock header %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="pb-2">
|
|
||||||
<div class="list-group list-group-horizontal" style="overflow-x: auto">
|
|
||||||
<span class="list-group-item">Searches:</span>
|
|
||||||
{% for name in get_default_searches(g.user.username)|list + g.user.saved_searches|list %}
|
|
||||||
<a class="list-group-item list-group-item-action border-0 {{'active' if search_req.saved_search == name else ''}}"
|
|
||||||
style="width: auto" href="/books?saved_search={{ name|urlencode }}" hx-swap="innerHTML show:no-scroll">{{
|
|
||||||
name }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# search form and hidden table state #}
|
|
||||||
<form id="search-form" action="/books" method="get" hx-include="closest form"
|
|
||||||
x-data="{columns: (JSON.parse(localStorage.getItem('columns')) || ['title', 'authors', 'genres'])}"
|
|
||||||
x-init="$watch('columns', (val) => localStorage.setItem('columns', JSON.stringify(val)))">
|
|
||||||
|
|
||||||
<div class="input-group pb-2">
|
|
||||||
<input class="form-control" type="text" name="q" placeholder="Begin typing to search books" hx-get="/books"
|
|
||||||
hx-target="#search-results" hx-trigger="keyup changed delay:500ms, search" value="{{search_req['q']|e}}">
|
|
||||||
<button class="btn btn-primary" type="submit">Search</button>
|
|
||||||
<button class="btn btn-secondary" type="button" hx-post="/books/saved_search"
|
|
||||||
hx-prompt="Name your search">Save</button>
|
|
||||||
<button class="btn btn-danger" type="button" hx-delete="/books/saved_search"
|
|
||||||
hx-prompt="Which search would you like to delete?">Delete</button>
|
|
||||||
</div>
|
|
||||||
<div x-data="{ofilters: false, ocols: false}">
|
|
||||||
<div class="btn-toolbar pb-2 justify-content-between">
|
|
||||||
<div class="btn-group pe-2">
|
|
||||||
<button class="btn btn-primary border" type="button" hx-get="/books/new">New</button>
|
|
||||||
<button class="btn btn-primary border" type="button"
|
|
||||||
onclick="document.querySelector('#isbn-prompt').showModal()">Import</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-primary border" type="button" @click="ofilters = !ofilters">Filters</button>
|
|
||||||
<button class="btn btn-primary border" type="button" @click="ocols = !ocols">Columns</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div x-show="ofilters">
|
|
||||||
{% macro yes_no_filter(field, name) %}
|
|
||||||
<div class="col form-floating">
|
|
||||||
<select class="form-select" hx-get="/books" hx-target="#search-results" hx-trigger="change"
|
|
||||||
name="{{field}}">
|
|
||||||
<option value="1" {% if search_req[field]==true %}selected{% endif %}>Yes</option>
|
|
||||||
<option value="0" {% if search_req[field]==false %}selected{% endif %}>No</option>
|
|
||||||
<option value="" {% if search_req[field]==none %}selected{% endif %}></option>
|
|
||||||
</select>
|
|
||||||
<label class="ms-2" for="{{field}}">{{name}}</label>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
<fieldset class="row pb-2 align-items-center">
|
|
||||||
<legend class="col-auto">Filters:</legend>
|
|
||||||
<div class="col row row-cols-2 row-cols-md-4">
|
|
||||||
{{ yes_no_filter('wishlisted', 'Wishlisted') }}
|
|
||||||
{{ yes_no_filter('read', 'Read') }}
|
|
||||||
{{ yes_no_filter('reading', 'Reading') }}
|
|
||||||
{{ yes_no_filter('dropped', 'Dropped') }}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
{% macro date_filter(field, name) %}
|
|
||||||
<fieldset class="row pb-2 align-items-center">
|
|
||||||
<legend class="col-auto">{{name}}:</legend>
|
|
||||||
<div class="col row">
|
|
||||||
<div class="col">
|
|
||||||
<input class="form-control" type="date" name="{{field}}_start" hx-get="/books"
|
|
||||||
hx-target="#search-results" value="{{search_req[field + '_start']|e}}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="col-auto">—</span>
|
|
||||||
|
|
||||||
<div class="col">
|
|
||||||
<input class="form-control" type="date" name="{{field}}_end" hx-get="/books"
|
|
||||||
hx-target="#search-results" value="{{search_req[field + '_end']|e}}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
{% endmacro %}
|
|
||||||
{{ date_filter('bought', 'Bought') }}
|
|
||||||
{{ date_filter('started_reading', 'Started') }}
|
|
||||||
{{ date_filter('finished_reading', 'Finished') }}
|
|
||||||
</div>
|
|
||||||
<fieldset x-show="ocols">
|
|
||||||
{% for c in all_columns %}
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<label class="form-check-label">
|
|
||||||
<input class="form-check-input" type="checkbox" x-model="columns" value="{{c}}">{{c}}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="hidden" name="sort_by" value="{{search_req.sort_by}}">
|
|
||||||
<input type="hidden" name="sort_order" value="{{search_req.sort_order}}">
|
|
||||||
|
|
||||||
|
|
||||||
{# results table #}
|
|
||||||
<div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover collapse-rows">
|
|
||||||
<thead x-data="{col_sizes: (JSON.parse(localStorage.getItem('col_sizes')) || {})}"
|
|
||||||
x-init="$watch('col_sizes', (val) => localStorage.setItem('col_sizes', JSON.stringify(val)))">
|
|
||||||
<tr>
|
|
||||||
{% macro results_th(col) %}
|
|
||||||
<th x-show="columns.includes('{{col}}')">
|
|
||||||
<div x-bind:style="'resize: horizontal; overflow: auto; ' + (
|
|
||||||
col_sizes['{{col}}'] ? 'width: ' + col_sizes['{{col}}'] + 'px' : '')" x-init="new ResizeObserver(entries => {
|
|
||||||
for (let entry of entries) {
|
|
||||||
col_sizes['{{col}}'] = entry.contentRect.width;
|
|
||||||
}
|
|
||||||
}).observe($el);">
|
|
||||||
<span hx-get="/books" hx-vals='{
|
|
||||||
"sort_by": "{{col}}", "sort_order": "{{"asc" if
|
|
||||||
col != search_req.sort_by or (col == search_req.sort_by and
|
|
||||||
search_req.sort_order == "desc")
|
|
||||||
else "desc"}}"}'>{{ col|replace("_", " ")|capitalize }}{% if col ==
|
|
||||||
search_req.sort_by %}{{ "▴" if
|
|
||||||
search_req.sort_order == "asc" else "▾"}}{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
{% endmacro %}
|
|
||||||
{% for col in all_columns %}
|
|
||||||
{{ results_th(col) }}
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="search-results">
|
|
||||||
{% block search_results %}
|
|
||||||
{% for book in books %}
|
|
||||||
<tr hx-get="/books/{{book.id}}" hx-params="none">
|
|
||||||
{% macro results_td(col, text) %}
|
|
||||||
<td x-show="columns.includes('{{col}}')" data-label="{{col|replace('_', ' ')|capitalize}}">{{
|
|
||||||
text or '-' }}</td>
|
|
||||||
{% endmacro %}
|
|
||||||
{{ results_td('title', book.title) }}
|
|
||||||
{{ results_td('first_published', book.first_published) }}
|
|
||||||
{{ results_td('edition', book.edition) }}
|
|
||||||
{{ results_td('added', book.added.strftime("%d/%m/%Y")) }}
|
|
||||||
{{ results_td('description', book.description) }}
|
|
||||||
{{ results_td('notes', book.notes) }}
|
|
||||||
{{ results_td('isbn', book.isbn) }}
|
|
||||||
{{ results_td('authors', book.authors|join(", ")) }}
|
|
||||||
{{ results_td('genres', book.genres|join(", ")) }}
|
|
||||||
{{ results_td('publisher', book.publisher) }}
|
|
||||||
{{ results_td('owner', book.owner) }}
|
|
||||||
{{ results_td('bought', book.bought.strftime("%d/%m/%Y")) }}
|
|
||||||
{{ results_td('location', book.location) }}
|
|
||||||
{{ results_td('loaned_to', book.loaned_to) }}
|
|
||||||
{{ results_td('loaned_from', book.loaned_from) }}
|
|
||||||
{{ results_td('wishlisted', 'X' if book.wishlisted else '') }}
|
|
||||||
{{ results_td('read', 'X' if book.read else '') }}
|
|
||||||
{{ results_td('reading', 'X' if book.reading else '') }}
|
|
||||||
{{ results_td('dropped', 'X' if book.dropped else '') }}
|
|
||||||
{{ results_td('started_reading', book.started_reading and book.started_reading or '') }}
|
|
||||||
{{ results_td('finished_reading', book.finished_reading and book.finished_reading or '') }}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock search_results %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<dialog id="isbn-prompt">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form action="/books/import" method="post">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Import a book</h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<label class="form-label" for="isbn">ISBN:</label>
|
|
||||||
<input class="form-control" type="text" name="isbn" id="isbn" inputmode="numeric" required>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer pt-2">
|
|
||||||
<button type="button" class="btn btn-secondary me-2" hx-post="/books/import">Import</button>
|
|
||||||
<button type="button" class="btn btn-secondary"
|
|
||||||
onClick="document.querySelector('#isbn-prompt').close()">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% macro validation_error(field, errors) %}
|
|
||||||
{% if field in errors %}
|
|
||||||
<span id="{{ field }}-error" hx-swap-oob="true" class="invalid-feedback"
|
|
||||||
hx-on::load="document.getElementById('{{ field }}').classList.add('is-invalid')">{{ errors.get(field)
|
|
||||||
}}</span>
|
|
||||||
{% else %}
|
|
||||||
<span id="{{ field }}-error" hx-swap-oob="true" class="invalid-feedback"
|
|
||||||
hx-on::load="document.getElementById('{{ field }}').classList.remove('is-invalid')"></span>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
@@ -9,17 +9,22 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
|
||||||
{#
|
{#
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}"> #}
|
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}"> #}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
|
||||||
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
||||||
|
|
||||||
{#
|
|
||||||
<script src="{{ url_for('static', filename='htmx.min.js') }}"></script> #}
|
<script src="{{ url_for('static', filename='htmx.min.js') }}"></script>
|
||||||
{#
|
<meta name="htmx-config" content='{"defaultFocusScroll":"true"}'>
|
||||||
<script src="{{ url_for('static', filename='alpine.min.js') }}" defer></script> #}
|
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify"></script>
|
||||||
{#
|
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.polyfills.min.js"></script>
|
||||||
<script src="{{ url_for('static', filename='tom-select.complete.min.js') }}"></script> #}
|
<link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.7/awesomplete.min.js"></script>
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.7/awesomplete.min.css" rel="stylesheet"
|
||||||
|
type="text/css" />
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// HTMX error handling
|
// HTMX error handling
|
||||||
@@ -33,10 +38,22 @@
|
|||||||
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body hx-boost="true">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
{% include 'components/header.html.j2' %}
|
{% include 'components/header.html.j2' %}
|
||||||
|
|
||||||
|
<!-- Floating Flash Messages -->
|
||||||
|
<div id="flash-messages-container" class="flash-messages-container">
|
||||||
|
{% for category, message in get_flashed_messages(with_categories=true) %}
|
||||||
|
<div
|
||||||
|
class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show flash-message"
|
||||||
|
role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Layout -->
|
<!-- Main Layout -->
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -48,15 +65,6 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="col-md-9 col-lg-10 main-content">
|
<main class="col-md-9 col-lg-10 main-content">
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
<!-- Flash Messages -->
|
|
||||||
{% for category, message in get_flashed_messages(with_categories=true) %}
|
|
||||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show"
|
|
||||||
role="alert">
|
|
||||||
{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
{% block header %}{% endblock %}
|
{% block header %}{% endblock %}
|
||||||
|
|
||||||
@@ -90,6 +98,14 @@
|
|||||||
|
|
||||||
<!-- Bootstrap JS -->
|
<!-- Bootstrap JS -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
htmx.onLoad(function (content) {
|
||||||
|
content.querySelectorAll('[data-bs-toggle="dropdown"]').forEach(function (dropdownToggle) {
|
||||||
|
new bootstrap.Dropdown(dropdownToggle);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -59,7 +59,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- 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">
|
<form id="book-form" action="/book/{{ book.id }}/edit" method="POST" hx-trigger="change,submit"
|
||||||
|
hx-swap="none show:none" hx-target="this" hx-select-oob="#flash-messages-container,#bookshelf-list">
|
||||||
{% include 'components/book_form.html.j2' %}
|
{% include 'components/book_form.html.j2' %}
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
@@ -71,8 +72,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User-Specific Data Sidebar -->
|
<!-- User-Specific Data Sidebar (not shown on mobile) -->
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4 d-none d-lg-block">
|
||||||
{% 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">
|
||||||
@@ -93,25 +94,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Simple form change detection
|
|
||||||
let originalFormData = new FormData(document.getElementById('book-form'));
|
|
||||||
let hasChanges = false;
|
|
||||||
|
|
||||||
document.getElementById('book-form').addEventListener('input', function () {
|
|
||||||
hasChanges = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('book-form').addEventListener('submit', function () {
|
|
||||||
hasChanges = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', function (e) {
|
|
||||||
if (hasChanges) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.returnValue = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -8,14 +8,9 @@
|
|||||||
|
|
||||||
<!-- Search Bar -->
|
<!-- Search Bar -->
|
||||||
<div class="search-container mx-3 flex-grow-1">
|
<div class="search-container mx-3 flex-grow-1">
|
||||||
<form method="GET" action="/">
|
<form method="GET" action="/" id="search-form">
|
||||||
<div class="input-group">
|
<input type="text" class="form-control" name="q" id="search-input" value="{{ query }}"
|
||||||
<input type="text" class="form-control" name="q" value="{{ query }}"
|
placeholder="Search with filters: owner:name is:read">
|
||||||
placeholder="Search books, authors, genres...">
|
|
||||||
<button class="btn btn-outline-secondary" type="submit">
|
|
||||||
🔍 Search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,6 +35,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">
|
||||||
@@ -59,4 +124,76 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
searchInput = document.querySelector('#search-input');
|
||||||
|
searchForm = document.querySelector('#search-form');
|
||||||
|
|
||||||
|
field_suggestions = {{ search_suggestions.keys() | list | pprint }}.map(f => f + ":");
|
||||||
|
suggestions = {
|
||||||
|
{% for key, values in search_suggestions.items() %}
|
||||||
|
"{{ key }}": {{ values | pprint }},
|
||||||
|
{% endfor %}
|
||||||
|
};
|
||||||
|
|
||||||
|
awesomplete = new Awesomplete(searchInput, {
|
||||||
|
minChars: 0,
|
||||||
|
maxItems: 100,
|
||||||
|
filter: () => { // We will provide a list that is already filtered ...
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
sort: false, // ... and sorted.
|
||||||
|
replace: function (text) {
|
||||||
|
// substitute the text after the last space, colon, minus
|
||||||
|
const inputText = this.input.value;
|
||||||
|
const lastSpaceIndex = inputText.lastIndexOf(" ");
|
||||||
|
const lastColonIndex = inputText.lastIndexOf(":");
|
||||||
|
const lastMinusIndex = inputText.lastIndexOf("-");
|
||||||
|
const splitIndex = Math.max(lastSpaceIndex, lastColonIndex, lastMinusIndex);
|
||||||
|
// surround the inserted text with quotes if it contains spaces and isn't already quoted
|
||||||
|
if (text.includes(" ") && !(text.startsWith('"') && text.endsWith('"'))) {
|
||||||
|
text = `"${text}"`;
|
||||||
|
}
|
||||||
|
const prefix = inputText.substring(0, splitIndex + 1);
|
||||||
|
this.input.value = prefix + text + (text.endsWith(":") ? "" : " ");
|
||||||
|
},
|
||||||
|
list: field_suggestions
|
||||||
|
});
|
||||||
|
|
||||||
|
["input", "focus", "awesomplete-selectcomplete"].forEach(eventType => {
|
||||||
|
searchInput.addEventListener(eventType, (event) => {
|
||||||
|
const inputText = searchInput.value;
|
||||||
|
if (inputText.length === 0 || inputText.endsWith(" ")) {
|
||||||
|
// show base suggestions if input is empty or ends with a space (indicating a completed term)
|
||||||
|
list = field_suggestions;
|
||||||
|
} else {
|
||||||
|
// Extract the last term being typed
|
||||||
|
const terms = inputText.split(/\s+/);
|
||||||
|
const lastTerm = terms[terms.length - 1].toLowerCase();
|
||||||
|
// remove leading minus if present
|
||||||
|
const normalizedLastTerm = lastTerm.startsWith("-") ? lastTerm.substring(1) : lastTerm;
|
||||||
|
|
||||||
|
// Provide suggestions based on the last term
|
||||||
|
if (normalizedLastTerm.includes(":")) {
|
||||||
|
const [field, query] = normalizedLastTerm.split(":", 2);
|
||||||
|
const options = suggestions[field] || [];
|
||||||
|
// match case insensitively and ignore any existing quotes in the query
|
||||||
|
const normalizedQuery = query.replace(/"/g, "");
|
||||||
|
list = options.filter(o => o.toLowerCase().startsWith(normalizedQuery));
|
||||||
|
} else {
|
||||||
|
// show filtered base suggestions if the last term is being typed but doesn't match known prefixes
|
||||||
|
list = field_suggestions.filter(o => o.toLowerCase().startsWith(normalizedLastTerm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
awesomplete.list = list;
|
||||||
|
awesomplete.evaluate();
|
||||||
|
if (list.length > 0) {
|
||||||
|
awesomplete.open();
|
||||||
|
} else {
|
||||||
|
awesomplete.close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="card book-card h-100" onclick="window.location.href='/book/{{ book.id }}'">
|
<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 -->
|
<!-- TODO: Book cover image -->
|
||||||
<div class="card-img-top bg-light d-flex align-items-center justify-content-center text-muted">
|
<div class="card-img-top bg-light d-flex align-items-center justify-content-center text-muted">
|
||||||
@@ -73,4 +73,4 @@
|
|||||||
<small class="text-muted"> • {{ book.location_place }}</small>
|
<small class="text-muted"> • {{ book.location_place }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
<input type="text" class="form-control" id="owner" name="owner"
|
<input type="text" class="form-control" id="owner" name="owner"
|
||||||
value="{% if book and book.owner %}{{ book.owner.username }}{% elif g.viewing_user %}{{ g.viewing_user.username }}{% endif %}"
|
value="{% if book and book.owner %}{{ book.owner.username }}{% elif g.viewing_user %}{{ g.viewing_user.username }}{% endif %}"
|
||||||
placeholder="Username">
|
placeholder="Username">
|
||||||
<div class="form-text">Leave empty for no owner</div>
|
|
||||||
</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>
|
||||||
@@ -23,13 +22,11 @@
|
|||||||
<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 | join('\n') }}{% endif %}</textarea>
|
||||||
<div class="form-text">Enter one author per line</div>
|
|
||||||
</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 | join('\n') }}{% endif %}</textarea>
|
||||||
<div class="form-text">Enter one genre per line</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,12 +60,25 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="location_place" class="form-label">Location (Place)</label>
|
<label for="location_place" class="form-label">Location (Place)</label>
|
||||||
<input type="text" class="form-control" id="location_place" name="location_place"
|
<input type="text" class="form-control" id="location_place" name="location_place"
|
||||||
value="{{ book.location_place if book else '' }}" placeholder="Home, Office, etc.">
|
value="{{ book.location_place if book else '' }}" placeholder="Home, Office, etc." list="places-list">
|
||||||
|
<datalist id="places-list">
|
||||||
|
{% for place in locations.keys() if place %}
|
||||||
|
<option value="{{ place }}"></option>
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="location_bookshelf" class="form-label">Bookshelf</label>
|
<label for="location_bookshelf" class="form-label">Bookshelf</label>
|
||||||
<input type="text" class="form-control" id="location_bookshelf" name="location_bookshelf"
|
<input type="text" class="form-control" id="location_bookshelf" name="location_bookshelf"
|
||||||
value="{{ book.location_bookshelf if book else '' }}" placeholder="Living room, Bedroom, etc.">
|
value="{{ book.location_bookshelf if book else '' }}" placeholder="Living room, Bedroom, etc."
|
||||||
|
list="bookshelf-list">
|
||||||
|
<datalist id="bookshelf-list">
|
||||||
|
{% if book and book.location_place and locations.get(book.location_place) %}
|
||||||
|
{% for bookshelf in locations[book.location_place] if bookshelf %}
|
||||||
|
<option value="{{ bookshelf }}"></option>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="location_shelf" class="form-label">Shelf Number</label>
|
<label for="location_shelf" class="form-label">Shelf Number</label>
|
||||||
@@ -81,8 +91,8 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="loaned_to" class="form-label">Loaned To</label>
|
<label for="loaned_to" class="form-label">Loaned To</label>
|
||||||
<input type="text" class="form-control" id="loaned_to" name="loaned_to"
|
<input type="text" class="form-control" id="loaned_to" name="loaned_to" value="{{ book.loaned_to if book else '' }}"
|
||||||
value="{{ book.loaned_to if book else '' }}" placeholder="Person's name">
|
placeholder="Person's name">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="loaned_date" class="form-label">Loan Date</label>
|
<label for="loaned_date" class="form-label">Loan Date</label>
|
||||||
@@ -97,3 +107,24 @@
|
|||||||
<textarea class="form-control" id="notes" name="notes" rows="3"
|
<textarea class="form-control" id="notes" name="notes" rows="3"
|
||||||
placeholder="Your personal notes about this book...">{{ book.notes if book else '' }}</textarea>
|
placeholder="Your personal notes about this book...">{{ book.notes if book else '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Initialize tagify for authors and genres
|
||||||
|
commmon_settings = {
|
||||||
|
delimiters: "\n",
|
||||||
|
originalInputValueFormat: valuesArr => valuesArr.map(item => item.value).join('\n'),
|
||||||
|
dropdown: {
|
||||||
|
enabled: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
new Tagify(document.querySelector('#genres'), {
|
||||||
|
...commmon_settings,
|
||||||
|
whitelist: {{ genres | map(attribute = 'name') | list | pprint }},
|
||||||
|
dropdown: { enabled: 0, closeOnSelect: false }
|
||||||
|
});
|
||||||
|
new Tagify(document.querySelector('#authors'), {
|
||||||
|
...commmon_settings,
|
||||||
|
whitelist: {{ authors | map(attribute = 'name') | list | pprint }},
|
||||||
|
dropdown: { enabled: 0, closeOnSelect: false }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -14,40 +14,22 @@
|
|||||||
|
|
||||||
<!-- User Selector -->
|
<!-- User Selector -->
|
||||||
<div class="navbar-nav ms-auto">
|
<div class="navbar-nav ms-auto">
|
||||||
<div class="nav-item dropdown position-static">
|
<form action="" method="get" class="d-flex align-items-center">
|
||||||
<button class="btn btn-outline-light dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
<label for="user-select" class="text-white me-2 mb-0 d-none d-sm-inline">View as:</label>
|
||||||
data-bs-boundary="viewport" data-bs-reference="parent">
|
<select id="user-select" name="user-select" class="form-select form-select-sm"
|
||||||
{% if session.get('viewing_as_user') %}
|
onchange="window.location.href=this.value" style="max-width: 120px; min-width: 100px;">
|
||||||
👤 {{ session.get('viewing_as_user') }}
|
<option value="{{ url_for('main.set_viewing_user', username='') }}" {% if not session.get('viewing_as_user')
|
||||||
{% else %}
|
%}selected{% endif %}>
|
||||||
🌐 All Users
|
🌐 All Users
|
||||||
{% endif %}
|
</option>
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end mobile-dropdown">
|
|
||||||
<li>
|
|
||||||
<h6 class="dropdown-header">View as User</h6>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {% if not session.get('viewing_as_user') %}active{% endif %}"
|
|
||||||
href="{{ url_for('main.set_viewing_user', username='') }}">
|
|
||||||
🌐 All Users
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% if users %}
|
|
||||||
<li>
|
|
||||||
<hr class="dropdown-divider">
|
|
||||||
</li>
|
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<li>
|
<option value="{{ url_for('main.set_viewing_user', username=user.username) }}" {% if
|
||||||
<a class="dropdown-item {% if session.get('viewing_as_user') == user.username %}active{% endif %}"
|
session.get('viewing_as_user')==user.username %}selected{% endif %}>
|
||||||
href="{{ url_for('main.set_viewing_user', username=user.username) }}">
|
👤 {{ user.username.title() }}
|
||||||
👤 {{ user.username.title() }}
|
</option>
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
</select>
|
||||||
</ul>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
{% if vars.completed_readings %}
|
{% if vars.completed_readings %}
|
||||||
<div class="alert alert-light border py-2 mb-3">
|
<div 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">
|
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">
|
||||||
<!-- 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"
|
||||||
@@ -59,7 +60,8 @@
|
|||||||
<!-- All Reading Sessions -->
|
<!-- All Reading Sessions -->
|
||||||
{% 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">
|
<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">
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
from werkzeug.datastructures import MultiDict
|
|
||||||
|
|
||||||
|
|
||||||
def flatten_form_data(form: MultiDict[str, str]) -> dict[str, str | list[str]]:
|
|
||||||
ret = {}
|
|
||||||
for key, values in form.lists():
|
|
||||||
if key.endswith("[]"):
|
|
||||||
key = key.removesuffix("[]")
|
|
||||||
v: str | list[str] = values
|
|
||||||
else:
|
|
||||||
v = values[0]
|
|
||||||
ret[key] = v
|
|
||||||
return ret
|
|
||||||
@@ -334,6 +334,7 @@ class TestBookSearchCommand:
|
|||||||
("owner:alice", "", ["Dune", "The Fellowship", "The Hobbit"]),
|
("owner:alice", "", ["Dune", "The Fellowship", "The Hobbit"]),
|
||||||
("place:home", "", ["Programming Book", "The Hobbit"]),
|
("place:home", "", ["Programming Book", "The Hobbit"]),
|
||||||
("bookshelf:fantasy", "", ["The Fellowship", "The Hobbit"]),
|
("bookshelf:fantasy", "", ["The Fellowship", "The Hobbit"]),
|
||||||
|
('author:"Frank Herbert"', "", ["Dune"]),
|
||||||
# Numeric field filters
|
# Numeric field filters
|
||||||
("rating>=4", "", ["Programming Book", "The Hobbit"]),
|
("rating>=4", "", ["Programming Book", "The Hobbit"]),
|
||||||
("rating=3", "", ["Dune"]),
|
("rating=3", "", ["Dune"]),
|
||||||
|
|||||||
Reference in New Issue
Block a user