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 👇

![image](https://github.com/user-attachments/assets/7237ae96-7e97-4db0-98da-06c3171aca3b)

smaller/mobile

![image](https://github.com/user-attachments/assets/ba20de0e-deb1-4aa1-9c5f-27cf473670e6)
This commit is contained in:
mik593
2025-01-22 16:51:06 -05:00
committed by GitHub
parent 7113905e25
commit 7f56c6b832
9 changed files with 266 additions and 23 deletions
+22 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5 -2
View File
@@ -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']
+72
View File
@@ -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" }
]
+10 -1
View File
@@ -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
+4
View File
@@ -67,6 +67,10 @@ footer {
outline: none;
}
.search-filter {
max-width: 210px;
}
/* Table Styles */
.table-responsive {
margin-bottom: 1rem;
+50 -10
View File
@@ -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);
}
});
+70
View File
@@ -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>