mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 10:21:11 +01:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user