Compare commits

..

5 Commits

25 changed files with 467 additions and 1352 deletions

View File

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

View File

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

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(): 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":

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

View File

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

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 { .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;
}

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='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>

View File

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

View File

@@ -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) }}">&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 %} {% 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 %}

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

View File

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

View File

@@ -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() }}
</a> </option>
</li>
{% endfor %} {% endfor %}
{% endif %} </select>
</ul> </form>
</div>
</div> </div>
</div> </div>
</header> </header>

View File

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

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"]), ("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"]),