mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 07:51:12 +01:00
feat: support search filters (#55)
Still a WIP but an initial attempt of search filters. #7 Currently supporting: - ISBN - Author - Title - Language - Sort - Content first attempt at some sort of UI for it 👇  smaller/mobile 
This commit is contained in:
@@ -8,9 +8,11 @@ from flask import url_for as flask_url_for
|
||||
from functools import partial
|
||||
|
||||
from logger import setup_logger
|
||||
from config import FLASK_HOST, FLASK_PORT, FLASK_DEBUG
|
||||
from config import FLASK_HOST, FLASK_PORT, FLASK_DEBUG, _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE
|
||||
import backend
|
||||
|
||||
from models import SearchFilters
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
app = Flask(__name__)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app) # type: ignore
|
||||
@@ -69,7 +71,7 @@ def index():
|
||||
"""
|
||||
Render main page with search and status table.
|
||||
"""
|
||||
return render_template('index.html')
|
||||
return render_template('index.html', book_languages=_SUPPORTED_BOOK_LANGUAGE, default_language=BOOK_LANGUAGE)
|
||||
|
||||
@app.route('/favico<path:_>')
|
||||
@app.route('/request/favico<path:_>')
|
||||
@@ -85,16 +87,32 @@ def api_search():
|
||||
|
||||
Query Parameters:
|
||||
query (str): Search term (ISBN, title, author, etc.)
|
||||
isbn (str): Book ISBN
|
||||
author (str): Book Author
|
||||
title (str): Book Title
|
||||
lang (str): Book Language
|
||||
sort (str): Order to sort results
|
||||
content (str): Content type of book
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of matching books or empty array if no query.
|
||||
"""
|
||||
query = request.args.get('query', '')
|
||||
if not query:
|
||||
|
||||
filters = SearchFilters(
|
||||
isbn = request.args.getlist('isbn'),
|
||||
author = request.args.getlist('author'),
|
||||
title = request.args.getlist('title'),
|
||||
lang = request.args.getlist('lang'),
|
||||
sort = request.args.get('sort'),
|
||||
content = request.args.getlist('content'),
|
||||
)
|
||||
|
||||
if not query and not any(vars(filters).values()):
|
||||
return jsonify([])
|
||||
|
||||
try:
|
||||
books = backend.search_books(query)
|
||||
books = backend.search_books(query, filters)
|
||||
return jsonify(books)
|
||||
except Exception as e:
|
||||
logger.error(f"Search error: {e}")
|
||||
|
||||
+4
-3
@@ -7,22 +7,23 @@ from typing import Dict, List, Optional, Any, Tuple
|
||||
|
||||
from logger import setup_logger
|
||||
from config import TMP_DIR, MAIN_LOOP_SLEEP_TIME, INGEST_DIR
|
||||
from models import book_queue, BookInfo, QueueStatus
|
||||
from models import book_queue, BookInfo, QueueStatus, SearchFilters
|
||||
import book_manager
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def search_books(query: str) -> List[Dict[str, Any]]:
|
||||
def search_books(query: str, filters: SearchFilters) -> List[Dict[str, Any]]:
|
||||
"""Search for books matching the query.
|
||||
|
||||
Args:
|
||||
query: Search term
|
||||
filters: Search filters object
|
||||
|
||||
Returns:
|
||||
List[Dict]: List of book information dictionaries
|
||||
"""
|
||||
try:
|
||||
books = book_manager.search_books(query)
|
||||
books = book_manager.search_books(query, filters)
|
||||
return [_book_info_to_dict(book) for book in books]
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching books: {e}")
|
||||
|
||||
+29
-3
@@ -9,12 +9,12 @@ from io import BytesIO
|
||||
|
||||
from logger import setup_logger
|
||||
from config import SUPPORTED_FORMATS, BOOK_LANGUAGE, AA_DONATOR_KEY, AA_BASE_URL, USE_CF_BYPASS
|
||||
from models import BookInfo
|
||||
from models import BookInfo, SearchFilters
|
||||
import network
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def search_books(query: str) -> List[BookInfo]:
|
||||
def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
"""Search for books matching the query.
|
||||
|
||||
Args:
|
||||
@@ -27,11 +27,37 @@ def search_books(query: str) -> List[BookInfo]:
|
||||
Exception: If no books found or parsing fails
|
||||
"""
|
||||
query_html = quote(query)
|
||||
|
||||
if filters.isbn:
|
||||
#ISBNs are included in query string
|
||||
isbns = " || ".join([f"('isbn13:{isbn}' || 'isbn10:{isbn}')" for isbn in filters.isbn])
|
||||
query_html = quote(f"({isbns}) {query}")
|
||||
|
||||
filters_query = ""
|
||||
|
||||
for value in filters.lang or BOOK_LANGUAGE:
|
||||
if value != "all":
|
||||
filters_query += f"&lang={quote(value)}"
|
||||
|
||||
if filters.sort:
|
||||
filters_query += f"&sort={quote(filters.sort)}"
|
||||
|
||||
for value in filters.content:
|
||||
filters_query += f"&content={quote(value)}"
|
||||
|
||||
index = 1
|
||||
for filter_type, filter_values in vars(filters).items():
|
||||
if filter_type == 'author' or filter_type == 'title' and filter_values:
|
||||
for value in filter_values:
|
||||
filters_query += f"&termtype_{index}={filter_type}&termval_{index}={quote(value)}"
|
||||
index += 1
|
||||
|
||||
url = (
|
||||
f"{AA_BASE_URL}"
|
||||
f"/search?index=&page=1&display=table"
|
||||
f"&acc=aa_download&acc=external_download&sort="
|
||||
f"&ext={'&ext='.join(SUPPORTED_FORMATS)}&lang={'&lang='.join(BOOK_LANGUAGE)}&q={query_html}"
|
||||
f"&ext={'&ext='.join(SUPPORTED_FORMATS)}&q={query_html}"
|
||||
f"{filters_query}"
|
||||
)
|
||||
|
||||
html = network.html_get_page(url)
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
_SUPPORTED_BOOK_LANGUAGE = ['en','zh','ru','es','fr','de','it','pt','pl','bg','nl','ja','ar','he','hu','la','cs','ko','tr','uk','id','ro','el','lt','bn','zh‑Hant','af','ca','sv','th','hi','ga','lv','kn','sr','bo','da','fa','hr','sk','jv','vi','ur','fi','no','rw','ta','be','kk','mn','ka','sl','eo','gl','mr','fil','gu','ml','ky','qu','az','sw','ba','pa','ms','te','sq','ug','hy','shn']
|
||||
|
||||
with open("data/book-languages.json") as file:
|
||||
_SUPPORTED_BOOK_LANGUAGE = json.load(file)
|
||||
|
||||
# Directory settings
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
@@ -34,7 +37,7 @@ AA_BASE_URL = os.getenv("AA_BASE_URL", "https://annas-archive.org").strip("/")
|
||||
SUPPORTED_FORMATS = os.getenv("SUPPORTED_FORMATS", "epub,mobi,azw3,fb2,djvu,cbz,cbr").split(",")
|
||||
|
||||
BOOK_LANGUAGE = os.getenv("BOOK_LANGUAGE", "en").lower().split(',')
|
||||
BOOK_LANGUAGE = [l for l in BOOK_LANGUAGE if l in _SUPPORTED_BOOK_LANGUAGE]
|
||||
BOOK_LANGUAGE = [l for l in BOOK_LANGUAGE if l in [lang['code'] for lang in _SUPPORTED_BOOK_LANGUAGE]]
|
||||
if len(BOOK_LANGUAGE) == 0:
|
||||
BOOK_LANGUAGE = ['en']
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{ "language": "English", "code": "en" },
|
||||
{ "language": "Chinese", "code": "zh" },
|
||||
{ "language": "Russian", "code": "ru" },
|
||||
{ "language": "Spanish", "code": "es" },
|
||||
{ "language": "French", "code": "fr" },
|
||||
{ "language": "German", "code": "de" },
|
||||
{ "language": "Italian", "code": "it" },
|
||||
{ "language": "Portuguese", "code": "pt" },
|
||||
{ "language": "Polish", "code": "pl" },
|
||||
{ "language": "Bulgarian", "code": "bg" },
|
||||
{ "language": "Dutch", "code": "nl" },
|
||||
{ "language": "Japanese", "code": "ja" },
|
||||
{ "language": "Arabic", "code": "ar" },
|
||||
{ "language": "Hebrew", "code": "he" },
|
||||
{ "language": "Turkish", "code": "tr" },
|
||||
{ "language": "Hungarian", "code": "hu" },
|
||||
{ "language": "Latin", "code": "la" },
|
||||
{ "language": "Czech", "code": "cs" },
|
||||
{ "language": "Korean", "code": "ko" },
|
||||
{ "language": "Ukrainian", "code": "uk" },
|
||||
{ "language": "Indonesian", "code": "id" },
|
||||
{ "language": "Romanian", "code": "ro" },
|
||||
{ "language": "Swedish", "code": "sv" },
|
||||
{ "language": "Greek", "code": "el" },
|
||||
{ "language": "Lithuanian", "code": "lt" },
|
||||
{ "language": "Bangla", "code": "bn" },
|
||||
{ "language": "Traditional Chinese", "code": "zh‑Hant" },
|
||||
{ "language": "Afrikaans", "code": "af" },
|
||||
{ "language": "Catalan", "code": "ca" },
|
||||
{ "language": "Danish", "code": "da" },
|
||||
{ "language": "Thai", "code": "th" },
|
||||
{ "language": "Hindi", "code": "hi" },
|
||||
{ "language": "Irish", "code": "ga" },
|
||||
{ "language": "Latvian", "code": "lv" },
|
||||
{ "language": "Tibetan", "code": "bo" },
|
||||
{ "language": "Kannada", "code": "kn" },
|
||||
{ "language": "Serbian", "code": "sr" },
|
||||
{ "language": "Persian", "code": "fa" },
|
||||
{ "language": "Croatian", "code": "hr" },
|
||||
{ "language": "Slovak", "code": "sk" },
|
||||
{ "language": "Javanese", "code": "jv" },
|
||||
{ "language": "Vietnamese", "code": "vi" },
|
||||
{ "language": "Urdu", "code": "ur" },
|
||||
{ "language": "Finnish", "code": "fi" },
|
||||
{ "language": "Norwegian", "code": "no" },
|
||||
{ "language": "Kinyarwanda", "code": "rw" },
|
||||
{ "language": "Tamil", "code": "ta" },
|
||||
{ "language": "Belarusian", "code": "be" },
|
||||
{ "language": "Kazakh", "code": "kk" },
|
||||
{ "language": "Mongolian", "code": "mn" },
|
||||
{ "language": "Georgian", "code": "ka" },
|
||||
{ "language": "Slovenian", "code": "sl" },
|
||||
{ "language": "Esperanto", "code": "eo" },
|
||||
{ "language": "Galician", "code": "gl" },
|
||||
{ "language": "Marathi", "code": "mr" },
|
||||
{ "language": "Filipino", "code": "fil" },
|
||||
{ "language": "Gujarati", "code": "gu" },
|
||||
{ "language": "Malayalam", "code": "ml" },
|
||||
{ "language": "Kyrgyz", "code": "ky" },
|
||||
{ "language": "Azerbaijani", "code": "az" },
|
||||
{ "language": "Quechua", "code": "qu" },
|
||||
{ "language": "Swahili", "code": "sw" },
|
||||
{ "language": "Bashkir", "code": "ba" },
|
||||
{ "language": "Punjabi", "code": "pa" },
|
||||
{ "language": "Malay", "code": "ms" },
|
||||
{ "language": "Telugu", "code": "te" },
|
||||
{ "language": "Albanian", "code": "sq" },
|
||||
{ "language": "Uyghur", "code": "ug" },
|
||||
{ "language": "Armenian", "code": "hy" },
|
||||
{ "language": "Shan", "code": "shn" }
|
||||
]
|
||||
@@ -107,4 +107,13 @@ class BookQueue:
|
||||
|
||||
|
||||
# Global instance of BookQueue
|
||||
book_queue = BookQueue()
|
||||
book_queue = BookQueue()
|
||||
|
||||
@dataclass
|
||||
class SearchFilters:
|
||||
isbn: Optional[List[str]] = None
|
||||
author: Optional[List[str]] = None
|
||||
title: Optional[List[str]] = None
|
||||
lang: Optional[List[str]] = None
|
||||
sort: Optional[str] = None
|
||||
content: Optional[List[str]] = None
|
||||
@@ -67,6 +67,10 @@ footer {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-filter {
|
||||
max-width: 210px;
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-responsive {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
+50
-10
@@ -2,8 +2,14 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
searchInput: document.getElementById('search-input'),
|
||||
searchButton: document.getElementById('search-button'),
|
||||
search: {
|
||||
searchInput: document.getElementById('search-input'),
|
||||
searchButton: document.getElementById('search-button'),
|
||||
advanced: {
|
||||
advSearchButton: document.getElementById('adv-search-button'),
|
||||
searchFiltersForm: document.getElementById('search-filters')
|
||||
}
|
||||
},
|
||||
selectAllCheckbox: document.getElementById('select-all-checkbox'),
|
||||
downloadSelectedButton: document.getElementById('download-selected-button'),
|
||||
resultsSectionAccordion: document.getElementById('results-section-accordion'),
|
||||
@@ -24,7 +30,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const selectedBooks = new Set();
|
||||
const STATE = {
|
||||
isSearching: false,
|
||||
isLoadingDetails: false
|
||||
isLoadingDetails: false,
|
||||
};
|
||||
|
||||
// Constants
|
||||
@@ -35,6 +41,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
download: '/request/api/download',
|
||||
status: '/request/api/status'
|
||||
};
|
||||
const FILTERS = ['isbn', 'author', 'title', 'lang' , 'sort', "content"];
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
@@ -241,8 +248,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!elements.searchAccordion.classList.contains('uk-open')) {
|
||||
utils.showAccordion(elements.resultsSectionAccordion);
|
||||
};
|
||||
|
||||
const data = await utils.fetchJson(
|
||||
`${API_ENDPOINTS.search}?query=${encodeURIComponent(query)}`
|
||||
`${API_ENDPOINTS.search}?${query}`
|
||||
);
|
||||
|
||||
this.displayResults(data);
|
||||
@@ -254,6 +262,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
},
|
||||
|
||||
buildQuery() {
|
||||
let queryParams = [];
|
||||
|
||||
if(elements.search.searchInput.value.trim()) {
|
||||
queryParams.push(`query=${encodeURIComponent(elements.search.searchInput.value.trim())}`);
|
||||
}
|
||||
|
||||
if(elements.search.advanced.searchFiltersForm.hasAttribute('hidden')) {
|
||||
//Not advanced search
|
||||
return queryParams.join('&');
|
||||
}
|
||||
|
||||
FILTERS.forEach(filterType => {
|
||||
const inputs = document.querySelectorAll(`[id^="${filterType}-input"]`);
|
||||
|
||||
inputs.forEach(input => {
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
queryParams.push(`${filterType}=${encodeURIComponent(value.trim())}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return queryParams.join('&');
|
||||
},
|
||||
|
||||
displayResults(books) {
|
||||
elements.resultsTableBody.innerHTML = '';
|
||||
|
||||
@@ -561,16 +595,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Event Listeners
|
||||
function setupEventListeners() {
|
||||
// Search events
|
||||
elements.searchButton.addEventListener('click', () => {
|
||||
const query = elements.searchInput.value.trim();
|
||||
if (query) search.performSearch(query);
|
||||
elements.search.searchButton.addEventListener('click', () => {
|
||||
const query = search.buildQuery();
|
||||
if(query) search.performSearch(query);
|
||||
});
|
||||
|
||||
elements.searchInput.addEventListener('keydown', (e) => {
|
||||
elements.search.advanced.advSearchButton.addEventListener('click', () => {
|
||||
const query = search.buildQuery();
|
||||
if(query) search.performSearch(query);
|
||||
});
|
||||
|
||||
elements.search.searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const query = elements.searchInput.value.trim();
|
||||
if (query) search.performSearch(query);
|
||||
|
||||
const query = search.buildQuery();
|
||||
if(query) search.performSearch(query);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,76 @@
|
||||
<input class="uk-search-input" type="search" placeholder="Search by ISBN, title, author..." aria-label="Search books" id="search-input">
|
||||
<button class="uk-search-icon-flip" uk-search-icon id="search-button" type="button"></button>
|
||||
</form>
|
||||
<button class="uk-button uk-button-default uk-float-right" uk-toggle="target: #search-filters" type="button">Advanced Search</button>
|
||||
<form hidden id="search-filters" class="uk-search uk-search-default uk-inline uk-width-expand uk-flex uk-flex-wrap">
|
||||
<div class="search-filter">
|
||||
<label for="isbn-input">ISBN</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<input class="uk-search-input uk-width-4-5" type="search" placeholder="ISBN..." aria-label="Search by ISBN" id="isbn-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="author-input">Author</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<input class="uk-search-input uk-width-4-5" type="search" placeholder="Author..." aria-label="Search by author" id="author-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="title-input">Title</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<input class="uk-search-input uk-width-4-5" type="search" placeholder="Title" aria-label="Search by title" id="title-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="lang-input">Language</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<select class="uk-select uk-width-4-5" id="lang-input">
|
||||
<option value="all">All</option>
|
||||
{% for lang in book_languages %}
|
||||
<option value="{{ lang.code }}"
|
||||
{% if lang.code == default_language[0] %}selected{% endif %}>
|
||||
{{ lang.language }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="sort-input">Sort</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<select class="uk-select uk-width-4-5" id="sort-input">
|
||||
<option value="">Most relevant</option>
|
||||
<option value="newest">Newest (publication year)</option>
|
||||
<option value="oldest">Oldest (publication year)</option>
|
||||
<option value="largest">Largest (filesize)</option>
|
||||
<option value="smallest">Smallest (filesize)</option>
|
||||
<option value="newest_added">Newest (open sourced)</option>
|
||||
<option value="oldest_added">Oldest (open sourced)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="content-input">Content</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<select class="uk-select uk-width-4-5" id="content-input">
|
||||
<option value="">All</option>
|
||||
<option value="book_nonfiction">Book (non-fiction)</option>
|
||||
<option value="book_fiction">Book (fiction)</option>
|
||||
<option value="book_unknown">Book (unkown)</option>
|
||||
<option value="magazine">Magazine</option>
|
||||
<option value="book_comic">Comic Book</option>
|
||||
<option value="standards_document">Standards document</option>
|
||||
<option value="other">Other</option>
|
||||
<option value="musical_score">Musical score</option>
|
||||
<option value="audiobook">Audiobook</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-flex uk-margin-auto-top uk-margin-auto-left">
|
||||
<button class="uk-button uk-button-default uk-margin-small-top" id="adv-search-button" type="button">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user