Expanded Hardcover list features (#719)

- Adds full interaction with Hardcover lists, including adding and
removing from lists + want to read status
- List selection exposed in search results, details modal and release
modal
- Added automatic list dropdown when selecting "list" search
- Added auto-removal of books from a list when downloading from that
specific list page
- Changed search selector to hover-activated
This commit is contained in:
Alex
2026-03-07 15:33:46 +00:00
committed by GitHub
parent 80aa289a64
commit 9d08bb3ef1
23 changed files with 1824 additions and 214 deletions
+8
View File
@@ -62,6 +62,14 @@ class CacheService:
return True
return False
def invalidate_prefix(self, prefix: str) -> int:
"""Remove all cache entries whose keys start with prefix."""
with self._lock:
matching_keys = [key for key in self._cache if key.startswith(prefix)]
for key in matching_keys:
del self._cache[key]
return len(matching_keys)
def clear(self) -> None:
"""Clear all cache entries."""
with self._lock:
+99 -17
View File
@@ -2178,14 +2178,17 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
cache_id = f"{book_dict['provider']}_{book_dict['provider_id']}"
book_dict['cover_url'] = transform_cover_url(book_dict['cover_url'], cache_id)
return jsonify({
response_data = {
"books": books_data,
"provider": provider.name,
"query": query,
"page": search_result.page,
"total_found": search_result.total_found,
"has_more": search_result.has_more
})
"has_more": search_result.has_more,
}
if search_result.source_url:
response_data["source_url"] = search_result.source_url
return jsonify(response_data)
except Exception as e:
logger.error_trace(f"Metadata search error: {e}")
return jsonify({"error": str(e)}), 500
@@ -2232,6 +2235,29 @@ def api_metadata_field_options() -> Response:
return jsonify({"options": []})
def _resolve_metadata_provider(provider_name: str):
"""Validate, instantiate and return a ready metadata provider.
Raises appropriate HTTP-friendly exceptions on failure.
"""
from shelfmark.metadata_providers import (
get_provider,
get_provider_kwargs,
is_provider_registered,
)
if not is_provider_registered(provider_name):
raise ValueError(f"Unknown metadata provider: {provider_name}")
kwargs = get_provider_kwargs(provider_name)
prov = get_provider(provider_name, **kwargs)
if not prov.is_available():
raise RuntimeError(f"Provider '{provider_name}' is not available")
return prov
@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]]:
@@ -2246,22 +2272,9 @@ def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Resp
flask.Response: JSON with book details.
"""
try:
from shelfmark.metadata_providers import (
get_provider,
is_provider_registered,
get_provider_kwargs,
)
from dataclasses import asdict
if not is_provider_registered(provider):
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
# Get provider instance with appropriate configuration
kwargs = get_provider_kwargs(provider)
prov = get_provider(provider, **kwargs)
if not prov.is_available():
return jsonify({"error": f"Provider '{provider}' is not available"}), 503
prov = _resolve_metadata_provider(provider)
book = prov.get_book(book_id)
if not book:
@@ -2278,11 +2291,80 @@ def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Resp
return jsonify(book_dict)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except RuntimeError as e:
return jsonify({"error": str(e)}), 503
except Exception as e:
logger.error_trace(f"Metadata book error: {e}")
return jsonify({"error": str(e)}), 500
def _handle_target_errors(fallback_message: str):
"""Decorator that wraps a metadata-target route with standard error handling."""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except (NotImplementedError, ValueError) as e:
return jsonify({"error": str(e)}), 400
except RuntimeError as e:
return jsonify({"error": str(e)}), 502
except Exception as e:
logger.error_trace(f"{fallback_message}: {e}")
return jsonify({"error": fallback_message}), 500
return wrapper
return decorator
@app.route('/api/metadata/book/<provider>/<book_id>/targets', methods=['GET'])
@login_required
@_handle_target_errors("Failed to load book targets")
def api_metadata_book_targets(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]:
"""Get provider-managed list/status targets for a specific book."""
prov = _resolve_metadata_provider(provider)
return jsonify({"options": prov.get_book_targets(book_id)})
@app.route('/api/metadata/book/<provider>/targets/batch', methods=['POST'])
@login_required
@_handle_target_errors("Failed to load book targets")
def api_metadata_book_targets_batch(provider: str) -> Union[Response, Tuple[Response, int]]:
"""Get provider-managed list/status targets for multiple books."""
prov = _resolve_metadata_provider(provider)
payload = request.get_json(silent=True) or {}
raw_ids = payload.get("book_ids", []) if isinstance(payload, dict) else []
if not isinstance(raw_ids, list) or not raw_ids:
return jsonify({"error": "book_ids must be a non-empty array"}), 400
book_ids = [str(bid) for bid in raw_ids[:50]]
return jsonify({"results": prov.get_book_targets_batch(book_ids)})
@app.route('/api/metadata/book/<provider>/<book_id>/targets', methods=['PUT'])
@login_required
@_handle_target_errors("Failed to update book targets")
def api_metadata_book_targets_update(provider: str, book_id: str) -> Union[Response, Tuple[Response, int]]:
"""Set whether a book belongs to a provider-managed list or shelf."""
prov = _resolve_metadata_provider(provider)
payload = request.get_json(silent=True) or {}
target = str(payload.get("target", "")).strip() if isinstance(payload, dict) else ""
selected = payload.get("selected") if isinstance(payload, dict) else None
if not target:
return jsonify({"error": "target is required"}), 400
if not isinstance(selected, bool):
return jsonify({"error": "selected must be a boolean"}), 400
result = prov.set_book_target_state(book_id, target, selected)
return jsonify({
"success": True,
"changed": bool(result.get("changed", True)),
"selected": selected,
})
@app.route('/api/releases', methods=['GET'])
@login_required
def api_releases() -> Union[Response, Tuple[Response, int]]:
+31
View File
@@ -310,6 +310,7 @@ class SearchResult:
page: int = 1
total_found: int = 0 # Total matching results (if known)
has_more: bool = False # True if more results available
source_url: Optional[str] = None # External URL for the result set (e.g. Hardcover list page)
class MetadataProvider(ABC):
@@ -373,6 +374,36 @@ class MetadataProvider(ABC):
"""Get dynamic options for a provider-specific search field."""
return []
def get_book_targets(self, book_id: str) -> List[Dict[str, Any]]:
"""Get provider-managed list or status targets for a specific book."""
raise NotImplementedError(f"{self.display_name} does not support book targets")
def get_book_targets_batch(self, book_ids: List[str]) -> Dict[str, List[Dict[str, Any]]]:
"""Get provider-managed targets for multiple books.
Returns a dict mapping each book_id to its list of target options.
Default implementation calls get_book_targets per book.
"""
results: Dict[str, List[Dict[str, Any]]] = {}
for book_id in book_ids:
try:
results[book_id] = self.get_book_targets(book_id)
except (NotImplementedError, ValueError):
results[book_id] = []
return results
def set_book_target_state(
self,
book_id: str,
target: str,
selected: bool,
) -> Dict[str, Any]:
"""Set whether a book belongs to a provider-managed list or shelf.
Returns a dict with at least ``{"changed": bool}``.
"""
raise NotImplementedError(f"{self.display_name} does not support book targets")
# Provider registry
_PROVIDERS: Dict[str, Type[MetadataProvider]] = {}
+546 -14
View File
@@ -2,11 +2,12 @@
import re
import requests
from dataclasses import dataclass
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.cache import cache_key, cacheable, get_metadata_cache
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
@@ -58,6 +59,10 @@ query LookupListsBySlug($slug: String!) {
LIST_BOOKS_BY_ID_QUERY = """
query GetListBooksById($id: Int!, $limit: Int!, $offset: Int!) {
lists(where: {id: {_eq: $id}}, limit: 1) {
slug
user {
username
}
books_count
list_books(order_by: {position: asc}, limit: $limit, offset: $offset) {
book {
@@ -172,6 +177,102 @@ query GetCurrentUserBooksByStatus($statusId: Int!, $limit: Int!, $offset: Int!)
}
"""
BOOK_TARGET_MEMBERSHIP_QUERY = """
query GetBookTargetMembership($bookId: Int!) {
me {
user_books(where: {book_id: {_eq: $bookId}}, limit: 1, order_by: [{created_at: desc}]) {
id
status_id
}
lists {
id
list_books(where: {book_id: {_eq: $bookId}}, limit: 1) {
id
}
}
}
}
"""
BOOK_TARGET_MEMBERSHIP_BATCH_QUERY = """
query GetBookTargetMembershipBatch($bookIds: [Int!]!) {
me {
user_books(where: {book_id: {_in: $bookIds}}, order_by: [{created_at: desc}]) {
id
book_id
status_id
}
lists {
id
list_books(where: {book_id: {_in: $bookIds}}) {
id
book_id
}
}
}
}
"""
INSERT_USER_BOOK_MUTATION = """
mutation AddBookToStatus($bookId: Int!, $statusId: Int!) {
insert_user_book(object: {book_id: $bookId, status_id: $statusId}) {
id
error
user_book {
id
book_id
status_id
}
}
}
"""
UPDATE_USER_BOOK_MUTATION = """
mutation UpdateBookStatus($userBookId: Int!, $statusId: Int!) {
update_user_book(id: $userBookId, object: {status_id: $statusId}) {
id
error
user_book {
id
book_id
status_id
}
}
}
"""
DELETE_USER_BOOK_MUTATION = """
mutation RemoveBookStatus($userBookId: Int!) {
delete_user_book(id: $userBookId) {
id
book_id
user_id
}
}
"""
INSERT_LIST_BOOK_MUTATION = """
mutation AddBookToList($bookId: Int!, $listId: Int!) {
insert_list_book(object: {book_id: $bookId, list_id: $listId}) {
id
list_book {
id
book_id
list_id
}
}
}
"""
DELETE_LIST_BOOK_MUTATION = """
mutation RemoveBookFromList($listBookId: Int!) {
delete_list_book(id: $listBookId) {
id
list_id
}
}
"""
SEARCH_FIELD_OPTIONS_QUERY = """
query SearchFieldOptions(
$query: String!,
@@ -271,6 +372,46 @@ query GetSeriesBooks($seriesId: Int!) {
HARDCOVER_WANT_TO_READ_STATUS_ID = 1
HARDCOVER_STATUS_PREFIX = "status:"
HARDCOVER_STATUS_URL_SLUGS: dict[int, str] = {
1: "want-to-read",
2: "currently-reading",
3: "read",
5: "did-not-finish",
}
HARDCOVER_LIST_ID_PREFIX = "id:"
HARDCOVER_WRITABLE_TARGET_GROUPS = {"My Books", "My Lists"}
@dataclass(frozen=True)
class HardcoverBookTargetState:
"""Current Hardcover target state for a specific book."""
user_book_id: Optional[int]
status_id: Optional[int]
list_book_ids: Dict[int, int]
class HardcoverGraphQLError(ValueError):
"""GraphQL request was rejected by Hardcover."""
def _extract_graphql_error_message(payload: Any) -> str:
"""Extract a readable message from a GraphQL error payload."""
if not isinstance(payload, dict):
return ""
errors = payload.get("errors", [])
if not isinstance(errors, list):
return ""
messages: List[str] = []
for error in errors:
if not isinstance(error, dict):
continue
message = str(error.get("message") or "").strip()
if message:
messages.append(message)
return "; ".join(messages)
# Mapping from abstract sort order to Hardcover sort parameter
@@ -662,14 +803,12 @@ class HardcoverProvider(MetadataProvider):
label="Author",
placeholder="Search author...",
description="Search by author name",
suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=author",
),
TextSearchField(
key="title",
label="Title",
placeholder="Search title...",
description="Search by book title",
suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=title",
),
TextSearchField(
key="series",
@@ -770,6 +909,14 @@ class HardcoverProvider(MetadataProvider):
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
# Build source URL from slug and owner username
source_url = None
list_slug = str(list_data.get("slug") or "").strip()
user_data = list_data.get("user", {})
owner_username = str(user_data.get("username") or "").strip() if isinstance(user_data, dict) else ""
if list_slug and owner_username:
source_url = f"https://hardcover.app/@{owner_username}/lists/{list_slug}"
try:
books_count = int(books_count_raw)
except (TypeError, ValueError):
@@ -790,7 +937,7 @@ class HardcoverProvider(MetadataProvider):
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)
return SearchResult(books=books, page=page, total_found=books_count, has_more=has_more, source_url=source_url)
@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:
@@ -1125,10 +1272,10 @@ class HardcoverProvider(MetadataProvider):
if not normalized_value:
return None
if normalized_value.startswith("id:"):
if normalized_value.startswith(HARDCOVER_LIST_ID_PREFIX):
try:
return {"id": int(normalized_value.split(":", 1)[1])}
except (IndexError, ValueError):
return {"id": self._parse_prefixed_int(normalized_value, "series id")}
except ValueError:
logger.debug(f"Invalid Hardcover series id field value: {normalized_value}")
return None
@@ -1358,7 +1505,15 @@ class HardcoverProvider(MetadataProvider):
logger.debug(f"Failed to parse Hardcover status book for status_id={status_id}: {exc}")
has_more = offset + len(status_books) < total_found
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more)
# Build source URL for the status shelf
source_url = None
url_slug = HARDCOVER_STATUS_URL_SLUGS.get(status_id)
username = _get_connected_username()
if url_slug and username:
source_url = f"https://hardcover.app/@{username}/books/{url_slug}"
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more, source_url=source_url)
def _fetch_user_lists(self) -> List[Dict[str, str]]:
"""Fetch raw list options from Hardcover me query."""
@@ -1453,6 +1608,359 @@ class HardcoverProvider(MetadataProvider):
return options
def get_book_targets(self, book_id: str) -> List[Dict[str, Any]]:
"""Get writable Hardcover list/status targets for a specific book."""
if not self.api_key:
return []
book_id_int = coerce_int(book_id, 0)
if book_id_int < 1:
raise ValueError("book_id must be a valid Hardcover book id")
state = self._fetch_book_target_state(book_id_int)
options = [
dict(option)
for option in self.get_user_lists()
if option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS
]
for option in options:
value = str(option.get("value") or "").strip()
option["checked"] = self._is_target_checked(value, state)
option["writable"] = True
return options
def set_book_target_state(self, book_id: str, target: str, selected: bool) -> Dict[str, Any]:
"""Set whether a Hardcover book belongs to a status shelf or user list."""
if not self.api_key:
raise ValueError("Hardcover is not configured")
book_id_int = coerce_int(book_id, 0)
if book_id_int < 1:
raise ValueError("book_id must be a valid Hardcover book id")
selected_target = str(target or "").strip()
if not selected_target:
raise ValueError("target is required")
if selected_target not in self._get_writable_targets():
raise ValueError("Unsupported Hardcover target")
state = self._fetch_book_target_state(book_id_int)
status_ids_to_invalidate: set[int] = set()
list_ids_to_invalidate: set[int] = set()
if selected_target.startswith(HARDCOVER_STATUS_PREFIX):
status_id = self._parse_prefixed_int(selected_target, "status target")
previous_status_id = state.status_id
changed = self._set_status_target_state(book_id_int, status_id, selected, state)
if changed:
if previous_status_id is not None:
status_ids_to_invalidate.add(previous_status_id)
status_ids_to_invalidate.add(status_id)
elif selected_target.startswith(HARDCOVER_LIST_ID_PREFIX):
list_id = self._parse_prefixed_int(selected_target, "list target")
changed = self._set_list_target_state(book_id_int, list_id, selected, state)
if changed:
list_ids_to_invalidate.add(list_id)
else:
raise ValueError("Unsupported Hardcover target")
if changed:
self._invalidate_book_target_caches(
connected_user_id=self._resolve_current_user_id(),
status_ids=status_ids_to_invalidate,
list_ids=list_ids_to_invalidate,
)
return {"changed": changed}
@staticmethod
def _unwrap_me_data(result: Optional[Dict]) -> Dict:
"""Extract and validate the ``me`` payload from a GraphQL result."""
if not isinstance(result, dict):
raise RuntimeError("Hardcover could not load book targets")
me_data = result.get("me", {})
if isinstance(me_data, list) and me_data:
me_data = me_data[0]
if not isinstance(me_data, dict):
raise RuntimeError("Hardcover returned an invalid target payload")
return me_data
def _fetch_book_target_state(self, book_id: int) -> HardcoverBookTargetState:
"""Load current Hardcover membership state for a specific book."""
result = self._execute_query(
BOOK_TARGET_MEMBERSHIP_QUERY,
{"bookId": book_id},
raise_on_error=True,
)
me_data = self._unwrap_me_data(result)
user_book_id: Optional[int] = None
status_id: Optional[int] = None
user_books = me_data.get("user_books", [])
if isinstance(user_books, list) and user_books:
latest_user_book = user_books[0] if isinstance(user_books[0], dict) else {}
user_book_id = coerce_int(latest_user_book.get("id"), 0) or None
status_id = coerce_int(latest_user_book.get("status_id"), 0) or None
list_book_ids: Dict[int, int] = {}
for user_list in me_data.get("lists", []):
if not isinstance(user_list, dict):
continue
list_id = coerce_int(user_list.get("id"), 0)
if list_id < 1:
continue
list_books = user_list.get("list_books", [])
if not isinstance(list_books, list) or not list_books:
continue
list_book = list_books[0] if isinstance(list_books[0], dict) else {}
list_book_id = coerce_int(list_book.get("id"), 0)
if list_book_id > 0:
list_book_ids[list_id] = list_book_id
return HardcoverBookTargetState(
user_book_id=user_book_id,
status_id=status_id,
list_book_ids=list_book_ids,
)
def _fetch_book_target_states_batch(
self, book_ids: List[int],
) -> Dict[int, HardcoverBookTargetState]:
"""Load Hardcover membership state for multiple books in one query."""
result = self._execute_query(
BOOK_TARGET_MEMBERSHIP_BATCH_QUERY,
{"bookIds": book_ids},
raise_on_error=True,
)
me_data = self._unwrap_me_data(result)
# Group user_books by book_id (keep only the latest per book)
user_book_by_book: Dict[int, Dict] = {}
for ub in me_data.get("user_books", []):
if not isinstance(ub, dict):
continue
bid = coerce_int(ub.get("book_id"), 0)
if bid > 0 and bid not in user_book_by_book:
user_book_by_book[bid] = ub
# Group list_book memberships by book_id
list_book_ids_by_book: Dict[int, Dict[int, int]] = {}
for user_list in me_data.get("lists", []):
if not isinstance(user_list, dict):
continue
list_id = coerce_int(user_list.get("id"), 0)
if list_id < 1:
continue
for lb in user_list.get("list_books", []):
if not isinstance(lb, dict):
continue
bid = coerce_int(lb.get("book_id"), 0)
lb_id = coerce_int(lb.get("id"), 0)
if bid > 0 and lb_id > 0:
list_book_ids_by_book.setdefault(bid, {})[list_id] = lb_id
states: Dict[int, HardcoverBookTargetState] = {}
for bid in book_ids:
ub = user_book_by_book.get(bid)
states[bid] = HardcoverBookTargetState(
user_book_id=coerce_int(ub.get("id"), 0) or None if ub else None,
status_id=coerce_int(ub.get("status_id"), 0) or None if ub else None,
list_book_ids=list_book_ids_by_book.get(bid, {}),
)
return states
def get_book_targets_batch(self, book_ids: List[str]) -> Dict[str, List[Dict[str, Any]]]:
"""Get writable Hardcover list/status targets for multiple books."""
if not self.api_key or not book_ids:
return {bid: [] for bid in book_ids}
int_ids = []
id_map: Dict[int, str] = {}
for bid in book_ids:
int_id = coerce_int(bid, 0)
if int_id > 0:
int_ids.append(int_id)
id_map[int_id] = bid
if not int_ids:
return {bid: [] for bid in book_ids}
states = self._fetch_book_target_states_batch(int_ids)
writable_options = [
dict(option)
for option in self.get_user_lists()
if option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS
]
results: Dict[str, List[Dict[str, Any]]] = {}
for int_id, str_id in id_map.items():
state = states.get(int_id, HardcoverBookTargetState(
user_book_id=None, status_id=None, list_book_ids={},
))
options = [dict(opt) for opt in writable_options]
for option in options:
value = str(option.get("value") or "").strip()
option["checked"] = self._is_target_checked(value, state)
option["writable"] = True
results[str_id] = options
# Fill in any book_ids that didn't parse as valid ints
for bid in book_ids:
if bid not in results:
results[bid] = []
return results
def _get_writable_targets(self) -> set[str]:
"""Return the set of writable Hardcover targets for the current user."""
writable_targets: set[str] = set()
for option in self.get_user_lists():
value = str(option.get("value") or "").strip()
if (
option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS
and value
and (
value.startswith(HARDCOVER_STATUS_PREFIX)
or value.startswith(HARDCOVER_LIST_ID_PREFIX)
)
):
writable_targets.add(value)
return writable_targets
def _is_target_checked(self, target: str, state: HardcoverBookTargetState) -> bool:
"""Return whether a target is currently selected for the book."""
if target.startswith(HARDCOVER_STATUS_PREFIX):
return state.status_id == self._parse_prefixed_int(target)
if target.startswith(HARDCOVER_LIST_ID_PREFIX):
return self._parse_prefixed_int(target) in state.list_book_ids
return False
def _set_status_target_state(
self,
book_id: int,
status_id: int,
selected: bool,
state: HardcoverBookTargetState,
) -> bool:
"""Set whether the book belongs to a Hardcover status shelf."""
if selected:
if state.user_book_id is None:
result = self._execute_query(
INSERT_USER_BOOK_MUTATION,
{"bookId": book_id, "statusId": status_id},
raise_on_error=True,
)
self._check_mutation_result(result, "insert_user_book")
return True
if state.status_id == status_id:
return False
result = self._execute_query(
UPDATE_USER_BOOK_MUTATION,
{"userBookId": state.user_book_id, "statusId": status_id},
raise_on_error=True,
)
self._check_mutation_result(result, "update_user_book")
return True
if state.user_book_id is None or state.status_id != status_id:
return False
result = self._execute_query(
DELETE_USER_BOOK_MUTATION,
{"userBookId": state.user_book_id},
raise_on_error=True,
)
self._check_mutation_result(result, "delete_user_book", check_error=False)
return True
def _set_list_target_state(
self,
book_id: int,
list_id: int,
selected: bool,
state: HardcoverBookTargetState,
) -> bool:
"""Set whether the book belongs to a Hardcover list."""
list_book_id = state.list_book_ids.get(list_id)
if selected:
if list_book_id is not None:
return False
result = self._execute_query(
INSERT_LIST_BOOK_MUTATION,
{"bookId": book_id, "listId": list_id},
raise_on_error=True,
)
self._check_mutation_result(result, "insert_list_book")
return True
if list_book_id is None:
return False
result = self._execute_query(
DELETE_LIST_BOOK_MUTATION,
{"listBookId": list_book_id},
raise_on_error=True,
)
self._check_mutation_result(result, "delete_list_book", check_error=False)
return True
def _invalidate_book_target_caches(
self,
*,
connected_user_id: Optional[str],
status_ids: set[int],
list_ids: set[int],
) -> None:
"""Invalidate caches affected by a target membership change."""
metadata_cache = get_metadata_cache()
if connected_user_id:
metadata_cache.invalidate(cache_key("hardcover:user_lists", connected_user_id))
for status_id in status_ids:
metadata_cache.invalidate_prefix(
cache_key("hardcover:user_books:status", connected_user_id, status_id)
)
for list_id in list_ids:
metadata_cache.invalidate_prefix(cache_key("hardcover:list:id", list_id))
@staticmethod
def _parse_prefixed_int(value: str, label: str = "target") -> int:
"""Parse an integer from a colon-prefixed value like 'status:1' or 'id:42'."""
try:
return int(value.split(":", 1)[1])
except (IndexError, ValueError) as exc:
raise ValueError(f"Invalid Hardcover {label}") from exc
@staticmethod
def _check_mutation_result(result: Any, key: str, *, check_error: bool = True) -> None:
"""Raise if a Hardcover mutation failed.
When *check_error* is True (the default) the ``error`` field inside
the payload is inspected and surfaced as a ``ValueError``. Pass
``check_error=False`` for delete mutations that don't return an
error field.
"""
payload = result.get(key, {}) if isinstance(result, dict) else {}
if isinstance(payload, dict):
if check_error:
error_text = str(payload.get("error") or "").strip()
if error_text:
raise ValueError(error_text)
if payload.get("id") is not None:
return
raise RuntimeError("Hardcover could not complete this action")
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
"""Search for books using Hardcover's search API."""
return self.search_paginated(options).books
@@ -1474,16 +1982,16 @@ class HardcoverProvider(MetadataProvider):
if list_value_from_field:
if list_value_from_field.startswith(HARDCOVER_STATUS_PREFIX):
try:
status_id = int(list_value_from_field.split(":", 1)[1])
status_id = self._parse_prefixed_int(list_value_from_field, "status")
return self._fetch_current_user_books_by_status(status_id, options.page, options.limit)
except (IndexError, ValueError):
except ValueError:
logger.debug(f"Invalid Hardcover status field value: {list_value_from_field}")
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
if list_value_from_field.startswith("id:"):
if list_value_from_field.startswith(HARDCOVER_LIST_ID_PREFIX):
try:
list_id = int(list_value_from_field.split(":", 1)[1])
list_id = self._parse_prefixed_int(list_value_from_field, "list")
return self._fetch_list_books_by_id(list_id, options.page, options.limit)
except (IndexError, ValueError):
except 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)
@@ -1755,7 +2263,13 @@ class HardcoverProvider(MetadataProvider):
logger.error(f"Hardcover ISBN search error: {e}")
return None
def _execute_query(self, query: str, variables: Dict[str, Any]) -> Optional[Dict]:
def _execute_query(
self,
query: str,
variables: Dict[str, Any],
*,
raise_on_error: bool = False,
) -> Optional[Dict]:
"""Execute a GraphQL query and return data or None on error."""
try:
response = self.session.post(
@@ -1770,21 +2284,39 @@ class HardcoverProvider(MetadataProvider):
if "errors" in data:
logger.error(f"GraphQL errors: {data['errors']}")
if raise_on_error:
message = _extract_graphql_error_message(data) or "Hardcover rejected this request"
raise HardcoverGraphQLError(message)
return None
return data.get("data")
except requests.Timeout:
logger.warning("Hardcover API request timed out")
if raise_on_error:
raise RuntimeError("Hardcover API request timed out")
return None
except requests.HTTPError as e:
if e.response.status_code == 401:
logger.error("Hardcover API key is invalid")
if raise_on_error:
raise RuntimeError("Hardcover API key is invalid")
else:
logger.error(f"Hardcover API HTTP error: {e}")
if raise_on_error:
raise RuntimeError(f"Hardcover API HTTP error: {e}")
return None
except HardcoverGraphQLError:
raise
except ValueError as e:
logger.error(f"Hardcover API returned invalid JSON: {e}")
if raise_on_error:
raise RuntimeError("Hardcover API returned an invalid response") from e
return None
except Exception as e:
logger.error(f"Hardcover API request failed: {e}")
if raise_on_error:
raise RuntimeError("Hardcover API request failed") from e
return None
def _parse_search_result(self, item: Dict) -> Optional[BookMetadata]:
+56 -2
View File
@@ -29,6 +29,7 @@ import {
createRequest,
isApiResponseError,
updateSelfUser,
setBookTargetState,
type DownloadReleasePayload,
} from './services/api';
import { useToast } from './hooks/useToast';
@@ -76,6 +77,8 @@ import {
toContentType,
} from './utils/requestPayload';
import { bookFromRequestData } from './utils/requestFulfil';
import { emitBookTargetChange, onBookTargetChange } from './utils/bookTargetEvents';
import { bookSupportsTargets } from './utils/bookTargetLoader';
import { policyTrace } from './utils/policyTrace';
import { SearchModeProvider } from './contexts/SearchModeContext';
import { useSocket } from './contexts/SocketContext';
@@ -384,6 +387,7 @@ function App() {
isLoadingMore,
loadMore,
totalFound,
resultsSourceUrl,
} = useSearch({
showToast,
setIsAuthenticated,
@@ -392,6 +396,19 @@ function App() {
contentType,
});
// When a book is removed from the Hardcover list currently being browsed, remove it from results
const searchFieldValuesRef = useRef(searchFieldValues);
searchFieldValuesRef.current = searchFieldValues;
useEffect(() => {
return onBookTargetChange((event) => {
if (event.selected) return;
const activeListValue = searchFieldValuesRef.current.hardcover_list;
if (!activeListValue || String(activeListValue) !== event.target) return;
setBooks((prev) => prev.filter((book) => book.provider_id !== event.bookId));
});
}, [setBooks]);
const [pendingRequestPayload, setPendingRequestPayload] = useState<CreateRequestPayload | null>(null);
const [actingAsUser, setActingAsUser] = useState<ActingAsUserSelection | null>(null);
const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState<PendingOnBehalfDownload | null>(null);
@@ -1072,6 +1089,30 @@ function App() {
[]
);
// When downloading a book while browsing a Hardcover list, automatically
// remove it from that list (fire-and-forget).
const searchFieldLabelsRef = useRef(searchFieldLabels);
searchFieldLabelsRef.current = searchFieldLabels;
const removeBookFromActiveList = useCallback((book: Book) => {
if (!bookSupportsTargets(book)) return;
const activeList = searchFieldValuesRef.current.hardcover_list;
if (!activeList) return;
const target = String(activeList);
void setBookTargetState(book.provider!, book.provider_id!, target, false).then((result) => {
if (result.changed) {
emitBookTargetChange({
provider: book.provider!,
bookId: book.provider_id!,
target,
selected: false,
});
const listName = searchFieldLabelsRef.current['hardcover_list'];
showToast(`Removed from ${listName || 'list'}`, 'info');
}
}).catch(() => {});
}, [showToast]);
const executeBookDownload = useCallback(
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
const source = getBrowseSource(book);
@@ -1079,6 +1120,7 @@ function App() {
try {
await downloadRelease(buildReleaseDataFromDirectBook(book), onBehalfOfUserId);
await fetchStatus();
removeBookFromActiveList(book);
} catch (error) {
console.error('Download failed:', error);
if (isPolicyGuardError(error)) {
@@ -1103,7 +1145,7 @@ function App() {
throw error;
}
},
[fetchStatus, openRequestConfirmation, refreshRequestPolicy, showToast]
[fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast]
);
const executeReleaseDownload = useCallback(
@@ -1120,6 +1162,7 @@ function App() {
onBehalfOfUserId
);
await fetchStatus();
removeBookFromActiveList(book);
} catch (error) {
console.error('Release download failed:', error);
if (isPolicyGuardError(error)) {
@@ -1168,7 +1211,7 @@ function App() {
throw error;
}
},
[buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, showToast, trackRelease]
[buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast, trackRelease]
);
const handleConfirmOnBehalfDownload = useCallback(async (): Promise<boolean> => {
@@ -1657,6 +1700,12 @@ function App() {
&& activeQueryValue !== ''
&& activeQueryValue !== false,
);
const activeQueryUsesListBrowse = Boolean(
activeQueryOption?.source === 'provider-field'
&& activeQueryOption.field?.type === 'DynamicSelectSearchField'
&& activeQueryValue !== ''
&& activeQueryValue !== false,
);
const effectiveMetadataSort = getEffectiveMetadataSort({
currentSort: advancedFilters.sort,
defaultSort: resolvedMetadataDefaultSort,
@@ -2086,6 +2135,7 @@ function App() {
getButtonState={getDirectActionButtonState}
getUniversalButtonState={getUniversalActionButtonState}
sortValue={visibleResultsSort}
showSortControl={!activeQueryUsesSeriesBrowse && !activeQueryUsesListBrowse}
onSortChange={(value) => {
const request = buildCurrentSearchRequest(value);
const shouldPersistAppliedSort = !(
@@ -2109,6 +2159,8 @@ function App() {
isLoadingMore={isLoadingMore}
onLoadMore={() => loadMore(config, effectiveSearchMode)}
totalFound={totalFound}
onShowToast={showToast}
resultsSourceUrl={resultsSourceUrl}
/>
{selectedBook && (
@@ -2116,6 +2168,7 @@ function App() {
book={selectedBook}
onClose={() => setSelectedBook(null)}
onDownload={handleDownload}
onShowToast={showToast}
onFindDownloads={(book) => {
setSelectedBook(null);
void handleGetReleases(book);
@@ -2152,6 +2205,7 @@ function App() {
onSearchSeries={isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) ? undefined : handleSearchSeries}
defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'}
onShowToast={showToast}
/>
)}
@@ -0,0 +1,248 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DropdownList, type DropdownListOption } from './DropdownList';
import {
setBookTargetState,
type BookTargetOption,
} from '../services/api';
import { loadBookTargets } from '../utils/bookTargetLoader';
import { emitBookTargetChange, onBookTargetChange } from '../utils/bookTargetEvents';
interface BookTargetDropdownProps {
provider: string;
bookId: string;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
widthClassName?: string;
variant?: 'default' | 'pill' | 'icon';
align?: 'left' | 'right' | 'auto';
className?: string;
onOpenChange?: (isOpen: boolean) => void;
}
const stripCountSuffix = (label: string): string => {
return label.replace(/\s+\(\d+\)\s*$/, '');
};
const BookmarkIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
className={`${className} flex-shrink-0`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
/>
</svg>
);
const renderSummary = (selectedOptions: DropdownListOption[]) => {
const count = selectedOptions.length;
return (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
<BookmarkIcon />
<span>Hardcover Lists{count > 0 ? ` (${count})` : ''}</span>
</span>
);
};
const updateOptionChecked = (
prev: BookTargetOption[],
target: string,
checked: boolean,
): BookTargetOption[] =>
prev.map((option) =>
option.value === target ? { ...option, checked } : option,
);
export const BookTargetDropdown = ({
provider,
bookId,
onShowToast,
widthClassName = 'w-full sm:w-56',
variant = 'default',
align = 'auto',
className,
onOpenChange,
}: BookTargetDropdownProps) => {
const [options, setOptions] = useState<BookTargetOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [pendingTargets, setPendingTargets] = useState<Set<string>>(new Set());
useEffect(() => {
let isMounted = true;
const run = async () => {
try {
const loaded = await loadBookTargets(provider, bookId);
if (!isMounted) return;
setOptions(loaded);
setLoadError(null);
} catch (error) {
if (!isMounted) return;
const message = error instanceof Error ? error.message : 'Failed to load Hardcover lists';
setOptions([]);
setLoadError(message);
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
setLoadError(null);
setPendingTargets(new Set());
setIsLoading(true);
void run();
return () => {
isMounted = false;
};
}, [provider, bookId]);
// Sync from changes made by other BookTargetDropdown instances for the same book
useEffect(() => {
return onBookTargetChange((event) => {
if (event.provider !== provider || event.bookId !== bookId) return;
setOptions((prev) => updateOptionChecked(prev, event.target, event.selected));
});
}, [provider, bookId]);
const selectedValues = useMemo(
() => options.filter((option) => option.checked).map((option) => option.value),
[options],
);
const dropdownOptions = useMemo<DropdownListOption[]>(() => {
if (isLoading) {
return [{ value: '__loading', label: 'Loading…', disabled: true }];
}
if (loadError) {
return [{ value: '__error', label: loadError, disabled: true }];
}
if (options.length === 0) {
return [{ value: '__empty', label: 'No writable Hardcover targets', disabled: true }];
}
return options.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
disabled: !option.writable || pendingTargets.has(option.value),
}));
}, [isLoading, loadError, options, pendingTargets]);
const handleChange = useCallback((nextValue: string[] | string) => {
if (!Array.isArray(nextValue)) {
return;
}
const nextSelected = new Set(nextValue);
const currentSelected = new Set(selectedValues);
const toggledTarget =
nextValue.find((value) => !currentSelected.has(value))
?? selectedValues.find((value) => !nextSelected.has(value));
if (!toggledTarget || pendingTargets.has(toggledTarget)) {
return;
}
const selected = nextSelected.has(toggledTarget);
const toggledOption = options.find((option) => option.value === toggledTarget);
if (!toggledOption) {
return;
}
setPendingTargets((prev) => new Set(prev).add(toggledTarget));
setOptions((prev) => updateOptionChecked(prev, toggledTarget, selected));
void (async () => {
try {
const result = await setBookTargetState(provider, bookId, toggledTarget, selected);
setOptions((prev) => updateOptionChecked(prev, toggledTarget, result.selected));
if (result.changed) {
emitBookTargetChange({
provider,
bookId,
target: toggledTarget,
selected: result.selected,
});
const label = stripCountSuffix(toggledOption.label);
onShowToast?.(
`${result.selected ? 'Added to' : 'Removed from'} ${label}`,
'success',
);
}
} catch (error) {
setOptions((prev) => updateOptionChecked(prev, toggledTarget, !selected));
const message = error instanceof Error ? error.message : 'Failed to update Hardcover list';
onShowToast?.(message, 'error');
} finally {
setPendingTargets((prev) => {
const nextPending = new Set(prev);
nextPending.delete(toggledTarget);
return nextPending;
});
}
})();
}, [bookId, onShowToast, options, pendingTargets, provider, selectedValues]);
const customTrigger = variant === 'pill'
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
const count = selectedValues.length;
return (
<button
type="button"
onClick={toggle}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-none`}
>
<BookmarkIcon className="w-3 h-3" />
Hardcover Lists{count > 0 ? ` (${count})` : ''}
</button>
);
}
: variant === 'icon'
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
const count = selectedValues.length;
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(); }}
className={`flex items-center justify-center rounded-full transition-all duration-200 focus:outline-none ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
aria-label="Hardcover Lists"
title={count > 0 ? `On ${count} Hardcover list${count > 1 ? 's' : ''}` : 'Hardcover Lists'}
>
<BookmarkIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${count > 0 ? 'fill-current' : ''}`} />
</button>
);
}
: undefined;
return (
<DropdownList
options={dropdownOptions}
value={selectedValues}
onChange={handleChange}
placeholder={isLoading ? 'Loading…' : 'Lists & Want to Read'}
widthClassName={variant !== 'default' ? 'w-auto' : widthClassName}
buttonClassName={variant !== 'default' ? '' : 'py-1.5 leading-none'}
panelClassName={variant !== 'default' ? 'w-56' : undefined}
align={align}
multiple
showCheckboxes
keepOpenOnSelect
summaryFormatter={(selectedOptions) => renderSummary(selectedOptions)}
renderTrigger={customTrigger}
onOpenChange={onOpenChange}
/>
);
};
+42 -19
View File
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
import { isUserCancelledError } from '../utils/errors';
import { BookTargetDropdown } from './BookTargetDropdown';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
interface DetailsModalProps {
book: Book | null;
@@ -10,9 +12,18 @@ interface DetailsModalProps {
onFindDownloads?: (book: Book) => void; // For Universal mode
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
buttonState: ButtonStateInfo;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSearchSeries, buttonState }: DetailsModalProps) => {
export const DetailsModal = ({
book,
onClose,
onDownload,
onFindDownloads,
onSearchSeries,
buttonState,
onShowToast,
}: DetailsModalProps) => {
const [isQueuing, setIsQueuing] = useState(false);
const [isClosing, setIsClosing] = useState(false);
@@ -56,6 +67,8 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
}
}, [book]);
const hasBookTargets = Boolean(book && isMetadataBook(book) && bookSupportsTargets(book));
if (!book && !isClosing) return null;
if (!book) return null;
@@ -319,14 +332,14 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
className="border-t border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Source link - shown for both Universal and Direct Download modes */}
{book.source_url && (
<a
href={book.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border-muted)] bg-[var(--bg)] px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-200"
className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
View on {isMetadata ? providerDisplay : "Source"}
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -339,22 +352,32 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
</svg>
</a>
)}
{/* Action button - mirrors search result action state/flow */}
<button
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
className={`ml-auto rounded-full px-6 py-2.5 text-sm font-medium text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
isMetadata
? buttonState.state === 'blocked'
? 'bg-gray-500 focus:ring-gray-400'
: 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
: buttonState.state === 'blocked'
? 'bg-gray-500 focus:ring-gray-400'
: 'bg-sky-700 hover:bg-sky-800 focus:ring-sky-500'
}`}
>
{isMetadata ? metadataActionText : buttonState.text}
</button>
<div className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
{hasBookTargets && book.provider_id && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id}
onShowToast={onShowToast}
widthClassName="w-full sm:w-56"
/>
)}
{/* Action button - mirrors search result action state/flow */}
<button
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
className={`rounded-lg px-5 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isMetadata
? buttonState.state === 'blocked'
? 'bg-gray-500'
: 'bg-emerald-600 hover:bg-emerald-700'
: buttonState.state === 'blocked'
? 'bg-gray-500'
: 'bg-sky-700 hover:bg-sky-800'
}`}
>
{isMetadata ? metadataActionText : buttonState.text}
</button>
</div>
</div>
</footer>
</div>
+33 -6
View File
@@ -6,7 +6,7 @@ function getScrollableAncestor(element: HTMLElement | null): HTMLElement | null
while (current) {
const style = getComputedStyle(current);
const overflowY = style.overflowY;
if (overflowY === 'auto' || overflowY === 'scroll') {
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'hidden') {
return current;
}
current = current.parentElement;
@@ -41,7 +41,7 @@ interface DropdownProps {
label?: string;
summary?: ReactNode;
children: (helpers: { close: () => void }) => ReactNode;
align?: 'left' | 'right';
align?: 'left' | 'right' | 'auto';
widthClassName?: string;
buttonClassName?: string;
panelClassName?: string;
@@ -50,6 +50,7 @@ interface DropdownProps {
/** Disable max-height and overflow scrolling (for panels with nested dropdowns) */
noScrollLimit?: boolean;
triggerChrome?: 'default' | 'minimal';
onOpenChange?: (isOpen: boolean) => void;
}
export const Dropdown = ({
@@ -64,18 +65,27 @@ export const Dropdown = ({
renderTrigger,
noScrollLimit = false,
triggerChrome = 'default',
onOpenChange,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
const [resolvedAlign, setResolvedAlign] = useState<'left' | 'right'>(align === 'right' ? 'right' : 'left');
const toggleOpen = () => {
if (disabled) return;
setIsOpen(prev => !prev);
setIsOpen(prev => {
const next = !prev;
onOpenChange?.(next);
return next;
});
};
const close = () => setIsOpen(false);
const close = () => {
setIsOpen(false);
onOpenChange?.(false);
};
useEffect(() => {
if (!isOpen) return;
@@ -124,7 +134,24 @@ export const Dropdown = ({
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
setPanelDirection(shouldOpenUp ? 'up' : 'down');
}, []);
// Auto horizontal alignment: check if panel overflows viewport right/left
if (align === 'auto') {
const panelWidth = panelRef.current.offsetWidth || panelRef.current.scrollWidth;
const overflowsRight = rect.left + panelWidth > window.innerWidth - 8;
const overflowsLeft = rect.right - panelWidth < 8;
if (overflowsRight && !overflowsLeft) {
setResolvedAlign('right');
} else if (overflowsLeft && !overflowsRight) {
setResolvedAlign('left');
} else {
setResolvedAlign('left');
}
} else {
setResolvedAlign(align === 'right' ? 'right' : 'left');
}
}, [align]);
useLayoutEffect(() => {
if (!isOpen) return;
@@ -192,7 +219,7 @@ export const Dropdown = ({
{isOpen && (
<div
ref={panelRef}
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
className={`absolute ${resolvedAlign === 'right' ? 'right-0' : 'left-0'} ${
panelDirection === 'down'
? renderTrigger ? 'mt-2' : ''
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
+10 -1
View File
@@ -17,12 +17,15 @@ interface DropdownListProps {
showCheckboxes?: boolean;
value: string[] | string | null | undefined;
onChange: (value: string[] | string) => void;
align?: 'left' | 'right';
align?: 'left' | 'right' | 'auto';
widthClassName?: string;
buttonClassName?: string;
panelClassName?: string;
summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode;
keepOpenOnSelect?: boolean;
triggerChrome?: 'default' | 'minimal';
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
onOpenChange?: (isOpen: boolean) => void;
}
export const DropdownList = ({
@@ -36,9 +39,12 @@ export const DropdownList = ({
align,
widthClassName,
buttonClassName,
panelClassName,
summaryFormatter,
keepOpenOnSelect,
triggerChrome = 'default',
renderTrigger,
onOpenChange,
}: DropdownListProps) => {
const selectedValues = normalizeValue(value, multiple);
const selectedOptions = options.filter(opt => selectedValues.includes(opt.value));
@@ -104,7 +110,10 @@ export const DropdownList = ({
align={align}
widthClassName={widthClassName}
buttonClassName={buttonClassName}
panelClassName={panelClassName}
triggerChrome={triggerChrome}
renderTrigger={renderTrigger}
onOpenChange={onOpenChange}
>
{({ close }) => (
<div role="listbox" aria-multiselectable={multiple}>
@@ -20,6 +20,8 @@ import { useSocket } from '../contexts/SocketContext';
import { Dropdown } from './Dropdown';
import { DropdownList } from './DropdownList';
import { BookDownloadButton } from './BookDownloadButton';
import { BookTargetDropdown } from './BookTargetDropdown';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { ReleaseCell } from './ReleaseCell';
import { getColorStyleFromHint } from '../utils/colorMaps';
import { getNestedValue } from '../utils/objectHelpers';
@@ -95,6 +97,7 @@ interface ReleaseModalProps {
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
defaultShowManualQuery?: boolean;
isRequestMode?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
@@ -529,6 +532,7 @@ export const ReleaseModal = ({
onSearchSeries,
defaultShowManualQuery = false,
isRequestMode = false,
onShowToast,
}: ReleaseModalProps) => {
// Use audiobook formats when in audiobook mode
const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0
@@ -1491,6 +1495,14 @@ export const ReleaseModal = ({
{isRequestingBook ? 'Adding...' : 'Add to requests'}
</button>
)}
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="pill"
/>
)}
</div>
</div>
</div>
+31 -4
View File
@@ -25,11 +25,14 @@ interface ResultsSectionProps {
sortValue: string;
onSortChange: (value: string) => void;
metadataSortOptions?: SortOption[];
showSortControl?: boolean;
// Pagination (universal mode)
hasMore?: boolean;
isLoadingMore?: boolean;
onLoadMore?: () => void;
totalFound?: number;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
resultsSourceUrl?: string;
}
export const ResultsSection = ({
@@ -43,10 +46,13 @@ export const ResultsSection = ({
sortValue,
onSortChange,
metadataSortOptions,
showSortControl = true,
hasMore,
isLoadingMore,
onLoadMore,
totalFound,
onShowToast,
resultsSourceUrl,
}: ResultsSectionProps) => {
const { searchMode } = useSearchMode();
const [viewMode, setViewMode] = useState<'card' | 'compact' | 'list'>(() => {
@@ -86,10 +92,29 @@ export const ResultsSection = ({
return (
<section id="results-section" className="mb-4 sm:mb-8 w-full">
<div className="flex items-center justify-between mb-2 sm:mb-3 relative z-10">
<SortControl value={sortValue} onChange={onSortChange} metadataSortOptions={metadataSortOptions} />
{showSortControl ? (
<SortControl value={sortValue} onChange={onSortChange} metadataSortOptions={metadataSortOptions} />
) : resultsSourceUrl ? (
<a
href={resultsSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200 animate-pop-up"
>
View list on Hardcover
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
) : null}
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 ml-auto">
{isDesktop && (
<button
onClick={() => setViewMode('card')}
@@ -175,7 +200,7 @@ export const ResultsSection = ({
</div>
</div>
{viewMode === 'list' ? (
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} showSeriesPosition={sortValue === 'series_order'} />
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} showSeriesPosition={sortValue === 'series_order'} onShowToast={onShowToast} />
) : (
<div
id="results-grid"
@@ -199,6 +224,7 @@ export const ResultsSection = ({
buttonState={buttonState}
animationDelay={animationDelay}
showSeriesPosition={sortValue === 'series_order'}
onShowToast={onShowToast}
/>
) : (
<CompactView
@@ -211,6 +237,7 @@ export const ResultsSection = ({
showDetailsButton={!isDesktop}
animationDelay={animationDelay}
showSeriesPosition={sortValue === 'series_order'}
onShowToast={onShowToast}
/>
);
})}
+38 -4
View File
@@ -187,6 +187,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
const selectTriggerRef = useRef<HTMLButtonElement>(null);
const selectPanelRef = useRef<HTMLDivElement>(null);
const autocompletePanelRef = useRef<HTMLDivElement>(null);
const selectorHoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const deferredTextInputValue = useDeferredValue(textInputValue);
const hasMultipleContentTypes = !allowedContentTypes || allowedContentTypes.length !== 1;
@@ -210,9 +211,22 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
useDismiss(isSelectOpen, [selectPanelRef, selectTriggerRef], () => setIsSelectOpen(false));
useDismiss(isAutocompleteOpen, [autocompletePanelRef, inputRef], () => setIsAutocompleteOpen(false));
// Close select dropdown when active field changes
// Clean up hover timeout on unmount
useEffect(() => {
setIsSelectOpen(false);
return () => {
if (selectorHoverTimeout.current) clearTimeout(selectorHoverTimeout.current);
};
}, []);
// Auto-open select dropdown only when transitioning into a select field
const prevFieldKeyRef = useRef<string | undefined>(undefined);
useEffect(() => {
const fieldKey = activeQueryField?.key;
const isSelect = activeQueryField?.type === 'SelectSearchField' || activeQueryField?.type === 'DynamicSelectSearchField';
const fieldChanged = fieldKey !== prevFieldKeyRef.current;
prevFieldKeyRef.current = fieldKey;
setIsSelectOpen(fieldChanged && isSelect);
setIsAutocompleteOpen(false);
setAutocompleteOptions([]);
}, [activeQueryField?.key, activeQueryField?.type]);
@@ -434,6 +448,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
onFocus={() => {
if (autocompleteEndpoint && textInputValue.trim().length >= autocompleteMinQueryLength) {
setIsAutocompleteOpen(true);
setIsSelectorOpen(false);
}
}}
onKeyDown={handleKeyDown}
@@ -496,6 +511,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
if (!disabled && !isDynamicLoading) {
setIsSelectOpen((prev) => !prev);
setIsSelectorOpen(false);
setIsAutocompleteOpen(false);
}
}}
disabled={disabled}
@@ -559,10 +575,28 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
}}
>
{showQueryTargetSelector && (
<div className="relative flex-shrink-0 flex self-stretch" ref={selectorRef}>
<div
className="relative flex-shrink-0 flex self-stretch"
ref={selectorRef}
onMouseEnter={() => {
if (selectorHoverTimeout.current) {
clearTimeout(selectorHoverTimeout.current);
selectorHoverTimeout.current = null;
}
setIsSelectorOpen(true);
setIsSelectOpen(false);
setIsAutocompleteOpen(false);
}}
onMouseLeave={() => {
selectorHoverTimeout.current = setTimeout(() => {
setIsSelectorOpen(false);
selectorHoverTimeout.current = null;
}, 150);
}}
>
<button
type="button"
onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); }}
onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); setIsAutocompleteOpen(false); }}
className="flex items-center gap-1.5 pl-5 pr-2 rounded-l-full transition-colors hover-action"
style={{ color: 'var(--text)' }}
aria-label={`Searching ${contentType === 'ebook' ? 'books' : 'audiobooks'} by ${activeTarget?.label ?? 'general'}. Click to change.`}
@@ -25,7 +25,7 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => {
};
return (
<div id="toast-container" className="fixed bottom-4 right-4 z-[100] space-y-2">
<div id="toast-container" className="fixed bottom-4 right-4 z-[1100] space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
@@ -2,6 +2,8 @@ import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
@@ -16,15 +18,17 @@ interface CardViewProps {
buttonState: ButtonStateInfo;
animationDelay?: number;
showSeriesPosition?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0, showSeriesPosition = false }: CardViewProps) => {
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0, showSeriesPosition = false, onShowToast }: CardViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const handleDetails = async (id: string) => {
setIsLoadingDetails(true);
@@ -46,83 +50,100 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
return (
<article
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-pop-up will-change-transform"
className="book-card flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-pop-up will-change-transform relative"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
boxShadow: isHovered || dropdownOpen ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
zIndex: dropdownOpen ? 20 : isHovered ? 10 : undefined,
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0 group" style={{ aspectRatio: '2/3' }}>
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}
>
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0" style={{ aspectRatio: '2/3' }}>
<div className="absolute inset-0 overflow-hidden sm:rounded-t-[.75rem] max-sm:rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
>
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
<div
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
style={{ opacity: isHovered || dropdownOpen ? 0.02 : 0 }}
/>
</div>
<div
className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none"
style={{ opacity: isHovered ? 0.02 : 0 }}
/>
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 max-sm:hidden"
className="absolute bottom-2 right-2 z-10 flex flex-col gap-1.5 max-sm:hidden transition-all duration-300"
style={{
opacity: isHovered || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
opacity: isHovered || dropdownOpen || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || dropdownOpen || isLoadingDetails ? 'auto' : 'none',
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
</button>
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
</div>
</div>
<div className="p-4 max-sm:p-3 max-sm:py-2 flex flex-col gap-3 max-sm:gap-2 max-sm:flex-1 max-sm:justify-between max-sm:min-w-0 sm:flex-1 sm:flex sm:flex-col sm:justify-end">
@@ -2,6 +2,8 @@ import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
@@ -17,15 +19,17 @@ interface CompactViewProps {
showDetailsButton?: boolean;
animationDelay?: number;
showSeriesPosition?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0, showSeriesPosition = false }: CompactViewProps) => {
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0, showSeriesPosition = false, onShowToast }: CompactViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const handleDetails = async (id: string) => {
setIsLoadingDetails(true);
@@ -47,11 +51,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
return (
<article
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform"
className="book-card !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-pop-up will-change-transform relative"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
boxShadow: isHovered ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
boxShadow: isHovered || dropdownOpen ? '0 10px 30px rgba(0, 0, 0, 0.15)' : 'none',
zIndex: dropdownOpen ? 20 : isHovered ? 10 : undefined,
animationDelay: `${animationDelay}ms`,
animationFillMode: 'both',
}}
@@ -59,69 +64,85 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-[120px] h-full flex-shrink-0">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}
>
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
<div className="absolute inset-0 overflow-hidden rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
<div
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
>
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
<SkeletonLoader />
</div>
)}
<img
src={book.preview}
alt={book.title || 'Book cover'}
className="w-full h-full"
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
objectPosition: 'top',
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-sm opacity-50" style={{ background: 'var(--border-muted)' }}>
No Cover
</div>
)}
<div className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none" style={{ opacity: isHovered ? 0.02 : 0 }} />
<div className="absolute inset-0 bg-white transition-opacity duration-300 pointer-events-none" style={{ opacity: isHovered || dropdownOpen ? 0.02 : 0 }} />
</div>
{!showDetailsButton && (
<button
className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
<div
className="absolute bottom-2 right-2 z-10 flex flex-col gap-1.5 transition-all duration-300"
style={{
opacity: isHovered || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || isLoadingDetails ? 'auto' : 'none',
opacity: isHovered || dropdownOpen || isLoadingDetails ? 1 : 0,
pointerEvents: isHovered || dropdownOpen || isLoadingDetails ? 'auto' : 'none',
}}
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
className="w-8 h-8 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm shadow-lg hover:scale-110"
onOpenChange={setDropdownOpen}
/>
)}
</button>
<button
className="w-8 h-8 rounded-full bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110"
onClick={(e) => {
e.stopPropagation();
handleDetails(book.id);
}}
disabled={isLoadingDetails}
aria-label="Book details"
>
{isLoadingDetails ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
</div>
)}
</div>
@@ -2,6 +2,8 @@ import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { DisplayFieldIcon, DisplayFieldBadge } from '../shared';
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
@@ -13,6 +15,7 @@ interface ListViewProps {
getButtonState: (bookId: string) => ButtonStateInfo;
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
showSeriesPosition?: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
@@ -48,10 +51,11 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
);
};
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState, showSeriesPosition = false }: ListViewProps) => {
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState, showSeriesPosition = false, onShowToast }: ListViewProps) => {
const { searchMode } = useSearchMode();
const [detailsLoadingId, setDetailsLoadingId] = useState<string | null>(null);
const [releasesLoadingId, setReleasesLoadingId] = useState<string | null>(null);
const [openDropdownBookId, setOpenDropdownBookId] = useState<string | null>(null);
if (books.length === 0) {
return null;
@@ -77,7 +81,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
return (
<article
className="w-full overflow-hidden rounded-lg sm:rounded-2xl"
className="w-full rounded-lg sm:rounded-2xl"
style={{
background: 'var(--bg-soft)',
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.08)',
@@ -100,8 +104,9 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
return (
<div
key={book.id}
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-pop-up will-change-transform"
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-pop-up will-change-transform relative"
style={{
zIndex: openDropdownBookId === book.id ? 30 : undefined,
animationDelay: `${index * 50}ms`,
animationFillMode: 'both',
}}
@@ -218,6 +223,15 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Action Buttons */}
<div className="flex flex-row justify-end gap-0.5 sm:gap-1 sm:pr-3">
{bookSupportsTargets(book) && (
<BookTargetDropdown
provider={book.provider!}
bookId={book.provider_id!}
onShowToast={onShowToast}
variant="icon"
onOpenChange={(isOpen) => setOpenDropdownBookId(isOpen ? book.id : null)}
/>
)}
<button
className="flex items-center justify-center p-1.5 sm:p-2 rounded-full text-gray-600 dark:text-gray-200 hover-action transition-all duration-200"
onClick={() => handleDetails(book.id)}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { DropdownList, DropdownListOption } from '../DropdownList';
import { DropdownList, type DropdownListOption } from '../DropdownList';
import { DynamicFieldOption, fetchFieldOptions } from '../../services/api';
const optionsCache = new Map<string, DynamicFieldOption[]>();
@@ -92,20 +92,22 @@ export const DynamicDropdown = ({
return [{ value: '__error', label: loadError, disabled: true }];
}
if (options.length === 0) {
return [{ value: '__empty', label: 'No options available', disabled: true }];
}
return buildOptions(options);
}, [isLoading, loadError, options]);
const handleChange = (nextValue: string[] | string) => {
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
const match = options.find((opt) => opt.value === normalized);
onChange(normalized, match?.label);
};
return (
<DropdownList
options={dropdownOptions}
value={value}
onChange={handleChange}
onChange={(nextValue) => {
const normalized = Array.isArray(nextValue) ? nextValue[0] ?? '' : nextValue;
const match = options.find((option) => option.value === normalized);
onChange(normalized, match?.label);
}}
placeholder={placeholder}
widthClassName={widthClassName}
buttonClassName={buttonClassName}
+9 -1
View File
@@ -20,7 +20,7 @@ type SearchFieldValues = Record<string, string | number | boolean>;
interface UseSearchReturn {
books: Book[];
setBooks: (books: Book[]) => void;
setBooks: React.Dispatch<React.SetStateAction<Book[]>>;
isSearching: boolean;
lastSearchQuery: string;
searchInput: string;
@@ -49,6 +49,8 @@ interface UseSearchReturn {
isLoadingMore: boolean;
loadMore: (config: AppConfig | null, searchMode?: SearchMode) => Promise<void>;
totalFound: number;
// Source URL for the current result set (e.g. Hardcover list page)
resultsSourceUrl: string | undefined;
}
export function useSearch(options: UseSearchOptions): UseSearchReturn {
@@ -79,6 +81,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [totalFound, setTotalFound] = useState(0);
const [resultsSourceUrl, setResultsSourceUrl] = useState<string | undefined>();
// Store last search params for loadMore
const lastSearchParamsRef = useRef<{
@@ -163,6 +166,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setHasMore(false);
setTotalFound(0);
setCurrentPage(1);
setResultsSourceUrl(undefined);
lastSearchParamsRef.current = null;
return;
}
@@ -188,6 +192,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setBooks(result.books);
setHasMore(result.hasMore);
setTotalFound(result.totalFound);
setResultsSourceUrl(result.sourceUrl);
// Store params for loadMore
lastSearchParamsRef.current = {
query: searchQuery,
@@ -200,6 +205,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setBooks([]);
setHasMore(false);
setTotalFound(0);
setResultsSourceUrl(undefined);
showToast('No results found', 'error');
}
} catch (error) {
@@ -269,6 +275,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setCurrentPage(1);
setHasMore(false);
setTotalFound(0);
setResultsSourceUrl(undefined);
lastSearchParamsRef.current = null;
}, [onSearchReset]);
@@ -332,5 +339,6 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
isLoadingMore,
loadMore,
totalFound,
resultsSourceUrl,
};
}
+103 -9
View File
@@ -224,6 +224,7 @@ interface MetadataSearchResponse {
page?: number;
total_found?: number;
has_more?: boolean;
source_url?: string;
}
// Metadata search result with pagination info
@@ -232,6 +233,7 @@ export interface MetadataSearchResult {
page: number;
totalFound: number;
hasMore: boolean;
sourceUrl?: string;
}
export interface DynamicFieldOption {
@@ -241,6 +243,20 @@ export interface DynamicFieldOption {
description?: string;
}
export interface BookTargetOption {
value: string;
label: string;
group?: string;
description?: string;
checked: boolean;
writable: boolean;
}
export interface BookTargetStateResult {
changed: boolean;
selected: boolean;
}
// Search metadata providers and normalize to Book format
export const searchMetadata = async (
query: string,
@@ -283,6 +299,7 @@ export const searchMetadata = async (
page: response.page || page,
totalFound: response.total_found || 0,
hasMore: response.has_more || false,
sourceUrl: response.source_url,
};
};
@@ -328,18 +345,95 @@ export const fetchFieldOptions = async (
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 };
})
return parseOptionList(response.options).map(({ value, label, group, description }) => ({
value,
label,
group,
description,
}));
};
const parseBaseOption = (
option: Record<string, unknown>,
): { value: string; label: string; group?: string; description?: string } => {
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 };
};
const parseOptionList = (raw: unknown): ReturnType<typeof parseBaseOption>[] => {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.map(parseBaseOption)
.filter((option) => option.value !== '');
};
const parseBookTargetOptions = (raw: unknown): BookTargetOption[] => {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.map((item) => ({
...parseBaseOption(item),
checked: item.checked === true,
writable: item.writable !== false,
}))
.filter((option) => option.value !== '');
};
export const fetchBookTargetOptions = async (
provider: string,
bookId: string,
): Promise<BookTargetOption[]> => {
const response = await fetchJSON<{ options?: unknown }>(
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/${encodeURIComponent(bookId)}/targets`
);
return parseBookTargetOptions(response.options);
};
export const fetchBookTargetOptionsBatch = async (
provider: string,
bookIds: string[],
): Promise<Map<string, BookTargetOption[]>> => {
const response = await fetchJSON<{ results?: unknown }>(
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/targets/batch`,
{
method: 'POST',
body: JSON.stringify({ book_ids: bookIds }),
}
);
const results = new Map<string, BookTargetOption[]>();
if (typeof response.results === 'object' && response.results !== null) {
for (const [bookId, options] of Object.entries(response.results as Record<string, unknown>)) {
results.set(bookId, parseBookTargetOptions(options));
}
}
return results;
};
export const setBookTargetState = async (
provider: string,
bookId: string,
target: string,
selected: boolean,
): Promise<BookTargetStateResult> => {
const response = await fetchJSON<{ changed?: unknown; selected?: unknown }>(
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/${encodeURIComponent(bookId)}/targets`,
{
method: 'PUT',
body: JSON.stringify({ target, selected }),
}
);
return {
changed: response.changed === true,
selected: response.selected === true,
};
};
export const getSourceRecordInfo = async (source: string, id: string): Promise<Book> => {
const response = await fetchJSON<SourceRecordData>(
`${API_BASE}/release-sources/${encodeURIComponent(source)}/records/${encodeURIComponent(id)}`
@@ -0,0 +1,19 @@
type BookTargetChangeEvent = {
provider: string;
bookId: string;
target: string;
selected: boolean;
};
type Listener = (event: BookTargetChangeEvent) => void;
const listeners = new Set<Listener>();
export const onBookTargetChange = (listener: Listener): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
export const emitBookTargetChange = (event: BookTargetChangeEvent): void => {
listeners.forEach((listener) => listener(event));
};
+145
View File
@@ -0,0 +1,145 @@
import { fetchBookTargetOptionsBatch, type BookTargetOption } from '../services/api';
import { onBookTargetChange } from './bookTargetEvents';
import type { Book } from '../types';
// Providers that support the book targets feature.
// Centralised here so the check isn't hardcoded across every view component.
const PROVIDERS_WITH_TARGETS = new Set(['hardcover']);
/** Returns true if the book's provider supports the book-target dropdown. */
export const bookSupportsTargets = (book: Book): boolean =>
Boolean(book.provider && book.provider_id && PROVIDERS_WITH_TARGETS.has(book.provider));
type PendingRequest = {
bookId: string;
resolve: (options: BookTargetOption[]) => void;
reject: (error: unknown) => void;
};
const BATCH_SIZE = 50;
const CACHE_TTL_MS = 5 * 60 * 1000;
const pendingByProvider = new Map<string, PendingRequest[]>();
let flushScheduled = false;
// Client-side cache to avoid re-fetching on view mode switches and re-renders
const cache = new Map<string, { options: BookTargetOption[]; expiresAt: number }>();
const cacheKey = (provider: string, bookId: string) => `${provider}:${bookId}`;
const getCached = (provider: string, bookId: string): BookTargetOption[] | undefined => {
const entry = cache.get(cacheKey(provider, bookId));
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
cache.delete(cacheKey(provider, bookId));
return undefined;
}
return entry.options;
};
const setCache = (provider: string, bookId: string, options: BookTargetOption[]) => {
cache.set(cacheKey(provider, bookId), { options, expiresAt: Date.now() + CACHE_TTL_MS });
};
export const invalidateCache = (provider: string, bookId: string) => {
cache.delete(cacheKey(provider, bookId));
};
// Invalidate cache when a target changes
onBookTargetChange((event) => {
invalidateCache(event.provider, event.bookId);
});
const fetchChunked = async (
provider: string,
bookIds: string[],
): Promise<Map<string, BookTargetOption[]>> => {
if (bookIds.length <= BATCH_SIZE) {
return fetchBookTargetOptionsBatch(provider, bookIds);
}
const merged = new Map<string, BookTargetOption[]>();
const chunks: string[][] = [];
for (let i = 0; i < bookIds.length; i += BATCH_SIZE) {
chunks.push(bookIds.slice(i, i + BATCH_SIZE));
}
const results = await Promise.all(
chunks.map((chunk) => fetchBookTargetOptionsBatch(provider, chunk)),
);
for (const chunkResult of results) {
for (const [bookId, options] of chunkResult) {
merged.set(bookId, options);
}
}
return merged;
};
const flush = async () => {
flushScheduled = false;
const snapshot = new Map(pendingByProvider);
pendingByProvider.clear();
await Promise.all(
[...snapshot].map(async ([provider, requests]) => {
// Deduplicate and separate cached vs uncached
const uncachedIds = new Set<string>();
for (const req of requests) {
if (!getCached(provider, req.bookId)) {
uncachedIds.add(req.bookId);
}
}
try {
let fetched = new Map<string, BookTargetOption[]>();
if (uncachedIds.size > 0) {
fetched = await fetchChunked(provider, [...uncachedIds]);
for (const [bookId, options] of fetched) {
setCache(provider, bookId, options);
}
}
for (const req of requests) {
const options = getCached(provider, req.bookId) ?? fetched.get(req.bookId) ?? [];
req.resolve(options);
}
} catch (error) {
for (const req of requests) {
req.reject(error);
}
}
}),
);
};
/**
* Load book target options using automatic batching.
*
* Multiple calls within the same microtask are coalesced into a single
* batch API request per provider. Results are cached client-side to avoid
* re-fetching on view mode switches and re-renders.
*/
export const loadBookTargets = (
provider: string,
bookId: string,
): Promise<BookTargetOption[]> => {
const cached = getCached(provider, bookId);
if (cached) {
return Promise.resolve(cached);
}
return new Promise<BookTargetOption[]>((resolve, reject) => {
let list = pendingByProvider.get(provider);
if (!list) {
list = [];
pendingByProvider.set(provider, list);
}
list.push({ bookId, resolve, reject });
if (!flushScheduled) {
flushScheduled = true;
// Use queueMicrotask so all synchronous mounts in the same tick are batched
queueMicrotask(() => void flush());
}
});
};
@@ -2,16 +2,12 @@ from shelfmark.metadata_providers.hardcover import HardcoverProvider
class TestHardcoverFieldOptions:
def test_search_fields_enable_typeahead_for_author_title_and_series(self):
def test_search_fields_enable_typeahead_for_series_only(self):
provider = HardcoverProvider(api_key="test-token")
fields_by_key = {field.key: field for field in provider.search_fields}
assert fields_by_key["author"].suggestions_endpoint == (
"/api/metadata/field-options?provider=hardcover&field=author"
)
assert fields_by_key["title"].suggestions_endpoint == (
"/api/metadata/field-options?provider=hardcover&field=title"
)
assert fields_by_key["author"].suggestions_endpoint is None
assert fields_by_key["title"].suggestions_endpoint is None
assert fields_by_key["series"].suggestions_endpoint == (
"/api/metadata/field-options?provider=hardcover&field=series"
)
+203
View File
@@ -1,3 +1,4 @@
from shelfmark.core.cache import cache_key
from shelfmark.metadata_providers import MetadataSearchOptions, SearchResult
from shelfmark.metadata_providers.hardcover import (
HARDCOVER_WANT_TO_READ_STATUS_ID,
@@ -5,6 +6,22 @@ from shelfmark.metadata_providers.hardcover import (
)
class CacheStub:
"""Minimal cache stub that records invalidation calls."""
def __init__(self):
self.invalidated: list[str] = []
self.invalidated_prefixes: list[str] = []
def invalidate(self, key: str) -> bool:
self.invalidated.append(key)
return True
def invalidate_prefix(self, prefix: str) -> int:
self.invalidated_prefixes.append(prefix)
return 1
class TestHardcoverLists:
def test_fetch_user_lists_includes_want_to_read_shelf(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
@@ -133,3 +150,189 @@ class TestHardcoverLists:
assert len(result.books) == 1
assert result.books[0].title == "Dune"
assert result.books[0].authors == ["Frank Herbert"]
def test_get_book_targets_marks_checked_memberships(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
monkeypatch.setattr(
provider,
"get_user_lists",
lambda: [
{
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
"label": "Want to Read (7)",
"group": "My Books",
},
{
"value": "id:42",
"label": "Sci-Fi Favourites (12)",
"group": "My Lists",
},
{
"value": "id:99",
"label": "Followed List (3)",
"group": "Followed Lists",
},
],
)
monkeypatch.setattr(
provider,
"_execute_query",
lambda query, variables, raise_on_error=False: {
"me": {
"user_books": [{"id": 55, "status_id": HARDCOVER_WANT_TO_READ_STATUS_ID}],
"lists": [
{"id": 42, "list_books": [{"id": 500}]},
{"id": 99, "list_books": [{"id": 900}]},
],
}
},
)
options = provider.get_book_targets("123")
assert options == [
{
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
"label": "Want to Read (7)",
"group": "My Books",
"checked": True,
"writable": True,
},
{
"value": "id:42",
"label": "Sci-Fi Favourites (12)",
"group": "My Lists",
"checked": True,
"writable": True,
},
]
def test_set_book_target_state_updates_existing_status(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
captured: dict[str, object] = {}
cache_stub = CacheStub()
monkeypatch.setattr(
provider,
"get_user_lists",
lambda: [
{
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
"label": "Want to Read (7)",
"group": "My Books",
}
],
)
monkeypatch.setattr(provider, "_resolve_current_user_id", lambda: "user-123")
monkeypatch.setattr("shelfmark.metadata_providers.hardcover.get_metadata_cache", lambda: cache_stub)
def fake_execute(query: str, variables, raise_on_error: bool = False):
if "query GetBookTargetMembership" in query:
return {
"me": {
"user_books": [{"id": 77, "status_id": 2}],
"lists": [],
}
}
if "mutation UpdateBookStatus" in query:
captured["variables"] = variables
return {
"update_user_book": {
"id": 77,
"error": None,
"user_book": {"id": 77, "book_id": 123, "status_id": HARDCOVER_WANT_TO_READ_STATUS_ID},
}
}
raise AssertionError(f"Unexpected query: {query}")
monkeypatch.setattr(provider, "_execute_query", fake_execute)
result = provider.set_book_target_state(
"123",
f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
True,
)
assert result == {"changed": True}
assert captured["variables"] == {
"userBookId": 77,
"statusId": HARDCOVER_WANT_TO_READ_STATUS_ID,
}
assert cache_stub.invalidated == [
cache_key("hardcover:user_lists", "user-123"),
]
assert set(cache_stub.invalidated_prefixes) == {
cache_key("hardcover:user_books:status", "user-123", 2),
cache_key("hardcover:user_books:status", "user-123", HARDCOVER_WANT_TO_READ_STATUS_ID),
}
def test_set_book_target_state_removes_list_membership(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
cache_stub = CacheStub()
monkeypatch.setattr(
provider,
"get_user_lists",
lambda: [
{
"value": "id:42",
"label": "Sci-Fi Favourites (12)",
"group": "My Lists",
}
],
)
monkeypatch.setattr(provider, "_resolve_current_user_id", lambda: "user-123")
monkeypatch.setattr("shelfmark.metadata_providers.hardcover.get_metadata_cache", lambda: cache_stub)
def fake_execute(query: str, variables, raise_on_error: bool = False):
if "query GetBookTargetMembership" in query:
return {
"me": {
"user_books": [],
"lists": [{"id": 42, "list_books": [{"id": 500}]}],
}
}
if "mutation RemoveBookFromList" in query:
return {
"delete_list_book": {
"id": 500,
"list_id": 42,
}
}
raise AssertionError(f"Unexpected query: {query}")
monkeypatch.setattr(provider, "_execute_query", fake_execute)
result = provider.set_book_target_state("123", "id:42", False)
assert result == {"changed": True}
assert cache_stub.invalidated == [
cache_key("hardcover:user_lists", "user-123"),
]
assert cache_stub.invalidated_prefixes == [
cache_key("hardcover:list:id", 42),
]
def test_set_book_target_state_rejects_non_writable_targets(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
monkeypatch.setattr(
provider,
"get_user_lists",
lambda: [
{
"value": "id:99",
"label": "Followed List (3)",
"group": "Followed Lists",
}
],
)
try:
provider.set_book_target_state("123", "id:99", True)
except ValueError as exc:
assert str(exc) == "Unsupported Hardcover target"
else:
raise AssertionError("Expected ValueError for followed list target")