mirror of
https://github.com/th3r00t/pyShelf.git
synced 2026-04-28 01:59:35 -04:00
Fixed collection system.
This commit is contained in:
2
pyShelf.py
vendored
Normal file → Executable file
2
pyShelf.py
vendored
Normal file → Executable file
@@ -22,7 +22,9 @@ def run_import():
|
||||
config.logger.info("Begining book import.")
|
||||
execute_scan(PRG_PATH, config=config)
|
||||
config.logger.info("Finished book import.")
|
||||
storage = Storage(config=config)
|
||||
# MakeCollections(PRG_PATH, config=config)
|
||||
storage.make_collections()
|
||||
return "Import Complete"
|
||||
|
||||
|
||||
|
||||
31
src/backend/lib/models.py
vendored
31
src/backend/lib/models.py
vendored
@@ -5,6 +5,7 @@ from sqlalchemy import func, ForeignKey
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
import datetime
|
||||
|
||||
# Timestamp annotation
|
||||
timestamp = Annotated[
|
||||
datetime.datetime,
|
||||
mapped_column(nullable=False, server_default=func.CURRENT_TIMESTAMP()),
|
||||
@@ -14,7 +15,6 @@ timestamp = Annotated[
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
class Book(Base):
|
||||
"""Book model."""
|
||||
@@ -36,8 +36,10 @@ class Book(Base):
|
||||
identifier: Mapped[Optional[str]]
|
||||
publisher: Mapped[Optional[str]]
|
||||
|
||||
# One book → many collection entries
|
||||
collections = relationship("Collection", back_populates="book", cascade="all, delete-orphan")
|
||||
# Relationship to join table
|
||||
book_collections = relationship(
|
||||
"BookCollection", back_populates="book", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Collection(Base):
|
||||
@@ -46,8 +48,23 @@ class Collection(Base):
|
||||
__tablename__ = "Collection"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
collection: Mapped[str]
|
||||
book_id: Mapped[int] = mapped_column(ForeignKey("Book.id"))
|
||||
name: Mapped[str] = mapped_column(unique=True)
|
||||
|
||||
# Each collection entry points to one book
|
||||
book = relationship("Book", back_populates="collections")
|
||||
# Relationship to join table
|
||||
book_collections = relationship(
|
||||
"BookCollection", back_populates="collection", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class BookCollection(Base):
|
||||
"""Association table linking Books and Collections."""
|
||||
|
||||
__tablename__ = "BookCollection"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
book_id: Mapped[int] = mapped_column(ForeignKey("Book.id"))
|
||||
collection_id: Mapped[int] = mapped_column(ForeignKey("Collection.id"))
|
||||
|
||||
# Relationships
|
||||
book = relationship("Book", back_populates="book_collections")
|
||||
collection = relationship("Collection", back_populates="book_collections")
|
||||
|
||||
183
src/backend/lib/storage.py
vendored
183
src/backend/lib/storage.py
vendored
@@ -4,7 +4,7 @@ from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Book, Collection
|
||||
from .models import Book, Collection, BookCollection
|
||||
|
||||
|
||||
class Storage:
|
||||
@@ -135,89 +135,132 @@ class Storage:
|
||||
collections : list()
|
||||
List of collections.
|
||||
"""
|
||||
# collections = []
|
||||
# title_regx = re.compile(r"^[0-9][0-9]*|-|\ \B")
|
||||
# book_path: Path = Path(book[3])
|
||||
# store_path: Path = Path(self.config.book_path)
|
||||
# relative_book_path: Path = book_path.relative_to(store_path)
|
||||
# for path in relative_book_path.parts:
|
||||
# collections.append(re.sub(title_regx, "", path).strip())
|
||||
# collections.pop(-1)
|
||||
# return collections
|
||||
collections = []
|
||||
title_regx = re.compile(r"^[0-9][0-9]*|-|\ \B")
|
||||
book_path: Path = Path(book[3])
|
||||
store_path: Path = Path(self.config.book_path)
|
||||
relative_book_path: Path = book_path.relative_to(store_path)
|
||||
for path in relative_book_path.parts:
|
||||
collections.append(re.sub(title_regx, "", path).strip())
|
||||
collections.pop(-1)
|
||||
# Keep all folder names except the actual file
|
||||
for folder in relative_book_path.parts[:-1]:
|
||||
clean_name = re.sub(title_regx, "", folder).strip()
|
||||
if clean_name:
|
||||
collections.append(clean_name)
|
||||
return collections
|
||||
|
||||
def make_collections(self):
|
||||
"""Parse book path's to determine common folder structure.
|
||||
|
||||
Stores collections based on shared paths.
|
||||
"""
|
||||
# TODO: Check this still works with the switch to sqlalchemy
|
||||
"""Ensure collections exist and link them to books (many-to-many)."""
|
||||
self.config.logger.info("Making collections.")
|
||||
_title_regx = re.compile(r"^[0-9][0-9]*|-|\ \B")
|
||||
session = Session(self.engine)
|
||||
_set = session.execute(select(Book.id, Book.file_name)).all()
|
||||
if _set.__len__() > 0:
|
||||
for book in _set:
|
||||
path = self.config.book_path + "/"
|
||||
_collections = []
|
||||
|
||||
# get all books and paths
|
||||
books = session.execute(select(Book.id, Book.file_name)).all()
|
||||
|
||||
for book_id, file_name in books:
|
||||
try:
|
||||
_pathing = book[1].split(path)[1].split("/")
|
||||
_pathing.pop(0)
|
||||
_pathing.pop(-1)
|
||||
except IndexError:
|
||||
continue # Skip if path is invalid eg. a book with no con-
|
||||
# taining folder
|
||||
for _p in _pathing:
|
||||
_s = _p.replace("'", "")
|
||||
_x = re.sub(_title_regx, "", _s)
|
||||
_s = _x.strip()
|
||||
_sess = Session(self.engine)
|
||||
_q = _sess.execute(
|
||||
select(Collection.id).where(
|
||||
Collection.collection == _s,
|
||||
# BUG: book.id is not the correct identifier.
|
||||
Collection.book_id == book.id,
|
||||
relative_parts = Path(file_name).relative_to(self.config.book_path).parts
|
||||
except ValueError:
|
||||
continue # skip books outside the configured path
|
||||
|
||||
# exclude the actual file name
|
||||
folder = relative_parts[1]
|
||||
# for folder in folders:
|
||||
# clean_name = re.sub(r"^[0-9][0-9]*|-|\ \B", "", folder).strip()
|
||||
# if not clean_name:
|
||||
# continue
|
||||
|
||||
# check if collection exists
|
||||
collection = session.execute(
|
||||
select(Collection).where(Collection.name == folder)
|
||||
).scalar_one_or_none()
|
||||
if not collection:
|
||||
collection = Collection(name=folder)
|
||||
session.add(collection)
|
||||
session.flush() # ensures collection.id is available
|
||||
|
||||
# check link
|
||||
link_exists = session.execute(
|
||||
select(BookCollection).where(
|
||||
BookCollection.book_id == book_id,
|
||||
BookCollection.collection_id == collection.id
|
||||
)
|
||||
)
|
||||
_sess.close()
|
||||
if _q.fetchone() is None:
|
||||
_collection = Collection(collection=_s, book_id=book.id)
|
||||
with Session(self.engine) as _sess:
|
||||
try:
|
||||
_sess.add(_collection)
|
||||
_sess.commit()
|
||||
_sess.close()
|
||||
# self.config.logger.info(f"Collection {_s} added.")
|
||||
except Exception as e:
|
||||
self.config.logger.error(f"Collection {_s} failed: {e}")
|
||||
_collections.append(_p)
|
||||
).first()
|
||||
|
||||
if not link_exists:
|
||||
session.add(BookCollection(book_id=book_id, collection_id=collection.id))
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
self.config.logger.info("Finished making collections.")
|
||||
|
||||
|
||||
|
||||
# def get_books(self, collection=None, skip=None, limit=None):
|
||||
# """Get books from database.
|
||||
#
|
||||
# Parameters
|
||||
# ----------
|
||||
# collection : str
|
||||
# Collection to filter by.
|
||||
#
|
||||
# Returns
|
||||
# -------
|
||||
# _result : ScalarResult Object
|
||||
# """
|
||||
# session = Session(self.engine)
|
||||
# if collection:
|
||||
# _result = session.execute(
|
||||
# select(Book)
|
||||
# .join(Collection)
|
||||
# # .where(Collection.id == collection)
|
||||
# .where(Collection.name == collection)
|
||||
# .offset(skip)
|
||||
# .limit(limit)
|
||||
# ).all()
|
||||
# else:
|
||||
# _result = session.execute(select(Book).offset(skip).limit(limit)).all()
|
||||
# session.close()
|
||||
# return _result
|
||||
|
||||
|
||||
def get_books(self, collection=None, skip=None, limit=None):
|
||||
"""Get books from database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
collection : str
|
||||
Collection to filter by.
|
||||
|
||||
Returns
|
||||
-------
|
||||
_result : ScalarResult Object
|
||||
collection : int or None
|
||||
Collection ID to filter by.
|
||||
skip : int or None
|
||||
Number of records to skip (offset).
|
||||
limit : int or None
|
||||
Maximum number of records to return.
|
||||
"""
|
||||
session = Session(self.engine)
|
||||
if collection:
|
||||
_result = session.execute(
|
||||
with Session(self.engine) as session:
|
||||
if collection is not None:
|
||||
# Join through BookCollection to filter books in a collection
|
||||
result = session.execute(
|
||||
select(Book)
|
||||
.join(Collection)
|
||||
# .where(Collection.id == collection)
|
||||
.where(Collection.collection == collection)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
).all()
|
||||
.join(BookCollection)
|
||||
.where(BookCollection.collection_id == collection)
|
||||
.offset(skip or 0)
|
||||
.limit(limit or 100)
|
||||
).scalars().all()
|
||||
else:
|
||||
_result = session.execute(select(Book).offset(skip).limit(limit)).all()
|
||||
session.close()
|
||||
return _result
|
||||
result = session.execute(
|
||||
select(Book)
|
||||
.offset(skip or 0)
|
||||
.limit(limit or 100)
|
||||
).scalars().all()
|
||||
return result
|
||||
|
||||
|
||||
def get_book(self, id):
|
||||
"""Get book from database.
|
||||
@@ -243,10 +286,15 @@ class Storage:
|
||||
-------
|
||||
_result : ScalarResult Object
|
||||
"""
|
||||
session = Session(self.engine)
|
||||
_result = session.execute(select(Collection).join(Book)).all()
|
||||
session.close()
|
||||
return _result
|
||||
with Session(self.engine) as session:
|
||||
result = session.execute(
|
||||
select(Collection).join(BookCollection).distinct()
|
||||
).scalars().all()
|
||||
return result
|
||||
# session = Session(self.engine)
|
||||
# _result = session.execute(select(Collection).join(Book)).all()
|
||||
# session.close()
|
||||
# return _result
|
||||
|
||||
def get_collection(self, name):
|
||||
"""Get collection from database.
|
||||
@@ -256,7 +304,6 @@ class Storage:
|
||||
_result : ScalarResult Object
|
||||
"""
|
||||
session = Session(self.engine)
|
||||
_result = session.execute(select(Collection).where(Collection.collection == name).join(Book)).all()
|
||||
breakpoint()
|
||||
_result = session.execute(select(Collection).where(Collection.name == name).join(Book)).all()
|
||||
session.close()
|
||||
return _result
|
||||
|
||||
96
src/frontend/lib/FastAPIServer.py
vendored
96
src/frontend/lib/FastAPIServer.py
vendored
@@ -64,20 +64,20 @@ def books_tojson(obj) -> dumps:
|
||||
for book in obj:
|
||||
convert_none = lambda x: x if x is not None else "None"
|
||||
_books.append({
|
||||
"book_id": book[0].id,
|
||||
"title": book[0].title,
|
||||
"author": book[0].author,
|
||||
"categories": convert_none(book[0].categories),
|
||||
"cover": base64decode(book[0].cover),
|
||||
"pages": convert_none(book[0].pages),
|
||||
"progress": convert_none(book[0].progress),
|
||||
"file_name": book[0].file_name,
|
||||
"description": convert_none(book[0].description),
|
||||
"date": convertDateTime(book[0].date),
|
||||
"rights": convert_none(book[0].rights),
|
||||
"tags": convert_none(book[0].tags),
|
||||
"identifier": convert_none(book[0].identifier),
|
||||
"publisher": convert_none(book[0].publisher),
|
||||
"book_id": book.id,
|
||||
"title": book.title,
|
||||
"author": book.author,
|
||||
"categories": convert_none(book.categories),
|
||||
"cover": base64decode(book.cover),
|
||||
"pages": convert_none(book.pages),
|
||||
"progress": convert_none(book.progress),
|
||||
"file_name": book.file_name,
|
||||
"description": convert_none(book.description),
|
||||
"date": convertDateTime(book.date),
|
||||
"rights": convert_none(book.rights),
|
||||
"tags": convert_none(book.tags),
|
||||
"identifier": convert_none(book.identifier),
|
||||
"publisher": convert_none(book.publisher),
|
||||
})
|
||||
return dumps(_books)
|
||||
|
||||
@@ -85,20 +85,20 @@ def books_tojson(obj) -> dumps:
|
||||
def book_tojson(book) -> dumps:
|
||||
"""Convert a book object to a json."""
|
||||
return dumps({
|
||||
"book_id": book[0].id,
|
||||
"title": book[0].title,
|
||||
"author": book[0].author,
|
||||
"categories": book[0].categories,
|
||||
"cover": base64decode(book[0].cover),
|
||||
"pages": book[0].pages,
|
||||
"progress": book[0].progress,
|
||||
"file_name": book[0].file_name,
|
||||
"description": book[0].description,
|
||||
"date": convertDateTime(book[0].date),
|
||||
"rights": book[0].rights,
|
||||
"tags": book[0].tags,
|
||||
"identifier": book[0].identifier,
|
||||
"publisher": book[0].publisher,
|
||||
"book_id": book.id,
|
||||
"title": book.title,
|
||||
"author": book.author,
|
||||
"categories": book.categories,
|
||||
"cover": base64decode(book.cover),
|
||||
"pages": book.pages,
|
||||
"progress": book.progress,
|
||||
"file_name": book.file_name,
|
||||
"description": book.description,
|
||||
"date": convertDateTime(book.date),
|
||||
"rights": book.rights,
|
||||
"tags": book.tags,
|
||||
"identifier": book.identifier,
|
||||
"publisher": book.publisher,
|
||||
})
|
||||
|
||||
def tojson(obj) -> dumps:
|
||||
@@ -109,13 +109,13 @@ def collections_tojson(collection) -> dumps:
|
||||
_collections = []
|
||||
_collection_id_set = set()
|
||||
for _collection in collection:
|
||||
if _collection[0].id in _collection_id_set:
|
||||
if _collection.id in _collection_id_set:
|
||||
pass
|
||||
else:
|
||||
_collection_id_set.add(_collection[0].id)
|
||||
_collection_id_set.add(_collection.id)
|
||||
_collections.append({
|
||||
"collection_id": _collection[0].id,
|
||||
"collection": _collection[0].collection,
|
||||
"collection_id": _collection.id,
|
||||
"collection": _collection.name,
|
||||
})
|
||||
return dumps(_collections)
|
||||
|
||||
@@ -167,8 +167,13 @@ class FastAPIServer():
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, skip: int = 0, limit: int = 30):
|
||||
if skip <= 0:
|
||||
skip_num = 0
|
||||
skip = 0
|
||||
else:
|
||||
skip_num = skip * limit
|
||||
storage = Storage(Config(os.path.abspath(os.getcwd())))
|
||||
books = storage.get_books(collection=None, skip=skip*limit, limit=limit)
|
||||
books = storage.get_books(collection=None, skip=skip_num, limit=limit)
|
||||
collections = storage.get_collections()
|
||||
"""Home page responder."""
|
||||
context = {"request": request, "books": books, "collections": collections, "page": skip, "limit": limit}
|
||||
@@ -208,16 +213,25 @@ class FastAPIServer():
|
||||
return JSONResponse(content=collections_tojson(collections))
|
||||
|
||||
@app.get("/api/collection/{collection}", response_class=JSONResponse)
|
||||
async def collection(request: Request, collection: str, skip=0, limit=30):
|
||||
storage = Storage(Config(os.path.abspath(os.getcwd())))
|
||||
# collection = storage.get_collection(collection_name)
|
||||
collection = storage.get_books(collection)
|
||||
async def collection(request: Request, collection: str, skip:int=0, limit:int=30):
|
||||
"""Collection file responder."""
|
||||
storage = Storage(Config(os.path.abspath(os.getcwd())))
|
||||
if skip <= 0:
|
||||
skip_num = 0
|
||||
skip = 0
|
||||
else:
|
||||
skip_num = skip * limit
|
||||
books = storage.get_books(collection, skip=skip_num, limit=limit)
|
||||
collections = storage.get_collections()
|
||||
# books = JSONResponse(content=books_tojson(collection))
|
||||
context = {"request": request, "books": collection, "collections": collections, "page": skip, "limit": limit}
|
||||
return templates.TemplateResponse("index.html", context)
|
||||
# return JSONResponse(content=collections_tojson(collection))
|
||||
context = {
|
||||
"request": request,
|
||||
"books": books,
|
||||
"collections": collections,
|
||||
"collection": collection,
|
||||
"page": skip,
|
||||
"limit": limit
|
||||
}
|
||||
return templates.TemplateResponse("collection.html", context)
|
||||
|
||||
async def run(self):
|
||||
"""Front end server entrypoint."""
|
||||
|
||||
52
src/frontend/templates/collection.html
Normal file
52
src/frontend/templates/collection.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
{% block javascript %}
|
||||
<script type="text/javascript" src={{ url_for('static', path='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"> -->
|
||||
<div id="book-shelf">
|
||||
{% for book in books %}
|
||||
{% set cover = book.cover|b64decode %}
|
||||
{% if cover != 'None' %}
|
||||
<div class="is-dark book" id="{{book.id}}" onclick="window.location.href='/api/get_book/{{ book.id }}'">
|
||||
<div class="image book-thumbnail">
|
||||
<figure class="image is-4by3">
|
||||
<img src="data:;base64,{{ book.cover|b64decode }}" alt="{{ book.title }}">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="is-dark book" id="{{book.id}}" onclick="window.location.href='/api/get_book/{{ book.id }}'">
|
||||
<div class="image book-thumbnail"
|
||||
style="
|
||||
background-image: url("{{ url_for('static', path='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.title }}</div>
|
||||
<!-- alt="{{ book.title }}" -->
|
||||
</figure>
|
||||
</div>
|
||||
<!-- <p class="content">{{ book.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="/api/collection/{{collection}}?skip={{ page|int - 1 }}" id="prev-page">Previous</a>
|
||||
<a class="pagination-next" href="/api/collection/{{collection}}?skip={{ page|int + 1 }}" id="next-page">Next</a>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
{% include 'footer.html' %}
|
||||
@@ -1,6 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
{% block javascript %}
|
||||
<script type="text/javascript" src=static/script/pako.min.js>
|
||||
<script type="text/javascript" src={{ url_for('static', path='script/pako.min.js') }}>
|
||||
<script type="text/javascript">
|
||||
const books = {{ books|books_tojson }};
|
||||
let inflatedJSON = {};
|
||||
@@ -11,34 +11,32 @@
|
||||
{% 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">
|
||||
<div id="book-shelf">
|
||||
{% for book in books %}
|
||||
{% set cover = book[0].cover|b64decode %}
|
||||
{% set cover = book.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="is-dark book" id="{{book.id}}" onclick="window.location.href='/api/get_book/{{ book.id }}'">
|
||||
<div class="image book-thumbnail">
|
||||
<figure class="image is-4by3">
|
||||
<img src="data:;base64,{{ book[0].cover|b64decode }}" alt="{{ book[0].title }}">
|
||||
<img src="data:;base64,{{ book.cover|b64decode }}" alt="{{ book.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="is-dark book" id="{{book.id}}" onclick="window.location.href='/api/get_book/{{ book.id }}'">
|
||||
<div class="image book-thumbnail"
|
||||
style="
|
||||
background-image: url('static/images/no-cover.jpg');
|
||||
background-image: url("{{ url_for('static', path='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 }}" -->
|
||||
<div class="no-image-title">{{ book.title }}</div>
|
||||
<!-- alt="{{ book.title }}" -->
|
||||
</figure>
|
||||
</div>
|
||||
<!-- <p class="content">{{ book[0].description|summarize }}</p> -->
|
||||
<!-- <p class="content">{{ book.description|summarize }}</p> -->
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<!-- <select id="collection_select" onchange="window.location.href='/api/collection/' + this.value"> -->
|
||||
<select id="collection_select">
|
||||
{% for collection in collections %}
|
||||
<option value="{{collection[0].collection}}" class="collection_selection">{{collection[0].collection}}</option>
|
||||
<option value="{{collection.id}}" class="collection_selection">{{collection.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user