From 71900e00dbbcf111798c24bb4c24876c7d531fa2 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:53:43 +0000 Subject: [PATCH] Feature: Hardcover list search (#694) - Adds the functionality to search Hardcover lists, either public lists or user's private lists - Paste a list URL into the search box to view results - Select a specific list from user's collection from advanced fields dropdown - Fixes content_type parameter in URL search query to use book/audiobook --- docs/url-search-parameters.md | 9 +- shelfmark/main.py | 44 ++ shelfmark/metadata_providers/__init__.py | 24 +- shelfmark/metadata_providers/hardcover.py | 447 +++++++++++++++++- src/frontend/src/App.tsx | 42 +- src/frontend/src/components/Header.tsx | 3 + src/frontend/src/components/SearchBar.tsx | 8 + src/frontend/src/components/SearchSection.tsx | 3 + .../src/components/shared/DynamicDropdown.tsx | 122 +++++ .../components/shared/SearchFieldRenderer.tsx | 12 + src/frontend/src/hooks/useSearch.ts | 13 +- src/frontend/src/hooks/useUrlSearch.ts | 2 +- src/frontend/src/services/api.ts | 32 +- .../tests/parseUrlSearchParams.node.test.ts | 45 ++ src/frontend/src/types/index.ts | 11 +- .../src/utils/parseUrlSearchParams.ts | 23 +- 16 files changed, 816 insertions(+), 24 deletions(-) create mode 100644 src/frontend/src/components/shared/DynamicDropdown.tsx create mode 100644 src/frontend/src/tests/parseUrlSearchParams.node.test.ts diff --git a/docs/url-search-parameters.md b/docs/url-search-parameters.md index 56fde306..aaf49120 100644 --- a/docs/url-search-parameters.md +++ b/docs/url-search-parameters.md @@ -19,6 +19,7 @@ http://your-server:8084/?q=harry+potter | `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` | | `format` | Filter by file format | `/?format=epub` | | `content` | Filter by content type | `/?content=fiction` | +| `content_type` | Select media type (`ebook` or `audiobook`) in Universal mode only | `/?q=dune&content_type=audiobook` | | `sort` | Sort order for results | `/?sort=newest` | ## Multiple Values @@ -57,15 +58,21 @@ Some parameters support multiple values by repeating the parameter: /?q=science+fiction&sort=newest ``` +**Universal search as audiobook:** +``` +/?q=dune&content_type=audiobook +``` + ## Search Mode Behavior ### Direct Download Mode (default) All parameters are used to filter results from the direct download source. +`content_type` is ignored in Direct mode. ### Universal Mode -Only `q` and `sort` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities. +`q`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities. ## Notes diff --git a/shelfmark/main.py b/shelfmark/main.py index e0d2e53d..7b929d90 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -2118,6 +2118,50 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": str(e)}), 500 +@app.route('/api/metadata/field-options', methods=['GET']) +@login_required +def api_metadata_field_options() -> Response: + """Return dynamic search-field options for a metadata provider.""" + try: + from shelfmark.metadata_providers import ( + get_configured_provider, + get_provider, + get_provider_kwargs, + is_provider_registered, + ) + + field_key = request.args.get('field', '').strip() + provider_name = request.args.get('provider', '').strip() + content_type = request.args.get('content_type', 'ebook').strip() + + if not field_key: + return jsonify({"options": []}) + + raw_db_user_id = session.get("db_user_id") + try: + db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None + except (TypeError, ValueError): + db_user_id = None + + provider = None + if provider_name: + if not is_provider_registered(provider_name): + return jsonify({"options": []}) + kwargs = get_provider_kwargs(provider_name) + provider = get_provider(provider_name, **kwargs) + else: + provider = get_configured_provider(content_type=content_type, user_id=db_user_id) + + if not provider or not provider.is_available(): + return jsonify({"options": []}) + + options = provider.get_search_field_options(field_key) + return jsonify({"options": options}) + except Exception as e: + logger.warning(f"Metadata field options endpoint error: {e}") + return jsonify({"options": []}) + + @app.route('/api/metadata/book//', methods=['GET']) @login_required def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]: diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index ca302ce2..7748a769 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -75,8 +75,24 @@ class CheckboxSearchField: default: bool = False +@dataclass +class DynamicSelectSearchField: + """Single-choice dropdown field with options loaded from an API endpoint.""" + key: str + label: str + options_endpoint: str + placeholder: str = "" + description: str = "" + + # Type alias for all search field types -SearchField = Union[TextSearchField, NumberSearchField, SelectSearchField, CheckboxSearchField] +SearchField = Union[ + TextSearchField, + NumberSearchField, + SelectSearchField, + CheckboxSearchField, + DynamicSelectSearchField, +] def serialize_search_field(search_field: SearchField) -> Dict[str, Any]: @@ -98,6 +114,8 @@ def serialize_search_field(search_field: SearchField) -> Dict[str, Any]: result["options"] = search_field.options elif isinstance(search_field, CheckboxSearchField): result["default"] = search_field.default + elif isinstance(search_field, DynamicSelectSearchField): + result["options_endpoint"] = search_field.options_endpoint return result @@ -315,6 +333,10 @@ class MetadataProvider(ABC): has_more=has_more ) + def get_search_field_options(self, field_key: str) -> List[Dict[str, str]]: + """Get dynamic options for a provider-specific search field.""" + return [] + # Provider registry _PROVIDERS: Dict[str, Type[MetadataProvider]] = {} diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 2934ea8f..98623f9e 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -4,6 +4,7 @@ import re import requests from datetime import datetime from typing import Any, Dict, List, Optional +from urllib.parse import urlparse from shelfmark.core.cache import cacheable from shelfmark.core.logger import setup_logger @@ -27,6 +28,7 @@ from shelfmark.metadata_providers import ( SortOrder, register_provider, register_provider_kwargs, + DynamicSelectSearchField, TextSearchField, ) @@ -34,6 +36,85 @@ logger = setup_logger(__name__) HARDCOVER_API_URL = "https://api.hardcover.app/v1/graphql" HARDCOVER_PAGE_SIZE = 25 # Hardcover API returns max 25 results per page +HARDCOVER_LIST_URL_PATTERN = re.compile( + r"^/(?:@([\w.-]+)/)?lists?/([\w-]+)/?$", + re.IGNORECASE, +) + +LIST_LOOKUP_QUERY = """ +query LookupListsBySlug($slug: String!) { + lists(where: {slug: {_eq: $slug}}, limit: 20) { + id + slug + user { + username + } + } +} +""" + +LIST_BOOKS_BY_ID_QUERY = """ +query GetListBooksById($id: Int!, $limit: Int!, $offset: Int!) { + lists(where: {id: {_eq: $id}}, limit: 1) { + books_count + list_books(order_by: {position: asc}, limit: $limit, offset: $offset) { + book { + id + title + subtitle + slug + release_date + headline + description + pages + rating + ratings_count + users_count + cached_image + cached_contributors + contributions(where: {contribution: {_eq: "Author"}}) { + author { + name + } + } + featured_book_series { + position + series { + name + primary_books_count + } + } + } + } + } +} +""" + +USER_LISTS_QUERY = """ +query GetUserLists { + me { + id + username + lists(order_by: {name: asc}) { + id + name + slug + books_count + } + followed_lists(order_by: {created_at: desc}) { + list { + id + name + slug + books_count + user { + username + } + } + } + } +} +""" # Mapping from abstract sort order to Hardcover sort parameter @@ -289,6 +370,13 @@ class HardcoverProvider(MetadataProvider): label="Series", description="Search by series name", ), + DynamicSelectSearchField( + key="hardcover_list", + label="List", + options_endpoint="/api/metadata/field-options?provider=hardcover&field=hardcover_list", + placeholder="Browse a list...", + description="Browse books from a Hardcover list", + ), ] def __init__(self, api_key: Optional[str] = None): @@ -327,6 +415,287 @@ class HardcoverProvider(MetadataProvider): return query, "series_names,title,alternative_titles,author_names", "5,3,1,2" return default_query, None, None + def _detect_list_url(self, query: str) -> Optional[tuple[Optional[str], str]]: + """Detect and extract optional owner username + list slug from a URL string.""" + candidate = query.strip() + if not candidate: + return None + + parsed = urlparse(candidate) + if parsed.scheme not in {"http", "https"}: + return None + + hostname = (parsed.hostname or "").lower() + if hostname not in {"hardcover.app", "www.hardcover.app"}: + return None + + match = HARDCOVER_LIST_URL_PATTERN.match(parsed.path or "") + if not match: + return None + + owner_username = match.group(1).strip() if match.group(1) else None + slug = match.group(2).strip() + if not slug: + return None + + return owner_username, slug + + @cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:list:id") + def _fetch_list_books_by_id(self, list_id: int, page: int, limit: int) -> SearchResult: + """Fetch list books by unique Hardcover list ID.""" + if not self.api_key: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + offset = (page - 1) * limit + + result = self._execute_query( + LIST_BOOKS_BY_ID_QUERY, + { + "id": list_id, + "limit": limit, + "offset": offset, + }, + ) + if not result: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + lists = result.get("lists", []) + if not lists: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + list_data = lists[0] if isinstance(lists[0], dict) else {} + list_books = list_data.get("list_books", []) if isinstance(list_data, dict) else [] + books_count_raw = list_data.get("books_count", 0) if isinstance(list_data, dict) else 0 + + try: + books_count = int(books_count_raw) + except (TypeError, ValueError): + books_count = 0 + + books: List[BookMetadata] = [] + for item in list_books: + if not isinstance(item, dict): + continue + book_data = item.get("book", {}) + if not isinstance(book_data, dict) or not book_data: + continue + try: + author_names: List[str] = [] + + for contrib in book_data.get("contributions", []) or []: + if not isinstance(contrib, dict): + continue + author_data = contrib.get("author", {}) + if isinstance(author_data, dict): + author_name = str(author_data.get("name") or "").strip() + if author_name: + author_names.append(author_name) + + if not author_names: + for contrib in book_data.get("cached_contributors", []) or []: + if isinstance(contrib, dict): + nested_author = contrib.get("author", {}) + if isinstance(nested_author, dict): + nested_name = str(nested_author.get("name") or "").strip() + if nested_name: + author_names.append(nested_name) + continue + + flat_name = str(contrib.get("name") or "").strip() + if flat_name: + author_names.append(flat_name) + elif isinstance(contrib, str): + normalized = contrib.strip() + if normalized: + author_names.append(normalized) + + search_like_item = { + "id": book_data.get("id"), + "title": book_data.get("title"), + "subtitle": book_data.get("subtitle"), + "slug": book_data.get("slug"), + "release_date": book_data.get("release_date"), + "headline": book_data.get("headline"), + "description": book_data.get("description"), + "rating": book_data.get("rating"), + "ratings_count": book_data.get("ratings_count"), + "users_count": book_data.get("users_count"), + "image": book_data.get("cached_image"), + "author_names": author_names, + } + + parsed_book = self._parse_search_result(search_like_item) + if parsed_book: + books.append(parsed_book) + except Exception as exc: + logger.debug(f"Failed to parse Hardcover list book for list_id={list_id}: {exc}") + + has_more = offset + len(list_books) < books_count + return SearchResult(books=books, page=page, total_found=books_count, has_more=has_more) + + @cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:list:slug") + def _fetch_list_books(self, slug: str, owner_username: Optional[str], page: int, limit: int) -> SearchResult: + """Fetch list books by slug, optionally disambiguating by owner username.""" + if not self.api_key: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + lookup = self._execute_query(LIST_LOOKUP_QUERY, {"slug": slug}) + if not lookup: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + lists = lookup.get("lists", []) + if not isinstance(lists, list) or not lists: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + selected: Optional[Dict[str, Any]] = None + normalized_owner = owner_username.lower() if owner_username else None + if normalized_owner: + for item in lists: + if not isinstance(item, dict): + continue + owner_data = item.get("user", {}) + if not isinstance(owner_data, dict): + continue + candidate_owner = str(owner_data.get("username") or "").strip().lower() + if candidate_owner == normalized_owner: + selected = item + break + + if selected is None: + first_item = lists[0] + selected = first_item if isinstance(first_item, dict) else None + + if not selected: + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + list_id_raw = selected.get("id") + try: + list_id = int(list_id_raw) + except (TypeError, ValueError): + return SearchResult(books=[], page=page, total_found=0, has_more=False) + + return self._fetch_list_books_by_id(list_id, page, limit) + + def _resolve_current_user_id(self) -> Optional[str]: + """Resolve current Hardcover user id from saved settings or API me query.""" + connected_user_id = _get_connected_user_id() + if connected_user_id: + return connected_user_id + + result = self._execute_query("query { me { id, username } }", {}) + if not result: + return None + + me_data = result.get("me", {}) + if isinstance(me_data, list) and me_data: + me_data = me_data[0] + if not isinstance(me_data, dict): + return None + + user_id_raw = me_data.get("id") + if user_id_raw is None: + return None + + user_id = str(user_id_raw) + username_raw = me_data.get("username") + username = str(username_raw).strip() if username_raw else _get_connected_username() + _save_connected_user(user_id, username) + return user_id + + def get_user_lists(self) -> List[Dict[str, str]]: + """Get authenticated user's own and followed Hardcover lists.""" + if not self.api_key: + return [] + + connected_user_id = self._resolve_current_user_id() + if not connected_user_id: + return self._fetch_user_lists() + + return self._get_user_lists_cached(connected_user_id) + + def get_search_field_options(self, field_key: str) -> List[Dict[str, str]]: + """Provide dynamic options for Hardcover-specific advanced fields.""" + if field_key == "hardcover_list": + return self.get_user_lists() + return [] + + @cacheable(ttl=120, key_prefix="hardcover:user_lists") + def _get_user_lists_cached(self, _cache_user_id: str) -> List[Dict[str, str]]: + """Cached wrapper keyed by Hardcover user id to avoid cross-user cache leakage.""" + return self._fetch_user_lists() + + def _fetch_user_lists(self) -> List[Dict[str, str]]: + """Fetch raw list options from Hardcover me query.""" + result = self._execute_query(USER_LISTS_QUERY, {}) + if not result: + return [] + + me_data = result.get("me", {}) + if isinstance(me_data, list) and me_data: + me_data = me_data[0] + if not isinstance(me_data, dict): + return [] + + options: List[Dict[str, str]] = [] + seen_values: set[str] = set() + current_username = str(me_data.get("username") or "").strip() + + def _format_label(name: str, books_count: Any) -> str: + try: + return f"{name} ({int(books_count)})" + except (TypeError, ValueError): + return name + + for list_item in me_data.get("lists", []): + if not isinstance(list_item, dict): + continue + list_id = list_item.get("id") + slug = str(list_item.get("slug") or "").strip() + name = str(list_item.get("name") or "").strip() + value = f"id:{list_id}" if list_id is not None else slug + if not value or not name or value in seen_values: + continue + seen_values.add(value) + options.append( + { + "value": value, + "label": _format_label(name, list_item.get("books_count")), + "group": "My Lists", + } + ) + + for followed_item in me_data.get("followed_lists", []): + if not isinstance(followed_item, dict): + continue + + list_item = followed_item.get("list", {}) + if not isinstance(list_item, dict): + continue + + list_id = list_item.get("id") + slug = str(list_item.get("slug") or "").strip() + name = str(list_item.get("name") or "").strip() + value = f"id:{list_id}" if list_id is not None else slug + if not value or not name or value in seen_values: + continue + seen_values.add(value) + + option: Dict[str, str] = { + "value": value, + "label": _format_label(name, list_item.get("books_count")), + "group": "Followed Lists", + } + owner_data = list_item.get("user", {}) + if isinstance(owner_data, dict): + owner_username = str(owner_data.get("username") or "").strip() + if owner_username: + option["description"] = f"by @{owner_username}" + elif current_username: + option["description"] = f"by @{current_username}" + options.append(option) + + return options + def search(self, options: MetadataSearchOptions) -> List[BookMetadata]: """Search for books using Hardcover's search API.""" return self.search_paginated(options).books @@ -337,6 +706,24 @@ class HardcoverProvider(MetadataProvider): logger.warning("Hardcover API key not configured") return SearchResult(books=[], page=options.page, total_found=0, has_more=False) + # Allow pasting a Hardcover list URL directly in the search input + list_url_parts = self._detect_list_url(options.query) + if list_url_parts: + owner_username, list_slug = list_url_parts + return self._fetch_list_books(list_slug, owner_username, options.page, options.limit) + + # Advanced filter list selector (shared fetch path with URL detection) + list_value_from_field = str(options.fields.get("hardcover_list", "")).strip() + if list_value_from_field: + if list_value_from_field.startswith("id:"): + try: + list_id = int(list_value_from_field.split(":", 1)[1]) + return self._fetch_list_books_by_id(list_id, options.page, options.limit) + except (IndexError, ValueError): + logger.debug(f"Invalid hardcover_list field value: {list_value_from_field}") + return SearchResult(books=[], page=options.page, total_found=0, has_more=False) + return self._fetch_list_books(list_value_from_field, None, options.page, options.limit) + # Handle ISBN search separately if options.search_type == SearchType.ISBN: result = self.search_by_isbn(options.query) @@ -853,6 +1240,33 @@ class HardcoverProvider(MetadataProvider): if code3 and code3 not in titles_by_language: titles_by_language[code3] = edition_title + # Build display fields from Hardcover-specific metrics + display_fields: List[DisplayField] = [] + + rating = book.get("rating") + ratings_count = book.get("ratings_count") + if rating is not None: + try: + rating_str = f"{float(rating):.1f}" + except (TypeError, ValueError): + rating_str = str(rating) + + if ratings_count: + try: + rating_str += f" ({int(ratings_count):,})" + except (TypeError, ValueError): + pass + + display_fields.append(DisplayField(label="Rating", value=rating_str, icon="star")) + + users_count = book.get("users_count") + if users_count: + try: + readers_value = f"{int(users_count):,}" + except (TypeError, ValueError): + readers_value = str(users_count) + display_fields.append(DisplayField(label="Readers", value=readers_value, icon="users")) + return BookMetadata( provider="hardcover", provider_id=str(book["id"]), @@ -873,6 +1287,7 @@ class HardcoverProvider(MetadataProvider): series_position=series_position, series_count=series_count, titles_by_language=titles_by_language, + display_fields=display_fields, ) @@ -892,8 +1307,8 @@ def _test_hardcover_connection(current_values: Optional[Dict[str, Any]] = None) logger.debug(f"Hardcover test: key length={key_len}") if not api_key: - # Clear any stored username since there's no key - _save_connected_username(None) + # Clear any stored connection metadata since there's no key + _save_connected_user(None, None) return {"success": False, "message": "API key is required"} if key_len < 100: @@ -911,30 +1326,37 @@ def _test_hardcover_connection(current_values: Optional[Dict[str, Any]] = None) me_data = result.get("me", {}) if isinstance(me_data, list) and me_data: me_data = me_data[0] + user_id = str(me_data.get("id")) if isinstance(me_data, dict) and me_data.get("id") is not None else None username = me_data.get("username", "Unknown") if isinstance(me_data, dict) else "Unknown" - # Save the username for persistent display - _save_connected_username(username) + # Save connected user metadata for persistent display + per-user list caching + _save_connected_user(user_id, username) return {"success": True, "message": f"Connected as: {username}"} else: - _save_connected_username(None) + _save_connected_user(None, None) return {"success": False, "message": "API request failed - check your API key"} except Exception as e: logger.exception("Hardcover connection test failed") - _save_connected_username(None) + _save_connected_user(None, None) return {"success": False, "message": f"Connection failed: {str(e)}"} -def _save_connected_username(username: Optional[str]) -> None: - """Save or clear the connected username in config.""" +def _save_connected_user(user_id: Optional[str], username: Optional[str]) -> None: + """Save or clear connected user metadata in config.""" from shelfmark.core.settings_registry import save_config_file, load_config_file config = load_config_file("hardcover") + if user_id: + config["_connected_user_id"] = user_id + else: + config.pop("_connected_user_id", None) + if username: config["_connected_username"] = username else: config.pop("_connected_username", None) + save_config_file("hardcover", config) @@ -946,6 +1368,15 @@ def _get_connected_username() -> Optional[str]: return config.get("_connected_username") +def _get_connected_user_id() -> Optional[str]: + """Get the stored connected Hardcover user id.""" + from shelfmark.core.settings_registry import load_config_file + + config = load_config_file("hardcover") + value = config.get("_connected_user_id") + return str(value) if value is not None else None + + # Hardcover sort options for settings UI _HARDCOVER_SORT_OPTIONS = [ {"value": "relevance", "label": "Most relevant"}, diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index bb450d23..18d28f07 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -585,9 +585,13 @@ function App() { }, [isAuthenticated, loadConfig]); const runSearchWithPolicyRefresh = useCallback( - (query: string, fields = searchFieldValues) => { + ( + query: string, + fields = searchFieldValues, + contentTypeOverride?: ContentType + ) => { void refreshRequestPolicy(); - handleSearch(query, config, fields); + handleSearch(query, config, fields, contentTypeOverride); }, [refreshRequestPolicy, handleSearch, config, searchFieldValues] ); @@ -596,13 +600,23 @@ function App() { useEffect(() => { if ( wasProcessed && - parsedParams?.hasSearchParams && + parsedParams && !urlSearchExecutedRef.current && config ) { urlSearchExecutedRef.current = true; const searchMode = config.search_mode || 'direct'; + const urlContentTypeOverride = + searchMode === 'universal' ? parsedParams.contentType : undefined; + + if (urlContentTypeOverride && urlContentTypeOverride !== contentType) { + setContentType(urlContentTypeOverride); + } + + if (!parsedParams.hasSearchParams) { + return; + } const bookLanguages = config.book_languages || []; const defaultLanguageCodes = config.default_language && config.default_language.length > 0 @@ -645,11 +659,12 @@ function App() { searchMode, }); - runSearchWithPolicyRefresh(query); + runSearchWithPolicyRefresh(query, searchFieldValues, urlContentTypeOverride); } }, [ wasProcessed, parsedParams, + contentType, config, advancedFilters, searchFieldValues, @@ -1315,6 +1330,23 @@ function App() { const universalDefaultMode = getUniversalDefaultPolicyMode(); const manualSearchAllowed = searchMode === 'universal' && (universalDefaultMode === 'download' || universalDefaultMode === 'request_release'); + const isListBrowsing = useMemo(() => { + const dynamicFieldKeys = (config?.metadata_search_fields ?? []) + .filter((field) => field.type === 'DynamicSelectSearchField') + .map((field) => field.key); + + if (dynamicFieldKeys.length === 0) { + return false; + } + + return dynamicFieldKeys.some((key) => { + const value = searchFieldValues[key]; + if (typeof value === 'string') { + return value.trim() !== ''; + } + return value !== undefined && value !== null && value !== false; + }); + }, [config?.metadata_search_fields, searchFieldValues]); // Reset manual search if policy changes to disallow it useEffect(() => { @@ -1406,6 +1438,7 @@ function App() { contentType={contentType} onContentTypeChange={setContentType} isManualSearch={isManualSearch} + searchDisabled={isListBrowsing} /> @@ -1472,6 +1505,7 @@ function App() { onContentTypeChange={setContentType} isManualSearch={isManualSearch} onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined} + searchDisabled={isListBrowsing} /> void; isManualSearch?: boolean; + searchDisabled?: boolean; } export const Header = forwardRef(({ @@ -71,6 +72,7 @@ export const Header = forwardRef(({ contentType = 'ebook', onContentTypeChange, isManualSearch = false, + searchDisabled = false, }, ref) => { const activityBadge = getActivityBadgeState(statusCounts, isAdmin); const settingsEnabled = canAccessSettings ?? isAdmin; @@ -655,6 +657,7 @@ export const Header = forwardRef(({ contentType={contentType} onContentTypeChange={onContentTypeChange} isManualSearch={isManualSearch} + disabled={searchDisabled} /> diff --git a/src/frontend/src/components/SearchBar.tsx b/src/frontend/src/components/SearchBar.tsx index 1b41a55a..39d5754e 100644 --- a/src/frontend/src/components/SearchBar.tsx +++ b/src/frontend/src/components/SearchBar.tsx @@ -26,6 +26,7 @@ interface SearchBarProps { onContentTypeChange?: (type: ContentType) => void; // Manual search mode isManualSearch?: boolean; + disabled?: boolean; } export interface SearchBarHandle { @@ -54,6 +55,7 @@ export const SearchBar = forwardRef(({ contentType = 'ebook', onContentTypeChange, isManualSearch = false, + disabled = false, }, ref) => { const { searchMode, isUniversalMode } = useSearchMode(); const inputRef = useRef(null); @@ -110,6 +112,10 @@ export const SearchBar = forwardRef(({ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { + if (disabled) { + e.preventDefault(); + return; + } onSubmit(); (e.target as HTMLInputElement).blur(); } @@ -124,6 +130,7 @@ export const SearchBar = forwardRef(({ const inputClasses = [ 'w-full pr-40 py-3 border outline-none search-input', showContentTypeSelector ? 'pl-3 rounded-r-full' : 'pl-4 rounded-full', + disabled ? 'opacity-60 cursor-not-allowed' : '', inputClassName, ] .filter(Boolean) @@ -253,6 +260,7 @@ export const SearchBar = forwardRef(({ type="search" placeholder={effectivePlaceholder} aria-label={inputAriaLabel} + disabled={disabled} autoComplete={autoComplete} enterKeyHint={enterKeyHint} className={inputClasses} diff --git a/src/frontend/src/components/SearchSection.tsx b/src/frontend/src/components/SearchSection.tsx index 1cbb3ace..cb3b341c 100644 --- a/src/frontend/src/components/SearchSection.tsx +++ b/src/frontend/src/components/SearchSection.tsx @@ -27,6 +27,7 @@ interface SearchSectionProps { // Manual search mode (universal only) isManualSearch?: boolean; onManualSearchToggle?: () => void; + searchDisabled?: boolean; } export const SearchSection = ({ @@ -50,6 +51,7 @@ export const SearchSection = ({ onContentTypeChange, isManualSearch = false, onManualSearchToggle, + searchDisabled = false, }: SearchSectionProps) => { const { searchMode } = useSearchMode(); @@ -92,6 +94,7 @@ export const SearchSection = ({ contentType={contentType} onContentTypeChange={onContentTypeChange} isManualSearch={isManualSearch} + disabled={searchDisabled} /> void; + placeholder?: string; + allLabel?: string; +} + +const GROUP_HEADER_PREFIX = '__group__'; + +const buildOptions = ( + options: DynamicFieldOption[], + allLabel: string +): DropdownListOption[] => { + const built: DropdownListOption[] = [{ value: '', label: allLabel }]; + let previousGroup: string | null = null; + + options.forEach((option, index) => { + if (option.group && option.group !== previousGroup) { + previousGroup = option.group; + built.push({ + value: `${GROUP_HEADER_PREFIX}${option.group}:${index}`, + label: option.group, + disabled: true, + }); + } else if (!option.group) { + previousGroup = null; + } + + built.push({ + value: option.value, + label: option.label, + description: option.description, + }); + }); + + return built; +}; + +export const DynamicDropdown = ({ + endpoint, + value, + onChange, + placeholder = 'Select an option', + allLabel = 'All', +}: DynamicDropdownProps) => { + const [options, setOptions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + let isMounted = true; + + const load = async () => { + setIsLoading(true); + setLoadError(null); + + try { + const loaded = await fetchFieldOptions(endpoint); + if (!isMounted) { + return; + } + setOptions(loaded); + } catch (error) { + if (!isMounted) { + return; + } + console.error('Failed to load dynamic dropdown options:', error); + setOptions([]); + setLoadError('Failed to load options'); + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + void load(); + return () => { + isMounted = false; + }; + }, [endpoint]); + + const dropdownOptions = useMemo(() => { + if (isLoading) { + return [ + { value: '', label: allLabel }, + { value: '__loading', label: 'Loading...', disabled: true }, + ]; + } + + if (loadError) { + return [ + { value: '', label: allLabel }, + { value: '__error', label: loadError, disabled: true }, + ]; + } + + return buildOptions(options, allLabel); + }, [allLabel, isLoading, loadError, options]); + + const handleChange = (nextValue: string[] | string) => { + const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue; + if (normalized.startsWith(GROUP_HEADER_PREFIX)) { + return; + } + onChange(normalized); + }; + + return ( + + ); +}; diff --git a/src/frontend/src/components/shared/SearchFieldRenderer.tsx b/src/frontend/src/components/shared/SearchFieldRenderer.tsx index 186703ef..aa2c4e2d 100644 --- a/src/frontend/src/components/shared/SearchFieldRenderer.tsx +++ b/src/frontend/src/components/shared/SearchFieldRenderer.tsx @@ -1,6 +1,7 @@ import { KeyboardEvent } from 'react'; import { MetadataSearchField } from '../../types'; import { DropdownList } from '../DropdownList'; +import { DynamicDropdown } from './DynamicDropdown'; interface SearchFieldRendererProps { field: MetadataSearchField; @@ -98,6 +99,17 @@ export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: Search ); + case 'DynamicSelectSearchField': + return ( + onChange(v)} + placeholder={field.placeholder || 'Select an option'} + allLabel="All" + /> + ); + default: return null; } diff --git a/src/frontend/src/hooks/useSearch.ts b/src/frontend/src/hooks/useSearch.ts index e5e01ca8..90e29a71 100644 --- a/src/frontend/src/hooks/useSearch.ts +++ b/src/frontend/src/hooks/useSearch.ts @@ -30,7 +30,12 @@ interface UseSearchReturn { advancedFilters: AdvancedFilterState; setAdvancedFilters: React.Dispatch>; updateAdvancedFilters: (updates: Partial) => void; - handleSearch: (query: string, config: AppConfig | null, fieldValues?: Record) => Promise; + handleSearch: ( + query: string, + config: AppConfig | null, + fieldValues?: Record, + contentTypeOverride?: ContentType + ) => Promise; handleResetSearch: (config: AppConfig | null) => void; handleSortChange: (value: string, config: AppConfig | null) => void; resetSortFilter: () => void; @@ -127,8 +132,10 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { const handleSearch = useCallback(async ( query: string, config: AppConfig | null, - fieldValues?: Record + fieldValues?: Record, + contentTypeOverride?: ContentType ) => { + const effectiveContentType = contentTypeOverride ?? contentType; const searchMode = config?.search_mode || 'direct'; // In universal mode, check if we have either a query or field values @@ -167,7 +174,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { setTotalFound(0); try { - const result = await searchMetadata(searchQuery, 40, sort, effectiveFieldValues, 1, contentType); + const result = await searchMetadata(searchQuery, 40, sort, effectiveFieldValues, 1, effectiveContentType); if (result.books.length > 0) { setBooks(result.books); setHasMore(result.hasMore); diff --git a/src/frontend/src/hooks/useUrlSearch.ts b/src/frontend/src/hooks/useUrlSearch.ts index d07ed9c9..0b2ba931 100644 --- a/src/frontend/src/hooks/useUrlSearch.ts +++ b/src/frontend/src/hooks/useUrlSearch.ts @@ -40,7 +40,7 @@ export function useUrlSearch({ enabled }: UseUrlSearchOptions): UseUrlSearchRetu useEffect(() => { if (enabled && !processedRef.current) { const parsed = parseUrlSearchParams(searchParams); - if (parsed.hasSearchParams) { + if (parsed.hasSearchParams || parsed.contentType) { parsedRef.current = parsed; } processedRef.current = true; diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 2466a906..a763762a 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -12,7 +12,7 @@ import { } from '../types'; import { SettingsResponse, ActionResult, UpdateResult, SettingsTab } from '../types/settings'; import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers'; -import { getApiBase } from '../utils/basePath'; +import { getApiBase, withBasePath } from '../utils/basePath'; import { buildAdminRequestActionUrl, buildFulfilAdminRequestBody, @@ -227,6 +227,13 @@ export interface MetadataSearchResult { hasMore: boolean; } +export interface DynamicFieldOption { + value: string; + label: string; + group?: string; + description?: string; +} + // Search metadata providers and normalize to Book format export const searchMetadata = async ( query: string, @@ -268,6 +275,29 @@ export const searchMetadata = async ( }; }; +export const fetchFieldOptions = async (endpoint: string): Promise => { + const normalizedEndpoint = + endpoint.startsWith('http://') || endpoint.startsWith('https://') + ? endpoint + : withBasePath(endpoint); + + const response = await fetchJSON<{ options?: unknown }>(normalizedEndpoint); + if (!Array.isArray(response.options)) { + return []; + } + + return response.options + .filter((option): option is Record => typeof option === 'object' && option !== null) + .map((option) => { + const value = typeof option.value === 'string' ? option.value : String(option.value ?? ''); + const label = typeof option.label === 'string' ? option.label : value; + const group = typeof option.group === 'string' ? option.group : undefined; + const description = typeof option.description === 'string' ? option.description : undefined; + return { value, label, group, description }; + }) + .filter((option) => option.value !== ''); +}; + export const getBookInfo = async (id: string): Promise => { return fetchJSON(`${API.info}?id=${encodeURIComponent(id)}`); }; diff --git a/src/frontend/src/tests/parseUrlSearchParams.node.test.ts b/src/frontend/src/tests/parseUrlSearchParams.node.test.ts new file mode 100644 index 00000000..11f510c4 --- /dev/null +++ b/src/frontend/src/tests/parseUrlSearchParams.node.test.ts @@ -0,0 +1,45 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parseUrlSearchParams } from '../utils/parseUrlSearchParams.js'; + +describe('parseUrlSearchParams', () => { + it('parses standard URL search filters', () => { + const parsed = parseUrlSearchParams( + new URLSearchParams('q=dune&author=frank+herbert&lang=en&format=epub&sort=newest') + ); + + assert.equal(parsed.searchInput, 'dune'); + assert.equal(parsed.hasSearchParams, true); + assert.deepEqual(parsed.advancedFilters, { + author: 'frank herbert', + lang: ['en'], + formats: ['epub'], + sort: 'newest', + }); + assert.equal(parsed.contentType, undefined); + }); + + it('parses content_type for supported values', () => { + const parsed = parseUrlSearchParams(new URLSearchParams('q=dune&content_type=audiobook')); + + assert.equal(parsed.searchInput, 'dune'); + assert.equal(parsed.hasSearchParams, true); + assert.equal(parsed.contentType, 'audiobook'); + }); + + it('ignores unsupported content_type values', () => { + const parsed = parseUrlSearchParams(new URLSearchParams('q=dune&content_type=podcast')); + + assert.equal(parsed.searchInput, 'dune'); + assert.equal(parsed.hasSearchParams, true); + assert.equal(parsed.contentType, undefined); + }); + + it('keeps content_type-only links from auto-triggering a blank search', () => { + const parsed = parseUrlSearchParams(new URLSearchParams('content_type=ebook')); + + assert.equal(parsed.searchInput, ''); + assert.equal(parsed.hasSearchParams, false); + assert.equal(parsed.contentType, 'ebook'); + }); +}); diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 8028a790..29fe5660 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -120,7 +120,8 @@ export type SearchFieldType = | 'TextSearchField' | 'NumberSearchField' | 'SelectSearchField' - | 'CheckboxSearchField'; + | 'CheckboxSearchField' + | 'DynamicSelectSearchField'; interface SearchFieldBase { key: string; @@ -151,11 +152,17 @@ export interface CheckboxSearchField extends SearchFieldBase { default?: boolean; } +export interface DynamicSelectSearchField extends SearchFieldBase { + type: 'DynamicSelectSearchField'; + options_endpoint: string; +} + export type MetadataSearchField = | TextSearchField | NumberSearchField | SelectSearchField - | CheckboxSearchField; + | CheckboxSearchField + | DynamicSelectSearchField; // App configuration // Content type for search (ebook vs audiobook) diff --git a/src/frontend/src/utils/parseUrlSearchParams.ts b/src/frontend/src/utils/parseUrlSearchParams.ts index 9f64dff8..592d988d 100644 --- a/src/frontend/src/utils/parseUrlSearchParams.ts +++ b/src/frontend/src/utils/parseUrlSearchParams.ts @@ -1,4 +1,4 @@ -import { AdvancedFilterState } from '../types'; +import { AdvancedFilterState, ContentType } from '../types'; /** * Parsed search parameters from URL @@ -6,24 +6,41 @@ import { AdvancedFilterState } from '../types'; export interface ParsedUrlSearch { searchInput: string; advancedFilters: Partial; + contentType?: ContentType; hasSearchParams: boolean; } +const parseContentType = (value: string | null): ContentType | undefined => { + if (!value) { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (normalized === 'ebook' || normalized === 'audiobook') { + return normalized; + } + return undefined; +}; + /** * Parse URL search parameters into search state. * * Supports both Direct Download and Universal mode parameters. - * In Universal mode, only query and sort are used (others are parsed but - * ignored by buildSearchQuery). + * In Universal mode, query/sort are used for search text, and content_type + * is used to select ebook vs audiobook. * * @example * // Direct mode: /?q=harry+potter&author=rowling&format=epub&lang=en * // Universal mode: /?q=dune&sort=popularity */ export function parseUrlSearchParams(searchParams: URLSearchParams): ParsedUrlSearch { + const parsedContentType = parseContentType( + searchParams.get('content_type') || searchParams.get('contentType') + ); + const result: ParsedUrlSearch = { searchInput: '', advancedFilters: {}, + contentType: parsedContentType, hasSearchParams: false, };