Delete old files

This commit is contained in:
2026-03-21 18:57:27 +01:00
parent 95e434a750
commit 452567d0c4
11 changed files with 1 additions and 1222 deletions

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

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

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

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