Updated ui, better balance.

This commit is contained in:
2025-08-07 17:26:53 +00:00
parent 5553226838
commit 616e073fe7
7 changed files with 318 additions and 150 deletions

View File

@@ -1,5 +1,7 @@
"""Pyshelf's Main Storage Class."""
import re
from collections import defaultdict
from rapidfuzz import process, fuzz
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from pathlib import Path
@@ -89,16 +91,16 @@ class Storage:
# collections = self.parse_collections_from_path(book)
# breakpoint()
_book = Book(
title=book[0],
author=book[1],
cover=cover_image,
file_name=book[3],
description=book[4],
identifier=book[5],
publisher=book[6],
rights=book[8],
tags=book[9],
)
title=book[0],
author=book[1],
cover=cover_image,
file_name=book[3],
description=book[4],
identifier=book[5],
publisher=book[6],
rights=book[8],
tags=book[9],
)
session.add(_book)
session.commit()
session.close()
@@ -163,21 +165,15 @@ class Storage:
# 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
# check if collection exists
collection = session.execute(
select(Collection).where(Collection.name == folder)
).scalar_one_or_none()
@@ -201,36 +197,6 @@ class Storage:
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.
@@ -263,7 +229,6 @@ class Storage:
).scalars().all()
return result
def get_book(self, id):
"""Get book from database.
@@ -293,11 +258,7 @@ class Storage:
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.
@@ -309,3 +270,79 @@ class Storage:
_result = session.execute(select(Collection).where(Collection.name == name).join(Book)).all()
session.close()
return _result
# def fuzzy_search_books(self, query: str, limit: int = 30):
# """Fuzzy search for books by title, author, or tags."""
# with Session(self.engine) as session:
# books = session.execute(select(Book)).scalars().all()
#
# # Prepare a combined text field
# book_choices = {book.id: f"{book.title or ''} {book.author or ''} {book.tags or ''}"
# for book in books}
#
# # Use RapidFuzz to score
# results = process.extract(query, book_choices, scorer=fuzz.WRatio, limit=limit)
#
# # results = [(matched_text, score, book_id), ...]
# book_ids = [book_id for (_, score, book_id) in results if score > 50] # threshold
# books = [b for b in books if b.id in book_ids]
# return books
def parse_advanced_query(self, query: str) -> dict:
"""Parse a query like 'title:"dark tower" author:king tags:fantasy'"""
fields = ["title", "author", "tags"]
tokens = re.findall(r'(\w+:"[^"]+"|\w+:\S+|"[^"]+"|\S+)', query)
parsed = defaultdict(list)
for token in tokens:
field_match = re.match(r"(\w+):\"(.+?)\"", token) or re.match(r"(\w+):(\S+)", token)
if field_match:
field, value = field_match.groups()
field = field.lower()
if field in fields:
parsed[field].append(value.strip('"'))
elif token.startswith('"') and token.endswith('"'):
parsed["keywords"].append(token.strip('"'))
else:
parsed["keywords"].append(token)
return parsed
def fuzzy_search_books(self, query: str, limit: int = 30):
parsed = self.parse_advanced_query(query)
with Session(self.engine) as session:
books = session.execute(select(Book)).scalars().all()
# Apply field filters
def match_field(book, field, values):
content = (getattr(book, field) or "").lower()
return all(v.lower() in content for v in values)
filtered = []
for book in books:
if any(
not match_field(book, field, values)
for field, values in parsed.items()
if field in ("title", "author", "tags")
):
continue
filtered.append(book)
# Apply fuzzy keyword match if needed
if "keywords" in parsed:
book_choices = {
b.id: f"{b.title or ''} {b.author or ''} {b.tags or ''}" for b in filtered
}
fuzzy_results = process.extract(
" ".join(parsed["keywords"]),
book_choices,
scorer=fuzz.WRatio,
limit=limit
)
matched_ids = [book_id for _, score, book_id in fuzzy_results if score > 50]
return [b for b in filtered if b.id in matched_ids]
return filtered

View File

@@ -86,21 +86,21 @@ def books_tojson(obj) -> dumps:
def book_tojson(book) -> dumps:
"""Convert a book object to a json."""
return dumps({
"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,
})
"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:
return dumps(obj)
@@ -178,6 +178,11 @@ class FastAPIServer():
collections = storage.get_collections()
"""Home page responder."""
total_books = len(storage.get_books())
if skip <= 0:
skip_num = 0
skip = 0
else:
skip_num = skip * limit
context = {
"request": request,
"total_pages": math.ceil(total_books / limit),
@@ -203,7 +208,7 @@ 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())))
@@ -221,7 +226,7 @@ class FastAPIServer():
"""Home page responder."""
return JSONResponse(content=collections_tojson(collections))
@app.get("/api/collection/{collection}", response_class=JSONResponse)
@app.get("/api/collection/{collection}", response_class=HTMLResponse)
async def collection(request: Request, collection: str, skip:int=0, limit:int=30):
"""Collection file responder."""
storage = Storage(Config(os.path.abspath(os.getcwd())))
@@ -243,6 +248,22 @@ class FastAPIServer():
"limit": limit
}
return templates.TemplateResponse("collection.html", context)
@app.get("/api/search", response_class=HTMLResponse)
async def search_books_api(request: Request, search: str):
"""Collection file responder."""
storage = Storage(Config(os.path.abspath(os.getcwd())))
books = storage.fuzzy_search_books(search)
total_books = len(books)
collections = storage.get_collections()
context = {
"request": request,
"books": books,
"collections": collections,
"total_pages": 1,
"total_books": total_books,
}
return templates.TemplateResponse("search.html", context)
async def run(self):
"""Front end server entrypoint."""

View File

@@ -55,6 +55,14 @@ $footer-background-color: $ps-color-primary-trans !important
margin: 1rem;
// background-color: $ps-color-background;
.is-rounded-left
border-top-left-radius: 20px !important;
border-bottom-left-radius: 20px !important;
.is-rounded-right
border-top-right-radius: 20px !important;
border-bottom-right-radius: 20px !important;
.book-thumbnail
z-index: 1;
position: relative;
@@ -112,6 +120,10 @@ $footer-background-color: $ps-color-primary-trans !important
margin-top: auto;
margin-bottom: auto;
.search_form
margin-top: auto !important;
margin-bottom: auto !important;
.footer
display: flex;
padding: 1rem !important;

View File

@@ -1,81 +1,94 @@
<script type="text/javascript">
const collections = {{ collections|collections_tojson }};
const collections = {{ collections|collections_tojson }};
</script>
<nav id="navbar-main" class="navbar is-fixed-top is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="https://github.com/th3r00t/pyShelf">
<img src="{{ url_for('static', path='images/svg/logo-no-background-no-border.svg') }}" width="112" height="28" />
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarMain">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarMain" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item">
Home
</a>
<a class="navbar-item">
Ebooks
</a>
<a class="navbar-item">
Comics
</a>
<a class="navbar-item">
Documentation
</a>
<div class="navbar-item dropdown is-hoverable is-right" id="more-menu">
<a class="navbar-link">
More
</a>
<div class="navbar-dropdown">
<a class="navbar-item">
About
</a>
<a class="navbar-item">
Logs
</a>
<a class="navbar-item">
Contact
</a>
<hr class="navbar-divider">
<a class="navbar-item">
Report an issue
</a>
</div>
</div>
<div class="select is-small is-rounded is-link" id="collection_dropdown">
<!-- <select id="collection_select" onchange="window.location.href='/api/collection/' + this.value"> -->
<select id="collection_select">
<option value="" disabled selected>Select a collection</option>
{% if collections is not defined or collections|length == 0 %}
<option value="" disabled>No collections available</option>
{% endif %}
{% for collection in collections %}
<option value="{{collection.id}}" class="collection_selection">{{collection.name}}</option>
{% endfor %}
</select>
<script>
document.getElementById("collection_select").addEventListener("change", function() {
const value = encodeURIComponent(this.value);
window.location.href = `/api/collection/${value}`;
});
</script>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary">
<strong>Sign up</strong>
</a>
<a class="button is-light">
Log in
</a>
</div>
</div>
</div>
</div>
<div class="navbar-brand">
<a class="navbar-item" href="https://github.com/th3r00t/pyShelf">
<img src="{{ url_for('static', path='images/svg/logo-no-background-no-border.svg') }}" width="112" height="28" />
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarMain">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarMain" class="navbar-menu">
<div class="navbar-start is-flex is-flex-grow-1">
<a class="navbar-item" href="/">Home</a>
<div class="navbar-item is-flex-grow-1 px-2">
<div class="field is-grouped is-fullwidth" style="width: 100%;">
<p class="control is-expanded">
<span class="select is-small is-rounded is-link is-fullwidth">
<select id="collection_select">
<option value="" disabled selected>Select a collection</option>
{% if collections is not defined or collections|length == 0 %}
<option value="" disabled>No collections available</option>
{% endif %}
{% for collection in collections %}
<option value="{{collection.id}}" class="collection_selection">
<!-- {{collection.name[:80]}} -->
{{collection.name}}
</option>
{% endfor %}
</select>
</span>
</p>
</div>
</div>
<div class="navbar-item is-flex-grow-1 px-2">
<form action="/api/search" method="get" class="field has-addons is-flex-grow-1">
<div class="control is-expanded">
<input
class="input is-small is-dark is-link is-rounded-left"
type="text"
name="search"
placeholder="Search books..." />
</div>
<div class="control">
<button
class="button is-small is-dark is-link is-rounded-right"
type="submit">
Search
</button>
</div>
</form>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item dropdown is-hoverable is-right" id="more-menu">
<a class="navbar-link">
More
</a>
<div class="navbar-dropdown">
<a class="navbar-item">
About
</a>
<a class="navbar-item">
Logs
</a>
<a class="navbar-item">
Documentation
</a>
<a class="navbar-item">
Contact
</a>
<hr class="navbar-divider">
<a class="navbar-item">
Report an issue
</a>
</div>
</div>
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary is-small is-rounded is-dark is-link">
<strong>Sign up</strong>
</a>
<a class="button is-dark is-small is-rounded is-link">
Log in
</a>
</div>
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,45 @@
<!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">
{% 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>
</section>
{% include 'footer.html' %}