First full version

This commit is contained in:
2024-04-24 17:59:20 +02:00
commit b4b931633b
33 changed files with 2039 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.envrc
.venv
.git
.vscode
**/instance
*.egg-info
**/__pycache__
**/__mypy_cache__

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
instance/

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.11-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN pip install -e .
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "hxbooks:create_app()"]

19
pyproject.toml Normal file
View File

@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "hxbooks"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"flask",
"flask_sqlalchemy",
"flask-htmx",
"jinja2_fragments",
"sqlalchemy",
"pydantic",
"requests",
"gunicorn",
]

64
requirements.txt Normal file
View File

@@ -0,0 +1,64 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile
#
annotated-types==0.6.0
# via pydantic
blinker==1.7.0
# via flask
certifi==2024.2.2
# via requests
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via flask
flask==3.0.3
# via
# flask-htmx
# flask-sqlalchemy
# hxbooks (pyproject.toml)
flask-htmx==0.3.2
# via hxbooks (pyproject.toml)
flask-sqlalchemy==3.1.1
# via hxbooks (pyproject.toml)
greenlet==3.0.3
# via sqlalchemy
gunicorn==22.0.0
# via hxbooks (pyproject.toml)
idna==3.7
# via requests
itsdangerous==2.2.0
# via flask
jinja2==3.1.3
# via
# flask
# jinja2-fragments
jinja2-fragments==1.3.0
# via hxbooks (pyproject.toml)
markupsafe==2.1.5
# via
# jinja2
# werkzeug
packaging==24.0
# via gunicorn
pydantic==2.7.1
# via hxbooks (pyproject.toml)
pydantic-core==2.18.2
# via pydantic
requests==2.31.0
# via hxbooks (pyproject.toml)
sqlalchemy==2.0.29
# via
# flask-sqlalchemy
# hxbooks (pyproject.toml)
typing-extensions==4.11.0
# via
# pydantic
# pydantic-core
# sqlalchemy
urllib3==2.2.1
# via requests
werkzeug==3.0.2
# via flask

43
src/hxbooks/__init__.py Normal file
View File

@@ -0,0 +1,43 @@
import os
from typing import Optional
from flask import Flask
from . import auth, book, db
from .htmx import htmx
def create_app(test_config: Optional[dict] = None) -> Flask:
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY="dev",
SQLALCHEMY_DATABASE_URI="sqlite:///hxbooks.sqlite",
)
if test_config is None:
# load the instance config, if it exists, when not testing
app.config.from_pyfile("config.py", silent=True)
else:
# load the test config if passed in
app.config.from_mapping(test_config)
# ensure the instance folder exists
try:
os.makedirs(app.instance_path)
except OSError:
pass
db.init_app(app)
htmx.init_app(app)
app.register_blueprint(auth.bp)
app.register_blueprint(book.bp)
app.add_url_rule("/", endpoint="books.books")
return app
if __name__ == "__main__":
app = create_app()
app.run()

10
src/hxbooks/__main__.py Normal file
View File

@@ -0,0 +1,10 @@
import livereload # type: ignore
from hxbooks import create_app
app = create_app()
app.debug = True
# app.run()
server = livereload.Server(app.wsgi_app)
server.watch("hxbooks/templates/**")
server.serve(port=5000, host="0.0.0.0")

107
src/hxbooks/auth.py Normal file
View File

@@ -0,0 +1,107 @@
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

573
src/hxbooks/book.py Normal file
View File

@@ -0,0 +1,573 @@
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: Optional[bool] = None
read: Optional[bool] = None
reading: Optional[bool] = None
dropped: Optional[bool] = None
bought_start: Optional[date] = None
bought_end: Optional[date] = None
started_reading_start: Optional[date] = None
started_reading_end: Optional[date] = None
finished_reading_start: Optional[date] = None
finished_reading_end: Optional[date] = None
sort_by: ResultColumn = "title"
sort_order: Literal["asc", "desc"] = "asc"
saved_search: Optional[str] = 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: Optional[int]
edition: str
added: datetime
description: str
notes: str
isbn: str
owner: Optional[str]
bought: date
location: str
loaned_to: str
loaned_from: str
wishlisted: bool
read: bool
reading: bool
dropped: bool
started_reading: Optional[date]
finished_reading: Optional[date]
@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()
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(
title=book_data.title,
description=book_data.description,
isbn=isbn,
authors=book_data.authors,
publisher=book_data.publisher,
first_published=book_data.publishedDate.year,
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: Optional[int] = None
edition: str = ""
notes: str = ""
isbn: str = ""
authors: list[str] = []
genres: list[str] = []
publisher: str = ""
owner_id: Optional[int] = None
bought: datetime = Field(default_factory=datetime.now)
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: datetime = Field(default_factory=datetime.now)
end_date: Optional[datetime] = None
finished: bool = False
dropped: bool = False
rating: Optional[int] = 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.now()
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,
}

15
src/hxbooks/db.py Normal file
View File

@@ -0,0 +1,15 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): ...
db = SQLAlchemy(model_class=Base)
def init_app(app: Flask) -> None:
db.init_app(app)
with app.app_context():
db.create_all()

80
src/hxbooks/gbooks.py Normal file
View File

@@ -0,0 +1,80 @@
from datetime import date
import requests
from pydantic import BaseModel
# {
# "title": "Concilio de Sombras (Sombras de Magia 2)",
# "authors": [
# "Victoria Schwab"
# ],
# "publisher": "Urano World",
# "publishedDate": "2023-11-14",
# "description": "Four months have passed since the shadow stone fell into Kell's possession. Four months since his path was crossed with Delilah Bard. Four months since Rhy was wounded and the Dane twins fell, and the stone was cast with Holland's dying body through the rift, and into Black London. In many ways, things have almost returned to normal, though Rhy is more sober, and Kell is now plagued by his guilt. Restless, and having been given up smuggling, Kell is visited by dreams of ominous magical events, waking only to think of Lila, who disappeared from the docks like she always meant to do. As Red London finalizes preparations for the Element Games-an extravagant international competition of magic, meant to entertain and keep the ties between neighboring countries healthy- a certain pirate ship draws closer, carrying old friends back into port. But while Red London is caught up in the pageantry and thrills of the Games, another London is coming back to life, and those who were thought to be forever gone have returned. After all, a shadow that was gone in the night reappears in the morning, and so it seems Black London has risen again-and so to keep magic's balance, another London must fall.",
# "industryIdentifiers": [
# {
# "type": "ISBN_10",
# "identifier": "8419030503"
# },
# {
# "type": "ISBN_13",
# "identifier": "9788419030504"
# }
# ],
# "readingModes": {
# "text": false,
# "image": false
# },
# "pageCount": 0,
# "printType": "BOOK",
# "categories": [
# "Fiction"
# ],
# "maturityRating": "NOT_MATURE",
# "allowAnonLogging": false,
# "contentVersion": "preview-1.0.0",
# "panelizationSummary": {
# "containsEpubBubbles": false,
# "containsImageBubbles": false
# },
# "imageLinks": {
# "smallThumbnail": "http://books.google.com/books/content?id=GM4G0AEACAAJ&printsec=frontcover&img=1&zoom=5&source=gbs_api",
# "thumbnail": "http://books.google.com/books/content?id=GM4G0AEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
# },
# "language": "es",
# "previewLink": "http://books.google.es/books?id=GM4G0AEACAAJ&dq=isbn:9788419030504&hl=&cd=1&source=gbs_api",
# "infoLink": "http://books.google.es/books?id=GM4G0AEACAAJ&dq=isbn:9788419030504&hl=&source=gbs_api",
# "canonicalVolumeLink": "https://books.google.com/books/about/Concilio_de_Sombras_Sombras_de_Magia_2.html?hl=&id=GM4G0AEACAAJ"
# }
class GoogleBook(BaseModel):
title: str
authors: list[str]
publisher: str
publishedDate: date
description: str
industryIdentifiers: list[dict[str, str]]
pageCount: int
printType: str
categories: list[str]
maturityRating: str
allowAnonLogging: bool
contentVersion: str
panelizationSummary: dict[str, bool]
imageLinks: dict[str, str]
language: str
previewLink: str
infoLink: str
canonicalVolumeLink: str
def fetch_google_book_data(isbn: str) -> GoogleBook:
req = requests.get(
"https://www.googleapis.com/books/v1/volumes", params={"q": f"isbn:{isbn}"}
)
req.raise_for_status()
data = req.json()
if data["totalItems"] == 0:
raise ValueError(f"Book with ISBN {isbn} not found")
return GoogleBook.model_validate(data["items"][0]["volumeInfo"])

3
src/hxbooks/htmx.py Normal file
View File

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

65
src/hxbooks/models.py Normal file
View File

@@ -0,0 +1,65 @@
from datetime import date, datetime
from typing import Optional
from sqlalchemy import JSON, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import db
class User(db.Model): # type: ignore[name-defined]
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column()
saved_searches: Mapped[dict] = mapped_column(JSON, default=dict)
readings: Mapped[list["Reading"]] = relationship(back_populates="user")
owned_books: Mapped[list["Book"]] = relationship(back_populates="owner")
wishes: Mapped[list["Wishlist"]] = relationship(back_populates="user")
class Book(db.Model): # type: ignore[name-defined]
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(default="")
description: Mapped[str] = mapped_column(default="")
first_published: Mapped[Optional[int]] = mapped_column(default=None)
edition: Mapped[str] = mapped_column(default="")
added: Mapped[datetime] = mapped_column(default=datetime.now)
notes: Mapped[str] = mapped_column(default="")
isbn: Mapped[str] = mapped_column(default="")
authors: Mapped[list[str]] = mapped_column(JSON, default=list)
genres: Mapped[list[str]] = mapped_column(JSON, default=list)
publisher: Mapped[str] = mapped_column(default="")
owner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id"))
bought: Mapped[datetime] = mapped_column(default=datetime.now)
location: Mapped[str] = mapped_column(default="billy salon")
loaned_to: Mapped[str] = mapped_column(default="")
loaned_from: Mapped[str] = mapped_column(default="")
owner: Mapped[Optional[User]] = relationship(back_populates="owned_books")
readings: Mapped[list["Reading"]] = relationship(
back_populates="book", cascade="delete, delete-orphan"
)
wished_by: Mapped[list["Wishlist"]] = relationship(
back_populates="book", cascade="delete, delete-orphan"
)
class Reading(db.Model): # type: ignore[name-defined]
id: Mapped[int] = mapped_column(primary_key=True)
start_date: Mapped[date] = mapped_column(default=datetime.now)
end_date: Mapped[Optional[date]] = mapped_column(default=None)
finished: Mapped[bool] = mapped_column(default=False)
dropped: Mapped[bool] = mapped_column(default=False)
rating: Mapped[Optional[int]] = mapped_column(default=None)
comments: Mapped[str] = mapped_column(default="")
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
user: Mapped["User"] = relationship(back_populates="readings")
book: Mapped["Book"] = relationship(back_populates="readings")
class Wishlist(db.Model): # type: ignore[name-defined]
id: Mapped[int] = mapped_column(primary_key=True)
wishlisted: Mapped[date] = mapped_column(default=datetime.now)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
user: Mapped["User"] = relationship(back_populates="wishes")
book: Mapped["Book"] = relationship(back_populates="wished_by")

5
src/hxbooks/static/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

6
src/hxbooks/static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
src/hxbooks/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -0,0 +1,52 @@
.form-check-input:checked {
background-color: var(--bs-secondary);
border-color: var(--bs-secondary);
}
.list-group-item.active {
background-color: var(--bs-secondary);
border-color: var(--bs-secondary);
}
.ts-wrapper.multi .ts-control>div {
background-color: var(--bs-secondary);
border-color: var(--bs-secondary);
color: var(--bs-body-color);
}
.ts-control,
.ts-control input,
.ts-dropdown {
color: var(--bs-body-color);
}
@media screen and (max-width: 576px) {
table.collapse-rows thead {
visibility: hidden;
height: 0;
position: absolute;
}
table.collapse-rows thead th {
max-width: 0;
}
table.collapse-rows tr {
display: block;
margin-bottom: .625em;
}
table.collapse-rows td {
display: block;
text-align: right;
}
table.collapse-rows td::before {
content: attr(data-label) ":";
float: left;
text-align: left;
font-weight: bold;
text-transform: capitalize;
}
}

View File

@@ -0,0 +1,440 @@
/**
* Tom Select v2.3.1
* Licensed under the Apache License, Version 2.0 (the "License");
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).TomSelect=t()}(this,(function(){"use strict"
function e(e,t){e.split(/\s+/).forEach((e=>{t(e)}))}class t{constructor(){this._events=void 0,this._events={}}on(t,i){e(t,(e=>{const t=this._events[e]||[]
t.push(i),this._events[e]=t}))}off(t,i){var s=arguments.length
0!==s?e(t,(e=>{if(1===s)return void delete this._events[e]
const t=this._events[e]
void 0!==t&&(t.splice(t.indexOf(i),1),this._events[e]=t)})):this._events={}}trigger(t,...i){var s=this
e(t,(e=>{const t=s._events[e]
void 0!==t&&t.forEach((e=>{e.apply(s,i)}))}))}}const i=e=>(e=e.filter(Boolean)).length<2?e[0]||"":1==l(e)?"["+e.join("")+"]":"(?:"+e.join("|")+")",s=e=>{if(!o(e))return e.join("")
let t="",i=0
const s=()=>{i>1&&(t+="{"+i+"}")}
return e.forEach(((n,o)=>{n!==e[o-1]?(s(),t+=n,i=1):i++})),s(),t},n=e=>{let t=c(e)
return i(t)},o=e=>new Set(e).size!==e.length,r=e=>(e+"").replace(/([\$\(\)\*\+\.\?\[\]\^\{\|\}\\])/gu,"\\$1"),l=e=>e.reduce(((e,t)=>Math.max(e,a(t))),0),a=e=>c(e).length,c=e=>Array.from(e),d=e=>{if(1===e.length)return[[e]]
let t=[]
const i=e.substring(1)
return d(i).forEach((function(i){let s=i.slice(0)
s[0]=e.charAt(0)+s[0],t.push(s),s=i.slice(0),s.unshift(e.charAt(0)),t.push(s)})),t},u=[[0,65535]]
let p,h
const g={},f={"/":"",0:"߀",a:"ⱥɐɑ",aa:"ꜳ",ae:"æǽǣ",ao:"ꜵ",au:"ꜷ",av:"ꜹꜻ",ay:"ꜽ",b:"ƀɓƃ",c:"ꜿƈȼↄ",d:"đɗɖᴅƌꮷԁɦ",e:"ɛǝᴇɇ",f:"ꝼƒ",g:"ǥɠꞡᵹꝿɢ",h:"ħⱨⱶɥ",i:"ɨı",j:"ɉȷ",k:"ƙⱪꝁꝃꝅꞣ",l:"łƚɫⱡꝉꝇꞁɭ",m:"ɱɯϻ",n:"ꞥƞɲꞑᴎлԉ",o:"øǿɔɵꝋꝍᴑ",oe:"œ",oi:"ƣ",oo:"ꝏ",ou:"ȣ",p:"ƥᵽꝑꝓꝕρ",q:"ꝗꝙɋ",r:"ɍɽꝛꞧꞃ",s:"ßȿꞩꞅʂ",t:"ŧƭʈⱦꞇ",th:"þ",tz:"ꜩ",u:"ʉ",v:"ʋꝟʌ",vy:"ꝡ",w:"ⱳ",y:"ƴɏỿ",z:"ƶȥɀⱬꝣ",hv:"ƕ"}
for(let e in f){let t=f[e]||""
for(let i=0;i<t.length;i++){let s=t.substring(i,i+1)
g[s]=e}}const v=new RegExp(Object.keys(g).join("|")+"|[̀-ͯ·ʾʼ]","gu"),m=(e,t="NFKD")=>e.normalize(t),y=e=>c(e).reduce(((e,t)=>e+O(t)),""),O=e=>(e=m(e).toLowerCase().replace(v,(e=>g[e]||"")),m(e,"NFC"))
const b=e=>{const t={},i=(e,i)=>{const s=t[e]||new Set,o=new RegExp("^"+n(s)+"$","iu")
i.match(o)||(s.add(r(i)),t[e]=s)}
for(let t of function*(e){for(const[t,i]of e)for(let e=t;e<=i;e++){let t=String.fromCharCode(e),i=y(t)
i!=t.toLowerCase()&&(i.length>3||0!=i.length&&(yield{folded:i,composed:t,code_point:e}))}}(e))i(t.folded,t.folded),i(t.folded,t.composed)
return t},w=e=>{const t=b(e),s={}
let o=[]
for(let e in t){let i=t[e]
i&&(s[e]=n(i)),e.length>1&&o.push(r(e))}o.sort(((e,t)=>t.length-e.length))
const l=i(o)
return h=new RegExp("^"+l,"u"),s},_=(e,t=1)=>(t=Math.max(t,e.length-1),i(d(e).map((e=>((e,t=1)=>{let i=0
return e=e.map((e=>(p[e]&&(i+=e.length),p[e]||e))),i>=t?s(e):""})(e,t))))),C=(e,t=!0)=>{let n=e.length>1?1:0
return i(e.map((e=>{let i=[]
const o=t?e.length():e.length()-1
for(let t=0;t<o;t++)i.push(_(e.substrs[t]||"",n))
return s(i)})))},S=(e,t)=>{for(const i of t){if(i.start!=e.start||i.end!=e.end)continue
if(i.substrs.join("")!==e.substrs.join(""))continue
let t=e.parts
const s=e=>{for(const i of t){if(i.start===e.start&&i.substr===e.substr)return!1
if(1!=e.length&&1!=i.length){if(e.start<i.start&&e.end>i.start)return!0
if(i.start<e.start&&i.end>e.start)return!0}}return!1}
if(!(i.parts.filter(s).length>0))return!0}return!1}
class I{constructor(){this.parts=[],this.substrs=[],this.start=0,this.end=0}add(e){e&&(this.parts.push(e),this.substrs.push(e.substr),this.start=Math.min(e.start,this.start),this.end=Math.max(e.end,this.end))}last(){return this.parts[this.parts.length-1]}length(){return this.parts.length}clone(e,t){let i=new I,s=JSON.parse(JSON.stringify(this.parts)),n=s.pop()
for(const e of s)i.add(e)
let o=t.substr.substring(0,e-n.start),r=o.length
return i.add({start:n.start,end:n.start+r,length:r,substr:o}),i}}const A=e=>{var t
void 0===p&&(p=w(t||u)),e=y(e)
let i="",s=[new I]
for(let t=0;t<e.length;t++){let n=e.substring(t).match(h)
const o=e.substring(t,t+1),r=n?n[0]:null
let l=[],a=new Set
for(const e of s){const i=e.last()
if(!i||1==i.length||i.end<=t)if(r){const i=r.length
e.add({start:t,end:t+i,length:i,substr:r}),a.add("1")}else e.add({start:t,end:t+1,length:1,substr:o}),a.add("2")
else if(r){let s=e.clone(t,i)
const n=r.length
s.add({start:t,end:t+n,length:n,substr:r}),l.push(s)}else a.add("3")}if(l.length>0){l=l.sort(((e,t)=>e.length()-t.length()))
for(let e of l)S(e,s)||s.push(e)}else if(t>0&&1==a.size&&!a.has("3")){i+=C(s,!1)
let e=new I
const t=s[0]
t&&e.add(t.last()),s=[e]}}return i+=C(s,!0),i},x=(e,t)=>{if(e)return e[t]},k=(e,t)=>{if(e){for(var i,s=t.split(".");(i=s.shift())&&(e=e[i]););return e}},F=(e,t,i)=>{var s,n
return e?(e+="",null==t.regex||-1===(n=e.search(t.regex))?0:(s=t.string.length/e.length,0===n&&(s+=.5),s*i)):0},L=(e,t)=>{var i=e[t]
if("function"==typeof i)return i
i&&!Array.isArray(i)&&(e[t]=[i])},E=(e,t)=>{if(Array.isArray(e))e.forEach(t)
else for(var i in e)e.hasOwnProperty(i)&&t(e[i],i)},T=(e,t)=>"number"==typeof e&&"number"==typeof t?e>t?1:e<t?-1:0:(e=y(e+"").toLowerCase())>(t=y(t+"").toLowerCase())?1:t>e?-1:0
class P{constructor(e,t){this.items=void 0,this.settings=void 0,this.items=e,this.settings=t||{diacritics:!0}}tokenize(e,t,i){if(!e||!e.length)return[]
const s=[],n=e.split(/\s+/)
var o
return i&&(o=new RegExp("^("+Object.keys(i).map(r).join("|")+"):(.*)$")),n.forEach((e=>{let i,n=null,l=null
o&&(i=e.match(o))&&(n=i[1],e=i[2]),e.length>0&&(l=this.settings.diacritics?A(e)||null:r(e),l&&t&&(l="\\b"+l)),s.push({string:e,regex:l?new RegExp(l,"iu"):null,field:n})})),s}getScoreFunction(e,t){var i=this.prepareSearch(e,t)
return this._getScoreFunction(i)}_getScoreFunction(e){const t=e.tokens,i=t.length
if(!i)return function(){return 0}
const s=e.options.fields,n=e.weights,o=s.length,r=e.getAttrFn
if(!o)return function(){return 1}
const l=1===o?function(e,t){const i=s[0].field
return F(r(t,i),e,n[i]||1)}:function(e,t){var i=0
if(e.field){const s=r(t,e.field)
!e.regex&&s?i+=1/o:i+=F(s,e,1)}else E(n,((s,n)=>{i+=F(r(t,n),e,s)}))
return i/o}
return 1===i?function(e){return l(t[0],e)}:"and"===e.options.conjunction?function(e){var s,n=0
for(let i of t){if((s=l(i,e))<=0)return 0
n+=s}return n/i}:function(e){var s=0
return E(t,(t=>{s+=l(t,e)})),s/i}}getSortFunction(e,t){var i=this.prepareSearch(e,t)
return this._getSortFunction(i)}_getSortFunction(e){var t,i=[]
const s=this,n=e.options,o=!e.query&&n.sort_empty?n.sort_empty:n.sort
if("function"==typeof o)return o.bind(this)
const r=function(t,i){return"$score"===t?i.score:e.getAttrFn(s.items[i.id],t)}
if(o)for(let t of o)(e.query||"$score"!==t.field)&&i.push(t)
if(e.query){t=!0
for(let e of i)if("$score"===e.field){t=!1
break}t&&i.unshift({field:"$score",direction:"desc"})}else i=i.filter((e=>"$score"!==e.field))
return i.length?function(e,t){var s,n
for(let o of i){if(n=o.field,s=("desc"===o.direction?-1:1)*T(r(n,e),r(n,t)))return s}return 0}:null}prepareSearch(e,t){const i={}
var s=Object.assign({},t)
if(L(s,"sort"),L(s,"sort_empty"),s.fields){L(s,"fields")
const e=[]
s.fields.forEach((t=>{"string"==typeof t&&(t={field:t,weight:1}),e.push(t),i[t.field]="weight"in t?t.weight:1})),s.fields=e}return{options:s,query:e.toLowerCase().trim(),tokens:this.tokenize(e,s.respect_word_boundaries,i),total:0,items:[],weights:i,getAttrFn:s.nesting?k:x}}search(e,t){var i,s,n=this
s=this.prepareSearch(e,t),t=s.options,e=s.query
const o=t.score||n._getScoreFunction(s)
e.length?E(n.items,((e,n)=>{i=o(e),(!1===t.filter||i>0)&&s.items.push({score:i,id:n})})):E(n.items,((e,t)=>{s.items.push({score:1,id:t})}))
const r=n._getSortFunction(s)
return r&&s.items.sort(r),s.total=s.items.length,"number"==typeof t.limit&&(s.items=s.items.slice(0,t.limit)),s}}const N=(e,t)=>{if(Array.isArray(e))e.forEach(t)
else for(var i in e)e.hasOwnProperty(i)&&t(e[i],i)},j=e=>{if(e.jquery)return e[0]
if(e instanceof HTMLElement)return e
if($(e)){var t=document.createElement("template")
return t.innerHTML=e.trim(),t.content.firstChild}return document.querySelector(e)},$=e=>"string"==typeof e&&e.indexOf("<")>-1,V=(e,t)=>{var i=document.createEvent("HTMLEvents")
i.initEvent(t,!0,!1),e.dispatchEvent(i)},q=(e,t)=>{Object.assign(e.style,t)},D=(e,...t)=>{var i=H(t);(e=M(e)).map((e=>{i.map((t=>{e.classList.add(t)}))}))},R=(e,...t)=>{var i=H(t);(e=M(e)).map((e=>{i.map((t=>{e.classList.remove(t)}))}))},H=e=>{var t=[]
return N(e,(e=>{"string"==typeof e&&(e=e.trim().split(/[\11\12\14\15\40]/)),Array.isArray(e)&&(t=t.concat(e))})),t.filter(Boolean)},M=e=>(Array.isArray(e)||(e=[e]),e),z=(e,t,i)=>{if(!i||i.contains(e))for(;e&&e.matches;){if(e.matches(t))return e
e=e.parentNode}},B=(e,t=0)=>t>0?e[e.length-1]:e[0],K=(e,t)=>{if(!e)return-1
t=t||e.nodeName
for(var i=0;e=e.previousElementSibling;)e.matches(t)&&i++
return i},Q=(e,t)=>{N(t,((t,i)=>{null==t?e.removeAttribute(i):e.setAttribute(i,""+t)}))},G=(e,t)=>{e.parentNode&&e.parentNode.replaceChild(t,e)},U=(e,t)=>{if(null===t)return
if("string"==typeof t){if(!t.length)return
t=new RegExp(t,"i")}const i=e=>3===e.nodeType?(e=>{var i=e.data.match(t)
if(i&&e.data.length>0){var s=document.createElement("span")
s.className="highlight"
var n=e.splitText(i.index)
n.splitText(i[0].length)
var o=n.cloneNode(!0)
return s.appendChild(o),G(n,s),1}return 0})(e):((e=>{1!==e.nodeType||!e.childNodes||/(script|style)/i.test(e.tagName)||"highlight"===e.className&&"SPAN"===e.tagName||Array.from(e.childNodes).forEach((e=>{i(e)}))})(e),0)
i(e)},J="undefined"!=typeof navigator&&/Mac/.test(navigator.userAgent)?"metaKey":"ctrlKey"
var W={options:[],optgroups:[],plugins:[],delimiter:",",splitOn:null,persist:!0,diacritics:!0,create:null,createOnBlur:!1,createFilter:null,highlight:!0,openOnFocus:!0,shouldOpen:null,maxOptions:50,maxItems:null,hideSelected:null,duplicates:!1,addPrecedence:!1,selectOnTab:!1,preload:null,allowEmptyOption:!1,refreshThrottle:300,loadThrottle:300,loadingClass:"loading",dataAttr:null,optgroupField:"optgroup",valueField:"value",labelField:"text",disabledField:"disabled",optgroupLabelField:"label",optgroupValueField:"value",lockOptgroupOrder:!1,sortField:"$order",searchField:["text"],searchConjunction:"and",mode:null,wrapperClass:"ts-wrapper",controlClass:"ts-control",dropdownClass:"ts-dropdown",dropdownContentClass:"ts-dropdown-content",itemClass:"item",optionClass:"option",dropdownParent:null,controlInput:'<input type="text" autocomplete="off" size="1" />',copyClassesToDropdown:!1,placeholder:null,hidePlaceholder:null,shouldLoad:function(e){return e.length>0},render:{}}
const X=e=>null==e?null:Y(e),Y=e=>"boolean"==typeof e?e?"1":"0":e+"",Z=e=>(e+"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),ee=(e,t)=>{var i
return function(s,n){var o=this
i&&(o.loading=Math.max(o.loading-1,0),clearTimeout(i)),i=setTimeout((function(){i=null,o.loadedSearches[s]=!0,e.call(o,s,n)}),t)}},te=(e,t,i)=>{var s,n=e.trigger,o={}
for(s of(e.trigger=function(){var i=arguments[0]
if(-1===t.indexOf(i))return n.apply(e,arguments)
o[i]=arguments},i.apply(e,[]),e.trigger=n,t))s in o&&n.apply(e,o[s])},ie=(e,t=!1)=>{e&&(e.preventDefault(),t&&e.stopPropagation())},se=(e,t,i,s)=>{e.addEventListener(t,i,s)},ne=(e,t)=>!!t&&(!!t[e]&&1===(t.altKey?1:0)+(t.ctrlKey?1:0)+(t.shiftKey?1:0)+(t.metaKey?1:0)),oe=(e,t)=>{const i=e.getAttribute("id")
return i||(e.setAttribute("id",t),t)},re=e=>e.replace(/[\\"']/g,"\\$&"),le=(e,t)=>{t&&e.append(t)}
function ae(e,t){var i=Object.assign({},W,t),s=i.dataAttr,n=i.labelField,o=i.valueField,r=i.disabledField,l=i.optgroupField,a=i.optgroupLabelField,c=i.optgroupValueField,d=e.tagName.toLowerCase(),u=e.getAttribute("placeholder")||e.getAttribute("data-placeholder")
if(!u&&!i.allowEmptyOption){let t=e.querySelector('option[value=""]')
t&&(u=t.textContent)}var p={placeholder:u,options:[],optgroups:[],items:[],maxItems:null}
return"select"===d?(()=>{var t,d=p.options,u={},h=1
let g=0
var f=e=>{var t=Object.assign({},e.dataset),i=s&&t[s]
return"string"==typeof i&&i.length&&(t=Object.assign(t,JSON.parse(i))),t},v=(e,t)=>{var s=X(e.value)
if(null!=s&&(s||i.allowEmptyOption)){if(u.hasOwnProperty(s)){if(t){var a=u[s][l]
a?Array.isArray(a)?a.push(t):u[s][l]=[a,t]:u[s][l]=t}}else{var c=f(e)
c[n]=c[n]||e.textContent,c[o]=c[o]||s,c[r]=c[r]||e.disabled,c[l]=c[l]||t,c.$option=e,c.$order=c.$order||++g,u[s]=c,d.push(c)}e.selected&&p.items.push(s)}}
p.maxItems=e.hasAttribute("multiple")?null:1,N(e.children,(e=>{var i,s,n
"optgroup"===(t=e.tagName.toLowerCase())?((n=f(i=e))[a]=n[a]||i.getAttribute("label")||"",n[c]=n[c]||h++,n[r]=n[r]||i.disabled,n.$order=n.$order||++g,p.optgroups.push(n),s=n[c],N(i.children,(e=>{v(e,s)}))):"option"===t&&v(e)}))})():(()=>{const t=e.getAttribute(s)
if(t)p.options=JSON.parse(t),N(p.options,(e=>{p.items.push(e[o])}))
else{var r=e.value.trim()||""
if(!i.allowEmptyOption&&!r.length)return
const t=r.split(i.delimiter)
N(t,(e=>{const t={}
t[n]=e,t[o]=e,p.options.push(t)})),p.items=t}})(),Object.assign({},W,p,t)}var ce=0
class de extends(function(e){return e.plugins={},class extends e{constructor(...e){super(...e),this.plugins={names:[],settings:{},requested:{},loaded:{}}}static define(t,i){e.plugins[t]={name:t,fn:i}}initializePlugins(e){var t,i
const s=this,n=[]
if(Array.isArray(e))e.forEach((e=>{"string"==typeof e?n.push(e):(s.plugins.settings[e.name]=e.options,n.push(e.name))}))
else if(e)for(t in e)e.hasOwnProperty(t)&&(s.plugins.settings[t]=e[t],n.push(t))
for(;i=n.shift();)s.require(i)}loadPlugin(t){var i=this,s=i.plugins,n=e.plugins[t]
if(!e.plugins.hasOwnProperty(t))throw new Error('Unable to find "'+t+'" plugin')
s.requested[t]=!0,s.loaded[t]=n.fn.apply(i,[i.plugins.settings[t]||{}]),s.names.push(t)}require(e){var t=this,i=t.plugins
if(!t.plugins.loaded.hasOwnProperty(e)){if(i.requested[e])throw new Error('Plugin has circular dependency ("'+e+'")')
t.loadPlugin(e)}return i.loaded[e]}}}(t)){constructor(e,t){var i
super(),this.control_input=void 0,this.wrapper=void 0,this.dropdown=void 0,this.control=void 0,this.dropdown_content=void 0,this.focus_node=void 0,this.order=0,this.settings=void 0,this.input=void 0,this.tabIndex=void 0,this.is_select_tag=void 0,this.rtl=void 0,this.inputId=void 0,this._destroy=void 0,this.sifter=void 0,this.isOpen=!1,this.isDisabled=!1,this.isReadOnly=!1,this.isRequired=void 0,this.isInvalid=!1,this.isValid=!0,this.isLocked=!1,this.isFocused=!1,this.isInputHidden=!1,this.isSetup=!1,this.ignoreFocus=!1,this.ignoreHover=!1,this.hasOptions=!1,this.currentResults=void 0,this.lastValue="",this.caretPos=0,this.loading=0,this.loadedSearches={},this.activeOption=null,this.activeItems=[],this.optgroups={},this.options={},this.userOptions={},this.items=[],this.refreshTimeout=null,ce++
var s=j(e)
if(s.tomselect)throw new Error("Tom Select already initialized on this element")
s.tomselect=this,i=(window.getComputedStyle&&window.getComputedStyle(s,null)).getPropertyValue("direction")
const n=ae(s,t)
this.settings=n,this.input=s,this.tabIndex=s.tabIndex||0,this.is_select_tag="select"===s.tagName.toLowerCase(),this.rtl=/rtl/i.test(i),this.inputId=oe(s,"tomselect-"+ce),this.isRequired=s.required,this.sifter=new P(this.options,{diacritics:n.diacritics}),n.mode=n.mode||(1===n.maxItems?"single":"multi"),"boolean"!=typeof n.hideSelected&&(n.hideSelected="multi"===n.mode),"boolean"!=typeof n.hidePlaceholder&&(n.hidePlaceholder="multi"!==n.mode)
var o=n.createFilter
"function"!=typeof o&&("string"==typeof o&&(o=new RegExp(o)),o instanceof RegExp?n.createFilter=e=>o.test(e):n.createFilter=e=>this.settings.duplicates||!this.options[e]),this.initializePlugins(n.plugins),this.setupCallbacks(),this.setupTemplates()
const r=j("<div>"),l=j("<div>"),a=this._render("dropdown"),c=j('<div role="listbox" tabindex="-1">'),d=this.input.getAttribute("class")||"",u=n.mode
var p
if(D(r,n.wrapperClass,d,u),D(l,n.controlClass),le(r,l),D(a,n.dropdownClass,u),n.copyClassesToDropdown&&D(a,d),D(c,n.dropdownContentClass),le(a,c),j(n.dropdownParent||r).appendChild(a),$(n.controlInput)){p=j(n.controlInput)
E(["autocorrect","autocapitalize","autocomplete","spellcheck"],(e=>{s.getAttribute(e)&&Q(p,{[e]:s.getAttribute(e)})})),p.tabIndex=-1,l.appendChild(p),this.focus_node=p}else n.controlInput?(p=j(n.controlInput),this.focus_node=p):(p=j("<input/>"),this.focus_node=l)
this.wrapper=r,this.dropdown=a,this.dropdown_content=c,this.control=l,this.control_input=p,this.setup()}setup(){const e=this,t=e.settings,i=e.control_input,s=e.dropdown,n=e.dropdown_content,o=e.wrapper,l=e.control,a=e.input,c=e.focus_node,d={passive:!0},u=e.inputId+"-ts-dropdown"
Q(n,{id:u}),Q(c,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":u})
const p=oe(c,e.inputId+"-ts-control"),h="label[for='"+(e=>e.replace(/['"\\]/g,"\\$&"))(e.inputId)+"']",g=document.querySelector(h),f=e.focus.bind(e)
if(g){se(g,"click",f),Q(g,{for:p})
const t=oe(g,e.inputId+"-ts-label")
Q(c,{"aria-labelledby":t}),Q(n,{"aria-labelledby":t})}if(o.style.width=a.style.width,e.plugins.names.length){const t="plugin-"+e.plugins.names.join(" plugin-")
D([o,s],t)}(null===t.maxItems||t.maxItems>1)&&e.is_select_tag&&Q(a,{multiple:"multiple"}),t.placeholder&&Q(i,{placeholder:t.placeholder}),!t.splitOn&&t.delimiter&&(t.splitOn=new RegExp("\\s*"+r(t.delimiter)+"+\\s*")),t.load&&t.loadThrottle&&(t.load=ee(t.load,t.loadThrottle)),se(s,"mousemove",(()=>{e.ignoreHover=!1})),se(s,"mouseenter",(t=>{var i=z(t.target,"[data-selectable]",s)
i&&e.onOptionHover(t,i)}),{capture:!0}),se(s,"click",(t=>{const i=z(t.target,"[data-selectable]")
i&&(e.onOptionSelect(t,i),ie(t,!0))})),se(l,"click",(t=>{var s=z(t.target,"[data-ts-item]",l)
s&&e.onItemSelect(t,s)?ie(t,!0):""==i.value&&(e.onClick(),ie(t,!0))})),se(c,"keydown",(t=>e.onKeyDown(t))),se(i,"keypress",(t=>e.onKeyPress(t))),se(i,"input",(t=>e.onInput(t))),se(c,"blur",(t=>e.onBlur(t))),se(c,"focus",(t=>e.onFocus(t))),se(i,"paste",(t=>e.onPaste(t)))
const v=t=>{const n=t.composedPath()[0]
if(!o.contains(n)&&!s.contains(n))return e.isFocused&&e.blur(),void e.inputState()
n==i&&e.isOpen?t.stopPropagation():ie(t,!0)},m=()=>{e.isOpen&&e.positionDropdown()}
se(document,"mousedown",v),se(window,"scroll",m,d),se(window,"resize",m,d),this._destroy=()=>{document.removeEventListener("mousedown",v),window.removeEventListener("scroll",m),window.removeEventListener("resize",m),g&&g.removeEventListener("click",f)},this.revertSettings={innerHTML:a.innerHTML,tabIndex:a.tabIndex},a.tabIndex=-1,a.insertAdjacentElement("afterend",e.wrapper),e.sync(!1),t.items=[],delete t.optgroups,delete t.options,se(a,"invalid",(()=>{e.isValid&&(e.isValid=!1,e.isInvalid=!0,e.refreshState())})),e.updateOriginalInput(),e.refreshItems(),e.close(!1),e.inputState(),e.isSetup=!0,a.disabled?e.disable():a.readOnly?e.setReadOnly(!0):e.enable(),e.on("change",this.onChange),D(a,"tomselected","ts-hidden-accessible"),e.trigger("initialize"),!0===t.preload&&e.preload()}setupOptions(e=[],t=[]){this.addOptions(e),E(t,(e=>{this.registerOptionGroup(e)}))}setupTemplates(){var e=this,t=e.settings.labelField,i=e.settings.optgroupLabelField,s={optgroup:e=>{let t=document.createElement("div")
return t.className="optgroup",t.appendChild(e.options),t},optgroup_header:(e,t)=>'<div class="optgroup-header">'+t(e[i])+"</div>",option:(e,i)=>"<div>"+i(e[t])+"</div>",item:(e,i)=>"<div>"+i(e[t])+"</div>",option_create:(e,t)=>'<div class="create">Add <strong>'+t(e.input)+"</strong>&hellip;</div>",no_results:()=>'<div class="no-results">No results found</div>',loading:()=>'<div class="spinner"></div>',not_loading:()=>{},dropdown:()=>"<div></div>"}
e.settings.render=Object.assign({},s,e.settings.render)}setupCallbacks(){var e,t,i={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"}
for(e in i)(t=this.settings[i[e]])&&this.on(e,t)}sync(e=!0){const t=this,i=e?ae(t.input,{delimiter:t.settings.delimiter}):t.settings
t.setupOptions(i.options,i.optgroups),t.setValue(i.items||[],!0),t.lastQuery=null}onClick(){var e=this
if(e.activeItems.length>0)return e.clearActiveItems(),void e.focus()
e.isFocused&&e.isOpen?e.blur():e.focus()}onMouseDown(){}onChange(){V(this.input,"input"),V(this.input,"change")}onPaste(e){var t=this
t.isInputHidden||t.isLocked?ie(e):t.settings.splitOn&&setTimeout((()=>{var e=t.inputValue()
if(e.match(t.settings.splitOn)){var i=e.trim().split(t.settings.splitOn)
E(i,(e=>{X(e)&&(this.options[e]?t.addItem(e):t.createItem(e))}))}}),0)}onKeyPress(e){var t=this
if(!t.isLocked){var i=String.fromCharCode(e.keyCode||e.which)
return t.settings.create&&"multi"===t.settings.mode&&i===t.settings.delimiter?(t.createItem(),void ie(e)):void 0}ie(e)}onKeyDown(e){var t=this
if(t.ignoreHover=!0,t.isLocked)9!==e.keyCode&&ie(e)
else{switch(e.keyCode){case 65:if(ne(J,e)&&""==t.control_input.value)return ie(e),void t.selectAll()
break
case 27:return t.isOpen&&(ie(e,!0),t.close()),void t.clearActiveItems()
case 40:if(!t.isOpen&&t.hasOptions)t.open()
else if(t.activeOption){let e=t.getAdjacent(t.activeOption,1)
e&&t.setActiveOption(e)}return void ie(e)
case 38:if(t.activeOption){let e=t.getAdjacent(t.activeOption,-1)
e&&t.setActiveOption(e)}return void ie(e)
case 13:return void(t.canSelect(t.activeOption)?(t.onOptionSelect(e,t.activeOption),ie(e)):(t.settings.create&&t.createItem()||document.activeElement==t.control_input&&t.isOpen)&&ie(e))
case 37:return void t.advanceSelection(-1,e)
case 39:return void t.advanceSelection(1,e)
case 9:return void(t.settings.selectOnTab&&(t.canSelect(t.activeOption)&&(t.onOptionSelect(e,t.activeOption),ie(e)),t.settings.create&&t.createItem()&&ie(e)))
case 8:case 46:return void t.deleteSelection(e)}t.isInputHidden&&!ne(J,e)&&ie(e)}}onInput(e){if(this.isLocked)return
const t=this.inputValue()
this.lastValue!==t&&(this.lastValue=t,""!=t?(this.refreshTimeout&&clearTimeout(this.refreshTimeout),this.refreshTimeout=((e,t)=>t>0?setTimeout(e,t):(e.call(null),null))((()=>{this.refreshTimeout=null,this._onInput()}),this.settings.refreshThrottle)):this._onInput())}_onInput(){const e=this.lastValue
this.settings.shouldLoad.call(this,e)&&this.load(e),this.refreshOptions(),this.trigger("type",e)}onOptionHover(e,t){this.ignoreHover||this.setActiveOption(t,!1)}onFocus(e){var t=this,i=t.isFocused
if(t.isDisabled||t.isReadOnly)return t.blur(),void ie(e)
t.ignoreFocus||(t.isFocused=!0,"focus"===t.settings.preload&&t.preload(),i||t.trigger("focus"),t.activeItems.length||(t.inputState(),t.refreshOptions(!!t.settings.openOnFocus)),t.refreshState())}onBlur(e){if(!1!==document.hasFocus()){var t=this
if(t.isFocused){t.isFocused=!1,t.ignoreFocus=!1
var i=()=>{t.close(),t.setActiveItem(),t.setCaret(t.items.length),t.trigger("blur")}
t.settings.create&&t.settings.createOnBlur?t.createItem(null,i):i()}}}onOptionSelect(e,t){var i,s=this
t.parentElement&&t.parentElement.matches("[data-disabled]")||(t.classList.contains("create")?s.createItem(null,(()=>{s.settings.closeAfterSelect&&s.close()})):void 0!==(i=t.dataset.value)&&(s.lastQuery=null,s.addItem(i),s.settings.closeAfterSelect&&s.close(),!s.settings.hideSelected&&e.type&&/click/.test(e.type)&&s.setActiveOption(t)))}canSelect(e){return!!(this.isOpen&&e&&this.dropdown_content.contains(e))}onItemSelect(e,t){var i=this
return!i.isLocked&&"multi"===i.settings.mode&&(ie(e),i.setActiveItem(t,e),!0)}canLoad(e){return!!this.settings.load&&!this.loadedSearches.hasOwnProperty(e)}load(e){const t=this
if(!t.canLoad(e))return
D(t.wrapper,t.settings.loadingClass),t.loading++
const i=t.loadCallback.bind(t)
t.settings.load.call(t,e,i)}loadCallback(e,t){const i=this
i.loading=Math.max(i.loading-1,0),i.lastQuery=null,i.clearActiveOption(),i.setupOptions(e,t),i.refreshOptions(i.isFocused&&!i.isInputHidden),i.loading||R(i.wrapper,i.settings.loadingClass),i.trigger("load",e,t)}preload(){var e=this.wrapper.classList
e.contains("preloaded")||(e.add("preloaded"),this.load(""))}setTextboxValue(e=""){var t=this.control_input
t.value!==e&&(t.value=e,V(t,"update"),this.lastValue=e)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(e,t){te(this,t?[]:["change"],(()=>{this.clear(t),this.addItems(e,t)}))}setMaxItems(e){0===e&&(e=null),this.settings.maxItems=e,this.refreshState()}setActiveItem(e,t){var i,s,n,o,r,l,a=this
if("single"!==a.settings.mode){if(!e)return a.clearActiveItems(),void(a.isFocused&&a.inputState())
if("click"===(i=t&&t.type.toLowerCase())&&ne("shiftKey",t)&&a.activeItems.length){for(l=a.getLastActive(),(n=Array.prototype.indexOf.call(a.control.children,l))>(o=Array.prototype.indexOf.call(a.control.children,e))&&(r=n,n=o,o=r),s=n;s<=o;s++)e=a.control.children[s],-1===a.activeItems.indexOf(e)&&a.setActiveItemClass(e)
ie(t)}else"click"===i&&ne(J,t)||"keydown"===i&&ne("shiftKey",t)?e.classList.contains("active")?a.removeActiveItem(e):a.setActiveItemClass(e):(a.clearActiveItems(),a.setActiveItemClass(e))
a.inputState(),a.isFocused||a.focus()}}setActiveItemClass(e){const t=this,i=t.control.querySelector(".last-active")
i&&R(i,"last-active"),D(e,"active last-active"),t.trigger("item_select",e),-1==t.activeItems.indexOf(e)&&t.activeItems.push(e)}removeActiveItem(e){var t=this.activeItems.indexOf(e)
this.activeItems.splice(t,1),R(e,"active")}clearActiveItems(){R(this.activeItems,"active"),this.activeItems=[]}setActiveOption(e,t=!0){e!==this.activeOption&&(this.clearActiveOption(),e&&(this.activeOption=e,Q(this.focus_node,{"aria-activedescendant":e.getAttribute("id")}),Q(e,{"aria-selected":"true"}),D(e,"active"),t&&this.scrollToOption(e)))}scrollToOption(e,t){if(!e)return
const i=this.dropdown_content,s=i.clientHeight,n=i.scrollTop||0,o=e.offsetHeight,r=e.getBoundingClientRect().top-i.getBoundingClientRect().top+n
r+o>s+n?this.scroll(r-s+o,t):r<n&&this.scroll(r,t)}scroll(e,t){const i=this.dropdown_content
t&&(i.style.scrollBehavior=t),i.scrollTop=e,i.style.scrollBehavior=""}clearActiveOption(){this.activeOption&&(R(this.activeOption,"active"),Q(this.activeOption,{"aria-selected":null})),this.activeOption=null,Q(this.focus_node,{"aria-activedescendant":null})}selectAll(){const e=this
if("single"===e.settings.mode)return
const t=e.controlChildren()
t.length&&(e.inputState(),e.close(),e.activeItems=t,E(t,(t=>{e.setActiveItemClass(t)})))}inputState(){var e=this
e.control.contains(e.control_input)&&(Q(e.control_input,{placeholder:e.settings.placeholder}),e.activeItems.length>0||!e.isFocused&&e.settings.hidePlaceholder&&e.items.length>0?(e.setTextboxValue(),e.isInputHidden=!0):(e.settings.hidePlaceholder&&e.items.length>0&&Q(e.control_input,{placeholder:""}),e.isInputHidden=!1),e.wrapper.classList.toggle("input-hidden",e.isInputHidden))}inputValue(){return this.control_input.value.trim()}focus(){var e=this
e.isDisabled||e.isReadOnly||(e.ignoreFocus=!0,e.control_input.offsetWidth?e.control_input.focus():e.focus_node.focus(),setTimeout((()=>{e.ignoreFocus=!1,e.onFocus()}),0))}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(e){return this.sifter.getScoreFunction(e,this.getSearchOptions())}getSearchOptions(){var e=this.settings,t=e.sortField
return"string"==typeof e.sortField&&(t=[{field:e.sortField}]),{fields:e.searchField,conjunction:e.searchConjunction,sort:t,nesting:e.nesting}}search(e){var t,i,s=this,n=this.getSearchOptions()
if(s.settings.score&&"function"!=typeof(i=s.settings.score.call(s,e)))throw new Error('Tom Select "score" setting must be a function that returns a function')
return e!==s.lastQuery?(s.lastQuery=e,t=s.sifter.search(e,Object.assign(n,{score:i})),s.currentResults=t):t=Object.assign({},s.currentResults),s.settings.hideSelected&&(t.items=t.items.filter((e=>{let t=X(e.id)
return!(t&&-1!==s.items.indexOf(t))}))),t}refreshOptions(e=!0){var t,i,s,n,o,r,l,a,c,d
const u={},p=[]
var h=this,g=h.inputValue()
const f=g===h.lastQuery||""==g&&null==h.lastQuery
var v=h.search(g),m=null,y=h.settings.shouldOpen||!1,O=h.dropdown_content
f&&(m=h.activeOption)&&(c=m.closest("[data-group]")),n=v.items.length,"number"==typeof h.settings.maxOptions&&(n=Math.min(n,h.settings.maxOptions)),n>0&&(y=!0)
const b=(e,t)=>{let i=u[e]
if(void 0!==i){let e=p[i]
if(void 0!==e)return[i,e.fragment]}let s=document.createDocumentFragment()
return i=p.length,p.push({fragment:s,order:t,optgroup:e}),[i,s]}
for(t=0;t<n;t++){let e=v.items[t]
if(!e)continue
let n=e.id,l=h.options[n]
if(void 0===l)continue
let a=Y(n),d=h.getOption(a,!0)
for(h.settings.hideSelected||d.classList.toggle("selected",h.items.includes(a)),o=l[h.settings.optgroupField]||"",i=0,s=(r=Array.isArray(o)?o:[o])&&r.length;i<s;i++){o=r[i]
let e=l.$order,t=h.optgroups[o]
void 0===t?o="":e=t.$order
const[s,a]=b(o,e)
i>0&&(d=d.cloneNode(!0),Q(d,{id:l.$id+"-clone-"+i,"aria-selected":null}),d.classList.add("ts-cloned"),R(d,"active"),h.activeOption&&h.activeOption.dataset.value==n&&c&&c.dataset.group===o.toString()&&(m=d)),a.appendChild(d),""!=o&&(u[o]=s)}}var w
h.settings.lockOptgroupOrder&&p.sort(((e,t)=>e.order-t.order)),l=document.createDocumentFragment(),E(p,(e=>{let t=e.fragment,i=e.optgroup
if(!t||!t.children.length)return
let s=h.optgroups[i]
if(void 0!==s){let e=document.createDocumentFragment(),i=h.render("optgroup_header",s)
le(e,i),le(e,t)
let n=h.render("optgroup",{group:s,options:e})
le(l,n)}else le(l,t)})),O.innerHTML="",le(O,l),h.settings.highlight&&(w=O.querySelectorAll("span.highlight"),Array.prototype.forEach.call(w,(function(e){var t=e.parentNode
t.replaceChild(e.firstChild,e),t.normalize()})),v.query.length&&v.tokens.length&&E(v.tokens,(e=>{U(O,e.regex)})))
var _=e=>{let t=h.render(e,{input:g})
return t&&(y=!0,O.insertBefore(t,O.firstChild)),t}
if(h.loading?_("loading"):h.settings.shouldLoad.call(h,g)?0===v.items.length&&_("no_results"):_("not_loading"),(a=h.canCreate(g))&&(d=_("option_create")),h.hasOptions=v.items.length>0||a,y){if(v.items.length>0){if(m||"single"!==h.settings.mode||null==h.items[0]||(m=h.getOption(h.items[0])),!O.contains(m)){let e=0
d&&!h.settings.addPrecedence&&(e=1),m=h.selectable()[e]}}else d&&(m=d)
e&&!h.isOpen&&(h.open(),h.scrollToOption(m,"auto")),h.setActiveOption(m)}else h.clearActiveOption(),e&&h.isOpen&&h.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(e,t=!1){const i=this
if(Array.isArray(e))return i.addOptions(e,t),!1
const s=X(e[i.settings.valueField])
return null!==s&&!i.options.hasOwnProperty(s)&&(e.$order=e.$order||++i.order,e.$id=i.inputId+"-opt-"+e.$order,i.options[s]=e,i.lastQuery=null,t&&(i.userOptions[s]=t,i.trigger("option_add",s,e)),s)}addOptions(e,t=!1){E(e,(e=>{this.addOption(e,t)}))}registerOption(e){return this.addOption(e)}registerOptionGroup(e){var t=X(e[this.settings.optgroupValueField])
return null!==t&&(e.$order=e.$order||++this.order,this.optgroups[t]=e,t)}addOptionGroup(e,t){var i
t[this.settings.optgroupValueField]=e,(i=this.registerOptionGroup(t))&&this.trigger("optgroup_add",i,t)}removeOptionGroup(e){this.optgroups.hasOwnProperty(e)&&(delete this.optgroups[e],this.clearCache(),this.trigger("optgroup_remove",e))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(e,t){const i=this
var s,n
const o=X(e),r=X(t[i.settings.valueField])
if(null===o)return
const l=i.options[o]
if(null==l)return
if("string"!=typeof r)throw new Error("Value must be set in option data")
const a=i.getOption(o),c=i.getItem(o)
if(t.$order=t.$order||l.$order,delete i.options[o],i.uncacheValue(r),i.options[r]=t,a){if(i.dropdown_content.contains(a)){const e=i._render("option",t)
G(a,e),i.activeOption===a&&i.setActiveOption(e)}a.remove()}c&&(-1!==(n=i.items.indexOf(o))&&i.items.splice(n,1,r),s=i._render("item",t),c.classList.contains("active")&&D(s,"active"),G(c,s)),i.lastQuery=null}removeOption(e,t){const i=this
e=Y(e),i.uncacheValue(e),delete i.userOptions[e],delete i.options[e],i.lastQuery=null,i.trigger("option_remove",e),i.removeItem(e,t)}clearOptions(e){const t=(e||this.clearFilter).bind(this)
this.loadedSearches={},this.userOptions={},this.clearCache()
const i={}
E(this.options,((e,s)=>{t(e,s)&&(i[s]=e)})),this.options=this.sifter.items=i,this.lastQuery=null,this.trigger("option_clear")}clearFilter(e,t){return this.items.indexOf(t)>=0}getOption(e,t=!1){const i=X(e)
if(null===i)return null
const s=this.options[i]
if(null!=s){if(s.$div)return s.$div
if(t)return this._render("option",s)}return null}getAdjacent(e,t,i="option"){var s
if(!e)return null
s="item"==i?this.controlChildren():this.dropdown_content.querySelectorAll("[data-selectable]")
for(let i=0;i<s.length;i++)if(s[i]==e)return t>0?s[i+1]:s[i-1]
return null}getItem(e){if("object"==typeof e)return e
var t=X(e)
return null!==t?this.control.querySelector(`[data-value="${re(t)}"]`):null}addItems(e,t){var i=this,s=Array.isArray(e)?e:[e]
const n=(s=s.filter((e=>-1===i.items.indexOf(e))))[s.length-1]
s.forEach((e=>{i.isPending=e!==n,i.addItem(e,t)}))}addItem(e,t){te(this,t?[]:["change","dropdown_close"],(()=>{var i,s
const n=this,o=n.settings.mode,r=X(e)
if((!r||-1===n.items.indexOf(r)||("single"===o&&n.close(),"single"!==o&&n.settings.duplicates))&&null!==r&&n.options.hasOwnProperty(r)&&("single"===o&&n.clear(t),"multi"!==o||!n.isFull())){if(i=n._render("item",n.options[r]),n.control.contains(i)&&(i=i.cloneNode(!0)),s=n.isFull(),n.items.splice(n.caretPos,0,r),n.insertAtCaret(i),n.isSetup){if(!n.isPending&&n.settings.hideSelected){let e=n.getOption(r),t=n.getAdjacent(e,1)
t&&n.setActiveOption(t)}n.isPending||n.settings.closeAfterSelect||n.refreshOptions(n.isFocused&&"single"!==o),0!=n.settings.closeAfterSelect&&n.isFull()?n.close():n.isPending||n.positionDropdown(),n.trigger("item_add",r,i),n.isPending||n.updateOriginalInput({silent:t})}(!n.isPending||!s&&n.isFull())&&(n.inputState(),n.refreshState())}}))}removeItem(e=null,t){const i=this
if(!(e=i.getItem(e)))return
var s,n
const o=e.dataset.value
s=K(e),e.remove(),e.classList.contains("active")&&(n=i.activeItems.indexOf(e),i.activeItems.splice(n,1),R(e,"active")),i.items.splice(s,1),i.lastQuery=null,!i.settings.persist&&i.userOptions.hasOwnProperty(o)&&i.removeOption(o,t),s<i.caretPos&&i.setCaret(i.caretPos-1),i.updateOriginalInput({silent:t}),i.refreshState(),i.positionDropdown(),i.trigger("item_remove",o,e)}createItem(e=null,t=(()=>{})){3===arguments.length&&(t=arguments[2]),"function"!=typeof t&&(t=()=>{})
var i,s=this,n=s.caretPos
if(e=e||s.inputValue(),!s.canCreate(e))return t(),!1
s.lock()
var o=!1,r=e=>{if(s.unlock(),!e||"object"!=typeof e)return t()
var i=X(e[s.settings.valueField])
if("string"!=typeof i)return t()
s.setTextboxValue(),s.addOption(e,!0),s.setCaret(n),s.addItem(i),t(e),o=!0}
return i="function"==typeof s.settings.create?s.settings.create.call(this,e,r):{[s.settings.labelField]:e,[s.settings.valueField]:e},o||r(i),!0}refreshItems(){var e=this
e.lastQuery=null,e.isSetup&&e.addItems(e.items),e.updateOriginalInput(),e.refreshState()}refreshState(){const e=this
e.refreshValidityState()
const t=e.isFull(),i=e.isLocked
e.wrapper.classList.toggle("rtl",e.rtl)
const s=e.wrapper.classList
var n
s.toggle("focus",e.isFocused),s.toggle("disabled",e.isDisabled),s.toggle("readonly",e.isReadOnly),s.toggle("required",e.isRequired),s.toggle("invalid",!e.isValid),s.toggle("locked",i),s.toggle("full",t),s.toggle("input-active",e.isFocused&&!e.isInputHidden),s.toggle("dropdown-active",e.isOpen),s.toggle("has-options",(n=e.options,0===Object.keys(n).length)),s.toggle("has-items",e.items.length>0)}refreshValidityState(){var e=this
e.input.validity&&(e.isValid=e.input.validity.valid,e.isInvalid=!e.isValid)}isFull(){return null!==this.settings.maxItems&&this.items.length>=this.settings.maxItems}updateOriginalInput(e={}){const t=this
var i,s
const n=t.input.querySelector('option[value=""]')
if(t.is_select_tag){const o=[],r=t.input.querySelectorAll("option:checked").length
function l(e,i,s){return e||(e=j('<option value="'+Z(i)+'">'+Z(s)+"</option>")),e!=n&&t.input.append(e),o.push(e),(e!=n||r>0)&&(e.selected=!0),e}t.input.querySelectorAll("option:checked").forEach((e=>{e.selected=!1})),0==t.items.length&&"single"==t.settings.mode?l(n,"",""):t.items.forEach((e=>{if(i=t.options[e],s=i[t.settings.labelField]||"",o.includes(i.$option)){l(t.input.querySelector(`option[value="${re(e)}"]:not(:checked)`),e,s)}else i.$option=l(i.$option,e,s)}))}else t.input.value=t.getValue()
t.isSetup&&(e.silent||t.trigger("change",t.getValue()))}open(){var e=this
e.isLocked||e.isOpen||"multi"===e.settings.mode&&e.isFull()||(e.isOpen=!0,Q(e.focus_node,{"aria-expanded":"true"}),e.refreshState(),q(e.dropdown,{visibility:"hidden",display:"block"}),e.positionDropdown(),q(e.dropdown,{visibility:"visible",display:"block"}),e.focus(),e.trigger("dropdown_open",e.dropdown))}close(e=!0){var t=this,i=t.isOpen
e&&(t.setTextboxValue(),"single"===t.settings.mode&&t.items.length&&t.inputState()),t.isOpen=!1,Q(t.focus_node,{"aria-expanded":"false"}),q(t.dropdown,{display:"none"}),t.settings.hideSelected&&t.clearActiveOption(),t.refreshState(),i&&t.trigger("dropdown_close",t.dropdown)}positionDropdown(){if("body"===this.settings.dropdownParent){var e=this.control,t=e.getBoundingClientRect(),i=e.offsetHeight+t.top+window.scrollY,s=t.left+window.scrollX
q(this.dropdown,{width:t.width+"px",top:i+"px",left:s+"px"})}}clear(e){var t=this
if(t.items.length){var i=t.controlChildren()
E(i,(e=>{t.removeItem(e,!0)})),t.inputState(),e||t.updateOriginalInput(),t.trigger("clear")}}insertAtCaret(e){const t=this,i=t.caretPos,s=t.control
s.insertBefore(e,s.children[i]||null),t.setCaret(i+1)}deleteSelection(e){var t,i,s,n,o,r=this
t=e&&8===e.keyCode?-1:1,i={start:(o=r.control_input).selectionStart||0,length:(o.selectionEnd||0)-(o.selectionStart||0)}
const l=[]
if(r.activeItems.length)n=B(r.activeItems,t),s=K(n),t>0&&s++,E(r.activeItems,(e=>l.push(e)))
else if((r.isFocused||"single"===r.settings.mode)&&r.items.length){const e=r.controlChildren()
let s
t<0&&0===i.start&&0===i.length?s=e[r.caretPos-1]:t>0&&i.start===r.inputValue().length&&(s=e[r.caretPos]),void 0!==s&&l.push(s)}if(!r.shouldDelete(l,e))return!1
for(ie(e,!0),void 0!==s&&r.setCaret(s);l.length;)r.removeItem(l.pop())
return r.inputState(),r.positionDropdown(),r.refreshOptions(!1),!0}shouldDelete(e,t){const i=e.map((e=>e.dataset.value))
return!(!i.length||"function"==typeof this.settings.onDelete&&!1===this.settings.onDelete(i,t))}advanceSelection(e,t){var i,s,n=this
n.rtl&&(e*=-1),n.inputValue().length||(ne(J,t)||ne("shiftKey",t)?(s=(i=n.getLastActive(e))?i.classList.contains("active")?n.getAdjacent(i,e,"item"):i:e>0?n.control_input.nextElementSibling:n.control_input.previousElementSibling)&&(s.classList.contains("active")&&n.removeActiveItem(i),n.setActiveItemClass(s)):n.moveCaret(e))}moveCaret(e){}getLastActive(e){let t=this.control.querySelector(".last-active")
if(t)return t
var i=this.control.querySelectorAll(".active")
return i?B(i,e):void 0}setCaret(e){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.setLocked(!0)}unlock(){this.setLocked(!1)}setLocked(e=this.isReadOnly||this.isDisabled){this.isLocked=e,this.refreshState()}disable(){this.setDisabled(!0),this.close()}enable(){this.setDisabled(!1)}setDisabled(e){this.focus_node.tabIndex=e?-1:this.tabIndex,this.isDisabled=e,this.input.disabled=e,this.control_input.disabled=e,this.setLocked()}setReadOnly(e){this.isReadOnly=e,this.input.readOnly=e,this.control_input.readOnly=e,this.setLocked()}destroy(){var e=this,t=e.revertSettings
e.trigger("destroy"),e.off(),e.wrapper.remove(),e.dropdown.remove(),e.input.innerHTML=t.innerHTML,e.input.tabIndex=t.tabIndex,R(e.input,"tomselected","ts-hidden-accessible"),e._destroy(),delete e.input.tomselect}render(e,t){var i,s
const n=this
if("function"!=typeof this.settings.render[e])return null
if(!(s=n.settings.render[e].call(this,t,Z)))return null
if(s=j(s),"option"===e||"option_create"===e?t[n.settings.disabledField]?Q(s,{"aria-disabled":"true"}):Q(s,{"data-selectable":""}):"optgroup"===e&&(i=t.group[n.settings.optgroupValueField],Q(s,{"data-group":i}),t.group[n.settings.disabledField]&&Q(s,{"data-disabled":""})),"option"===e||"item"===e){const i=Y(t[n.settings.valueField])
Q(s,{"data-value":i}),"item"===e?(D(s,n.settings.itemClass),Q(s,{"data-ts-item":""})):(D(s,n.settings.optionClass),Q(s,{role:"option",id:t.$id}),t.$div=s,n.options[i]=t)}return s}_render(e,t){const i=this.render(e,t)
if(null==i)throw"HTMLElement expected"
return i}clearCache(){E(this.options,(e=>{e.$div&&(e.$div.remove(),delete e.$div)}))}uncacheValue(e){const t=this.getOption(e)
t&&t.remove()}canCreate(e){return this.settings.create&&e.length>0&&this.settings.createFilter.call(this,e)}hook(e,t,i){var s=this,n=s[t]
s[t]=function(){var t,o
return"after"===e&&(t=n.apply(s,arguments)),o=i.apply(s,arguments),"instead"===e?o:("before"===e&&(t=n.apply(s,arguments)),t)}}}return de.define("change_listener",(function(){se(this.input,"change",(()=>{this.sync()}))})),de.define("checkbox_options",(function(e){var t=this,i=t.onOptionSelect
t.settings.hideSelected=!1
const s=Object.assign({className:"tomselect-checkbox",checkedClassNames:void 0,uncheckedClassNames:void 0},e)
var n=function(e,t){t?(e.checked=!0,s.uncheckedClassNames&&e.classList.remove(...s.uncheckedClassNames),s.checkedClassNames&&e.classList.add(...s.checkedClassNames)):(e.checked=!1,s.checkedClassNames&&e.classList.remove(...s.checkedClassNames),s.uncheckedClassNames&&e.classList.add(...s.uncheckedClassNames))},o=function(e){setTimeout((()=>{var t=e.querySelector("input."+s.className)
t instanceof HTMLInputElement&&n(t,e.classList.contains("selected"))}),1)}
t.hook("after","setupTemplates",(()=>{var e=t.settings.render.option
t.settings.render.option=(i,o)=>{var r=j(e.call(t,i,o)),l=document.createElement("input")
s.className&&l.classList.add(s.className),l.addEventListener("click",(function(e){ie(e)})),l.type="checkbox"
const a=X(i[t.settings.valueField])
return n(l,!!(a&&t.items.indexOf(a)>-1)),r.prepend(l),r}})),t.on("item_remove",(e=>{var i=t.getOption(e)
i&&(i.classList.remove("selected"),o(i))})),t.on("item_add",(e=>{var i=t.getOption(e)
i&&o(i)})),t.hook("instead","onOptionSelect",((e,s)=>{if(s.classList.contains("selected"))return s.classList.remove("selected"),t.removeItem(s.dataset.value),t.refreshOptions(),void ie(e,!0)
i.call(t,e,s),o(s)}))})),de.define("clear_button",(function(e){const t=this,i=Object.assign({className:"clear-button",title:"Clear All",html:e=>`<div class="${e.className}" title="${e.title}">&#10799;</div>`},e)
t.on("initialize",(()=>{var e=j(i.html(i))
e.addEventListener("click",(e=>{t.isLocked||(t.clear(),"single"===t.settings.mode&&t.settings.allowEmptyOption&&t.addItem(""),e.preventDefault(),e.stopPropagation())})),t.control.appendChild(e)}))})),de.define("drag_drop",(function(){var e=this
if("multi"!==e.settings.mode)return
var t=e.lock,i=e.unlock
let s,n=!0
e.hook("after","setupTemplates",(()=>{var t=e.settings.render.item
e.settings.render.item=(i,o)=>{const r=j(t.call(e,i,o))
Q(r,{draggable:"true"})
const l=e=>{e.preventDefault(),r.classList.add("ts-drag-over"),a(r,s)},a=(e,t)=>{var i,s,n
void 0!==t&&(((e,t)=>{do{var i
if(e==(t=null==(i=t)?void 0:i.previousElementSibling))return!0}while(t&&t.previousElementSibling)
return!1})(t,r)?(s=t,null==(n=(i=e).parentNode)||n.insertBefore(s,i.nextSibling)):((e,t)=>{var i
null==(i=e.parentNode)||i.insertBefore(t,e)})(e,t))}
return se(r,"mousedown",(e=>{n||ie(e),e.stopPropagation()})),se(r,"dragstart",(e=>{s=r,setTimeout((()=>{r.classList.add("ts-dragging")}),0)})),se(r,"dragenter",l),se(r,"dragover",l),se(r,"dragleave",(()=>{r.classList.remove("ts-drag-over")})),se(r,"dragend",(()=>{var t
document.querySelectorAll(".ts-drag-over").forEach((e=>e.classList.remove("ts-drag-over"))),null==(t=s)||t.classList.remove("ts-dragging"),s=void 0
var i=[]
e.control.querySelectorAll("[data-value]").forEach((e=>{if(e.dataset.value){let t=e.dataset.value
t&&i.push(t)}})),e.setValue(i)})),r}})),e.hook("instead","lock",(()=>(n=!1,t.call(e)))),e.hook("instead","unlock",(()=>(n=!0,i.call(e))))})),de.define("dropdown_header",(function(e){const t=this,i=Object.assign({title:"Untitled",headerClass:"dropdown-header",titleRowClass:"dropdown-header-title",labelClass:"dropdown-header-label",closeClass:"dropdown-header-close",html:e=>'<div class="'+e.headerClass+'"><div class="'+e.titleRowClass+'"><span class="'+e.labelClass+'">'+e.title+'</span><a class="'+e.closeClass+'">&times;</a></div></div>'},e)
t.on("initialize",(()=>{var e=j(i.html(i)),s=e.querySelector("."+i.closeClass)
s&&s.addEventListener("click",(e=>{ie(e,!0),t.close()})),t.dropdown.insertBefore(e,t.dropdown.firstChild)}))})),de.define("caret_position",(function(){var e=this
e.hook("instead","setCaret",(t=>{"single"!==e.settings.mode&&e.control.contains(e.control_input)?(t=Math.max(0,Math.min(e.items.length,t)))==e.caretPos||e.isPending||e.controlChildren().forEach(((i,s)=>{s<t?e.control_input.insertAdjacentElement("beforebegin",i):e.control.appendChild(i)})):t=e.items.length,e.caretPos=t})),e.hook("instead","moveCaret",(t=>{if(!e.isFocused)return
const i=e.getLastActive(t)
if(i){const s=K(i)
e.setCaret(t>0?s+1:s),e.setActiveItem(),R(i,"last-active")}else e.setCaret(e.caretPos+t)}))})),de.define("dropdown_input",(function(){const e=this
e.settings.shouldOpen=!0,e.hook("before","setup",(()=>{e.focus_node=e.control,D(e.control_input,"dropdown-input")
const t=j('<div class="dropdown-input-wrap">')
t.append(e.control_input),e.dropdown.insertBefore(t,e.dropdown.firstChild)
const i=j('<input class="items-placeholder" tabindex="-1" />')
i.placeholder=e.settings.placeholder||"",e.control.append(i)})),e.on("initialize",(()=>{e.control_input.addEventListener("keydown",(t=>{switch(t.keyCode){case 27:return e.isOpen&&(ie(t,!0),e.close()),void e.clearActiveItems()
case 9:e.focus_node.tabIndex=-1}return e.onKeyDown.call(e,t)})),e.on("blur",(()=>{e.focus_node.tabIndex=e.isDisabled?-1:e.tabIndex})),e.on("dropdown_open",(()=>{e.control_input.focus()}))
const t=e.onBlur
e.hook("instead","onBlur",(i=>{if(!i||i.relatedTarget!=e.control_input)return t.call(e)})),se(e.control_input,"blur",(()=>e.onBlur())),e.hook("before","close",(()=>{e.isOpen&&e.focus_node.focus({preventScroll:!0})}))}))})),de.define("input_autogrow",(function(){var e=this
e.on("initialize",(()=>{var t=document.createElement("span"),i=e.control_input
t.style.cssText="position:absolute; top:-99999px; left:-99999px; width:auto; padding:0; white-space:pre; ",e.wrapper.appendChild(t)
for(const e of["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"])t.style[e]=i.style[e]
var s=()=>{t.textContent=i.value,i.style.width=t.clientWidth+"px"}
s(),e.on("update item_add item_remove",s),se(i,"input",s),se(i,"keyup",s),se(i,"blur",s),se(i,"update",s)}))})),de.define("no_backspace_delete",(function(){var e=this,t=e.deleteSelection
this.hook("instead","deleteSelection",(i=>!!e.activeItems.length&&t.call(e,i)))})),de.define("no_active_items",(function(){this.hook("instead","setActiveItem",(()=>{})),this.hook("instead","selectAll",(()=>{}))})),de.define("optgroup_columns",(function(){var e=this,t=e.onKeyDown
e.hook("instead","onKeyDown",(i=>{var s,n,o,r
if(!e.isOpen||37!==i.keyCode&&39!==i.keyCode)return t.call(e,i)
e.ignoreHover=!0,r=z(e.activeOption,"[data-group]"),s=K(e.activeOption,"[data-selectable]"),r&&(r=37===i.keyCode?r.previousSibling:r.nextSibling)&&(n=(o=r.querySelectorAll("[data-selectable]"))[Math.min(o.length-1,s)])&&e.setActiveOption(n)}))})),de.define("remove_button",(function(e){const t=Object.assign({label:"&times;",title:"Remove",className:"remove",append:!0},e)
var i=this
if(t.append){var s='<a href="javascript:void(0)" class="'+t.className+'" tabindex="-1" title="'+Z(t.title)+'">'+t.label+"</a>"
i.hook("after","setupTemplates",(()=>{var e=i.settings.render.item
i.settings.render.item=(t,n)=>{var o=j(e.call(i,t,n)),r=j(s)
return o.appendChild(r),se(r,"mousedown",(e=>{ie(e,!0)})),se(r,"click",(e=>{i.isLocked||(ie(e,!0),i.isLocked||i.shouldDelete([o],e)&&(i.removeItem(o),i.refreshOptions(!1),i.inputState()))})),o}}))}})),de.define("restore_on_backspace",(function(e){const t=this,i=Object.assign({text:e=>e[t.settings.labelField]},e)
t.on("item_remove",(function(e){if(t.isFocused&&""===t.control_input.value.trim()){var s=t.options[e]
s&&t.setTextboxValue(i.text.call(t,s))}}))})),de.define("virtual_scroll",(function(){const e=this,t=e.canLoad,i=e.clearActiveOption,s=e.loadCallback
var n,o,r={},l=!1,a=[]
if(e.settings.shouldLoadMore||(e.settings.shouldLoadMore=()=>{if(n.clientHeight/(n.scrollHeight-n.scrollTop)>.9)return!0
if(e.activeOption){var t=e.selectable()
if(Array.from(t).indexOf(e.activeOption)>=t.length-2)return!0}return!1}),!e.settings.firstUrl)throw"virtual_scroll plugin requires a firstUrl() method"
e.settings.sortField=[{field:"$order"},{field:"$score"}]
const c=t=>!("number"==typeof e.settings.maxOptions&&n.children.length>=e.settings.maxOptions)&&!(!(t in r)||!r[t]),d=(t,i)=>e.items.indexOf(i)>=0||a.indexOf(i)>=0
e.setNextUrl=(e,t)=>{r[e]=t},e.getUrl=t=>{if(t in r){const e=r[t]
return r[t]=!1,e}return e.clearPagination(),e.settings.firstUrl.call(e,t)},e.clearPagination=()=>{r={}},e.hook("instead","clearActiveOption",(()=>{if(!l)return i.call(e)})),e.hook("instead","canLoad",(i=>i in r?c(i):t.call(e,i))),e.hook("instead","loadCallback",((t,i)=>{if(l){if(o){const i=t[0]
void 0!==i&&(o.dataset.value=i[e.settings.valueField])}}else e.clearOptions(d)
s.call(e,t,i),l=!1})),e.hook("after","refreshOptions",(()=>{const t=e.lastValue
var i
c(t)?(i=e.render("loading_more",{query:t}))&&(i.setAttribute("data-selectable",""),o=i):t in r&&!n.querySelector(".no-results")&&(i=e.render("no_more_results",{query:t})),i&&(D(i,e.settings.optionClass),n.append(i))})),e.on("initialize",(()=>{a=Object.keys(e.options),n=e.dropdown_content,e.settings.render=Object.assign({},{loading_more:()=>'<div class="loading-more-results">Loading more results ... </div>',no_more_results:()=>'<div class="no-more-results">No more results</div>'},e.settings.render),n.addEventListener("scroll",(()=>{e.settings.shouldLoadMore.call(e)&&c(e.lastValue)&&(l||(l=!0,e.load.call(e,e.lastValue)))}))}))})),de}))
var tomSelect=function(e,t){return new TomSelect(e,t)}
//# sourceMappingURL=tom-select.complete.min.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
{% 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

@@ -0,0 +1,26 @@
{% 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

@@ -0,0 +1,88 @@
<!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') }}">
</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>
<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>
</body>
</html>

View File

@@ -0,0 +1,173 @@
{% 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-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" value="Submit">
<button class="btn btn-danger" hx-delete="/books/{{ book.id }}" hx-target="body" hx-swap="innerHTML"
hx-confirm="Are you sure?">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

@@ -0,0 +1,196 @@
{# 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"
hx-target="#search-results">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

@@ -0,0 +1,10 @@
{% 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 %}

13
src/hxbooks/util.py Normal file
View File

@@ -0,0 +1,13 @@
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