diff --git a/app.py b/app.py index ada7abab..ba911200 100644 --- a/app.py +++ b/app.py @@ -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') @app.route('/request/favico') @@ -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}") diff --git a/backend.py b/backend.py index 4818b3a9..7fe22222 100644 --- a/backend.py +++ b/backend.py @@ -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}") diff --git a/book_manager.py b/book_manager.py index 1c77788e..e1d595a5 100644 --- a/book_manager.py +++ b/book_manager.py @@ -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) diff --git a/config.py b/config.py index fa66c66a..c0975d4d 100644 --- a/config.py +++ b/config.py @@ -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'] diff --git a/data/book-languages.json b/data/book-languages.json new file mode 100644 index 00000000..533fa362 --- /dev/null +++ b/data/book-languages.json @@ -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" } +] \ No newline at end of file diff --git a/models.py b/models.py index d086ed8f..c7f73f48 100644 --- a/models.py +++ b/models.py @@ -107,4 +107,13 @@ class BookQueue: # Global instance of BookQueue -book_queue = BookQueue() \ No newline at end of file +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 \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css index a1f32805..a65f3d89 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -67,6 +67,10 @@ footer { outline: none; } +.search-filter { + max-width: 210px; +} + /* Table Styles */ .table-responsive { margin-bottom: 1rem; diff --git a/static/js/main.js b/static/js/main.js index 65032674..e4a2947b 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -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); } }); diff --git a/templates/index.html b/templates/index.html index 4c38e39e..6fc6b750 100644 --- a/templates/index.html +++ b/templates/index.html @@ -30,6 +30,76 @@ + +