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]
requires = ["hatchling"]
build-backend = "hatchling.build"
requires = ["setuptools>=64", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pyshelf"
dynamic = ["version"]
description = ''
version = "0.1.0"
description = ""
readme = "README.md"
requires-python = ">=3.12"
license = "GPL-3.0-or-later"
license = { text = "GPL-3.0-or-later" }
keywords = []
authors = [
{ name = "th3r00t", email = "tty0@th3r00t.dev" },
@@ -21,10 +21,15 @@ classifiers = [
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = [
"websockets", "loguru", "pypdf2", "bs4", "requests", "psycopg2",
"websockets", "loguru", "pypdf2", "bs4", "requests", "psycopg2-binary",
"mobi-python", "lxml", "sqlalchemy", "sqlalchemy.orm", "fastapi[all]",
"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]
@@ -32,51 +37,8 @@ Documentation = "https://github.com/th3r00t/pyshelf#readme"
Issues = "https://github.com/th3r00t/pyshelf/issues"
Source = "https://github.com/th3r00t/pyshelf"
[tool.hatch.version]
path = "src/__about__.py"
[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.setuptools.packages.find]
where = ["src"]
[tool.black]
target-version = ["py312"]
@@ -87,46 +49,14 @@ skip-string-normalization = true
target-version = "py312"
line-length = 120
select = [
"A",
"ARG",
"B",
"C",
"DTZ",
"E",
"EM",
"F",
"FBT",
"I",
"ICN",
"ISC",
"N",
"PLC",
"PLE",
"PLR",
"PLW",
"Q",
"RUF",
"S",
"T",
"TID",
"UP",
"W",
"YTT",
"A", "ARG", "B", "C", "DTZ", "E", "EM", "F", "FBT", "I", "ICN", "ISC", "N",
"PLC", "PLE", "PLR", "PLW", "Q", "RUF", "S", "T", "TID", "UP", "W", "YTT"
]
ignore = [
# Allow non-abstract empty methods in abstract base classes
"B027",
# 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",
"B027", "FBT003", "S105", "S106", "S107", "C901", "PLR0911",
"PLR0912", "PLR0913", "PLR0915"
]
unfixable = ["F401"]
[tool.ruff.isort]
known-first-party = ["pyshelf"]
@@ -135,16 +65,13 @@ known-first-party = ["pyshelf"]
ban-relative-imports = "all"
[tool.ruff.per-file-ignores]
# Tests can use magic values, assertions, and relative imports
"tests/**/*" = ["PLR2004", "S101", "TID252"]
[tool.coverage.run]
source_pkgs = ["pyshelf", "tests"]
branch = true
parallel = true
omit = [
"src/pyshelf/__about__.py",
]
omit = ["src/pyshelf/__about__.py"]
[tool.coverage.paths]
pyshelf = ["src/pyshelf", "*/pyshelf/src/pyshelf"]
@@ -156,13 +83,15 @@ exclude_lines = [
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
[tool.isort]
force_grid_wrap = 0
include_trailing_comma = true
line_length = 88
multi_line_output = 3
use_parentheses = true
# NOTE: the known_third_party setting is managed by
# seed-isort-config and should not be modified directly.
# Any changes made to this setting will be overwritten.
known_third_party = ["backend", "bs4", "django", "interface", "mobi", "prompt_toolkit", "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()
session.close()
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 base64 import b64encode
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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -85,7 +85,7 @@ def books_tojson(obj) -> dumps:
def book_tojson(book) -> dumps:
"""Convert a book object to a json."""
return dumps({
"book_id": book[0].book_id,
"book_id": book[0].id,
"title": book[0].title,
"author": book[0].author,
"categories": book[0].categories,
@@ -147,7 +147,12 @@ class FastAPIServer():
_pyShelf_src = sass.compile(
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:
_pyShelf.write(_pyShelf_src[0])
@@ -161,12 +166,12 @@ class FastAPIServer():
route.operation_id = route.name
@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())))
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()
"""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)
@app.get("/api/books", response_class=JSONResponse)
@@ -184,6 +189,16 @@ class FastAPIServer():
book = storage.get_book(book_id)
"""Home page responder."""
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)
async def collections(request: Request):
@@ -192,6 +207,12 @@ class FastAPIServer():
"""Home page responder."""
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):
"""Front end server entrypoint."""

View File

@@ -24,72 +24,8 @@ $navbar-item-color: $white
$navbar-item-hover-background-color: $ps-color-secondary
$navbar-dropdown-background-color: $ps-color-primary-trans
$navbar-dropdown-item-hover-color: $ps-color-background !important
$footer-background-color: $ps-color-primary-trans
$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"
$footer-background-color: $ps-color-primary-trans !important
// $footer-padding: 0.5rem 0.5rem
.center-all
align-items: center;
@@ -110,12 +46,60 @@ $footer-padding: 0.5rem 0.5rem
#book-shelf
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)) // <== add this
gap: 1rem // space between rows and columns
justify-content: space-evenly;
align-items: center;
// align-items: center;
align-content: center;
padding: 1rem;
margin: 1rem;
// 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
display: flex;
flex-direction: column;
@@ -123,3 +107,9 @@ $footer-padding: 0.5rem 0.5rem
align-items: center;
align-content: center;
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">
<a href="https://python.org">
<img
src="{{ url_for('static', path='images/python-logo-transparent.svg') }}"
alt="Powered by Python"
width="128"
height="24">
</a>
<a href="https://bulma.io">
<img
src="https://bulma.io/images/made-with-bulma--white.png"
alt="Made with Bulma"
width="128"
height="24">
</a>
<a href="https://python.org">
<img
src="{{ url_for('static', path='images/python-logo-transparent.svg') }}"
alt="Powered by Python"
width="128"
height="24">
</a>
<a href="https://bulma.io">
<img
src="https://bulma.io/assets/images/made-with-bulma--dark.png"
alt="Made with Bulma"
width="128"
height="24">
</a>
</footer>
</body>
</body>
</html>

View File

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

1841
uv.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff