Compare commits

...

5 Commits

25 changed files with 467 additions and 1352 deletions

View File

@@ -2,26 +2,29 @@
## 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:**
- **Backend**: Flask 3.1+ with SQLAlchemy 2.0 (modern `Mapped[]` annotations)
- **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
- **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
- **CLI**: Click-based comprehensive command interface
## Architecture
**Application Factory Pattern:**
- `create_app()` in [src/hxbooks/__init__.py](src/hxbooks/__init__.py)
- Blueprint organization: `auth.py` (authentication), `book.py` (main features)
- Models: User, Book, Reading, Wishlist with cascade relationships
**Service Layer Pattern:**
- Flask app factory in [src/hxbooks/app.py](src/hxbooks/app.py)
- Service layer: [src/hxbooks/library.py](src/hxbooks/library.py) - business logic shared between web and CLI
- 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:**
- [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with modern `Mapped[]` syntax
- [src/hxbooks/book.py](src/hxbooks/book.py): Complex search with Pydantic validation
- [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with proper normalization and relationships
- [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/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
uv sync # Install dependencies
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:**
- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Livereload server watching templates
- VS Code debugging: Use "Python Debugger: Flask" launch configuration
- CLI command `hxbooks serve`: Livereload server watching templates
- 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)
**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:**
- Dockerfile uses Gunicorn on port 8080
- **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]`)
- **Naming**: snake_case functions/vars, PascalCase classes, UPPERCASE constants
- **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:**
- 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)
- 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):**
- Templates: `.j2` extension, fragments for partial updates
- 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
**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/models.py](src/hxbooks/models.py): Database models
**Feature Modules:**
- [src/hxbooks/auth.py](src/hxbooks/auth.py): User authentication (username-based)
- [src/hxbooks/book.py](src/hxbooks/book.py): Book CRUD, search, filtering
- [src/hxbooks/main.py](src/hxbooks/main.py): Web routes and form handling
- [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
**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
**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
- Foreign key constraints explicitly defined
**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
- Saved searches stored as JSON in User model
- Support for negation (`-genre:romance`) and special operators (`is:reading`)
**HTMX Integration:**
- 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
**Development Notes:**
- No testing framework configured yet
- No linting/formatting tools setup
- Testing framework: pytest with parametrized tests for comprehensive coverage
- Linting/formatting: ruff configured with strict rules
- Instance folder for environment-specific config (gitignored)
- Production requires either `requirements.txt` generation or Dockerfile updates for UV

View File

@@ -4,8 +4,7 @@ from pathlib import Path
from flask import Flask
from flask_migrate import Migrate
from . import auth, db
from .htmx import htmx
from . import db
from .main import bp as main_bp
# Get the project root (parent of src/)
@@ -36,13 +35,11 @@ def create_app(test_config: dict | None = None) -> Flask:
pass
db.init_app(app)
htmx.init_app(app)
# Initialize migrations
Migrate(app, db.db)
# Register blueprints
app.register_blueprint(auth.bp)
app.register_blueprint(main_bp)
return app

View File

@@ -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

View File

@@ -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,
}

View File

@@ -304,7 +304,7 @@ def search_books(
with app.app_context():
try:
books = library.search_books_advanced(
books, _ = library.search_books_advanced(
query_string=query, limit=limit, username=username
)
if output_format == "json":

View File

@@ -1,3 +0,0 @@
from flask_htmx import HTMX # type: ignore
htmx = HTMX()

View File

@@ -5,6 +5,7 @@ Clean service layer for book management, reading tracking, and wishlist operatio
Separated from web interface concerns to enable both CLI and web access.
"""
from collections import defaultdict
from collections.abc import Sequence
from datetime import date, datetime
from typing import assert_never
@@ -276,9 +277,13 @@ query_parser = QueryParser()
def search_books_advanced(
query_string: str, limit: int = 50, username: str | None = None
) -> Sequence[Book]:
"""Advanced search with field filters supporting comparison operators."""
query_string: str, limit: int = 50, offset: int = 0, username: str | None = None
) -> tuple[Sequence[Book], int]:
"""Advanced search with field filters supporting comparison operators.
Returns:
tuple: (books, total_count)
"""
parsed_query = query_parser.parse(query_string)
query = (
@@ -332,10 +337,17 @@ def search_books_advanced(
sort_columns.append(Book.added_date.desc())
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)
return result.scalars().unique().all()
books = result.scalars().unique().all()
return books, total_count
def _build_field_condition(
@@ -872,3 +884,29 @@ def get_user_by_username(username: str) -> User | None:
def list_users() -> Sequence[User]:
"""List all users."""
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

View File

@@ -6,10 +6,11 @@ Provides clean URL structure and integrates with library.py business logic.
import traceback
from datetime import date
from typing import Annotated, Any
from typing import Annotated, Any, Literal
from flask import (
Blueprint,
Response,
flash,
g,
redirect,
@@ -29,6 +30,8 @@ from pydantic import (
from pydantic_extra_types.isbn import ISBN
from hxbooks.models import Reading, User
from hxbooks.search import Field as SearchField
from hxbooks.search import IsOperatorValue, SortDirection
from . import library
from .db import db
@@ -39,7 +42,7 @@ bp = Blueprint("main", __name__)
# Pydantic validation models
StrOrNone = Annotated[str | None, BeforeValidator(lambda v: v.strip() or None)]
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[
list[str],
BeforeValidator(
@@ -57,7 +60,7 @@ IntOrNone = Annotated[int | None, BeforeValidator(lambda v: v.strip() or None)]
class BookFormData(BaseModel):
title: StripStr = Field(min_length=1)
owner: StrOrNone = None
isbn: ISBNOrNone = None
isbn: ISBNOrEmpty = ""
authors: TextareaList = Field(default_factory=list)
genres: TextareaList = Field(default_factory=list)
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 {}
@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
@bp.app_context_processor
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("/")
def index() -> ResponseReturnValue:
"""Book list view - main application page."""
# Get search parameters
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
viewing_user = session.get("viewing_as_user")
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:
flash(f"Search error: {e}", "error")
# print traceback for debugging
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>")
@@ -146,7 +211,13 @@ def book_detail(book_id: int) -> ResponseReturnValue:
flash("Book not found", "error")
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:
@@ -176,7 +247,7 @@ def create_book() -> ResponseReturnValue:
owner_id=owner_id,
authors=form_data.authors,
genres=form_data.genres,
isbn=str(form_data.isbn) if form_data.isbn else None,
isbn=str(form_data.isbn),
publisher=form_data.publisher,
edition=form_data.edition,
description=form_data.description,
@@ -227,7 +298,7 @@ def update_book(book_id: int) -> ResponseReturnValue:
owner_id=owner_id,
authors=form_data.authors,
genres=form_data.genres,
isbn=str(form_data.isbn) if form_data.isbn else None,
isbn=form_data.isbn,
publisher=form_data.publisher,
edition=form_data.edition,
description=form_data.description,

View File

@@ -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 {
position: sticky;
top: 0;
@@ -223,4 +257,9 @@
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* awesomplete Custom Styles */
.awesomplete {
display: block;
}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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">&mdash;</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 %}

View File

@@ -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 %}

View File

@@ -9,17 +9,22 @@
<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 src="{{ url_for('static', filename='htmx.min.js') }}"></script>
<meta name="htmx-config" content='{"defaultFocusScroll":"true"}'>
<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>
<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>
// HTMX error handling
@@ -33,10 +38,22 @@
</head>
<body>
<body hx-boost="true">
<!-- Header -->
{% 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 -->
<div class="container-fluid">
<div class="row">
@@ -48,15 +65,6 @@
<!-- Main Content -->
<main class="col-md-9 col-lg-10 main-content">
<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 -->
{% block header %}{% endblock %}
@@ -90,6 +98,14 @@
<!-- Bootstrap JS -->
<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>
</html>

View File

@@ -18,7 +18,7 @@
<div class="card-body">
<form action="/book/new" method="POST">
{% include 'components/book_form.html.j2' %}
<div class="row mt-4">
<div class="col">
<button type="submit" class="btn btn-primary">📚 Create Book</button>

View File

@@ -59,7 +59,8 @@
{% endif %}
<!-- Book Details Form -->
<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' %}
<div class="row mt-4">
@@ -71,8 +72,8 @@
</form>
</div>
<!-- User-Specific Data Sidebar -->
<div class="col-lg-4">
<!-- User-Specific Data Sidebar (not shown on mobile) -->
<div class="col-lg-4 d-none d-lg-block">
{% if session.get('viewing_as_user') %}
<div class="card">
<div class="card-header">
@@ -93,25 +94,4 @@
</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 %}

View File

@@ -8,14 +8,9 @@
<!-- Search Bar -->
<div class="search-container mx-3 flex-grow-1">
<form method="GET" action="/">
<div class="input-group">
<input type="text" class="form-control" name="q" value="{{ query }}"
placeholder="Search books, authors, genres...">
<button class="btn btn-outline-secondary" type="submit">
🔍 Search
</button>
</div>
<form method="GET" action="/" id="search-form">
<input type="text" class="form-control" name="q" id="search-input" value="{{ query }}"
placeholder="Search with filters: owner:name is:read">
</form>
</div>
@@ -40,6 +35,76 @@
</div>
{% endfor %}
</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) }}">&laquo;
Previous</a>
{% else %}
<span class="page-link">&laquo; 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
&raquo;</a>
{% else %}
<span class="page-link">Next &raquo;</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 %}
<div class="text-center py-5">
<div class="mb-3">
@@ -59,4 +124,76 @@
</p>
</div>
{% 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 %}

View File

@@ -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">
<!-- TODO: Book cover image -->
<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>
{% endif %}
</div>
</div>
</a>

View File

@@ -9,7 +9,6 @@
<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 %}"
placeholder="Username">
<div class="form-text">Leave empty for no owner</div>
</div>
<div class="col-md-3">
<label for="isbn" class="form-label">ISBN</label>
@@ -23,13 +22,11 @@
<label for="authors" class="form-label">Authors</label>
<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>
<div class="form-text">Enter one author per line</div>
</div>
<div class="col-md-6">
<label for="genres" class="form-label">Genres</label>
<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>
<div class="form-text">Enter one genre per line</div>
</div>
</div>
@@ -63,12 +60,25 @@
<div class="col-md-4">
<label for="location_place" class="form-label">Location (Place)</label>
<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 class="col-md-4">
<label for="location_bookshelf" class="form-label">Bookshelf</label>
<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 class="col-md-4">
<label for="location_shelf" class="form-label">Shelf Number</label>
@@ -81,8 +91,8 @@
<div class="row mb-3">
<div class="col-md-6">
<label for="loaned_to" class="form-label">Loaned To</label>
<input type="text" class="form-control" id="loaned_to" name="loaned_to"
value="{{ book.loaned_to if book else '' }}" placeholder="Person's name">
<input type="text" class="form-control" id="loaned_to" name="loaned_to" value="{{ book.loaned_to if book else '' }}"
placeholder="Person's name">
</div>
<div class="col-md-6">
<label for="loaned_date" class="form-label">Loan Date</label>
@@ -96,4 +106,25 @@
<label for="notes" class="form-label">Personal Notes</label>
<textarea class="form-control" id="notes" name="notes" rows="3"
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>

View File

@@ -14,40 +14,22 @@
<!-- User Selector -->
<div class="navbar-nav ms-auto">
<div class="nav-item dropdown position-static">
<button class="btn btn-outline-light dropdown-toggle" type="button" data-bs-toggle="dropdown"
data-bs-boundary="viewport" data-bs-reference="parent">
{% if session.get('viewing_as_user') %}
👤 {{ session.get('viewing_as_user') }}
{% else %}
🌐 All Users
{% endif %}
</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>
<form action="" method="get" class="d-flex align-items-center">
<label for="user-select" class="text-white me-2 mb-0 d-none d-sm-inline">View as:</label>
<select id="user-select" name="user-select" class="form-select form-select-sm"
onchange="window.location.href=this.value" style="max-width: 120px; min-width: 100px;">
<option value="{{ url_for('main.set_viewing_user', username='') }}" {% if not session.get('viewing_as_user')
%}selected{% endif %}>
🌐 All Users
</option>
{% for user in users %}
<li>
<a class="dropdown-item {% if session.get('viewing_as_user') == user.username %}active{% endif %}"
href="{{ url_for('main.set_viewing_user', username=user.username) }}">
👤 {{ user.username.title() }}
</a>
</li>
<option value="{{ url_for('main.set_viewing_user', username=user.username) }}" {% if
session.get('viewing_as_user')==user.username %}selected{% endif %}>
👤 {{ user.username.title() }}
</option>
{% endfor %}
{% endif %}
</ul>
</div>
</select>
</form>
</div>
</div>
</header>

View File

@@ -28,7 +28,8 @@
{% if vars.completed_readings %}
<div class="alert alert-light border py-2 mb-3">
<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 -->
<input type="hidden" name="start_date" value="{{ vars.completed_readings[0].start_date.strftime('%Y-%m-%d') }}">
<input type="hidden" name="end_date"
@@ -59,7 +60,8 @@
<!-- All Reading Sessions -->
{% 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 %}">
<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="col-md-6">
<label class="form-label-sm">Start Date</label>

View File

@@ -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

View File

@@ -334,6 +334,7 @@ class TestBookSearchCommand:
("owner:alice", "", ["Dune", "The Fellowship", "The Hobbit"]),
("place:home", "", ["Programming Book", "The Hobbit"]),
("bookshelf:fantasy", "", ["The Fellowship", "The Hobbit"]),
('author:"Frank Herbert"', "", ["Dune"]),
# Numeric field filters
("rating>=4", "", ["Programming Book", "The Hobbit"]),
("rating=3", "", ["Dune"]),