Working pagination and download of book files

This commit is contained in:
2025-08-04 02:12:41 -04:00
parent 4e0997df06
commit 794abb7d28
11 changed files with 2065 additions and 224 deletions

4
.envrc vendored Normal file
View File

@@ -0,0 +1,4 @@
source .venv/bin/activate
uv sync
export PYTHONBREAKPOINT="pudb.set_trace"
export PYTHONSTARTUP="ipython_startup.py"

24
Makefile vendored Normal file
View File

@@ -0,0 +1,24 @@
test:
uv run pytest tests
test-cov:
uv run coverage run -m pytest tests
cov-report:
uv run coverage combine && uv run coverage report
cov: test-cov cov-report
typing:
uv run mypy --install-types --non-interactive src/pyshelf tests
style:
uv run ruff . && uv run black --check --diff .
fmt:
uv run black . && uv run ruff --fix . && make style
lint: style typing
compile:
cd src/frontend && sh compile.sh && cd ../..

123
pyproject.toml vendored
View File

@@ -1,14 +1,14 @@
[build-system] [build-system]
requires = ["hatchling"] requires = ["setuptools>=64", "wheel"]
build-backend = "hatchling.build" build-backend = "setuptools.build_meta"
[project] [project]
name = "pyshelf" name = "pyshelf"
dynamic = ["version"] version = "0.1.0"
description = '' description = ""
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = "GPL-3.0-or-later" license = { text = "GPL-3.0-or-later" }
keywords = [] keywords = []
authors = [ authors = [
{ name = "th3r00t", email = "tty0@th3r00t.dev" }, { name = "th3r00t", email = "tty0@th3r00t.dev" },
@@ -21,10 +21,15 @@ classifiers = [
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",
] ]
dependencies = [ dependencies = [
"websockets", "loguru", "pypdf2", "bs4", "requests", "psycopg2", "websockets", "loguru", "pypdf2", "bs4", "requests", "psycopg2-binary",
"mobi-python", "lxml", "sqlalchemy", "sqlalchemy.orm", "fastapi[all]", "mobi-python", "lxml", "sqlalchemy", "sqlalchemy.orm", "fastapi[all]",
"jinja2", "libsass", "nodejs-bin", "npm", "brotlipy", "debugpy", "pudb", "jinja2", "libsass", "nodejs-bin", "npm", "brotlipy", "debugpy", "pudb",
"ptipython", "chardet", "pre-commit" "ptipython", "chardet", "pre-commit", "coverage[toml]>=6.5","pytest",
"black>=23.1.0", "mypy>=1.0.0", "ruff>=0.0.243"
]
[project.optional-dependencies]
dev = [
] ]
[project.urls] [project.urls]
@@ -32,51 +37,8 @@ Documentation = "https://github.com/th3r00t/pyshelf#readme"
Issues = "https://github.com/th3r00t/pyshelf/issues" Issues = "https://github.com/th3r00t/pyshelf/issues"
Source = "https://github.com/th3r00t/pyshelf" Source = "https://github.com/th3r00t/pyshelf"
[tool.hatch.version] [tool.setuptools.packages.find]
path = "src/__about__.py" where = ["src"]
[tool.hatch.envs.default]
dependencies = [
"coverage[toml]>=6.5",
"pytest",
]
[tool.hatch.envs.default.scripts]
test = "pytest {args:tests}"
test-cov = "coverage run -m pytest {args:tests}"
cov-report = [
"- coverage combine",
"coverage report",
]
cov = [
"test-cov",
"cov-report",
]
[[tool.hatch.envs.all.matrix]]
python = ["3.12"]
[tool.hatch.envs.lint]
detached = true
dependencies = [
"black>=23.1.0",
"mypy>=1.0.0",
"ruff>=0.0.243",
]
[tool.hatch.envs.lint.scripts]
typing = "mypy --install-types --non-interactive {args:src/pyshelf tests}"
style = [
"ruff {args:.}",
"black --check --diff {args:.}",
]
fmt = [
"black {args:.}",
"ruff --fix {args:.}",
"style",
]
all = [
"style",
"typing",
]
[tool.black] [tool.black]
target-version = ["py312"] target-version = ["py312"]
@@ -87,46 +49,14 @@ skip-string-normalization = true
target-version = "py312" target-version = "py312"
line-length = 120 line-length = 120
select = [ select = [
"A", "A", "ARG", "B", "C", "DTZ", "E", "EM", "F", "FBT", "I", "ICN", "ISC", "N",
"ARG", "PLC", "PLE", "PLR", "PLW", "Q", "RUF", "S", "T", "TID", "UP", "W", "YTT"
"B",
"C",
"DTZ",
"E",
"EM",
"F",
"FBT",
"I",
"ICN",
"ISC",
"N",
"PLC",
"PLE",
"PLR",
"PLW",
"Q",
"RUF",
"S",
"T",
"TID",
"UP",
"W",
"YTT",
] ]
ignore = [ ignore = [
# Allow non-abstract empty methods in abstract base classes "B027", "FBT003", "S105", "S106", "S107", "C901", "PLR0911",
"B027", "PLR0912", "PLR0913", "PLR0915"
# Allow boolean positional values in function calls, like `dict.get(... True)`
"FBT003",
# Ignore checks for possible passwords
"S105", "S106", "S107",
# Ignore complexity
"C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915",
]
unfixable = [
# Don't touch unused imports
"F401",
] ]
unfixable = ["F401"]
[tool.ruff.isort] [tool.ruff.isort]
known-first-party = ["pyshelf"] known-first-party = ["pyshelf"]
@@ -135,16 +65,13 @@ known-first-party = ["pyshelf"]
ban-relative-imports = "all" ban-relative-imports = "all"
[tool.ruff.per-file-ignores] [tool.ruff.per-file-ignores]
# Tests can use magic values, assertions, and relative imports
"tests/**/*" = ["PLR2004", "S101", "TID252"] "tests/**/*" = ["PLR2004", "S101", "TID252"]
[tool.coverage.run] [tool.coverage.run]
source_pkgs = ["pyshelf", "tests"] source_pkgs = ["pyshelf", "tests"]
branch = true branch = true
parallel = true parallel = true
omit = [ omit = ["src/pyshelf/__about__.py"]
"src/pyshelf/__about__.py",
]
[tool.coverage.paths] [tool.coverage.paths]
pyshelf = ["src/pyshelf", "*/pyshelf/src/pyshelf"] pyshelf = ["src/pyshelf", "*/pyshelf/src/pyshelf"]
@@ -156,13 +83,15 @@ exclude_lines = [
"if __name__ == .__main__.:", "if __name__ == .__main__.:",
"if TYPE_CHECKING:", "if TYPE_CHECKING:",
] ]
[tool.isort] [tool.isort]
force_grid_wrap = 0 force_grid_wrap = 0
include_trailing_comma = true include_trailing_comma = true
line_length = 88 line_length = 88
multi_line_output = 3 multi_line_output = 3
use_parentheses = true use_parentheses = true
# NOTE: the known_third_party setting is managed by known_third_party = [
# seed-isort-config and should not be modified directly. "backend", "bs4", "django", "interface", "mobi", "prompt_toolkit",
# Any changes made to this setting will be overwritten. "psycopg2", "pyfiglet", "requests"
known_third_party = ["backend", "bs4", "django", "interface", "mobi", "prompt_toolkit", "psycopg2", "pyfiglet", "requests"] ]

View File

@@ -244,3 +244,16 @@ class Storage:
_result = session.execute(select(Collection).join(Book)).all() _result = session.execute(select(Collection).join(Book)).all()
session.close() session.close()
return _result return _result
def get_collection(self, name):
"""Get collection from database.
Returns
-------
_result : ScalarResult Object
"""
session = Session(self.engine)
breakpoint()
_result = session.execute(select(Collection).where(Collection.name == name).join(Book)).all()
session.close()
return _result

2
src/frontend/compile.sh vendored Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/sh
exec npx tsc static/script/pyshelf.ts

View File

@@ -8,7 +8,7 @@ import datetime
from json import dumps from json import dumps
from base64 import b64encode from base64 import b64encode
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -85,7 +85,7 @@ def books_tojson(obj) -> dumps:
def book_tojson(book) -> dumps: def book_tojson(book) -> dumps:
"""Convert a book object to a json.""" """Convert a book object to a json."""
return dumps({ return dumps({
"book_id": book[0].book_id, "book_id": book[0].id,
"title": book[0].title, "title": book[0].title,
"author": book[0].author, "author": book[0].author,
"categories": book[0].categories, "categories": book[0].categories,
@@ -147,7 +147,12 @@ class FastAPIServer():
_pyShelf_src = sass.compile( _pyShelf_src = sass.compile(
filename='src/frontend/static/styles/pyShelf.sass', filename='src/frontend/static/styles/pyShelf.sass',
source_map_filename='src/frontend/static/styles/pyShelf.sass', source_map_filename='src/frontend/static/styles/pyShelf.sass',
output_style='compressed') output_style='compressed',
include_paths=[
'node_modules',
'src/frontend/static/styles'
]
)
with open('src/frontend/static/styles/pyShelf.css', 'w') as _pyShelf: with open('src/frontend/static/styles/pyShelf.css', 'w') as _pyShelf:
_pyShelf.write(_pyShelf_src[0]) _pyShelf.write(_pyShelf_src[0])
@@ -161,12 +166,12 @@ class FastAPIServer():
route.operation_id = route.name route.operation_id = route.name
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(request: Request, skip: int = 0, limit: int = 10): async def index(request: Request, skip: int = 0, limit: int = 30):
storage = Storage(Config(os.path.abspath(os.getcwd()))) storage = Storage(Config(os.path.abspath(os.getcwd())))
books = storage.get_books(collection=None, skip=skip, limit=limit) books = storage.get_books(collection=None, skip=skip*limit, limit=limit)
collections = storage.get_collections() collections = storage.get_collections()
"""Home page responder.""" """Home page responder."""
context = {"request": request, "books": books, "collections": collections} context = {"request": request, "books": books, "collections": collections, "page": skip, "limit": limit}
return templates.TemplateResponse("index.html", context) return templates.TemplateResponse("index.html", context)
@app.get("/api/books", response_class=JSONResponse) @app.get("/api/books", response_class=JSONResponse)
@@ -184,6 +189,16 @@ class FastAPIServer():
book = storage.get_book(book_id) book = storage.get_book(book_id)
"""Home page responder.""" """Home page responder."""
return JSONResponse(content=book_tojson(book)) return JSONResponse(content=book_tojson(book))
@app.get("/api/get_book/{book_id}", response_class=FileResponse)
async def book(request: Request, book_id: int):
storage = Storage(Config(os.path.abspath(os.getcwd())))
book = storage.get_book(book_id)
file_path = book[0].file_name
if not os.path.exists(file_path):
return JSONResponse(status_code=404, content={"error": "File not found"})
"""Book file responder."""
return FileResponse(path=file_path, filename=os.path.basename(file_path), media_type="application/octet-stream")
@app.get("/api/collections", response_class=JSONResponse) @app.get("/api/collections", response_class=JSONResponse)
async def collections(request: Request): async def collections(request: Request):
@@ -192,6 +207,12 @@ class FastAPIServer():
"""Home page responder.""" """Home page responder."""
return JSONResponse(content=collections_tojson(collections)) return JSONResponse(content=collections_tojson(collections))
@app.get("/api/collection/{collection_id}", response_class=JSONResponse)
async def collection(request: Request, collection_name: str):
storage = Storage(Config(os.path.abspath(os.getcwd())))
collection = storage.get_collection(collection_name)
"""Collection file responder."""
return JSONResponse(content=collections_tojson(collection))
async def run(self): async def run(self):
"""Front end server entrypoint.""" """Front end server entrypoint."""

View File

@@ -24,72 +24,8 @@ $navbar-item-color: $white
$navbar-item-hover-background-color: $ps-color-secondary $navbar-item-hover-background-color: $ps-color-secondary
$navbar-dropdown-background-color: $ps-color-primary-trans $navbar-dropdown-background-color: $ps-color-primary-trans
$navbar-dropdown-item-hover-color: $ps-color-background !important $navbar-dropdown-item-hover-color: $ps-color-background !important
$footer-background-color: $ps-color-primary-trans $footer-background-color: $ps-color-primary-trans !important
$footer-padding: 0.5rem 0.5rem // $footer-padding: 0.5rem 0.5rem
//$navbar-dropdown-item-hover-background-color: $background !default
//$navbar-dropdown-item-active-color: $link !default
//$navbar-dropdown-item-active-background-color: $background !default
@import "../../node_modules/bulma/sass/utilities/_all.sass";
@import "../../node_modules/bulma/sass/utilities/initial-variables.sass";
@import "../../node_modules/bulma/sass/utilities/functions.sass";
@import "../../node_modules/bulma/sass/utilities/derived-variables.sass";
@import "../../node_modules/bulma/sass/utilities/mixins.sass";
@import "../../node_modules/bulma/sass/utilities/controls.sass";
@import "../../node_modules/bulma/sass/utilities/extends.sass";
@import "../../node_modules/bulma/sass/base/_all.sass";
@import "../../node_modules/bulma/sass/base/minireset.sass";
@import "../../node_modules/bulma/sass/base/generic.sass";
@import "../../node_modules/bulma/sass/base/animations.sass";
@import "../../node_modules/bulma/sass/elements/_all.sass";
@import "../../node_modules/bulma/sass/elements/box.sass";
@import "../../node_modules/bulma/sass/elements/button.sass";
@import "../../node_modules/bulma/sass/elements/container.sass";
@import "../../node_modules/bulma/sass/elements/content.sass";
@import "../../node_modules/bulma/sass/elements/icon.sass";
@import "../../node_modules/bulma/sass/elements/image.sass";
@import "../../node_modules/bulma/sass/elements/notification.sass";
@import "../../node_modules/bulma/sass/elements/progress.sass";
@import "../../node_modules/bulma/sass/elements/table.sass";
@import "../../node_modules/bulma/sass/elements/tag.sass";
@import "../../node_modules/bulma/sass/elements/title.sass";
@import "../../node_modules/bulma/sass/elements/other.sass";
@import "../../node_modules/bulma/sass/form/_all.sass";
@import "../../node_modules/bulma/sass/form/shared.sass";
@import "../../node_modules/bulma/sass/form/input-textarea.sass";
@import "../../node_modules/bulma/sass/form/checkbox-radio.sass";
@import "../../node_modules/bulma/sass/form/select.sass";
@import "../../node_modules/bulma/sass/form/file.sass";
@import "../../node_modules/bulma/sass/form/tools.sass";
@import "../../node_modules/bulma/sass/components/_all.sass";
@import "../../node_modules/bulma/sass/components/breadcrumb.sass";
@import "../../node_modules/bulma/sass/components/card.sass";
@import "../../node_modules/bulma/sass/components/dropdown.sass";
@import "../../node_modules/bulma/sass/components/level.sass";
@import "../../node_modules/bulma/sass/components/media.sass";
@import "../../node_modules/bulma/sass/components/menu.sass";
@import "../../node_modules/bulma/sass/components/message.sass";
@import "../../node_modules/bulma/sass/components/modal.sass";
@import "../../node_modules/bulma/sass/components/navbar.sass";
@import "../../node_modules/bulma/sass/components/pagination.sass";
@import "../../node_modules/bulma/sass/components/panel.sass";
@import "../../node_modules/bulma/sass/components/tabs.sass";
@import "../../node_modules/bulma/sass/grid/_all.sass";
@import "../../node_modules/bulma/sass/grid/columns.sass";
@import "../../node_modules/bulma/sass/grid/tiles.sass";
@import "../../node_modules/bulma/sass/helpers/_all.sass";
@import "../../node_modules/bulma/sass/helpers/color.sass";
@import "../../node_modules/bulma/sass/helpers/flexbox.sass";
@import "../../node_modules/bulma/sass/helpers/float.sass";
@import "../../node_modules/bulma/sass/helpers/other.sass";
@import "../../node_modules/bulma/sass/helpers/overflow.sass";
@import "../../node_modules/bulma/sass/helpers/position.sass";
@import "../../node_modules/bulma/sass/helpers/spacing.sass";
@import "../../node_modules/bulma/sass/helpers/typography.sass";
@import "../../node_modules/bulma/sass/helpers/visibility.sass";
@import "../../node_modules/bulma/sass/layout/_all.sass";
@import "../../node_modules/bulma/sass/layout/hero.sass";
@import "../../node_modules/bulma/sass/layout/section.sass";
@import "../../node_modules/bulma/sass/layout/footer.sass"
.center-all .center-all
align-items: center; align-items: center;
@@ -110,12 +46,60 @@ $footer-padding: 0.5rem 0.5rem
#book-shelf #book-shelf
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)) // <== add this
gap: 1rem // space between rows and columns
justify-content: space-evenly; justify-content: space-evenly;
align-items: center; // align-items: center;
align-content: center; align-content: center;
padding: 1rem; padding: 1rem;
margin: 1rem; margin: 1rem;
// background-color: $ps-color-background; // background-color: $ps-color-background;
.book-thumbnail
z-index: 1;
position: relative;
.no-image-title
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: $white;
font-size: 1.2rem;
text-align: center;
background-color: rgba(0,0,0,0.7);
padding: 0.5rem;
border-radius: 5px;
.no-image-author
position: absolute;
top: 60%;
left: 50%;
transform: translate(-50%, -50%);
color: $white;
font-size: 1rem;
text-align: center;
background-color: rgba(0,0,0,0.7);
padding: 0.5rem;
border-radius: 5px;
.display-alt::after
content: attr(alt);
display: block !important;
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.7);
color: white;
font-size: 0.8rem;
padding: 0.2rem;
.title, .subtitle
z-index: 2;
position: relative;
top: -155px;
.collection .collection
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -123,3 +107,9 @@ $footer-padding: 0.5rem 0.5rem
align-items: center; align-items: center;
align-content: center; align-content: center;
background-color: $ps-color-background; background-color: $ps-color-background;
.footer
display: flex;
padding: 1rem !important;
@import "../../node_modules/bulma/bulma.scss"

View File

@@ -1,18 +1,18 @@
<footer class="footer is-dark" id="footer-main"> <footer class="footer is-dark" id="footer-main">
<a href="https://python.org"> <a href="https://python.org">
<img <img
src="{{ url_for('static', path='images/python-logo-transparent.svg') }}" src="{{ url_for('static', path='images/python-logo-transparent.svg') }}"
alt="Powered by Python" alt="Powered by Python"
width="128" width="128"
height="24"> height="24">
</a> </a>
<a href="https://bulma.io"> <a href="https://bulma.io">
<img <img
src="https://bulma.io/images/made-with-bulma--white.png" src="https://bulma.io/assets/images/made-with-bulma--dark.png"
alt="Made with Bulma" alt="Made with Bulma"
width="128" width="128"
height="24"> height="24">
</a> </a>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -1,40 +1,53 @@
<!doctype html> <!DOCTYPE html>
{% block javascript %} {% block javascript %}
<script type="text/javascript" src=static/script/pako.min.js> <script type="text/javascript" src=static/script/pako.min.js>
<script type="text/javascript"> <script type="text/javascript">
const books = {{ books|books_tojson }}; const books = {{ books|books_tojson }};
let inflatedJSON = {}; let inflatedJSON = {};
const pako = require('pako'); const pako = require('pako');
inflatedJSON = JSON.parse(pako.inflate(books, { to: 'string'})); inflatedJSON = JSON.parse(pako.inflate(books, { to: 'string'}));
</script> </script>
{% endblock %} {% endblock %}
{% include 'header.html' %} {% include 'header.html' %}
{% include 'navigation.html' %} {% include 'navigation.html' %}
<section id="master-container"> <section id="master-container">
<div id="book-shelf" class="container is-dark"> <p>Total books: {{ books|length }}</p>
{% for book in books %} <!-- <div id="book-shelf" class="container is-dark"> -->
{% set cover = book[0].cover|b64decode %} <div id="book-shelf" class="is-dark">
{% if cover != 'None' %} {% for book in books %}
<div class="is-dark book" id="{{book[0].id}}"> {% set cover = book[0].cover|b64decode %}
<div class="image book-thumbnail"> {% if cover != 'None' %}
<figure class="image is-4by3"> <div class="is-dark book" id="{{book[0].id}}" onclick="window.location.href='/api/get_book/{{ book[0].id }}'">
<img src="data:;base64,{{ book[0].cover|b64decode }}" alt="{{ book[0].title }}"> <div class="image book-thumbnail">
</figure> <figure class="image is-4by3">
</div> <img src="data:;base64,{{ book[0].cover|b64decode }}" alt="{{ book[0].title }}">
</div> </figure>
{% else %} </div>
<div class="is-dark book" id="{{book[0].id}}"> </div>
<div class="image book-thumbnail"> {% else %}
<figure class="image is-4by3"> <div class="is-dark book" id="{{book[0].id}}" onclick="window.location.href='/api/get_book/{{ book[0].id }}'">
<img src="static/images/no-cover.jpg" alt="{{ book[0].title }}"> <div class="image book-thumbnail"
</figure> style="
</div> background-image: url('static/images/no-cover.jpg');
<h3 class="title is-3">{{ book[0].title }}</h3> background-size: cover;
<h4 class="subtitle is-4">{{ book[0].author }}</h4> background-position: center;
<p class="content">{{ book[0].description|summarize }}</p> background-repeat: no-repeat;
</div> ">
{% endif %} <figure class="image is-4by3">
{% endfor %} <div class="no-image-title">{{ book[0].title }}</div>
</div> <!-- alt="{{ book[0].title }}" -->
</section> </figure>
{% include 'footer.html' %} </div>
<!-- <p class="content">{{ book[0].description|summarize }}</p> -->
</div>
{% endif %}
{% endfor %}
</div>
<div id="pagination" class="container is-dark">
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<a class="pagination-previous" href="/?skip={{ page-1 }}" id="prev-page">Previous</a>
<a class="pagination-next" href="/?skip={{ page+1 }}" id="next-page">Next</a>
</nav>
</div>
</section>
{% include 'footer.html' %}

View File

@@ -49,7 +49,11 @@
<div class="select is-small is-rounded is-link" id="collection_dropdown"> <div class="select is-small is-rounded is-link" id="collection_dropdown">
<select id="collection_select"> <select id="collection_select">
{% for collection in collections %} {% for collection in collections %}
<option value={{collection[0].id}} class="collection_selection">{{collection[0].collection}}</option> <option
value={{collection[0].id}}
class="collection_selection"
onclick="window.location.href='/api/collection/{{ collection[0].collection }}'"
>{{collection[0].collection}}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>

1841
uv.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff