diff --git a/pyShelf.py b/pyShelf.py old mode 100644 new mode 100755 index 15f860e..e0683b6 --- a/pyShelf.py +++ b/pyShelf.py @@ -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" diff --git a/src/backend/lib/models.py b/src/backend/lib/models.py index f51be29..d85b0b7 100644 --- a/src/backend/lib/models.py +++ b/src/backend/lib/models.py @@ -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") \ No newline at end of file + # 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") diff --git a/src/backend/lib/storage.py b/src/backend/lib/storage.py index c927c04..6823b69 100644 --- a/src/backend/lib/storage.py +++ b/src/backend/lib/storage.py @@ -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 = [] - 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, - ) - ) - _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) + + # get all books and paths + books = session.execute(select(Book.id, Book.file_name)).all() + + for book_id, file_name in books: + try: + 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 + ) + ).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() - else: - _result = session.execute(select(Book).offset(skip).limit(limit)).all() - session.close() - return _result + .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 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 diff --git a/src/frontend/lib/FastAPIServer.py b/src/frontend/lib/FastAPIServer.py index 0ab49c8..ff405eb 100644 --- a/src/frontend/lib/FastAPIServer.py +++ b/src/frontend/lib/FastAPIServer.py @@ -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.""" diff --git a/src/frontend/templates/collection.html b/src/frontend/templates/collection.html new file mode 100644 index 0000000..3efb811 --- /dev/null +++ b/src/frontend/templates/collection.html @@ -0,0 +1,52 @@ + +{% block javascript %} + + {% endblock %} + {% include 'header.html' %} + {% include 'navigation.html' %} + + + + {% for book in books %} + {% set cover = book.cover|b64decode %} + {% if cover != 'None' %} + + + + + + + + {% else %} + + + + {{ book.title }} + + + + + + {% endif %} + {% endfor %} + + + + Previous + Next + + + + {% include 'footer.html' %} diff --git a/src/frontend/templates/index.html b/src/frontend/templates/index.html index ee13c50..8785c34 100644 --- a/src/frontend/templates/index.html +++ b/src/frontend/templates/index.html @@ -1,6 +1,6 @@ {% block javascript %} -