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
This commit is contained in:
Alex
2026-03-03 21:53:43 +00:00
committed by GitHub
parent de18f2b9fe
commit 71900e00db
16 changed files with 816 additions and 24 deletions
+8 -1
View File
@@ -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
+44
View File
@@ -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/<provider>/<book_id>', methods=['GET'])
@login_required
def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]:
+23 -1
View File
@@ -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]] = {}
+439 -8
View File
@@ -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"},
+38 -4
View File
@@ -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}
/>
</div>
@@ -1472,6 +1505,7 @@ function App() {
onContentTypeChange={setContentType}
isManualSearch={isManualSearch}
onManualSearchToggle={manualSearchAllowed ? () => setIsManualSearch(prev => !prev) : undefined}
searchDisabled={isListBrowsing}
/>
<ResultsSection
+3
View File
@@ -40,6 +40,7 @@ interface HeaderProps {
contentType?: ContentType;
onContentTypeChange?: (type: ContentType) => void;
isManualSearch?: boolean;
searchDisabled?: boolean;
}
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
@@ -71,6 +72,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
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<HeaderHandle, HeaderProps>(({
contentType={contentType}
onContentTypeChange={onContentTypeChange}
isManualSearch={isManualSearch}
disabled={searchDisabled}
/>
</div>
</div>
@@ -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<SearchBarHandle, SearchBarProps>(({
contentType = 'ebook',
onContentTypeChange,
isManualSearch = false,
disabled = false,
}, ref) => {
const { searchMode, isUniversalMode } = useSearchMode();
const inputRef = useRef<HTMLInputElement>(null);
@@ -110,6 +112,10 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (disabled) {
e.preventDefault();
return;
}
onSubmit();
(e.target as HTMLInputElement).blur();
}
@@ -124,6 +130,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
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<SearchBarHandle, SearchBarProps>(({
type="search"
placeholder={effectivePlaceholder}
aria-label={inputAriaLabel}
disabled={disabled}
autoComplete={autoComplete}
enterKeyHint={enterKeyHint}
className={inputClasses}
@@ -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}
/>
<AdvancedFilters
visible={showAdvanced}
@@ -0,0 +1,122 @@
import { useEffect, useMemo, useState } from 'react';
import { DropdownList, DropdownListOption } from '../DropdownList';
import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
interface DynamicDropdownProps {
endpoint: string;
value: string;
onChange: (value: string) => 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<DynamicFieldOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(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 (
<DropdownList
options={dropdownOptions}
value={value}
onChange={handleChange}
placeholder={placeholder}
/>
);
};
@@ -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
</label>
);
case 'DynamicSelectSearchField':
return (
<DynamicDropdown
endpoint={field.options_endpoint}
value={(value as string) ?? ''}
onChange={(v) => onChange(v)}
placeholder={field.placeholder || 'Select an option'}
allLabel="All"
/>
);
default:
return null;
}
+10 -3
View File
@@ -30,7 +30,12 @@ interface UseSearchReturn {
advancedFilters: AdvancedFilterState;
setAdvancedFilters: React.Dispatch<React.SetStateAction<AdvancedFilterState>>;
updateAdvancedFilters: (updates: Partial<AdvancedFilterState>) => void;
handleSearch: (query: string, config: AppConfig | null, fieldValues?: Record<string, string | number | boolean>) => Promise<void>;
handleSearch: (
query: string,
config: AppConfig | null,
fieldValues?: Record<string, string | number | boolean>,
contentTypeOverride?: ContentType
) => Promise<void>;
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<string, string | number | boolean>
fieldValues?: Record<string, string | number | boolean>,
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);
+1 -1
View File
@@ -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;
+31 -1
View File
@@ -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<DynamicFieldOption[]> => {
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<string, unknown> => 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<Book> => {
return fetchJSON<Book>(`${API.info}?id=${encodeURIComponent(id)}`);
};
@@ -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');
});
});
+9 -2
View File
@@ -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)
+20 -3
View File
@@ -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<AdvancedFilterState>;
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,
};