diff --git a/README.md b/README.md index a23e8aa4f..6fa16d400 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,45 @@ Built-in KOReader progress sync with automatic book identification: - Configurable scheduling with progress tracking in Tasks - Graceful handling of invalid tokens +#### **Shelfmark External Search (Stage 4)** 🔎📚 +- Optional CWA-side integration that shows Shelfmark external results alongside normal library search results +- Simple search and advanced search can both surface Shelfmark results: + - simple search forwards the search term directly + - advanced search only sends title, author, and publisher metadata when those fields are present + - other advanced filters stay local to CWA +- Duplicate awareness is deliberately exact and read-only: + - `metadata.db` + - exact `hardcover-id` + - no filename, title, or author fuzzy matching +- External results are grouped to make the state obvious: + - already in your library + - external candidates with an exact Hardcover ID but no local match + - external results where duplicate status is unavailable because Shelfmark returned no exact Hardcover ID +- The search and detail views now highlight those states visually with grouped headers, quick-jump links back into CWA, and clearer primary actions for: + - opening the existing CWA book + - requesting in Shelfmark + - opening Shelfmark directly +- External results that already exist in the library link straight back to the existing CWA book page +- External detail pages keep the same exact duplicate rules and show the existing CWA book when a match exists +- Request attribution stays on the Shelfmark side: + - CWA can fetch external results server-side + - the browser probes Shelfmark session state via Shelfmark auth APIs + - if the browser already has a valid Shelfmark session and the current Shelfmark policy allows it, the action upgrades to `Request in Shelfmark` + - otherwise the safe fallback remains `Open in Shelfmark` +- Recommended deployment model for direct requests: + - put CWA and Shelfmark behind the same browser origin or reverse proxy path + - if CWA reaches Shelfmark server-side through a private or LAN-only URL, set `Shelfmark Browser URL` to the browser-facing same-origin or reverse-proxied Shelfmark address + - if the browser sees Shelfmark as cross-origin, CWA falls back to `Open in Shelfmark` rather than trying to fake request attribution + - if Shelfmark is configured to sync/auth users from the mounted CWA `app.db`, users can log into Shelfmark with those synced CWA credentials and direct requests will then be attributed in Shelfmark as that same user +- Admin configuration: + - enable `Shelfmark External Search` + - set `Shelfmark Base URL` + - `Shelfmark Base URL` is the exact trust boundary for CWA's server-side Shelfmark metadata requests, including local/private Shelfmark hosts + - optionally set `Shelfmark Browser URL` when browsers should open Shelfmark through a different same-origin or reverse-proxied address than the server-side search URL + - if Shelfmark protects `/api/metadata/search`, set Shelfmark search username/password in CWA admin for CWA's server-side metadata search + - those search-account fields are separate from the browser's Shelfmark login used for `Request in Shelfmark` + - when Shelfmark is using the mounted CWA `app.db` for auth, users can still use their own synced CWA credentials in the browser for per-user request attribution, while CWA can use a dedicated search account here if needed + #### **Library Auto-Detect** 📚🕵️ - Made to **MASSIVELY** simplify the setup process for both **new and existing users** alike - **New Users without existing Libraries:** 🆕 @@ -486,4 +525,4 @@ CWA is and always will be free and open source. If it makes your library life ea - Development tools and infrastructure - Coffee ☕ (lots of coffee) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/crocodilestick) \ No newline at end of file +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/crocodilestick) diff --git a/cps/admin.py b/cps/admin.py index 4d50ecec6..4d70938c2 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -40,6 +40,7 @@ from .embed_helper import get_calibre_binarypath from .gdriveutils import is_gdrive_ready, gdrive_support from .render_template import render_title_template, get_sidebar_config +from .services.shelfmark_search import run_shelfmark_settings_diagnostics from .services.worker import WorkerThread from .usermanagement import user_login_required from .cw_babel import get_available_translations, get_available_locale, get_user_locale_language @@ -2353,6 +2354,20 @@ def _configuration_update_helper(): _config_checkbox(to_save, "config_hardcover_annotations_sync") _config_string(to_save, "config_hardcover_token") + # Shelfmark external search configuration + _config_checkbox(to_save, "config_shelfmark_search") + _config_string(to_save, "config_shelfmark_url") + _config_string(to_save, "config_shelfmark_browser_url") + _config_string(to_save, "config_shelfmark_username") + if to_save.get("config_shelfmark_password_e", ""): + _config_string(to_save, "config_shelfmark_password_e") + _config_checkbox(to_save, "config_shelfmark_preferred_release_enabled") + _config_string(to_save, "config_shelfmark_preferred_release_provider") + _config_string(to_save, "config_shelfmark_preferred_release_content_type") + _config_string(to_save, "config_shelfmark_preferred_release_ranking") + if config.config_shelfmark_search and not config.config_shelfmark_url: + return _configuration_result(_('Shelfmark Base URL is required when integrated Shelfmark search is enabled')) + _config_int(to_save, "config_updatechannel") # Reverse proxy login configuration @@ -2958,6 +2973,15 @@ def test_metadata(): log.error("Metadata test failed: %s", e) return json.dumps({'success': False, 'message': _('An unknown error occurred.')}), 200 + +@admi.route("/admin/test_shelfmark_connection", methods=["POST"]) +@user_login_required +@admin_required +def test_shelfmark_connection(): + payload = request.get_json(silent=True) or {} + diagnostics = run_shelfmark_settings_diagnostics(payload) + return jsonify(diagnostics.to_template_dict()) + # --- Last Resort Calibre DB Restore --- @admi.route("/admin/restore_calibre_db", methods=["POST"]) @user_login_required @@ -3113,4 +3137,4 @@ def restore_calibre_db(): except Exception: pass except Exception: - pass \ No newline at end of file + pass diff --git a/cps/author_profile.py b/cps/author_profile.py new file mode 100644 index 000000000..5f22ae2e8 --- /dev/null +++ b/cps/author_profile.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +# Calibre-Web Automated – fork of Calibre-Web +# Copyright (C) 2026 Calibre-Web Automated contributors +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +from dataclasses import dataclass +import re + +from flask_babel import gettext as _ + +from . import logger, services + +log = logger.create() + + +@dataclass(frozen=True) +class AuthorProfileView: + name: str + image_url: str | None = None + safe_about: str | None = None + link: str | None = None + source_label: str | None = None + + +def _normalize_author_key(value: str | None) -> str: + if not value: + return "" + normalized = str(value).replace("|", ",").lower() + normalized = re.sub(r"[^a-z0-9]+", " ", normalized) + return " ".join(normalized.split()) + + +def _candidate_author_names(author_name: str) -> tuple[str, ...]: + normalized = " ".join((author_name or "").replace("|", ",").split()) + if not normalized: + return tuple() + + candidates = [normalized] + if "," in normalized: + parts = [part.strip() for part in normalized.split(",") if part.strip()] + if len(parts) == 2: + display_name = " ".join(reversed(parts)).strip() + if display_name and display_name not in candidates: + candidates.append(display_name) + return tuple(candidates) + + +def _from_hardcover(author_name: str) -> AuthorProfileView | None: + hardcover_service = getattr(services, "hardcover", None) + if not hardcover_service: + return None + + for candidate_name in _candidate_author_names(author_name): + try: + profile = hardcover_service.get_author_profile(candidate_name) + except Exception as exc: # pragma: no cover - defensive app-path guard + log.warning("Hardcover author profile lookup failed for %s: %s", candidate_name, exc) + continue + + if not profile: + continue + + return AuthorProfileView( + name=profile.name or candidate_name, + image_url=profile.image_url, + safe_about=profile.safe_about, + link=profile.link, + source_label=_("Hardcover"), + ) + return None + + +def _extract_hardcover_book_ids(library_books) -> tuple[int, ...]: + seen: set[int] = set() + normalized_ids: list[int] = [] + for book in library_books or (): + for identifier in getattr(book, "identifiers", None) or (): + identifier_type = str(getattr(identifier, "type", "") or "").strip().lower() + if identifier_type != "hardcover-id": + continue + raw_value = getattr(identifier, "val", None) + try: + hardcover_id = int(str(raw_value).strip()) + except (TypeError, ValueError): + continue + if hardcover_id in seen: + continue + seen.add(hardcover_id) + normalized_ids.append(hardcover_id) + return tuple(normalized_ids) + + +def _select_hardcover_author_id(author_name: str, library_books) -> int | None: + hardcover_service = getattr(services, "hardcover", None) + if not hardcover_service or not hasattr(hardcover_service, "get_hardcover_client"): + return None + + book_ids = _extract_hardcover_book_ids(library_books) + if not book_ids: + return None + + try: + client = hardcover_service.get_hardcover_client(load_privacy=False) + except Exception as exc: # pragma: no cover - defensive app-path guard + log.warning("Hardcover client lookup failed while resolving author profile for %s: %s", author_name, exc) + return None + if client is None: + return None + + try: + books = client.list_books_by_ids(list(book_ids)) + except Exception as exc: # pragma: no cover - defensive app-path guard + log.warning("Hardcover book lookup failed while resolving author profile for %s: %s", author_name, exc) + return None + + candidate_keys = {_normalize_author_key(candidate) for candidate in _candidate_author_names(author_name)} + candidate_keys.discard("") + if not candidate_keys: + return None + + scores: dict[int, int] = {} + for book in books or (): + for contribution in book.get("contributions") or (): + author = contribution.get("author") if isinstance(contribution, dict) else None + if not isinstance(author, dict): + continue + try: + hardcover_author_id = int(author.get("id")) + except (TypeError, ValueError): + continue + if _normalize_author_key(author.get("name")) not in candidate_keys: + continue + scores[hardcover_author_id] = scores.get(hardcover_author_id, 0) + 1 + + if not scores: + return None + + return max(scores.items(), key=lambda item: (item[1], -item[0]))[0] + + +def _from_hardcover_library_books(author_name: str, library_books) -> AuthorProfileView | None: + hardcover_service = getattr(services, "hardcover", None) + if not hardcover_service or not hasattr(hardcover_service, "get_author_profile_by_id"): + return None + + hardcover_author_id = _select_hardcover_author_id(author_name, library_books) + if hardcover_author_id is None: + return None + + try: + profile = hardcover_service.get_author_profile_by_id(hardcover_author_id) + except Exception as exc: # pragma: no cover - defensive app-path guard + log.warning( + "Hardcover author profile by id failed for %s (author_id=%s): %s", + author_name, + hardcover_author_id, + exc, + ) + return None + if not profile: + return None + + return AuthorProfileView( + name=profile.name or author_name, + image_url=profile.image_url, + safe_about=profile.safe_about, + link=profile.link, + source_label=_("Hardcover"), + ) + + +def _from_goodreads(author_name: str) -> AuthorProfileView | None: + goodreads_service = getattr(services, "goodreads_support", None) + if not goodreads_service: + return None + + for candidate_name in _candidate_author_names(author_name): + try: + profile = goodreads_service.get_author_info(candidate_name) + except Exception as exc: # pragma: no cover - defensive app-path guard + log.warning("Goodreads author profile lookup failed for %s: %s", candidate_name, exc) + continue + + if not profile: + continue + + return AuthorProfileView( + name=getattr(profile, "name", None) or candidate_name, + image_url=getattr(profile, "image_url", None), + safe_about=getattr(profile, "safe_about", None), + link=getattr(profile, "link", None), + source_label=_("Goodreads"), + ) + return None + + +def build_author_profile(author_name: str, *, library_books=None) -> AuthorProfileView | None: + if not author_name: + return None + + hardcover_profile = _from_hardcover_library_books(author_name, library_books) + if hardcover_profile and (hardcover_profile.image_url or hardcover_profile.safe_about): + return hardcover_profile + + hardcover_profile = _from_hardcover(author_name) + if hardcover_profile and (hardcover_profile.image_url or hardcover_profile.safe_about): + return hardcover_profile + + goodreads_profile = _from_goodreads(author_name) + if goodreads_profile and (goodreads_profile.image_url or goodreads_profile.safe_about): + return goodreads_profile + + return hardcover_profile or goodreads_profile diff --git a/cps/config_sql.py b/cps/config_sql.py index 7a9fea256..835f52a2e 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -114,6 +114,15 @@ class _Settings(_Base): config_use_goodreads = Column(Boolean, default=False) config_goodreads_api_key = Column(String) config_hardcover_token = Column(String) + config_shelfmark_search = Column(Boolean, default=False) + config_shelfmark_url = Column(String, default="") + config_shelfmark_browser_url = Column(String, default="") + config_shelfmark_username = Column(String, default="") + config_shelfmark_password_e = Column(String) + config_shelfmark_preferred_release_enabled = Column(Boolean, default=False) + config_shelfmark_preferred_release_provider = Column(String, default="") + config_shelfmark_preferred_release_content_type = Column(String, default="ebook") + config_shelfmark_preferred_release_ranking = Column(String, default="seeders_desc") config_register_email = Column(Boolean, default=False) config_login_type = Column(Integer, default=0) @@ -438,7 +447,10 @@ def invalidate(self, error=None): self.save() def get_book_path(self): - return self.config_calibre_split_dir if self.config_calibre_split else self.config_calibre_dir + split_library = bool(getattr(self, "config_calibre_split", False)) + if split_library: + return getattr(self, "config_calibre_split_dir", None) or getattr(self, "config_calibre_dir", None) + return getattr(self, "config_calibre_dir", None) def store_calibre_uuid(self, calibre_db, Library_table): from . import app diff --git a/cps/constants.py b/cps/constants.py index 4b54ef392..9133ee0ce 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -89,6 +89,7 @@ SIDEBAR_DOWNLOAD = 1 << 16 SIDEBAR_LIST = 1 << 17 SIDEBAR_DUPLICATES = 1 << 18 +SIDEBAR_REQUESTS = 1 << 19 sidebar_settings = { "detail_random": DETAIL_RANDOM, @@ -112,7 +113,7 @@ ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS -ADMIN_USER_SIDEBAR = (SIDEBAR_DUPLICATES << 1) - 1 +ADMIN_USER_SIDEBAR = (SIDEBAR_REQUESTS << 1) - 1 UPDATE_STABLE = 0 << 0 AUTO_UPDATE_STABLE = 1 << 0 diff --git a/cps/cover_utils.py b/cps/cover_utils.py new file mode 100644 index 000000000..bfafc616b --- /dev/null +++ b/cps/cover_utils.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Callable, Protocol + + +class LoggerLike(Protocol): + def debug(self, msg: str, *args) -> None: ... + def info(self, msg: str, *args) -> None: ... + def warning(self, msg: str, *args) -> None: ... + + +@dataclass(frozen=True) +class CoverUpdateOutcome: + applied: bool + modify_date: bool + error: str | None = None + cleared_cover: bool = False + normalized_cover_url: str | None = None + + +def normalize_utc_timestamp(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value + return value.astimezone(timezone.utc).replace(tzinfo=None) + + +def is_cached_thumbnail_stale( + book_last_modified: datetime | None, + thumbnail_generated_at: datetime | None, +) -> bool: + normalized_book_modified = normalize_utc_timestamp(book_last_modified) + normalized_thumbnail_generated = normalize_utc_timestamp(thumbnail_generated_at) + if normalized_book_modified is None or normalized_thumbnail_generated is None: + return False + return normalized_book_modified > normalized_thumbnail_generated + + +def apply_selected_cover_url( + *, + book_id: int, + book_path: str, + cover_url: str | None, + logger: LoggerLike, + save_cover_from_url: Callable[[str, str], tuple[bool, str | None]], + refresh_thumbnail_cache: Callable[[int], None], +) -> CoverUpdateOutcome: + normalized_cover_url = (cover_url or "").strip() + if not normalized_cover_url: + logger.debug( + "Cover update skipped for book %s because no selected cover payload was present", + book_id, + ) + return CoverUpdateOutcome( + applied=False, + modify_date=False, + normalized_cover_url=None, + ) + + if normalized_cover_url.endswith("/static/generic_cover.svg"): + logger.info( + "Metadata save for book %s: generic cover selected; clearing existing cover flag", + book_id, + ) + return CoverUpdateOutcome( + applied=True, + modify_date=False, + cleared_cover=True, + normalized_cover_url=normalized_cover_url, + ) + + logger.info( + "Metadata save for book %s: selected provider cover will be applied", + book_id, + ) + result, error = save_cover_from_url(normalized_cover_url, book_path) + if not result: + logger.warning( + "Metadata save for book %s: cover write failed error=%s", + book_id, + error, + ) + return CoverUpdateOutcome( + applied=False, + modify_date=False, + error=error, + normalized_cover_url=normalized_cover_url, + ) + + logger.info("Downloaded provider cover for book %s", book_id) + logger.info("Wrote updated cover for book %s", book_id) + refresh_thumbnail_cache(book_id) + logger.info("Queued cached thumbnail invalidation/regeneration for book %s", book_id) + return CoverUpdateOutcome( + applied=True, + modify_date=True, + normalized_cover_url=normalized_cover_url, + ) diff --git a/cps/editbook_save_utils.py b/cps/editbook_save_utils.py new file mode 100644 index 000000000..158397004 --- /dev/null +++ b/cps/editbook_save_utils.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# Calibre-Web Automated – fork of Calibre-Web +# Copyright (C) 2026 Calibre-Web Automated contributors +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +from contextlib import nullcontext +from typing import Any, Callable, Protocol + + +class LoggerLike(Protocol): + def warning(self, msg: str, *args) -> None: ... + def info(self, msg: str, *args) -> None: ... + + +def recover_calibre_session( + *, + calibre_db_instance: Any, + logger: LoggerLike, + book_id: int, + phase: str, +) -> None: + session = getattr(calibre_db_instance, "session", None) + if session is not None: + try: + session.rollback() + except Exception as rollback_error: + logger.warning( + "Manual metadata apply rollback failed for book %s during %s: %s", + book_id, + phase, + rollback_error, + ) + try: + session.close() + except Exception as close_error: + logger.warning( + "Manual metadata apply session close failed for book %s during %s: %s", + book_id, + phase, + close_error, + ) + + calibre_db_instance.session = None + try: + calibre_db_instance.ensure_session() + except Exception as ensure_error: + logger.warning( + "Manual metadata apply session recovery failed for book %s during %s: %s", + book_id, + phase, + ensure_error, + ) + + +def ensure_calibre_session_ready( + *, + calibre_db_instance: Any, + logger: LoggerLike, + book_id: int, + phase: str, +) -> Any: + calibre_db_instance.ensure_session() + session = getattr(calibre_db_instance, "session", None) + if session is None: + return None + + try: + transaction = session.get_transaction() if hasattr(session, "get_transaction") else None + except Exception as transaction_error: + logger.warning( + "Manual metadata apply transaction inspection failed for book %s during %s: %s", + book_id, + phase, + transaction_error, + ) + recover_calibre_session( + calibre_db_instance=calibre_db_instance, + logger=logger, + book_id=book_id, + phase=phase, + ) + return getattr(calibre_db_instance, "session", None) + + if transaction is not None and not getattr(transaction, "is_active", True): + logger.warning( + "Manual metadata apply found closed transaction for book %s during %s; recreating session", + book_id, + phase, + ) + recover_calibre_session( + calibre_db_instance=calibre_db_instance, + logger=logger, + book_id=book_id, + phase=phase, + ) + return getattr(calibre_db_instance, "session", None) + + return session + + +def build_post_save_location( + *, + book_id: int, + detail_view: bool, + url_for: Callable[..., str], +) -> str: + endpoint = "web.show_book" if detail_view else "edit-book.show_edit_book" + return url_for(endpoint, book_id=book_id) + + +def load_custom_columns_without_autoflush( + *, + session: Any, + custom_columns_model: Any, + cc_exceptions: Any, + column_id: int | None = None, +) -> list[Any]: + context_manager = session.no_autoflush if hasattr(session, "no_autoflush") else nullcontext() + with context_manager: + query = session.query(custom_columns_model).filter( + custom_columns_model.datatype.notin_(cc_exceptions) + ) + if column_id is not None: + query = query.filter(custom_columns_model.id == column_id) + return query.all() diff --git a/cps/editbooks.py b/cps/editbooks.py index c935bbaa2..8ee10f159 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -36,6 +36,13 @@ from .cwa_functions import get_ingest_dir from .usermanagement import user_login_required, login_required_if_no_ano from .string_helper import strip_whitespaces +from .cover_utils import apply_selected_cover_url +from .editbook_save_utils import ( + build_post_save_location, + ensure_calibre_session_ready, + load_custom_columns_without_autoflush, + recover_calibre_session, +) from werkzeug.utils import secure_filename import uuid @@ -837,11 +844,19 @@ def table_xchange_author_title(): def do_edit_book(book_id, upload_formats=None): request_start = time.monotonic() log.debug("[edit_book] start book_id=%s user=%s upload_formats=%s", book_id, getattr(current_user, "name", "unknown"), bool(upload_formats)) + log.info("Manual metadata apply started for book %s", book_id) modify_date = False edit_error = False + save_phase = "loading book" # create the function for sorting... calibre_db.create_functions(config) + ensure_calibre_session_ready( + calibre_db_instance=calibre_db, + logger=log, + book_id=book_id, + phase="request start", + ) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) # Book not found @@ -859,6 +874,7 @@ def do_edit_book(book_id, upload_formats=None): input_authors = [author.name for author in book.authors] # Stage 1: Collect all metadata changes and apply them to the book object in the session + save_phase = "staging form fields" if "title" in to_save: title_change = handle_title_on_edit(book, to_save["title"]) @@ -870,30 +886,49 @@ def do_edit_book(book_id, upload_formats=None): else: to_save, edit_error = upload_book_formats(upload_formats, book, book_id, book.has_cover) + save_phase = "cover upload" cover_upload_success = upload_cover(request, book) if cover_upload_success or to_save.get("format_cover"): book.has_cover = 1 modify_date = True - if to_save.get("cover_url"): - if not current_user.role_edit(): - edit_error = True - flash(_("User has no rights to upload cover"), category="error") - elif to_save["cover_url"].endswith('/static/generic_cover.svg'): - book.has_cover = 0 - else: - cover_start = time.monotonic() - result, error = helper.save_cover_from_url(to_save["cover_url"].strip(), book.path) - if result: + selected_cover_url = to_save.get("cover_url", "") + if selected_cover_url and not current_user.role_edit(): + edit_error = True + flash(_("User has no rights to upload cover"), category="error") + else: + cover_start = time.monotonic() + save_phase = "provider cover update" + cover_update = apply_selected_cover_url( + book_id=book.id, + book_path=book.path, + cover_url=selected_cover_url, + logger=log, + save_cover_from_url=helper.save_cover_from_url, + refresh_thumbnail_cache=helper.replace_cover_thumbnail_cache, + ) + if cover_update.applied: + if cover_update.cleared_cover: + book.has_cover = 0 + elif cover_update.normalized_cover_url: book.has_cover = 1 - modify_date = True - # Force thumbnail regeneration after successful cover fetch - helper.replace_cover_thumbnail_cache(book.id) - log.debug("[edit_book] cover saved book_id=%s duration=%.3fs", book.id, time.monotonic() - cover_start) - else: - log.warning("[edit_book] cover save failed book_id=%s duration=%.3fs error=%s", book.id, time.monotonic() - cover_start, error) - edit_error = True - flash(error, category="error") + modify_date |= cover_update.modify_date + if cover_update.normalized_cover_url and not cover_update.cleared_cover: + log.info("Manual metadata apply wrote provider cover for book %s", book.id) + log.debug( + "[edit_book] cover handling completed book_id=%s duration=%.3fs", + book.id, + time.monotonic() - cover_start, + ) + elif cover_update.error: + log.warning( + "[edit_book] cover save failed book_id=%s duration=%.3fs error=%s", + book.id, + time.monotonic() - cover_start, + cover_update.error, + ) + edit_error = True + flash(cover_update.error, category="error") modify_date |= edit_book_series_index(to_save.get("series_index"), book) modify_date |= edit_book_comments(Markup(to_save.get('comments')).unescape(), book) @@ -918,6 +953,7 @@ def do_edit_book(book_id, upload_formats=None): flash(str(e), category="error") edit_error = True + save_phase = "custom column update" modify_date |= edit_all_cc_data(book_id, book, to_save) # Handle hardcover sync blacklist settings @@ -945,29 +981,29 @@ def do_edit_book(book_id, upload_formats=None): # Stage 3: Commit all changes to the database if modify_date: + save_phase = "marking metadata dirty" book.last_modified = datetime.now(timezone.utc) kobo_sync_status.remove_synced_book(book.id, all=True) calibre_db.set_metadata_dirty(book.id) try: + save_phase = "database commit" calibre_db.session.merge(book) calibre_db.session.commit() + log.info("Manual metadata apply committed DB changes for book %s", book.id) log.debug("[edit_book] db commit ok book_id=%s duration=%.3fs", book.id, time.monotonic() - request_start) except InvalidRequestError as e: # Recover from closed/invalid transaction by recreating the session and retrying once - log.warning("Edit book transaction invalid, retrying commit: %s", e) - try: - calibre_db.session.rollback() - except Exception: - pass - try: - calibre_db.session.close() - except Exception: - pass - calibre_db.session = None - calibre_db.ensure_session() + log.warning("Manual metadata apply encountered invalid transaction during database commit for book %s; retrying: %s", book.id, e) + recover_calibre_session( + calibre_db_instance=calibre_db, + logger=log, + book_id=book.id, + phase="database commit retry", + ) calibre_db.session.merge(book) calibre_db.session.commit() + log.info("Manual metadata apply committed DB changes for book %s after session recovery", book.id) log.debug("[edit_book] db commit retry ok book_id=%s duration=%.3fs", book.id, time.monotonic() - request_start) # CWA: Export of changed Metadata after commit, to avoid race conditions with folder renames @@ -1031,21 +1067,40 @@ def do_edit_book(book_id, upload_formats=None): if upload_formats: return Response(json.dumps({"location": url_for('edit-book.show_edit_book', book_id=book_id)}), mimetype='application/json') - if "detail_view" in to_save: - return redirect(url_for('web.show_book', book_id=book.id)) - else: - return render_edit_book(book_id) + save_phase = "response redirect" + redirect_target = build_post_save_location( + book_id=book.id, + detail_view="detail_view" in to_save, + url_for=url_for, + ) + log.info("Manual metadata apply finished successfully for book %s", book.id) + return redirect(redirect_target) except (ValueError, OperationalError, IntegrityError, StaleDataError, InterfaceError, InvalidRequestError) as e: - log.error_or_exception("Database or Value error: {}".format(e)) - calibre_db.session.rollback() + log.error_or_exception( + "Manual metadata apply failed for book %s during %s: %s", + book_id, + save_phase, + e, + ) + recover_calibre_session( + calibre_db_instance=calibre_db, + logger=log, + book_id=book_id, + phase=save_phase, + ) flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e), category="error") - return redirect(url_for('web.show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book_id)) except Exception as ex: - log.error_or_exception(ex) - calibre_db.session.rollback() + log.error_or_exception("Manual metadata apply failed for book %s during %s: %s", book_id, save_phase, ex) + recover_calibre_session( + calibre_db_instance=calibre_db, + logger=log, + book_id=book_id, + phase=save_phase, + ) flash(_("Error editing book: {}".format(ex)), category="error") - return redirect(url_for('web.show_book', book_id=book.id)) + return redirect(url_for('web.show_book', book_id=book_id)) def merge_metadata(book, meta, to_save): @@ -1654,15 +1709,21 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string): def edit_single_cc_data(book_id, book, column_id, to_save): - cc = (calibre_db.session.query(db.CustomColumns) - .filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)) - .filter(db.CustomColumns.id == column_id) - .all()) + cc = load_custom_columns_without_autoflush( + session=calibre_db.session, + custom_columns_model=db.CustomColumns, + cc_exceptions=db.cc_exceptions, + column_id=column_id, + ) return edit_cc_data(book_id, book, to_save, cc) def edit_all_cc_data(book_id, book, to_save): - cc = calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)).all() + cc = load_custom_columns_without_autoflush( + session=calibre_db.session, + custom_columns_model=db.CustomColumns, + cc_exceptions=db.cc_exceptions, + ) return edit_cc_data(book_id, book, to_save, cc) diff --git a/cps/helper.py b/cps/helper.py index 02a8f8ba4..bd6fe14b1 100755 --- a/cps/helper.py +++ b/cps/helper.py @@ -49,10 +49,19 @@ from .constants import (STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES, SUPPORTED_CALIBRE_BINARIES) from .subproc_wrapper import process_wait +from .cover_utils import is_cached_thumbnail_stale # Track books with pending thumbnail generation to prevent duplicate tasks _pending_thumbnail_books = set() + +def thumbnail_cache_available(force_refresh=False): + try: + return bool(ub.ensure_thumbnail_table(force_refresh=force_refresh)) + except Exception as ex: + log.debug("Thumbnail table availability check failed: %s", ex) + return False + import sys sys.path.insert(1, '/app/calibre-web-automated/scripts/') from cwa_db import CWA_DB @@ -863,24 +872,52 @@ def get_book_cover_with_uuid(book_uuid, resolution=None): def get_book_cover_internal(book, resolution=None): """Serve book cover with improved thumbnail generation fallback. - When a thumbnail is requested but missing, generate it synchronously - instead of falling back to the original cover.jpg. + When a thumbnail is requested but missing or stale, queue regeneration + and fall back to the freshly written cover.jpg until the cache catches up. """ if book and book.has_cover: # Send the book cover thumbnail if it exists in cache - if resolution: + if resolution and thumbnail_cache_available(): cache = fs.FileSystem() # Check for both webp and jpg thumbnails, generate missing ones webp_thumb = get_book_cover_thumbnail_by_format(book, resolution, 'webp') jpg_thumb = get_book_cover_thumbnail_by_format(book, resolution, 'jpg') - # Check if files actually exist on disk + # Check if files actually exist on disk and are still current webp_exists = webp_thumb and cache.get_cache_file_exists(webp_thumb.filename, CACHE_TYPE_THUMBNAILS) jpg_exists = jpg_thumb and cache.get_cache_file_exists(jpg_thumb.filename, CACHE_TYPE_THUMBNAILS) + webp_stale = bool( + webp_exists and is_cached_thumbnail_stale( + getattr(book, 'last_modified', None), + getattr(webp_thumb, 'generated_at', None), + ) + ) + jpg_stale = bool( + jpg_exists and is_cached_thumbnail_stale( + getattr(book, 'last_modified', None), + getattr(jpg_thumb, 'generated_at', None), + ) + ) - # Generate missing thumbnails on-demand (skip for Kobo requests to avoid delays) - if not webp_exists or not jpg_exists: + if webp_stale: + log.debug( + "Ignoring stale cached webp cover thumbnail for book %s resolution=%s", + book.id, + resolution, + ) + if jpg_stale: + log.debug( + "Ignoring stale cached jpg cover thumbnail for book %s resolution=%s", + book.id, + resolution, + ) + + webp_ready = bool(webp_exists and not webp_stale) + jpg_ready = bool(jpg_exists and not jpg_stale) + + # Generate missing or stale thumbnails on-demand (skip for Kobo requests to avoid delays) + if not webp_ready or not jpg_ready: try: from flask import has_request_context, request is_kobo_request = (has_request_context() and @@ -917,12 +954,12 @@ def get_book_cover_internal(book, resolution=None): # Prefer jpg for Kobo requests, webp for web requests if is_kobo_request: - thumbnail_to_serve = jpg_thumb if jpg_exists else (webp_thumb if webp_exists else None) + thumbnail_to_serve = jpg_thumb if jpg_ready else (webp_thumb if webp_ready else None) else: - thumbnail_to_serve = webp_thumb if webp_exists else (jpg_thumb if jpg_exists else None) + thumbnail_to_serve = webp_thumb if webp_ready else (jpg_thumb if jpg_ready else None) except: # Fallback if we can't determine request context - thumbnail_to_serve = webp_thumb if webp_exists else (jpg_thumb if jpg_exists else None) + thumbnail_to_serve = webp_thumb if webp_ready else (jpg_thumb if jpg_ready else None) if thumbnail_to_serve: return send_from_directory(cache.get_cache_file_dir(thumbnail_to_serve.filename, CACHE_TYPE_THUMBNAILS), thumbnail_to_serve.filename) @@ -954,7 +991,7 @@ def get_book_cover_internal(book, resolution=None): def get_book_cover_thumbnail(book, resolution): - if book and book.has_cover: + if book and book.has_cover and thumbnail_cache_available(): return (ub.session .query(ub.Thumbnail) .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) @@ -966,7 +1003,7 @@ def get_book_cover_thumbnail(book, resolution): def get_book_cover_thumbnail_by_format(book, resolution, format): """Get thumbnail for specific book, resolution, and format (webp/jpg)""" - if book and book.has_cover: + if book and book.has_cover and thumbnail_cache_available(): return (ub.session .query(ub.Thumbnail) .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) @@ -994,7 +1031,7 @@ def get_series_cover_thumbnail(series_id, resolution=None): def get_series_cover_internal(series_id, resolution=None): # Send the series thumbnail if it exists in cache - if resolution: + if resolution and thumbnail_cache_available(): thumbnail = get_series_thumbnail(series_id, resolution) if thumbnail: cache = fs.FileSystem() @@ -1006,6 +1043,8 @@ def get_series_cover_internal(series_id, resolution=None): def get_series_thumbnail(series_id, resolution): + if not thumbnail_cache_available(): + return None return (ub.session .query(ub.Thumbnail) .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES) @@ -1161,6 +1200,9 @@ def save_cover(img, book_path): def trigger_thumbnail_generation_for_book(book_id): """Trigger thumbnail generation for a book after cover changes.""" try: + if not thumbnail_cache_available(): + log.debug("Thumbnail generation skipped for book %s because the thumbnail table is unavailable", book_id) + return from .tasks.thumbnail import TaskGenerateCoverThumbnails if use_IM: @@ -1484,6 +1526,9 @@ def get_download_link(book_id, book_format, client): def clear_cover_thumbnail_cache(book_id): # Always allow clearing thumbnail cache + if not thumbnail_cache_available(): + log.debug("Thumbnail cache clear skipped for book %s because the thumbnail table is unavailable", book_id) + return # Remove from pending set when clearing cache (e.g., during book deletion) _pending_thumbnail_books.discard(book_id) WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True) @@ -1491,6 +1536,9 @@ def clear_cover_thumbnail_cache(book_id): def replace_cover_thumbnail_cache(book_id): # Always allow replacing thumbnail cache + if not thumbnail_cache_available(): + log.debug("Thumbnail cache refresh skipped for book %s because the thumbnail table is unavailable", book_id) + return # Remove from pending set to allow regeneration _pending_thumbnail_books.discard(book_id) # Try to queue clear task (not critical if it fails) @@ -1507,11 +1555,17 @@ def replace_cover_thumbnail_cache(book_id): def delete_thumbnail_cache(): + if not thumbnail_cache_available(): + log.debug("Thumbnail cache delete skipped because the thumbnail table is unavailable") + return WorkerThread.add(None, TaskClearCoverThumbnailCache(-1)) def add_book_to_thumbnail_cache(book_id): # Always generate thumbnails for new books + if not thumbnail_cache_available(): + log.debug("Thumbnail generation skipped for book %s because the thumbnail table is unavailable", book_id) + return # Ensure not in pending set (shouldn't be for new books, but defensive) _pending_thumbnail_books.discard(book_id) # Queue generation task and add to pending set only if successful @@ -1524,6 +1578,9 @@ def add_book_to_thumbnail_cache(book_id): def update_thumbnail_cache(): # Always allow manual thumbnail cache updates + if not thumbnail_cache_available(): + log.debug("Thumbnail cache rebuild skipped because the thumbnail table is unavailable") + return None task = TaskGenerateCoverThumbnails() WorkerThread.add(None, task) # Return task ID for tracking diff --git a/cps/metadata_helper.py b/cps/metadata_helper.py index 98f2e51e8..4eac9a942 100755 --- a/cps/metadata_helper.py +++ b/cps/metadata_helper.py @@ -5,15 +5,589 @@ # SPDX-License-Identifier: GPL-3.0-or-later # See CONTRIBUTORS for full list of authors. +from dataclasses import dataclass, field +from datetime import datetime, timezone import json from cps import logger, db from cps.search_metadata import cl as metadata_providers +from cps.utils.shelfmark_import_provenance import select_exact_hardcover_result import sys sys.path.insert(1, '/app/calibre-web-automated/scripts/') from cwa_db import CWA_DB log = logger.create() +ENGLISH_LANGUAGE_CODES = {"en", "eng"} +EBOOK_IMPORT_FORMATS = {"EPUB", "EPUB3", "KEPUB", "AZW", "AZW3", "MOBI"} + + +@dataclass +class ExactHardcoverLookupOutcome: + attempted: bool = False + metadata: object | None = None + existing_identifiers: dict[str, str] = field(default_factory=dict) + match_basis: str | None = None + reason: str | None = None + error: str | None = None + preferred_edition_id: str | None = None + preferred_edition_reason: str | None = None + + +def _normalize_text(value) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _get_book_identifier_map(book) -> dict[str, str]: + identifiers: dict[str, str] = {} + for identifier in getattr(book, 'identifiers', []) or []: + identifier_type = getattr(identifier, 'type', None) + identifier_value = getattr(identifier, 'val', None) + if identifier_type and identifier_value: + identifiers[str(identifier_type)] = str(identifier_value) + return identifiers + + +def _find_metadata_provider(provider_id: str): + for provider in metadata_providers: + if getattr(provider, '__id__', None) == provider_id: + return provider + return None + + +def _metadata_source_id(metadata) -> str | None: + source = getattr(metadata, 'source', None) + return _normalize_text(getattr(source, 'id', None)) + + +def _is_english_language(language_code: str | None) -> bool: + normalized = _normalize_text(language_code) + if normalized is None: + return False + return normalized.lower() in ENGLISH_LANGUAGE_CODES + + +def _get_book_formats(book) -> set[str]: + formats: set[str] = set() + for entry in getattr(book, 'data', []) or []: + book_format = _normalize_text(getattr(entry, 'format', None)) + if book_format is not None: + formats.add(book_format.upper()) + return formats + + +def _get_book_language_codes(book) -> set[str]: + language_codes: set[str] = set() + for language in getattr(book, 'languages', []) or []: + code = _normalize_text(getattr(language, 'lang_code', None)) + if code is not None: + language_codes.add(code.lower()) + return language_codes + + +def _format_language_codes(language_codes: set[str]) -> str: + if not language_codes: + return "" + return ",".join(sorted(language_codes)) + + +def _get_metadata_identifier(metadata, identifier_type: str) -> str | None: + identifiers = getattr(metadata, 'identifiers', None) + if not isinstance(identifiers, dict): + return None + return _normalize_text(identifiers.get(identifier_type)) + + +def _metadata_language_code(metadata) -> str | None: + return _normalize_text(getattr(metadata, 'hardcover_matched_edition_language', None)) + + +def _metadata_is_ebook(metadata) -> bool: + metadata_format = _normalize_text(getattr(metadata, 'format', None)) + if metadata_format is None: + return False + return metadata_format.lower() == 'e-book' + + +def _metadata_matches_language(metadata, language_codes: set[str]) -> bool: + if not language_codes: + return False + metadata_language = _metadata_language_code(metadata) + if metadata_language is None: + return False + return metadata_language.lower() in language_codes + + +def _annotate_exact_hardcover_preferred_edition(metadata, selection_reason: str) -> None: + metadata.hardcover_preferred_edition_id = _get_metadata_identifier(metadata, 'hardcover-edition') + metadata.hardcover_preferred_edition_reason = selection_reason + metadata.hardcover_preferred_edition_language_code = _metadata_language_code(metadata) + + +def _apply_exact_hardcover_book_level_fallback(metadata) -> None: + identifiers = getattr(metadata, 'identifiers', None) + if not isinstance(identifiers, dict): + identifiers = {} + else: + identifiers = dict(identifiers) + + identifiers.pop('hardcover-edition', None) + identifiers.pop('isbn', None) + metadata.identifiers = identifiers + metadata.publisher = "" + metadata.publishedDate = _normalize_text(getattr(metadata, 'hardcover_book_release_date', None)) or "" + metadata.languages = [] + metadata.format = None + metadata.cover = "" + + +def _resolve_preferred_exact_hardcover_metadata(book, results, existing_identifiers): + explicit_result = select_exact_hardcover_result(results, existing_identifiers) + if explicit_result is None: + return None, None + + explicit_edition = existing_identifiers.get('hardcover-edition') + if explicit_edition: + _annotate_exact_hardcover_preferred_edition(explicit_result, 'explicit hardcover-edition') + return explicit_result, 'explicit hardcover-edition' + + preferred_language_codes = _get_book_language_codes(book) + prefers_ebook = bool(_get_book_formats(book) & EBOOK_IMPORT_FORMATS) + book_id = getattr(book, 'id', 'unknown') + imported_language_label = _format_language_codes(preferred_language_codes) + + if prefers_ebook: + default_ebook_edition_id = _normalize_text( + getattr(explicit_result, 'hardcover_default_ebook_edition_id', None) + ) + if default_ebook_edition_id: + for result in results: + if _get_metadata_identifier(result, 'hardcover-edition') == default_ebook_edition_id: + default_ebook_language = _metadata_language_code(result) + if preferred_language_codes: + if _metadata_matches_language(result, preferred_language_codes): + log.info( + "Imported ebook language=%s; accepting default_ebook_edition %s for book_id=%s because it matches imported language", + imported_language_label, + default_ebook_edition_id, + book_id, + ) + _annotate_exact_hardcover_preferred_edition(result, 'default_ebook_edition') + return result, 'default_ebook_edition' + + log.info( + "Imported ebook language=%s; rejecting default_ebook_edition %s for book_id=%s because language=%s does not match imported language", + imported_language_label, + default_ebook_edition_id, + book_id, + default_ebook_language or "", + ) + elif ( + default_ebook_language is None + or _is_english_language(default_ebook_language) + ): + _annotate_exact_hardcover_preferred_edition(result, 'default_ebook_edition') + return result, 'default_ebook_edition' + + ebook_candidates = [result for result in results if _metadata_is_ebook(result)] + for result in ebook_candidates: + if _metadata_matches_language(result, preferred_language_codes): + if preferred_language_codes: + log.info( + "Imported ebook language=%s; preferring matching Hardcover ebook edition %s for book_id=%s", + imported_language_label, + _get_metadata_identifier(result, 'hardcover-edition') or "", + book_id, + ) + _annotate_exact_hardcover_preferred_edition(result, 'language-matching ebook edition') + return result, 'language-matching ebook edition' + if not preferred_language_codes: + for result in ebook_candidates: + if _is_english_language(_metadata_language_code(result)): + _annotate_exact_hardcover_preferred_edition(result, 'english ebook edition search') + return result, 'english ebook edition search' + + if preferred_language_codes and _metadata_matches_language(explicit_result, preferred_language_codes): + _annotate_exact_hardcover_preferred_edition(explicit_result, 'language-matching fallback') + return explicit_result, 'language-matching fallback' + + explicit_result.hardcover_preferred_edition_id = None + explicit_result.hardcover_preferred_edition_reason = 'book-level fallback' + explicit_result.hardcover_preferred_edition_language_code = _metadata_language_code(explicit_result) + _apply_exact_hardcover_book_level_fallback(explicit_result) + return explicit_result, 'book-level fallback' + + +def _choose_exact_hardcover_title(book, metadata, existing_identifiers: dict[str, str]) -> None: + if _metadata_source_id(metadata) != 'hardcover': + return + + book_id = getattr(book, 'id', 'unknown') + provenance_id = existing_identifiers.get('hardcover-id') + provenance_slug = existing_identifiers.get('hardcover-slug') + matched_edition_title = ( + _normalize_text(getattr(metadata, 'hardcover_matched_edition_title', None)) + or _normalize_text(getattr(metadata, 'title', None)) + ) + matched_edition_language = _normalize_text( + getattr(metadata, 'hardcover_matched_edition_language', None) + ) + book_title = _normalize_text(getattr(metadata, 'hardcover_book_title', None)) + default_ebook_title = _normalize_text( + getattr(metadata, 'hardcover_default_ebook_title', None) + ) + default_ebook_language = _normalize_text( + getattr(metadata, 'hardcover_default_ebook_language', None) + ) + default_cover_title = _normalize_text( + getattr(metadata, 'hardcover_default_cover_title', None) + ) + imported_title = _normalize_text(getattr(book, 'title', None)) + imported_language_codes = _get_book_language_codes(book) + preferred_edition_id = _normalize_text( + getattr(metadata, 'hardcover_preferred_edition_id', None) + ) + preferred_edition_reason = _normalize_text( + getattr(metadata, 'hardcover_preferred_edition_reason', None) + ) + + log.info( + "Exact Hardcover provenance for book %s: id=%s slug=%s", + book_id, + provenance_id, + provenance_slug, + ) + log.info( + "Exact Hardcover metadata titles for book %s: book_title=%r default_ebook_title=%r " + "default_ebook_language=%s default_cover_title=%r matched_edition_title=%r " + "matched_edition_language=%s", + book_id, + book_title, + default_ebook_title, + default_ebook_language or "", + default_cover_title, + matched_edition_title, + matched_edition_language or "", + ) + + chosen_title = matched_edition_title + chosen_source = "matched edition title" + + if preferred_edition_id and preferred_edition_reason != 'book-level fallback': + chosen_title = ( + matched_edition_title + or default_ebook_title + or book_title + or imported_title + ) + if chosen_title == default_ebook_title and preferred_edition_reason == 'default_ebook_edition': + chosen_source = "default ebook edition title" + elif chosen_title == matched_edition_title: + chosen_source = "matched edition title" + elif chosen_title == book_title: + chosen_source = "book title" + elif chosen_title == imported_title: + chosen_source = "preserved imported title" + elif existing_identifiers.get('hardcover-edition'): + chosen_title = ( + matched_edition_title + or default_ebook_title + or book_title + or imported_title + ) + if chosen_title == default_ebook_title: + chosen_source = "default ebook edition title" + elif chosen_title == book_title: + chosen_source = "book title" + elif chosen_title == imported_title: + chosen_source = "preserved imported title" + else: + if imported_language_codes: + if default_ebook_title and ( + default_ebook_language is not None + and default_ebook_language.lower() in imported_language_codes + ): + chosen_title = default_ebook_title + chosen_source = "default ebook edition title" + elif matched_edition_title and ( + matched_edition_language is not None + and matched_edition_language.lower() in imported_language_codes + ): + chosen_title = matched_edition_title + chosen_source = "matched edition title" + elif imported_title: + chosen_title = imported_title + chosen_source = "preserved imported title" + elif book_title: + chosen_title = book_title + chosen_source = "book title" + else: + if default_ebook_title and _is_english_language(default_ebook_language): + chosen_title = default_ebook_title + chosen_source = "default ebook edition title" + elif book_title: + chosen_title = book_title + chosen_source = "book title" + elif matched_edition_title and ( + matched_edition_language is None or _is_english_language(matched_edition_language) + ): + chosen_title = matched_edition_title + chosen_source = "matched edition title" + elif imported_title: + chosen_title = imported_title + chosen_source = "preserved imported title" + + if chosen_source == "preserved imported title": + log.info( + "Preserving imported title for book %s; exact Hardcover matched edition title appears language-mismatched", + book_id, + ) + else: + log.info( + "Choosing %s for exact Hardcover metadata on book %s", + chosen_source, + book_id, + ) + + if chosen_title is not None: + metadata.title = chosen_title + metadata.hardcover_title_source = chosen_source + + +def _choose_exact_hardcover_cover(book, metadata) -> None: + if _metadata_source_id(metadata) != 'hardcover': + return + + book_id = getattr(book, 'id', 'unknown') + preferred_edition_id = _normalize_text( + getattr(metadata, 'hardcover_preferred_edition_id', None) + ) + preferred_edition_reason = _normalize_text( + getattr(metadata, 'hardcover_preferred_edition_reason', None) + ) + matched_edition_cover = ( + _normalize_text(getattr(metadata, 'hardcover_matched_edition_cover_url', None)) + or _normalize_text(getattr(metadata, 'cover', None)) + ) + default_ebook_cover = _normalize_text( + getattr(metadata, 'hardcover_default_ebook_cover_url', None) + ) + default_cover = _normalize_text( + getattr(metadata, 'hardcover_default_cover_url', None) + ) + + if preferred_edition_id and preferred_edition_reason != 'book-level fallback': + if matched_edition_cover: + metadata.cover = matched_edition_cover + metadata.hardcover_cover_source = 'chosen edition cover' + log.info( + "Using chosen edition cover for exact Hardcover provenance on book_id=%s", + book_id, + ) + return + if preferred_edition_reason == 'default_ebook_edition' and default_ebook_cover: + metadata.cover = default_ebook_cover + metadata.hardcover_cover_source = 'chosen edition cover' + log.info( + "Using chosen edition cover for exact Hardcover provenance on book_id=%s", + book_id, + ) + return + + if default_cover: + metadata.cover = default_cover + metadata.hardcover_cover_source = 'parent/default cover fallback' + log.info( + "Falling back to parent/default cover because chosen edition cover was unavailable for book_id=%s", + book_id, + ) + return + + metadata.cover = "" + metadata.hardcover_cover_source = 'no cover resolved' + + +def _determine_exact_hardcover_match_basis(metadata, existing_identifiers: dict[str, str]) -> str | None: + metadata_identifiers = getattr(metadata, 'identifiers', None) + if not isinstance(metadata_identifiers, dict): + metadata_identifiers = {} + + hardcover_edition = existing_identifiers.get('hardcover-edition') + if hardcover_edition and metadata_identifiers.get('hardcover-edition') == hardcover_edition: + return 'hardcover-edition' + + hardcover_slug = existing_identifiers.get('hardcover-slug') + if hardcover_slug and metadata_identifiers.get('hardcover-slug') == hardcover_slug: + return 'hardcover-slug' + + hardcover_id = existing_identifiers.get('hardcover-id') + if hardcover_id and metadata_identifiers.get('hardcover-id') == hardcover_id: + return 'hardcover-id' + + return None + + +def _load_cover_update_dependencies(): + from cps import helper as helper_module + from cps.cover_utils import apply_selected_cover_url + + return helper_module, apply_selected_cover_url + + +def _apply_hardcover_cover_to_book(book, metadata) -> bool: + if _metadata_source_id(metadata) != 'hardcover': + return False + + cover_url = _normalize_text(getattr(metadata, 'cover', None)) + book_id = getattr(book, 'id', 'unknown') + cover_source = _normalize_text(getattr(metadata, 'hardcover_cover_source', None)) or 'provider cover' + + if not cover_url: + log.info( + "No safe preferred exact cover found; preserving existing/imported cover for book_id=%s", + book_id, + ) + return False + + try: + helper_module, apply_selected_cover_url = _load_cover_update_dependencies() + except Exception as exc: + log.warning( + "Metadata fetch: cover helpers unavailable for book_id=%s; skipping exact Hardcover cover update: %s", + book_id, + exc, + ) + return False + + log.info( + "Applying exact Hardcover cover during ingest for book_id=%s from %s", + book_id, + cover_source, + ) + outcome = apply_selected_cover_url( + book_id=book_id, + book_path=getattr(book, 'path', ''), + cover_url=cover_url, + logger=log, + save_cover_from_url=helper_module.save_cover_from_url, + refresh_thumbnail_cache=helper_module.replace_cover_thumbnail_cache, + ) + if not outcome.applied: + if outcome.error: + log.warning( + "Metadata fetch: exact Hardcover cover update failed for book_id=%s error=%s", + book_id, + outcome.error, + ) + return False + + if outcome.cleared_cover: + book.has_cover = 0 + else: + book.has_cover = 1 + + log.info( + "Queued cover thumbnail invalidation after exact Hardcover ingest cover update for book_id=%s", + book_id, + ) + return True + + +def _fetch_exact_hardcover_metadata(book, provider_hierarchy, enabled_map): + """Fetch metadata through the exact Hardcover path when stable identifiers exist. + + `hardcover-id` is the lookup key. If the ingest sidecar also attached + `hardcover-edition` or `hardcover-slug`, those identifiers are used to pick the + exact returned Hardcover result rather than falling back to a fuzzy first match. + """ + existing_identifiers = _get_book_identifier_map(book) + hardcover_id = existing_identifiers.get('hardcover-id') + if not hardcover_id: + return ExactHardcoverLookupOutcome( + existing_identifiers=existing_identifiers, + reason='no_provenance', + ) + + if 'hardcover' not in provider_hierarchy: + return ExactHardcoverLookupOutcome( + existing_identifiers=existing_identifiers, + reason='provider_not_configured', + ) + + if not enabled_map.get('hardcover', True): + return ExactHardcoverLookupOutcome( + existing_identifiers=existing_identifiers, + reason='provider_disabled', + ) + + provider = _find_metadata_provider('hardcover') + if not provider or not provider.active: + return ExactHardcoverLookupOutcome( + existing_identifiers=existing_identifiers, + reason='provider_unavailable', + ) + + log.info( + "Metadata fetch: using exact Hardcover lookup for book_id=%s hardcover-id=%s", + getattr(book, 'id', 'unknown'), + hardcover_id, + ) + + hardcover_edition = existing_identifiers.get('hardcover-edition') + if hardcover_edition: + log.info( + "Metadata fetch: preferring Hardcover result matching hardcover-edition=%s for book_id=%s", + hardcover_edition, + getattr(book, 'id', 'unknown'), + ) + else: + hardcover_slug = existing_identifiers.get('hardcover-slug') + if hardcover_slug: + log.info( + "Metadata fetch: preferring Hardcover result matching hardcover-slug=%s for book_id=%s", + hardcover_slug, + getattr(book, 'id', 'unknown'), + ) + + try: + results = provider.search(f"hardcover-id:{hardcover_id}", "", "en") or [] + except Exception as e: + return ExactHardcoverLookupOutcome( + attempted=True, + existing_identifiers=existing_identifiers, + reason='lookup_error', + error=str(e), + ) + + if not results: + return ExactHardcoverLookupOutcome( + attempted=True, + existing_identifiers=existing_identifiers, + reason='no_results', + ) + + metadata, preferred_edition_reason = _resolve_preferred_exact_hardcover_metadata( + book, + results, + existing_identifiers, + ) + if metadata is None: + return ExactHardcoverLookupOutcome( + attempted=True, + existing_identifiers=existing_identifiers, + reason='no_exact_match', + ) + + return ExactHardcoverLookupOutcome( + attempted=True, + metadata=metadata, + existing_identifiers=existing_identifiers, + match_basis=_determine_exact_hardcover_match_basis(metadata, existing_identifiers), + preferred_edition_id=_normalize_text(getattr(metadata, 'hardcover_preferred_edition_id', None)), + preferred_edition_reason=preferred_edition_reason, + ) def fetch_and_apply_metadata(book_id: int, user_enabled: bool = False) -> bool: """ @@ -46,7 +620,7 @@ def fetch_and_apply_metadata(book_id: int, user_enabled: bool = False) -> bool: log.error(f"Book with ID {book_id} not found") return False - # Create search query from book title and author + # Create fuzzy fallback search query from book title and author. search_query = book.title if book.authors: author_names = [author.name for author in book.authors] @@ -64,9 +638,133 @@ def fetch_and_apply_metadata(book_id: int, user_enabled: bool = False) -> bool: enabled_map = _parse_metadata_providers_enabled( cwa_settings.get('metadata_providers_enabled', '{}') ) - - # Try each provider in order + metadata_found = False + + exact_hardcover_lookup = _fetch_exact_hardcover_metadata( + book, + provider_hierarchy=provider_hierarchy, + enabled_map=enabled_map, + ) + exact_identifiers = exact_hardcover_lookup.existing_identifiers + if exact_hardcover_lookup.reason == 'no_provenance': + log.info( + "Metadata fetch: no exact Hardcover provenance present for book_id=%s; falling back to fuzzy lookup", + book.id, + ) + elif exact_hardcover_lookup.reason == 'provider_not_configured': + log.info( + "Metadata fetch: exact Hardcover provenance present for book_id=%s hardcover-id=%s, " + "but Hardcover is not in the metadata provider hierarchy; falling back to fuzzy lookup", + book.id, + exact_identifiers.get('hardcover-id'), + ) + elif exact_hardcover_lookup.reason == 'provider_disabled': + log.info( + "Metadata fetch: exact Hardcover provenance present for book_id=%s hardcover-id=%s, " + "but the Hardcover provider is disabled; falling back to fuzzy lookup", + book.id, + exact_identifiers.get('hardcover-id'), + ) + elif exact_hardcover_lookup.reason == 'provider_unavailable': + log.info( + "Metadata fetch: exact Hardcover provenance present for book_id=%s hardcover-id=%s, " + "but the Hardcover provider is unavailable; falling back to fuzzy lookup", + book.id, + exact_identifiers.get('hardcover-id'), + ) + elif exact_hardcover_lookup.reason == 'lookup_error': + log.warning( + "Metadata fetch: exact Hardcover lookup failed for book_id=%s hardcover-id=%s: %s", + book.id, + exact_identifiers.get('hardcover-id'), + exact_hardcover_lookup.error, + ) + log.info( + "Metadata fetch: falling back to fuzzy lookup for book_id=%s after exact Hardcover lookup failure", + book.id, + ) + elif exact_hardcover_lookup.reason == 'no_results': + log.info( + "Metadata fetch: exact Hardcover lookup returned no results for book_id=%s hardcover-id=%s; " + "falling back to fuzzy lookup", + book.id, + exact_identifiers.get('hardcover-id'), + ) + elif exact_hardcover_lookup.reason == 'no_exact_match': + log.info( + "Metadata fetch: exact Hardcover lookup found no matching result for book_id=%s hardcover-id=%s; " + "falling back to fuzzy lookup", + book.id, + exact_identifiers.get('hardcover-id'), + ) + + if exact_hardcover_lookup.metadata is not None: + if exact_hardcover_lookup.match_basis == 'hardcover-edition': + log.info( + "Metadata fetch: exact Hardcover result matched via hardcover-edition=%s for book_id=%s", + exact_identifiers.get('hardcover-edition'), + book.id, + ) + elif exact_hardcover_lookup.match_basis == 'hardcover-slug': + log.info( + "Metadata fetch: exact Hardcover result matched via hardcover-slug=%s for book_id=%s", + exact_identifiers.get('hardcover-slug'), + book.id, + ) + elif exact_hardcover_lookup.match_basis == 'hardcover-id': + log.info( + "Metadata fetch: exact Hardcover result matched via hardcover-id=%s for book_id=%s", + exact_identifiers.get('hardcover-id'), + book.id, + ) + + preferred_edition_reason = exact_hardcover_lookup.preferred_edition_reason + preferred_edition_id = exact_hardcover_lookup.preferred_edition_id + if preferred_edition_id and preferred_edition_reason: + log.info( + "Resolved preferred exact Hardcover edition %s for book_id=%s from %s", + preferred_edition_id, + book.id, + preferred_edition_reason, + ) + log.info( + "Using edition-level metadata from Hardcover edition %s for title/isbn/language/publisher/pages/release date", + preferred_edition_id, + ) + else: + log.info( + "No safe preferred exact Hardcover edition was resolved for book_id=%s; falling back to book-level title safety", + book.id, + ) + log.info( + "Using parent book metadata for slug/series/description on book_id=%s", + book.id, + ) + + _choose_exact_hardcover_title( + book, + exact_hardcover_lookup.metadata, + exact_identifiers, + ) + _choose_exact_hardcover_cover(book, exact_hardcover_lookup.metadata) + + if _apply_metadata_to_book(book, exact_hardcover_lookup.metadata, calibre_db_instance): + log.info(f"Successfully applied exact Hardcover metadata for book: {book.title}") + metadata_found = True + else: + log.info( + "Metadata fetch: exact Hardcover metadata resolved for book_id=%s but produced no applied changes; " + "not falling back to fuzzy lookup because exact provenance is authoritative", + book.id, + ) + calibre_db_instance.session.close() + return False + + if metadata_found: + calibre_db_instance.session.close() + return True + for provider_id in provider_hierarchy: # Check if explicitly disabled (default is enabled if not specified) is_enabled = enabled_map.get(provider_id, True) @@ -75,11 +773,7 @@ def fetch_and_apply_metadata(book_id: int, user_enabled: bool = False) -> bool: continue try: # Find the provider - provider = None - for p in metadata_providers: - if p.__id__ == provider_id: - provider = p - break + provider = _find_metadata_provider(provider_id) if not provider or not provider.active: continue @@ -131,6 +825,7 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool: use_smart_application = cwa_settings.get('auto_metadata_smart_application', False) updated = False + cover_updated = False # Update title - only if enabled in settings if (cwa_settings.get('auto_metadata_update_title', True) and @@ -235,7 +930,6 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool: if (cwa_settings.get('auto_metadata_update_published_date', True) and hasattr(metadata, 'publishedDate') and metadata.publishedDate): try: - from datetime import datetime if isinstance(metadata.publishedDate, str): # Try to parse various date formats for fmt in ['%Y-%m-%d', '%Y-%m', '%Y']: @@ -272,29 +966,44 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool: hasattr(metadata, 'identifiers') and metadata.identifiers): for identifier_type, identifier_value in metadata.identifiers.items(): if identifier_type and identifier_value: + persisted_identifier = False # Check if identifier already exists existing = False for identifier in book.identifiers: if identifier.type == identifier_type: identifier.val = identifier_value existing = True + persisted_identifier = True break if not existing: new_identifier = db.Identifiers(identifier_value, identifier_type, book.id) calibre_db_instance.session.add(new_identifier) book.identifiers.append(new_identifier) + persisted_identifier = True + if ( + persisted_identifier + and identifier_type == 'hardcover-edition' + and _metadata_source_id(metadata) == 'hardcover' + and _normalize_text(getattr(metadata, 'hardcover_preferred_edition_id', None)) + ): + log.info( + "Persisted hardcover-edition=%s after exact Hardcover provenance resolution for book_id=%s", + identifier_value, + getattr(book, 'id', 'unknown'), + ) updated = True - # Handle cover image - only if enabled in settings - if (cwa_settings.get('auto_metadata_update_cover', True) and - hasattr(metadata, 'cover') and metadata.cover): - # TODO: Implement cover resolution checking for smart mode - # For now, just apply the cover in normal mode - if not use_smart_application: - # Apply cover (implementation depends on how covers are handled in Calibre-Web) - pass + # Handle cover image for exact Hardcover metadata - only if enabled in settings + if cwa_settings.get('auto_metadata_update_cover', True): + cover_updated = _apply_hardcover_cover_to_book(book, metadata) + updated |= cover_updated if updated: + if cover_updated and hasattr(book, 'last_modified'): + book.last_modified = datetime.now(timezone.utc) + set_dirty = getattr(calibre_db_instance, 'set_metadata_dirty', None) + if callable(set_dirty): + set_dirty(getattr(book, 'id', None)) calibre_db_instance.session.commit() return updated diff --git a/cps/metadata_provider/hardcover.py b/cps/metadata_provider/hardcover.py index 772679ba7..f1d3ee212 100644 --- a/cps/metadata_provider/hardcover.py +++ b/cps/metadata_provider/hardcover.py @@ -134,11 +134,21 @@ class Hardcover(Metadata): EDITION_QUERY = ( "query BookEditions($query: Int!) { " "books(where: {id: {_eq: $query}}) { " - " id slug description " + " id title subtitle slug description release_date " " book_series { position series { name } } " " cached_tags(path: \"Genre\") " + " default_cover_edition { " + " id title subtitle " + " image { url } " + " language { code3 } " + " } " + " default_ebook_edition { " + " id title subtitle " + " image { url } " + " language { code3 } " + " } " " editions { " - " id title release_date isbn_13 isbn_10 reading_format_id " + " id title subtitle release_date pages isbn_13 isbn_10 reading_format_id " " image { url } " " language { code3 } " " publisher { name } " @@ -267,6 +277,10 @@ def _parse_edition_results( ) -> List[MetaRecord]: editions: List[MetaRecord] = [] book_id = result.get("id", "") + book_title = result.get("title", "") + book_subtitle = result.get("subtitle", "") + default_cover_edition = result.get("default_cover_edition") or {} + default_ebook_edition = result.get("default_ebook_edition") or {} for edition in result.get("editions", []) or []: match = MetaRecord( id=book_id, @@ -300,6 +314,37 @@ def _parse_edition_results( match.format = Hardcover.FORMATS[rf_id] else: match.format = "" + # Carry exact-title context forward so metadata_helper can choose a stable + # canonical title when only book-level provenance is known. + match.hardcover_book_title = book_title + match.hardcover_book_subtitle = book_subtitle + match.hardcover_book_release_date = result.get("release_date", "") + match.hardcover_default_cover_title = default_cover_edition.get("title", "") + match.hardcover_default_cover_edition_id = default_cover_edition.get("id", "") + match.hardcover_default_cover_url = self._safe_get( + default_cover_edition, "image", "url", default="" + ) + match.hardcover_default_cover_language = self._safe_get( + default_cover_edition, "language", "code3", default="" + ) + match.hardcover_default_ebook_title = default_ebook_edition.get("title", "") + match.hardcover_default_ebook_edition_id = default_ebook_edition.get("id", "") + match.hardcover_default_ebook_cover_url = self._safe_get( + default_ebook_edition, "image", "url", default="" + ) + match.hardcover_default_ebook_language = self._safe_get( + default_ebook_edition, "language", "code3", default="" + ) + match.hardcover_matched_edition_title = edition.get("title", "") + match.hardcover_matched_edition_subtitle = edition.get("subtitle", "") + match.hardcover_matched_edition_cover_url = (edition.get("image") or {}).get("url", "") + match.hardcover_matched_edition_language = self._safe_get( + edition, "language", "code3", default="" + ) + match.hardcover_matched_edition_pages = edition.get("pages") + match.hardcover_matched_edition_release_date = edition.get("release_date", "") + match.hardcover_matched_edition_publisher = (edition.get("publisher") or {}).get("name", "") + match.hardcover_matched_edition_format = match.format editions.append(match) return editions @@ -554,4 +599,4 @@ def _to_dict(obj): return {k: _to_dict(v) for k, v in obj.items()} return obj - print(json.dumps([_to_dict(r) for r in results], ensure_ascii=False, indent=2)) \ No newline at end of file + print(json.dumps([_to_dict(r) for r in results], ensure_ascii=False, indent=2)) diff --git a/cps/render_template.py b/cps/render_template.py index 17e0ce10d..3c78b01f7 100644 --- a/cps/render_template.py +++ b/cps/render_template.py @@ -5,7 +5,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later # See CONTRIBUTORS for full list of authors. -from flask import render_template, g, abort, request, flash, current_app +from flask import render_template, g, abort, request, flash, current_app, url_for from flask_babel import gettext as _ from flask_babel import get_locale import polib @@ -67,6 +67,69 @@ def get_sidebar_config(kwargs=None): sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand", "visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover", "show_text": _('Show Random Books'), "config_show": True}) + sidebar.append({ + "kind": "heading", + "text": _('Requests'), + "id": "requests-heading", + "section": "requests", + "visibility": constants.SIDEBAR_REQUESTS, + 'public': (not current_user.is_anonymous), + "page": "requests", + "show_text": _('Show Requests workspace'), + "config_show": True, + }) + sidebar.append({ + "glyph": "glyphicon-bookmark", + "text": _('Series'), + "link": 'web.requests_workspace_view', + "href": url_for('web.requests_workspace_view', view_name='series'), + "id": "requests-series", + "section": "requests", + "visibility": constants.SIDEBAR_REQUESTS, + 'public': (not current_user.is_anonymous), + "page": "requests-series", + "show_text": _('Show Requests workspace'), + "config_show": False, + }) + sidebar.append({ + "glyph": "glyphicon-user", + "text": _('Authors'), + "link": 'web.requests_workspace_view', + "href": url_for('web.requests_workspace_view', view_name='authors'), + "id": "requests-authors", + "section": "requests", + "visibility": constants.SIDEBAR_REQUESTS, + 'public': (not current_user.is_anonymous), + "page": "requests-authors", + "show_text": _('Show Requests workspace'), + "config_show": False, + }) + sidebar.append({ + "glyph": "glyphicon-fire", + "text": _('Hot'), + "link": 'web.requests_workspace_view', + "href": url_for('web.requests_workspace_view', view_name='hot'), + "id": "requests-hot", + "section": "requests", + "visibility": constants.SIDEBAR_REQUESTS, + 'public': (not current_user.is_anonymous), + "page": "requests-hot", + "show_text": _('Show Requests workspace'), + "config_show": False, + }) + sidebar.append({ + "glyph": "glyphicon-time", + "text": _('New'), + "link": 'web.requests_workspace_view', + "href": url_for('web.requests_workspace_view', view_name='new'), + "id": "requests-new", + "section": "requests", + "visibility": constants.SIDEBAR_REQUESTS, + 'public': (not current_user.is_anonymous), + "page": "requests-new", + "show_text": _('Show Requests workspace'), + "config_show": False, + }) sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat", "visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category", "show_text": _('Show Category Section'), "config_show": True}) @@ -109,6 +172,28 @@ def get_sidebar_config(kwargs=None): return sidebar, simple + +def build_sidebar_sections(sidebar): + browse_section = {"id": "browse", "title": _('Browse'), "entries": []} + requests_section = {"id": "requests", "title": _('Requests'), "entries": []} + + for element in sidebar: + if element.get("kind") == "heading": + if element.get("section") == "requests": + requests_section["title"] = _(element["text"]) + continue + + if not current_user.check_visibility(element['visibility']) or not element['public']: + continue + + target = requests_section if element.get("section") == "requests" else browse_section + target["entries"].append(element) + + sections = [browse_section] + if requests_section["entries"]: + sections.append(requests_section) + return tuple(section for section in sections if section["entries"]) + # Checks if an update for CWA is available, returning True if yes def cwa_update_available() -> tuple[bool, str, str]: try: @@ -230,6 +315,7 @@ def translations_missing_notification() -> None: # Returns the template for rendering and includes the instance name def render_title_template(*args, **kwargs): sidebar, simple = get_sidebar_config(kwargs) + sidebar_sections = build_sidebar_sections(sidebar) try: magic_shelf_routes = { "render": 'web.render_magic_shelf' in current_app.view_functions, @@ -308,7 +394,7 @@ def render_title_template(*args, **kwargs): except Exception as e: log.debug("[cwa-duplicates] Failed to build duplicate notification context: %s", str(e)) try: - return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple, + return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, sidebar_sections=sidebar_sections, simple=simple, accept=config.config_upload_formats.split(','), magic_shelf_routes=magic_shelf_routes, duplicate_notification=duplicate_notification, diff --git a/cps/search.py b/cps/search.py index f5dda4192..169093b3f 100755 --- a/cps/search.py +++ b/cps/search.py @@ -7,7 +7,7 @@ import json from datetime import datetime -from flask import Blueprint, request, redirect, url_for, flash +from flask import Blueprint, request, redirect, render_template, url_for, flash, jsonify from flask import session as flask_session from .cw_login import current_user from flask_babel import format_date @@ -20,13 +20,35 @@ from .usermanagement import login_required_if_no_ano from .render_template import render_title_template from .pagination import Pagination +from .shelfmark_ui import ( + build_search_page_shelfmark_section, + build_shelfmark_detail_url, + build_shelfmark_row_response, + build_shelfmark_top_up_rows, + current_shelfmark_state_url, + decorate_shelfmark_result_rows, + requested_shelfmark_flag, + requested_shelfmark_page_size, + requested_shelfmark_sort, + safe_local_return_url, +) +from .services.shelfmark_search import ( + DEFAULT_SHELFMARK_FILTER_HAS_COVER, + DEFAULT_SHELFMARK_FILTER_REQUESTABLE, + DEFAULT_SHELFMARK_SERIES_FILTER, + ShelfmarkIntegrationError, + build_shelfmark_advanced_query, + fetch_shelfmark_detail, + get_shelfmark_preferred_release_settings, + lookup_visible_library_matches, + search_shelfmark_results, +) search = Blueprint('search', __name__) log = logger.create() - @search.route("/search", methods=["GET"]) @login_required_if_no_ano def simple_search(): @@ -376,10 +398,22 @@ def render_adv_search_results(term, offset=None, order=None, limit=None): ub.store_combo_ids(results) entries = calibre_db.order_authors(results, list_return=True, combined=True) + shelfmark_query, _shelfmark_fields = build_shelfmark_advanced_query(term) + shelfmark_section = build_search_page_shelfmark_section( + shelfmark_query, + detail_url_builder=lambda book: build_shelfmark_detail_url( + book, + query=shelfmark_query or search_term, + return_to=current_shelfmark_state_url(), + ), + empty_message=_("Add a title, author, or publisher filter to include Shelfmark external results in advanced search."), + ) return render_title_template('search.html', adv_searchterm=search_term, pagination=pagination, entries=entries, + query="", + shelfmark_section=shelfmark_section, result_count=result_count, title=_("Advanced Search"), page="advsearch", order=order[1]) @@ -417,6 +451,7 @@ def render_prepare_search_form(cc): def render_search_results(term, offset=None, order=None, limit=None): + shelfmark_section = None if term: join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series entries, result_count, pagination = calibre_db.get_search_results(term, @@ -425,6 +460,14 @@ def render_search_results(term, offset=None, order=None, limit=None): order, limit, *join) + shelfmark_section = build_search_page_shelfmark_section( + term, + detail_url_builder=lambda book: build_shelfmark_detail_url( + book, + query=term, + return_to=current_shelfmark_state_url(), + ), + ) else: entries = list() order = [None, None] @@ -436,9 +479,229 @@ def render_search_results(term, offset=None, order=None, limit=None): query=term, adv_searchterm=term, entries=entries, + shelfmark_section=shelfmark_section, result_count=result_count, title=_("Search"), page="search", order=order[1]) +@search.route("/search/external/shelfmark//", methods=["GET"]) +@login_required_if_no_ano +def shelfmark_external_detail(provider, provider_id): + query = (request.args.get("query") or "").strip() + return_to = safe_local_return_url(request.args.get("return_to")) + modal_view = (request.args.get("view") or "").strip().lower() == "modal" + preferred_release = get_shelfmark_preferred_release_settings().to_template_dict() + detail_url = url_for( + "search.shelfmark_external_detail", + provider=provider, + provider_id=provider_id, + query=query, + return_to=return_to, + ) + + try: + result = fetch_shelfmark_detail( + provider, + provider_id, + detail_url=detail_url, + ).to_template_dict() + if modal_view: + return render_template( + "shelfmark_external_detail_content.html", + result=result, + modal_mode=True, + preferred_release=preferred_release, + shelfmark_error=None, + ) + return render_title_template( + "shelfmark_external_detail.html", + title=result.get("title") or _("Shelfmark External Result"), + page="search", + result=result, + search_query=query, + return_to=return_to, + modal_mode=False, + preferred_release=preferred_release, + ) + except ShelfmarkIntegrationError as exc: + flash(str(exc), category="error") + if modal_view: + return render_template( + "shelfmark_external_detail_content.html", + title=_("Shelfmark External Result"), + result=None, + modal_mode=True, + preferred_release=preferred_release, + shelfmark_error=str(exc), + ) + return render_title_template( + "shelfmark_external_detail.html", + title=_("Shelfmark External Result"), + page="search", + result=None, + search_query=query, + return_to=return_to, + shelfmark_error=str(exc), + modal_mode=False, + preferred_release=preferred_release, + ) + + +@search.route("/search/external/shelfmark///row", methods=["GET"]) +@login_required_if_no_ano +def shelfmark_external_row(provider, provider_id): + query = (request.args.get("query") or "").strip() + return_to = safe_local_return_url(request.args.get("return_to")) + preferred_release = get_shelfmark_preferred_release_settings().to_template_dict() + detail_url = url_for( + "search.shelfmark_external_detail", + provider=provider, + provider_id=provider_id, + query=query, + return_to=return_to, + ) + + try: + result_view = fetch_shelfmark_detail( + provider, + provider_id, + detail_url=detail_url, + ) + payload = build_shelfmark_row_response( + result_view, + preferred_release=preferred_release, + requestable_only=requested_shelfmark_flag( + "shelfmark_filter_requestable", + default=DEFAULT_SHELFMARK_FILTER_REQUESTABLE, + ), + has_cover_only=requested_shelfmark_flag( + "shelfmark_filter_has_cover", + default=DEFAULT_SHELFMARK_FILTER_HAS_COVER, + ), + ) + return jsonify( + { + "ok": payload["ok"], + "matches_filters": payload["matches_filters"], + "row_class_name": payload["row_class_name"], + "row_status_provider": payload["row_status_provider"], + "row_status_provider_id": payload["row_status_provider_id"], + "row_status_in_library": payload["row_status_in_library"], + "row_has_cover": payload["row_has_cover"], + "library_book_url": payload.get("library_book_url"), + "library_book_title": payload.get("library_book_title"), + "html": render_template( + "shelfmark_external_result_card_inner.html", + result=payload["result"], + preferred_release=payload["preferred_release"], + ), + } + ) + except ShelfmarkIntegrationError as exc: + return jsonify({"ok": False, "message": str(exc)}), 503 + + +@search.route("/search/external/shelfmark/topup", methods=["GET"]) +@login_required_if_no_ano +def shelfmark_external_topup(): + query = (request.args.get("query") or "").strip() + return_to = safe_local_return_url(request.args.get("return_to")) + preferred_release = get_shelfmark_preferred_release_settings().to_template_dict() + try: + source_page = int(request.args.get("shelfmark_source_page", "1")) + except (TypeError, ValueError): + source_page = 1 + if source_page < 1: + source_page = 1 + + try: + section = search_shelfmark_results( + query, + detail_url_builder=lambda book: build_shelfmark_detail_url( + book, + query=query, + return_to=return_to, + ), + page=source_page, + page_size=requested_shelfmark_page_size(), + sort=requested_shelfmark_sort(), + series_filter=DEFAULT_SHELFMARK_SERIES_FILTER, + filter_requestable=requested_shelfmark_flag( + "shelfmark_filter_requestable", + default=DEFAULT_SHELFMARK_FILTER_REQUESTABLE, + ), + filter_has_cover=requested_shelfmark_flag( + "shelfmark_filter_has_cover", + default=DEFAULT_SHELFMARK_FILTER_HAS_COVER, + ), + ).to_template_dict() + section["state_url"] = return_to + decorate_shelfmark_result_rows( + section, + query=query, + enable_progressive_enrichment=True, + ) + rows = [] + for row in build_shelfmark_top_up_rows(section, preferred_release=preferred_release): + rows.append( + { + "provider": row["provider"], + "provider_id": row["provider_id"], + "row_class_name": row["row_class_name"], + "row_status_provider": row["row_status_provider"], + "row_status_provider_id": row["row_status_provider_id"], + "row_status_in_library": row["row_status_in_library"], + "row_has_cover": row["row_has_cover"], + "library_book_url": row.get("library_book_url"), + "library_book_title": row.get("library_book_title"), + "row_enrichment_url": row.get("row_enrichment_url") or "", + "html": render_template( + "shelfmark_external_result_card_inner.html", + result=row["result"], + preferred_release=row["preferred_release"], + ), + } + ) + return jsonify( + { + "ok": True, + "page": source_page, + "has_more": bool(section.get("has_more")), + "next_page": section.get("next_page"), + "rows": rows, + } + ) + except ShelfmarkIntegrationError as exc: + return jsonify({"ok": False, "message": str(exc)}), 503 + + +@search.route("/search/external/shelfmark/library-status", methods=["GET"]) +@login_required_if_no_ano +def shelfmark_external_library_status(): + provider_ids = [] + seen_provider_ids = set() + for raw_value in request.args.getlist("provider_id"): + normalized = (raw_value or "").strip() + if not normalized or normalized in seen_provider_ids: + continue + seen_provider_ids.add(normalized) + provider_ids.append(normalized) + + matches = lookup_visible_library_matches(provider_ids) + payload = {} + for provider_id in provider_ids: + library_match = matches.get(provider_id) + payload[provider_id] = { + "in_library": bool(library_match), + "book_id": library_match.book_id if library_match is not None else None, + "book_title": library_match.title if library_match is not None else None, + "book_url": ( + url_for("web.show_book", book_id=library_match.book_id) + if library_match is not None + else None + ), + } + + return jsonify({"ok": True, "matches": payload}) diff --git a/cps/services/hardcover.py b/cps/services/hardcover.py index 7cad305d2..aac1772e7 100644 --- a/cps/services/hardcover.py +++ b/cps/services/hardcover.py @@ -15,10 +15,14 @@ - Update book status (Want to Read, Reading, Read) """ -from datetime import datetime +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from os import getenv import requests -from .. import logger +from .. import config, logger +from ..clean_html import clean_string +from ..cw_login import current_user log = logger.create() @@ -57,6 +61,253 @@ } }""" +BOOK_BY_ID_QUERY = """ + query HardcoverBookById($id: Int!) { + books_by_pk(id: $id) { + id + title + subtitle + slug + description + release_date + pages + rating + ratings_count + reviews_count + users_count + editions_count + lists_count + cached_image + default_cover_edition { + id + title + subtitle + release_date + pages + edition_format + physical_format + audio_seconds + cached_image + } + featured_book_series { + featured + position + series { + name + slug + primary_books_count + } + } + book_series { + featured + position + series { + name + slug + primary_books_count + } + } + default_ebook_edition { + id + title + subtitle + release_date + pages + edition_format + physical_format + audio_seconds + cached_image + } + default_physical_edition { + id + title + subtitle + release_date + pages + edition_format + physical_format + audio_seconds + cached_image + } + default_audio_edition { + id + title + subtitle + release_date + pages + edition_format + physical_format + audio_seconds + cached_image + } + editions { + id + title + subtitle + release_date + pages + edition_format + physical_format + audio_seconds + cached_image + } + taggings { + spoiler + tag { + tag + slug + tag_category { + category + slug + } + } + } + } +}""" + + +BOOK_LIST_FRAGMENT = """ + fragment hardcoverBookListFields on books { + id + title + subtitle + slug + description + release_date + pages + rating + ratings_count + reviews_count + users_count + editions_count + lists_count + cached_image + contributions( + where: {contribution: {_in: ["Author", "author"]}} + order_by: {id: asc} + ) { + contribution + author { + id + name + slug + } + } + featured_book_series { + featured + position + series { + name + slug + primary_books_count + } + } + book_series { + featured + position + series { + name + slug + primary_books_count + } + } + default_cover_edition { + id + title + subtitle + release_date + pages + edition_format + physical_format + audio_seconds + cached_image + } + default_ebook_edition { + id + title + subtitle + release_date + pages + edition_format + physical_format + audio_seconds + cached_image + } + default_physical_edition { + id + title + subtitle + release_date + pages + edition_format + physical_format + audio_seconds + cached_image + } + default_audio_edition { + id + title + subtitle + release_date + pages + edition_format + physical_format + audio_seconds + cached_image + } + taggings { + spoiler + tag { + tag + slug + tag_category { + category + slug + } + } + } + } +""" + + +BOOKS_BY_IDS_QUERY = ( + """ + query HardcoverBooksByIds($ids: [Int!]) { + books(where: {id: {_in: $ids}}) { + ...hardcoverBookListFields + } + } +""" + + BOOK_LIST_FRAGMENT +) + + +POPULAR_BOOKS_QUERY = ( + """ + query HardcoverPopularBooks($limit: Int!, $offset: Int!) { + books( + limit: $limit + offset: $offset + order_by: {users_count: desc, ratings_count: desc, rating: desc} + where: {canonical: {_eq: true}} + ) { + ...hardcoverBookListFields + } + } +""" + + BOOK_LIST_FRAGMENT +) + + +TRENDING_BOOK_IDS_QUERY = """ + query HardcoverTrendingBooks($from: date!, $to: date!, $limit: Int!, $offset: Int!) { + books_trending(from: $from, to: $to, limit: $limit, offset: $offset) { + error + ids + } + } +""" + def escape_markdown(text): """Escape markdown special characters to prevent injection. @@ -80,8 +331,41 @@ class MissingHardcoverToken(Exception): pass +@dataclass(frozen=True) +class HardcoverAuthorProfile: + id: int + name: str + slug: str | None = None + bio: str | None = None + safe_about: str | None = None + image_url: str | None = None + link: str | None = None + + +AUTHOR_SEARCH_QUERY = """ + query SearchAuthors($query: String!) { + search(query: $query, query_type: "Author", per_page: 5) { + results + } + } +""" + + +AUTHOR_BY_ID_QUERY = """ + query HardcoverAuthorById($id: Int!) { + authors_by_pk(id: $id) { + id + name + slug + bio + cached_image + } + } +""" + + class HardcoverClient: - def __init__(self, token: str): + def __init__(self, token: str, *, load_privacy: bool = True): if not token: raise MissingHardcoverToken("Hardcover API token is required") self.endpoint = GRAPHQL_ENDPOINT @@ -89,11 +373,13 @@ def __init__(self, token: str): "Content-Type": "application/json", "Authorization": f"Bearer {token}", } - try: - self.privacy = self.get_privacy() - except Exception as e: - log.error(f"Error fetching Hardcover account privacy setting: {e}") - raise + self.privacy = None + if load_privacy: + try: + self.privacy = self.get_privacy() + except Exception as e: + log.error(f"Error fetching Hardcover account privacy setting: {e}") + raise def get_privacy(self): query = """ @@ -148,6 +434,182 @@ def get_user_book(self, ids): response = self.execute(query, variables) return next(iter(response.get("me")[0].get("user_books")), None) + def get_book_by_id(self, hardcover_id): + if hardcover_id is None: + return None + response = self.execute(BOOK_BY_ID_QUERY, {"id": int(hardcover_id)}) + book = response.get("books_by_pk") + return book if isinstance(book, dict) else None + + @staticmethod + def _normalize_author_name(value: str | None) -> str: + if not value: + return "" + return " ".join(str(value).strip().lower().replace("|", ",").split()) + + @staticmethod + def _resolve_cached_image_url(value) -> str | None: + if not value: + return None + if isinstance(value, str): + candidate = value.strip() + return candidate or None + if isinstance(value, dict): + for key in ("url", "secure_url", "original_url", "src"): + candidate = value.get(key) + if isinstance(candidate, str) and candidate.strip(): + return candidate.strip() + return None + + @classmethod + def _build_author_profile_from_payload( + cls, + author: dict | None, + *, + fallback_name: str | None = None, + ) -> HardcoverAuthorProfile | None: + if not isinstance(author, dict): + return None + author_id = author.get("id") + resolved_name = author.get("name") or fallback_name + if author_id in (None, "") or not resolved_name: + return None + bio = author.get("bio") + image_url = cls._resolve_cached_image_url(author.get("cached_image")) + slug = author.get("slug") + safe_about = clean_string(bio) if bio else None + return HardcoverAuthorProfile( + id=int(author_id), + name=resolved_name, + slug=slug, + bio=bio, + safe_about=safe_about, + image_url=image_url, + link=f"https://hardcover.app/authors/{slug}" if slug else None, + ) + + def _search_author_documents(self, author_name: str) -> list[dict]: + response = self.execute(AUTHOR_SEARCH_QUERY, {"query": author_name}) + raw_results = response.get("search", {}).get("results", []) + if isinstance(raw_results, str): + import json as _json + try: + raw_results = _json.loads(raw_results) + except Exception: + raw_results = [] + hits = raw_results.get("hits", []) if isinstance(raw_results, dict) else [] + return [ + hit.get("document") + for hit in hits + if isinstance(hit, dict) and isinstance(hit.get("document"), dict) + ] + + def _select_author_document(self, author_name: str) -> dict | None: + normalized_query = self._normalize_author_name(author_name) + documents = self._search_author_documents(author_name) + if not documents: + return None + + exact_match = next( + ( + document + for document in documents + if self._normalize_author_name(document.get("name")) == normalized_query + ), + None, + ) + if exact_match: + return exact_match + + token_match = next( + ( + document + for document in documents + if normalized_query and normalized_query in self._normalize_author_name(document.get("name")) + ), + None, + ) + if token_match: + return token_match + + return documents[0] + + def get_author_by_id(self, author_id: int | str | None) -> dict | None: + if author_id in (None, ""): + return None + response = self.execute(AUTHOR_BY_ID_QUERY, {"id": int(author_id)}) + author = response.get("authors_by_pk") + return author if isinstance(author, dict) else None + + def get_author_profile_by_id(self, author_id: int | str | None) -> HardcoverAuthorProfile | None: + author = self.get_author_by_id(author_id) + return self._build_author_profile_from_payload(author) + + def list_books_by_ids(self, book_ids: list[int] | tuple[int, ...]) -> list[dict]: + normalized_ids = [int(book_id) for book_id in (book_ids or []) if str(book_id).strip()] + if not normalized_ids: + return [] + response = self.execute(BOOKS_BY_IDS_QUERY, {"ids": normalized_ids}) + books = response.get("books") + if not isinstance(books, list): + return [] + order = {book_id: index for index, book_id in enumerate(normalized_ids)} + normalized_books = [book for book in books if isinstance(book, dict)] + normalized_books.sort(key=lambda book: order.get(int(book.get("id") or 0), len(order))) + return normalized_books + + def list_popular_books(self, *, limit: int = 12, offset: int = 0) -> list[dict]: + response = self.execute( + POPULAR_BOOKS_QUERY, + { + "limit": max(1, int(limit or 12)), + "offset": max(0, int(offset or 0)), + }, + ) + books = response.get("books") + return [book for book in books if isinstance(book, dict)] if isinstance(books, list) else [] + + def list_trending_books( + self, + *, + limit: int = 12, + offset: int = 0, + from_date: date | None = None, + to_date: date | None = None, + ) -> list[dict]: + window_end = to_date or date.today() + window_start = from_date or (window_end - timedelta(days=30)) + response = self.execute( + TRENDING_BOOK_IDS_QUERY, + { + "from": window_start.isoformat(), + "to": window_end.isoformat(), + "limit": max(1, int(limit or 12)), + "offset": max(0, int(offset or 0)), + }, + ) + payload = response.get("books_trending") or {} + if not isinstance(payload, dict): + return [] + if payload.get("error"): + raise Exception(f"GraphQL error: {payload['error']}") + ids = payload.get("ids") or [] + normalized_ids = [int(book_id) for book_id in ids if str(book_id).strip()] + if not normalized_ids: + return [] + return self.list_books_by_ids(normalized_ids) + + def find_author_profile(self, author_name: str) -> HardcoverAuthorProfile | None: + document = self._select_author_document(author_name) + if not document: + return None + + author = self.get_author_by_id(document.get("id")) or document + return self._build_author_profile_from_payload( + author if isinstance(author, dict) else document, + fallback_name=document.get("name") or author_name, + ) + # TODO Add option for autocreate if missing books instead of forcing it. def update_reading_progress(self, identifiers, progress_percent): ids = self.parse_identifiers(identifiers) @@ -522,7 +984,7 @@ def parse_identifiers(self, identifiers): def execute(self, query, variables=None): payload = {"query": query, "variables": variables or {}} - response = requests.post(self.endpoint, json=payload, headers=self.headers) + response = requests.post(self.endpoint, json=payload, headers=self.headers, timeout=REQUEST_TIMEOUT) try: response.raise_for_status() except requests.exceptions.HTTPError as e: @@ -534,3 +996,54 @@ def execute(self, query, variables=None): if "errors" in result: raise Exception(f"GraphQL error: {result['errors']}") return result.get("data", {}) + + +def get_author_profile(author_name: str) -> HardcoverAuthorProfile | None: + token = ( + getattr(current_user, "hardcover_token", None) + or getattr(config, "config_hardcover_token", None) + or getenv("HARDCOVER_TOKEN") + ) + if not token or not author_name: + return None + + try: + client = HardcoverClient(token.replace("Bearer ", ""), load_privacy=False) + return client.find_author_profile(author_name) + except MissingHardcoverToken: + return None + except Exception as exc: + log.warning("Hardcover author profile lookup failed for %s: %s", author_name, exc) + return None + + +def get_author_profile_by_id(author_id: int | str | None) -> HardcoverAuthorProfile | None: + if author_id in (None, ""): + return None + client = get_hardcover_client(load_privacy=False) + if client is None: + return None + try: + return client.get_author_profile_by_id(author_id) + except MissingHardcoverToken: + return None + except Exception as exc: + log.warning("Hardcover author profile lookup failed for author_id=%s: %s", author_id, exc) + return None + + +def get_hardcover_client(*, load_privacy: bool = False) -> HardcoverClient | None: + token = ( + getattr(current_user, "hardcover_token", None) + or getattr(config, "config_hardcover_token", None) + or getenv("HARDCOVER_TOKEN") + ) + if not token: + return None + try: + return HardcoverClient(token.replace("Bearer ", ""), load_privacy=load_privacy) + except MissingHardcoverToken: + return None + except Exception as exc: + log.warning("Hardcover client initialization failed: %s", exc) + return None diff --git a/cps/services/requests_workspace.py b/cps/services/requests_workspace.py new file mode 100644 index 000000000..0a1604e80 --- /dev/null +++ b/cps/services/requests_workspace.py @@ -0,0 +1,1498 @@ +# -*- coding: utf-8 -*- +# Calibre-Web Automated – fork of Calibre-Web +# Copyright (C) 2026 Calibre-Web Automated contributors +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import time +from datetime import date, timedelta +from dataclasses import dataclass, field, replace +from typing import Any, Mapping, Sequence + +from flask import url_for +from flask_babel import gettext as _ +from sqlalchemy.sql.expression import func, text + +from cps import calibre_db, db, logger +from cps.services.hardcover import get_hardcover_client +from cps.services.shelfmark_search import ( + ShelfmarkResultView, + apply_primary_series_context, + build_owned_series_map, + build_shelfmark_quality_state, + build_shelfmark_result_view, + build_shelfmark_series_membership_contexts, + build_shelfmark_triage_state, + get_shelfmark_client_config, + lookup_visible_library_matches, + lookup_visible_owned_series, + search_shelfmark_contextual_results, +) + + +log = logger.create() + +REQUESTS_SERIES_CONTEXT_LIMIT = 4 +REQUESTS_AUTHOR_CONTEXT_LIMIT = 4 +REQUESTS_DISCOVER_LIMIT = 12 +REQUESTS_NEW_LIMIT = 12 +REQUESTS_MAX_SERIES = 8 +REQUESTS_MAX_AUTHORS = 8 +REQUESTS_CONTEXT_RELEASE_LIMIT = 4 +REQUESTS_CONTEXT_POPULAR_LIMIT = 3 +REQUESTS_RECENT_WINDOW_DAYS = 180 +REQUESTS_SECTION_CACHE_TTL_SECONDS = 60 + +_REQUESTS_SECTION_CACHE: dict[tuple[Any, ...], tuple[float, Any]] = {} + + +@dataclass(frozen=True) +class RequestsOwnedSeriesSeed: + key: str + name: str + book_count: int + owned_positions: tuple[float, ...] + max_position: float | None + contiguous_position: int | None + + @property + def has_gap(self) -> bool: + if self.contiguous_position is None or self.max_position is None: + return False + return self.max_position > self.contiguous_position + + def hint(self) -> str: + parts: list[str] = [ + _( + "%(count)s books in your library", + count=self.book_count, + ) + ] + if self.contiguous_position: + parts.append(_("Owned through %(position)s", position=_format_position(self.contiguous_position))) + return " \u00b7 ".join(parts) + + +@dataclass(frozen=True) +class RequestsOwnedAuthorSeed: + author_id: int + name: str + book_count: int + + def hint(self) -> str: + label = _("book") if self.book_count == 1 else _("books") + return _("You already own %(count)s %(label)s by this author", count=self.book_count, label=label) + + +@dataclass(frozen=True) +class RequestsCandidate: + key: str + result: ShelfmarkResultView + reason_label: str + reason_detail: str | None = None + reason_icon: str = "glyphicon glyphicon-book" + source_kind: str = "" + group_key: str | None = None + group_title: str | None = None + group_hint: str | None = None + priority_bucket: str = "normal" + sort_key: tuple[Any, ...] = field(default_factory=tuple) + + def to_template_dict(self) -> dict[str, Any]: + result_payload = self.result.to_template_dict() + result_payload["recommendation_reason_label"] = self.reason_label + result_payload["recommendation_reason_detail"] = self.reason_detail + result_payload["recommendation_reason_icon"] = self.reason_icon + return { + "key": self.key, + "result": result_payload, + "reason_label": self.reason_label, + "reason_detail": self.reason_detail, + "reason_icon": self.reason_icon, + "source_kind": self.source_kind, + "group_key": self.group_key, + "group_title": self.group_title, + "group_hint": self.group_hint, + "priority_bucket": self.priority_bucket, + } + + +@dataclass(frozen=True) +class RequestsGroup: + key: str + title: str + hint: str | None = None + candidates: tuple[RequestsCandidate, ...] = field(default_factory=tuple) + + def to_template_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "title": self.title, + "hint": self.hint, + "count": len(self.candidates), + "candidates": [candidate.to_template_dict() for candidate in self.candidates], + } + + +@dataclass(frozen=True) +class RequestsSection: + key: str + title: str + subtitle: str + empty_message: str + layout: str = "cards" + compact: bool = False + see_more_url: str | None = None + candidates: tuple[RequestsCandidate, ...] = field(default_factory=tuple) + groups: tuple[RequestsGroup, ...] = field(default_factory=tuple) + + @property + def has_content(self) -> bool: + return bool(self.candidates or self.groups) + + def to_template_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "title": self.title, + "subtitle": self.subtitle, + "empty_message": self.empty_message, + "layout": self.layout, + "compact": self.compact, + "see_more_url": self.see_more_url, + "has_content": self.has_content, + "candidates": [candidate.to_template_dict() for candidate in self.candidates], + "groups": [group.to_template_dict() for group in self.groups], + } + + +@dataclass(frozen=True) +class RequestsSectionShell: + key: str + title: str + subtitle: str + empty_message: str + load_url: str + loading_message: str + layout: str = "cards" + compact: bool = False + see_more_url: str | None = None + + def to_template_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "title": self.title, + "subtitle": self.subtitle, + "empty_message": self.empty_message, + "load_url": self.load_url, + "loading_message": self.loading_message, + "layout": self.layout, + "compact": self.compact, + "see_more_url": self.see_more_url, + } + + +@dataclass(frozen=True) +class RequestsWorkspaceView: + enabled: bool + active_view: str + eyebrow: str + title: str + subtitle: str + sections: tuple[RequestsSectionShell, ...] = field(default_factory=tuple) + message: str | None = None + + def to_template_dict(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "active_view": self.active_view, + "eyebrow": self.eyebrow, + "title": self.title, + "subtitle": self.subtitle, + "sections": [section.to_template_dict() for section in self.sections], + "message": self.message, + } + + +def _format_position(value: float | int | None) -> str: + if value is None: + return "?" + numeric = float(value) + if numeric.is_integer(): + return str(int(numeric)) + return f"{numeric:.2f}".rstrip("0").rstrip(".") + + +def _result_key(result: ShelfmarkResultView) -> str: + return result.hardcover_id or f"{result.provider}:{result.provider_id}" + + +def _detail_url_for_result(result: Mapping[str, Any], *, query: str, state_url: str | None) -> str | None: + provider = str(result.get("provider") or "hardcover").strip() + provider_id = str(result.get("provider_id") or result.get("id") or "").strip() + if not provider_id: + return None + return url_for( + "search.shelfmark_external_detail", + provider=provider, + provider_id=provider_id, + query=query, + return_to=state_url, + ) + + +def _result_popularity(result: ShelfmarkResultView) -> int: + return int(result.readers_count or result.ratings_count or 0) + + +def _parse_release_date(raw_release_date: str | None) -> date | None: + if not raw_release_date: + return None + try: + return date.fromisoformat(str(raw_release_date).split("T", 1)[0]) + except ValueError: + return None + + +def _resolve_release_date( + result: ShelfmarkResultView, + *, + raw_release_date: str | None = None, +) -> date | None: + return _parse_release_date(raw_release_date or result.release_date) + + +def _release_bucket_for_result( + result: ShelfmarkResultView, + *, + raw_release_date: str | None = None, + today: date | None = None, +) -> str | None: + current_day = today or date.today() + release_day = _resolve_release_date(result, raw_release_date=raw_release_date) + if release_day is not None: + if release_day >= current_day: + return "upcoming" + if release_day >= current_day - timedelta(days=REQUESTS_RECENT_WINDOW_DAYS): + return "recent" + return None + if result.publish_year is None: + return None + if result.publish_year > current_day.year: + return "upcoming" + if result.publish_year == current_day.year: + return "recent" + return None + + +def _owned_series_seeds(limit: int = REQUESTS_MAX_SERIES) -> tuple[RequestsOwnedSeriesSeed, ...]: + rows = ( + calibre_db.session.query( + db.Series.name.label("series_name"), + db.Books.id.label("book_id"), + db.Books.series_index.label("series_position"), + ) + .join(db.books_series_link, db.books_series_link.c.series == db.Series.id) + .join(db.Books, db.books_series_link.c.book == db.Books.id) + .filter(calibre_db.common_filters()) + .order_by(db.Series.name.asc(), db.Books.series_index.asc(), db.Books.id.asc()) + .all() + ) + owned_series = build_owned_series_map( + { + "series_name": row.series_name, + "book_id": row.book_id, + "series_position": row.series_position, + } + for row in rows + ) + ranked = sorted( + owned_series.values(), + key=lambda item: ( + 0 if item.max_position and item.contiguous_position and item.max_position > item.contiguous_position else 1, + -item.book_count, + -(item.contiguous_position or 0), + item.series_name.casefold(), + ), + ) + return tuple( + RequestsOwnedSeriesSeed( + key=item.key, + name=item.series_name, + book_count=item.book_count, + owned_positions=item.owned_positions, + max_position=item.max_position, + contiguous_position=item.contiguous_position, + ) + for item in ranked[: max(1, int(limit or REQUESTS_MAX_SERIES))] + ) + + +def _owned_author_seeds(limit: int = REQUESTS_MAX_AUTHORS) -> tuple[RequestsOwnedAuthorSeed, ...]: + rows = ( + calibre_db.session.query( + db.Authors.id.label("author_id"), + db.Authors.name.label("author_name"), + func.count(db.books_authors_link.c.book).label("book_count"), + ) + .join(db.books_authors_link, db.Authors.id == db.books_authors_link.c.author) + .join(db.Books, db.books_authors_link.c.book == db.Books.id) + .filter(calibre_db.common_filters()) + .group_by(text("books_authors_link.author")) + .order_by(func.count(db.books_authors_link.c.book).desc(), db.Authors.sort.asc()) + .all() + ) + return tuple( + RequestsOwnedAuthorSeed( + author_id=int(row.author_id), + name=(row.author_name or "").replace("|", ","), + book_count=int(row.book_count or 0), + ) + for row in rows[: max(1, int(limit or REQUESTS_MAX_AUTHORS))] + if row.author_name + ) + + +def _is_missing_volume(seed: RequestsOwnedSeriesSeed, result: ShelfmarkResultView) -> bool: + context = result.best_series_context or result.series_context + return bool(context and context.is_next_missing and seed.has_gap) + + +def _is_next_in_series(seed: RequestsOwnedSeriesSeed, result: ShelfmarkResultView) -> bool: + context = result.best_series_context or result.series_context + if not context: + return False + if context.is_next_missing and not seed.has_gap: + return True + if context.is_continuation and not seed.has_gap: + return True + return False + + +def _gap_reason_detail(seed: RequestsOwnedSeriesSeed, result: ShelfmarkResultView) -> str: + position = result.series_position or (result.best_series_context.series_position if result.best_series_context else None) + if position is None: + return _("Missing inside a series you already own") + lower = [owned for owned in seed.owned_positions if owned < position] + higher = [owned for owned in seed.owned_positions if owned > position] + if lower and higher: + return _( + "Missing between volumes %(before)s and %(after)s", + before=_format_position(max(lower)), + after=_format_position(min(higher)), + ) + if lower: + return _("Missing after volume %(volume)s", volume=_format_position(max(lower))) + return _("Missing volume in a series you already own") + + +def _next_reason_detail(seed: RequestsOwnedSeriesSeed) -> str: + if seed.contiguous_position: + return _( + "Next after volume %(position)s in a series you own", + position=_format_position(seed.contiguous_position), + ) + return _("Continue a series you already own") + + +def _build_series_candidate( + seed: RequestsOwnedSeriesSeed, + result: ShelfmarkResultView, + *, + section_key: str, +) -> RequestsCandidate | None: + if result.already_in_library: + return None + if _is_missing_volume(seed, result): + reason_label = _("Missing volume") + reason_detail = _gap_reason_detail(seed, result) + priority_bucket = "critical" + priority_order = 0 + elif _is_next_in_series(seed, result): + reason_label = _("Next in series") + reason_detail = _next_reason_detail(seed) + priority_bucket = "high" + priority_order = 1 + else: + return None + + position_value = result.series_position if result.series_position is not None else float("inf") + popularity_value = -(result.readers_count or result.ratings_count or 0) + return RequestsCandidate( + key=f"{section_key}:{seed.key}:{_result_key(result)}", + result=result, + reason_label=reason_label, + reason_detail=reason_detail, + reason_icon="glyphicon glyphicon-bookmark", + source_kind="series", + group_key=seed.key, + group_title=seed.name, + group_hint=seed.hint(), + priority_bucket=priority_bucket, + sort_key=(priority_order, -seed.book_count, position_value, popularity_value, result.title.casefold()), + ) + + +def _build_author_candidate( + seed: RequestsOwnedAuthorSeed, + result: ShelfmarkResultView, +) -> RequestsCandidate | None: + if result.already_in_library: + return None + popularity = _result_popularity(result) + return RequestsCandidate( + key=f"authors-popular:{seed.author_id}:{_result_key(result)}", + result=result, + reason_label=_("Popular missing title"), + reason_detail=seed.hint(), + reason_icon="glyphicon glyphicon-user", + source_kind="author", + group_key=str(seed.author_id), + group_title=seed.name, + group_hint=seed.hint(), + priority_bucket="high" if seed.book_count >= 3 else "normal", + sort_key=(-seed.book_count, -(popularity or 0), result.title.casefold()), + ) + + +def _build_series_release_candidate( + seed: RequestsOwnedSeriesSeed, + result: ShelfmarkResultView, + *, + bucket: str, +) -> RequestsCandidate | None: + if result.already_in_library: + return None + release_value = _release_sort_value(result.release_date, result.publish_year) + popularity = _result_popularity(result) + reason_label = _("Upcoming release") if bucket == "upcoming" else _("New release") + sort_key = ( + -seed.book_count, + release_value if bucket == "upcoming" else -(release_value or 0), + -(popularity or 0), + result.title.casefold(), + ) + return RequestsCandidate( + key=f"series-{bucket}:{seed.key}:{_result_key(result)}", + result=result, + reason_label=reason_label, + reason_detail=_("Series you already own"), + reason_icon="glyphicon glyphicon-bookmark", + source_kind="series", + group_key=seed.key, + group_title=seed.name, + group_hint=seed.hint(), + priority_bucket="high" if bucket == "upcoming" else "normal", + sort_key=sort_key, + ) + + +def _build_author_release_candidate( + seed: RequestsOwnedAuthorSeed, + result: ShelfmarkResultView, + *, + bucket: str, +) -> RequestsCandidate | None: + if result.already_in_library: + return None + release_value = _release_sort_value(result.release_date, result.publish_year) + popularity = _result_popularity(result) + reason_label = _("Upcoming release") if bucket == "upcoming" else _("New release") + sort_key = ( + -seed.book_count, + release_value if bucket == "upcoming" else -(release_value or 0), + -(popularity or 0), + result.title.casefold(), + ) + return RequestsCandidate( + key=f"authors-{bucket}:{seed.author_id}:{_result_key(result)}", + result=result, + reason_label=reason_label, + reason_detail=_("Author you already own"), + reason_icon="glyphicon glyphicon-user", + source_kind="author", + group_key=str(seed.author_id), + group_title=seed.name, + group_hint=seed.hint(), + priority_bucket="high" if bucket == "upcoming" else "normal", + sort_key=sort_key, + ) + + +def _build_context_hot_candidate( + result: ShelfmarkResultView, + *, + seed_name: str, + seed_hint: str, + source_kind: str, + seed_key: str, +) -> RequestsCandidate | None: + if result.already_in_library or not result.cover_url: + return None + popularity = _result_popularity(result) + reason_label = ( + _("Hot from a series you own") + if source_kind == "series" + else _("Hot from an author you own") + ) + return RequestsCandidate( + key=f"hot-{source_kind}:{seed_key}:{_result_key(result)}", + result=result, + reason_label=reason_label, + reason_detail=_("%(seed)s \u00b7 %(hint)s", seed=seed_name, hint=seed_hint), + reason_icon="glyphicon glyphicon-fire", + source_kind=source_kind, + priority_bucket="high" if popularity >= 2500 else "normal", + sort_key=(-(popularity or 0), seed_name.casefold(), result.title.casefold()), + ) + + +def _build_discover_candidate(result: ShelfmarkResultView, *, trending: bool) -> RequestsCandidate | None: + if result.already_in_library or not result.cover_url: + return None + popularity = _result_popularity(result) + reason_label = _("Trending now on Hardcover") if trending else _("Popular with Hardcover readers") + reason_detail = ( + _("Popular now and not already in your library") + if trending + else _("Popular missing book with strong Hardcover activity") + ) + return RequestsCandidate( + key=f"hot-general:{_result_key(result)}", + result=result, + reason_label=reason_label, + reason_detail=reason_detail, + reason_icon="glyphicon glyphicon-fire", + source_kind="discover", + priority_bucket="normal", + sort_key=(-(popularity or 0), result.title.casefold()), + ) + + +def _release_sort_value(raw_release_date: str | None, publish_year: int | None) -> int: + if raw_release_date: + try: + return date.fromisoformat(str(raw_release_date).split("T", 1)[0]).toordinal() + except ValueError: + pass + if publish_year: + return date(int(publish_year), 1, 1).toordinal() + return 0 + + +def _build_new_candidate( + result: ShelfmarkResultView, + *, + raw_release_date: str | None, +) -> RequestsCandidate | None: + if result.already_in_library or not result.cover_url: + return None + bucket = _release_bucket_for_result(result, raw_release_date=raw_release_date) + if bucket not in {"upcoming", "recent"}: + return None + popularity = _result_popularity(result) + release_value = _release_sort_value(raw_release_date, result.publish_year) + reason_label = _("Coming soon") if bucket == "upcoming" else _("New release") + reason_detail = ( + _("High-signal upcoming book missing from your library") + if bucket == "upcoming" + else _("Recently released book missing from your library") + ) + return RequestsCandidate( + key=f"new-{bucket}:{_result_key(result)}", + result=result, + reason_label=reason_label, + reason_detail=reason_detail, + reason_icon="glyphicon glyphicon-time", + source_kind="new", + priority_bucket="normal", + sort_key=( + release_value if bucket == "upcoming" else -(release_value or 0), + -(popularity or 0), + result.title.casefold(), + ), + ) + + +def _dedupe_candidates( + candidates: Sequence[RequestsCandidate], + *, + limit: int | None = None, +) -> tuple[RequestsCandidate, ...]: + seen: set[str] = set() + deduped: list[RequestsCandidate] = [] + for candidate in sorted(candidates, key=lambda item: item.sort_key): + identity = _result_key(candidate.result) + if identity in seen: + continue + seen.add(identity) + deduped.append(candidate) + if limit is not None and len(deduped) >= limit: + break + return tuple(deduped) + + +def _group_candidates( + groups: dict[str, list[RequestsCandidate]], + *, + title_lookup: Mapping[str, str], + hint_lookup: Mapping[str, str], +) -> tuple[RequestsGroup, ...]: + ordered_groups: list[RequestsGroup] = [] + for key, candidates in groups.items(): + deduped = _dedupe_candidates(candidates) + if not deduped: + continue + ordered_groups.append( + RequestsGroup( + key=key, + title=title_lookup.get(key) or key, + hint=hint_lookup.get(key), + candidates=deduped, + ) + ) + ordered_groups.sort( + key=lambda group: ( + min((candidate.sort_key for candidate in group.candidates), default=(float("inf"),)), + group.title.casefold(), + ) + ) + return tuple(ordered_groups) + + +def _cache_value(cache_key: tuple[Any, ...], builder): + now = time.monotonic() + cached = _REQUESTS_SECTION_CACHE.get(cache_key) + if cached and cached[0] > now: + return cached[1] + value = builder() + _REQUESTS_SECTION_CACHE[cache_key] = (now + REQUESTS_SECTION_CACHE_TTL_SECONDS, value) + return value + + +def _build_grouped_requests_section( + *, + key: str, + title: str, + subtitle: str, + empty_message: str, + groups: dict[str, list[RequestsCandidate]], + title_lookup: Mapping[str, str], + hint_lookup: Mapping[str, str], +) -> RequestsSection: + return RequestsSection( + key=key, + title=title, + subtitle=subtitle, + empty_message=empty_message, + layout="grouped", + compact=True, + groups=_group_candidates(groups, title_lookup=title_lookup, hint_lookup=hint_lookup), + ) + + +def _collect_series_bundle( + *, + state_url: str | None, + max_series: int, + relevance_limit: int, + release_limit: int, +) -> dict[str, RequestsSection]: + next_groups: dict[str, list[RequestsCandidate]] = {} + missing_groups: dict[str, list[RequestsCandidate]] = {} + upcoming_groups: dict[str, list[RequestsCandidate]] = {} + new_groups: dict[str, list[RequestsCandidate]] = {} + group_titles: dict[str, str] = {} + group_hints: dict[str, str] = {} + + for seed in _owned_series_seeds(limit=max_series): + group_titles[seed.key] = seed.name + group_hints[seed.key] = seed.hint() + + relevance_section = search_shelfmark_contextual_results( + seed.name, + detail_url_builder=lambda book, series_name=seed.name: _detail_url_for_result( + book, + query=series_name, + state_url=state_url, + ), + context_type="series", + context_value=seed.name, + limit=relevance_limit * 2, + sort="relevance", + filter_requestable=True, + filter_has_cover=True, + query_label=seed.name, + ) + if relevance_section.available and relevance_section.results: + for result in relevance_section.results: + candidate = _build_series_candidate(seed, result, section_key="series") + if candidate is None: + continue + target = missing_groups if candidate.reason_label == _("Missing volume") else next_groups + target.setdefault(seed.key, []).append(candidate) + + release_section = search_shelfmark_contextual_results( + seed.name, + detail_url_builder=lambda book, series_name=seed.name: _detail_url_for_result( + book, + query=series_name, + state_url=state_url, + ), + context_type="series", + context_value=seed.name, + limit=release_limit, + sort="newest", + filter_requestable=True, + filter_has_cover=True, + query_label=seed.name, + ) + if not release_section.available or not release_section.results: + continue + for result in release_section.results: + bucket = _release_bucket_for_result(result) + if bucket not in {"upcoming", "recent"}: + continue + candidate = _build_series_release_candidate(seed, result, bucket=bucket) + if candidate is None: + continue + target = upcoming_groups if bucket == "upcoming" else new_groups + target.setdefault(seed.key, []).append(candidate) + + return { + "series-next": _build_grouped_requests_section( + key="series-next", + title=_("Next in series"), + subtitle=_("Likely continuations after the farthest point you already own."), + empty_message=_("No likely next-in-series requests surfaced right now."), + groups=next_groups, + title_lookup=group_titles, + hint_lookup=group_hints, + ), + "series-missing": _build_grouped_requests_section( + key="series-missing", + title=_("Missing volumes"), + subtitle=_("Gap fills inside series you already started."), + empty_message=_("No missing-volume gaps surfaced right now."), + groups=missing_groups, + title_lookup=group_titles, + hint_lookup=group_hints, + ), + "series-upcoming": _build_grouped_requests_section( + key="series-upcoming", + title=_("Upcoming in your series"), + subtitle=_("Forthcoming entries tied to series already in your library."), + empty_message=_("No upcoming books in your series surfaced right now."), + groups=upcoming_groups, + title_lookup=group_titles, + hint_lookup=group_hints, + ), + "series-new-releases": _build_grouped_requests_section( + key="series-new-releases", + title=_("New releases in your series"), + subtitle=_("Recently released books tied to series already in your library."), + empty_message=_("No recent series releases surfaced right now."), + groups=new_groups, + title_lookup=group_titles, + hint_lookup=group_hints, + ), + } + + +def _collect_author_bundle( + *, + state_url: str | None, + max_authors: int, + popularity_limit: int, + release_limit: int, +) -> dict[str, RequestsSection]: + popular_groups: dict[str, list[RequestsCandidate]] = {} + upcoming_groups: dict[str, list[RequestsCandidate]] = {} + new_groups: dict[str, list[RequestsCandidate]] = {} + group_titles: dict[str, str] = {} + group_hints: dict[str, str] = {} + + for seed in _owned_author_seeds(limit=max_authors): + group_key = str(seed.author_id) + group_titles[group_key] = seed.name + group_hints[group_key] = seed.hint() + + popular_section = search_shelfmark_contextual_results( + seed.name, + detail_url_builder=lambda book, author_name=seed.name: _detail_url_for_result( + book, + query=author_name, + state_url=state_url, + ), + context_type="author", + context_value=seed.name, + limit=popularity_limit, + sort="popularity", + filter_requestable=True, + filter_has_cover=True, + query_label=seed.name, + ) + if popular_section.available and popular_section.results: + for result in popular_section.results: + candidate = _build_author_candidate(seed, result) + if candidate is None: + continue + popular_groups.setdefault(group_key, []).append(candidate) + + release_section = search_shelfmark_contextual_results( + seed.name, + detail_url_builder=lambda book, author_name=seed.name: _detail_url_for_result( + book, + query=author_name, + state_url=state_url, + ), + context_type="author", + context_value=seed.name, + limit=release_limit, + sort="newest", + filter_requestable=True, + filter_has_cover=True, + query_label=seed.name, + ) + if not release_section.available or not release_section.results: + continue + for result in release_section.results: + bucket = _release_bucket_for_result(result) + if bucket not in {"upcoming", "recent"}: + continue + candidate = _build_author_release_candidate(seed, result, bucket=bucket) + if candidate is None: + continue + target = upcoming_groups if bucket == "upcoming" else new_groups + target.setdefault(group_key, []).append(candidate) + + return { + "authors-popular": _build_grouped_requests_section( + key="authors-popular", + title=_("More from authors you own"), + subtitle=_("Notable missing works by authors already represented in your library."), + empty_message=_("No author-led request candidates surfaced right now."), + groups=popular_groups, + title_lookup=group_titles, + hint_lookup=group_hints, + ), + "authors-upcoming": _build_grouped_requests_section( + key="authors-upcoming", + title=_("Upcoming from your authors"), + subtitle=_("Forthcoming books by authors already in your library."), + empty_message=_("No upcoming books from your authors surfaced right now."), + groups=upcoming_groups, + title_lookup=group_titles, + hint_lookup=group_hints, + ), + "authors-new-releases": _build_grouped_requests_section( + key="authors-new-releases", + title=_("New releases from your authors"), + subtitle=_("Recently released books by authors already in your library."), + empty_message=_("No recent releases from your authors surfaced right now."), + groups=new_groups, + title_lookup=group_titles, + hint_lookup=group_hints, + ), + } + + +def _build_hardcover_result(book: Mapping[str, Any], *, state_url: str | None, detail_query: str) -> ShelfmarkResultView | None: + hardcover_id = str(book.get("id") or "").strip() + if not hardcover_id: + return None + enriched_book = dict(book) + enriched_book.setdefault("provider", "hardcover") + enriched_book.setdefault("provider_id", hardcover_id) + library_match = lookup_visible_library_matches([hardcover_id]).get(hardcover_id) + result = build_shelfmark_result_view( + enriched_book, + library_match=library_match, + detail_url=_detail_url_for_result( + enriched_book, + query=detail_query, + state_url=state_url, + ), + shelfmark_browser_base_url=get_shelfmark_client_config().browser_base_url, + ) + owned_series = lookup_visible_owned_series( + [membership.name for membership in result.series_memberships] + ) + contexts = build_shelfmark_series_membership_contexts((result,), owned_series)[0] + result = apply_primary_series_context(replace(result, series_contexts=contexts)) + result = replace(result, quality_state=build_shelfmark_quality_state(result)) + return replace( + result, + triage_state=build_shelfmark_triage_state(result, quality_state=result.quality_state), + ) + + +def _collect_hot_section(*, state_url: str | None, limit: int) -> RequestsSection: + client = get_hardcover_client(load_privacy=False) + if client is None: + return RequestsSection( + key="hot-general", + title=_("Trending / popular now"), + subtitle=_("Popular missing books worth considering next."), + empty_message=_("Hardcover discovery data is unavailable right now."), + layout="cards", + ) + + books: list[dict] = [] + trending = True + try: + books = client.list_trending_books(limit=limit) + except Exception as exc: + log.warning("Hardcover trending lookup failed for Requests workspace: %s", exc) + books = [] + + if not books: + trending = False + try: + books = client.list_popular_books(limit=limit) + except Exception as exc: + log.warning("Hardcover popular-books lookup failed for Requests workspace: %s", exc) + books = [] + + candidates: list[RequestsCandidate] = [] + for book in books: + result = _build_hardcover_result( + book, + state_url=state_url, + detail_query=str(book.get("title") or ""), + ) + if result is None: + continue + candidate = _build_discover_candidate(result, trending=trending) + if candidate is None: + continue + candidates.append(candidate) + + return RequestsSection( + key="hot-general", + title=_("Trending / popular now"), + subtitle=_("Popular missing books with strong Hardcover momentum."), + empty_message=_("No trending or popular discovery candidates are available right now."), + layout="cards", + candidates=_dedupe_candidates(candidates, limit=limit), + ) + + +def _collect_hot_context_section(*, state_url: str | None, limit: int) -> RequestsSection: + candidates: list[RequestsCandidate] = [] + + for seed in _owned_series_seeds(limit=max(1, REQUESTS_MAX_SERIES // 2)): + section = search_shelfmark_contextual_results( + seed.name, + detail_url_builder=lambda book, series_name=seed.name: _detail_url_for_result( + book, + query=series_name, + state_url=state_url, + ), + context_type="series", + context_value=seed.name, + limit=REQUESTS_CONTEXT_POPULAR_LIMIT, + sort="popularity", + filter_requestable=True, + filter_has_cover=True, + query_label=seed.name, + ) + if not section.available or not section.results: + continue + for result in section.results: + candidate = _build_context_hot_candidate( + result, + seed_name=seed.name, + seed_hint=seed.hint(), + source_kind="series", + seed_key=seed.key, + ) + if candidate is not None: + candidates.append(candidate) + + for seed in _owned_author_seeds(limit=max(1, REQUESTS_MAX_AUTHORS // 2)): + section = search_shelfmark_contextual_results( + seed.name, + detail_url_builder=lambda book, author_name=seed.name: _detail_url_for_result( + book, + query=author_name, + state_url=state_url, + ), + context_type="author", + context_value=seed.name, + limit=REQUESTS_CONTEXT_POPULAR_LIMIT, + sort="popularity", + filter_requestable=True, + filter_has_cover=True, + query_label=seed.name, + ) + if not section.available or not section.results: + continue + for result in section.results: + candidate = _build_context_hot_candidate( + result, + seed_name=seed.name, + seed_hint=seed.hint(), + source_kind="author", + seed_key=str(seed.author_id), + ) + if candidate is not None: + candidates.append(candidate) + + return RequestsSection( + key="hot-context", + title=_("Hot from your authors and series"), + subtitle=_("Popularity-led picks tied to authors and series already in your library."), + empty_message=_("No strong popularity-led matches from your authors or series surfaced right now."), + layout="cards", + candidates=_dedupe_candidates(candidates, limit=limit), + ) + + +def _collect_new_sections(*, state_url: str | None, limit: int) -> dict[str, RequestsSection]: + client = get_hardcover_client(load_privacy=False) + if client is None: + return { + "new-upcoming": RequestsSection( + key="new-upcoming", + title=_("Upcoming you may like"), + subtitle=_("High-signal forthcoming books from Hardcover."), + empty_message=_("Hardcover discovery data is unavailable right now."), + layout="cards", + ), + "new-releases": RequestsSection( + key="new-releases", + title=_("New releases you may like"), + subtitle=_("Recently released books with strong Hardcover activity."), + empty_message=_("Hardcover discovery data is unavailable right now."), + layout="cards", + ), + } + + books: list[dict] = [] + try: + books = client.list_popular_books(limit=max(limit * 4, limit)) + except Exception as exc: + log.warning("Hardcover recent-books lookup failed for Requests workspace: %s", exc) + books = [] + + upcoming_candidates: list[RequestsCandidate] = [] + new_release_candidates: list[RequestsCandidate] = [] + for book in (book for book in books if isinstance(book, Mapping)): + result = _build_hardcover_result( + book, + state_url=state_url, + detail_query=str(book.get("title") or ""), + ) + if result is None: + continue + candidate = _build_new_candidate( + result, + raw_release_date=book.get("release_date"), + ) + if candidate is None: + continue + target = upcoming_candidates if candidate.key.startswith("new-upcoming:") else new_release_candidates + target.append(candidate) + + return { + "new-upcoming": RequestsSection( + key="new-upcoming", + title=_("Upcoming you may like"), + subtitle=_("High-signal forthcoming books from Hardcover."), + empty_message=_("No upcoming books worth watching surfaced right now."), + layout="cards", + candidates=_dedupe_candidates(upcoming_candidates, limit=limit), + ), + "new-releases": RequestsSection( + key="new-releases", + title=_("New releases you may like"), + subtitle=_("Recently released books with strong Hardcover activity."), + empty_message=_("No recent releases worth watching surfaced right now."), + layout="cards", + candidates=_dedupe_candidates(new_release_candidates, limit=limit), + ), + } + + +def _cached_series_bundle(*, state_url: str | None, cache_scope: Any) -> dict[str, RequestsSection]: + return _cache_value( + ( + "series-bundle", + cache_scope, + state_url, + REQUESTS_MAX_SERIES, + REQUESTS_SERIES_CONTEXT_LIMIT, + REQUESTS_CONTEXT_RELEASE_LIMIT, + ), + lambda: _collect_series_bundle( + state_url=state_url, + max_series=REQUESTS_MAX_SERIES, + relevance_limit=REQUESTS_SERIES_CONTEXT_LIMIT, + release_limit=REQUESTS_CONTEXT_RELEASE_LIMIT, + ), + ) + + +def _cached_author_bundle(*, state_url: str | None, cache_scope: Any) -> dict[str, RequestsSection]: + return _cache_value( + ( + "author-bundle", + cache_scope, + state_url, + REQUESTS_MAX_AUTHORS, + REQUESTS_AUTHOR_CONTEXT_LIMIT, + REQUESTS_CONTEXT_RELEASE_LIMIT, + ), + lambda: _collect_author_bundle( + state_url=state_url, + max_authors=REQUESTS_MAX_AUTHORS, + popularity_limit=REQUESTS_AUTHOR_CONTEXT_LIMIT, + release_limit=REQUESTS_CONTEXT_RELEASE_LIMIT, + ), + ) + + +def _cached_hot_section(*, state_url: str | None, cache_scope: Any, limit: int) -> RequestsSection: + return _cache_value( + ("hot-section", cache_scope, state_url, limit), + lambda: _collect_hot_section( + state_url=state_url, + limit=limit, + ), + ) + + +def _cached_hot_context_section(*, state_url: str | None, cache_scope: Any, limit: int) -> RequestsSection: + return _cache_value( + ("hot-context-section", cache_scope, state_url, limit, REQUESTS_CONTEXT_POPULAR_LIMIT), + lambda: _collect_hot_context_section( + state_url=state_url, + limit=limit, + ), + ) + + +def _cached_new_sections(*, state_url: str | None, cache_scope: Any, limit: int) -> dict[str, RequestsSection]: + return _cache_value( + ("new-sections", cache_scope, state_url, limit), + lambda: _collect_new_sections( + state_url=state_url, + limit=limit, + ), + ) + + +def normalize_requests_view(active_view: str | None) -> str: + normalized_view = (active_view or "series").strip().lower() + if normalized_view == "discover": + return "hot" + if normalized_view == "home": + return "series" + if normalized_view not in {"series", "authors", "hot", "new"}: + return "series" + return normalized_view + + +def _requests_state_url_for_view(active_view: str, state_url: str | None) -> str: + if state_url: + return state_url + return url_for("web.requests_workspace_view", view_name=active_view) + + +def _requests_view_title(active_view: str) -> str: + if active_view == "authors": + return _("Authors") + if active_view == "series": + return _("Series") + if active_view == "hot": + return _("Hot") + if active_view == "new": + return _("New") + return _("Requests") + + +def _requests_view_subtitle(active_view: str) -> str: + if active_view == "authors": + return _("Find more by authors you already own, including upcoming and newly released books.") + if active_view == "series": + return _("Continue the series you own, fill gaps, and catch upcoming or newly released entries.") + if active_view == "hot": + return _("Browse the strongest popularity-led requests, both broadly and from the library you already own.") + if active_view == "new": + return _("Focus on upcoming and newly released books, both broadly and within your authors and series.") + return _("Browse requests shaped around the books you already own.") + + +def _build_section_shell( + *, + active_view: str, + state_url: str, + key: str, + title: str, + subtitle: str, + empty_message: str, + see_more_url: str | None = None, + layout: str = "cards", + compact: bool = False, +) -> RequestsSectionShell: + return RequestsSectionShell( + key=key, + title=title, + subtitle=subtitle, + empty_message=empty_message, + load_url=url_for( + "web.requests_workspace_section", + view_name=active_view, + section_key=key, + return_to=state_url, + ), + loading_message=_("Loading recommendations…"), + layout=layout, + compact=compact, + see_more_url=see_more_url, + ) + + +def _build_section_shells(active_view: str, *, state_url: str) -> tuple[RequestsSectionShell, ...]: + if active_view == "authors": + return ( + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="authors-popular", + title=_("More from authors you own"), + subtitle=_("Notable missing works by authors already represented in your library."), + empty_message=_("No author-led request candidates surfaced right now."), + layout="grouped", + compact=True, + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="authors-upcoming", + title=_("Upcoming from your authors"), + subtitle=_("Forthcoming books by authors already in your library."), + empty_message=_("No upcoming books from your authors surfaced right now."), + layout="grouped", + compact=True, + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="authors-new-releases", + title=_("New releases from your authors"), + subtitle=_("Recently released books by authors already in your library."), + empty_message=_("No recent releases from your authors surfaced right now."), + layout="grouped", + compact=True, + ), + ) + if active_view == "series": + return ( + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="series-next", + title=_("Next in series"), + subtitle=_("Likely continuations after the farthest point you already own."), + empty_message=_("No likely next-in-series requests surfaced right now."), + layout="grouped", + compact=True, + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="series-missing", + title=_("Missing volumes"), + subtitle=_("Gap fills inside series you already started."), + empty_message=_("No missing-volume gaps surfaced right now."), + layout="grouped", + compact=True, + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="series-upcoming", + title=_("Upcoming in your series"), + subtitle=_("Forthcoming entries tied to series already in your library."), + empty_message=_("No upcoming books in your series surfaced right now."), + layout="grouped", + compact=True, + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="series-new-releases", + title=_("New releases in your series"), + subtitle=_("Recently released books tied to series already in your library."), + empty_message=_("No recent series releases surfaced right now."), + layout="grouped", + compact=True, + ), + ) + if active_view == "hot": + return ( + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="hot-general", + title=_("Trending / popular now"), + subtitle=_("Popular missing books with strong Hardcover momentum."), + empty_message=_("No trending or popular discovery candidates are available right now."), + layout="cards", + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="hot-context", + title=_("Hot from your authors and series"), + subtitle=_("Popularity-led picks tied to authors and series already in your library."), + empty_message=_("No strong popularity-led matches from your authors or series surfaced right now."), + layout="cards", + ), + ) + return ( + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="new-upcoming", + title=_("Upcoming you may like"), + subtitle=_("High-signal forthcoming books from Hardcover."), + empty_message=_("No upcoming books worth watching surfaced right now."), + layout="cards", + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="new-releases", + title=_("New releases you may like"), + subtitle=_("Recently released books with strong Hardcover activity."), + empty_message=_("No recent releases worth watching surfaced right now."), + layout="cards", + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="authors-upcoming", + title=_("Upcoming from your authors"), + subtitle=_("Forthcoming books by authors already in your library."), + empty_message=_("No upcoming books from your authors surfaced right now."), + layout="grouped", + compact=True, + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="series-upcoming", + title=_("Upcoming in your series"), + subtitle=_("Forthcoming entries tied to series already in your library."), + empty_message=_("No upcoming books in your series surfaced right now."), + layout="grouped", + compact=True, + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="authors-new-releases", + title=_("New releases from your authors"), + subtitle=_("Recently released books by authors already in your library."), + empty_message=_("No recent releases from your authors surfaced right now."), + layout="grouped", + compact=True, + ), + _build_section_shell( + active_view=active_view, + state_url=state_url, + key="series-new-releases", + title=_("New releases in your series"), + subtitle=_("Recently released books tied to series already in your library."), + empty_message=_("No recent series releases surfaced right now."), + layout="grouped", + compact=True, + ), + ) + + +def build_requests_section(active_view: str, section_key: str, *, state_url: str | None, cache_scope: Any = None) -> RequestsSection: + normalized_view = normalize_requests_view(active_view) + resolved_state_url = _requests_state_url_for_view(normalized_view, state_url) + + if normalized_view == "series": + sections = _cached_series_bundle( + state_url=resolved_state_url, + cache_scope=cache_scope, + ) + if section_key not in {"series-next", "series-missing", "series-upcoming", "series-new-releases"}: + raise KeyError(section_key) + return sections[section_key] + + if normalized_view == "authors": + sections = _cached_author_bundle( + state_url=resolved_state_url, + cache_scope=cache_scope, + ) + if section_key not in {"authors-popular", "authors-upcoming", "authors-new-releases"}: + raise KeyError(section_key) + return sections[section_key] + + if normalized_view == "hot": + if section_key == "hot-general": + return _cached_hot_section( + state_url=resolved_state_url, + cache_scope=cache_scope, + limit=REQUESTS_DISCOVER_LIMIT, + ) + if section_key == "hot-context": + return _cached_hot_context_section( + state_url=resolved_state_url, + cache_scope=cache_scope, + limit=REQUESTS_DISCOVER_LIMIT, + ) + raise KeyError(section_key) + if normalized_view == "new": + if section_key in {"new-upcoming", "new-releases"}: + return _cached_new_sections( + state_url=resolved_state_url, + cache_scope=cache_scope, + limit=REQUESTS_NEW_LIMIT, + )[section_key] + if section_key in {"authors-upcoming", "authors-new-releases"}: + return _cached_author_bundle( + state_url=resolved_state_url, + cache_scope=cache_scope, + )[section_key] + if section_key in {"series-upcoming", "series-new-releases"}: + return _cached_series_bundle( + state_url=resolved_state_url, + cache_scope=cache_scope, + )[section_key] + raise KeyError(section_key) + + raise KeyError(section_key) + + +def build_requests_workspace(active_view: str, *, state_url: str | None) -> RequestsWorkspaceView: + normalized_view = normalize_requests_view(active_view) + resolved_state_url = _requests_state_url_for_view(normalized_view, state_url) + + if not get_shelfmark_client_config().enabled: + return RequestsWorkspaceView( + enabled=False, + active_view=normalized_view, + eyebrow=_("Requests"), + title=_requests_view_title(normalized_view), + subtitle=_requests_view_subtitle(normalized_view), + sections=tuple(), + message=_("Enable Shelfmark Search to use Requests."), + ) + + return RequestsWorkspaceView( + enabled=True, + active_view=normalized_view, + eyebrow=_("Requests"), + title=_requests_view_title(normalized_view), + subtitle=_requests_view_subtitle(normalized_view), + sections=_build_section_shells(normalized_view, state_url=resolved_state_url), + message=None, + ) diff --git a/cps/services/shelfmark_request_download_handoff.md b/cps/services/shelfmark_request_download_handoff.md new file mode 100644 index 000000000..984bf2a92 --- /dev/null +++ b/cps/services/shelfmark_request_download_handoff.md @@ -0,0 +1,164 @@ +# Shelfmark Request and Download Handoff Design + +## Scope +This note captures the verified Shelfmark request and download contract, the current CWA ingest and metadata-on-import behavior, and the safest next step for exact Hardcover provenance handoff into CWA. + +It is intentionally limited to what is supported today: +- `shelfmark/` request, release, queue, and activity routes +- CWA ingest processor and metadata-on-import flow + +## What Shelfmark Supports Today + +### Request-only flow +- `POST /api/requests` +- `POST /api/requests/batch` + +These accept `book_data`, `content_type`, `request_level`, and optional `release_data`. + +If `release_data` is absent, Shelfmark defaults to a book-level request and creates a pending request row. + +### Immediate download flow +There are two verified ways to queue a download: + +1. `POST /api/releases/download` + - queues one concrete release directly + - requires at least `source` and `source_id` + - does not create a request record by itself + +2. `POST /api/requests` or `/api/requests/batch` with policy resolving to `download` + - only works when a concrete `release_data` payload is supplied + - requires a release-level request once `release_data` is present + - Shelfmark then queues the selected release immediately + +### Admin fulfilment flow +- `POST /api/admin/requests//fulfil` + +This is the current bridge from a pending request to a queued download. + +It: +- loads the pending request +- requires concrete `release_data` unless `manual_approval=true` +- persists fulfilment state +- injects `_request_id` into the queued release payload +- queues the release under the requesting user identity + +### Activity and delivery-state tracking +- `GET /api/activity/snapshot` + +This syncs request delivery state from queue and download state. The linkage is driven by: +- explicit `request_id` on queued tasks when available +- otherwise `source_id` extracted from `release_data` + +## Safest Recommended Workflow + +### Safe now +Keep the current CWA browser-side request-only workflow for generic book-level requests. + +### Next safe workflow for auto-download +- Search releases in Shelfmark for the exact target book. +- Select one concrete release in CWA. +- Submit a release-level request with `release_data` so Shelfmark can queue immediately when policy mode is `download`. + +This is safer than: +- generic book request followed by automatic guesswork +- direct release download without preserving request provenance + +### What should not be done speculatively +- Do not assume a generic book request can be auto-approved into a correct release later without a release-selection step. +- Do not treat `POST /api/releases/download` as a full replacement for request workflow, because it bypasses request provenance. + +## Release Preference Viability + +Shelfmark release metadata already exposes enough fields to support a small future preference model if CWA adds release selection later. + +Verified fields include: +- `source` +- `source_id` +- `protocol` +- `indexer` +- `format` +- `language` +- `content_type` +- `publisher` +- `year` + +This makes the following future settings viable: +- request mode: request only, or request and auto-download preferred release +- preferred source type: any, direct, torrent, usenet +- preferred provider or indexer: any, or one explicit provider +- preferred format: any, EPUB, PDF, MOBI + +These settings should wait until CWA has a concrete release-selection workflow. They should not be bolted onto the current book-request-only flow. + +## Current CWA Hardcover-ID-on-Import Behavior + +When "auto retrieve Hardcover ID and metadata on import" is enabled, the current generic import flow does this: +- imports the file into Calibre +- loads the newly added book +- builds a fuzzy metadata query from title and authors +- walks the configured provider hierarchy +- applies the first viable metadata result + +That means an exact Hardcover ID already known upstream can still be ignored during metadata lookup unless fuzzy search rediscovers the same record. + +## Recommended Provenance Handoff + +### Preferred design +Use an explicit sidecar manifest next to the completed download: + +`.cwa.json` + +This is already a supported ingest seam in CWA and is safer than: +- filename markers +- guessing from download path names +- relying on embedded ebook metadata only + +### Recommended manifest shape +```json +{ + "provenance": { + "provider": "hardcover", + "provider_id": "379631", + "hardcover_edition": "91234", + "hardcover_slug": "mort" + } +} +``` + +Optional explicit identifiers also work: +```json +{ + "identifiers": { + "hardcover-id": "379631", + "hardcover-edition": "91234" + } +} +``` + +### Why this is the cleanest handoff +- explicit +- stable across file renames +- does not affect ordinary imports +- can be produced by Shelfmark or any adjacent downloader later +- lets CWA persist exact identifiers before metadata lookup runs + +## Safe Implementation Completed in CWA +- ingest now consumes identifier and provenance data from `.cwa.json` sidecars during normal import +- exact `hardcover-id` and `hardcover-edition` can land in Calibre immediately +- metadata fetch now prefers exact Hardcover lookup when the imported book already has a `hardcover-id` + +This improves correctness now without changing the existing Shelfmark request flow. + +## What Should Be Implemented Next +1. Extend Shelfmark’s completed-download output to write a `.cwa.json` sidecar into the CWA ingest area. +2. Decide whether the future CWA UX should remain: + - request only + - or add an opt-in request-and-auto-download mode +3. If auto-download is added, build it on top of Shelfmark release search and release-level request or fulfilment contracts, not book-level guesswork. +4. Only after that should CWA expose user-facing preferences for source type, provider or indexer, and format. + +## What Should Wait +- speculative CWA settings UI for auto-download +- automatic release guessing without release search +- backend-side request impersonation from CWA +- any change to the existing exact duplicate contract diff --git a/cps/services/shelfmark_search.py b/cps/services/shelfmark_search.py new file mode 100644 index 000000000..c14fe61e2 --- /dev/null +++ b/cps/services/shelfmark_search.py @@ -0,0 +1,5084 @@ +from __future__ import annotations +from collections import OrderedDict +from dataclasses import asdict, dataclass, field, replace +import ipaddress +import math +import re +import socket +from time import monotonic +from typing import Any, Callable, Iterable, Mapping, Sequence +import unicodedata +from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit + +from requests import RequestException +from flask import url_for +from flask_babel import gettext as _ +from markupsafe import Markup +from sqlalchemy.sql.expression import func + +from cps import calibre_db, config, db, logger +from cps.clean_html import clean_string +from cps.cw_advocate import AddrValidator +from cps.cw_advocate import Session as SafeSession +from cps.cw_advocate.exceptions import UnacceptableAddressException + +log = logger.create() + +DEFAULT_SHELFMARK_TIMEOUT_SECONDS = 15 +DEFAULT_SHELFMARK_LIMIT = 12 +DEFAULT_SHELFMARK_SORT = "popularity" +DEFAULT_SHELFMARK_PAGE = 1 +DEFAULT_SHELFMARK_PREFERRED_RELEASE_CONTENT_TYPE = "ebook" +DEFAULT_SHELFMARK_PREFERRED_RELEASE_RANKING = "seeders_desc" +DEFAULT_SHELFMARK_FILTER_REQUESTABLE = True +DEFAULT_SHELFMARK_FILTER_HAS_COVER = True +DEFAULT_SHELFMARK_FILTER_HIGH_CONFIDENCE = False +DEFAULT_SHELFMARK_SERIES_FILTER = "all" +DEFAULT_SHELFMARK_TRIAGE_FILTER = "all" +DEFAULT_SHELFMARK_CONTEXTUAL_LIMIT = 8 +SHELFMARK_CONTEXT_SCAN_PAGE_SIZE = 24 +SHELFMARK_CONTEXT_MAX_SOURCE_PAGES = 5 +SHELFMARK_PAGE_SIZE_OPTIONS = (12, 24, 50, 100) +SHELFMARK_SORT_OPTIONS = ( + ("popularity", "Most popular"), + ("relevance", "Most relevant"), + ("rating", "Highest rated"), + ("newest", "Newest"), + ("oldest", "Oldest"), +) +SHELFMARK_SERIES_FILTER_OPTIONS = ( + ("all", "All matches"), + ("owned", "Owned series"), + ("next_missing", "Next missing"), +) +SHELFMARK_TRIAGE_FILTER_OPTIONS = ( + ("all", "All shown"), + ("strong", "Strong candidates"), +) +SHELFMARK_METADATA_PROVIDER = "hardcover" +SHELFMARK_CONTENT_TYPE = "ebook" +SHELFMARK_REQUEST_MODE = "request_book" +SHELFMARK_REQUEST_KIND = "book" +SHELFMARK_DETAIL_CACHE_TTL_SECONDS = 300 +SHELFMARK_DETAIL_CACHE_MAX_ENTRIES = 256 +HARDCOVER_DETAIL_CACHE_TTL_SECONDS = 300 +HARDCOVER_DETAIL_CACHE_MAX_ENTRIES = 128 +SHELFMARK_COVER_CACHE_TTL_SECONDS = 300 +SHELFMARK_COVER_CACHE_MAX_ENTRIES = 512 +SHELFMARK_SCAN_CACHE_TTL_SECONDS = 120 +SHELFMARK_SCAN_CACHE_MAX_ENTRIES = 16 +SHELFMARK_SCAN_PAGE_SIZE = 100 +SHELFMARK_QUALITY_MIN_METADATA_SIGNALS = 5 +SHELFMARK_TRIAGE_MIN_RATINGS = 200 +SHELFMARK_TRIAGE_MIN_READERS = 1000 +SHELFMARK_TRIAGE_MIN_RATING = 4.0 +SHELFMARK_TRIAGE_MIN_RATING_COUNT = 50 +SHELFMARK_AUDIOBOOK_HINTS = ( + "audiobook", + "audio book", + "audible audio", + "audio cd", + "audio-cd", + "m4b", + "cassette", +) +SHELFMARK_EBOOK_HINTS = ( + "ebook", + "e-book", + "epub", + "digital", + "kindle", +) +SHELFMARK_PHYSICAL_HINTS = ( + "physical", + "physical book", + "hardcover", + "paperback", + "softcover", + "mass market paperback", + "board book", + "print", +) +SHELFMARK_GENRE_CATEGORY_HINTS = ("genre", "genres") +SHELFMARK_MOOD_CATEGORY_HINTS = ("mood", "moods") +SHELFMARK_CONTENT_WARNING_CATEGORY_HINTS = ( + "content warning", + "content warnings", + "warning", + "warnings", + "trigger warning", + "trigger warnings", +) + +_SHELFMARK_DETAIL_CACHE: OrderedDict[tuple[str, str, str], tuple[float, dict[str, Any]]] = OrderedDict() +_HARDCOVER_DETAIL_CACHE: OrderedDict[str, tuple[float, dict[str, Any]]] = OrderedDict() +_SHELFMARK_COVER_CACHE: OrderedDict[tuple[str, str], tuple[float, str]] = OrderedDict() +_SHELFMARK_SCAN_CACHE: OrderedDict[ + tuple[str, str, str, int, int], + tuple[float, tuple[dict[str, Any], ...], int, int, bool], +] = OrderedDict() + + +class ShelfmarkIntegrationError(RuntimeError): + """Raised when the Shelfmark search integration cannot complete safely.""" + + +@dataclass(frozen=True) +class ShelfmarkLibraryMatch: + hardcover_id: str + book_id: int + title: str + + +@dataclass(frozen=True) +class ShelfmarkProbeState: + authenticated: bool = False + auth_required: bool = True + requests_enabled: bool = False + ebook_mode: str | None = None + probe_available: bool = False + + +@dataclass(frozen=True) +class ShelfmarkActionState: + mode: str + label: str + hint: str + button_class: str + icon_class: str + + +@dataclass(frozen=True) +class ShelfmarkLibraryState: + key: str + label: str | None + hint: str | None + row_class: str + badge_class: str | None + panel_class: str + icon_class: str + + +@dataclass(frozen=True) +class ShelfmarkOwnedSeries: + key: str + series_name: str + book_count: int + owned_positions: tuple[float, ...] + max_position: float | None + contiguous_position: int | None + + +@dataclass(frozen=True) +class ShelfmarkSeriesMembership: + key: str + name: str + position: float | None = None + book_count: int | None = None + featured: bool = False + display: str | None = None + url: str | None = None + + def to_template_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "name": self.name, + "position": self.position, + "book_count": self.book_count, + "featured": self.featured, + "display": self.display or _format_series_display(self.name, self.position) or self.name, + "url": self.url, + } + + +@dataclass(frozen=True) +class ShelfmarkSeriesContext: + membership: ShelfmarkSeriesMembership + matched: bool = False + owned_series_name: str | None = None + owned_book_count: int = 0 + owned_max_position: float | None = None + owned_contiguous_position: int | None = None + is_continuation: bool = False + is_next_missing: bool = False + badges: tuple[dict[str, str], ...] = field(default_factory=tuple) + facts: tuple[str, ...] = field(default_factory=tuple) + detail_value: str | None = None + + @property + def series_name(self) -> str: + return self.membership.name + + @property + def series_position(self) -> float | None: + return self.membership.position + + @property + def featured(self) -> bool: + return self.membership.featured + + @property + def series_display(self) -> str: + return self.membership.display or _format_series_display(self.series_name, self.series_position) or self.series_name + + @property + def series_url(self) -> str | None: + return self.membership.url + + def to_template_dict(self) -> dict[str, Any]: + return { + "membership": self.membership.to_template_dict(), + "matched": self.matched, + "owned_series_name": self.owned_series_name, + "owned_book_count": self.owned_book_count, + "owned_max_position": self.owned_max_position, + "owned_contiguous_position": self.owned_contiguous_position, + "is_continuation": self.is_continuation, + "is_next_missing": self.is_next_missing, + "badges": [dict(badge) for badge in self.badges], + "facts": list(self.facts), + "detail_value": self.detail_value, + "series_name": self.series_name, + "series_position": self.series_position, + "series_display": self.series_display, + "series_url": self.series_url, + "featured": self.featured, + } + + +@dataclass(frozen=True) +class ShelfmarkWorkflowState: + key: str + label: str + chip_class: str + + +@dataclass(frozen=True) +class ShelfmarkTriageState: + strong_candidate: bool = False + metadata_rich: bool = False + popularity_signal: bool = False + facts: tuple[str, ...] = field(default_factory=tuple) + detail_value: str | None = None + + +@dataclass(frozen=True) +class ShelfmarkQualityState: + high_confidence: bool = False + metadata_complete: bool = False + popularity_signal: bool = False + rating_signal: bool = False + bibliographic_signal: bool = False + facts: tuple[str, ...] = field(default_factory=tuple) + detail_value: str | None = None + + +@dataclass(frozen=True) +class ShelfmarkResultView: + provider: str + provider_id: str + title: str + subtitle: str | None + authors: tuple[str, ...] + cover_url: str | None + description: str | None + publish_year: int | None + source_url: str | None + display_fields: tuple[dict[str, Any], ...] + rating: float | None + ratings_count: int | None + reviews_count: int | None + readers_count: int | None + hardcover_id: str | None + already_in_library: bool + library_book_id: int | None + library_book_title: str | None + library_book_url: str | None + detail_url: str | None + shelfmark_base_url: str + shelfmark_open_url: str + request_payload: dict[str, Any] | None + library_state: ShelfmarkLibraryState + action: ShelfmarkActionState + pages: int | None = None + editions_count: int | None = None + lists_count: int | None = None + description_html: str | None = None + series_memberships: tuple[ShelfmarkSeriesMembership, ...] = field(default_factory=tuple) + series_contexts: tuple[ShelfmarkSeriesContext, ...] = field(default_factory=tuple) + best_series_context: ShelfmarkSeriesContext | None = None + secondary_series_note: str | None = None + series_name: str | None = None + series_position: float | None = None + series_count: int | None = None + series_display: str | None = None + series_url: str | None = None + series_entries: tuple[dict[str, Any], ...] = field(default_factory=tuple) + facts: tuple[str, ...] = field(default_factory=tuple) + detail_stats: tuple[dict[str, str], ...] = field(default_factory=tuple) + genres: tuple[str, ...] = field(default_factory=tuple) + moods: tuple[str, ...] = field(default_factory=tuple) + content_warnings: tuple[str, ...] = field(default_factory=tuple) + series_context: ShelfmarkSeriesContext | None = None + workflow_state: ShelfmarkWorkflowState | None = None + quality_state: ShelfmarkQualityState | None = None + triage_state: ShelfmarkTriageState | None = None + needs_progressive_enrichment: bool = False + progressive_filter_pending: bool = False + release_date: str | None = None + + def to_template_dict(self) -> dict[str, Any]: + payload = asdict(self) + payload["authors"] = list(self.authors) + payload["display_fields"] = list(self.display_fields) + payload["series_memberships"] = [ + membership.to_template_dict() + for membership in self.series_memberships + ] + payload["series_contexts"] = [ + context.to_template_dict() + for context in self.series_contexts + ] + payload["best_series_context"] = ( + self.best_series_context.to_template_dict() + if self.best_series_context + else None + ) + payload["series_entries"] = list(self.series_entries) + payload["facts"] = list(self.facts) + payload["detail_stats"] = list(self.detail_stats) + payload["genres"] = list(self.genres) + payload["moods"] = list(self.moods) + payload["content_warnings"] = list(self.content_warnings) + if self.series_context: + payload["series_context"] = self.series_context.to_template_dict() + if self.quality_state: + payload["quality_state"]["facts"] = list(self.quality_state.facts) + if self.triage_state: + payload["triage_state"]["facts"] = list(self.triage_state.facts) + payload["library_state"] = asdict(self.library_state) + payload["action"] = asdict(self.action) + return payload + + +@dataclass(frozen=True) +class ShelfmarkResultSummary: + total_results: int = 0 + total_available: int = 0 + raw_total_available: int = 0 + has_more: bool = False + already_in_library: int = 0 + external_candidates: int = 0 + library_match_unavailable: int = 0 + + def to_template_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class ShelfmarkResultGroup: + key: str + title: str + hint: str | None = None + panel_class: str = "panel-default" + badge_class: str = "label-default" + icon_class: str = "glyphicon glyphicon-book" + results: tuple[ShelfmarkResultView, ...] = field(default_factory=tuple) + + @property + def count(self) -> int: + return len(self.results) + + def to_template_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "title": self.title, + "hint": self.hint, + "panel_class": self.panel_class, + "badge_class": self.badge_class, + "icon_class": self.icon_class, + "count": self.count, + "results": [result.to_template_dict() for result in self.results], + } + + +@dataclass(frozen=True) +class ShelfmarkSearchSection: + enabled: bool + available: bool + query: str + page: int = DEFAULT_SHELFMARK_PAGE + page_size: int = DEFAULT_SHELFMARK_LIMIT + selected_sort: str = DEFAULT_SHELFMARK_SORT + sort_options: tuple[dict[str, str], ...] = field(default_factory=tuple) + selected_series_filter: str = DEFAULT_SHELFMARK_SERIES_FILTER + series_filter_options: tuple[dict[str, str], ...] = field(default_factory=tuple) + selected_triage_filter: str = DEFAULT_SHELFMARK_TRIAGE_FILTER + triage_filter_options: tuple[dict[str, str], ...] = field(default_factory=tuple) + page_size_options: tuple[int, ...] = SHELFMARK_PAGE_SIZE_OPTIONS + total_pages: int = 0 + visible_start: int = 0 + visible_end: int = 0 + has_previous: bool = False + previous_page: int | None = None + next_page: int | None = None + has_more: bool = False + total_available: int = 0 + raw_total_available: int = 0 + page_result_count: int = 0 + filter_requestable: bool = DEFAULT_SHELFMARK_FILTER_REQUESTABLE + filter_has_cover: bool = DEFAULT_SHELFMARK_FILTER_HAS_COVER + filter_high_confidence: bool = DEFAULT_SHELFMARK_FILTER_HIGH_CONFIDENCE + filters_active: bool = False + open_search_url: str | None = None + query_label: str | None = None + context_hint: str | None = None + message: str | None = None + message_level: str = "info" + pagination_mode: str = "shelfmark" + progressive_refinement: bool = False + progressive_refinement_note: str | None = None + results: tuple[ShelfmarkResultView, ...] = field(default_factory=tuple) + groups: tuple[ShelfmarkResultGroup, ...] = field(default_factory=tuple) + summary: ShelfmarkResultSummary = field(default_factory=ShelfmarkResultSummary) + + def to_template_dict(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "available": self.available, + "query": self.query, + "page": self.page, + "page_size": self.page_size, + "selected_sort": self.selected_sort, + "sort_options": [dict(option) for option in self.sort_options], + "selected_series_filter": self.selected_series_filter, + "series_filter_options": [dict(option) for option in self.series_filter_options], + "selected_triage_filter": self.selected_triage_filter, + "triage_filter_options": [dict(option) for option in self.triage_filter_options], + "page_size_options": list(self.page_size_options), + "total_pages": self.total_pages, + "visible_start": self.visible_start, + "visible_end": self.visible_end, + "has_previous": self.has_previous, + "previous_page": self.previous_page, + "next_page": self.next_page, + "has_more": self.has_more, + "total_available": self.total_available, + "raw_total_available": self.raw_total_available, + "page_result_count": self.page_result_count, + "filter_requestable": self.filter_requestable, + "filter_has_cover": self.filter_has_cover, + "filter_high_confidence": self.filter_high_confidence, + "filters_active": self.filters_active, + "open_search_url": self.open_search_url, + "query_label": self.query_label, + "context_hint": self.context_hint, + "message": self.message, + "message_level": self.message_level, + "pagination_mode": self.pagination_mode, + "progressive_refinement": self.progressive_refinement, + "progressive_refinement_note": self.progressive_refinement_note, + "results": [result.to_template_dict() for result in self.results], + "groups": [group.to_template_dict() for group in self.groups], + "summary": self.summary.to_template_dict(), + } + + +@dataclass(frozen=True) +class ShelfmarkClientConfig: + enabled: bool + base_url: str + browser_base_url: str + username: str | None + password: str | None + timeout_seconds: int = DEFAULT_SHELFMARK_TIMEOUT_SECONDS + + +@dataclass(frozen=True) +class ShelfmarkSearchResponse: + books: tuple[dict[str, Any], ...] + page: int = DEFAULT_SHELFMARK_PAGE + total_found: int = 0 + has_more: bool = False + + +@dataclass(frozen=True) +class ShelfmarkPreferredReleaseSettings: + enabled: bool = False + provider: str = "" + content_type: str = DEFAULT_SHELFMARK_PREFERRED_RELEASE_CONTENT_TYPE + ranking: str = DEFAULT_SHELFMARK_PREFERRED_RELEASE_RANKING + + def to_template_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class ShelfmarkDiagnosticCheck: + key: str + label: str + status: str + summary: str + detail: str | None = None + + def to_template_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class ShelfmarkPreferredProviderOption: + value: str + label: str + kind: str + + def to_template_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class ShelfmarkSettingsDiagnostics: + success: bool + checks: tuple[ShelfmarkDiagnosticCheck, ...] = field(default_factory=tuple) + provider_options: tuple[ShelfmarkPreferredProviderOption, ...] = field(default_factory=tuple) + provider_options_message: str | None = None + selected_probe_source: str | None = None + + def to_template_dict(self) -> dict[str, Any]: + return { + "success": self.success, + "checks": [check.to_template_dict() for check in self.checks], + "provider_options": [option.to_template_dict() for option in self.provider_options], + "provider_options_message": self.provider_options_message, + "selected_probe_source": self.selected_probe_source, + } + + +def clear_shelfmark_detail_cache() -> None: + _SHELFMARK_DETAIL_CACHE.clear() + + +def clear_shelfmark_cover_cache() -> None: + _SHELFMARK_COVER_CACHE.clear() + + +def clear_shelfmark_scan_cache() -> None: + _SHELFMARK_SCAN_CACHE.clear() + + +def get_shelfmark_client_config(values: Mapping[str, Any] | None = None) -> ShelfmarkClientConfig: + raw_values = values or {} + base_url = str( + raw_values.get("config_shelfmark_url", getattr(config, "config_shelfmark_url", "")) or "" + ).strip() + browser_base_url = str( + raw_values.get( + "config_shelfmark_browser_url", + getattr(config, "config_shelfmark_browser_url", ""), + ) + or "" + ).strip() or base_url + username = str( + raw_values.get( + "config_shelfmark_username", + getattr(config, "config_shelfmark_username", ""), + ) + or "" + ).strip() or None + password_value = raw_values.get("config_shelfmark_password_e") + if password_value is None: + password = str(getattr(config, "config_shelfmark_password_e", "") or "").strip() or None + else: + password = str(password_value or "").strip() or ( + str(getattr(config, "config_shelfmark_password_e", "") or "").strip() or None + ) + enabled_flag = raw_values.get( + "config_shelfmark_search", + getattr(config, "config_shelfmark_search", False), + ) + enabled = ( + (_normalize_flag(enabled_flag) if isinstance(enabled_flag, (str, bytes)) else bool(enabled_flag)) + and bool(base_url) + ) + return ShelfmarkClientConfig( + enabled=enabled, + base_url=base_url, + browser_base_url=browser_base_url, + username=username, + password=password, + ) + + +def get_shelfmark_preferred_release_settings( + values: Mapping[str, Any] | None = None, +) -> ShelfmarkPreferredReleaseSettings: + raw_values = values or {} + provider = _normalize_text( + raw_values.get( + "config_shelfmark_preferred_release_provider", + getattr(config, "config_shelfmark_preferred_release_provider", ""), + ) + or "" + ) or "" + content_type = _normalize_preferred_release_content_type( + raw_values.get( + "config_shelfmark_preferred_release_content_type", + getattr( + config, + "config_shelfmark_preferred_release_content_type", + DEFAULT_SHELFMARK_PREFERRED_RELEASE_CONTENT_TYPE, + ), + ) + ) + ranking = ( + _normalize_text( + raw_values.get( + "config_shelfmark_preferred_release_ranking", + getattr( + config, + "config_shelfmark_preferred_release_ranking", + DEFAULT_SHELFMARK_PREFERRED_RELEASE_RANKING, + ), + ) + ) + or DEFAULT_SHELFMARK_PREFERRED_RELEASE_RANKING + ).lower() + if ranking != DEFAULT_SHELFMARK_PREFERRED_RELEASE_RANKING: + ranking = DEFAULT_SHELFMARK_PREFERRED_RELEASE_RANKING + + return ShelfmarkPreferredReleaseSettings( + enabled=( + _normalize_flag( + raw_values.get( + "config_shelfmark_preferred_release_enabled", + getattr(config, "config_shelfmark_preferred_release_enabled", False), + ) + ) + if isinstance( + raw_values.get( + "config_shelfmark_preferred_release_enabled", + getattr(config, "config_shelfmark_preferred_release_enabled", False), + ), + (str, bytes), + ) + else bool( + raw_values.get( + "config_shelfmark_preferred_release_enabled", + getattr(config, "config_shelfmark_preferred_release_enabled", False), + ) + ) + ), + provider=provider, + content_type=content_type, + ranking=ranking, + ) + + +def _base_url_host_port(base_url: str) -> tuple[str, int]: + parsed = urlsplit(base_url) + host = parsed.hostname + if not parsed.scheme or not host: + raise ShelfmarkIntegrationError( + _("Configure Shelfmark Base URL as a full http:// or https:// URL.") + ) + if parsed.port is not None: + return host, parsed.port + return host, 443 if parsed.scheme.lower() == "https" else 80 + + +def _ip_network_for_address(value: str) -> ipaddress._BaseNetwork: + address = ipaddress.ip_address(value) + suffix = 32 if address.version == 4 else 128 + return ipaddress.ip_network(f"{address.exploded}/{suffix}", strict=False) + + +def _resolve_trusted_ip_networks( + host: str, + port: int, + *, + resolver: Callable[..., Sequence[Any]] | None = None, +) -> set[ipaddress._BaseNetwork]: + try: + return {_ip_network_for_address(host)} + except ValueError: + pass + + lookup = resolver or socket.getaddrinfo + try: + records = lookup(host, port, 0, socket.SOCK_STREAM) + except socket.gaierror as exc: + raise ShelfmarkIntegrationError( + _("Shelfmark Base URL could not be resolved for trusted server-side access.") + ) from exc + + trusted_networks: set[ipaddress._BaseNetwork] = set() + for record in records: + try: + socket_address = record[4] + raw_ip = str(socket_address[0]).split("%", 1)[0] + trusted_networks.add(_ip_network_for_address(raw_ip)) + except (IndexError, TypeError, ValueError): + continue + + if not trusted_networks: + raise ShelfmarkIntegrationError( + _("Shelfmark Base URL could not be resolved for trusted server-side access.") + ) + return trusted_networks + + +def build_shelfmark_validator( + config_data: ShelfmarkClientConfig, + *, + resolver: Callable[..., Sequence[Any]] | None = None, +) -> AddrValidator: + host, port = _base_url_host_port(config_data.base_url) + trusted_networks = _resolve_trusted_ip_networks(host, port, resolver=resolver) + return AddrValidator( + ip_whitelist=trusted_networks, + port_whitelist={port}, + ) + + +def create_shelfmark_session( + config_data: ShelfmarkClientConfig, + *, + session_factory: Callable[..., SafeSession] = SafeSession, + resolver: Callable[..., Sequence[Any]] | None = None, +): + validator = build_shelfmark_validator(config_data, resolver=resolver) + return session_factory(validator=validator) + + +def build_shelfmark_open_url( + base_url: str, + *, + title: str, + authors: Sequence[str], + hardcover_id: str | None, + content_type: str = SHELFMARK_CONTENT_TYPE, +) -> str: + # Shelfmark does not currently expose a stable URL-addressable metadata-book + # detail route. Use the most specific browser search URL it supports today. + primary_author = _normalize_text(authors[0]) if authors else None + query_terms = [value for value in (title, primary_author, hardcover_id) if value] + search_query = " ".join(query_terms[:2]) or (hardcover_id or "") + params: dict[str, str] = { + "content_type": content_type, + "sort": DEFAULT_SHELFMARK_SORT, + } + if search_query: + params["query"] = search_query + if title: + params["title"] = title + if primary_author: + params["author"] = primary_author + return _with_query(_join_base_url(base_url, "/"), params) + + +def build_shelfmark_search_url( + base_url: str, + *, + query: str, + content_type: str = SHELFMARK_CONTENT_TYPE, + page: int = DEFAULT_SHELFMARK_PAGE, + page_size: int = DEFAULT_SHELFMARK_LIMIT, + sort: str = DEFAULT_SHELFMARK_SORT, +) -> str: + params = { + "content_type": content_type, + "sort": sort or DEFAULT_SHELFMARK_SORT, + "limit": str(_normalize_page_size(page_size)), + "page": str(page), + "query": query, + } + return _with_query(_join_base_url(base_url, "/"), params) + + +def build_shelfmark_request_payload(book: Mapping[str, Any]) -> dict[str, Any] | None: + hardcover_id = _extract_hardcover_id(book) + title = _resolve_shelfmark_title(book) + authors = _resolve_shelfmark_authors(book) + if not hardcover_id or not title or not authors: + return None + + payload: dict[str, Any] = { + "book_data": { + "title": title, + "author": authors[0], + "provider": SHELFMARK_METADATA_PROVIDER, + "provider_id": hardcover_id, + }, + "content_type": SHELFMARK_CONTENT_TYPE, + "context": { + "source": "*", + "content_type": SHELFMARK_CONTENT_TYPE, + "request_level": SHELFMARK_REQUEST_KIND, + }, + } + + subtitle = _normalize_text(book.get("subtitle")) + if subtitle: + payload["book_data"]["subtitle"] = subtitle + source_url = _normalize_text(book.get("source_url")) + if source_url: + payload["book_data"]["source_url"] = source_url + + return payload + + +def build_shelfmark_advanced_query(term: Mapping[str, Any]) -> tuple[str | None, tuple[str, ...]]: + title = _normalize_text(term.get("title")) + author = _normalize_text(term.get("authors")) + publisher = _normalize_text(term.get("publisher")) + fragments: list[str] = [] + labels: list[str] = [] + seen: set[str] = set() + + candidates = ( + (_("title"), title), + (_("author"), author.replace("|", ",") if author else None), + (_("publisher"), publisher), + ) + for label, raw_value in candidates: + normalized = _normalize_text(raw_value) + if not normalized: + continue + dedupe_key = normalized.casefold() + if dedupe_key in seen: + continue + seen.add(dedupe_key) + fragments.append(normalized) + labels.append(label) + + if not fragments: + return None, tuple() + + return " ".join(fragments), tuple(labels) + + +def get_shelfmark_sort_options() -> tuple[dict[str, str], ...]: + return tuple( + { + "value": value, + "label": _(label), + } + for value, label in SHELFMARK_SORT_OPTIONS + ) + + +def get_shelfmark_series_filter_options() -> tuple[dict[str, str], ...]: + return tuple( + { + "value": value, + "label": _(label), + } + for value, label in SHELFMARK_SERIES_FILTER_OPTIONS + ) + + +def get_shelfmark_triage_filter_options() -> tuple[dict[str, str], ...]: + return tuple( + { + "value": value, + "label": _(label), + } + for value, label in SHELFMARK_TRIAGE_FILTER_OPTIONS + ) + + +def _normalize_page_size(value: Any) -> int: + normalized = _normalize_int(value) or DEFAULT_SHELFMARK_LIMIT + if normalized in SHELFMARK_PAGE_SIZE_OPTIONS: + return normalized + return DEFAULT_SHELFMARK_LIMIT + + +def _normalize_sort(value: Any) -> str: + normalized = (_normalize_text(value) or DEFAULT_SHELFMARK_SORT).lower() + if normalized in {item[0] for item in SHELFMARK_SORT_OPTIONS}: + return normalized + return DEFAULT_SHELFMARK_SORT + + +def _normalize_series_filter(value: Any) -> str: + normalized = (_normalize_text(value) or DEFAULT_SHELFMARK_SERIES_FILTER).lower() + if normalized in {item[0] for item in SHELFMARK_SERIES_FILTER_OPTIONS}: + return normalized + return DEFAULT_SHELFMARK_SERIES_FILTER + + +def _normalize_triage_filter(value: Any) -> str: + normalized = (_normalize_text(value) or DEFAULT_SHELFMARK_TRIAGE_FILTER).lower() + if normalized in {item[0] for item in SHELFMARK_TRIAGE_FILTER_OPTIONS}: + return normalized + return DEFAULT_SHELFMARK_TRIAGE_FILTER + + +def _normalize_flag(value: Any) -> bool: + normalized = (_normalize_text(value) or "").lower() + return normalized in {"1", "true", "yes", "on"} + + +def parse_shelfmark_probe_state( + auth_payload: Mapping[str, Any] | None, + policy_payload: Mapping[str, Any] | None, +) -> ShelfmarkProbeState: + authenticated = bool(auth_payload and auth_payload.get("authenticated")) + auth_required = True if auth_payload is None else bool(auth_payload.get("auth_required", True)) + requests_enabled = bool(policy_payload and policy_payload.get("requests_enabled")) + ebook_mode = None + if policy_payload and isinstance(policy_payload.get("defaults"), Mapping): + raw_mode = policy_payload["defaults"].get("ebook") + ebook_mode = str(raw_mode).strip().lower() if raw_mode else None + return ShelfmarkProbeState( + authenticated=authenticated, + auth_required=auth_required, + requests_enabled=requests_enabled, + ebook_mode=ebook_mode, + probe_available=auth_payload is not None, + ) + + +def select_shelfmark_action( + *, + already_in_library: bool, + library_book_url: str | None, + hardcover_id: str | None, + request_payload: Mapping[str, Any] | None, + missing_request_requirements: Sequence[str] = (), + probe_state: ShelfmarkProbeState | None, +) -> ShelfmarkActionState: + if already_in_library and library_book_url: + return ShelfmarkActionState( + mode="view_library", + label=_("Open existing CWA book"), + hint=None, + button_class="btn-success", + icon_class="glyphicon glyphicon-book", + ) + + if not hardcover_id: + return ShelfmarkActionState( + mode="open", + label=_("Open in Shelfmark"), + hint=_("Direct request needs an exact Hardcover ID."), + button_class="btn-default", + icon_class="glyphicon glyphicon-new-window", + ) + + if request_payload is None: + requirement_hint = _describe_missing_request_requirements(missing_request_requirements) + return ShelfmarkActionState( + mode="open", + label=_("Open in Shelfmark"), + hint=requirement_hint + or _("Direct request needs more exact metadata from Shelfmark."), + button_class="btn-default", + icon_class="glyphicon glyphicon-new-window", + ) + + if probe_state is None or not probe_state.probe_available: + return ShelfmarkActionState( + mode="request", + label=_("Request in Shelfmark"), + hint=None, + button_class="btn-primary", + icon_class="glyphicon glyphicon-send", + ) + + if not probe_state.authenticated: + suffix = _("Login required") if probe_state.auth_required else _("Open in Shelfmark") + return ShelfmarkActionState( + mode="open", + label=_("Open in Shelfmark"), + hint=_("Shelfmark needs a live browser login here. %(suffix)s.", suffix=suffix), + button_class="btn-default", + icon_class="glyphicon glyphicon-new-window", + ) + + if probe_state.requests_enabled and probe_state.ebook_mode == SHELFMARK_REQUEST_MODE: + return ShelfmarkActionState( + mode="request", + label=_("Request in Shelfmark"), + hint=None, + button_class="btn-primary", + icon_class="glyphicon glyphicon-send", + ) + + return ShelfmarkActionState( + mode="open", + label=_("Open in Shelfmark"), + hint=_("Open this in Shelfmark to continue under the current request policy."), + button_class="btn-default", + icon_class="glyphicon glyphicon-new-window", + ) + + +def build_shelfmark_library_state( + *, + library_match: ShelfmarkLibraryMatch | None, + hardcover_id: str | None, +) -> ShelfmarkLibraryState: + if library_match is not None: + return ShelfmarkLibraryState( + key="already_in_library", + label=_("In library"), + hint=None, + row_class="success", + badge_class="label-success", + panel_class="panel-success", + icon_class="glyphicon glyphicon-ok-circle", + ) + + if hardcover_id: + return ShelfmarkLibraryState( + key="external_candidate", + label=None, + hint=None, + row_class="info", + badge_class=None, + panel_class="panel-info", + icon_class="glyphicon glyphicon-cloud-download", + ) + + return ShelfmarkLibraryState( + key="library_match_unavailable", + label=_("No Hardcover ID"), + hint=_("Duplicate check is unavailable because Shelfmark did not return an exact Hardcover ID."), + row_class="warning", + badge_class="label-warning", + panel_class="panel-warning", + icon_class="glyphicon glyphicon-question-sign", + ) + + +def build_shelfmark_workflow_state( + *, + already_in_library: bool, + hardcover_id: str | None, + request_payload: Mapping[str, Any] | None, +) -> ShelfmarkWorkflowState | None: + if already_in_library: + return ShelfmarkWorkflowState( + key="imported", + label=_("In library"), + chip_class="shelfmark-status-chip--imported", + ) + + if hardcover_id and request_payload is not None: + return ShelfmarkWorkflowState( + key="available", + label=_("Available to request"), + chip_class="shelfmark-status-chip--available", + ) + + return None + + +def build_library_match_map(rows: Iterable[Mapping[str, Any]]) -> dict[str, ShelfmarkLibraryMatch]: + matches: dict[str, ShelfmarkLibraryMatch] = {} + for row in rows: + hardcover_id = _normalize_text(row.get("hardcover_id")) + if not hardcover_id or hardcover_id in matches: + continue + try: + book_id = int(row.get("book_id")) + except (TypeError, ValueError): + continue + matches[hardcover_id] = ShelfmarkLibraryMatch( + hardcover_id=hardcover_id, + book_id=book_id, + title=_normalize_text(row.get("title")) or "", + ) + return matches + + +def lookup_visible_library_matches(hardcover_ids: Sequence[str]) -> dict[str, ShelfmarkLibraryMatch]: + normalized_ids = [value for value in (_normalize_text(identifier) for identifier in hardcover_ids) if value] + if not normalized_ids: + return {} + + rows = ( + calibre_db.session.query( + db.Identifiers.val.label("hardcover_id"), + db.Books.id.label("book_id"), + db.Books.title.label("title"), + ) + .join(db.Books, db.Identifiers.book == db.Books.id) + .filter(func.lower(db.Identifiers.type) == "hardcover-id") + .filter(db.Identifiers.val.in_(normalized_ids)) + .filter(calibre_db.common_filters()) + .order_by(db.Books.id.asc()) + .all() + ) + + return build_library_match_map( + { + "hardcover_id": row.hardcover_id, + "book_id": row.book_id, + "title": row.title, + } + for row in rows + ) + + +def _normalize_series_name_key(value: Any) -> str | None: + normalized = _normalize_text(value) + if not normalized: + return None + return re.sub(r"\s+", " ", normalized.casefold()).strip() or None + + +def _largest_contiguous_series_prefix(positions: Sequence[float]) -> int | None: + normalized_positions = sorted( + {int(position) for position in positions if position > 0 and float(position).is_integer()} + ) + if not normalized_positions: + return None + + expected = 1 + for position in normalized_positions: + if position != expected: + return expected - 1 + expected += 1 + return expected - 1 + + +def build_owned_series_map(rows: Iterable[Mapping[str, Any]]) -> dict[str, ShelfmarkOwnedSeries]: + grouped: dict[str, dict[str, Any]] = {} + for row in rows: + series_name = _normalize_text(row.get("series_name")) + key = _normalize_series_name_key(series_name) + if not key: + continue + + item = grouped.setdefault( + key, + { + "series_name": series_name or "", + "book_ids": set(), + "positions": [], + }, + ) + book_id = _normalize_int(row.get("book_id")) + if book_id is not None: + item["book_ids"].add(book_id) + series_position = _normalize_series_position(row.get("series_position")) + if series_position is not None: + item["positions"].append(series_position) + + owned_series: dict[str, ShelfmarkOwnedSeries] = {} + for key, item in grouped.items(): + owned_positions = tuple(sorted(set(item["positions"]))) + max_position = owned_positions[-1] if owned_positions else None + owned_series[key] = ShelfmarkOwnedSeries( + key=key, + series_name=item["series_name"], + book_count=len(item["book_ids"]), + owned_positions=owned_positions, + max_position=max_position, + contiguous_position=_largest_contiguous_series_prefix(owned_positions), + ) + return owned_series + + +def lookup_visible_owned_series(series_names: Sequence[str]) -> dict[str, ShelfmarkOwnedSeries]: + normalized_names = { + value + for value in (_normalize_series_name_key(series_name) for series_name in series_names) + if value + } + if not normalized_names: + return {} + + rows = ( + calibre_db.session.query( + db.Series.name.label("series_name"), + db.Books.id.label("book_id"), + db.Books.series_index.label("series_position"), + ) + .join(db.books_series_link, db.books_series_link.c.series == db.Series.id) + .join(db.Books, db.books_series_link.c.book == db.Books.id) + .filter(func.lower(db.Series.name).in_(tuple(normalized_names))) + .filter(calibre_db.common_filters()) + .order_by(db.Series.name.asc(), db.Books.id.asc()) + .all() + ) + + return build_owned_series_map( + { + "series_name": row.series_name, + "book_id": row.book_id, + "series_position": row.series_position, + } + for row in rows + ) + + +def _build_series_context_badges( + *, + matched: bool, + is_continuation: bool, + is_next_missing: bool, +) -> tuple[dict[str, str], ...]: + if is_next_missing: + return ({"label": _("Next missing"), "badge_class": "label-primary"},) + if is_continuation: + return ({"label": _("Continue series"), "badge_class": "label-info"},) + if matched: + return ({"label": _("Owned series"), "badge_class": "label-default"},) + return tuple() + + +def _build_series_context_facts( + *, + matched: bool, + owned_book_count: int, + owned_contiguous_position: int | None, + owned_max_position: float | None, + is_continuation: bool, + is_next_missing: bool, +) -> tuple[str, ...]: + if not matched: + return tuple() + + facts: list[str] = [] + if owned_book_count: + label = _("book") if owned_book_count == 1 else _("books") + facts.append( + _("%(count)s %(label)s owned in this series", count=owned_book_count, label=label) + ) + + if owned_contiguous_position and owned_contiguous_position > 0: + facts.append(_("Owned through %(position)s", position=owned_contiguous_position)) + + return tuple(facts) + + +def _build_series_context_detail_value(context: ShelfmarkSeriesContext) -> str | None: + if not context.matched: + return None + parts: list[str] = [] + if context.is_next_missing: + parts.append(_("Next missing")) + elif context.is_continuation: + parts.append(_("Continue series")) + else: + parts.append(_("Owned series")) + parts.extend(context.facts) + return " \u00b7 ".join(part for part in parts if part) + + +def _build_series_context_for_membership( + result: ShelfmarkResultView, + membership: ShelfmarkSeriesMembership, + owned_series: Mapping[str, ShelfmarkOwnedSeries], +) -> ShelfmarkSeriesContext | None: + owned = owned_series.get(membership.key) + if owned is None: + return None + + expected_next = ( + owned.contiguous_position + 1 + if owned.contiguous_position is not None + else None + ) + numeric_position = membership.position + is_next_missing = bool( + not result.already_in_library + and numeric_position is not None + and float(numeric_position).is_integer() + and expected_next is not None + and int(numeric_position) == expected_next + ) + is_continuation = bool( + not result.already_in_library + and numeric_position is not None + and owned.max_position is not None + and numeric_position > owned.max_position + ) + context = ShelfmarkSeriesContext( + membership=membership, + matched=True, + owned_series_name=owned.series_name, + owned_book_count=owned.book_count, + owned_max_position=owned.max_position, + owned_contiguous_position=owned.contiguous_position, + is_continuation=is_continuation, + is_next_missing=is_next_missing, + ) + context = replace( + context, + badges=_build_series_context_badges( + matched=context.matched, + is_continuation=context.is_continuation and not context.is_next_missing, + is_next_missing=context.is_next_missing, + ), + ) + context = replace( + context, + facts=_build_series_context_facts( + matched=context.matched, + owned_book_count=context.owned_book_count, + owned_contiguous_position=context.owned_contiguous_position, + owned_max_position=context.owned_max_position, + is_continuation=context.is_continuation and not context.is_next_missing, + is_next_missing=context.is_next_missing, + ), + ) + return replace(context, detail_value=_build_series_context_detail_value(context)) + + +def build_shelfmark_series_membership_contexts( + results: Sequence[ShelfmarkResultView], + owned_series: Mapping[str, ShelfmarkOwnedSeries], +) -> tuple[tuple[ShelfmarkSeriesContext, ...], ...]: + all_contexts: list[tuple[ShelfmarkSeriesContext, ...]] = [] + for result in results: + contexts = tuple( + context + for context in ( + _build_series_context_for_membership(result, membership, owned_series) + for membership in result.series_memberships + ) + if context is not None + ) + all_contexts.append(contexts) + return tuple(all_contexts) + + +def _series_name_specificity_key(value: str | None) -> tuple[int, int]: + normalized = _normalize_text(value) + if not normalized: + return (0, 0) + tokens = [token for token in re.split(r"[\s\-:]+", normalized) if token] + return (len(tokens), len(normalized)) + + +def _series_membership_sort_key(membership: ShelfmarkSeriesMembership) -> tuple[int, int, int, float, str]: + specificity = _series_name_specificity_key(membership.name) + return ( + 0 if membership.featured else 1, + -specificity[0], + -specificity[1], + membership.position if membership.position is not None else float("inf"), + membership.name.casefold(), + ) + + +def _series_context_sort_key( + context: ShelfmarkSeriesContext, + *, + preferred_series_name: str | None = None, +) -> tuple[int, int, float, int, int, int, str]: + normalized_preferred = _normalize_series_name_key(preferred_series_name) + preferred_rank = 0 if normalized_preferred and context.membership.key == normalized_preferred else 1 + if context.is_next_missing: + bucket = 0 + elif context.is_continuation: + bucket = 1 + elif context.matched: + bucket = 2 + else: + bucket = 3 + specificity = _series_name_specificity_key(context.series_name) + return ( + preferred_rank, + bucket, + context.series_position if context.series_position is not None else float("inf"), + -specificity[0], + -specificity[1], + 0 if context.featured else 1, + context.series_name.casefold(), + ) + + +def _select_best_series_context( + contexts: Sequence[ShelfmarkSeriesContext], + *, + preferred_series_name: str | None = None, +) -> ShelfmarkSeriesContext | None: + if not contexts: + return None + return min( + contexts, + key=lambda context: _series_context_sort_key( + context, + preferred_series_name=preferred_series_name, + ), + ) + + +def _select_best_series_membership( + memberships: Sequence[ShelfmarkSeriesMembership], + *, + preferred_series_name: str | None = None, +) -> ShelfmarkSeriesMembership | None: + if not memberships: + return None + normalized_preferred = _normalize_series_name_key(preferred_series_name) + if normalized_preferred: + matching = [membership for membership in memberships if membership.key == normalized_preferred] + if matching: + return min(matching, key=_series_membership_sort_key) + return min(memberships, key=_series_membership_sort_key) + + +def _build_secondary_series_note( + memberships: Sequence[ShelfmarkSeriesMembership], + primary_name: str | None, +) -> str | None: + remaining = [ + membership.name + for membership in memberships + if membership.name and _normalize_text(membership.name) != _normalize_text(primary_name) + ] + if not remaining: + return None + if len(remaining) == 1: + return _("Also in %(series)s", series=remaining[0]) + return _("Also in %(series)s and more", series=remaining[0]) + + +def _build_result_facts_for_view(result: ShelfmarkResultView) -> tuple[str, ...]: + facts: list[str] = [] + + if result.rating is not None: + facts.append(f"{result.rating:.1f} \u2605") + if result.ratings_count: + label = _("rating") if result.ratings_count == 1 else _("ratings") + facts.append(f"{result.ratings_count:,} {label}") + if result.readers_count: + label = _("reader") if result.readers_count == 1 else _("readers") + facts.append(f"{result.readers_count:,} {label}") + if result.publish_year: + facts.append(str(result.publish_year)) + if result.pages: + label = _("page") if result.pages == 1 else _("pages") + facts.append(f"{result.pages:,} {label}") + if result.series_display: + facts.append(result.series_display) + + return tuple(facts) + + +def apply_primary_series_context( + result: ShelfmarkResultView, + *, + preferred_series_name: str | None = None, +) -> ShelfmarkResultView: + best_context = _select_best_series_context( + result.series_contexts, + preferred_series_name=preferred_series_name, + ) + primary_membership = ( + best_context.membership + if best_context is not None + else _select_best_series_membership( + result.series_memberships, + preferred_series_name=preferred_series_name, + ) + ) + primary_series_name = primary_membership.name if primary_membership is not None else None + primary_series_position = primary_membership.position if primary_membership is not None else None + primary_series_display = ( + primary_membership.display + if primary_membership is not None + else None + ) + primary_series_url = primary_membership.url if primary_membership is not None else None + + updated = replace( + result, + best_series_context=best_context, + series_context=best_context, + series_name=primary_series_name, + series_position=primary_series_position, + series_display=primary_series_display, + series_url=primary_series_url, + secondary_series_note=_build_secondary_series_note( + result.series_memberships, + primary_series_name, + ), + ) + return replace(updated, facts=_build_result_facts_for_view(updated)) + + +def build_shelfmark_series_contexts( + results: Sequence[ShelfmarkResultView], + owned_series: Mapping[str, ShelfmarkOwnedSeries], +) -> tuple[ShelfmarkSeriesContext | None, ...]: + return tuple( + _select_best_series_context(contexts) + for contexts in build_shelfmark_series_membership_contexts(results, owned_series) + ) + + +def _series_rank_key(result: ShelfmarkResultView) -> tuple[int, int, int, float, int]: + context = result.best_series_context or result.series_context + if context and context.is_next_missing: + bucket = 0 + elif context and context.is_continuation: + bucket = 1 + elif context and context.matched: + bucket = 2 + elif result.series_memberships: + bucket = 3 + else: + bucket = 4 + + duplicate_rank = 1 if result.already_in_library else 0 + request_rank = 0 if (result.hardcover_id and result.request_payload) else 1 + series_position = result.series_position if result.series_position is not None else float("inf") + extra_membership_rank = 0 if len(result.series_memberships) > 1 else 1 + return bucket, duplicate_rank, request_rank, series_position, extra_membership_rank + + +def _triage_rank_key(result: ShelfmarkResultView) -> tuple[int, int, int]: + triage = result.triage_state + strong_rank = 0 if triage and triage.strong_candidate else 1 + metadata_rank = 0 if triage and triage.metadata_rich else 1 + popularity_rank = 0 if triage and triage.popularity_signal else 1 + return strong_rank, metadata_rank, popularity_rank + + +def _quality_rank_key(result: ShelfmarkResultView) -> tuple[int, int, int, int]: + quality = result.quality_state + confidence_rank = 0 if quality and quality.high_confidence else 1 + rating_rank = 0 if quality and quality.rating_signal else 1 + popularity_rank = 0 if quality and quality.popularity_signal else 1 + metadata_rank = 0 if quality and quality.metadata_complete else 1 + return confidence_rank, rating_rank, popularity_rank, metadata_rank + + +def _rank_visible_results(results: Sequence[ShelfmarkResultView]) -> tuple[ShelfmarkResultView, ...]: + indexed_results = list(enumerate(results)) + indexed_results.sort( + key=lambda item: ( + *_series_rank_key(item[1]), + *_triage_rank_key(item[1]), + *_quality_rank_key(item[1]), + item[0], + ) + ) + return tuple(result for _, result in indexed_results) + + +def build_shelfmark_result_view( + book: Mapping[str, Any], + *, + library_match: ShelfmarkLibraryMatch | None, + detail_url: str | None, + shelfmark_browser_base_url: str, + probe_state: ShelfmarkProbeState | None = None, + series_context: ShelfmarkSeriesContext | None = None, +) -> ShelfmarkResultView: + title = _resolve_shelfmark_title(book) or _("Unknown title") + authors = tuple(_resolve_shelfmark_authors(book)) + hardcover_id = _extract_hardcover_id(book) + publish_year = _resolve_publish_year(book) + description_html = _sanitize_description_html(book.get("description")) + series_memberships = _resolve_series_memberships(book) + primary_membership = _select_best_series_membership(series_memberships) + series_name = ( + primary_membership.name + if primary_membership is not None + else _resolve_series_name(book) + ) + series_position = ( + primary_membership.position + if primary_membership is not None + else _resolve_series_position(book) + ) + series_count = ( + primary_membership.book_count + if primary_membership is not None and primary_membership.book_count is not None + else _resolve_series_count(book) + ) + series_entries = _series_memberships_to_entries(series_memberships) + rating = _resolve_rating_value(book) + ratings_count = _resolve_ratings_count(book) + reviews_count = _resolve_reviews_count(book) + readers_count = _resolve_readers_count(book) + pages = _resolve_pages(book) + editions_count = _resolve_editions_count(book) + lists_count = _resolve_lists_count(book) + library_book_url = ( + url_for("web.show_book", book_id=library_match.book_id) + if library_match is not None + else None + ) + request_payload = build_shelfmark_request_payload(book) + missing_request_requirements = _missing_request_requirements(book) + library_state = build_shelfmark_library_state( + library_match=library_match, + hardcover_id=hardcover_id, + ) + action = select_shelfmark_action( + already_in_library=library_match is not None, + library_book_url=library_book_url, + hardcover_id=hardcover_id, + request_payload=request_payload, + missing_request_requirements=missing_request_requirements, + probe_state=probe_state, + ) + return ShelfmarkResultView( + provider=_normalize_text(book.get("provider")) or SHELFMARK_METADATA_PROVIDER, + provider_id=_normalize_text(book.get("provider_id")) or "", + title=title, + subtitle=_resolve_shelfmark_subtitle(book), + authors=authors, + cover_url=_normalize_shelfmark_cover_url( + shelfmark_browser_base_url, + _resolve_shelfmark_cover_value(book), + ), + description=_plain_text_from_html(description_html), + release_date=_normalize_text(book.get("release_date")), + publish_year=publish_year, + source_url=_resolve_source_url(book), + display_fields=_normalize_display_fields(book.get("display_fields")), + rating=rating, + ratings_count=ratings_count, + reviews_count=reviews_count, + readers_count=readers_count, + hardcover_id=hardcover_id, + already_in_library=library_match is not None, + library_book_id=library_match.book_id if library_match is not None else None, + library_book_title=library_match.title if library_match is not None else None, + library_book_url=library_book_url, + detail_url=detail_url, + shelfmark_base_url=shelfmark_browser_base_url, + shelfmark_open_url=build_shelfmark_open_url( + shelfmark_browser_base_url, + title=title, + authors=authors, + hardcover_id=hardcover_id, + ), + request_payload=request_payload, + library_state=library_state, + action=action, + pages=pages, + editions_count=editions_count, + lists_count=lists_count, + description_html=description_html, + series_memberships=series_memberships, + best_series_context=series_context, + series_name=series_name, + series_position=series_position, + series_count=series_count, + series_display=( + primary_membership.display + if primary_membership is not None + else _format_series_display(series_name, series_position) + ), + series_url=( + primary_membership.url + if primary_membership is not None + else _resolve_series_url(book) + ), + series_entries=series_entries, + facts=_build_result_facts(book, publish_year=publish_year), + detail_stats=_build_detail_stats(book, publish_year=publish_year), + genres=_resolve_genres(book), + moods=_resolve_moods(book), + content_warnings=_resolve_content_warnings(book), + series_context=series_context, + workflow_state=build_shelfmark_workflow_state( + already_in_library=library_match is not None, + hardcover_id=hardcover_id, + request_payload=request_payload, + ), + ) + + +def summarize_shelfmark_results( + results: Sequence[ShelfmarkResultView], + *, + total_available: int | None = None, + raw_total_available: int | None = None, + has_more: bool = False, +) -> ShelfmarkResultSummary: + total_results = len(results) + already_in_library = sum(1 for result in results if result.library_state.key == "already_in_library") + library_match_unavailable = sum( + 1 for result in results if result.library_state.key == "library_match_unavailable" + ) + external_candidates = total_results - already_in_library - library_match_unavailable + return ShelfmarkResultSummary( + total_results=total_results, + total_available=max(total_results, int(total_available or 0)), + raw_total_available=max(int(raw_total_available or 0), int(total_available or 0), total_results), + has_more=bool(has_more), + already_in_library=already_in_library, + external_candidates=external_candidates, + library_match_unavailable=library_match_unavailable, + ) + + +def group_shelfmark_results(results: Sequence[ShelfmarkResultView]) -> tuple[ShelfmarkResultGroup, ...]: + grouped: list[ShelfmarkResultGroup] = [] + definitions = ( + ( + "already_in_library", + _("Already in Your Library"), + None, + ), + ( + "external_candidate", + _("External Candidates"), + None, + ), + ( + "library_match_unavailable", + _("External Results Without Exact Hardcover ID"), + _("Duplicate checking is unavailable for these results because Shelfmark did not return an exact Hardcover ID."), + ), + ) + + for key, title, hint in definitions: + members = tuple(result for result in results if result.library_state.key == key) + if not members: + continue + grouped.append( + ShelfmarkResultGroup( + key=key, + title=title, + hint=hint, + panel_class=members[0].library_state.panel_class, + badge_class=members[0].library_state.badge_class, + icon_class=members[0].library_state.icon_class, + results=members, + ) + ) + + return tuple(grouped) + + +def _book_has_detail_identity(book: Mapping[str, Any]) -> bool: + return bool( + _normalize_text(book.get("provider")) + and _normalize_text(book.get("provider_id")) + ) + + +def _needs_detail_enrichment(book: Mapping[str, Any]) -> bool: + if not _book_has_detail_identity(book): + return False + + series_name = _resolve_series_name(book) + series_position = _resolve_series_position(book) + incomplete_series_metadata = bool(series_name) != bool(series_position is not None) + + return bool( + not _resolve_shelfmark_cover_value(book) + or not _normalize_text(book.get("description")) + or _resolve_rating_value(book) is None + or _resolve_ratings_count(book) is None + or _resolve_readers_count(book) is None + or _resolve_pages(book) is None + or incomplete_series_metadata + ) + + +def _has_usable_value(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, Mapping): + return bool(value) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return bool(value) + return True + + +def _merge_book_details( + search_book: Mapping[str, Any], + detail_book: Mapping[str, Any] | None, +) -> dict[str, Any]: + merged = dict(search_book) + if not detail_book: + return merged + + preferred_keys = ( + "title", + "subtitle", + "authors", + "author", + "search_title", + "search_author", + "description", + "publish_year", + "release_year", + "release_date", + "source_url", + "slug", + "canonical", + "canonical_id", + "display_fields", + "genres", + "series_id", + "series_url", + "series_slug", + "series_name", + "series_position", + "series_count", + "featured_book_series", + "featured_book_series_id", + "book_series", + "rating", + "ratings_count", + "reviews_count", + "users_count", + "users_read_count", + "pages", + "editions_count", + "editions", + "editions_aggregate", + "lists_count", + "moods", + "content_warnings", + "warnings", + "taggings", + "cached_tags", + "cached_featured_series", + "default_audio_edition", + "default_audio_edition_id", + "default_cover_edition", + "default_cover_edition_id", + "default_ebook_edition", + "default_ebook_edition_id", + "default_physical_edition", + "default_physical_edition_id", + "edition", + "edition_id", + "content_type", + "format", + "edition_format", + "media_type", + "physical_format", + "object_type", + "reading_format", + "provider_display_name", + "cover_url", + "preview", + "cached_image", + "image", + ) + + for key in preferred_keys: + value = detail_book.get(key) + if _has_usable_value(value): + merged[key] = value + return merged + + +def _prune_ttl_cache( + cache: OrderedDict[Any, tuple[float, Any]], + *, + ttl_seconds: int, + max_entries: int, + now: float | None = None, +) -> None: + current_time = monotonic() if now is None else now + expired_keys = [ + cache_key + for cache_key, (cached_at, _) in cache.items() + if current_time - cached_at >= ttl_seconds + ] + for cache_key in expired_keys: + cache.pop(cache_key, None) + + while len(cache) > max_entries: + cache.popitem(last=False) + + +def _prune_shelfmark_detail_cache(now: float | None = None) -> None: + _prune_ttl_cache( + _SHELFMARK_DETAIL_CACHE, + ttl_seconds=SHELFMARK_DETAIL_CACHE_TTL_SECONDS, + max_entries=SHELFMARK_DETAIL_CACHE_MAX_ENTRIES, + now=now, + ) + + +def _prune_hardcover_detail_cache(now: float | None = None) -> None: + _prune_ttl_cache( + _HARDCOVER_DETAIL_CACHE, + ttl_seconds=HARDCOVER_DETAIL_CACHE_TTL_SECONDS, + max_entries=HARDCOVER_DETAIL_CACHE_MAX_ENTRIES, + now=now, + ) + + +def _get_cached_shelfmark_detail_book( + base_url: str, + provider: str, + provider_id: str, +) -> dict[str, Any] | None: + now = monotonic() + _prune_shelfmark_detail_cache(now) + cache_key = (base_url, provider, provider_id) + cached_entry = _SHELFMARK_DETAIL_CACHE.get(cache_key) + if not cached_entry: + return None + + cached_at, cached_payload = cached_entry + if now - cached_at >= SHELFMARK_DETAIL_CACHE_TTL_SECONDS: + _SHELFMARK_DETAIL_CACHE.pop(cache_key, None) + return None + _SHELFMARK_DETAIL_CACHE.move_to_end(cache_key) + return dict(cached_payload) + + +def _remember_shelfmark_detail_book( + base_url: str, + provider: str, + provider_id: str, + payload: Mapping[str, Any], +) -> dict[str, Any]: + _prune_shelfmark_detail_cache() + cache_key = (base_url, provider, provider_id) + cached_payload = dict(payload) + _SHELFMARK_DETAIL_CACHE[cache_key] = (monotonic(), cached_payload) + _SHELFMARK_DETAIL_CACHE.move_to_end(cache_key) + _prune_shelfmark_detail_cache() + return dict(cached_payload) + + +def _get_cached_hardcover_detail_book(hardcover_id: str) -> dict[str, Any] | None: + now = monotonic() + _prune_hardcover_detail_cache(now) + cache_key = _normalize_text(hardcover_id) or "" + if not cache_key: + return None + cached_entry = _HARDCOVER_DETAIL_CACHE.get(cache_key) + if not cached_entry: + return None + + cached_at, cached_payload = cached_entry + if now - cached_at >= HARDCOVER_DETAIL_CACHE_TTL_SECONDS: + _HARDCOVER_DETAIL_CACHE.pop(cache_key, None) + return None + _HARDCOVER_DETAIL_CACHE.move_to_end(cache_key) + return dict(cached_payload) + + +def _remember_hardcover_detail_book( + hardcover_id: str, + payload: Mapping[str, Any], +) -> dict[str, Any]: + cache_key = _normalize_text(hardcover_id) or "" + cached_payload = dict(payload) + if not cache_key: + return cached_payload + _prune_hardcover_detail_cache() + _HARDCOVER_DETAIL_CACHE[cache_key] = (monotonic(), cached_payload) + _HARDCOVER_DETAIL_CACHE.move_to_end(cache_key) + _prune_hardcover_detail_cache() + return dict(cached_payload) + + +def _prune_shelfmark_cover_cache(now: float | None = None) -> None: + _prune_ttl_cache( + _SHELFMARK_COVER_CACHE, + ttl_seconds=SHELFMARK_COVER_CACHE_TTL_SECONDS, + max_entries=SHELFMARK_COVER_CACHE_MAX_ENTRIES, + now=now, + ) + + +def _prune_shelfmark_scan_cache(now: float | None = None) -> None: + current_time = monotonic() if now is None else now + expired_keys = [ + cache_key + for cache_key, (cached_at, _, _, _, _) in _SHELFMARK_SCAN_CACHE.items() + if current_time - cached_at >= SHELFMARK_SCAN_CACHE_TTL_SECONDS + ] + for cache_key in expired_keys: + _SHELFMARK_SCAN_CACHE.pop(cache_key, None) + + while len(_SHELFMARK_SCAN_CACHE) > SHELFMARK_SCAN_CACHE_MAX_ENTRIES: + _SHELFMARK_SCAN_CACHE.popitem(last=False) + + +def _get_cached_shelfmark_cover_url(base_url: str, cover_value: str) -> str | None: + now = monotonic() + _prune_shelfmark_cover_cache(now) + cache_key = (base_url, cover_value) + cached_entry = _SHELFMARK_COVER_CACHE.get(cache_key) + if not cached_entry: + return None + + cached_at, cached_value = cached_entry + if now - cached_at >= SHELFMARK_COVER_CACHE_TTL_SECONDS: + _SHELFMARK_COVER_CACHE.pop(cache_key, None) + return None + _SHELFMARK_COVER_CACHE.move_to_end(cache_key) + return cached_value + + +def _remember_shelfmark_cover_url(base_url: str, cover_value: str, resolved_url: str) -> str: + _prune_shelfmark_cover_cache() + cache_key = (base_url, cover_value) + _SHELFMARK_COVER_CACHE[cache_key] = (monotonic(), resolved_url) + _SHELFMARK_COVER_CACHE.move_to_end(cache_key) + _prune_shelfmark_cover_cache() + return resolved_url + + +def _get_cached_shelfmark_scan( + base_url: str, + query: str, + sort: str, + *, + page: int, + page_size: int, +) -> ShelfmarkSearchResponse | None: + now = monotonic() + _prune_shelfmark_scan_cache(now) + cache_key = (base_url, query, sort, page, page_size) + cached_entry = _SHELFMARK_SCAN_CACHE.get(cache_key) + if not cached_entry: + return None + + cached_at, cached_books, cached_page, total_found, has_more = cached_entry + if now - cached_at >= SHELFMARK_SCAN_CACHE_TTL_SECONDS: + _SHELFMARK_SCAN_CACHE.pop(cache_key, None) + return None + + _SHELFMARK_SCAN_CACHE.move_to_end(cache_key) + return ShelfmarkSearchResponse( + books=tuple(dict(book) for book in cached_books), + page=cached_page, + total_found=total_found, + has_more=has_more, + ) + + +def _remember_shelfmark_scan( + base_url: str, + query: str, + sort: str, + *, + page: int, + page_size: int, + response: ShelfmarkSearchResponse, +) -> ShelfmarkSearchResponse: + _prune_shelfmark_scan_cache() + cache_key = (base_url, query, sort, page, page_size) + cached_books = tuple(dict(book) for book in response.books) + _SHELFMARK_SCAN_CACHE[cache_key] = ( + monotonic(), + cached_books, + max(DEFAULT_SHELFMARK_PAGE, _normalize_int(response.page) or page), + int(response.total_found or len(cached_books)), + bool(response.has_more), + ) + _SHELFMARK_SCAN_CACHE.move_to_end(cache_key) + _prune_shelfmark_scan_cache() + return ShelfmarkSearchResponse( + books=tuple(dict(book) for book in cached_books), + page=max(DEFAULT_SHELFMARK_PAGE, _normalize_int(response.page) or page), + total_found=int(response.total_found or len(cached_books)), + has_more=bool(response.has_more), + ) + + +def _fetch_shelfmark_search_page( + client: "ShelfmarkClient", + query: str, + *, + page: int, + page_size: int, + sort: str, +) -> ShelfmarkSearchResponse: + cached_page = _get_cached_shelfmark_scan( + client.config.base_url, + query, + sort, + page=page, + page_size=page_size, + ) + if cached_page is not None: + return cached_page + + response = client.search_books( + query, + limit=page_size, + page=page, + sort=sort, + ) + return _remember_shelfmark_scan( + client.config.base_url, + query, + sort, + page=page, + page_size=page_size, + response=response, + ) + + +def _fetch_direct_hardcover_detail_book(hardcover_id: str | None) -> Mapping[str, Any] | None: + normalized_id = _normalize_text(hardcover_id) + if not normalized_id: + return None + + cached_detail = _get_cached_hardcover_detail_book(normalized_id) + if cached_detail is not None: + return cached_detail + + token = _normalize_text(getattr(config, "config_hardcover_token", None)) + if not token: + return None + + try: + from cps.services.hardcover import HardcoverClient, MissingHardcoverToken + except Exception as exc: + log.debug("Hardcover detail client unavailable for %s: %s", normalized_id, exc) + return None + + try: + client = HardcoverClient(token.replace("Bearer ", ""), load_privacy=False) + detail_book = client.get_book_by_id(int(normalized_id)) + except MissingHardcoverToken: + return None + except Exception as exc: + log.debug("Hardcover detail lookup skipped for %s: %s", normalized_id, exc) + return None + + if not isinstance(detail_book, Mapping): + return None + + return _remember_hardcover_detail_book(normalized_id, detail_book) + + +def _get_search_enrichment_detail_book( + client: "ShelfmarkClient", + book: Mapping[str, Any], + local_detail_cache: dict[tuple[str, str], Mapping[str, Any] | None], +) -> Mapping[str, Any] | None: + provider = _normalize_text(book.get("provider")) or SHELFMARK_METADATA_PROVIDER + provider_id = _normalize_text(book.get("provider_id")) or "" + if not provider_id: + return None + + cache_key = (provider, provider_id) + if cache_key in local_detail_cache: + return local_detail_cache[cache_key] + + cached_detail = _get_cached_shelfmark_detail_book( + client.config.base_url, + provider, + provider_id, + ) + if cached_detail is not None: + local_detail_cache[cache_key] = cached_detail + return cached_detail + + if not _needs_detail_enrichment(book): + local_detail_cache[cache_key] = None + return None + + try: + detail_book = client.fetch_book(provider, provider_id) + except ShelfmarkIntegrationError as exc: + log.debug( + "Shelfmark detail enrichment skipped for %s/%s: %s", + provider, + provider_id, + exc, + ) + local_detail_cache[cache_key] = None + return None + + local_detail_cache[cache_key] = detail_book + return detail_book + + +def _enrich_books_with_detail_covers( + client: "ShelfmarkClient", + books: Sequence[Mapping[str, Any]], +) -> tuple[dict[str, Any], ...]: + detail_cache: dict[tuple[str, str], Mapping[str, Any] | None] = {} + enriched: list[dict[str, Any]] = [] + + for book in books: + normalized_book = dict(book) + if not _book_has_detail_identity(normalized_book): + enriched.append(normalized_book) + continue + + detail_book = _get_search_enrichment_detail_book(client, normalized_book, detail_cache) + merged_book = _merge_book_details(normalized_book, detail_book) + provider = _normalize_text(merged_book.get("provider")) or SHELFMARK_METADATA_PROVIDER + provider_id = _normalize_text(merged_book.get("provider_id")) or "" + if provider_id: + _remember_shelfmark_detail_book( + client.config.base_url, + provider, + provider_id, + merged_book, + ) + enriched.append(merged_book) + + return tuple(enriched) + + +def _iter_request_media_signals(book: Mapping[str, Any]) -> tuple[str, ...]: + values: list[str] = [] + for record in (book, _resolve_display_edition(book), _select_edition_by_kind(book, "audio")): + if isinstance(record, Mapping): + values.extend(_iter_media_signals(record)) + for label in ("Format", "Edition format", "Media", "Medium"): + display_value = _lookup_display_field_value(book, label) + if display_value: + values.append(display_value.casefold()) + return tuple(values) + + +def _is_audiobook_result(book: Mapping[str, Any]) -> bool: + primary_content_type = _resolve_primary_content_type(book) + if primary_content_type == "audio": + return True + if primary_content_type in {"ebook", "physical"}: + return False + + for value in _iter_request_media_signals(book): + if value == "audiobook": + return True + if any(hint in value for hint in SHELFMARK_AUDIOBOOK_HINTS): + return True + return False + + +def _build_search_result_views( + books: Sequence[Mapping[str, Any]], + *, + detail_url_builder: Callable[[Mapping[str, Any]], str | None], + shelfmark_browser_base_url: str, + enrich_with_details: bool = False, + detail_client: "ShelfmarkClient" | None = None, +) -> tuple[tuple[ShelfmarkResultView, ...], tuple[Mapping[str, Any], ...]]: + page_books = tuple(book for book in books if not _is_audiobook_result(book)) + if enrich_with_details and detail_client is not None: + page_books = _enrich_books_with_detail_covers(detail_client, page_books) + + hardcover_ids = [value for value in (_extract_hardcover_id(book) for book in page_books) if value] + library_matches = lookup_visible_library_matches(hardcover_ids) + results = tuple( + build_shelfmark_result_view( + book, + library_match=library_matches.get(_extract_hardcover_id(book) or ""), + detail_url=detail_url_builder(book), + shelfmark_browser_base_url=shelfmark_browser_base_url, + ) + for book in page_books + ) + owned_series = lookup_visible_owned_series( + [ + membership.name + for result in results + for membership in result.series_memberships + ] + ) + result_contexts = build_shelfmark_series_membership_contexts(results, owned_series) + results = tuple( + apply_primary_series_context( + replace(result, series_contexts=contexts), + ) + for result, contexts in zip(results, result_contexts) + ) + results = tuple( + replace(result, quality_state=build_shelfmark_quality_state(result)) + for result in results + ) + results = tuple( + replace( + result, + triage_state=build_shelfmark_triage_state( + result, + quality_state=result.quality_state, + ), + ) + for result in results + ) + return results, page_books + + +def _result_matches_filters( + result: ShelfmarkResultView, + *, + requestable_only: bool = False, + has_cover_only: bool = False, + high_confidence_only: bool = False, + series_filter: str = DEFAULT_SHELFMARK_SERIES_FILTER, + triage_filter: str = DEFAULT_SHELFMARK_TRIAGE_FILTER, +) -> bool: + if requestable_only and ( + result.already_in_library + or not result.hardcover_id + or not result.request_payload + ): + return False + if has_cover_only and not result.cover_url: + return False + if high_confidence_only and not ( + result.quality_state and result.quality_state.high_confidence + ): + return False + if series_filter == "owned" and not ( + result.series_context and result.series_context.matched + ): + return False + if series_filter == "next_missing" and not ( + result.series_context and result.series_context.is_next_missing + ): + return False + if triage_filter == "strong" and not ( + result.triage_state and result.triage_state.strong_candidate + ): + return False + return True + + +def _result_may_match_filters_after_enrichment( + result: ShelfmarkResultView, + raw_book: Mapping[str, Any], + *, + requestable_only: bool = False, + has_cover_only: bool = False, + high_confidence_only: bool = False, + series_filter: str = DEFAULT_SHELFMARK_SERIES_FILTER, + triage_filter: str = DEFAULT_SHELFMARK_TRIAGE_FILTER, +) -> bool: + detail_possible = _book_has_detail_identity(raw_book) and _needs_detail_enrichment(raw_book) + if not detail_possible: + return False + + if requestable_only and ( + not result.already_in_library + and (not result.hardcover_id or not result.request_payload) + ): + return True + + if has_cover_only and not result.cover_url: + return True + + if high_confidence_only and not result.already_in_library and not ( + result.quality_state and result.quality_state.high_confidence + ): + return True + + if series_filter == "owned" and not ( + result.series_context and result.series_context.matched + ): + return True + + if series_filter == "next_missing" and not ( + result.series_context and result.series_context.is_next_missing + ): + return True + + if triage_filter == "strong" and not result.already_in_library and not ( + result.triage_state and result.triage_state.strong_candidate + ): + return True + + return False + + +def _prepare_progressive_page_results( + results: Sequence[ShelfmarkResultView], + raw_books: Sequence[Mapping[str, Any]], + *, + requestable_only: bool = False, + has_cover_only: bool = False, + high_confidence_only: bool = False, + series_filter: str = DEFAULT_SHELFMARK_SERIES_FILTER, + triage_filter: str = DEFAULT_SHELFMARK_TRIAGE_FILTER, +) -> tuple[ShelfmarkResultView, ...]: + visible_results: list[ShelfmarkResultView] = [] + + for result, raw_book in zip(results, raw_books): + matches_filters = _result_matches_filters( + result, + requestable_only=requestable_only, + has_cover_only=has_cover_only, + high_confidence_only=high_confidence_only, + series_filter=series_filter, + triage_filter=triage_filter, + ) + filter_pending = ( + not matches_filters + and _result_may_match_filters_after_enrichment( + result, + raw_book, + requestable_only=requestable_only, + has_cover_only=has_cover_only, + high_confidence_only=high_confidence_only, + series_filter=series_filter, + triage_filter=triage_filter, + ) + ) + if not matches_filters and not filter_pending: + continue + + visible_results.append( + replace( + result, + needs_progressive_enrichment=bool( + _book_has_detail_identity(raw_book) + and _needs_detail_enrichment(raw_book) + ), + progressive_filter_pending=filter_pending, + ) + ) + + return _rank_visible_results(visible_results) + + +def _filter_visible_results( + results: Sequence[ShelfmarkResultView], + *, + requestable_only: bool = False, + has_cover_only: bool = False, + high_confidence_only: bool = False, + series_filter: str = DEFAULT_SHELFMARK_SERIES_FILTER, + triage_filter: str = DEFAULT_SHELFMARK_TRIAGE_FILTER, +) -> tuple[ShelfmarkResultView, ...]: + return tuple( + result + for result in results + if _result_matches_filters( + result, + requestable_only=requestable_only, + has_cover_only=has_cover_only, + high_confidence_only=high_confidence_only, + series_filter=series_filter, + triage_filter=triage_filter, + ) + ) + + +def result_matches_shelfmark_filters( + result: ShelfmarkResultView, + *, + requestable_only: bool = False, + has_cover_only: bool = False, + high_confidence_only: bool = False, + series_filter: str = DEFAULT_SHELFMARK_SERIES_FILTER, + triage_filter: str = DEFAULT_SHELFMARK_TRIAGE_FILTER, +) -> bool: + return _result_matches_filters( + result, + requestable_only=requestable_only, + has_cover_only=has_cover_only, + high_confidence_only=high_confidence_only, + series_filter=series_filter, + triage_filter=triage_filter, + ) + + +def _normalize_context_tokens(value: str | None) -> tuple[str, ...]: + if not value: + return tuple() + normalized = _normalize_text( + str(value) + .replace("|", " ") + .replace(",", " ") + .replace(".", " ") + ) + if not normalized: + return tuple() + return tuple(sorted(token for token in normalized.split() if token)) + + +def _result_matches_series_context_name( + result: ShelfmarkResultView, + series_name: str | None, +) -> bool: + target = _normalize_text(series_name) + if not target: + return True + if any( + context.matched and _normalize_text(context.owned_series_name) == target + for context in result.series_contexts + ): + return True + if any(_normalize_text(membership.name) == target for membership in result.series_memberships): + return True + if _normalize_text(result.series_name) == target: + return True + return any(_normalize_text((entry or {}).get("name")) == target for entry in result.series_entries) + + +def _result_matches_author_context_name( + result: ShelfmarkResultView, + author_name: str | None, +) -> bool: + target_tokens = _normalize_context_tokens(author_name) + if not target_tokens: + return True + return any(_normalize_context_tokens(author) == target_tokens for author in result.authors) + + +def _contextual_result_matches( + result: ShelfmarkResultView, + *, + context_type: str | None = None, + context_value: str | None = None, +) -> bool: + normalized_context_type = _normalize_text(context_type) + if normalized_context_type == "series": + return _result_matches_series_context_name(result, context_value) + if normalized_context_type == "author": + return _result_matches_author_context_name(result, context_value) + return True + + +def _apply_contextual_series_preference( + results: Sequence[ShelfmarkResultView], + *, + context_type: str | None = None, + context_value: str | None = None, +) -> tuple[ShelfmarkResultView, ...]: + if _normalize_text(context_type) != "series": + return tuple(results) + contextual_results: list[ShelfmarkResultView] = [] + for result in results: + contextual_result = apply_primary_series_context( + result, + preferred_series_name=context_value, + ) + quality_state = build_shelfmark_quality_state(contextual_result) + contextual_results.append( + replace( + contextual_result, + quality_state=quality_state, + triage_state=build_shelfmark_triage_state( + contextual_result, + quality_state=quality_state, + ), + ) + ) + return tuple(contextual_results) + + +def _result_identity_key(result: ShelfmarkResultView) -> tuple[str, str]: + return result.provider, result.provider_id + + +def search_shelfmark_contextual_results( + query: str | None, + *, + detail_url_builder: Callable[[Mapping[str, Any]], str | None], + context_type: str | None = None, + context_value: str | None = None, + limit: int = DEFAULT_SHELFMARK_CONTEXTUAL_LIMIT, + sort: str = "relevance", + max_source_pages: int = SHELFMARK_CONTEXT_MAX_SOURCE_PAGES, + scan_page_size: int = SHELFMARK_CONTEXT_SCAN_PAGE_SIZE, + filter_requestable: bool = True, + filter_has_cover: bool = True, + filter_high_confidence: bool = False, + query_label: str | None = None, + context_hint: str | None = None, + empty_message: str | None = None, +) -> ShelfmarkSearchSection: + normalized_query = _normalize_text(query) + contextual_limit = max(1, _normalize_int(limit) or DEFAULT_SHELFMARK_CONTEXTUAL_LIMIT) + selected_sort = _normalize_sort(sort) + sort_options = get_shelfmark_sort_options() + config_data = get_shelfmark_client_config() + + if not config_data.enabled: + return ShelfmarkSearchSection(enabled=False, available=False, query=normalized_query or "") + + if not normalized_query: + return ShelfmarkSearchSection( + enabled=True, + available=True, + query="", + page=1, + page_size=contextual_limit, + selected_sort=selected_sort, + sort_options=sort_options, + page_size_options=SHELFMARK_PAGE_SIZE_OPTIONS, + query_label=query_label, + context_hint=context_hint, + message=empty_message, + message_level="info", + results=tuple(), + groups=tuple(), + summary=ShelfmarkResultSummary(), + ) + + client = ShelfmarkClient(config_data) + effective_scan_page_size = max( + contextual_limit, + _normalize_int(scan_page_size) or SHELFMARK_CONTEXT_SCAN_PAGE_SIZE, + ) + allowed_pages = max(1, _normalize_int(max_source_pages) or SHELFMARK_CONTEXT_MAX_SOURCE_PAGES) + collected_results: list[ShelfmarkResultView] = [] + seen_keys: set[tuple[str, str]] = set() + total_found = 0 + has_more = False + + try: + for source_page in range(1, allowed_pages + 1): + search_response = _fetch_shelfmark_search_page( + client, + normalized_query, + page=source_page, + page_size=effective_scan_page_size, + sort=selected_sort, + ) + total_found = max(total_found, search_response.total_found or 0) + page_results, _ = _build_search_result_views( + search_response.books, + detail_url_builder=detail_url_builder, + shelfmark_browser_base_url=config_data.browser_base_url, + enrich_with_details=True, + detail_client=client, + ) + page_results = _filter_visible_results( + page_results, + requestable_only=bool(filter_requestable), + has_cover_only=bool(filter_has_cover), + high_confidence_only=bool(filter_high_confidence), + ) + page_results = _rank_visible_results( + _apply_contextual_series_preference( + tuple( + result + for result in page_results + if _contextual_result_matches( + result, + context_type=context_type, + context_value=context_value, + ) + ), + context_type=context_type, + context_value=context_value, + ) + ) + + for result in page_results: + identity_key = _result_identity_key(result) + if identity_key in seen_keys: + continue + seen_keys.add(identity_key) + collected_results.append(result) + + has_more = bool(search_response.has_more) + if len(collected_results) >= contextual_limit or not has_more: + break + + results = _rank_visible_results( + _apply_contextual_series_preference( + tuple(collected_results), + context_type=context_type, + context_value=context_value, + ) + )[:contextual_limit] + summary = summarize_shelfmark_results( + results, + total_available=total_found, + raw_total_available=total_found, + has_more=bool(has_more or len(collected_results) > len(results)), + ) + return ShelfmarkSearchSection( + enabled=True, + available=True, + query=normalized_query, + page=1, + page_size=contextual_limit, + selected_sort=selected_sort, + sort_options=sort_options, + page_size_options=SHELFMARK_PAGE_SIZE_OPTIONS, + total_pages=1 if results else 0, + visible_start=1 if results else 0, + visible_end=len(results), + has_previous=False, + previous_page=None, + next_page=None, + has_more=summary.has_more, + total_available=summary.total_available, + raw_total_available=summary.raw_total_available, + page_result_count=len(results), + filter_requestable=bool(filter_requestable), + filter_has_cover=bool(filter_has_cover), + filter_high_confidence=bool(filter_high_confidence), + filters_active=False, + open_search_url=build_shelfmark_search_url( + config_data.browser_base_url, + query=normalized_query, + page=1, + page_size=contextual_limit, + sort=selected_sort, + ), + query_label=query_label, + context_hint=context_hint, + results=results, + groups=group_shelfmark_results(results), + summary=summary, + message=empty_message if not results else None, + message_level="info", + ) + except ShelfmarkIntegrationError as exc: + log.warning("Shelfmark contextual search unavailable for query '%s': %s", normalized_query, exc) + return ShelfmarkSearchSection( + enabled=True, + available=False, + query=normalized_query, + page=1, + page_size=contextual_limit, + selected_sort=selected_sort, + sort_options=sort_options, + page_size_options=SHELFMARK_PAGE_SIZE_OPTIONS, + query_label=query_label, + context_hint=context_hint, + message=str(exc), + message_level="warning", + ) + + +def search_shelfmark_results( + query: str | None, + *, + detail_url_builder: Callable[[Mapping[str, Any]], str | None], + page: int = DEFAULT_SHELFMARK_PAGE, + page_size: int = DEFAULT_SHELFMARK_LIMIT, + sort: str = DEFAULT_SHELFMARK_SORT, + filter_requestable: bool = DEFAULT_SHELFMARK_FILTER_REQUESTABLE, + filter_has_cover: bool = DEFAULT_SHELFMARK_FILTER_HAS_COVER, + filter_high_confidence: bool = DEFAULT_SHELFMARK_FILTER_HIGH_CONFIDENCE, + series_filter: str = DEFAULT_SHELFMARK_SERIES_FILTER, + triage_filter: str = DEFAULT_SHELFMARK_TRIAGE_FILTER, + query_label: str | None = None, + context_hint: str | None = None, + empty_message: str | None = None, +) -> ShelfmarkSearchSection: + normalized_query = _normalize_text(query) + requested_page = max(DEFAULT_SHELFMARK_PAGE, _normalize_int(page) or DEFAULT_SHELFMARK_PAGE) + requested_page_size = _normalize_page_size(page_size) + selected_sort = _normalize_sort(sort) + selected_series_filter = _normalize_series_filter(series_filter) + selected_triage_filter = _normalize_triage_filter(triage_filter) + requestable_only = bool(filter_requestable) + has_cover_only = bool(filter_has_cover) + high_confidence_only = bool(filter_high_confidence) + filters_active = ( + requestable_only != DEFAULT_SHELFMARK_FILTER_REQUESTABLE + or has_cover_only != DEFAULT_SHELFMARK_FILTER_HAS_COVER + or high_confidence_only != DEFAULT_SHELFMARK_FILTER_HIGH_CONFIDENCE + or selected_series_filter != DEFAULT_SHELFMARK_SERIES_FILTER + or selected_triage_filter != DEFAULT_SHELFMARK_TRIAGE_FILTER + ) + sort_options = get_shelfmark_sort_options() + series_filter_options = get_shelfmark_series_filter_options() + triage_filter_options = get_shelfmark_triage_filter_options() + config_data = get_shelfmark_client_config() + if not config_data.enabled: + return ShelfmarkSearchSection(enabled=False, available=False, query=normalized_query or "") + + if not normalized_query: + return ShelfmarkSearchSection( + enabled=True, + available=True, + query="", + page=requested_page, + page_size=requested_page_size, + selected_sort=selected_sort, + sort_options=sort_options, + selected_series_filter=selected_series_filter, + series_filter_options=series_filter_options, + selected_triage_filter=selected_triage_filter, + triage_filter_options=triage_filter_options, + page_size_options=SHELFMARK_PAGE_SIZE_OPTIONS, + total_pages=0, + visible_start=0, + visible_end=0, + has_previous=False, + previous_page=None, + next_page=None, + has_more=False, + total_available=0, + raw_total_available=0, + page_result_count=0, + filter_requestable=requestable_only, + filter_has_cover=has_cover_only, + filter_high_confidence=high_confidence_only, + filters_active=filters_active, + open_search_url=( + build_shelfmark_search_url( + config_data.browser_base_url, + query="", + page=requested_page, + page_size=requested_page_size, + sort=selected_sort, + ) + if config_data.browser_base_url + else None + ), + query_label=query_label, + context_hint=context_hint, + message=empty_message, + message_level="info", + results=tuple(), + groups=tuple(), + summary=ShelfmarkResultSummary(), + ) + + client = ShelfmarkClient(config_data) + try: + search_response = _fetch_shelfmark_search_page( + client, + normalized_query, + page=requested_page, + page_size=requested_page_size, + sort=selected_sort, + ) + page_results, raw_page_books = _build_search_result_views( + search_response.books, + detail_url_builder=detail_url_builder, + shelfmark_browser_base_url=config_data.browser_base_url, + ) + results = _prepare_progressive_page_results( + page_results, + raw_page_books, + requestable_only=requestable_only, + has_cover_only=has_cover_only, + high_confidence_only=high_confidence_only, + series_filter=selected_series_filter, + triage_filter=selected_triage_filter, + ) + total_available = search_response.total_found + raw_total_available = search_response.total_found + current_page = max(DEFAULT_SHELFMARK_PAGE, _normalize_int(search_response.page) or requested_page) + total_pages = ( + max(1, math.ceil(total_available / requested_page_size)) + if total_available + else 0 + ) + visible_start = ((current_page - 1) * requested_page_size) + 1 if results else 0 + visible_end = visible_start + len(results) - 1 if results else 0 + has_more = bool( + search_response.has_more + or ( + total_pages + and current_page < total_pages + ) + ) + summary = summarize_shelfmark_results( + results, + total_available=total_available, + raw_total_available=raw_total_available, + has_more=has_more, + ) + progressive_refinement = bool( + any(result.needs_progressive_enrichment for result in results) + or filters_active + ) + return ShelfmarkSearchSection( + enabled=True, + available=True, + query=normalized_query, + page=current_page, + page_size=requested_page_size, + selected_sort=selected_sort, + sort_options=sort_options, + selected_series_filter=selected_series_filter, + series_filter_options=series_filter_options, + selected_triage_filter=selected_triage_filter, + triage_filter_options=triage_filter_options, + page_size_options=SHELFMARK_PAGE_SIZE_OPTIONS, + total_pages=total_pages, + visible_start=visible_start, + visible_end=visible_end, + has_previous=current_page > DEFAULT_SHELFMARK_PAGE, + previous_page=current_page - 1 if current_page > DEFAULT_SHELFMARK_PAGE else None, + next_page=current_page + 1 if has_more else None, + has_more=has_more, + total_available=summary.total_available, + raw_total_available=summary.raw_total_available, + page_result_count=len(results), + filter_requestable=requestable_only, + filter_has_cover=has_cover_only, + filter_high_confidence=high_confidence_only, + filters_active=filters_active, + open_search_url=build_shelfmark_search_url( + config_data.browser_base_url, + query=normalized_query, + page=current_page, + page_size=requested_page_size, + sort=selected_sort, + ), + query_label=query_label, + context_hint=context_hint, + pagination_mode="shelfmark", + progressive_refinement=progressive_refinement, + progressive_refinement_note=( + _( + "Shelfmark totals are shown as-is. Visible rows refine as details load." + ) + if progressive_refinement + else None + ), + results=results, + groups=group_shelfmark_results(results), + summary=summary, + ) + except ShelfmarkIntegrationError as exc: + log.warning("Shelfmark search unavailable for query '%s': %s", normalized_query, exc) + return ShelfmarkSearchSection( + enabled=True, + available=False, + query=normalized_query, + query_label=query_label, + context_hint=context_hint, + message=str(exc), + message_level="warning", + ) + + +def fetch_shelfmark_detail( + provider: str, + provider_id: str, + *, + detail_url: str | None = None, + probe_state: ShelfmarkProbeState | None = None, +) -> ShelfmarkResultView: + config_data = get_shelfmark_client_config() + if not config_data.enabled: + raise ShelfmarkIntegrationError(_("Shelfmark integration is not enabled.")) + + client = ShelfmarkClient(config_data) + book = client.fetch_book(provider, provider_id) + hardcover_id = _extract_hardcover_id(book) + if hardcover_id: + book = _merge_book_details( + book, + _fetch_direct_hardcover_detail_book(hardcover_id), + ) + hardcover_id = _extract_hardcover_id(book) + library_match = None + if hardcover_id: + library_match = lookup_visible_library_matches([hardcover_id]).get(hardcover_id) + result = build_shelfmark_result_view( + book, + library_match=library_match, + detail_url=detail_url, + shelfmark_browser_base_url=config_data.browser_base_url, + probe_state=probe_state, + ) + owned_series = lookup_visible_owned_series( + [membership.name for membership in result.series_memberships] + ) + series_contexts = build_shelfmark_series_membership_contexts((result,), owned_series)[0] + result = apply_primary_series_context( + replace(result, series_contexts=series_contexts), + ) + result = replace(result, quality_state=build_shelfmark_quality_state(result)) + return replace( + result, + triage_state=build_shelfmark_triage_state( + result, + quality_state=result.quality_state, + ), + ) + + +class ShelfmarkClient: + def __init__(self, config_data: ShelfmarkClientConfig, session: SafeSession | None = None): + self.config = config_data + self.session = session or create_shelfmark_session(config_data) + self._authenticated = False + + def search_books( + self, + query: str, + *, + limit: int = DEFAULT_SHELFMARK_LIMIT, + page: int = DEFAULT_SHELFMARK_PAGE, + sort: str = DEFAULT_SHELFMARK_SORT, + provider: str = SHELFMARK_METADATA_PROVIDER, + content_type: str = SHELFMARK_CONTENT_TYPE, + ) -> ShelfmarkSearchResponse: + self._ensure_authenticated() + normalized_limit = _normalize_page_size(limit) + current_page = max(DEFAULT_SHELFMARK_PAGE, _normalize_int(page) or DEFAULT_SHELFMARK_PAGE) + selected_sort = _normalize_sort(sort) + response = self._perform_request( + "get", + _join_base_url(self.config.base_url, "/api/metadata/search"), + _("Shelfmark search failed."), + params={ + "query": query, + "limit": normalized_limit, + "sort": selected_sort, + "page": current_page, + "provider": provider, + "content_type": content_type, + }, + ) + payload = self._parse_json_response( + response, + _("Shelfmark search failed."), + unauthorized_message=self._search_auth_failure_message(), + forbidden_message=self._search_forbidden_message(), + invalid_payload_message=_("Shelfmark returned an unexpected metadata search response."), + ) + books = payload.get("books") + if not isinstance(books, list): + raise ShelfmarkIntegrationError( + _("Shelfmark returned an unexpected metadata search response. Expected a 'books' list.") + ) + normalized_books = tuple(book for book in books if isinstance(book, dict)) + return ShelfmarkSearchResponse( + books=normalized_books, + page=_normalize_int(payload.get("page")) or DEFAULT_SHELFMARK_PAGE, + total_found=_normalize_int(payload.get("total_found")) or len(normalized_books), + has_more=bool(payload.get("has_more")), + ) + + def fetch_book(self, provider: str, provider_id: str) -> dict[str, Any]: + cached_payload = _get_cached_shelfmark_detail_book( + self.config.base_url, + provider, + provider_id, + ) + if cached_payload is not None: + return cached_payload + + self._ensure_authenticated() + response = self._perform_request( + "get", + _join_base_url( + self.config.base_url, + f"/api/metadata/book/{provider}/{provider_id}", + ), + _("Shelfmark book details are unavailable."), + ) + payload = self._parse_json_response(response, _("Shelfmark book details are unavailable.")) + if not isinstance(payload, dict): + raise ShelfmarkIntegrationError(_("Shelfmark returned an invalid book detail payload.")) + return _remember_shelfmark_detail_book( + self.config.base_url, + provider, + provider_id, + payload, + ) + + def auth_check(self) -> Mapping[str, Any]: + response = self._perform_request( + "get", + _join_base_url(self.config.base_url, "/api/auth/check"), + _("Shelfmark auth check failed."), + ) + return self._parse_json_response( + response, + _("Shelfmark auth check failed."), + invalid_payload_message=_("Shelfmark returned an unexpected auth check response."), + ) + + def request_policy(self) -> Mapping[str, Any]: + self._ensure_authenticated() + response = self._perform_request( + "get", + _join_base_url(self.config.base_url, "/api/request-policy"), + _("Shelfmark request policy check failed."), + ) + return self._parse_json_response( + response, + _("Shelfmark request policy check failed."), + invalid_payload_message=_("Shelfmark returned an unexpected request policy response."), + ) + + def release_sources(self) -> tuple[Mapping[str, Any], ...]: + self._ensure_authenticated() + response = self._perform_request( + "get", + _join_base_url(self.config.base_url, "/api/release-sources"), + _("Shelfmark release source discovery failed."), + ) + payload = self._parse_json_body( + response, + _("Shelfmark release source discovery failed."), + invalid_payload_message=_("Shelfmark returned an unexpected release source response."), + ) + if not response.ok: + raw_error = _normalize_text(payload.get("error") or payload.get("message")) + raise ShelfmarkIntegrationError( + raw_error or _("Shelfmark release source discovery failed.") + ) + sources = payload if isinstance(payload, Sequence) else [] + return tuple(item for item in sources if isinstance(item, Mapping)) + + def probe_release_lookup( + self, + source: str, + *, + content_type: str, + title: str = "Settings Diagnostic", + author: str = "Calibre-Web Automated", + manual_query: str = "settings diagnostic", + ) -> tuple[bool, Mapping[str, Any]]: + self._ensure_authenticated() + response = self._perform_request( + "get", + _join_base_url(self.config.base_url, "/api/releases"), + _("Shelfmark release lookup check failed."), + params={ + "provider": "manual", + "book_id": "cwa-settings-diagnostic", + "source": source, + "title": title, + "author": author, + "manual_query": manual_query, + "content_type": content_type, + }, + ) + payload = self._parse_json_body( + response, + _("Shelfmark release lookup check failed."), + invalid_payload_message=_("Shelfmark returned an unexpected release lookup response."), + ) + if not isinstance(payload, Mapping): + raise ShelfmarkIntegrationError( + _("Shelfmark returned an unexpected release lookup response.") + ) + return bool(response.ok), payload + + def authenticate_search_account(self) -> None: + self._ensure_authenticated() + + def _ensure_authenticated(self) -> None: + if self._authenticated: + return + + username = self.config.username + password = self.config.password + if not username and not password: + self._authenticated = True + return + + if not username or not password: + raise ShelfmarkIntegrationError( + _("Configure both a Shelfmark search username and password before enabling integrated external search.") + ) + + response = self._perform_request( + "post", + _join_base_url(self.config.base_url, "/api/auth/login"), + _("Shelfmark login failed for the configured search account."), + json={ + "username": username, + "password": password, + "remember_me": False, + }, + ) + payload = self._parse_json_response(response, _("Shelfmark login failed for the configured search account.")) + if not isinstance(payload, dict) or not payload.get("success"): + raise ShelfmarkIntegrationError( + _("Shelfmark login failed for the configured search account.") + ) + self._authenticated = True + + def _perform_request(self, method: str, url: str, default_message: str, **kwargs: Any): + try: + return getattr(self.session, method)( + url, + timeout=self.config.timeout_seconds, + **kwargs, + ) + except UnacceptableAddressException as exc: + raise ShelfmarkIntegrationError( + _( + "Shelfmark server-side request validation blocked this address. " + "Check that Shelfmark Base URL points directly to your Shelfmark instance " + "and is not redirecting to a different host or port." + ) + ) from exc + except RequestException as exc: + raise ShelfmarkIntegrationError(default_message) from exc + + def _search_auth_failure_message(self) -> str: + if not self.config.username and not self.config.password: + return _( + "Shelfmark metadata search requires authentication. Configure Shelfmark Search Username and Password in CWA admin. " + "Your browser's Shelfmark login is only used for browser-side request actions, not for server-side external search." + ) + return _( + "Shelfmark rejected the configured search account. Verify Shelfmark Search Username and Password and confirm that account can use metadata search." + ) + + @staticmethod + def _search_forbidden_message() -> str: + return _( + "Shelfmark denied the configured search account access to metadata search. Verify the account and any reverse-proxy or auth restrictions on Shelfmark." + ) + + @staticmethod + def _parse_json_response( + response: Any, + default_message: str, + *, + unauthorized_message: str | None = None, + forbidden_message: str | None = None, + invalid_payload_message: str | None = None, + ) -> Mapping[str, Any]: + try: + payload = response.json() + except ValueError as exc: + raise ShelfmarkIntegrationError(invalid_payload_message or default_message) from exc + + status_code = getattr(response, "status_code", getattr(response, "status", None)) + + if response.ok: + if isinstance(payload, Mapping): + return payload + raise ShelfmarkIntegrationError(invalid_payload_message or default_message) + + if status_code == 401 and unauthorized_message: + raise ShelfmarkIntegrationError(unauthorized_message) + + if status_code == 403 and forbidden_message: + raise ShelfmarkIntegrationError(forbidden_message) + + message = default_message + if isinstance(payload, Mapping): + raw_error = payload.get("error") or payload.get("message") + normalized = _normalize_text(raw_error) + if normalized: + message = normalized + raise ShelfmarkIntegrationError(message) + + @staticmethod + def _parse_json_body( + response: Any, + default_message: str, + *, + invalid_payload_message: str | None = None, + ) -> Any: + try: + payload = response.json() + except ValueError as exc: + raise ShelfmarkIntegrationError(invalid_payload_message or default_message) from exc + if payload is None: + raise ShelfmarkIntegrationError(invalid_payload_message or default_message) + return payload + + +def _build_diagnostic_check( + key: str, + label: str, + status: str, + summary: str, + detail: str | None = None, +) -> ShelfmarkDiagnosticCheck: + return ShelfmarkDiagnosticCheck( + key=key, + label=label, + status=status, + summary=summary, + detail=detail, + ) + + +def _preferred_provider_option_label( + *, + display_name: str, + kind: str, +) -> str: + if kind == "indexer": + return _("%(name)s (indexer)", name=display_name) + return _("%(name)s (source)", name=display_name) + + +def _build_preferred_provider_option( + value: str, + *, + display_name: str | None = None, + kind: str, +) -> ShelfmarkPreferredProviderOption | None: + normalized_value = _normalize_text(value) + if not normalized_value: + return None + label_text = _normalize_text(display_name) or normalized_value.replace("_", " ").title() + return ShelfmarkPreferredProviderOption( + value=normalized_value, + label=_preferred_provider_option_label(display_name=label_text, kind=kind), + kind=kind, + ) + + +def _source_mode_map(policy_payload: Mapping[str, Any] | None) -> dict[str, Mapping[str, Any]]: + if not isinstance(policy_payload, Mapping): + return {} + source_modes = policy_payload.get("source_modes") + if not isinstance(source_modes, Sequence) or isinstance(source_modes, (str, bytes)): + return {} + + mapped: dict[str, Mapping[str, Any]] = {} + for entry in source_modes: + if not isinstance(entry, Mapping): + continue + source_name = _normalize_text(entry.get("source")) + if source_name: + mapped[source_name] = entry + return mapped + + +def _release_source_map( + release_sources_payload: Sequence[Mapping[str, Any]] | None, +) -> dict[str, Mapping[str, Any]]: + mapped: dict[str, Mapping[str, Any]] = {} + for entry in release_sources_payload or (): + if not isinstance(entry, Mapping): + continue + source_name = _normalize_text(entry.get("name")) + if source_name: + mapped[source_name] = entry + return mapped + + +def _supports_content_type(entry: Mapping[str, Any], content_type: str) -> bool: + supported = entry.get("supported_content_types") + if not isinstance(supported, Sequence) or isinstance(supported, (str, bytes)): + return True + normalized_supported = { + (_normalize_text(item) or "").lower() + for item in supported + if _normalize_text(item) + } + return not normalized_supported or content_type in normalized_supported + + +def _build_discovered_source_options( + *, + content_type: str, + release_sources_payload: Sequence[Mapping[str, Any]] | None, + policy_payload: Mapping[str, Any] | None, +) -> tuple[ShelfmarkPreferredProviderOption, ...]: + source_map = _release_source_map(release_sources_payload) + mode_map = _source_mode_map(policy_payload) + + candidates: list[ShelfmarkPreferredProviderOption] = [] + seen_values: set[str] = set() + + candidate_names = set(source_map.keys()) | set(mode_map.keys()) + for source_name in sorted(candidate_names): + release_source_entry = source_map.get(source_name) + policy_entry = mode_map.get(source_name) + if release_source_entry is not None and not bool(release_source_entry.get("enabled", True)): + continue + if release_source_entry is not None and not _supports_content_type(release_source_entry, content_type): + continue + if policy_entry is not None and not _supports_content_type(policy_entry, content_type): + continue + if policy_entry is not None: + modes = policy_entry.get("modes") + mode_value = None + if isinstance(modes, Mapping): + mode_value = _normalize_text(modes.get(content_type)) + if mode_value and mode_value.lower() == "blocked": + continue + + option = _build_preferred_provider_option( + source_name, + display_name=( + _normalize_text(release_source_entry.get("display_name")) + if isinstance(release_source_entry, Mapping) + else None + ), + kind="source", + ) + if option is None or option.value in seen_values: + continue + seen_values.add(option.value) + candidates.append(option) + + return tuple(candidates) + + +def _extract_release_lookup_indexers( + release_lookup_payload: Mapping[str, Any] | None, +) -> tuple[ShelfmarkPreferredProviderOption, ...]: + if not isinstance(release_lookup_payload, Mapping): + return tuple() + + values: list[str] = [] + column_config = release_lookup_payload.get("column_config") + if isinstance(column_config, Mapping): + available_indexers = column_config.get("available_indexers") + if isinstance(available_indexers, Sequence) and not isinstance(available_indexers, (str, bytes)): + values.extend( + normalized + for normalized in (_normalize_text(item) for item in available_indexers) + if normalized + ) + + if not values: + releases = release_lookup_payload.get("releases") + if isinstance(releases, Sequence) and not isinstance(releases, (str, bytes)): + for release in releases: + if not isinstance(release, Mapping): + continue + normalized_indexer = _normalize_text(release.get("indexer")) + if normalized_indexer: + values.append(normalized_indexer) + + options: list[ShelfmarkPreferredProviderOption] = [] + seen_values: set[str] = set() + for value in sorted(set(values), key=lambda item: item.casefold()): + option = _build_preferred_provider_option(value, display_name=value, kind="indexer") + if option is None or option.value in seen_values: + continue + seen_values.add(option.value) + options.append(option) + return tuple(options) + + +def _pick_release_probe_source( + *, + preferred_value: str, + content_type: str, + release_sources_payload: Sequence[Mapping[str, Any]] | None, + policy_payload: Mapping[str, Any] | None, +) -> str | None: + source_options = _build_discovered_source_options( + content_type=content_type, + release_sources_payload=release_sources_payload, + policy_payload=policy_payload, + ) + available_values = [option.value for option in source_options] + if preferred_value and preferred_value in available_values: + return preferred_value + if "prowlarr" in available_values: + return "prowlarr" + return available_values[0] if available_values else None + + +def _diagnostics_success(checks: Sequence[ShelfmarkDiagnosticCheck]) -> bool: + return not any(check.status == "error" for check in checks) + + +def run_shelfmark_settings_diagnostics( + values: Mapping[str, Any] | None = None, + *, + client_factory: Callable[[ShelfmarkClientConfig], ShelfmarkClient] | None = None, +) -> ShelfmarkSettingsDiagnostics: + config_data = get_shelfmark_client_config(values) + preferred_settings = get_shelfmark_preferred_release_settings(values) + + if not config_data.base_url: + checks = ( + _build_diagnostic_check( + "base_url", + _("Shelfmark base URL"), + "error", + _("Enter a Shelfmark Base URL before running the test."), + ), + _build_diagnostic_check( + "auth", + _("Auth/session"), + "warning", + _("No auth check was attempted because the Shelfmark Base URL is missing."), + ), + _build_diagnostic_check( + "request_policy", + _("Request policy"), + "warning", + _("No request policy check was attempted because the Shelfmark Base URL is missing."), + ), + _build_diagnostic_check( + "release_lookup", + _("Release lookup"), + "warning", + _("No release lookup was attempted because the Shelfmark Base URL is missing."), + ), + _build_diagnostic_check( + "provider_options", + _("Preferred provider/indexer options"), + "warning", + _("No provider or indexer options could be loaded yet."), + ), + ) + return ShelfmarkSettingsDiagnostics( + success=False, + checks=checks, + provider_options=tuple(), + provider_options_message=_( + "Provider/source options are loaded from Shelfmark only when the connection test succeeds." + ), + selected_probe_source=None, + ) + + try: + _base_url_host_port(config_data.base_url) + except ShelfmarkIntegrationError as exc: + checks = ( + _build_diagnostic_check( + "base_url", + _("Shelfmark base URL"), + "error", + str(exc), + ), + ) + return ShelfmarkSettingsDiagnostics( + success=False, + checks=checks, + provider_options=tuple(), + provider_options_message=_( + "Fix the Shelfmark Base URL first, then rerun the test to load options." + ), + selected_probe_source=None, + ) + + client = (client_factory or ShelfmarkClient)(config_data) + checks: list[ShelfmarkDiagnosticCheck] = [] + provider_options: list[ShelfmarkPreferredProviderOption] = [] + release_sources_payload: tuple[Mapping[str, Any], ...] = tuple() + request_policy_payload: Mapping[str, Any] | None = None + release_lookup_payload: Mapping[str, Any] | None = None + probe_source: str | None = None + auth_ready = False + + try: + auth_payload = client.auth_check() + checks.append( + _build_diagnostic_check( + "base_url", + _("Shelfmark base URL"), + "ok", + _("Shelfmark is reachable at %(url)s.", url=config_data.base_url), + ) + ) + except ShelfmarkIntegrationError as exc: + checks.extend( + ( + _build_diagnostic_check( + "base_url", + _("Shelfmark base URL"), + "error", + str(exc), + ), + _build_diagnostic_check( + "auth", + _("Auth/session"), + "warning", + _("The auth check could not run because Shelfmark was not reachable."), + ), + _build_diagnostic_check( + "request_policy", + _("Request policy"), + "warning", + _("The request policy check was skipped because Shelfmark was not reachable."), + ), + _build_diagnostic_check( + "release_lookup", + _("Release lookup"), + "warning", + _("The release lookup check was skipped because Shelfmark was not reachable."), + ), + _build_diagnostic_check( + "provider_options", + _("Preferred provider/indexer options"), + "warning", + _("No provider or indexer options could be loaded because Shelfmark was not reachable."), + ), + ) + ) + return ShelfmarkSettingsDiagnostics( + success=False, + checks=tuple(checks), + provider_options=tuple(), + provider_options_message=_( + "Provider/source options load only after the connection test can reach Shelfmark." + ), + selected_probe_source=None, + ) + + if config_data.username and config_data.password: + try: + client.authenticate_search_account() + auth_payload = client.auth_check() + except ShelfmarkIntegrationError as exc: + checks.append( + _build_diagnostic_check( + "auth", + _("Auth/session"), + "error", + str(exc), + ) + ) + else: + authenticated = bool(auth_payload.get("authenticated")) + checks.append( + _build_diagnostic_check( + "auth", + _("Auth/session"), + "ok" if authenticated else "warning", + _( + "Search-account login succeeded and Shelfmark reports an authenticated session." + ) + if authenticated + else _( + "Search-account login ran, but Shelfmark did not report an authenticated session." + ), + ) + ) + auth_ready = authenticated + else: + authenticated = bool(isinstance(auth_payload, Mapping) and auth_payload.get("authenticated")) + auth_required = True if not isinstance(auth_payload, Mapping) else bool( + auth_payload.get("auth_required", True) + ) + if authenticated or not auth_required: + checks.append( + _build_diagnostic_check( + "auth", + _("Auth/session"), + "ok", + _("Shelfmark auth check works without needing a separate configured search login."), + ) + ) + auth_ready = True + else: + checks.append( + _build_diagnostic_check( + "auth", + _("Auth/session"), + "warning", + _( + "Shelfmark is reachable, but no authenticated server-side session is available yet. Configure a Shelfmark search account here if request policy or release discovery needs login." + ), + ) + ) + + if auth_ready: + try: + request_policy_payload = client.request_policy() + except ShelfmarkIntegrationError as exc: + checks.append( + _build_diagnostic_check( + "request_policy", + _("Request policy"), + "error", + str(exc), + ) + ) + else: + source_mode_count = len(_source_mode_map(request_policy_payload)) + requests_enabled = bool(request_policy_payload.get("requests_enabled")) + checks.append( + _build_diagnostic_check( + "request_policy", + _("Request policy"), + "ok" if requests_enabled else "warning", + _( + "Request policy loaded for %(count)s release sources.", + count=source_mode_count, + ) + if requests_enabled + else _( + "Request policy loaded, but Shelfmark currently reports that requests are disabled." + ), + ) + ) + + try: + release_sources_payload = client.release_sources() + except ShelfmarkIntegrationError: + release_sources_payload = tuple() + + provider_options.extend( + _build_discovered_source_options( + content_type=preferred_settings.content_type, + release_sources_payload=release_sources_payload, + policy_payload=request_policy_payload, + ) + ) + + probe_source = _pick_release_probe_source( + preferred_value=preferred_settings.provider, + content_type=preferred_settings.content_type, + release_sources_payload=release_sources_payload, + policy_payload=request_policy_payload, + ) + if probe_source: + try: + release_lookup_ok, release_lookup_payload = client.probe_release_lookup( + probe_source, + content_type=preferred_settings.content_type, + ) + except ShelfmarkIntegrationError as exc: + checks.append( + _build_diagnostic_check( + "release_lookup", + _("Release lookup"), + "error", + str(exc), + ) + ) + else: + checks.append( + _build_diagnostic_check( + "release_lookup", + _("Release lookup"), + "ok" if release_lookup_ok else "warning", + _( + "Release lookup responded for %(source)s.", + source=probe_source.replace("_", " ").title(), + ) + if release_lookup_ok + else _( + "Release lookup returned an error response for %(source)s.", + source=probe_source.replace("_", " ").title(), + ), + detail=_normalize_text( + release_lookup_payload.get("error") + or release_lookup_payload.get("message") + ), + ) + ) + provider_options.extend(_extract_release_lookup_indexers(release_lookup_payload)) + else: + checks.append( + _build_diagnostic_check( + "release_lookup", + _("Release lookup"), + "warning", + _( + "No compatible release source was available to probe for %(content_type)s.", + content_type=preferred_settings.content_type, + ), + ) + ) + else: + checks.extend( + ( + _build_diagnostic_check( + "request_policy", + _("Request policy"), + "warning", + _("Request policy was not tested because no authenticated server-side Shelfmark session was available."), + ), + _build_diagnostic_check( + "release_lookup", + _("Release lookup"), + "warning", + _("Release lookup was not tested because no authenticated server-side Shelfmark session was available."), + ), + ) + ) + + deduped_provider_options: list[ShelfmarkPreferredProviderOption] = [] + seen_provider_values: set[str] = set() + for option in provider_options: + if option.value in seen_provider_values: + continue + seen_provider_values.add(option.value) + deduped_provider_options.append(option) + + if deduped_provider_options: + indexer_count = sum(1 for option in deduped_provider_options if option.kind == "indexer") + source_count = sum(1 for option in deduped_provider_options if option.kind == "source") + provider_status = "ok" if indexer_count > 0 else "warning" + provider_summary = ( + _("Loaded %(sources)s sources and %(indexers)s indexers.", sources=source_count, indexers=indexer_count) + if indexer_count > 0 + else _("Loaded %(sources)s Shelfmark sources. No indexer names were exposed by the tested release source.", sources=source_count) + ) + provider_message = ( + _("Provider/source options now include Shelfmark sources and discovered indexers.") + if indexer_count > 0 + else _( + "Shelfmark exposed source choices, but not a global indexer list. Custom indexer fallback remains available." + ) + ) + checks.append( + _build_diagnostic_check( + "provider_options", + _("Preferred provider/indexer options"), + provider_status, + provider_summary, + ) + ) + else: + provider_message = _( + "Shelfmark did not expose any provider or indexer options from this test run, so custom fallback remains available." + ) + checks.append( + _build_diagnostic_check( + "provider_options", + _("Preferred provider/indexer options"), + "warning", + _("No provider or indexer options were discovered from this test run."), + ) + ) + + return ShelfmarkSettingsDiagnostics( + success=_diagnostics_success(checks), + checks=tuple(checks), + provider_options=tuple(deduped_provider_options), + provider_options_message=provider_message, + selected_probe_source=probe_source, + ) + + +def _normalize_text(value: Any) -> str | None: + if value is None: + return None + text_value = str(value).strip() + return text_value or None + + +def _normalize_preferred_release_content_type(value: Any) -> str: + normalized = (_normalize_text(value) or "").lower() + if normalized == "audiobook": + return "audiobook" + return DEFAULT_SHELFMARK_PREFERRED_RELEASE_CONTENT_TYPE + + +def _normalize_int(value: Any) -> int | None: + try: + if value in (None, ""): + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _normalize_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _normalize_authors(value: Any) -> list[str]: + if isinstance(value, (str, bytes)): + normalized = _normalize_text(value) + return [normalized] if normalized else [] + if not isinstance(value, Sequence): + return [] + authors: list[str] = [] + for author in value: + normalized = _normalize_text(author) + if normalized: + authors.append(normalized) + return authors + + +def _iter_mapping_items(value: Any) -> tuple[Mapping[str, Any], ...]: + if isinstance(value, Mapping): + return (value,) + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + return tuple() + return tuple(item for item in value if isinstance(item, Mapping)) + + +def _resolve_shelfmark_title(book: Mapping[str, Any]) -> str | None: + direct_title = _normalize_text(book.get("title")) or _normalize_text(book.get("search_title")) + if direct_title: + return direct_title + + canonical = book.get("canonical") + if isinstance(canonical, Mapping): + return _normalize_text(canonical.get("title")) + return None + + +def _resolve_shelfmark_authors(book: Mapping[str, Any]) -> list[str]: + authors = _normalize_authors(book.get("authors")) + if authors: + return authors + + contribution_authors: list[str] = [] + seen_contributors: set[str] = set() + for contribution in _iter_mapping_items(book.get("contributions")): + author = contribution.get("author") + author_name = _normalize_text(author.get("name")) if isinstance(author, Mapping) else None + if not author_name: + continue + contributor_key = author_name.casefold() + if contributor_key in seen_contributors: + continue + seen_contributors.add(contributor_key) + contribution_authors.append(author_name) + if contribution_authors: + return contribution_authors + + cached_contributors = book.get("cached_contributors") + if isinstance(cached_contributors, Sequence) and not isinstance(cached_contributors, (str, bytes)): + normalized_cached: list[str] = [] + for contributor in cached_contributors: + if isinstance(contributor, Mapping): + contributor_name = _normalize_text( + contributor.get("name") + or contributor.get("author") + or contributor.get("display_name") + ) + else: + contributor_name = _normalize_text(contributor) + if contributor_name: + normalized_cached.append(contributor_name) + if normalized_cached: + return normalized_cached + + for key in ("author", "search_author"): + authors = _normalize_authors(book.get(key)) + if authors: + return authors + + return [] + + +def _normalize_cover_candidate(value: Any) -> str | None: + if isinstance(value, Mapping): + for key in ("url", "href", "src"): + normalized = _normalize_text(value.get(key)) + if normalized: + return normalized + return None + return _normalize_text(value) + + +def _normalize_year_from_value(value: Any) -> int | None: + direct_year = _normalize_int(value) + if direct_year is not None: + return direct_year + + normalized = _normalize_text(value) + if not normalized: + return None + + match = re.search(r"\b(1[89]\d{2}|20\d{2}|21\d{2})\b", normalized) + if not match: + return None + return _normalize_int(match.group(1)) + + +def _normalize_tag_category(value: Any) -> str | None: + normalized = _normalize_text(value) + if not normalized: + return None + normalized = normalized.casefold().replace("_", " ").replace("-", " ") + return re.sub(r"\s+", " ", normalized).strip() or None + + +def _tag_category_matches(category: Any, allowed_labels: Sequence[str]) -> bool: + normalized_category = _normalize_tag_category(category) + if not normalized_category: + return False + allowed = {_normalize_tag_category(label) for label in allowed_labels} + return normalized_category in allowed + + +def _extract_schema_tag_label(tagging: Mapping[str, Any]) -> str | None: + tag = tagging.get("tag") + if isinstance(tag, Mapping): + return ( + _normalize_text(tag.get("tag")) + or _normalize_text(tag.get("name")) + or _normalize_text(tag.get("title")) + or _normalize_text(tag.get("label")) + ) + return None + + +def _resolve_schema_tag_values( + book: Mapping[str, Any], + *, + categories: Sequence[str], + limit: int, + prefer_non_spoilers: bool = False, +) -> tuple[str, ...]: + matches: list[tuple[int, int, str]] = [] + seen: set[str] = set() + + for index, tagging in enumerate(_iter_mapping_items(book.get("taggings"))): + tag = tagging.get("tag") + if not isinstance(tag, Mapping): + continue + + tag_category = tag.get("tag_category") + category_value = None + if isinstance(tag_category, Mapping): + category_value = tag_category.get("category") or tag_category.get("slug") + if not _tag_category_matches(category_value, categories): + continue + + label = _extract_schema_tag_label(tagging) + if not label: + continue + + label_key = label.casefold() + if label_key in seen: + continue + seen.add(label_key) + + spoiler_rank = 1 if bool(tagging.get("spoiler")) else 0 + if not prefer_non_spoilers: + spoiler_rank = 0 + matches.append((spoiler_rank, index, label)) + + if prefer_non_spoilers: + matches.sort(key=lambda item: (item[0], item[1])) + + return tuple(label for _, _, label in matches[:limit]) + + +def _resolve_hardcover_slug(book: Mapping[str, Any]) -> str | None: + slug = _normalize_text(book.get("slug")) + if slug: + return slug + + canonical = book.get("canonical") + if isinstance(canonical, Mapping): + return _normalize_text(canonical.get("slug")) + return None + + +def _resolve_source_url(book: Mapping[str, Any]) -> str | None: + explicit_source_url = _normalize_text(book.get("source_url")) + if explicit_source_url: + return explicit_source_url + + if (_normalize_text(book.get("provider")) or SHELFMARK_METADATA_PROVIDER) != SHELFMARK_METADATA_PROVIDER: + return None + + slug = _resolve_hardcover_slug(book) + if not slug: + return None + return f"https://hardcover.app/books/{slug}" + + +def _edition_identity(edition: Mapping[str, Any]) -> tuple[str | None, ...]: + return ( + _normalize_text(edition.get("id")), + _normalize_text(edition.get("title")), + _normalize_text(edition.get("isbn_13")), + _normalize_text(edition.get("isbn_10")), + ) + + +def _iter_schema_editions(book: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]: + editions: list[Mapping[str, Any]] = [] + seen: set[tuple[str | None, ...]] = set() + + def add_candidate(candidate: Any) -> None: + if not isinstance(candidate, Mapping): + return + edition_key = _edition_identity(candidate) + if edition_key in seen: + return + seen.add(edition_key) + editions.append(candidate) + + for key in ( + "default_ebook_edition", + "edition", + "default_physical_edition", + "default_audio_edition", + "default_cover_edition", + ): + add_candidate(book.get(key)) + + for candidate in _iter_mapping_items(book.get("editions")): + add_candidate(candidate) + + return tuple(editions) + + +def _iter_media_signals(record: Mapping[str, Any]) -> tuple[str, ...]: + values: list[str] = [] + for key in ( + "content_type", + "format", + "edition_format", + "media_type", + "physical_format", + "object_type", + ): + normalized = _normalize_text(record.get(key)) + if normalized: + values.append(normalized.casefold()) + + reading_format = record.get("reading_format") + if isinstance(reading_format, Mapping): + normalized = _normalize_text(reading_format.get("format")) + if normalized: + values.append(normalized.casefold()) + + return tuple(values) + + +def _classify_schema_content_type(record: Mapping[str, Any]) -> str | None: + media_signals = _iter_media_signals(record) + if not media_signals: + return None + + has_ebook_signal = any( + any(hint in signal for hint in SHELFMARK_EBOOK_HINTS) + for signal in media_signals + ) + has_physical_signal = any( + any(hint in signal for hint in SHELFMARK_PHYSICAL_HINTS) + for signal in media_signals + ) + has_audio_signal = any( + signal == "audiobook" + or any(hint in signal for hint in SHELFMARK_AUDIOBOOK_HINTS) + for signal in media_signals + ) + + if has_ebook_signal: + return "ebook" + if has_physical_signal: + return "physical" + if has_audio_signal: + return "audio" + return None + + +def _select_edition_by_kind(book: Mapping[str, Any], kind: str) -> Mapping[str, Any] | None: + default_key_by_kind = { + "ebook": "default_ebook_edition", + "physical": "default_physical_edition", + "audio": "default_audio_edition", + } + default_candidate = book.get(default_key_by_kind[kind]) + if isinstance(default_candidate, Mapping): + return default_candidate + + inline_edition = book.get("edition") + if isinstance(inline_edition, Mapping): + inline_kind = _classify_schema_content_type(inline_edition) + if inline_kind == kind or (kind == "ebook" and inline_kind is None): + return inline_edition + + for candidate in _iter_mapping_items(book.get("editions")): + if _classify_schema_content_type(candidate) == kind: + return candidate + + return None + + +def _resolve_display_edition(book: Mapping[str, Any]) -> Mapping[str, Any] | None: + for kind in ("ebook", "physical", "audio"): + candidate = _select_edition_by_kind(book, kind) + if candidate is not None: + return candidate + + default_cover_edition = book.get("default_cover_edition") + if isinstance(default_cover_edition, Mapping): + return default_cover_edition + + all_editions = _iter_schema_editions(book) + return all_editions[0] if all_editions else None + + +def _resolve_primary_content_type(book: Mapping[str, Any]) -> str | None: + explicit_type = _classify_schema_content_type(book) + if _select_edition_by_kind(book, "ebook") is not None: + return "ebook" + if explicit_type == "ebook": + return "ebook" + + if _select_edition_by_kind(book, "physical") is not None: + return "physical" + if explicit_type == "physical": + return "physical" + + if _select_edition_by_kind(book, "audio") is not None: + return "audio" + if explicit_type == "audio": + return "audio" + + return None + + +def _resolve_featured_series_row(book: Mapping[str, Any]) -> Mapping[str, Any] | None: + featured = book.get("featured_book_series") + if isinstance(featured, Mapping): + return featured + + for row in _iter_mapping_items(book.get("book_series")): + if bool(row.get("featured")) and isinstance(row.get("series"), Mapping): + return row + + for row in _iter_mapping_items(book.get("book_series")): + if isinstance(row.get("series"), Mapping): + return row + + return None + + +def _resolve_featured_series(book: Mapping[str, Any]) -> Mapping[str, Any] | None: + row = _resolve_featured_series_row(book) + if not row: + return None + series = row.get("series") + return series if isinstance(series, Mapping) else None + + +def _build_series_membership( + *, + name: str | None, + position: float | None, + slug: str | None, + book_count: int | None = None, + allow_generated_url: bool = True, + featured: bool = False, +) -> ShelfmarkSeriesMembership | None: + normalized_name = _normalize_text(name) + if not normalized_name: + return None + normalized_slug = _normalize_text(slug) + fallback_slug = _slugify_hardcover_path_segment(normalized_name) if allow_generated_url else None + return ShelfmarkSeriesMembership( + key=_normalize_series_name_key(normalized_name) or normalized_name.casefold(), + name=normalized_name, + position=position, + book_count=book_count, + featured=bool(featured), + display=_format_series_display(normalized_name, position) or normalized_name, + url=( + f"https://hardcover.app/series/{normalized_slug}" + if allow_generated_url and normalized_slug + else f"https://hardcover.app/series/{fallback_slug}" + if fallback_slug + else None + ), + ) + + +def _resolve_series_memberships(book: Mapping[str, Any]) -> tuple[ShelfmarkSeriesMembership, ...]: + memberships: list[ShelfmarkSeriesMembership] = [] + seen: set[tuple[str, str]] = set() + allow_generated_url = ( + (_normalize_text(book.get("provider")) or SHELFMARK_METADATA_PROVIDER) + == SHELFMARK_METADATA_PROVIDER + ) + + def append_membership(series_row: Mapping[str, Any] | None, *, force_featured: bool = False) -> None: + if not isinstance(series_row, Mapping): + return + series = series_row.get("series") + series_data = series if isinstance(series, Mapping) else {} + name = _normalize_text(series_data.get("name")) or _normalize_text(series_row.get("series_name")) + position = _normalize_series_position(series_row.get("position")) + slug = _normalize_text(series_data.get("slug")) or _normalize_text(series_row.get("series_slug")) + book_count = ( + _normalize_int(series_data.get("primary_books_count")) + or _normalize_int(series_data.get("books_count")) + or _normalize_int(series_row.get("series_count")) + ) + featured = force_featured or bool(series_row.get("featured")) + membership = _build_series_membership( + name=name, + position=position, + slug=slug, + book_count=book_count, + allow_generated_url=allow_generated_url, + featured=featured, + ) + if not membership: + return + key = ( + membership.key, + _format_series_position(membership.position) or "", + ) + if key in seen: + return + seen.add(key) + memberships.append(membership) + + append_membership(_resolve_featured_series_row(book), force_featured=True) + for row in _iter_mapping_items(book.get("book_series")): + append_membership(row) + + if not memberships: + fallback_membership = _build_series_membership( + name=_resolve_series_name(book), + position=_resolve_series_position(book), + slug=_normalize_text(book.get("series_slug")), + book_count=_resolve_series_count(book), + allow_generated_url=allow_generated_url, + ) + if fallback_membership: + memberships.append(fallback_membership) + + memberships.sort(key=_series_membership_sort_key) + return tuple(memberships) + + +def _series_memberships_to_entries( + memberships: Sequence[ShelfmarkSeriesMembership], +) -> tuple[dict[str, Any], ...]: + return tuple( + membership.to_template_dict() + for membership in memberships + ) + + +def _resolve_shelfmark_cover_value(book: Mapping[str, Any]) -> Any: + for key in ("cover_url", "preview", "cached_image", "image"): + value = _normalize_cover_candidate(book.get(key)) + if value: + return value + + default_cover_edition = book.get("default_cover_edition") + if isinstance(default_cover_edition, Mapping): + for key in ("cached_image", "image"): + value = _normalize_cover_candidate(default_cover_edition.get(key)) + if value: + return value + + display_edition = _resolve_display_edition(book) + if isinstance(display_edition, Mapping): + for key in ("cached_image", "image"): + value = _normalize_cover_candidate(display_edition.get(key)) + if value: + return value + return None + + +def _sanitize_description_html(value: Any) -> str | None: + normalized = _normalize_text(value) + if not normalized: + return None + cleaned = clean_string(normalized) + cleaned = _normalize_text(cleaned) + return cleaned + + +def _plain_text_from_html(value: Any) -> str | None: + normalized = _normalize_text(value) + if not normalized: + return None + plain = _normalize_text(Markup(normalized).striptags()) + return plain + + +def _normalize_series_position(value: Any) -> float | None: + return _normalize_float(value) + + +def _format_series_position(value: float | None) -> str | None: + if value is None: + return None + if float(value).is_integer(): + return str(int(value)) + return f"{value:.2f}".rstrip("0").rstrip(".") + + +def _resolve_series_name(book: Mapping[str, Any]) -> str | None: + featured_series = _resolve_featured_series(book) + if isinstance(featured_series, Mapping): + name = _normalize_text(featured_series.get("name")) + if name: + return name + return _normalize_text(book.get("series_name")) + + +def _resolve_series_position(book: Mapping[str, Any]) -> float | None: + featured_series_row = _resolve_featured_series_row(book) + if isinstance(featured_series_row, Mapping): + position = _normalize_series_position(featured_series_row.get("position")) + if position is not None: + return position + return _normalize_series_position(book.get("series_position")) + + +def _resolve_series_count(book: Mapping[str, Any]) -> int | None: + featured_series = _resolve_featured_series(book) + if isinstance(featured_series, Mapping): + for key in ("primary_books_count", "books_count"): + count = _normalize_int(featured_series.get(key)) + if count is not None: + return count + return _normalize_int(book.get("series_count")) + + +def _resolve_publish_year(book: Mapping[str, Any]) -> int | None: + for candidate in ( + book.get("publish_year"), + book.get("release_year"), + book.get("release_date"), + ): + resolved_year = _normalize_year_from_value(candidate) + if resolved_year is not None: + return resolved_year + + display_edition = _resolve_display_edition(book) + if isinstance(display_edition, Mapping): + for candidate in ( + display_edition.get("release_year"), + display_edition.get("release_date"), + ): + resolved_year = _normalize_year_from_value(candidate) + if resolved_year is not None: + return resolved_year + + return None + + +def _lookup_display_field_value(book: Mapping[str, Any], *labels: str) -> str | None: + target_labels = {label.casefold() for label in labels if label} + if not target_labels: + return None + + for field in _normalize_display_fields(book.get("display_fields")): + label = _normalize_text(field.get("label")) + if label and label.casefold() in target_labels: + return _normalize_text(field.get("value")) + return None + + +def _resolve_pages(book: Mapping[str, Any]) -> int | None: + display_edition = _resolve_display_edition(book) + if isinstance(display_edition, Mapping): + edition_pages = _normalize_int(display_edition.get("pages")) + if edition_pages is not None: + return edition_pages + + pages = _normalize_int(book.get("pages")) + if pages is not None: + return pages + + display_value = _lookup_display_field_value(book, "Pages") + if not display_value: + return None + return _normalize_int(re.sub(r"[^\d]", "", display_value)) + + +def _resolve_editions_count(book: Mapping[str, Any]) -> int | None: + editions_count = _normalize_int(book.get("editions_count")) + if editions_count is not None: + return editions_count + + editions_aggregate = book.get("editions_aggregate") + if isinstance(editions_aggregate, Mapping): + aggregate = editions_aggregate.get("aggregate") + if isinstance(aggregate, Mapping): + editions_count = _normalize_int(aggregate.get("count")) + if editions_count is not None: + return editions_count + + display_value = _lookup_display_field_value(book, "Editions") + if not display_value: + return None + return _normalize_int(re.sub(r"[^\d]", "", display_value)) + + +def _resolve_lists_count(book: Mapping[str, Any]) -> int | None: + lists_count = _normalize_int(book.get("lists_count")) + if lists_count is not None: + return lists_count + + list_count = _normalize_int(book.get("list_count")) + if list_count is not None: + return list_count + + display_value = _lookup_display_field_value(book, "Lists", "List count", "Lists count") + if not display_value: + return None + return _normalize_int(re.sub(r"[^\d]", "", display_value)) + + +def _resolve_reviews_count(book: Mapping[str, Any]) -> int | None: + reviews_count = _normalize_int(book.get("reviews_count")) + if reviews_count is not None: + return reviews_count + + review_count = _normalize_int(book.get("review_count")) + if review_count is not None: + return review_count + + display_value = _lookup_display_field_value(book, "Reviews", "Review count", "Reviews count") + if not display_value: + return None + return _normalize_int(re.sub(r"[^\d]", "", display_value)) + + +def _resolve_rating_value(book: Mapping[str, Any]) -> float | None: + rating_value = _normalize_float(book.get("rating")) + if rating_value is not None: + return rating_value + + display_value = _lookup_display_field_value(book, "Rating") + if not display_value: + return None + match = re.search(r"\d+(?:\.\d+)?", display_value) + if not match: + return None + return _normalize_float(match.group(0)) + + +def _resolve_ratings_count(book: Mapping[str, Any]) -> int | None: + ratings_count = _normalize_int(book.get("ratings_count")) + if ratings_count is not None: + return ratings_count + + display_value = _lookup_display_field_value(book, "Ratings") + if display_value: + return _normalize_int(re.sub(r"[^\d]", "", display_value)) + + rating_display = _lookup_display_field_value(book, "Rating") + if not rating_display: + return None + match = re.search(r"\(([\d,]+)\)", rating_display) + if not match: + return None + return _normalize_int(match.group(1).replace(",", "")) + + +def _resolve_readers_count(book: Mapping[str, Any]) -> int | None: + readers_count = _normalize_int(book.get("users_count")) + if readers_count is not None: + return readers_count + + readers_count = _normalize_int(book.get("users_read_count")) + if readers_count is not None: + return readers_count + + display_value = _lookup_display_field_value(book, "Readers", "Reader count") + if not display_value: + return None + return _normalize_int(re.sub(r"[^\d]", "", display_value)) + + +def _resolve_shelfmark_subtitle(book: Mapping[str, Any]) -> str | None: + subtitle = _normalize_text(book.get("subtitle")) + + if (_normalize_text(book.get("provider")) or SHELFMARK_METADATA_PROVIDER) != SHELFMARK_METADATA_PROVIDER: + return subtitle + + display_edition = _resolve_display_edition(book) + edition_subtitle = None + if isinstance(display_edition, Mapping): + edition_subtitle = _normalize_text(display_edition.get("subtitle")) + + if subtitle and edition_subtitle: + return subtitle if subtitle.casefold() == edition_subtitle.casefold() else None + if edition_subtitle and not subtitle: + return edition_subtitle + + # Hardcover subtitles are often noisy unless we can confirm them against a + # schema-backed edition relationship. + return None + + +def _format_series_display(series_name: str | None, series_position: float | None) -> str | None: + if not series_name: + return None + position = _format_series_position(series_position) + if position: + return f"{series_name} ({position})" + return series_name + + +def _slugify_hardcover_path_segment(value: str | None) -> str | None: + normalized = _normalize_text(value) + if not normalized: + return None + normalized = ( + unicodedata.normalize("NFKD", normalized) + .encode("ascii", "ignore") + .decode("ascii") + ) + normalized = normalized.casefold() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized).strip("-") + return normalized or None + + +def _resolve_series_url(book: Mapping[str, Any]) -> str | None: + explicit_url = _normalize_text(book.get("series_url")) + if explicit_url: + return explicit_url + + if (_normalize_text(book.get("provider")) or SHELFMARK_METADATA_PROVIDER) != SHELFMARK_METADATA_PROVIDER: + return None + + primary_entry = next(iter(_resolve_series_memberships(book)), None) + if isinstance(primary_entry, ShelfmarkSeriesMembership): + return _normalize_text(primary_entry.url) + return None + + +def _normalize_tag_list(value: Any, *, limit: int | None = None) -> tuple[str, ...]: + if isinstance(value, (str, bytes)): + normalized = _normalize_text(value) + return (normalized,) if normalized else tuple() + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + return tuple() + + tags: list[str] = [] + seen: set[str] = set() + generic_labels = { + "genre", + "mood", + "tag", + "content warning", + "content warnings", + "warning", + "warnings", + } + for item in value: + if isinstance(item, Mapping): + normalized = ( + _normalize_text(item.get("value")) + or _normalize_text(item.get("tag")) + or _normalize_text(item.get("name")) + or _normalize_text(item.get("title")) + or _normalize_text(item.get("label")) + ) + else: + normalized = _normalize_text(item) + if not normalized: + continue + if normalized.casefold() in generic_labels: + continue + tag_key = normalized.casefold() + if tag_key in seen: + continue + seen.add(tag_key) + tags.append(normalized) + if limit and len(tags) >= limit: + break + return tuple(tags) + + +def _resolve_genres(book: Mapping[str, Any]) -> tuple[str, ...]: + schema_values = _resolve_schema_tag_values( + book, + categories=SHELFMARK_GENRE_CATEGORY_HINTS, + limit=5, + ) + if schema_values: + return schema_values + + direct = _normalize_tag_list(book.get("genres"), limit=5) + if direct: + return direct + + cached_tags = _normalize_tag_list(book.get("cached_tags"), limit=5) + if cached_tags: + return cached_tags + + tag_fallback = _normalize_tag_list(book.get("tags"), limit=5) + if tag_fallback: + return tag_fallback + + return _normalize_compact_values( + _lookup_display_field_value(book, "Genres", "Genre", "Tags", "Tag"), + limit=5, + ) + + +def _normalize_compact_values(value: Any, *, limit: int | None = None) -> tuple[str, ...]: + generic_labels = { + "genre", + "mood", + "tag", + "content warning", + "content warnings", + "warning", + "warnings", + } + if isinstance(value, Mapping): + normalized = ( + _normalize_text(value.get("value")) + or _normalize_text(value.get("tag")) + or _normalize_text(value.get("label")) + or _normalize_text(value.get("name")) + ) + if not normalized or normalized.casefold() in generic_labels: + return tuple() + return (normalized,) + + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return _normalize_tag_list(value, limit=limit) + + normalized = _normalize_text(value) + if not normalized: + return tuple() + + parts = re.split(r"\s*(?:,|;|\n|\u2022|\u00b7|\|)\s*", normalized) + values: list[str] = [] + seen: set[str] = set() + for part in parts: + item = _normalize_text(part) + if not item: + continue + if item.casefold() in generic_labels: + continue + item_key = item.casefold() + if item_key in seen: + continue + seen.add(item_key) + values.append(item) + if limit and len(values) >= limit: + break + return tuple(values) + + +def _resolve_moods(book: Mapping[str, Any]) -> tuple[str, ...]: + schema_values = _resolve_schema_tag_values( + book, + categories=SHELFMARK_MOOD_CATEGORY_HINTS, + limit=5, + ) + if schema_values: + return schema_values + + direct = _normalize_compact_values(book.get("moods"), limit=5) + if direct: + return direct + return _normalize_compact_values( + _lookup_display_field_value(book, "Moods", "Mood"), + limit=5, + ) + + +def _resolve_content_warnings(book: Mapping[str, Any]) -> tuple[str, ...]: + schema_values = _resolve_schema_tag_values( + book, + categories=SHELFMARK_CONTENT_WARNING_CATEGORY_HINTS, + limit=6, + prefer_non_spoilers=True, + ) + if schema_values: + return schema_values + + direct = _normalize_compact_values(book.get("content_warnings"), limit=6) + if direct: + return direct + + fallback = _normalize_compact_values(book.get("warnings"), limit=6) + if fallback: + return fallback + + return _normalize_compact_values( + _lookup_display_field_value(book, "Content warnings", "Content warning", "Warnings"), + limit=6, + ) + + +def build_shelfmark_quality_state(result: ShelfmarkResultView) -> ShelfmarkQualityState | None: + requestable_now = bool( + not result.already_in_library + and result.hardcover_id + and result.request_payload + ) + if not requestable_now: + return None + + has_cover = bool(result.cover_url) + has_description = bool(result.description) + has_authors = bool(result.authors) + bibliographic_signal = bool( + _normalize_text(result.title) + and has_authors + and (not result.series_name or result.series_display) + ) + series_signal = bool( + result.series_context + and (result.series_context.is_next_missing or result.series_context.is_continuation) + ) + metadata_signal_count = sum( + 1 + for value in ( + result.cover_url, + result.description, + result.publish_year, + result.pages, + result.rating, + result.ratings_count, + result.readers_count, + result.series_display, + ) + if value not in (None, "") + ) + metadata_complete = bool( + has_cover + and has_description + and bibliographic_signal + and metadata_signal_count >= SHELFMARK_QUALITY_MIN_METADATA_SIGNALS + ) + popularity_signal = bool( + (result.readers_count or 0) >= SHELFMARK_TRIAGE_MIN_READERS + or (result.ratings_count or 0) >= SHELFMARK_TRIAGE_MIN_RATINGS + ) + rating_signal = bool( + result.rating is not None + and result.rating >= SHELFMARK_TRIAGE_MIN_RATING + and (result.ratings_count or 0) >= SHELFMARK_TRIAGE_MIN_RATING_COUNT + ) + high_confidence = bool( + requestable_now + and has_cover + and has_description + and bibliographic_signal + and (series_signal or rating_signal or (metadata_complete and popularity_signal)) + ) + + facts: list[str] = [] + if rating_signal: + facts.append(_("Well rated")) + if popularity_signal: + facts.append(_("Popular")) + if metadata_complete: + facts.append(_("Complete metadata")) + + detail_parts: list[str] = [] + if high_confidence: + detail_parts.append(_("High confidence")) + detail_parts.extend(facts) + + return ShelfmarkQualityState( + high_confidence=high_confidence, + metadata_complete=metadata_complete, + popularity_signal=popularity_signal, + rating_signal=rating_signal, + bibliographic_signal=bibliographic_signal, + facts=tuple(facts), + detail_value=" \u00b7 ".join(detail_parts) if detail_parts else None, + ) + + +def build_shelfmark_triage_state( + result: ShelfmarkResultView, + *, + quality_state: ShelfmarkQualityState | None = None, +) -> ShelfmarkTriageState | None: + requestable_now = bool( + not result.already_in_library + and result.hardcover_id + and result.request_payload + ) + if not requestable_now: + return None + + quality = quality_state or build_shelfmark_quality_state(result) + has_cover = bool(result.cover_url) + series_signal = bool( + result.series_context + and (result.series_context.is_next_missing or result.series_context.is_continuation) + ) + metadata_rich = bool(quality and quality.metadata_complete) + popularity_signal = bool(quality and (quality.popularity_signal or quality.rating_signal)) + strong_candidate = bool( + requestable_now + and has_cover + and ( + series_signal + or (quality and quality.high_confidence) + ) + ) + + facts: list[str] = [] + if popularity_signal: + facts.append(_("Popular")) + if metadata_rich: + facts.append(_("Rich metadata")) + + detail_parts: list[str] = [] + if strong_candidate: + detail_parts.append(_("Strong candidate")) + detail_parts.extend(facts) + + return ShelfmarkTriageState( + strong_candidate=strong_candidate, + metadata_rich=metadata_rich, + popularity_signal=popularity_signal, + facts=tuple(facts), + detail_value=" \u00b7 ".join(detail_parts) if detail_parts else None, + ) + + +def _build_result_facts(book: Mapping[str, Any], *, publish_year: int | None) -> tuple[str, ...]: + facts: list[str] = [] + + rating_value = _resolve_rating_value(book) + ratings_count = _resolve_ratings_count(book) + readers_count = _resolve_readers_count(book) + pages = _resolve_pages(book) + series_display = _format_series_display( + _resolve_series_name(book), + _resolve_series_position(book), + ) + + if rating_value is not None: + facts.append(f"{rating_value:.1f} \u2605") + if ratings_count: + label = _("rating") if ratings_count == 1 else _("ratings") + facts.append(f"{ratings_count:,} {label}") + if readers_count: + label = _("reader") if readers_count == 1 else _("readers") + facts.append(f"{readers_count:,} {label}") + if publish_year: + facts.append(str(publish_year)) + if pages: + label = _("page") if pages == 1 else _("pages") + facts.append(f"{pages:,} {label}") + if series_display: + facts.append(series_display) + + return tuple(facts) + + +def _build_detail_stats(book: Mapping[str, Any], *, publish_year: int | None) -> tuple[dict[str, str], ...]: + stats: list[dict[str, str]] = [] + reviews_count = _resolve_reviews_count(book) + editions_count = _resolve_editions_count(book) + lists_count = _resolve_lists_count(book) + + if reviews_count: + stats.append({"label": _("Reviews"), "value": f"{reviews_count:,}"}) + if editions_count: + stats.append({"label": _("Editions"), "value": f"{editions_count:,}"}) + if lists_count: + stats.append({"label": _("Lists"), "value": f"{lists_count:,}"}) + + return tuple(stats) + + +def _normalize_shelfmark_cover_url(base_url: str, value: Any) -> str | None: + normalized = _normalize_text(value) + if not normalized: + return None + + normalized_base_url = _normalize_text(base_url) or "" + cached_value = _get_cached_shelfmark_cover_url(normalized_base_url, normalized) + if cached_value is not None: + return cached_value + + parsed = urlsplit(normalized) + if parsed.scheme or parsed.netloc: + return _remember_shelfmark_cover_url(normalized_base_url, normalized, normalized) + + if normalized.startswith("/"): + base_parts = urlsplit(normalized_base_url) + if not base_parts.scheme or not base_parts.netloc: + return _remember_shelfmark_cover_url(normalized_base_url, normalized, normalized) + normalized_path = parsed.path + base_prefix = (base_parts.path or "").rstrip("/") + if base_prefix and normalized_path.startswith("/api/covers/"): + normalized_path = f"{base_prefix}{normalized_path}" + return _remember_shelfmark_cover_url( + normalized_base_url, + normalized, + urlunsplit( + ( + base_parts.scheme, + base_parts.netloc, + normalized_path, + parsed.query, + parsed.fragment, + ) + ), + ) + + return _remember_shelfmark_cover_url( + normalized_base_url, + normalized, + _join_base_url(normalized_base_url, normalized), + ) + + +def _normalize_display_fields(value: Any) -> tuple[dict[str, Any], ...]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + return tuple() + normalized_fields: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, Mapping): + continue + label = _normalize_text(item.get("label")) + field_value = _normalize_text(item.get("value")) + if not label or not field_value: + continue + normalized_fields.append( + { + "label": label, + "value": field_value, + "icon": _normalize_text(item.get("icon")), + } + ) + return tuple(normalized_fields) + + +def _extract_hardcover_id(book: Mapping[str, Any]) -> str | None: + identifiers = book.get("identifiers") + if isinstance(identifiers, Mapping): + for key in ("hardcover-id", "hardcover_id"): + normalized_identifier = _normalize_text(identifiers.get(key)) + if normalized_identifier: + return normalized_identifier + + provider = _normalize_text(book.get("provider")) + if provider != SHELFMARK_METADATA_PROVIDER: + return None + return _normalize_text(book.get("provider_id")) + + +def _missing_request_requirements(book: Mapping[str, Any]) -> tuple[str, ...]: + missing: list[str] = [] + if not _extract_hardcover_id(book): + missing.append("hardcover_id") + if not _resolve_shelfmark_title(book): + missing.append("title") + if not _resolve_shelfmark_authors(book): + missing.append("author") + return tuple(missing) + + +def _describe_missing_request_requirements(requirements: Sequence[str]) -> str | None: + labels = { + "hardcover_id": _("exact Hardcover ID"), + "title": _("book title"), + "author": _("at least one author"), + } + normalized = [labels[item] for item in requirements if item in labels] + if not normalized: + return None + if len(normalized) == 1: + requirements_text = normalized[0] + elif len(normalized) == 2: + requirements_text = _("%(first)s and %(second)s", first=normalized[0], second=normalized[1]) + else: + requirements_text = _( + "%(first)s, %(second)s, and %(third)s", + first=normalized[0], + second=normalized[1], + third=normalized[2], + ) + return _( + "CWA needs the %(requirements)s from Shelfmark before it can prepare a direct request here.", + requirements=requirements_text, + ) + + +def _join_base_url(base_url: str, path: str) -> str: + normalized_base = base_url.rstrip("/") + return urljoin(f"{normalized_base}/", path.lstrip("/")) + + +def _with_query(base_url: str, params: Mapping[str, str]) -> str: + parsed = urlsplit(base_url) + return urlunsplit( + ( + parsed.scheme, + parsed.netloc, + parsed.path, + urlencode(params, doseq=True), + "", + ) + ) diff --git a/cps/shelfmark_ui.py b/cps/shelfmark_ui.py new file mode 100644 index 000000000..8535d973f --- /dev/null +++ b/cps/shelfmark_ui.py @@ -0,0 +1,515 @@ +# Calibre-Web Automated – fork of Calibre-Web +# Copyright (C) 2026 Calibre-Web Automated contributors +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +from collections.abc import Mapping + +from flask import render_template, request, url_for +from flask_babel import gettext as _ + +from .services.shelfmark_search import ( + DEFAULT_SHELFMARK_CONTEXTUAL_LIMIT, + DEFAULT_SHELFMARK_FILTER_HAS_COVER, + DEFAULT_SHELFMARK_FILTER_REQUESTABLE, + DEFAULT_SHELFMARK_SERIES_FILTER, + DEFAULT_SHELFMARK_SORT, + fetch_shelfmark_detail, + get_shelfmark_client_config, + get_shelfmark_preferred_release_settings, + result_matches_shelfmark_filters, + search_shelfmark_contextual_results, + search_shelfmark_results, +) + +DEFAULT_CONTEXTUAL_ASYNC_BATCH_SIZE = DEFAULT_SHELFMARK_CONTEXTUAL_LIMIT + + +def search_page_has_shell(section): + if not section or not section.get("enabled"): + return False + result_count = len(section.get("results") or []) + return bool( + result_count > 0 + or section.get("raw_total_available") + or section.get("total_available") + or section.get("has_more") + or section.get("next_page") + or section.get("page_result_count") + ) + + +def apply_shelfmark_render_state(section): + if not section: + return section + + is_search = section.get("variant") == "search" + has_results = bool(section.get("results")) + has_page_shell = search_page_has_shell(section) if is_search else has_results + + section["has_page_shell"] = has_page_shell + section["render_modal"] = has_page_shell + section["render_scripts"] = has_page_shell + return section + + +def safe_local_return_url(value): + if not value: + return None + candidate = str(value).strip() + if not candidate or not candidate.startswith("/") or candidate.startswith("//"): + return None + return candidate + + +def current_request_params(*, include_transient=True): + params = request.args.to_dict(flat=True) + if include_transient: + return params + params.pop("shelfmark_series_filter", None) + for key in ( + "shelfmark_detail_provider", + "shelfmark_detail_id", + "shelfmark_filter_high_confidence", + "shelfmark_triage_filter", + ): + params.pop(key, None) + return params + + +def request_url_for_params(params): + if not params: + return url_for(request.endpoint, **(request.view_args or {})) + return url_for(request.endpoint, **(request.view_args or {}), **params) + + +def current_request_path(*, include_transient=True): + return request_url_for_params(current_request_params(include_transient=include_transient)) + + +def current_shelfmark_state_url(): + return current_request_path(include_transient=False) + + +def requested_shelfmark_page(): + try: + page = int(request.args.get("shelfmark_page", "1")) + except (TypeError, ValueError): + return 1 + return page if page > 0 else 1 + + +def requested_shelfmark_page_size(): + try: + page_size = int(request.args.get("shelfmark_page_size", "12")) + except (TypeError, ValueError): + return 12 + return page_size if page_size > 0 else 12 + + +def requested_shelfmark_sort(): + return (request.args.get("shelfmark_sort", DEFAULT_SHELFMARK_SORT) or DEFAULT_SHELFMARK_SORT).strip().lower() + + +def requested_shelfmark_flag(name, default=False): + values = request.args.getlist(name) + if not values: + return default + return (values[-1] or "").strip().lower() in {"1", "true", "yes", "on"} + + +def current_request_url_with(**updates): + params = current_request_params(include_transient=False) + params.update({key: str(value) for key, value in updates.items() if value not in (None, "")}) + for key, value in updates.items(): + if value in (None, ""): + params.pop(key, None) + return request_url_for_params(params) + + +def build_shelfmark_detail_url(book, *, query, return_to=None): + provider = (book or {}).get("provider") + provider_id = (book or {}).get("provider_id") + if not provider or not provider_id: + return None + return url_for( + "search.shelfmark_external_detail", + provider=provider, + provider_id=provider_id, + query=query, + return_to=safe_local_return_url(return_to), + ) + + +def build_shelfmark_row_enrichment_url(book, *, query, return_to=None): + provider = (book or {}).get("provider") + provider_id = (book or {}).get("provider_id") + if not provider or not provider_id: + return None + + params = current_request_params(include_transient=False) + params["query"] = query + safe_return_to = safe_local_return_url(return_to) + if safe_return_to: + params["return_to"] = safe_return_to + else: + params.pop("return_to", None) + return url_for( + "search.shelfmark_external_row", + provider=provider, + provider_id=provider_id, + **params, + ) + + +def build_shelfmark_top_up_url(*, query, return_to=None): + params = current_request_params(include_transient=False) + params["query"] = query + params.pop("shelfmark_source_page", None) + safe_return_to = safe_local_return_url(return_to) + if safe_return_to: + params["return_to"] = safe_return_to + else: + params.pop("return_to", None) + return url_for("search.shelfmark_external_topup", **params) + + +def build_shelfmark_result_row_class_name(result): + library_state = result.get("library_state") or {} + classes = [ + "shelfmark-result-card", + "js-shelfmark-result-row", + "shelfmark-result-card--%s" % (library_state.get("row_class") or "info"), + ] + if result.get("workflow_state") and not result.get("already_in_library"): + classes.append("js-shelfmark-status-target") + if result.get("request_payload") and not result.get("already_in_library"): + classes.append("js-shelfmark-batch-row") + if result.get("needs_progressive_enrichment"): + classes.append("js-shelfmark-progressive-row") + if result.get("needs_progressive_enrichment") or result.get("progressive_filter_pending"): + classes.append("shelfmark-result-card--refining") + return " ".join(classes) + + +def populate_shelfmark_row_state(result): + result["row_class_name"] = build_shelfmark_result_row_class_name(result) + result["row_status_provider"] = ( + "hardcover" + if result.get("workflow_state") and not result.get("already_in_library") + else "" + ) + result["row_status_provider_id"] = result.get("hardcover_id") or "" + result["row_status_in_library"] = "1" if result.get("already_in_library") else "0" + result["row_has_cover"] = "1" if result.get("cover_url") else "0" + + +def decorate_shelfmark_result_rows(section, *, query, enable_progressive_enrichment): + results = section.get("results") or [] + state_url = section.get("state_url") + for index, result in enumerate(results): + result["row_index"] = index + populate_shelfmark_row_state(result) + result["row_enrichment_url"] = ( + build_shelfmark_row_enrichment_url( + result, + query=query, + return_to=state_url, + ) + if enable_progressive_enrichment and result.get("needs_progressive_enrichment") + else None + ) + + +def _display_page_has_possible_next(section, display_page, page_size): + raw_total = section.get("raw_total_available") or section.get("total_available") or 0 + if raw_total and raw_total > (display_page * page_size): + return True + return bool(section.get("has_more")) + + +def build_search_page_shelfmark_section(query, **kwargs): + preferred_release = get_shelfmark_preferred_release_settings().to_template_dict() + display_page = requested_shelfmark_page() + requested_page_size = requested_shelfmark_page_size() + section = search_shelfmark_results( + query, + page=1, + page_size=requested_page_size, + sort=requested_shelfmark_sort(), + series_filter=DEFAULT_SHELFMARK_SERIES_FILTER, + filter_requestable=requested_shelfmark_flag( + "shelfmark_filter_requestable", + default=DEFAULT_SHELFMARK_FILTER_REQUESTABLE, + ), + filter_has_cover=requested_shelfmark_flag( + "shelfmark_filter_has_cover", + default=DEFAULT_SHELFMARK_FILTER_HAS_COVER, + ), + **kwargs, + ).to_template_dict() + if not section.get("enabled"): + return section + + section["variant"] = "search" + section["section_title"] = _("Shelfmark Results") + section["section_subtitle"] = ( + _("Request-ready matches from Shelfmark.") + if section.get("filter_requestable") + else _("Additional matches from Shelfmark.") + ) + section["source_page"] = 1 + section["source_next_page"] = section.get("next_page") + section["source_total_pages"] = section.get("total_pages") + section["page"] = display_page + section["page_size"] = requested_page_size + section["previous_page"] = display_page - 1 if display_page > 1 else None + section["has_previous"] = bool(section["previous_page"]) + section["next_page"] = ( + display_page + 1 + if _display_page_has_possible_next(section, display_page, requested_page_size) + else None + ) + section["total_pages"] = 0 + section["state_url"] = current_request_url_with( + shelfmark_page=section.get("page") or 1, + shelfmark_page_size=section.get("page_size") or 12, + shelfmark_sort=section.get("selected_sort"), + shelfmark_filter_requestable="1" if section.get("filter_requestable") else "0", + shelfmark_filter_has_cover="1" if section.get("filter_has_cover") else "0", + ) + previous_page = section.get("previous_page") + next_page = section.get("next_page") + section["previous_page_url"] = ( + current_request_url_with(shelfmark_page=previous_page) + if previous_page + else None + ) + section["next_page_url"] = ( + current_request_url_with(shelfmark_page=next_page) + if next_page + else None + ) + section["clear_filters_url"] = current_request_url_with( + shelfmark_page=1, + shelfmark_sort=None, + shelfmark_filter_requestable=None, + shelfmark_filter_has_cover=None, + ) + section["top_up_url"] = build_shelfmark_top_up_url( + query=query, + return_to=section.get("state_url"), + ) + section["preferred_release_settings"] = preferred_release + decorate_shelfmark_result_rows( + section, + query=query, + enable_progressive_enrichment=True, + ) + return apply_shelfmark_render_state(section) + + +def shelfmark_contextual_enabled(): + return bool(get_shelfmark_client_config().enabled) + + +def build_contextual_shelfmark_runtime(): + enabled = shelfmark_contextual_enabled() + return { + "enabled": enabled, + "render_modal": enabled, + "render_scripts": enabled, + "state_url": current_request_path(include_transient=False), + "preferred_release_settings": get_shelfmark_preferred_release_settings().to_template_dict(), + } + + +def build_contextual_shelfmark_loader(initial_url, *, context_type): + if not initial_url: + return None + return { + "context_type": context_type, + "container_id": f"shelfmark-contextual-{context_type}", + "initial_url": initial_url, + "loading_message": _("Loading Shelfmark results…"), + "failure_message": _("Shelfmark is unavailable right now."), + "load_more_failure_message": _("Could not load more Shelfmark results right now."), + } + + +def render_contextual_shelfmark_partial(section): + if not section: + return ("", 200) + return render_template("shelfmark_contextual_async_section.html", shelfmark_section=section) + + +def render_contextual_shelfmark_append(section): + if section and not section.get("available"): + return ("", 503) + if not section or not (section.get("results") or section.get("load_more_url")): + return ("", 200) + return render_template("shelfmark_contextual_async_append.html", shelfmark_section=section) + + +def _slice_contextual_section_results(section, *, query, offset, limit): + results = list(section.get("results") or []) + batch_results = results[offset:offset + limit] + section["results"] = batch_results + section["page"] = 1 + section["page_size"] = limit + section["page_result_count"] = len(batch_results) + section["offset"] = offset + section["variant"] = "contextual" + section["has_page_shell"] = bool(batch_results) + section["render_modal"] = False + section["render_scripts"] = False + decorate_shelfmark_result_rows( + section, + query=query, + enable_progressive_enrichment=False, + ) + for index, result in enumerate(section.get("results") or []): + result["row_index"] = offset + index + return section + + +def build_contextual_shelfmark_section_page( + query, + *, + context_type, + context_value, + section_title, + section_subtitle, + endpoint_name, + endpoint_kwargs, + state_url=None, + offset=0, + limit=DEFAULT_CONTEXTUAL_ASYNC_BATCH_SIZE, + sort="relevance", + empty_message=None, +): + contextual_limit = max(1, int(limit or DEFAULT_CONTEXTUAL_ASYNC_BATCH_SIZE)) + requested_offset = max(0, int(offset or 0)) + requested_total = requested_offset + contextual_limit + probe_total = requested_total + 1 + preferred_release = get_shelfmark_preferred_release_settings().to_template_dict() + return_to = safe_local_return_url(state_url) or current_request_path(include_transient=False) + section = search_shelfmark_contextual_results( + query, + detail_url_builder=lambda book: build_shelfmark_detail_url( + book, + query=query, + return_to=return_to, + ), + context_type=context_type, + context_value=context_value, + limit=probe_total, + sort=sort, + filter_requestable=True, + filter_has_cover=True, + empty_message=empty_message, + ).to_template_dict() + + if not section.get("enabled"): + return None + + section["section_title"] = section_title + section["section_subtitle"] = section_subtitle + section["state_url"] = return_to + section["preferred_release_settings"] = preferred_release + available_results = list(section.get("results") or []) + section = _slice_contextual_section_results( + section, + query=query, + offset=requested_offset, + limit=contextual_limit, + ) + remaining_results = requested_offset + len(section.get("results") or []) + has_more = len(available_results) > remaining_results + section["has_more_contextual"] = has_more + section["next_offset"] = remaining_results if has_more else None + section["load_more_url"] = ( + url_for(endpoint_name, offset=remaining_results, append=1, return_to=return_to, **endpoint_kwargs) + if has_more + else None + ) + if not section.get("available") and not section.get("message"): + section["message"] = _("Shelfmark is unavailable right now.") + return section + + +def build_author_contextual_shelfmark_section_page(author_name, *, author_id, state_url=None, offset=0): + return build_contextual_shelfmark_section_page( + author_name, + context_type="author", + context_value=author_name, + section_title=_("Shelfmark"), + section_subtitle=_("Missing most popular requestable books by this author from Shelfmark"), + endpoint_name="web.author_shelfmark_section", + endpoint_kwargs={"author_id": author_id}, + state_url=state_url, + offset=offset, + sort="popularity", + ) + + +def build_series_contextual_shelfmark_section_page(series_name, *, series_id, state_url=None, offset=0): + return build_contextual_shelfmark_section_page( + series_name, + context_type="series", + context_value=series_name, + section_title=_("Shelfmark"), + section_subtitle=_("Missing requestable books for this series from Shelfmark"), + endpoint_name="web.series_shelfmark_section", + endpoint_kwargs={"series_id": series_id}, + state_url=state_url, + offset=offset, + ) + + +def build_shelfmark_row_response(result_view, *, preferred_release, requestable_only, has_cover_only): + result = result_view.to_template_dict() + populate_shelfmark_row_state(result) + result["needs_progressive_enrichment"] = False + result["progressive_filter_pending"] = False + return { + "ok": True, + "matches_filters": result_matches_shelfmark_filters( + result_view, + requestable_only=requestable_only, + has_cover_only=has_cover_only, + series_filter=DEFAULT_SHELFMARK_SERIES_FILTER, + ), + "row_class_name": result["row_class_name"], + "row_status_provider": result["row_status_provider"], + "row_status_provider_id": result["row_status_provider_id"], + "row_status_in_library": result["row_status_in_library"], + "row_has_cover": result["row_has_cover"], + "library_book_url": result.get("library_book_url"), + "library_book_title": result.get("library_book_title"), + "preferred_release": preferred_release, + "result": result, + } + + +def build_shelfmark_top_up_rows(section, *, preferred_release): + rows = [] + for result in section.get("results") or []: + rows.append( + { + "provider": result.get("provider") or "", + "provider_id": result.get("provider_id") or "", + "row_class_name": result.get("row_class_name") or "", + "row_status_provider": result.get("row_status_provider") or "", + "row_status_provider_id": result.get("row_status_provider_id") or "", + "row_status_in_library": result.get("row_status_in_library") or "0", + "row_has_cover": result.get("row_has_cover") or "0", + "library_book_url": result.get("library_book_url"), + "library_book_title": result.get("library_book_title"), + "row_enrichment_url": result.get("row_enrichment_url") or "", + "preferred_release": preferred_release, + "result": result, + } + ) + return rows diff --git a/cps/static/css/cwa.css b/cps/static/css/cwa.css index 78e027553..496fde8ab 100644 --- a/cps/static/css/cwa.css +++ b/cps/static/css/cwa.css @@ -45,3 +45,200 @@ body.modal-open #remove-from-shelves + .tags { #exclude_tag + .btn-group .dropdown-menu { max-width: 40vw; } + +/* Sidebar subsection styling for Requests. */ +.navigation .nav-head--requests { + margin-top: 10px; +} + +.navigation #scnd-nav li.nav-subitem > a { + padding-left: 22px; +} + +.navigation #scnd-nav li.nav-subitem > a .glyphicon { + opacity: 0.92; +} + +.navigation #scnd-nav li.nav-subitem--requests > a { + position: relative; +} + +.navigation #scnd-nav li.nav-subitem--requests > a::before { + content: ""; + position: absolute; + top: 8px; + bottom: 8px; + left: 10px; + width: 2px; + border-radius: 999px; + background: rgba(150, 196, 243, 0.18); +} + +/* Author page layout should use the main content width instead of a narrow inset strip. */ +body.author:not(.authorlist) > div.container-fluid > div > div.col-sm-10 { + width: calc(100% - 240px); + left: 240px; + padding-left: clamp(24px, 3vw, 42px); + padding-right: clamp(24px, 3.5vw, 52px); + padding-bottom: 16px; +} + +body.author:not(.authorlist) .author-page { + width: 100%; +} + +body.author:not(.authorlist) .author-page__header { + margin: 0 0 18px; + padding: 12px 0 14px; + border-bottom: 1px solid rgba(236, 244, 248, 0.12); +} + +body.author:not(.authorlist) .author-page__title { + margin: 0 0 12px; + padding: 0; + min-height: 0; + border: 0; + color: #f4f8fb; +} + +body.author:not(.authorlist) .author-page__profile { + display: grid; + grid-template-columns: minmax(112px, 140px) minmax(0, 1fr); + gap: 0 clamp(20px, 2.6vw, 34px); + align-items: start; + margin: 0; + padding: 0; + border: 0; + width: 100%; +} + +body.author:not(.authorlist) .author-bio--text-only { + grid-template-columns: minmax(0, 1fr); +} + +body.author:not(.authorlist) .author-bio .author-photo { + width: clamp(112px, 9vw, 132px); + max-width: none; +} + +body.author:not(.authorlist) .author-bio__content { + width: 100%; + max-width: none; +} + +body.author:not(.authorlist) .author-bio__copy, +body.author:not(.authorlist) .author-bio__meta { + max-width: min(96ch, 100%); +} + +body.author:not(.authorlist) .author-bio__toggle { + margin-top: 6px; +} + +body.author:not(.authorlist) .author-bio__meta { + margin-top: 4px; +} + +body.author:not(.authorlist) .author-page__section { + width: 100%; + margin: 0 0 18px; + padding: 0 !important; +} + +body.author:not(.authorlist) .author-page__section:last-child { + margin-bottom: 0; +} + +body.author:not(.authorlist) .author-library-section > h3 { + margin: 0 0 8px; +} + +body.author:not(.authorlist) .author-library-section .filterheader { + margin: 0 0 10px; +} + +body.author:not(.authorlist) .author-library-section .row.display-flex { + margin-left: -10px; + margin-right: -10px; +} + +body.author:not(.authorlist) .author-library-section .book { + padding-left: 10px; + padding-right: 10px; + margin-bottom: 16px; +} + +body.author:not(.authorlist) .author-shelfmark-section .shelfmark-section-line--contextual { + margin: 0 0 10px; +} + +body.author:not(.authorlist) .author-shelfmark-section .shelfmark-contextual-async__placeholder { + padding: 6px 0 2px; +} + +body.author:not(.authorlist) .author-shelfmark-section .shelfmark-contextual-load-more { + margin-top: 10px; +} + +@media (max-width: 991px) { + body.author:not(.authorlist) > div.container-fluid > div > div.col-sm-10 { + padding-left: 20px; + padding-right: 20px; + } + + body.author:not(.authorlist) .author-page__profile { + grid-template-columns: 100px minmax(0, 1fr); + gap: 0 18px; + } + + body.author:not(.authorlist) .author-bio .author-photo { + width: 100px; + } + + body.author:not(.authorlist) .author-bio__copy, + body.author:not(.authorlist) .author-bio__meta { + max-width: min(84ch, 100%); + } +} + +@media (max-width: 767px) { + body.author:not(.authorlist) > div.container-fluid > div > div.col-sm-10 { + width: 100%; + left: 0; + padding: 12px 16px 14px; + } + + body.author:not(.authorlist) .author-page__header { + margin-bottom: 14px; + padding: 8px 0 12px; + } + + body.author:not(.authorlist) .author-page__title { + margin-bottom: 10px; + } + + body.author:not(.authorlist) .author-page__profile { + grid-template-columns: 84px minmax(0, 1fr); + gap: 0 14px; + } + + body.author:not(.authorlist) .author-bio__copy, + body.author:not(.authorlist) .author-bio__meta { + max-width: none; + } + + body.author:not(.authorlist) .author-library-section .row.display-flex { + margin-left: -6px; + margin-right: -6px; + } + + body.author:not(.authorlist) .author-library-section .book { + padding-left: 6px; + padding-right: 6px; + margin-bottom: 14px; + } + + body.author:not(.authorlist) .author-page__section { + margin-bottom: 14px; + } +} diff --git a/cps/static/css/requests_workspace.css b/cps/static/css/requests_workspace.css new file mode 100644 index 000000000..4a75a7753 --- /dev/null +++ b/cps/static/css/requests_workspace.css @@ -0,0 +1,164 @@ +.requests-workspace-page { + margin-bottom: 30px; +} + +.requests-workspace-shell { + margin-top: 4px; +} + +.requests-workspace-header { + margin: 0 0 18px; +} + +.requests-workspace-header__eyebrow { + margin: 0 0 4px; + color: #8fb0cb; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.requests-workspace-header__title { + margin: 0; + color: var(--shelfmark-shell-text); + font-size: 32px; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.requests-workspace-header__subtitle { + margin: 6px 0 0; + max-width: 72ch; + color: var(--shelfmark-shell-muted); + font-size: 14px; + line-height: 1.5; +} + +.requests-workspace-message { + margin: 0 0 16px; +} + +.requests-workspace-sections { + display: flex; + flex-direction: column; + gap: 22px; +} + +.requests-workspace-section { + margin: 0; +} + +.requests-workspace-section__header { + margin-bottom: 10px; +} + +.requests-workspace-section__more { + align-self: center; + white-space: nowrap; +} + +.requests-workspace-section__placeholder, +.requests-workspace-empty-state { + margin-bottom: 0; +} + +.requests-workspace-section__placeholder .shelfmark-empty-state__message, +.requests-workspace-empty-state .shelfmark-empty-state__message { + margin-bottom: 0; +} + +.requests-workspace-section__placeholder.is-error { + border-color: rgba(215, 120, 100, 0.24); +} + +.requests-workspace-section__spinner { + animation: requests-workspace-spin 900ms linear infinite; +} + +.requests-workspace-groups { + display: flex; + flex-direction: column; + gap: 16px; +} + +.requests-workspace-group { + padding-top: 14px; + border-top: 1px solid var(--shelfmark-shell-soft-border); +} + +.requests-workspace-groups > .requests-workspace-group:first-child { + padding-top: 0; + border-top: 0; +} + +.requests-workspace-group__header { + margin: 0 0 8px; +} + +.requests-workspace-group__title { + margin: 0; + color: var(--shelfmark-shell-text); + font-size: 16px; + font-weight: 600; + line-height: 1.35; +} + +.requests-workspace-group__hint { + margin: 4px 0 0; + color: var(--shelfmark-shell-muted); + font-size: 12px; + line-height: 1.45; +} + +.requests-workspace-results { + margin: 0; +} + +.shelfmark-result-card__recommendation { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 6px 0 0; + color: #d3deea; + font-size: 12px; + line-height: 1.45; +} + +.shelfmark-result-card__recommendation-label { + color: #edf4f9; + font-weight: 600; +} + +.shelfmark-result-card__recommendation-detail { + color: #a8bdcf; +} + +@keyframes requests-workspace-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@media (max-width: 767px) { + .requests-workspace-header { + margin-bottom: 14px; + } + + .requests-workspace-header__title { + font-size: 26px; + } + + .requests-workspace-header__subtitle { + font-size: 13px; + } + + .requests-workspace-sections { + gap: 18px; + } +} diff --git a/cps/static/css/shelfmark_search.css b/cps/static/css/shelfmark_search.css new file mode 100644 index 000000000..532b9dbf6 --- /dev/null +++ b/cps/static/css/shelfmark_search.css @@ -0,0 +1,1921 @@ +.shelfmark-search-page { + margin-bottom: 30px; + --shelfmark-shell-text: rgba(242, 247, 251, 0.96); + --shelfmark-shell-muted: rgba(215, 226, 234, 0.76); + --shelfmark-shell-border: rgba(236, 244, 248, 0.16); + --shelfmark-shell-soft-border: rgba(236, 244, 248, 0.1); + --shelfmark-shell-panel: rgba(14, 23, 31, 0.3); + --shelfmark-shell-panel-strong: rgba(10, 17, 24, 0.44); + --shelfmark-shell-panel-highlight: rgba(255, 255, 255, 0.05); +} + +.shelfmark-search-divider { + margin: 26px 0 18px; + border-top-color: #d7e0e8; +} + +.shelfmark-external-shell, +.shelfmark-detail-page__shell { + max-width: 1080px; + margin: 0 auto; +} + +.shelfmark-external-shell--contextual { + margin-top: 10px; +} + +.shelfmark-section-line { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 14px; + padding: 0 0 8px; + margin-bottom: 14px; + border-bottom: 1px solid rgba(226, 236, 242, 0.14); +} + +.shelfmark-section-line--local { + margin-top: 4px; +} + +.shelfmark-section-line--external { + margin-bottom: 10px; +} + +.shelfmark-section-line--contextual { + align-items: center; +} + +.shelfmark-section-line__lead { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 12px; + min-width: 0; +} + +.shelfmark-section-line__title { + margin: 0; + font-size: 19px; + font-weight: 600; + line-height: 1.25; + color: var(--shelfmark-shell-text); +} + +.shelfmark-section-line__title .glyphicon { + margin-right: 6px; + color: rgba(209, 224, 236, 0.78); +} + +.shelfmark-section-line__subtitle { + margin: 0; + max-width: 64ch; + color: var(--shelfmark-shell-muted); + font-size: 13px; + line-height: 1.45; +} + +.shelfmark-section-line__meta { + flex: 0 0 auto; + padding-bottom: 2px; + color: rgba(223, 233, 240, 0.78); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.shelfmark-external-header { + margin: 0 0 16px; + padding-bottom: 0; + border-bottom: 0; +} + +.shelfmark-status-banner { + margin: 0 0 14px; + padding: 10px 14px; + border: 1px solid #d5dfe7; + border-radius: 10px; + background: #f7fafc; + color: #4a6174; + font-size: 13px; + line-height: 1.45; + box-shadow: none; + transition: opacity 160ms ease, transform 160ms ease, margin 160ms ease, padding 160ms ease; +} + +.shelfmark-status-banner.alert { + position: static !important; + top: auto !important; + right: auto !important; + bottom: auto !important; + left: auto !important; + width: auto !important; + max-width: none !important; + margin: 0 0 14px !important; + transform: none !important; + z-index: auto !important; +} + +.shelfmark-status-banner.is-settled { + padding: 7px 11px; + margin-bottom: 10px; + border-color: #dde5ec; + background: #fbfcfd; + color: #607487; + font-size: 12px; +} + +.shelfmark-status-banner.is-hidden { + display: none; +} + +.shelfmark-empty-state { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; + padding: 13px 15px; + border: 1px solid rgba(226, 236, 242, 0.11); + border-radius: 12px; + background: rgba(10, 16, 23, 0.28); + box-shadow: none; +} + +.shelfmark-empty-state__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + flex: 0 0 34px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.08); + color: rgba(225, 235, 242, 0.9); + font-size: 15px; +} + +.shelfmark-empty-state__body { + min-width: 0; +} + +.shelfmark-empty-state__title { + margin: 1px 0 4px; + color: var(--shelfmark-shell-text); + font-size: 16px; + font-weight: 600; +} + +.shelfmark-empty-state__message { + margin: 0 0 8px; + color: var(--shelfmark-shell-muted); + font-size: 13px; + line-height: 1.5; +} + +.shelfmark-empty-state__query { + margin: 0; + color: #4b6074; + font-size: 12px; +} + +.shelfmark-empty-state__query code { + margin-left: 4px; + padding: 3px 8px; + border-radius: 8px; + border: 1px solid rgba(226, 236, 242, 0.16); + background: rgba(255, 255, 255, 0.06); + color: var(--shelfmark-shell-text); + font-size: 12px; +} + +.shelfmark-external-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px 16px; + margin-top: 10px; + padding: 12px 14px; + border: 1px solid var(--shelfmark-shell-soft-border); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); +} + +.shelfmark-external-toolbar__actions { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} + +.shelfmark-external-controls { + display: flex; + flex: 1 1 640px; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + min-width: 0; +} + +.shelfmark-external-controls__group { + display: inline-flex; + align-items: center; + flex-wrap: nowrap; + gap: 7px; + margin-right: 0; + min-width: 0; +} + +.shelfmark-external-controls__checkbox-group { + display: inline-flex; + align-items: center; + margin: 0; +} + +.shelfmark-external-controls__submit-group { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: 4px; +} + +.shelfmark-external-controls__label { + margin: 0; + color: var(--shelfmark-shell-muted); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.shelfmark-external-controls__select { + min-width: 88px; + height: 32px; + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.06); + color: var(--shelfmark-shell-text); + font-size: 12px; + font-weight: 600; +} + +#shelfmark_sort.shelfmark-external-controls__select { + min-width: 146px; +} + +.shelfmark-external-controls__select option { + color: #1e2c38; +} + +.shelfmark-external-controls__checkbox-label { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 20px; + padding: 0; + margin: 0; + color: var(--shelfmark-shell-muted); + font-size: 12px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +.shelfmark-external-controls__checkbox { + margin: 0; + accent-color: rgba(93, 170, 222, 0.92); +} + +.shelfmark-external-controls__apply, +.shelfmark-external-controls__reset, +.shelfmark-external-toolbar__bulk-toggle, +.shelfmark-external-toolbar__open-search, +.shelfmark-batch-toolbar__request, +.shelfmark-pagination-footer__page-link { + padding: 6px 12px; + min-height: 32px; + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.06); + color: var(--shelfmark-shell-text); + font-size: 12px; + font-weight: 600; +} + +.shelfmark-external-controls__apply { + min-width: 64px; + background: rgba(84, 153, 204, 0.22); + border-color: rgba(105, 176, 229, 0.34); +} + +.shelfmark-external-controls__apply:hover, +.shelfmark-external-controls__apply:focus, +.shelfmark-external-controls__reset:hover, +.shelfmark-external-controls__reset:focus, +.shelfmark-external-toolbar__bulk-toggle:hover, +.shelfmark-external-toolbar__bulk-toggle:focus, +.shelfmark-external-toolbar__open-search:hover, +.shelfmark-external-toolbar__open-search:focus, +.shelfmark-batch-toolbar__request:hover, +.shelfmark-batch-toolbar__request:focus, +.shelfmark-pagination-footer__page-link:hover, +.shelfmark-pagination-footer__page-link:focus { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.shelfmark-external-controls__reset { + min-width: 64px; +} + +.shelfmark-external-toolbar__bulk-toggle { + background: rgba(255, 255, 255, 0.08); +} + +.shelfmark-external-toolbar__open-search { + background: transparent; +} + +.shelfmark-external-toolbar__open-search .glyphicon { + margin-right: 6px; +} + +.shelfmark-empty-state__actions { + margin: 10px 0 0; +} + +.shelfmark-contextual-page .shelfmark-result-card__description { + display: none; +} + +.shelfmark-contextual-async.is-hidden { + display: none; +} + +.js-shelfmark-contextual-shell.is-hidden { + display: none; +} + +.author-bio { + display: flex; + align-items: flex-start; + gap: 18px; + margin: 8px 0 14px; + padding: 0 0 12px; + border-bottom: 1px solid rgba(236, 244, 248, 0.12); + color: rgba(232, 239, 245, 0.88); +} + +.author-bio__media { + flex: 0 0 auto; +} + +.author-bio .author-photo { + position: static !important; + left: auto !important; + top: auto !important; + width: 110px; + max-width: min(24vw, 110px); + height: auto; + display: block; + float: none !important; + margin: 0 !important; + border-radius: 12px; + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.22); + object-fit: cover; +} + +.author-bio__content { + min-width: 0; + flex: 1 1 auto; + max-width: 62ch; +} + +.author-bio__copy { + position: relative; + max-width: 58ch; + color: rgba(236, 243, 248, 0.9); + font-size: 14px; + line-height: 1.58; + overflow: hidden; +} + +.author-bio__copy.is-collapsed { + max-height: calc(1.58em * 5); +} + +.author-bio__copy.is-collapsed::after { + content: ""; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2.5em; + background: linear-gradient(to bottom, rgba(19, 28, 36, 0), rgba(19, 28, 36, 0.96)); + pointer-events: none; +} + +.author-bio__copy.is-expanded { + max-height: none; +} + +.author-bio__copy p, +.author-bio__copy ul, +.author-bio__copy ol { + margin: 0 0 0.58em; +} + +.author-bio__copy > :last-child { + margin-bottom: 0; +} + +.author-bio__toggle { + padding: 0; + margin: 7px 0 0; + border: 0; + background: transparent; + color: rgba(180, 214, 240, 0.92); + font-size: 12px; + font-weight: 600; + line-height: 1.3; +} + +.author-bio__toggle:hover, +.author-bio__toggle:focus { + color: rgba(214, 234, 249, 0.98); + text-decoration: underline; +} + +.author-bio__toggle[hidden] { + display: none !important; +} + +.author-bio__meta { + margin: 6px 0 0; + color: rgba(215, 226, 234, 0.65); + font-size: 12px; + line-height: 1.45; +} + +.author-bio__meta .author-link { + color: rgba(215, 226, 234, 0.82); +} + +.author-bio__meta .author-link:hover, +.author-bio__meta .author-link:focus { + color: rgba(242, 247, 251, 0.96); +} + +.author-bio + .discover.load-more { + margin-top: 0; +} + +.shelfmark-contextual-async__placeholder { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 0 4px; + color: var(--shelfmark-shell-muted); + font-size: 13px; + line-height: 1.45; +} + +.shelfmark-contextual-async__spinner { + animation: shelfmark-contextual-spin 900ms linear infinite; +} + +.shelfmark-contextual-load-more { + margin-top: 12px; +} + +.shelfmark-contextual-load-more__button { + min-height: 32px; + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.06); + color: var(--shelfmark-shell-text); + font-size: 12px; + font-weight: 600; +} + +.shelfmark-contextual-load-more__button[disabled] { + opacity: 0.72; + cursor: wait; +} + +.shelfmark-contextual-load-more__message { + margin: 8px 0 0; + color: var(--shelfmark-shell-muted); + font-size: 12px; + line-height: 1.45; +} + +.shelfmark-contextual-page__message { + margin: 0 0 12px; + color: var(--shelfmark-shell-muted); + font-size: 13px; + line-height: 1.45; +} + +@keyframes shelfmark-contextual-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@media (max-width: 767px) { + .author-bio { + gap: 12px; + margin-bottom: 12px; + padding-bottom: 10px; + } + + .author-bio .author-photo { + width: 84px; + max-width: 84px; + } + + .author-bio__content, + .author-bio__copy { + max-width: none; + } +} + +.shelfmark-result-card { + padding: 16px 18px; + border: 1px solid #d7e0e8; + border-left-width: 4px; + border-radius: 12px; + background: #fff; + box-shadow: 0 1px 4px rgba(27, 40, 52, 0.04); +} + +.shelfmark-result-card--success { + border-left-color: #4d9d67; + background: linear-gradient(180deg, #ffffff 0%, #f4fbf7 100%); +} + +.shelfmark-result-card--info { + border-left-color: #4c8ec7; + background: linear-gradient(180deg, #ffffff 0%, #f4f8fc 100%); +} + +.shelfmark-result-card--warning { + border-left-color: #cf8b1b; + background: linear-gradient(180deg, #ffffff 0%, #fff8ed 100%); +} + +.shelfmark-result-card__media { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.shelfmark-result-card__select { + display: inline-flex; + align-items: flex-start; + justify-content: center; + flex: 0 0 auto; + padding-top: 3px; +} + +.shelfmark-batch-select { + margin: 0; +} + +.shelfmark-batch-select input { + margin: 0; + accent-color: rgba(93, 170, 222, 0.92); +} + +.shelfmark-search-page:not(.shelfmark-search-page--bulk-mode) .shelfmark-result-card__select, +.shelfmark-search-page:not(.shelfmark-search-page--bulk-mode) .shelfmark-batch-toolbar { + display: none !important; +} + +.shelfmark-result-card__cover { + flex: 0 0 108px; + width: 108px; +} + +.shelfmark-result-card__body { + min-width: 0; + flex: 1 1 auto; +} + +.shelfmark-result-card__cover-image { + display: block; + width: 100%; + width: 84px; + max-width: 84px; + border: 1px solid #d3dde7; + border-radius: 10px; + box-shadow: 0 3px 8px rgba(27, 40, 52, 0.07); +} + +.shelfmark-result-card__cover-fallback { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + width: 84px; + height: 112px; + padding: 12px 10px; + border: 1px solid #d7e1ec; + border-radius: 10px; + background: linear-gradient(180deg, #f7fafc 0%, #edf3f7 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85); + color: #5b6f82; +} + +.shelfmark-result-card__cover-fallback-icon, +.shelfmark-detail-hero__cover-fallback-icon { + font-size: 30px; +} + +.shelfmark-result-card__cover-fallback-label, +.shelfmark-detail-hero__cover-fallback-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.shelfmark-result-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.shelfmark-result-card__title { + margin: 0 0 4px; + font-size: 19px; + font-weight: 700; + line-height: 1.3; + letter-spacing: -0.01em; + color: #203649; +} + +.shelfmark-result-card__title a { + color: #1f4267; +} + +.shelfmark-result-card__title a:hover, +.shelfmark-result-card__title a:focus { + color: #163959; + text-decoration: none; +} + +.shelfmark-result-card__subtitle { + margin: 0; + color: #5c7082; + font-size: 13px; + line-height: 1.45; +} + +.shelfmark-result-card__badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + min-width: 170px; +} + +.shelfmark-result-card__badges .label, +.shelfmark-detail-hero__badges .label { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.shelfmark-result-card__badges .label .glyphicon { + margin-right: 4px; +} + +.shelfmark-result-card__badges .label-success, +.shelfmark-detail-hero__badges .label-success { + background: #e5f5eb; + color: #216a3d; +} + +.shelfmark-result-card__badges .label-warning, +.shelfmark-detail-hero__badges .label-warning { + background: #fff1d8; + color: #895410; +} + +.shelfmark-result-card__badges .label-info, +.shelfmark-detail-hero__badges .label-info { + background: #e3eff9; + color: #1f5b89; +} + +.shelfmark-result-card__badges .label-primary, +.shelfmark-detail-hero__badges .label-primary { + background: #dceef7; + color: #0f5677; +} + +.shelfmark-result-card__badges .shelfmark-triage-chip--strong, +.shelfmark-detail-hero__badges .shelfmark-triage-chip--strong { + background: rgba(139, 205, 164, 0.16); + border: 1px solid rgba(139, 205, 164, 0.28); + color: rgba(224, 247, 231, 0.96); +} + +.shelfmark-result-card__badges .shelfmark-quality-chip--high, +.shelfmark-detail-hero__badges .shelfmark-quality-chip--high { + background: rgba(123, 175, 221, 0.18); + border: 1px solid rgba(123, 175, 221, 0.28); + color: rgba(228, 241, 252, 0.98); +} + +.shelfmark-status-chip { + border: 1px solid transparent; +} + +.shelfmark-result-card__badges .shelfmark-status-chip--available, +.shelfmark-detail-status .shelfmark-status-chip--available { + background: rgba(93, 170, 222, 0.18); + border-color: rgba(93, 170, 222, 0.26); + color: rgba(223, 240, 251, 0.98); +} + +.shelfmark-result-card__badges .shelfmark-status-chip--requested, +.shelfmark-detail-status .shelfmark-status-chip--requested { + background: rgba(137, 176, 230, 0.18); + border-color: rgba(137, 176, 230, 0.26); + color: rgba(230, 239, 250, 0.98); +} + +.shelfmark-result-card__badges .shelfmark-status-chip--queue, +.shelfmark-detail-status .shelfmark-status-chip--queue { + background: rgba(227, 179, 90, 0.18); + border-color: rgba(227, 179, 90, 0.28); + color: rgba(252, 234, 197, 0.98); +} + +.shelfmark-result-card__badges .shelfmark-status-chip--downloaded, +.shelfmark-detail-status .shelfmark-status-chip--downloaded { + background: rgba(93, 195, 131, 0.16); + border-color: rgba(93, 195, 131, 0.24); + color: rgba(226, 247, 234, 0.98); +} + +.shelfmark-result-card__badges .shelfmark-status-chip--imported, +.shelfmark-detail-status .shelfmark-status-chip--imported { + background: rgba(93, 195, 131, 0.22); + border-color: rgba(93, 195, 131, 0.3); + color: rgba(232, 250, 238, 0.98); +} + +.shelfmark-result-card__badges .shelfmark-status-chip--failed, +.shelfmark-detail-status .shelfmark-status-chip--failed { + background: rgba(222, 111, 111, 0.18); + border-color: rgba(222, 111, 111, 0.28); + color: rgba(252, 224, 224, 0.98); +} + +.shelfmark-status-chip.is-hidden, +.shelfmark-detail-status__timestamp.is-hidden, +.shelfmark-detail-status__message.is-hidden { + display: none; +} + +.shelfmark-result-card__badges .label-default, +.shelfmark-detail-hero__badges .label-default { + background: #edf3f7; + color: #42586d; +} + +.shelfmark-result-card__authors, +.shelfmark-result-card__description, +.shelfmark-result-card__action-hint { + margin-bottom: 9px; +} + +.shelfmark-result-card__authors { + color: #33495d; + font-size: 14px; + line-height: 1.5; +} + +.shelfmark-result-card__authors strong { + color: #455b70; + font-weight: 600; +} + +.shelfmark-result-card__description { + max-width: 72ch; + color: #5d7183; + font-size: 13px; + line-height: 1.55; +} + +.shelfmark-result-card__actions { + display: flex; + flex-wrap: wrap; + gap: 9px; + align-items: center; + margin-top: 12px; + padding-top: 11px; + border-top: 1px solid rgba(69, 88, 109, 0.1); +} + +.shelfmark-result-card__actions .btn .glyphicon, +.shelfmark-detail-hero__actions .btn .glyphicon { + margin-right: 6px; +} + +.shelfmark-result-card__primary-action, +.shelfmark-detail-hero__primary-action { + min-width: 210px; + font-weight: 600; + box-shadow: 0 3px 8px rgba(27, 40, 52, 0.08); +} + +.shelfmark-result-card__primary-action.btn-default, +.shelfmark-detail-hero__primary-action.btn-default { + border-color: #b8cad9; + background: #eef5fb; + color: #244764; +} + +.shelfmark-result-card__primary-action.btn-default:hover, +.shelfmark-result-card__primary-action.btn-default:focus, +.shelfmark-detail-hero__primary-action.btn-default:hover, +.shelfmark-detail-hero__primary-action.btn-default:focus { + border-color: #a8bfd2; + background: #e3eef8; + color: #1b4060; +} + +.shelfmark-result-card__secondary-action { + color: #375168; + border-color: #cbd7e3; + background: #fff; +} + +.shelfmark-result-card__secondary-action:hover, +.shelfmark-result-card__secondary-action:focus { + color: #24435f; + border-color: #b8c8d8; + background: #f7fafc; +} + +.shelfmark-result-card__action-hint, +.shelfmark-detail-hero__action-hint { + max-width: 72ch; + color: #65798b; + font-size: 12px; + line-height: 1.5; +} + +.shelfmark-external-shell .shelfmark-section-line__title, +.shelfmark-external-shell .shelfmark-section-line__subtitle, +.shelfmark-external-shell .shelfmark-section-line__meta { + color: var(--shelfmark-shell-muted); +} + +.shelfmark-external-shell .shelfmark-section-line__title { + color: var(--shelfmark-shell-text); +} + +.shelfmark-results-list { + margin: 0; +} + +.shelfmark-results-list--compact > .shelfmark-result-card { + padding-top: 12px; + padding-bottom: 12px; +} + +.shelfmark-results-list > .shelfmark-result-card { + margin: 0; + padding: 15px 0 15px 12px; + border: 0; + border-left: 2px solid transparent; + border-radius: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; +} + +.shelfmark-results-list > .shelfmark-result-card + .shelfmark-result-card { + border-top: 1px solid var(--shelfmark-shell-soft-border); +} + +.shelfmark-results-list > .shelfmark-result-card.is-batch-selected { + background: rgba(255, 255, 255, 0.04); +} + +.shelfmark-results-list > .shelfmark-result-card.is-shelfmark-handled { + opacity: 0.72; +} + +.shelfmark-results-list > .shelfmark-result-card.is-shelfmark-handled:hover { + opacity: 0.84; +} + +.shelfmark-results-list > .shelfmark-result-card.shelfmark-result-card--refining { + opacity: 0.82; +} + +.shelfmark-results-list > .shelfmark-result-card.is-shelfmark-filter-hidden { + display: none; +} + +.shelfmark-results-list > .shelfmark-result-card.is-shelfmark-page-hidden { + display: none; +} + +.shelfmark-result-card--success { + border-left-color: rgba(93, 195, 131, 0.92); +} + +.shelfmark-result-card--info { + border-left-color: rgba(93, 170, 222, 0.92); +} + +.shelfmark-result-card--warning { + border-left-color: rgba(227, 179, 90, 0.94); +} + +.shelfmark-result-card__cover-image { + width: 100%; + max-width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + border-color: rgba(255, 255, 255, 0.14); + border-radius: 8px; + box-shadow: 0 6px 14px rgba(8, 14, 19, 0.18); + background: rgba(255, 255, 255, 0.04); +} + +.shelfmark-result-card__cover-fallback { + width: 100%; + height: 156px; + border-color: rgba(255, 255, 255, 0.12); + background: rgba(17, 27, 35, 0.42); + box-shadow: none; + color: rgba(224, 235, 243, 0.8); +} + +.shelfmark-result-card__title { + color: var(--shelfmark-shell-text); + font-size: 17px; + font-weight: 600; +} + +.shelfmark-result-card__title a, +.shelfmark-result-card__title a:hover, +.shelfmark-result-card__title a:focus { + color: var(--shelfmark-shell-text); +} + +.shelfmark-result-card__subtitle, +.shelfmark-result-card__authors, +.shelfmark-result-card__facts, +.shelfmark-result-card__series-note, +.shelfmark-result-card__description, +.shelfmark-result-card__action-hint { + color: var(--shelfmark-shell-muted); +} + +.shelfmark-result-card__subtitle { + font-size: 12px; +} + +.shelfmark-result-card__authors { + color: var(--shelfmark-shell-text); + font-size: 15px; + font-weight: 600; + line-height: 1.45; +} + +.shelfmark-result-card__facts, +.shelfmark-detail-hero__facts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + margin: 0 0 10px; + color: var(--shelfmark-shell-muted); + font-size: 12px; + line-height: 1.55; +} + +.shelfmark-result-card__fact, +.shelfmark-detail-hero__fact { + display: inline-flex; + align-items: center; +} + +.shelfmark-result-card__fact + .shelfmark-result-card__fact::before, +.shelfmark-detail-hero__fact + .shelfmark-detail-hero__fact::before { + content: "\00b7"; + margin: 0 8px; + color: rgba(215, 226, 234, 0.46); +} + +.shelfmark-result-card__series-note { + margin: -4px 0 10px; + font-size: 12px; + line-height: 1.5; +} + +.shelfmark-result-card__badges .label-default, +.shelfmark-detail-hero__badges .label-default { + background: rgba(255, 255, 255, 0.08); + color: var(--shelfmark-shell-text); +} + +.shelfmark-result-card__actions { + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top-color: var(--shelfmark-shell-soft-border); +} + +.shelfmark-result-card__primary-action, +.shelfmark-detail-hero__primary-action { + min-width: 0; + padding: 6px 11px; + font-size: 11px; + font-weight: 600; + box-shadow: none; +} + +.shelfmark-result-card__primary-action.btn-default, +.shelfmark-detail-hero__primary-action.btn-default, +.shelfmark-result-card__secondary-action { + padding: 6px 9px; + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.06); + color: var(--shelfmark-shell-text); + font-size: 11px; + font-weight: 600; +} + +.shelfmark-result-card__secondary-action { + padding: 4px 2px; + border: 0; + background: transparent; + color: rgba(214, 227, 237, 0.86); + font-size: 11px; + font-weight: 500; + text-decoration: none; +} + +.shelfmark-result-card__action-hint.is-hidden, +.shelfmark-detail-hero__action-hint.is-hidden { + display: none; +} + +.shelfmark-result-card__primary-action.btn-default:hover, +.shelfmark-result-card__primary-action.btn-default:focus, +.shelfmark-detail-hero__primary-action.btn-default:hover, +.shelfmark-detail-hero__primary-action.btn-default:focus, +.shelfmark-result-card__secondary-action:hover, +.shelfmark-result-card__secondary-action:focus { + border-color: transparent; + background: transparent; + color: #fff; +} + +.shelfmark-result-card__primary-action.is-shelfmark-action--waiting, +.shelfmark-detail-hero__primary-action.is-shelfmark-action--waiting { + border-color: rgba(227, 179, 90, 0.28); + background: rgba(227, 179, 90, 0.12); + color: rgba(252, 234, 197, 0.98); +} + +.shelfmark-result-card__workflow-state { + display: none !important; +} + +.shelfmark-results-list--compact .shelfmark-result-card__media { + gap: 12px; +} + +.shelfmark-results-list--compact .shelfmark-result-card__cover { + flex: 0 0 72px; + width: 72px; +} + +.shelfmark-results-list--compact .shelfmark-result-card__cover-fallback { + height: 108px; + padding: 10px 8px; +} + +.shelfmark-results-list--compact .shelfmark-result-card__title { + font-size: 15px; +} + +.shelfmark-results-list--compact .shelfmark-result-card__subtitle, +.shelfmark-results-list--compact .shelfmark-result-card__authors, +.shelfmark-results-list--compact .shelfmark-result-card__facts, +.shelfmark-results-list--compact .shelfmark-result-card__action-hint { + font-size: 11px; +} + +.shelfmark-results-list--compact .shelfmark-result-card__authors { + margin-bottom: 6px; +} + +.shelfmark-results-list--compact .shelfmark-result-card__facts { + margin-bottom: 8px; +} + +.shelfmark-results-list--compact .shelfmark-result-card__description { + display: none; +} + +.shelfmark-results-list--compact .shelfmark-result-card__actions { + gap: 5px; + margin-top: 6px; + padding-top: 6px; +} + +.shelfmark-results-list--compact .shelfmark-result-card__primary-action, +.shelfmark-results-list--compact .shelfmark-result-card__secondary-action { + min-height: 28px; + font-size: 10px; +} + +.shelfmark-results-list--compact .shelfmark-result-card__secondary-action { + padding-top: 3px; + padding-bottom: 3px; +} + +.shelfmark-status-banner { + display: none; + margin: 14px 0 0; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--shelfmark-shell-soft-border); + background: var(--shelfmark-shell-panel-strong); + color: var(--shelfmark-shell-muted); + box-shadow: none; +} + +.shelfmark-status-banner.is-visible, +.shelfmark-status-banner:not(.is-hidden) { + display: block; +} + +.shelfmark-status-banner.alert-success { + border-color: rgba(93, 195, 131, 0.24); + background: rgba(39, 79, 54, 0.38); + color: rgba(228, 247, 236, 0.96); +} + +.shelfmark-status-banner.alert-warning { + border-color: rgba(227, 179, 90, 0.28); + background: rgba(88, 63, 20, 0.38); + color: rgba(252, 234, 197, 0.96); +} + +.shelfmark-status-banner.alert-danger { + border-color: rgba(222, 111, 111, 0.26); + background: rgba(88, 31, 31, 0.42); + color: rgba(252, 224, 224, 0.96); +} + +.shelfmark-batch-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 14px; + margin: 8px 0 14px; + padding: 10px 12px; + border: 1px solid var(--shelfmark-shell-soft-border); + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); +} + +.shelfmark-batch-toolbar.is-hidden, +.shelfmark-batch-select.is-hidden, +.shelfmark-batch-toolbar__message.is-hidden { + display: none; +} + +.shelfmark-batch-toolbar__summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + min-width: 0; + color: var(--shelfmark-shell-muted); + font-size: 12px; + line-height: 1.45; +} + +.shelfmark-batch-toolbar__count { + color: var(--shelfmark-shell-text); + font-weight: 600; +} + +.shelfmark-batch-toolbar__meta { + color: var(--shelfmark-shell-muted); +} + +.shelfmark-batch-toolbar__message { + color: var(--shelfmark-shell-text); +} + +.shelfmark-batch-toolbar__message--success { + color: rgba(220, 247, 230, 0.96); +} + +.shelfmark-batch-toolbar__message--warning { + color: rgba(252, 234, 197, 0.96); +} + +.shelfmark-batch-toolbar__message--danger { + color: rgba(252, 224, 224, 0.96); +} + +.shelfmark-batch-toolbar__actions { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.shelfmark-batch-toolbar__link { + padding: 0; + color: rgba(232, 239, 244, 0.82); +} + +.shelfmark-batch-toolbar__link:hover, +.shelfmark-batch-toolbar__link:focus { + color: #fff; +} + +.shelfmark-batch-toolbar__request[disabled], +.shelfmark-batch-toolbar__link[disabled] { + opacity: 0.55; + pointer-events: none; +} + +.shelfmark-pagination-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--shelfmark-shell-soft-border); +} + +.shelfmark-pagination-footer__summary { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + color: var(--shelfmark-shell-muted); + font-size: 12px; + line-height: 1.5; +} + +.shelfmark-pagination-footer__controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; + align-items: center; +} + +.shelfmark-pagination-footer__page-size-form { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; +} + +.shelfmark-pagination-footer__page-size-label { + margin: 0; + color: var(--shelfmark-shell-muted); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.shelfmark-pagination-footer__page-size-select { + width: 78px; + min-width: 78px; + height: 32px; + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.06); + color: var(--shelfmark-shell-text); + font-size: 12px; + font-weight: 600; +} + +.shelfmark-pagination-footer__page-size-select option { + color: #1e2c38; +} + +.shelfmark-pagination-footer__page-link .glyphicon { + margin-right: 6px; +} + +.shelfmark-pagination-footer__page-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.shelfmark-pagination-footer__page-link.disabled, +.shelfmark-pagination-footer__page-link[aria-disabled="true"] { + opacity: 0.55; + pointer-events: none; +} + +.shelfmark-detail-page__title { + margin: 0; + color: var(--shelfmark-shell-text); + font-size: 30px; + line-height: 1.2; +} + +.shelfmark-detail-page__titletext { + display: inline; +} + +.shelfmark-detail-page__authorinline { + color: var(--shelfmark-shell-muted); + font-size: 0.72em; + font-weight: 500; +} + +.shelfmark-detail-page__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin: 0 0 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--shelfmark-shell-soft-border); +} + +.shelfmark-detail-page__header-main { + min-width: 0; +} + +.shelfmark-detail-page__subtitle { + margin: 6px 0 0; + color: var(--shelfmark-shell-muted); + font-size: 15px; + line-height: 1.45; +} + +.shelfmark-detail-page__header-actions { + flex: 0 0 auto; +} + +.shelfmark-detail-page__back-action { + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.06); + color: var(--shelfmark-shell-text); +} + +.shelfmark-detail-pane__subtitle { + margin: 4px 0 0; + color: var(--shelfmark-shell-muted); + font-size: 13px; + line-height: 1.45; +} + +.shelfmark-detail-pane__titleblock { + margin: 0 0 12px; +} + +.shelfmark-detail-pane__titleline { + margin: 0; + color: var(--shelfmark-shell-text); + font-size: 26px; + line-height: 1.22; + font-weight: 600; + letter-spacing: -0.01em; +} + +.shelfmark-detail-pane__titletext { + display: inline; +} + +.shelfmark-detail-pane__authorinline { + color: var(--shelfmark-shell-muted); + font-size: 0.72em; + font-weight: 500; +} + +.shelfmark-detail-layout { + display: grid; + grid-template-columns: minmax(200px, 248px) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.shelfmark-detail-sidebar, +.shelfmark-detail-main { + min-width: 0; +} + +.shelfmark-detail-cover-card { + position: sticky; + top: 12px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + margin-bottom: 0; +} + +.shelfmark-detail-hero__body { + padding: 0; +} + +.shelfmark-detail-hero { + padding: 0 0 14px; + border-top: 0; + border-bottom: 1px solid var(--shelfmark-shell-soft-border); +} + +.shelfmark-detail-hero--success { + border-left: 0; +} + +.shelfmark-detail-hero--info { + border-left: 0; +} + +.shelfmark-detail-hero--warning { + border-left: 0; +} + +.shelfmark-detail-hero__cover-image { + width: 100%; + max-width: 100%; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 8px 20px rgba(8, 14, 19, 0.22); +} + +.shelfmark-detail-hero__cover-fallback { + display: flex; + min-height: 280px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + padding: 24px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + background: rgba(17, 27, 35, 0.42); + box-shadow: none; + color: rgba(224, 235, 243, 0.8); +} + +.shelfmark-detail-hero__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.shelfmark-detail-hero__badges .label .glyphicon { + margin-right: 4px; +} + +.shelfmark-detail-hero__actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.shelfmark-detail-status { + display: grid; + gap: 6px; + margin: 0 0 10px; +} + +.shelfmark-detail-status.is-hidden { + display: none; +} + +.shelfmark-detail-status__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; +} + +.shelfmark-detail-status__timestamp { + color: var(--shelfmark-shell-muted); + font-size: 12px; + line-height: 1.45; +} + +.shelfmark-detail-status__message { + margin: 0; + color: rgba(229, 236, 242, 0.9); + font-size: 13px; + line-height: 1.5; +} + +.shelfmark-detail-meta-list__series-row { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 0; +} + +.shelfmark-detail-meta-list__context-list { + display: grid; + gap: 8px; +} + +.shelfmark-detail-meta-list__context-entry { + display: grid; + gap: 2px; +} + +.shelfmark-detail-meta-list__context-name { + color: var(--shelfmark-shell-text); + font-weight: 600; +} + +.shelfmark-detail-meta-list__context-detail { + color: var(--shelfmark-shell-muted); +} + +.shelfmark-detail-meta-list__separator { + margin: 0 8px; + color: rgba(215, 226, 234, 0.46); +} + +.shelfmark-detail-top-stats { + display: flex; + flex-wrap: wrap; + gap: 10px 14px; + margin: 0 0 12px; +} + +.shelfmark-detail-top-stats__item { + margin: 0; + padding: 0; + border: 0; + background: transparent; + min-width: 0; +} + +.shelfmark-detail-top-stats__label { + display: inline; + margin: 0 6px 0 0; + color: var(--shelfmark-shell-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.shelfmark-detail-top-stats__value { + display: inline; + margin: 0; + color: var(--shelfmark-shell-text); + font-size: 14px; + font-weight: 600; + line-height: 1.4; +} + +.shelfmark-detail-section { + padding: 14px 0; + border-top: 1px solid var(--shelfmark-shell-soft-border); +} + +.shelfmark-detail-section:first-of-type { + border-top: 0; + padding-top: 16px; +} + +.shelfmark-detail-section__title { + margin: 0 0 12px; + color: var(--shelfmark-shell-text); + font-size: 16px; + font-weight: 700; +} + +.shelfmark-detail-section__copy { + margin: 0; + color: var(--shelfmark-shell-muted); + font-size: 14px; + line-height: 1.65; +} + +.shelfmark-detail-section__copy--description { + white-space: normal; +} + +.shelfmark-detail-section__copy--description p { + margin: 0 0 0.95em; +} + +.shelfmark-detail-section__copy--description p:last-child { + margin-bottom: 0; +} + +.shelfmark-detail-modal__dialog { + width: min(980px, calc(100% - 24px)); + max-width: 980px; + margin: 18px auto; +} + +.shelfmark-detail-modal__content { + border-color: rgba(255, 255, 255, 0.14); + background: linear-gradient(180deg, rgba(13, 21, 29, 0.98) 0%, rgba(10, 16, 22, 0.98) 100%); + color: var(--shelfmark-shell-text); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.42); +} + +.shelfmark-detail-modal__header { + padding: 14px 18px 12px; + border-bottom-color: var(--shelfmark-shell-soft-border); + background: rgba(255, 255, 255, 0.02); +} + +.shelfmark-detail-modal__title { + color: var(--shelfmark-shell-text); + font-size: 22px; + font-weight: 600; +} + +.shelfmark-detail-modal__close { + color: rgba(245, 249, 252, 0.86); + opacity: 0.78; + text-shadow: none; +} + +.shelfmark-detail-modal__close:hover, +.shelfmark-detail-modal__close:focus { + color: #fff; + opacity: 1; +} + +.shelfmark-detail-modal__body { + max-height: calc(100vh - 132px); + overflow-y: auto; + padding: 20px 24px 26px; +} + +.shelfmark-detail-modal__loading { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--shelfmark-shell-muted); + font-size: 13px; +} + +.shelfmark-detail-modal__loading.is-hidden { + display: none; +} + +.shelfmark-detail-modal__loading::before { + content: ""; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.18); + border-top-color: rgba(255, 255, 255, 0.82); + border-radius: 50%; + animation: shelfmark-detail-spin 0.8s linear infinite; +} + +.shelfmark-detail-pane--modal .shelfmark-detail-cover-card { + position: static; +} + +.shelfmark-detail-meta-list { + display: grid; + grid-template-columns: minmax(118px, 160px) minmax(0, 1fr); + gap: 10px 16px; + margin: 0; +} + +.shelfmark-detail-meta-list dt { + margin: 0; + color: rgba(209, 220, 228, 0.72); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.shelfmark-detail-meta-list dd { + margin: 0; + color: var(--shelfmark-shell-text); + font-size: 13px; + line-height: 1.5; +} + +.shelfmark-detail-meta-list__inline-fact + .shelfmark-detail-meta-list__inline-fact::before { + content: "\00b7"; + margin: 0 8px 0 6px; + color: var(--shelfmark-shell-muted); +} + +.shelfmark-detail-meta-list__link { + color: rgba(213, 231, 246, 0.96); + text-decoration: none; + border-bottom: 1px solid rgba(213, 231, 246, 0.24); +} + +.shelfmark-detail-meta-list__link:hover, +.shelfmark-detail-meta-list__link:focus { + color: #fff; + text-decoration: none; + border-bottom-color: rgba(255, 255, 255, 0.52); +} + +.shelfmark-detail-token-list { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + vertical-align: top; +} + +.shelfmark-detail-token { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: rgba(244, 248, 251, 0.94); + font-size: 12px; + line-height: 1.45; + white-space: nowrap; +} + +.shelfmark-detail-token-list--warnings .shelfmark-detail-token { + background: rgba(173, 123, 58, 0.18); + color: rgba(255, 235, 214, 0.96); +} + +@media (max-width: 767px) { + .shelfmark-external-shell, + .shelfmark-detail-page__shell { + max-width: none; + } + + .shelfmark-batch-toolbar { + align-items: stretch; + } + + .shelfmark-batch-toolbar__actions { + justify-content: flex-start; + } + + .shelfmark-empty-state { + padding: 14px; + } + + .shelfmark-section-line { + flex-direction: column; + align-items: flex-start; + } + + .shelfmark-section-line__lead { + align-items: flex-start; + } + + .shelfmark-detail-layout { + display: block; + } + + .shelfmark-external-toolbar, + .shelfmark-detail-page__header-actions { + display: block; + } + + .shelfmark-external-controls { + display: flex; + flex-direction: column; + align-items: stretch; + } + + .shelfmark-external-controls__group { + justify-content: space-between; + } + + .shelfmark-section-line__meta { + padding-bottom: 0; + } + + .shelfmark-result-card__header { + flex-direction: column; + } + + .shelfmark-result-card__badges { + justify-content: flex-start; + min-width: 0; + } + + .shelfmark-result-card__actions, + .shelfmark-detail-hero__actions { + flex-direction: column; + } + + .shelfmark-result-card__media { + flex-direction: column; + } + + .shelfmark-result-card__cover { + width: 108px; + } + + .shelfmark-pagination-footer, + .shelfmark-pagination-footer__controls { + flex-direction: column; + align-items: stretch; + } + + .shelfmark-result-card__primary-action, + .shelfmark-detail-hero__primary-action { + min-width: 0; + } + + .shelfmark-result-card__actions .btn, + .shelfmark-detail-hero__actions .btn, + .shelfmark-pagination-footer__controls .btn { + width: 100%; + } + + .shelfmark-results-list > .shelfmark-result-card { + padding-left: 12px; + } + + .shelfmark-external-toolbar__open-search, + .shelfmark-external-controls__apply, + .shelfmark-external-controls__toggle-link, + .shelfmark-external-controls__reset, + .shelfmark-detail-page__back-action { + margin-top: 10px; + } + + .shelfmark-detail-sidebar { + margin-bottom: 18px; + } + + .shelfmark-detail-page__header { + display: block; + } + + .shelfmark-detail-cover-card { + position: static; + } + + .shelfmark-detail-modal__dialog { + width: calc(100% - 12px); + margin: 6px auto; + } + + .shelfmark-detail-modal__body { + max-height: calc(100vh - 82px); + padding: 16px 14px 20px; + } + + .shelfmark-detail-page__title, + .shelfmark-detail-pane__titleline { + font-size: 24px; + } + + .shelfmark-detail-meta-list { + grid-template-columns: 1fr; + gap: 4px; + } +} + +@keyframes shelfmark-detail-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/cps/static/js/duplicate-notifier.js b/cps/static/js/duplicate-notifier.js index 8903aef90..28d0888ab 100644 --- a/cps/static/js/duplicate-notifier.js +++ b/cps/static/js/duplicate-notifier.js @@ -8,13 +8,10 @@ const STORAGE_KEY = 'cwa_duplicates_notification_shown'; const LAST_COUNT_KEY = 'cwa_duplicates_last_count'; - const POLL_INTERVAL_MS = 2500; - const POLL_MAX_ATTEMPTS = 60; // ~2.5 minutes let currentDuplicateCount = 0; - let pollAttempts = 0; - let pollTimer = null; let lastPreviewSignature = ''; + let lastStatusWasStale = false; /** * Check if notification was already shown in this session @@ -77,30 +74,6 @@ }); } - function startStatusPolling() { - if (pollTimer) { - return; - } - if (isModalActive()) { - return; - } - pollAttempts = 0; - pollTimer = setInterval(() => { - pollAttempts += 1; - fetchDuplicateStatus().then(handleStatusResponse); - if (pollAttempts >= POLL_MAX_ATTEMPTS) { - stopStatusPolling(); - } - }, POLL_INTERVAL_MS); - } - - function stopStatusPolling() { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - } - function isModalActive() { const modal = document.getElementById('duplicate-notification-modal'); return modal && modal.classList.contains('active'); @@ -158,7 +131,6 @@ // Mark as shown and store count markNotificationShown(); setLastNotifiedCount(count); - stopStatusPolling(); }, 500); } } @@ -168,34 +140,20 @@ return; } + lastStatusWasStale = Boolean(data.needs_scan || data.stale); + if (isModalActive()) { - stopStatusPolling(); return; } updateBadge(data.count); if (data.count > 0 && data.enabled) { - stopStatusPolling(); showNotificationModal(data); if (isModalActive()) { return; } } - - if ((data.needs_scan || data.stale) && !isModalActive()) { - startStatusPolling(); - return; - } - - if (data.count > 0) { - stopStatusPolling(); - return; - } - - if (data.enabled) { - startStatusPolling(); - } } /** @@ -263,8 +221,9 @@ // Initialize event listeners initializeEventListeners(); - // Fetch initial status once on page load - // No periodic updates - badge refreshes after ingest operations only + // Use server-rendered bootstrap data on page load. Avoid immediately + // re-fetching duplicate status here because that endpoint is expensive + // and the server has already rendered the latest cached summary. const bootstrapData = window.cwaDuplicateBootstrap; if (bootstrapData && typeof bootstrapData === 'object') { handleStatusResponse({ @@ -276,13 +235,12 @@ stale: !!bootstrapData.stale, needs_scan: !!bootstrapData.stale }); + } else { + fetchDuplicateStatus().then(handleStatusResponse); } - fetchDuplicateStatus().then(handleStatusResponse); - startStatusPolling(); - document.addEventListener('visibilitychange', function() { - if (!document.hidden) { + if (!document.hidden && lastStatusWasStale) { fetchDuplicateStatus().then(handleStatusResponse); } }); diff --git a/cps/static/js/requests_workspace_async.js b/cps/static/js/requests_workspace_async.js new file mode 100644 index 000000000..efecafe26 --- /dev/null +++ b/cps/static/js/requests_workspace_async.js @@ -0,0 +1,104 @@ +(function () { + 'use strict'; + + var MAX_CONCURRENT_REQUESTS = 2; + + function getShells(root) { + return Array.prototype.slice.call((root || document).querySelectorAll('.js-requests-section-shell[data-section-url]')); + } + + function trimHtml(value) { + return typeof value === 'string' ? value.trim() : ''; + } + + function replaceNodeFromHtml(node, html) { + var template = document.createElement('template'); + template.innerHTML = trimHtml(html); + var replacement = template.content.firstElementChild; + if (!replacement) { + node.classList.add('is-hidden'); + return null; + } + node.replaceWith(replacement); + return replacement; + } + + function initShelfmarkScope(scope) { + if (window.CwaShelfmarkExternalSearch && typeof window.CwaShelfmarkExternalSearch.init === 'function') { + window.CwaShelfmarkExternalSearch.init(scope || document); + } + } + + function renderFailure(shell) { + var placeholder = shell.querySelector('.requests-workspace-section__placeholder'); + if (!placeholder) { + return; + } + placeholder.classList.add('is-error'); + placeholder.innerHTML = ( + '' + + '' + (shell.getAttribute('data-failure-message') || 'Could not load this Requests section right now.') + '' + ); + } + + function fetchSection(shell) { + if (!shell || shell.dataset.loaded === '1' || shell.dataset.pending === '1') { + return Promise.resolve(); + } + shell.dataset.pending = '1'; + return fetch(shell.getAttribute('data-section-url'), { + credentials: 'same-origin', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }).then(function (response) { + if (!response.ok) { + throw new Error('requests_section_failed'); + } + return response.text(); + }).then(function (html) { + var replacement = replaceNodeFromHtml(shell, html); + if (replacement) { + initShelfmarkScope(replacement); + } + }).catch(function () { + renderFailure(shell); + }).finally(function () { + shell.dataset.pending = '0'; + shell.dataset.loaded = '1'; + }); + } + + function drainQueue(shells) { + var queue = shells.slice(); + var active = 0; + + function schedule() { + while (active < MAX_CONCURRENT_REQUESTS && queue.length) { + active += 1; + fetchSection(queue.shift()).finally(function () { + active -= 1; + schedule(); + }); + } + } + + schedule(); + } + + function init(root) { + var shells = getShells(root); + if (!shells.length) { + return; + } + drainQueue(shells); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + init(document); + }); + } else { + init(document); + } +}()); diff --git a/cps/static/js/shelfmark_contextual_async.js b/cps/static/js/shelfmark_contextual_async.js new file mode 100644 index 000000000..fd15dbf13 --- /dev/null +++ b/cps/static/js/shelfmark_contextual_async.js @@ -0,0 +1,209 @@ +(function () { + 'use strict'; + + function toArray(value) { + return Array.prototype.slice.call(value || []); + } + + function parseHtml(html) { + var template = document.createElement('template'); + template.innerHTML = html || ''; + return template.content; + } + + function getExternalSearchRuntime() { + return window.CwaShelfmarkExternalSearch || null; + } + + function initInjectedShelfmark(root) { + var runtime = getExternalSearchRuntime(); + if (runtime && typeof runtime.init === 'function') { + runtime.init(root); + } + } + + function setLoadingState(container, isLoading) { + container.classList.toggle('is-loading', Boolean(isLoading)); + } + + function renderFailure(container, message) { + var shell = container.closest ? container.closest('.js-shelfmark-contextual-shell') : null; + container.innerHTML = ( + '

' + + (message || container.dataset.failureMessage || 'Shelfmark is unavailable right now.') + + '

' + ); + container.classList.remove('is-hidden'); + if (shell && shell.classList) { + shell.classList.remove('is-hidden'); + } + } + + function hideContainer(container) { + var shell = container.closest ? container.closest('.js-shelfmark-contextual-shell') : null; + container.innerHTML = ''; + container.classList.add('is-hidden'); + if (shell && shell.classList) { + shell.classList.add('is-hidden'); + } + } + + function fetchHtml(url) { + return fetch(url, { + credentials: 'same-origin', + headers: { + 'Accept': 'text/html', + 'X-Requested-With': 'XMLHttpRequest' + } + }).then(function (response) { + if (!response.ok) { + throw new Error('Shelfmark contextual request failed.'); + } + return response.text(); + }); + } + + function replaceSection(container, html) { + var shell = container.closest ? container.closest('.js-shelfmark-contextual-shell') : null; + if (!html || !html.trim()) { + hideContainer(container); + return; + } + container.innerHTML = html; + container.classList.remove('is-hidden'); + if (shell && shell.classList) { + shell.classList.remove('is-hidden'); + } + initInjectedShelfmark(container); + } + + function replaceLoadMoreControl(container, fragment) { + var currentWrap = container.querySelector('.js-shelfmark-contextual-load-more-wrap'); + var nextWrap = fragment.querySelector('.js-shelfmark-contextual-load-more-wrap'); + if (currentWrap && currentWrap.parentNode) { + currentWrap.parentNode.removeChild(currentWrap); + } + if (nextWrap) { + container.appendChild(nextWrap); + } + } + + function appendMoreRows(container, html) { + if (!html || !html.trim()) { + replaceLoadMoreControl(container, document.createDocumentFragment()); + return; + } + + var fragment = parseHtml(html); + var currentList = container.querySelector('.js-shelfmark-results-list'); + var responseList = fragment.querySelector('.js-shelfmark-results-list'); + var appendedNodes = []; + + if (currentList && responseList) { + toArray(responseList.children).forEach(function (node) { + appendedNodes.push(node); + currentList.appendChild(node); + }); + } + + replaceLoadMoreControl(container, fragment); + appendedNodes.forEach(function (node) { + initInjectedShelfmark(node); + }); + } + + function setLoadMoreFailure(button, message) { + var wrap = button && button.closest ? button.closest('.js-shelfmark-contextual-load-more-wrap') : null; + var messageNode = wrap ? wrap.querySelector('.js-shelfmark-contextual-load-more-message') : null; + if (!messageNode) { + return; + } + messageNode.textContent = message; + messageNode.classList.remove('is-hidden'); + } + + function clearLoadMoreFailure(button) { + var wrap = button && button.closest ? button.closest('.js-shelfmark-contextual-load-more-wrap') : null; + var messageNode = wrap ? wrap.querySelector('.js-shelfmark-contextual-load-more-message') : null; + if (!messageNode) { + return; + } + messageNode.textContent = ''; + messageNode.classList.add('is-hidden'); + } + + function bindContainer(container) { + if (!container || container.dataset.asyncBound === '1') { + return; + } + container.dataset.asyncBound = '1'; + + container.addEventListener('click', function (event) { + var button = event.target && event.target.closest + ? event.target.closest('.js-shelfmark-contextual-load-more') + : null; + if (!button) { + return; + } + + event.preventDefault(); + if (button.disabled || !button.dataset.loadUrl) { + return; + } + + clearLoadMoreFailure(button); + button.disabled = true; + button.dataset.defaultLabel = button.dataset.defaultLabel || button.textContent; + button.textContent = 'Loading…'; + + fetchHtml(button.dataset.loadUrl).then(function (html) { + appendMoreRows(container, html); + }).catch(function () { + button.disabled = false; + button.textContent = button.dataset.defaultLabel || 'Load more'; + setLoadMoreFailure( + button, + container.dataset.loadMoreFailureMessage || 'Could not load more Shelfmark results right now.' + ); + }); + }); + } + + function loadInitialSection(container) { + if (!container || !container.dataset.initialUrl) { + return Promise.resolve(); + } + + setLoadingState(container, true); + bindContainer(container); + return fetchHtml(container.dataset.initialUrl).then(function (html) { + replaceSection(container, html); + bindContainer(container); + }).catch(function () { + renderFailure( + container, + container.dataset.failureMessage || 'Shelfmark is unavailable right now.' + ); + }).finally(function () { + setLoadingState(container, false); + }); + } + + function init(root) { + toArray((root || document).querySelectorAll('.js-shelfmark-contextual-async')).forEach(function (container) { + loadInitialSection(container); + }); + } + + window.CwaShelfmarkContextualAsync = { + init: init + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + init(document); + }); + } else { + init(document); + } +}()); diff --git a/cps/static/js/shelfmark_external_search.js b/cps/static/js/shelfmark_external_search.js new file mode 100644 index 000000000..787d8dbca --- /dev/null +++ b/cps/static/js/shelfmark_external_search.js @@ -0,0 +1,2928 @@ +(function () { + 'use strict'; + + var DEFAULT_TIMEOUT_MS = 10000; + var DETAIL_VIEW_PARAM = 'view'; + var DETAIL_VIEW_VALUE = 'modal'; + var DETAIL_STATE_PROVIDER_PARAM = 'shelfmark_detail_provider'; + var DETAIL_STATE_ID_PARAM = 'shelfmark_detail_id'; + var DETAIL_HTML_CACHE_TTL_MS = 300000; + var DETAIL_HTML_CACHE_MAX_ENTRIES = 64; + var ROW_ENRICH_TIMEOUT_MS = 12000; + var ROW_ENRICH_MAX_CONCURRENCY = 2; + var ROW_ENRICH_ROOT_MARGIN = '180px 0px'; + var PREFERRED_RELEASE_TIMEOUT_MS = 20000; + var TOP_UP_TIMEOUT_MS = 12000; + var BATCH_REQUEST_MAX_CONCURRENCY = 4; + var ACTIVITY_SNAPSHOT_TTL_MS = 30000; + var LIBRARY_STATUS_ENDPOINT = '/search/external/shelfmark/library-status'; + var POST_REQUEST_REFRESH_SCHEDULE_MS = [2000, 5000, 10000, 20000]; + var STATUS_SETTLE_DELAY_MS = typeof window.CWA_SHELFMARK_STATUS_SETTLE_DELAY_MS === 'number' + ? window.CWA_SHELFMARK_STATUS_SETTLE_DELAY_MS + : 2600; + var STATUS_CHIP_CLASSES = [ + 'shelfmark-status-chip--available', + 'shelfmark-status-chip--requested', + 'shelfmark-status-chip--queue', + 'shelfmark-status-chip--downloaded', + 'shelfmark-status-chip--imported', + 'shelfmark-status-chip--failed' + ]; + var statusTimers = new WeakMap(); + var boundActionNodes = new WeakSet(); + var boundDetailLinks = new WeakSet(); + var boundBatchToolbars = new WeakSet(); + var boundProgressiveRows = new WeakSet(); + var activeDetailLink = null; + var activeDetailRequest = null; + var detailHtmlCache = new Map(); + var activitySnapshotCache = new Map(); + var detailHistoryBound = false; + var detailModalState = null; + var rowEnrichmentObserver = null; + var rowEnrichmentQueue = []; + var rowEnrichmentInFlight = 0; + var topUpInFlight = false; + var fetchedTopUpPages = new Set(); + var scheduledWorkflowRefreshTimers = new Map(); + + function toArray(value) { + return Array.prototype.slice.call(value || []); + } + + function getFlow() { + return typeof window !== 'undefined' ? (window.CwaShelfmarkRequestFlow || null) : null; + } + + function getSearchPageRoot() { + return document.querySelector('.shelfmark-search-page'); + } + + function parseJson(value) { + if (!value) { + return null; + } + try { + return JSON.parse(value); + } catch (err) { + return null; + } + } + + function cloneJsonValue(value) { + if (!value || typeof value !== 'object') { + return value; + } + return parseJson(JSON.stringify(value)); + } + + function toOptionalNumber(value) { + if (value === null || value === undefined || value === '') { + return null; + } + var normalized = Number(value); + return Number.isFinite(normalized) ? normalized : null; + } + + function pruneTimedCache(cache, maxEntries, ttlMs) { + var now = Date.now(); + Array.from(cache.entries()).forEach(function (entry) { + var key = entry[0]; + var value = entry[1]; + if (!value || typeof value.fetchedAt !== 'number' || now - value.fetchedAt >= ttlMs) { + cache.delete(key); + } + }); + while (cache.size > maxEntries) { + cache.delete(cache.keys().next().value); + } + } + + function getDetailHtmlCacheKey(provider, providerId) { + var normalizedProvider = toOptionalText(provider); + var normalizedProviderId = toOptionalText(providerId); + if (!normalizedProvider || !normalizedProviderId) { + return ''; + } + return normalizedProvider + '::' + normalizedProviderId; + } + + function getCachedDetailHtml(provider, providerId) { + var cacheKey = getDetailHtmlCacheKey(provider, providerId); + if (!cacheKey) { + return ''; + } + pruneTimedCache(detailHtmlCache, DETAIL_HTML_CACHE_MAX_ENTRIES, DETAIL_HTML_CACHE_TTL_MS); + var cached = detailHtmlCache.get(cacheKey); + if (!cached || typeof cached.html !== 'string') { + return ''; + } + detailHtmlCache.delete(cacheKey); + detailHtmlCache.set(cacheKey, cached); + return cached.html; + } + + function rememberDetailHtml(provider, providerId, html) { + var cacheKey = getDetailHtmlCacheKey(provider, providerId); + if (!cacheKey || typeof html !== 'string' || !html) { + return; + } + detailHtmlCache.set(cacheKey, { + fetchedAt: Date.now(), + html: html + }); + pruneTimedCache(detailHtmlCache, DETAIL_HTML_CACHE_MAX_ENTRIES, DETAIL_HTML_CACHE_TTL_MS); + } + + function stripTrailingSlash(value) { + return (value || '').replace(/\/+$/, ''); + } + + function toOptionalText(value) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; + } + + function isPlainLeftClick(event) { + return Boolean( + event && + !event.defaultPrevented && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ); + } + + function buildDetailModalUrl(href) { + try { + var detailUrl = new URL(href, window.location.href); + detailUrl.searchParams.set(DETAIL_VIEW_PARAM, DETAIL_VIEW_VALUE); + return detailUrl.toString(); + } catch (err) { + var separator = href.indexOf('?') === -1 ? '?' : '&'; + return href + separator + DETAIL_VIEW_PARAM + '=' + DETAIL_VIEW_VALUE; + } + } + + function buildUrl(href) { + try { + return new URL(href, window.location.href); + } catch (err) { + return null; + } + } + + function clearDetailStateParams(url) { + if (!url || !url.searchParams) { + return; + } + url.searchParams.delete(DETAIL_STATE_PROVIDER_PARAM); + url.searchParams.delete(DETAIL_STATE_ID_PARAM); + } + + function buildSearchStateUrl(href) { + var url = buildUrl(href || window.location.href); + if (!url) { + return href || window.location.href; + } + clearDetailStateParams(url); + return url.toString(); + } + + function buildSearchStateUrlWithDetail(searchStateUrl, provider, providerId) { + var url = buildUrl(searchStateUrl || window.location.href); + if (!url) { + return searchStateUrl || window.location.href; + } + clearDetailStateParams(url); + if (provider) { + url.searchParams.set(DETAIL_STATE_PROVIDER_PARAM, provider); + } + if (providerId) { + url.searchParams.set(DETAIL_STATE_ID_PARAM, providerId); + } + return url.toString(); + } + + function parseDetailState(href) { + var url = buildUrl(href || window.location.href); + if (!url) { + return { provider: '', providerId: '' }; + } + return { + provider: toOptionalText(url.searchParams.get(DETAIL_STATE_PROVIDER_PARAM)), + providerId: toOptionalText(url.searchParams.get(DETAIL_STATE_ID_PARAM)) + }; + } + + function currentUrlHasDetailState() { + var state = parseDetailState(window.location.href); + return Boolean(state.provider && state.providerId); + } + + function emitCustomEvent(node, name) { + if (!node || !name) { + return; + } + if (typeof node.dispatchEvent === 'function' && typeof window.Event === 'function') { + try { + node.dispatchEvent(new window.Event(name)); + return; + } catch (err) { + // Fall through to simple listener invocation. + } + } + if (!node.listeners || !Array.isArray(node.listeners[name])) { + return; + } + node.listeners[name].forEach(function (handler) { + handler(); + }); + } + + function clearStatusTimer(node) { + var timerId = statusTimers.get(node); + if (timerId) { + clearTimeout(timerId); + statusTimers.delete(node); + } + } + + function scheduleStatusSettle(node, delayMs) { + clearStatusTimer(node); + if (delayMs <= 0) { + node.classList.add('is-settled'); + return; + } + statusTimers.set(node, setTimeout(function () { + node.classList.add('is-settled'); + statusTimers.delete(node); + }, delayMs)); + } + + function setStatusText(statusNodes, text, level, options) { + var settings = options || {}; + var alertLevel = level || 'alert-info'; + var persist = Object.prototype.hasOwnProperty.call(settings, 'persist') + ? Boolean(settings.persist) + : (alertLevel === 'alert-warning' || alertLevel === 'alert-danger'); + var settleDelayMs = typeof settings.settleDelayMs === 'number' + ? settings.settleDelayMs + : STATUS_SETTLE_DELAY_MS; + + (statusNodes || []).forEach(function (node) { + clearStatusTimer(node); + if (!text) { + node.textContent = ''; + node.classList.remove('is-visible', 'is-settled', 'alert-info', 'alert-success', 'alert-warning', 'alert-danger'); + node.classList.add('is-hidden'); + node.setAttribute('aria-hidden', 'true'); + return; + } + node.classList.remove('is-hidden', 'is-settled'); + node.classList.add('is-visible'); + node.removeAttribute('aria-hidden'); + node.textContent = text; + node.classList.remove('alert-info', 'alert-success', 'alert-warning', 'alert-danger'); + node.classList.add(alertLevel); + if (!persist) { + scheduleStatusSettle(node, settleDelayMs); + } + }); + } + + function findHintNode(node) { + if (!node || !node.parentNode || !node.parentNode.parentNode) { + return null; + } + return node.parentNode.parentNode.querySelector('.js-shelfmark-action-hint'); + } + + function applyButtonClass(node, buttonClass) { + node.classList.remove('btn-default', 'btn-primary', 'btn-success', 'btn-info', 'btn-warning', 'btn-danger'); + if (buttonClass) { + node.classList.add(buttonClass); + } + } + + function updateActionNode(node, next) { + var hintNode = findHintNode(node); + var iconNode = node.querySelector('.js-shelfmark-action-icon'); + var labelNode = node.querySelector('.js-shelfmark-action-label'); + + node.dataset.mode = next.mode; + if (next.mode === 'view_library') { + node.removeAttribute('target'); + node.removeAttribute('rel'); + } else { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); + } + applyButtonClass(node, next.buttonClass); + + if (iconNode) { + iconNode.className = (next.iconClass || '') + ' js-shelfmark-action-icon'; + } + if (labelNode) { + labelNode.textContent = next.label; + } else { + node.textContent = next.label; + } + if (hintNode) { + hintNode.textContent = next.hint || ''; + hintNode.classList.toggle('is-hidden', !next.hint); + } + } + + function getActionMatchKey(node) { + if (!node || !node.dataset) { + return ''; + } + return buildRequestPayloadMatchKey(parseJson(node.dataset.requestPayload)); + } + + function findMatchingActionNodes(scope, requestPayload) { + var matchKey = buildRequestPayloadMatchKey(requestPayload); + if (!matchKey) { + return []; + } + return toArray((scope || document).querySelectorAll('.js-shelfmark-action')).filter(function (node) { + return getActionMatchKey(node) === matchKey; + }); + } + + function applyPendingState(scope, requestPayload, isPending, options) { + findMatchingActionNodes(scope, requestPayload).forEach(function (node) { + setPending(node, isPending, options); + }); + } + + function setPending(node, isPending, options) { + var settings = options || {}; + var iconNode = node.querySelector('.js-shelfmark-action-icon'); + var labelNode = node.querySelector('.js-shelfmark-action-label'); + var hintNode = findHintNode(node); + var wasWaiting = node.classList.contains('is-shelfmark-action--waiting'); + + if (isPending) { + node.classList.add('disabled'); + node.setAttribute('aria-disabled', 'true'); + node.setAttribute('aria-busy', 'true'); + node.classList.remove('is-shelfmark-action--waiting'); + if (settings.pendingClass) { + node.classList.add(settings.pendingClass); + } + if (iconNode) { + iconNode.className = (settings.iconClass || 'glyphicon glyphicon-refresh') + ' js-shelfmark-action-icon'; + } + if (labelNode) { + labelNode.textContent = settings.label || 'Requesting...'; + } else { + node.textContent = settings.label || 'Requesting...'; + } + if (hintNode) { + if (Object.prototype.hasOwnProperty.call(settings, 'hint')) { + hintNode.textContent = settings.hint || ''; + hintNode.classList.toggle('is-hidden', !settings.hint); + } else if (wasWaiting) { + hintNode.textContent = ''; + hintNode.classList.add('is-hidden'); + } + } + return; + } + node.classList.remove('disabled'); + node.classList.remove('is-shelfmark-action--waiting'); + node.removeAttribute('aria-disabled'); + node.removeAttribute('aria-busy'); + } + + function createRequestError(message, options) { + var requestFlow = getFlow(); + if (!requestFlow) { + return Object.assign(new Error(message), options || {}); + } + return requestFlow.createBrowserError(message, options || {}); + } + + function fetchJson(url, options, timeoutMs) { + var controller = typeof AbortController === 'function' ? new AbortController() : null; + var timeoutId = controller && timeoutMs && timeoutMs > 0 + ? setTimeout(function () { controller.abort(); }, timeoutMs) + : null; + + return fetch(url, { + ...options, + credentials: 'include', + signal: controller ? controller.signal : undefined, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + ...(options && options.headers ? options.headers : {}) + } + }).then(async function (response) { + var payload = null; + try { + payload = await response.json(); + } catch (err) { + payload = null; + } + + if (!response.ok) { + var message = response.status + ' ' + response.statusText; + if (payload && typeof payload === 'object') { + if (typeof payload.message === 'string' && payload.message) { + message = payload.message; + } else if (typeof payload.error === 'string' && payload.error) { + message = payload.error; + } + } + + throw createRequestError(message, { + kind: response.status === 401 ? 'auth' : 'http', + status: response.status, + payload: payload, + code: payload && typeof payload.code === 'string' ? payload.code : null, + requiredMode: payload && typeof payload.required_mode === 'string' ? payload.required_mode : null + }); + } + + return payload || {}; + }).catch(function (error) { + if (error && error.name === 'AbortError') { + throw createRequestError('Request timed out while contacting Shelfmark.', { kind: 'timeout' }); + } + if (error && error.isShelfmarkBrowserError) { + throw error; + } + throw createRequestError( + 'The browser could not reach Shelfmark directly. This usually means the network/proxy blocked the request, or the apps are not on a compatible same origin.', + { kind: 'network' } + ); + }).finally(function () { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); + } + + function getActivitySnapshotCacheKey(baseUrl) { + return stripTrailingSlash(baseUrl || ''); + } + + function buildRequestPayloadMatchKey(requestPayload) { + var bookData = requestPayload && requestPayload.book_data ? requestPayload.book_data : {}; + return buildStatusMatchKey(bookData.provider || 'hardcover', bookData.provider_id); + } + + function invalidateActivitySnapshot(baseUrl) { + var cacheKey = getActivitySnapshotCacheKey(baseUrl); + if (!cacheKey) { + return; + } + activitySnapshotCache.delete(cacheKey); + } + + function fetchActivitySnapshot(baseUrl) { + var cacheKey = getActivitySnapshotCacheKey(baseUrl); + var now = Date.now(); + var cached = activitySnapshotCache.get(cacheKey); + + if (cached && cached.data && now - cached.fetchedAt < ACTIVITY_SNAPSHOT_TTL_MS) { + return Promise.resolve(cached.data); + } + if (cached && cached.promise) { + return cached.promise; + } + + var promise = fetchJson(cacheKey + '/api/activity/snapshot', {}, DEFAULT_TIMEOUT_MS) + .then(function (payload) { + var data = payload && typeof payload === 'object' ? payload : {}; + activitySnapshotCache.set(cacheKey, { + fetchedAt: Date.now(), + data: data, + promise: null + }); + return data; + }) + .catch(function (error) { + activitySnapshotCache.delete(cacheKey); + throw error; + }); + + activitySnapshotCache.set(cacheKey, { + fetchedAt: cached && cached.fetchedAt ? cached.fetchedAt : 0, + data: cached && cached.data ? cached.data : null, + promise: promise + }); + + return promise; + } + + function fetchLibraryPresence(scope) { + var providerIds = []; + var seenProviderIds = new Set(); + getStatusTargetNodes(scope).forEach(function (target) { + var providerId = toOptionalText(target && target.dataset ? target.dataset.statusProviderId : ''); + if (!providerId || seenProviderIds.has(providerId)) { + return; + } + seenProviderIds.add(providerId); + providerIds.push(providerId); + }); + + if (!providerIds.length) { + return Promise.resolve({}); + } + + var url = buildUrl(LIBRARY_STATUS_ENDPOINT); + if (!url) { + return Promise.resolve({}); + } + providerIds.forEach(function (providerId) { + url.searchParams.append('provider_id', providerId); + }); + + return fetchJson(url.toString(), { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }, DEFAULT_TIMEOUT_MS).then(function (payload) { + if (!payload || payload.ok !== true || typeof payload.matches !== 'object') { + return {}; + } + return payload.matches; + }).catch(function () { + return {}; + }); + } + + function applyLibraryPresence(scope, matches) { + getStatusTargetNodes(scope).forEach(function (target) { + var providerId = toOptionalText(target && target.dataset ? target.dataset.statusProviderId : ''); + if (!providerId || !matches || typeof matches !== 'object' || !Object.prototype.hasOwnProperty.call(matches, providerId)) { + return; + } + var match = matches[providerId]; + target.dataset.statusInLibrary = match.in_library ? '1' : '0'; + if (match.book_url) { + target.dataset.libraryBookUrl = match.book_url; + } else { + delete target.dataset.libraryBookUrl; + } + if (match.book_title) { + target.dataset.libraryBookTitle = match.book_title; + } else { + delete target.dataset.libraryBookTitle; + } + }); + } + + function scopeHasClass(scope, className) { + return Boolean(scope && scope.classList && scope.classList.contains(className)); + } + + function getScopedClassNodes(scope, className) { + var selector = '.' + className; + var matches = toArray((scope || document).querySelectorAll(selector)); + if (scopeHasClass(scope, className)) { + matches.unshift(scope); + } + return matches; + } + + function getStatusTargetNodes(scope) { + return getScopedClassNodes(scope, 'js-shelfmark-status-target').filter(function (target) { + return !isFilterHiddenRow(target); + }); + } + + function isFilterHiddenRow(row) { + return Boolean(row && row.classList && row.classList.contains('is-shelfmark-filter-hidden')); + } + + function buildStatusMatchKey(provider, providerId) { + var normalizedProvider = toOptionalText(provider).toLowerCase(); + var normalizedProviderId = toOptionalText(providerId); + if (!normalizedProvider || !normalizedProviderId) { + return ''; + } + return normalizedProvider + ':' + normalizedProviderId; + } + + function getTargetMatchKey(target) { + if (!target || !target.dataset) { + return ''; + } + return buildStatusMatchKey(target.dataset.statusProvider, target.dataset.statusProviderId); + } + + function parseStatusRecordBookData(record) { + if (!record || typeof record !== 'object' || !record.book_data || typeof record.book_data !== 'object') { + return {}; + } + return record.book_data; + } + + function buildRequestStatusIndex(requests) { + var index = new Map(); + (Array.isArray(requests) ? requests : []).forEach(function (record) { + var bookData = parseStatusRecordBookData(record); + var matchKey = buildStatusMatchKey(bookData.provider || 'hardcover', bookData.provider_id); + if (!matchKey || index.has(matchKey)) { + return; + } + index.set(matchKey, record); + }); + return index; + } + + function selectStatusTimestamp(record) { + return toOptionalText( + record && ( + record.delivery_updated_at + || record.reviewed_at + || record.updated_at + || record.created_at + ) + ); + } + + function formatStatusTimestamp(value) { + var rawValue = toOptionalText(value); + if (!rawValue) { + return ''; + } + var parsed = Date.parse(rawValue); + if (!Number.isFinite(parsed)) { + return rawValue; + } + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short' + }).format(new Date(parsed)); + } catch (err) { + return new Date(parsed).toLocaleString(); + } + } + + function buildWorkflowState(config) { + return { + key: config.key, + label: config.label, + chipClass: config.chipClass, + timestamp: config.timestamp || '', + message: config.message || '' + }; + } + + function serializeWorkflowState(workflowState) { + if (!workflowState) { + return ''; + } + try { + return JSON.stringify(workflowState); + } catch (err) { + return ''; + } + } + + function parseWorkflowOverride(target) { + if (!target || !target.dataset) { + return null; + } + return parseJson(target.dataset.workflowOverride); + } + + function setWorkflowOverride(target, workflowState) { + if (!target || !target.dataset) { + return; + } + if (!workflowState) { + delete target.dataset.workflowOverride; + return; + } + target.dataset.workflowOverride = serializeWorkflowState(workflowState); + } + + function mapRequestRecordToWorkflowState(record) { + if (!record || typeof record !== 'object') { + return null; + } + + var requestStatus = toOptionalText(record.status).toLowerCase(); + var deliveryState = toOptionalText(record.delivery_state).toLowerCase(); + var timestamp = selectStatusTimestamp(record); + var failureReason = toOptionalText(record.last_failure_reason) || toOptionalText(record.admin_note); + + if (requestStatus === 'cancelled') { + return null; + } + + if (requestStatus === 'rejected') { + return buildWorkflowState({ + key: 'failed', + label: 'Failed', + chipClass: 'shelfmark-status-chip--failed', + timestamp: timestamp, + message: failureReason || 'Request was rejected in Shelfmark.' + }); + } + + if (requestStatus === 'pending') { + if (failureReason) { + return buildWorkflowState({ + key: 'failed', + label: 'Failed', + chipClass: 'shelfmark-status-chip--failed', + timestamp: timestamp, + message: failureReason + }); + } + return buildWorkflowState({ + key: 'requested', + label: 'Requested', + chipClass: 'shelfmark-status-chip--requested', + timestamp: timestamp + }); + } + + if (requestStatus === 'fulfilled') { + if (deliveryState === 'complete') { + return buildWorkflowState({ + key: 'downloaded', + label: 'Downloaded', + chipClass: 'shelfmark-status-chip--downloaded', + timestamp: timestamp + }); + } + if (deliveryState === 'error' || deliveryState === 'cancelled') { + return buildWorkflowState({ + key: 'failed', + label: 'Failed', + chipClass: 'shelfmark-status-chip--failed', + timestamp: timestamp, + message: failureReason || 'Delivery failed in Shelfmark.' + }); + } + if (deliveryState === 'queued' || deliveryState === 'resolving' || deliveryState === 'locating' || deliveryState === 'downloading') { + return buildWorkflowState({ + key: 'queue', + label: 'In queue', + chipClass: 'shelfmark-status-chip--queue', + timestamp: timestamp + }); + } + return buildWorkflowState({ + key: 'requested', + label: 'Requested', + chipClass: 'shelfmark-status-chip--requested', + timestamp: timestamp + }); + } + + return null; + } + + function buildAvailableWorkflowState() { + return buildWorkflowState({ + key: 'available', + label: 'Available to request', + chipClass: 'shelfmark-status-chip--available' + }); + } + + function buildImportedWorkflowState() { + return buildWorkflowState({ + key: 'imported', + label: 'In library', + chipClass: 'shelfmark-status-chip--imported' + }); + } + + function buildHandledActionState(workflowState) { + if (!workflowState || !workflowState.key) { + return null; + } + + if (workflowState.key === 'requested') { + return { + mode: 'open', + label: 'Requested', + hint: 'Already requested in Shelfmark.', + buttonClass: 'btn-success', + iconClass: 'glyphicon glyphicon-ok' + }; + } + + if (workflowState.key === 'queue') { + return { + mode: 'open', + label: 'In queue', + hint: 'Shelfmark is still processing this request.', + buttonClass: 'btn-warning', + iconClass: 'glyphicon glyphicon-time' + }; + } + + if (workflowState.key === 'downloaded') { + return { + mode: 'open', + label: 'Downloaded', + hint: 'Shelfmark has completed delivery for this request.', + buttonClass: 'btn-success', + iconClass: 'glyphicon glyphicon-download-alt' + }; + } + + return null; + } + + function syncActionWithWorkflowState(target, workflowState) { + var actionNode = target ? target.querySelector('.js-shelfmark-action') : null; + if (!actionNode) { + return; + } + if (workflowState && workflowState.key === 'imported') { + var libraryBookUrl = toOptionalText(target.dataset ? target.dataset.libraryBookUrl : ''); + if (!libraryBookUrl) { + return; + } + updateActionNode(actionNode, { + mode: 'view_library', + label: 'Open existing CWA book', + hint: '', + buttonClass: 'btn-success', + iconClass: 'glyphicon glyphicon-book' + }); + actionNode.setAttribute('href', libraryBookUrl); + return; + } + var nextActionState = buildHandledActionState(workflowState); + if (!nextActionState) { + return; + } + updateActionNode(actionNode, nextActionState); + actionNode.setAttribute('href', actionNode.dataset.openUrl || actionNode.getAttribute('href') || '#'); + } + + function shouldHideAvailableWorkflowState(target, workflowState) { + if (!target || !workflowState || workflowState.key !== 'available') { + return false; + } + var actionNode = target.querySelector('.js-shelfmark-action'); + var requestFlow = getFlow(); + return Boolean(actionNode && requestFlow && requestFlow.normalizeMode(actionNode.dataset.mode) === 'request'); + } + + function setWorkflowState(target, workflowState) { + if (!target) { + return; + } + + var chipNode = target.querySelector('.js-shelfmark-status-chip'); + var timestampNode = target.querySelector('.js-shelfmark-status-timestamp'); + var messageNode = target.querySelector('.js-shelfmark-status-message'); + var detailStatusNode = target.querySelector('.shelfmark-detail-status'); + + if (!chipNode) { + return; + } + + var statusKey = workflowState ? workflowState.key : ''; + var isHandled = statusKey === 'requested' || statusKey === 'queue' || statusKey === 'downloaded' || statusKey === 'imported'; + target.classList.toggle('is-shelfmark-handled', isHandled); + + STATUS_CHIP_CLASSES.forEach(function (className) { + chipNode.classList.remove(className); + }); + + if (!workflowState) { + chipNode.classList.add('is-hidden'); + chipNode.textContent = ''; + chipNode.dataset.statusKey = ''; + if (detailStatusNode) { + detailStatusNode.classList.add('is-hidden'); + } + if (timestampNode) { + timestampNode.textContent = ''; + timestampNode.classList.add('is-hidden'); + } + if (messageNode) { + messageNode.textContent = ''; + messageNode.classList.add('is-hidden'); + } + return; + } + + var shouldHide = shouldHideAvailableWorkflowState(target, workflowState); + + chipNode.classList.toggle('is-hidden', shouldHide); + chipNode.textContent = workflowState.label; + chipNode.dataset.statusKey = workflowState.key; + if (workflowState.chipClass) { + chipNode.classList.add(workflowState.chipClass); + } + if (detailStatusNode) { + detailStatusNode.classList.toggle('is-hidden', shouldHide); + } + + if (timestampNode) { + var formattedTimestamp = formatStatusTimestamp(workflowState.timestamp); + if (formattedTimestamp && !shouldHide) { + timestampNode.textContent = 'Updated ' + formattedTimestamp; + timestampNode.classList.remove('is-hidden'); + } else { + timestampNode.textContent = ''; + timestampNode.classList.add('is-hidden'); + } + } + + if (messageNode) { + if (workflowState.message && !shouldHide) { + messageNode.textContent = workflowState.message; + messageNode.classList.remove('is-hidden'); + } else { + messageNode.textContent = ''; + messageNode.classList.add('is-hidden'); + } + } + + syncActionWithWorkflowState(target, workflowState); + } + + function getWorkflowStatusKey(target) { + if (!target) { + return ''; + } + var chipNode = target.querySelector('.js-shelfmark-status-chip'); + if (!chipNode || !chipNode.dataset) { + return ''; + } + return toOptionalText(chipNode.dataset.statusKey).toLowerCase(); + } + + function resolveWorkflowStateForTarget(target, requestIndex) { + if (!target || !target.dataset) { + return null; + } + + if (target.dataset.statusInLibrary === '1') { + return buildImportedWorkflowState(); + } + + var matchKey = getTargetMatchKey(target); + if (!matchKey) { + return null; + } + + var matchedRequest = requestIndex ? requestIndex.get(matchKey) : null; + var mappedState = mapRequestRecordToWorkflowState(matchedRequest); + if (mappedState) { + setWorkflowOverride(target, null); + return mappedState; + } + + var overrideState = parseWorkflowOverride(target); + if (overrideState) { + return overrideState; + } + + var actionNode = target.querySelector('.js-shelfmark-action'); + var requestFlow = getFlow(); + if (actionNode && requestFlow && requestFlow.normalizeMode(actionNode.dataset.mode) === 'request') { + return buildAvailableWorkflowState(); + } + + return null; + } + + function updateStatusTargets(scope, requestIndex) { + getStatusTargetNodes(scope).forEach(function (target) { + setWorkflowState(target, resolveWorkflowStateForTarget(target, requestIndex)); + }); + syncBatchUi(document); + } + + function findRelatedStatusTargets(baseScope, requestPayload) { + var scope = baseScope || document; + var bookData = requestPayload && requestPayload.book_data ? requestPayload.book_data : {}; + var matchKey = buildStatusMatchKey(bookData.provider || 'hardcover', bookData.provider_id); + if (!matchKey) { + return []; + } + return getStatusTargetNodes(scope).filter(function (target) { + return getTargetMatchKey(target) === matchKey; + }); + } + + function buildSubmissionWorkflowState(responseData) { + if (responseData && responseData.kind === 'download' && responseData.status === 'queued') { + return buildWorkflowState({ + key: 'queue', + label: 'In queue', + chipClass: 'shelfmark-status-chip--queue', + timestamp: new Date().toISOString() + }); + } + + return buildWorkflowState({ + key: 'requested', + label: 'Requested', + chipClass: 'shelfmark-status-chip--requested', + timestamp: new Date().toISOString() + }); + } + + function applyImmediateSubmissionState(scope, requestPayload, responseData) { + var state = buildSubmissionWorkflowState(responseData); + findRelatedStatusTargets(scope, requestPayload).forEach(function (target) { + setWorkflowOverride(target, state); + setWorkflowState(target, state); + }); + syncBatchUi(document); + } + + function applyActionState(actions, nextState) { + actions.forEach(function (node) { + if (!parseJson(node.dataset.requestPayload)) { + return; + } + updateActionNode(node, nextState); + }); + } + + function applyPerActionProbeStates(actions, probeOptions) { + var requestFlow = getFlow(); + var requestableCount = 0; + var blockedCount = 0; + var payloadCount = 0; + + actions.forEach(function (node) { + var payload = parseJson(node.dataset.requestPayload); + if (!payload) { + return; + } + payloadCount += 1; + var outcome = requestFlow.resolveProbeState({ + baseUrl: probeOptions.baseUrl, + currentOrigin: probeOptions.currentOrigin, + authPayload: probeOptions.authPayload, + policyPayload: probeOptions.policyPayload, + requestPayload: payload + }); + updateActionNode(node, outcome.actionState); + node.dataset.probeKind = outcome.kind; + if (outcome.actionState.mode === 'request') { + requestableCount += 1; + } else { + blockedCount += 1; + } + }); + + return { + payloadCount: payloadCount, + requestableCount: requestableCount, + blockedCount: blockedCount + }; + } + + function applyProbeOutcome(actions, outcome, statusNodes) { + applyActionState(actions, outcome.actionState); + setStatusText(statusNodes, outcome.bannerText, outcome.bannerLevel); + syncBatchUi(document); + } + + function getBatchToolbars(scope) { + return getScopedClassNodes(scope, 'js-shelfmark-batch-toolbar'); + } + + function getBatchRows(scope) { + return getScopedClassNodes(scope, 'js-shelfmark-batch-row').filter(function (row) { + return !isFilterHiddenRow(row) && !isDisplayHiddenRow(row); + }); + } + + function getBatchToolbar(scope) { + return getBatchToolbars(scope)[0] || null; + } + + function getBatchToggleNode(row) { + return row ? row.querySelector('.js-shelfmark-batch-toggle') : null; + } + + function getBatchSelectNode(row) { + return row ? row.querySelector('.js-shelfmark-batch-select') : null; + } + + function getBatchActionNode(row) { + return row ? row.querySelector('.js-shelfmark-action') : null; + } + + function getSelectedBatchRows(scope) { + return getSelectableBatchRows(scope).filter(function (row) { + var toggle = getBatchToggleNode(row); + return Boolean(toggle && toggle.checked && !toggle.disabled); + }); + } + + function getSelectableBatchRows(scope) { + return getBatchRows(scope).filter(function (row) { + return Boolean( + getBatchToggleNode(row) + && getBatchSelectNode(row) + && isBatchEligibleRow(row) + ); + }); + } + + function formatBatchSelectedCount(count) { + return count === 1 ? '1 selected' : count + ' selected'; + } + + function formatBatchReadyCount(count) { + return count === 1 ? '1 ready on this page' : count + ' ready on this page'; + } + + function getBatchModeButtons(scope) { + return getScopedClassNodes(scope, 'js-shelfmark-batch-mode'); + } + + function isBatchModeEnabled(toolbar) { + return Boolean(toolbar && toolbar.dataset && toolbar.dataset.bulkMode === '1'); + } + + function clearBatchSelections(scope) { + getBatchRows(scope).forEach(function (row) { + var toggleNode = getBatchToggleNode(row); + if (toggleNode) { + toggleNode.checked = false; + } + }); + } + + function updateBatchModeButton(enabled) { + getBatchModeButtons(document).forEach(function (modeNode) { + if (!modeNode.dataset.inactiveLabel) { + modeNode.dataset.inactiveLabel = modeNode.textContent || 'Bulk mode'; + } + if (!modeNode.dataset.activeLabel) { + modeNode.dataset.activeLabel = 'Done'; + } + modeNode.textContent = enabled ? modeNode.dataset.activeLabel : modeNode.dataset.inactiveLabel; + modeNode.setAttribute('aria-pressed', enabled ? 'true' : 'false'); + }); + } + + function setBatchMode(toolbar, enabled) { + if (!toolbar || !toolbar.dataset) { + return; + } + toolbar.dataset.bulkMode = enabled ? '1' : '0'; + if (!enabled) { + clearBatchSelections(document); + } + var pageRoot = getSearchPageRoot(); + var resultsList = getResultsList(); + if (pageRoot && pageRoot.classList) { + pageRoot.classList.toggle('shelfmark-search-page--bulk-mode', enabled); + } + if (resultsList && resultsList.classList) { + resultsList.classList.toggle('shelfmark-results-list--compact', enabled); + } + updateBatchModeButton(enabled); + } + + function clearBatchMessage(toolbar) { + if (!toolbar) { + return; + } + var messageNode = toolbar.querySelector('.js-shelfmark-batch-message'); + if (!messageNode) { + return; + } + messageNode.textContent = ''; + messageNode.classList.add('is-hidden'); + messageNode.classList.remove( + 'shelfmark-batch-toolbar__message--success', + 'shelfmark-batch-toolbar__message--warning', + 'shelfmark-batch-toolbar__message--danger' + ); + } + + function setBatchMessage(toolbar, text, tone) { + if (!toolbar) { + return; + } + var messageNode = toolbar.querySelector('.js-shelfmark-batch-message'); + if (!messageNode) { + return; + } + clearBatchMessage(toolbar); + if (!text) { + return; + } + messageNode.textContent = text; + messageNode.classList.remove('is-hidden'); + if (tone === 'success') { + messageNode.classList.add('shelfmark-batch-toolbar__message--success'); + } else if (tone === 'warning') { + messageNode.classList.add('shelfmark-batch-toolbar__message--warning'); + } else if (tone === 'danger') { + messageNode.classList.add('shelfmark-batch-toolbar__message--danger'); + } + } + + function isBatchEligibleRow(row) { + if (!row) { + return false; + } + var actionNode = getBatchActionNode(row); + var requestFlow = getFlow(); + if (!actionNode || !parseJson(actionNode.dataset.requestPayload)) { + return false; + } + if (!requestFlow || requestFlow.normalizeMode(actionNode.dataset.mode) !== 'request') { + return false; + } + return getWorkflowStatusKey(row) === 'available'; + } + + function syncBatchUi(scope) { + var toolbar = getBatchToolbar(scope) || getBatchToolbar(document); + var batchRows = getBatchRows(document); + if (!toolbar) { + return; + } + + var bulkModeEnabled = isBatchModeEnabled(toolbar); + var eligibleCount = 0; + var selectedCount = 0; + var isPending = toolbar.dataset.pending === '1'; + + batchRows.forEach(function (row) { + var toggleNode = getBatchToggleNode(row); + var selectNode = getBatchSelectNode(row); + if (!toggleNode || !selectNode) { + return; + } + + var isEligible = isBatchEligibleRow(row); + if (!isEligible || !bulkModeEnabled) { + toggleNode.checked = false; + } + + toggleNode.disabled = !isEligible || !bulkModeEnabled || isPending; + selectNode.classList.toggle('is-hidden', !bulkModeEnabled || !isEligible); + row.classList.toggle('is-batch-eligible', isEligible); + row.classList.toggle( + 'is-batch-selected', + bulkModeEnabled && isEligible && Boolean(toggleNode.checked) + ); + + if (isEligible) { + eligibleCount += 1; + } + if (bulkModeEnabled && isEligible && toggleNode.checked) { + selectedCount += 1; + } + }); + + updateBatchModeButton(bulkModeEnabled); + + var countNode = toolbar.querySelector('.js-shelfmark-batch-count'); + var readyNode = toolbar.querySelector('.js-shelfmark-batch-ready'); + var selectVisibleNode = toolbar.querySelector('.js-shelfmark-batch-select-visible'); + var clearNode = toolbar.querySelector('.js-shelfmark-batch-clear'); + var requestNode = toolbar.querySelector('.js-shelfmark-batch-request'); + var hasMessage = Boolean( + toolbar.querySelector('.js-shelfmark-batch-message') + && !toolbar.querySelector('.js-shelfmark-batch-message').classList.contains('is-hidden') + ); + + if (countNode) { + countNode.textContent = formatBatchSelectedCount(selectedCount); + } + if (readyNode) { + readyNode.textContent = formatBatchReadyCount(eligibleCount); + } + getBatchModeButtons(document).forEach(function (modeNode) { + modeNode.disabled = (eligibleCount < 1 && selectedCount < 1 && !bulkModeEnabled) || isPending; + }); + if (selectVisibleNode) { + selectVisibleNode.disabled = !bulkModeEnabled || eligibleCount < 1 || isPending; + } + if (clearNode) { + clearNode.disabled = !bulkModeEnabled || selectedCount < 1 || isPending; + } + if (requestNode) { + requestNode.disabled = !bulkModeEnabled || selectedCount < 1 || isPending; + } + + var shouldShowToolbar = bulkModeEnabled; + toolbar.classList.toggle('is-hidden', !shouldShowToolbar); + toolbar.setAttribute('aria-hidden', shouldShowToolbar ? 'false' : 'true'); + } + + function setBatchPending(toolbar, isPending) { + if (!toolbar) { + return; + } + toolbar.dataset.pending = isPending ? '1' : '0'; + var requestNode = toolbar.querySelector('.js-shelfmark-batch-request'); + if (!requestNode) { + return; + } + if (!requestNode.dataset.defaultLabel) { + requestNode.dataset.defaultLabel = requestNode.textContent; + } + requestNode.textContent = isPending ? 'Requesting...' : requestNode.dataset.defaultLabel; + } + + function clearScheduledWorkflowRefresh(baseUrl) { + var normalizedBaseUrl = toOptionalText(baseUrl); + var timers = normalizedBaseUrl ? scheduledWorkflowRefreshTimers.get(normalizedBaseUrl) : null; + if (!timers) { + return; + } + timers.forEach(function (timerId) { + clearTimeout(timerId); + }); + scheduledWorkflowRefreshTimers.delete(normalizedBaseUrl); + } + + function scheduleWorkflowRefresh(scope, baseUrl) { + var normalizedBaseUrl = toOptionalText(baseUrl); + if (!normalizedBaseUrl) { + return; + } + clearScheduledWorkflowRefresh(normalizedBaseUrl); + var timers = POST_REQUEST_REFRESH_SCHEDULE_MS.map(function (delayMs) { + return setTimeout(function () { + refreshWorkflowStatus(scope || document, normalizedBaseUrl); + }, delayMs); + }); + scheduledWorkflowRefreshTimers.set(normalizedBaseUrl, timers); + } + + async function refreshWorkflowStatus(scope, baseUrl) { + var requestFlow = getFlow(); + if (!baseUrl || !requestFlow || !requestFlow.isDirectRequestViable(baseUrl, window.location.origin)) { + return; + } + try { + var payloads = await Promise.all([ + fetchActivitySnapshot(baseUrl), + fetchLibraryPresence(scope) + ]); + applyLibraryPresence(scope, payloads[1]); + updateStatusTargets(scope, buildRequestStatusIndex(payloads[0] && payloads[0].requests)); + } catch (error) { + // Leave the current optimistic state in place when refresh is unavailable. + } + } + + function rememberPolicyPayload(actions, policyPayload) { + (actions || []).forEach(function (node) { + if (!node || !node.dataset) { + return; + } + if (policyPayload) { + node.dataset.requestPolicy = serializeWorkflowState(policyPayload); + } else { + delete node.dataset.requestPolicy; + } + }); + } + + function getStoredPolicyPayload(node) { + if (!node || !node.dataset) { + return null; + } + return parseJson(node.dataset.requestPolicy); + } + + function getPreferredReleaseSettings(node) { + var requestFlow = getFlow(); + if (!requestFlow || !node || !node.dataset) { + return null; + } + return requestFlow.normalizePreferredReleaseSettings({ + enabled: node.dataset.preferredReleaseEnabled === '1', + provider: node.dataset.preferredReleaseProvider, + contentType: node.dataset.preferredReleaseContentType, + ranking: node.dataset.preferredReleaseRanking + }); + } + + function buildPreferredReleaseSearchUrl(baseUrl, requestPayload, settings, policyPayload) { + var requestFlow = getFlow(); + var bookData = requestPayload && requestPayload.book_data ? requestPayload.book_data : {}; + var provider = toOptionalText(bookData.provider); + var bookId = toOptionalText(bookData.provider_id); + if (!requestFlow || !provider || !bookId) { + return ''; + } + + var url = buildUrl(stripTrailingSlash(baseUrl) + '/api/releases'); + if (!url) { + return ''; + } + + var preferredSource = requestFlow.resolvePreferredReleaseSource(settings, policyPayload); + url.searchParams.set('provider', provider); + url.searchParams.set('book_id', bookId); + url.searchParams.set('content_type', settings.contentType); + if (preferredSource) { + url.searchParams.set('source', preferredSource); + } else if (settings.provider) { + url.searchParams.set('indexers', settings.provider); + } + return url.toString(); + } + + async function fetchPreferredReleaseCandidates(node, requestPayload, settings, policyPayload) { + var requestUrl = buildPreferredReleaseSearchUrl(node.dataset.baseUrl, requestPayload, settings, policyPayload); + if (!requestUrl) { + return []; + } + + var response = await fetchJson(requestUrl, { + method: 'GET' + }, PREFERRED_RELEASE_TIMEOUT_MS); + return Array.isArray(response && response.releases) ? response.releases : []; + } + + async function resolvePreferredSubmission(node, requestPayload) { + var requestFlow = getFlow(); + var settings = getPreferredReleaseSettings(node); + if (!requestFlow || !settings || !requestFlow.isPreferredReleaseWorkflowEnabled(settings)) { + return { + payload: requestPayload, + usedPreferredRelease: false, + fallbackReason: 'disabled' + }; + } + + if (requestFlow.getRequestPayloadLevel(requestPayload) !== 'book') { + return { + payload: requestPayload, + usedPreferredRelease: false, + fallbackReason: 'already_release_level' + }; + } + + var policyPayload = getStoredPolicyPayload(node); + if (!policyPayload) { + try { + policyPayload = await fetchJson( + stripTrailingSlash(node.dataset.baseUrl) + '/api/request-policy', + {}, + DEFAULT_TIMEOUT_MS + ); + rememberPolicyPayload([node], policyPayload); + } catch (error) { + return { + payload: requestPayload, + usedPreferredRelease: false, + fallbackReason: 'policy_unavailable' + }; + } + } + + var releases; + try { + releases = await fetchPreferredReleaseCandidates(node, requestPayload, settings, policyPayload); + } catch (error) { + return { + payload: requestPayload, + usedPreferredRelease: false, + fallbackReason: 'release_lookup_failed' + }; + } + + var preferredRelease = requestFlow.selectPreferredRelease(releases, settings, policyPayload); + if (!preferredRelease) { + return { + payload: requestPayload, + usedPreferredRelease: false, + fallbackReason: 'no_match' + }; + } + + var resolvedMode = requestFlow.resolveSourceModeFromPolicy( + policyPayload, + preferredRelease.source, + settings.contentType, + { preferSourceSpecific: true } + ); + if (resolvedMode !== requestFlow.REQUEST_RELEASE_MODE && resolvedMode !== requestFlow.DOWNLOAD_MODE) { + return { + payload: requestPayload, + usedPreferredRelease: false, + fallbackReason: 'policy_requires_book' + }; + } + + var preferredPayload = requestFlow.buildPreferredReleaseRequestPayload( + cloneJsonValue(requestPayload), + preferredRelease, + settings + ); + if ( + !preferredPayload + || !preferredPayload.release_data + || !preferredPayload.release_data.source + || !preferredPayload.release_data.source_id + ) { + return { + payload: requestPayload, + usedPreferredRelease: false, + fallbackReason: 'incomplete_release_payload' + }; + } + + return { + payload: preferredPayload, + usedPreferredRelease: true, + resolvedMode: resolvedMode, + release: preferredRelease + }; + } + + async function submitResolvedRequest(node, payload, openUrl, statusNodes, options) { + var settings = options || {}; + var showStatusBanner = !settings.silentStatus; + var resolvedPayload = payload; + + applyPendingState(document, payload, true); + + if (showStatusBanner) { + var preferredSettings = getPreferredReleaseSettings(node); + var requestFlow = getFlow(); + if ( + requestFlow + && preferredSettings + && requestFlow.isPreferredReleaseWorkflowEnabled(preferredSettings) + && requestFlow.getRequestPayloadLevel(payload) === 'book' + ) { + setStatusText(statusNodes, 'Selecting preferred Shelfmark release…', 'alert-info', { persist: true }); + } + } + + try { + var resolved = await resolvePreferredSubmission(node, payload); + resolvedPayload = resolved && resolved.payload ? resolved.payload : payload; + } catch (error) { + resolvedPayload = payload; + } + + return submitRequest(node, resolvedPayload, openUrl, statusNodes, options); + } + + async function submitRequest(node, payload, openUrl, statusNodes, options) { + var requestFlow = getFlow(); + var settings = options || {}; + var showStatusBanner = !settings.silentStatus; + + if (showStatusBanner) { + setStatusText(statusNodes, 'Creating request in Shelfmark…', 'alert-info', { persist: true }); + } + + try { + var responseData = await fetchJson(stripTrailingSlash(node.dataset.baseUrl) + '/api/requests', { + method: 'POST', + body: JSON.stringify(payload) + }, DEFAULT_TIMEOUT_MS); + + var successOutcome = requestFlow.resolveRequestOutcome({ success: true, response: responseData }); + updateActionNode(node, successOutcome.actionState); + node.setAttribute('href', openUrl); + if (showStatusBanner) { + setStatusText(statusNodes, successOutcome.bannerText, successOutcome.bannerLevel); + } + invalidateActivitySnapshot(node.dataset.baseUrl); + applyImmediateSubmissionState(document, payload, responseData); + refreshWorkflowStatus(document, node.dataset.baseUrl); + scheduleWorkflowRefresh(document, node.dataset.baseUrl); + return { + success: true, + outcome: successOutcome, + response: responseData + }; + } catch (error) { + var failureOutcome = requestFlow.resolveRequestOutcome({ success: false, error: error }); + updateActionNode(node, failureOutcome.actionState); + node.setAttribute('href', openUrl); + if (showStatusBanner) { + setStatusText(statusNodes, failureOutcome.bannerText, failureOutcome.bannerLevel); + } + if (error && error.kind === 'timeout' && node.dataset && node.dataset.baseUrl) { + invalidateActivitySnapshot(node.dataset.baseUrl); + refreshWorkflowStatus(document, node.dataset.baseUrl); + scheduleWorkflowRefresh(document, node.dataset.baseUrl); + } + return { + success: false, + error: error, + outcome: failureOutcome + }; + } finally { + applyPendingState(document, payload, false); + syncBatchUi(document); + } + } + + async function requestSelectedRows(toolbar) { + var requestFlow = getFlow(); + var selectedRows = getSelectedBatchRows(document); + if (!selectedRows.length || !requestFlow) { + syncBatchUi(document); + return; + } + + clearBatchMessage(toolbar); + setBatchPending(toolbar, true); + syncBatchUi(document); + + var requestItems = []; + var seenKeys = new Set(); + var baseUrl = ''; + + selectedRows.forEach(function (row) { + var actionNode = getBatchActionNode(row); + var payload = actionNode ? parseJson(actionNode.dataset.requestPayload) : null; + if (!actionNode || !payload) { + return; + } + var matchKey = buildRequestPayloadMatchKey(payload); + if (matchKey && seenKeys.has(matchKey)) { + return; + } + if (matchKey) { + seenKeys.add(matchKey); + } + if (!baseUrl) { + baseUrl = actionNode.dataset.baseUrl || ''; + } + requestItems.push({ + actionNode: actionNode, + payload: payload, + openUrl: actionNode.dataset.openUrl || actionNode.getAttribute('href') || '' + }); + }); + + var successCount = 0; + var queuedCount = 0; + var pendingConfirmationCount = 0; + var failureCount = 0; + + requestItems.forEach(function (item, index) { + if (index >= BATCH_REQUEST_MAX_CONCURRENCY) { + applyPendingState(document, item.payload, true, { + label: 'Waiting', + iconClass: 'glyphicon glyphicon-time', + hint: 'Waiting for an earlier Shelfmark request slot to free up.', + pendingClass: 'is-shelfmark-action--waiting' + }); + } + }); + + var responses = await runWithConcurrency( + requestItems, + BATCH_REQUEST_MAX_CONCURRENCY, + function (item) { + applyPendingState(document, item.payload, false); + return submitResolvedRequest(item.actionNode, item.payload, item.openUrl, [], { + silentStatus: true + }); + } + ); + + responses.forEach(function (response) { + if (response && response.success) { + successCount += 1; + if (response.outcome && response.outcome.kind === 'release_queued') { + queuedCount += 1; + } + return; + } + if (response && response.outcome && response.outcome.kind === 'request_confirmation_pending') { + pendingConfirmationCount += 1; + return; + } + failureCount += 1; + }); + + clearBatchSelections(document); + + if (successCount > 0 && baseUrl) { + await refreshWorkflowStatus(document, baseUrl); + } + + setBatchPending(toolbar, false); + + if (successCount > 0 && pendingConfirmationCount > 0 && failureCount === 0) { + setBatchMessage( + toolbar, + 'Requested ' + successCount + '. Still checking ' + pendingConfirmationCount + ' more.', + 'warning' + ); + } else if (successCount > 0 && failureCount === 0) { + setBatchMessage( + toolbar, + queuedCount === successCount + ? (successCount === 1 ? 'Queued 1 book.' : 'Queued ' + successCount + ' books.') + : (successCount === 1 ? 'Requested 1 book.' : 'Requested ' + successCount + ' books.'), + 'success' + ); + } else if (pendingConfirmationCount > 0 && successCount === 0 && failureCount === 0) { + setBatchMessage( + toolbar, + pendingConfirmationCount === 1 + ? 'Still checking 1 Shelfmark request.' + : 'Still checking ' + pendingConfirmationCount + ' Shelfmark requests.', + 'warning' + ); + } else if (successCount > 0 && failureCount > 0) { + setBatchMessage( + toolbar, + 'Requested ' + successCount + ' of ' + (successCount + failureCount) + '. Some rows still need attention.', + 'warning' + ); + } else { + setBatchMessage(toolbar, 'No Shelfmark requests were created.', 'danger'); + } + + syncBatchUi(document); + } + + async function runWithConcurrency(items, concurrency, worker) { + var limit = Math.max(1, concurrency || 1); + var queue = Array.isArray(items) ? items.slice() : []; + var results = []; + + async function runNext() { + while (queue.length) { + var item = queue.shift(); + try { + results.push(await worker(item)); + } catch (error) { + results.push({ success: false, error: error }); + } + } + } + + var runners = []; + var runnerCount = Math.min(limit, queue.length); + for (var index = 0; index < runnerCount; index += 1) { + runners.push(runNext()); + } + await Promise.all(runners); + return results; + } + + async function attachProbe(actions, baseUrl, statusNodes) { + var requestFlow = getFlow(); + var currentOrigin = window.location.origin; + var probeOutcome; + + if (!requestFlow.isDirectRequestViable(baseUrl, currentOrigin)) { + probeOutcome = requestFlow.resolveProbeState({ + baseUrl: baseUrl, + currentOrigin: currentOrigin + }); + applyProbeOutcome(actions, probeOutcome, statusNodes); + return; + } + + try { + var authPayload = await fetchJson(stripTrailingSlash(baseUrl) + '/api/auth/check', {}, DEFAULT_TIMEOUT_MS); + if (!authPayload || !authPayload.authenticated) { + probeOutcome = requestFlow.resolveProbeState({ + baseUrl: baseUrl, + currentOrigin: currentOrigin, + authPayload: authPayload + }); + applyProbeOutcome(actions, probeOutcome, statusNodes); + return; + } + + var policyPayload = await fetchJson(stripTrailingSlash(baseUrl) + '/api/request-policy', {}, DEFAULT_TIMEOUT_MS); + rememberPolicyPayload(actions, policyPayload); + probeOutcome = requestFlow.resolveProbeState({ + baseUrl: baseUrl, + currentOrigin: currentOrigin, + authPayload: authPayload, + policyPayload: policyPayload + }); + var perActionSummary = applyPerActionProbeStates(actions, { + baseUrl: baseUrl, + currentOrigin: currentOrigin, + authPayload: authPayload, + policyPayload: policyPayload + }); + + if (!perActionSummary.payloadCount) { + setStatusText(statusNodes, ''); + return; + } + + if (perActionSummary.requestableCount > 0) { + setStatusText(statusNodes, ''); + syncBatchUi(document); + return; + } + + setStatusText( + statusNodes, + 'Open in Shelfmark is required for the visible results.', + 'alert-warning' + ); + syncBatchUi(document); + } catch (error) { + rememberPolicyPayload(actions, null); + probeOutcome = requestFlow.resolveProbeState({ + baseUrl: baseUrl, + currentOrigin: currentOrigin, + error: error + }); + applyProbeOutcome(actions, probeOutcome, statusNodes); + } + } + + async function attachWorkflowStatus(scope, baseUrl, probePromise) { + var targets = getStatusTargetNodes(scope); + var requestFlow = getFlow(); + if (!targets.length || !requestFlow || !requestFlow.isDirectRequestViable(baseUrl, window.location.origin)) { + return; + } + + try { + var payloads = await Promise.all([ + fetchActivitySnapshot(baseUrl), + fetchLibraryPresence(scope) + ]); + if (probePromise && typeof probePromise.then === 'function') { + try { + await probePromise; + } catch (err) { + // Keep the last known/default status if probe fails. + } + } + applyLibraryPresence(scope, payloads[1]); + updateStatusTargets(scope, buildRequestStatusIndex(payloads[0] && payloads[0].requests)); + } catch (error) { + // Leave the default server-rendered state in place when status enrichment is unavailable. + } + } + + function attachRequestHandler(node, payload, openUrl, statusNodes) { + if (boundActionNodes.has(node)) { + return; + } + boundActionNodes.add(node); + + node.addEventListener('click', async function (event) { + var requestFlow = getFlow(); + if (!requestFlow || requestFlow.normalizeMode(node.dataset.mode) !== 'request') { + return; + } + if (node.classList.contains('disabled') || node.getAttribute('aria-disabled') === 'true') { + event.preventDefault(); + return; + } + + event.preventDefault(); + clearBatchMessage(getBatchToolbars(document)[0] || null); + await submitResolvedRequest(node, payload, openUrl, statusNodes, {}); + }); + } + + function getModalJquery(modalNode) { + if (!modalNode || !window.jQuery || typeof window.jQuery !== 'function') { + return null; + } + return window.jQuery(modalNode); + } + + function showModal(modalNode) { + var modalApi = getModalJquery(modalNode); + if (modalApi && typeof modalApi.modal === 'function') { + modalApi.modal('show'); + return; + } + modalNode.style.display = 'block'; + modalNode.classList.add('in'); + if (document.body) { + document.body.classList.add('modal-open'); + } + } + + function hideModal(modalNode) { + var modalApi = getModalJquery(modalNode); + if (modalApi && typeof modalApi.modal === 'function') { + modalApi.modal('hide'); + return; + } + modalNode.style.display = 'none'; + modalNode.classList.remove('in'); + if (document.body) { + document.body.classList.remove('modal-open'); + } + emitCustomEvent(modalNode, 'cwa:modal-hidden'); + } + + function registerModalHiddenHandler(modalNode, handler) { + if (!modalNode || modalNode.dataset.shelfmarkModalBound === '1') { + return; + } + modalNode.dataset.shelfmarkModalBound = '1'; + var modalApi = getModalJquery(modalNode); + if (modalApi && typeof modalApi.on === 'function') { + modalApi.on('hidden.bs.modal', handler); + return; + } + modalNode.addEventListener('cwa:modal-hidden', handler); + } + + function abortActiveDetailRequest() { + if (activeDetailRequest && typeof activeDetailRequest.abort === 'function') { + activeDetailRequest.abort(); + } + activeDetailRequest = null; + } + + function resetDetailModal(modalBody, modalTitle) { + abortActiveDetailRequest(); + if (modalBody) { + modalBody.innerHTML = ''; + modalBody.setAttribute('aria-busy', 'false'); + } + if (modalTitle) { + modalTitle.textContent = 'Shelfmark Details'; + } + if (activeDetailLink && typeof activeDetailLink.focus === 'function') { + activeDetailLink.focus(); + } + activeDetailLink = null; + detailModalState = null; + } + + function modalIsOpen(modalNode) { + return Boolean(modalNode && modalNode.classList && modalNode.classList.contains('in')); + } + + function findMatchingDetailLink(detailLinks, provider, providerId) { + return (detailLinks || []).find(function (link) { + return ( + toOptionalText(link.dataset.detailProvider) === provider && + toOptionalText(link.dataset.detailProviderId) === providerId + ); + }) || null; + } + + function getModalSearchStateUrl(modalNode) { + var configured = toOptionalText(modalNode && modalNode.dataset ? modalNode.dataset.searchStateUrl : ''); + return configured || buildSearchStateUrl(window.location.href); + } + + function getProgressiveRows(scope) { + return getScopedClassNodes(scope, 'js-shelfmark-progressive-row'); + } + + function getVisibleResultRows(scope) { + return getScopedClassNodes(scope, 'js-shelfmark-result-row').filter(function (row) { + return !isFilterHiddenRow(row) && !isDisplayHiddenRow(row); + }); + } + + function isUnsettledProgressiveRow(row) { + var state = getProgressiveRowState(row); + return state === 'pending' || state === 'queued' || state === 'loading'; + } + + function getFilledResultRows(scope) { + return getVisibleResultRows(scope).filter(function (row) { + return !isUnsettledProgressiveRow(row); + }); + } + + function getResultsList() { + return document.querySelector('.js-shelfmark-results-list'); + } + + function getConfiguredDisplayPage(resultsList) { + var parsed = toOptionalNumber(resultsList && resultsList.dataset ? resultsList.dataset.displayPage : null); + return parsed && parsed > 0 ? parsed : 1; + } + + function getConfiguredPageSize(resultsList) { + var parsed = toOptionalNumber(resultsList && resultsList.dataset ? resultsList.dataset.pageSize : null); + return parsed && parsed > 0 ? parsed : 0; + } + + function isHasCoverFilterEnabled(resultsList) { + return Boolean(resultsList && resultsList.dataset && resultsList.dataset.filterHasCover === '1'); + } + + function isRequestableFilterEnabled(resultsList) { + return Boolean(resultsList && resultsList.dataset && resultsList.dataset.filterRequestable === '1'); + } + + function getRequiredDisplayRowCount(resultsList) { + return getConfiguredDisplayPage(resultsList) * getConfiguredPageSize(resultsList); + } + + function getNextTopUpPage(resultsList) { + var parsed = toOptionalNumber(resultsList && resultsList.dataset ? resultsList.dataset.nextPage : null); + return parsed && parsed > 0 ? parsed : null; + } + + function hasFetchedTopUpPage(pageNumber) { + return pageNumber !== null && fetchedTopUpPages.has(pageNumber); + } + + function setNextTopUpPage(resultsList, nextPage) { + if (!resultsList || !resultsList.dataset) { + return; + } + if (nextPage && nextPage > 0) { + resultsList.dataset.nextPage = String(nextPage); + return; + } + delete resultsList.dataset.nextPage; + } + + function getTopUpUrl(resultsList) { + return toOptionalText(resultsList && resultsList.dataset ? resultsList.dataset.topUpUrl : ''); + } + + function isTopUpPending(resultsList) { + return Boolean(resultsList && resultsList.dataset && resultsList.dataset.topUpPending === '1'); + } + + function setTopUpPending(resultsList, pending) { + if (!resultsList || !resultsList.dataset) { + return; + } + if (pending) { + resultsList.dataset.topUpPending = '1'; + return; + } + delete resultsList.dataset.topUpPending; + } + + function setRowDatasetValue(row, key, value) { + if (!row || !row.dataset) { + return; + } + if (value === null || value === undefined || value === '') { + delete row.dataset[key]; + return; + } + row.dataset[key] = String(value); + } + + function applyRowStateDatasetValues(row, payload) { + if (!row || !payload) { + return; + } + setRowDatasetValue( + row, + 'alreadyInLibrary', + payload.row_already_in_library || (payload.row_status_in_library === '1' ? '1' : '0') + ); + setRowDatasetValue(row, 'hasCover', payload.row_has_cover || '0'); + } + + function isDisplayHiddenRow(row) { + return Boolean(row && row.classList && row.classList.contains('is-shelfmark-page-hidden')); + } + + function getDisplayEligibleRows(resultsList) { + return getScopedClassNodes(resultsList || document, 'js-shelfmark-result-row').filter(function (row) { + return !isFilterHiddenRow(row); + }).sort(function (left, right) { + return getRowOrder(left) - getRowOrder(right); + }); + } + + function buildPaginationUrl(pageNumber) { + var url = buildUrl(window.location.href); + if (!url) { + return ''; + } + url.searchParams.set('shelfmark_page', String(pageNumber)); + return url.toString(); + } + + function updatePaginationFooter(resultsList, visibleCount) { + var footer = document.querySelector('.js-shelfmark-pagination-footer'); + if (!footer) { + return; + } + var currentPage = getConfiguredDisplayPage(resultsList); + var pageSize = getConfiguredPageSize(resultsList); + var eligibleRows = getDisplayEligibleRows(resultsList); + var isExhausted = !getNextTopUpPage(resultsList) && !isTopUpPending(resultsList) && !hasPendingProgressiveRows(resultsList); + var knownTotalPages = (isExhausted && pageSize > 0) + ? Math.max(1, Math.ceil(eligibleRows.length / pageSize)) + : null; + var pageIndicator = footer.querySelector('.js-shelfmark-page-indicator'); + var prevNodes = toArray(footer.querySelectorAll('.shelfmark-pagination-footer__page-link')).filter(function (node) { + return node.textContent.indexOf('Previous page') !== -1; + }); + var nextNodes = toArray(footer.querySelectorAll('.shelfmark-pagination-footer__page-link')).filter(function (node) { + return node.textContent.indexOf('Next page') !== -1; + }); + var hasPrevious = currentPage > 1; + var requiredCount = getRequiredDisplayRowCount(resultsList); + var hasNext = eligibleRows.length > requiredCount || !isExhausted; + + if (pageIndicator) { + pageIndicator.textContent = knownTotalPages + ? ('Page ' + currentPage + ' of ' + knownTotalPages) + : ('Page ' + currentPage); + } + + prevNodes.forEach(function (node) { + if (node.tagName === 'A') { + node.setAttribute('href', buildPaginationUrl(Math.max(1, currentPage - 1))); + } + node.classList.toggle('disabled', !hasPrevious); + node.setAttribute('aria-disabled', hasPrevious ? 'false' : 'true'); + }); + + nextNodes.forEach(function (node) { + if (node.tagName === 'A') { + node.setAttribute('href', buildPaginationUrl(currentPage + 1)); + } + node.classList.toggle('disabled', !hasNext); + node.setAttribute('aria-disabled', hasNext ? 'false' : 'true'); + }); + } + + function buildRowIdentityKey(provider, providerId) { + var normalizedProvider = toOptionalText(provider).toLowerCase(); + var normalizedProviderId = toOptionalText(providerId); + if (!normalizedProvider || !normalizedProviderId) { + return ''; + } + return normalizedProvider + ':' + normalizedProviderId; + } + + function getRenderedRowIdentityKeys(resultsList) { + return new Set( + toArray((resultsList || document).querySelectorAll('.js-shelfmark-result-row')).map(function (row) { + return buildRowIdentityKey( + row && row.dataset ? row.dataset.provider : '', + row && row.dataset ? row.dataset.providerId : '' + ); + }).filter(Boolean) + ); + } + + function getNextRowIndex(resultsList) { + var maxIndex = -1; + toArray((resultsList || document).querySelectorAll('.js-shelfmark-result-row')).forEach(function (row) { + var parsed = toOptionalNumber(row && row.dataset ? row.dataset.rowIndex : null); + if (parsed !== null && parsed > maxIndex) { + maxIndex = parsed; + } + }); + return maxIndex + 1; + } + + function buildTopUpRequestUrl(baseUrl, nextPage) { + var url = buildUrl(baseUrl); + if (!url) { + return ''; + } + url.searchParams.set('shelfmark_source_page', String(nextPage)); + return url.toString(); + } + + function canTopUpResults(resultsList, filledCount) { + var requiredCount = getRequiredDisplayRowCount(resultsList); + if (!resultsList || !requiredCount || filledCount >= requiredCount) { + return false; + } + var nextPage = getNextTopUpPage(resultsList); + if (topUpInFlight || !nextPage || hasFetchedTopUpPage(nextPage)) { + return false; + } + return Boolean(getTopUpUrl(resultsList)); + } + + function hasPendingProgressiveRows(resultsList) { + if (!resultsList) { + return false; + } + if (rowEnrichmentInFlight > 0 || rowEnrichmentQueue.length > 0) { + return true; + } + return getScopedClassNodes(resultsList, 'js-shelfmark-result-row').some(function (row) { + var state = getProgressiveRowState(row); + return state === 'queued' || state === 'loading'; + }); + } + + function appendTopUpRows(resultsList, rows) { + if (!resultsList || !rows || !rows.length || typeof document.createElement !== 'function') { + return 0; + } + + var appended = 0; + var renderedKeys = getRenderedRowIdentityKeys(resultsList); + var nextRowIndex = getNextRowIndex(resultsList); + + rows.forEach(function (rowPayload) { + var identityKey = buildRowIdentityKey(rowPayload.provider, rowPayload.provider_id); + if (identityKey && renderedKeys.has(identityKey)) { + return; + } + + var row = document.createElement('article'); + row.className = rowPayload.row_class_name || 'shelfmark-result-card js-shelfmark-result-row'; + row.setAttribute('role', 'listitem'); + row.dataset.rowIndex = String(nextRowIndex); + nextRowIndex += 1; + + if (rowPayload.provider) { + row.dataset.provider = rowPayload.provider; + } + if (rowPayload.provider_id) { + row.dataset.providerId = rowPayload.provider_id; + } + if (rowPayload.row_status_provider) { + row.dataset.statusProvider = rowPayload.row_status_provider; + row.dataset.statusProviderId = rowPayload.row_status_provider_id || ''; + row.dataset.statusInLibrary = rowPayload.row_status_in_library || '0'; + } + if (rowPayload.library_book_url) { + row.dataset.libraryBookUrl = rowPayload.library_book_url; + row.dataset.libraryBookTitle = rowPayload.library_book_title || ''; + } + if (rowPayload.row_enrichment_url) { + row.dataset.rowEnrichUrl = rowPayload.row_enrichment_url; + } + applyRowStateDatasetValues(row, rowPayload); + row.innerHTML = typeof rowPayload.html === 'string' ? rowPayload.html : ''; + resultsList.appendChild(row); + if (identityKey) { + renderedKeys.add(identityKey); + } + appended += 1; + initActions(row); + initDetailModal(row); + initProgressiveEnrichment(row); + initBatchToolbar(row); + }); + + if (appended > 0) { + updateProgressiveCounts(); + } + + return appended; + } + + function ensurePageFilled() { + var resultsList = getResultsList(); + var filledCount = getDisplayEligibleRows(resultsList).length; + + if (!canTopUpResults(resultsList, filledCount)) { + updateProgressiveCounts(); + return Promise.resolve(); + } + + var nextPage = getNextTopUpPage(resultsList); + var requestUrl = buildTopUpRequestUrl(getTopUpUrl(resultsList), nextPage); + if (!requestUrl) { + updateProgressiveCounts(); + return Promise.resolve(); + } + + topUpInFlight = true; + fetchedTopUpPages.add(nextPage); + setTopUpPending(resultsList, true); + updateProgressiveCounts(); + + return fetchJson(requestUrl, { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }, TOP_UP_TIMEOUT_MS).then(function (payload) { + if (!payload || payload.ok !== true) { + throw new Error(payload && payload.message ? payload.message : 'Shelfmark page top-up failed.'); + } + setNextTopUpPage(resultsList, payload.next_page); + appendTopUpRows(resultsList, Array.isArray(payload.rows) ? payload.rows : []); + }).catch(function () { + setNextTopUpPage(resultsList, null); + }).finally(function () { + topUpInFlight = false; + setTopUpPending(resultsList, false); + updateProgressiveCounts(); + if (canTopUpResults(resultsList, getDisplayEligibleRows(resultsList).length)) { + ensurePageFilled(); + } + }); + } + + function applyDisplayPagination(resultsList) { + if (!resultsList) { + return { + visibleCount: 0, + eligibleCount: 0, + startIndex: 0, + endIndex: 0 + }; + } + var eligibleRows = getDisplayEligibleRows(resultsList); + var pageSize = getConfiguredPageSize(resultsList); + var currentPage = getConfiguredDisplayPage(resultsList); + var startIndex = Math.max(0, (currentPage - 1) * pageSize); + var endIndex = pageSize > 0 ? startIndex + pageSize : eligibleRows.length; + + eligibleRows.forEach(function (row, index) { + var isVisibleOnPage = index >= startIndex && index < endIndex; + row.classList.toggle('is-shelfmark-page-hidden', !isVisibleOnPage); + row.setAttribute('aria-hidden', isVisibleOnPage ? 'false' : 'true'); + }); + + getScopedClassNodes(resultsList, 'js-shelfmark-result-row').filter(function (row) { + return isFilterHiddenRow(row); + }).forEach(function (row) { + row.classList.remove('is-shelfmark-page-hidden'); + }); + + return { + visibleCount: Math.max(0, Math.min(pageSize || eligibleRows.length, Math.max(0, eligibleRows.length - startIndex))), + eligibleCount: eligibleRows.length, + startIndex: startIndex, + endIndex: endIndex + }; + } + + function getRowOrder(row) { + var parsed = toOptionalNumber(row && row.dataset ? row.dataset.rowIndex : null); + return parsed === null ? Number.MAX_SAFE_INTEGER : parsed; + } + + function sortQueuedRows() { + rowEnrichmentQueue.sort(function (left, right) { + return getRowOrder(left) - getRowOrder(right); + }); + } + + function updateProgressiveCounts() { + var resultsList = getResultsList(); + applyDisplayPagination(resultsList); + var visibleCount = resultsList ? getVisibleResultRows(resultsList).length : getVisibleResultRows(document).length; + var summaryNodes = toArray(document.querySelectorAll('.js-shelfmark-page-summary')); + + updatePaginationFooter(resultsList, visibleCount); + summaryNodes.forEach(function (node) { + node.textContent = visibleCount > 0 + ? (visibleCount === 1 ? '1 shown on this page' : visibleCount + ' shown on this page') + : 'No visible results on this page'; + }); + } + + function setProgressiveRowState(row, state) { + if (!row || !row.dataset) { + return; + } + row.dataset.rowEnrichmentState = state; + } + + function getProgressiveRowState(row) { + if (!row || !row.dataset) { + return ''; + } + return toOptionalText(row.dataset.rowEnrichmentState) || ''; + } + + function queueProgressiveRow(row, options) { + var settings = options || {}; + if (!row || !row.dataset || !toOptionalText(row.dataset.rowEnrichUrl)) { + return; + } + if (isFilterHiddenRow(row)) { + return; + } + var state = getProgressiveRowState(row); + if (state === 'loading' || state === 'done' || state === 'failed') { + return; + } + if (rowEnrichmentQueue.indexOf(row) === -1) { + rowEnrichmentQueue.push(row); + sortQueuedRows(); + } + setProgressiveRowState(row, 'queued'); + if (!settings.deferDrain) { + drainProgressiveRowQueue(); + } + } + + function markProgressiveRowVisible(row) { + if (!row || !row.dataset) { + return; + } + row.dataset.rowVisible = '1'; + queueProgressiveRow(row); + } + + function getRowEnrichmentObserver() { + if (typeof window.IntersectionObserver !== 'function') { + return null; + } + if (!rowEnrichmentObserver) { + rowEnrichmentObserver = new window.IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry || !entry.target) { + return; + } + if (entry.isIntersecting || entry.intersectionRatio > 0) { + markProgressiveRowVisible(entry.target); + } + }); + }, { + rootMargin: ROW_ENRICH_ROOT_MARGIN, + threshold: 0.01 + }); + } + return rowEnrichmentObserver; + } + + function applyProgressiveRowAttrs(row, payload) { + if (!row || !payload) { + return; + } + if (payload.row_class_name) { + var preservedClasses = [ + 'is-batch-eligible', + 'is-batch-selected', + 'is-shelfmark-handled' + ].filter(function (className) { + return row.classList.contains(className); + }); + row.className = payload.row_class_name; + preservedClasses.forEach(function (className) { + row.classList.add(className); + }); + } + + if (payload.row_status_provider) { + row.dataset.statusProvider = payload.row_status_provider; + row.dataset.statusProviderId = payload.row_status_provider_id || ''; + row.dataset.statusInLibrary = payload.row_status_in_library || '0'; + } else if (row.dataset) { + delete row.dataset.statusProvider; + delete row.dataset.statusProviderId; + delete row.dataset.statusInLibrary; + } + if (payload.library_book_url) { + row.dataset.libraryBookUrl = payload.library_book_url; + row.dataset.libraryBookTitle = payload.library_book_title || ''; + } else if (row.dataset) { + delete row.dataset.libraryBookUrl; + delete row.dataset.libraryBookTitle; + } + applyRowStateDatasetValues(row, payload); + } + + function handleProgressiveRowResponse(row, payload) { + if (!row || !payload || payload.ok !== true) { + throw new Error(payload && payload.message ? payload.message : 'Shelfmark row enrichment failed.'); + } + + applyProgressiveRowAttrs(row, payload); + if (typeof payload.html === 'string') { + row.innerHTML = payload.html; + } + if (payload.matches_filters === false) { + row.classList.add('is-shelfmark-filter-hidden'); + row.setAttribute('aria-hidden', 'true'); + } else { + row.classList.remove('is-shelfmark-filter-hidden'); + row.removeAttribute('aria-hidden'); + } + + if (row.dataset) { + delete row.dataset.rowEnrichUrl; + delete row.dataset.rowVisible; + } + row.classList.remove('shelfmark-result-card--refining'); + setProgressiveRowState(row, 'done'); + initActions(row); + initDetailModal(row); + initBatchToolbar(row); + updateProgressiveCounts(); + syncBatchUi(document); + ensurePageFilled(); + } + + function startProgressiveRowEnrichment(row) { + var url = toOptionalText(row && row.dataset ? row.dataset.rowEnrichUrl : ''); + if (!row || !url) { + return Promise.resolve(); + } + + setProgressiveRowState(row, 'loading'); + row.classList.add('shelfmark-result-card--refining'); + rowEnrichmentInFlight += 1; + + return fetchJson(url, { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }, ROW_ENRICH_TIMEOUT_MS).then(function (payload) { + handleProgressiveRowResponse(row, payload); + }).catch(function () { + row.classList.remove('shelfmark-result-card--refining'); + setProgressiveRowState(row, 'failed'); + }).finally(function () { + rowEnrichmentInFlight = Math.max(0, rowEnrichmentInFlight - 1); + rowEnrichmentQueue = rowEnrichmentQueue.filter(function (queuedRow) { + return queuedRow !== row; + }); + drainProgressiveRowQueue(); + }); + } + + function drainProgressiveRowQueue() { + if (rowEnrichmentInFlight >= ROW_ENRICH_MAX_CONCURRENCY) { + return; + } + sortQueuedRows(); + while (rowEnrichmentInFlight < ROW_ENRICH_MAX_CONCURRENCY && rowEnrichmentQueue.length) { + var nextRow = rowEnrichmentQueue.shift(); + if (!nextRow || (typeof nextRow.isConnected === 'boolean' && !nextRow.isConnected) || isFilterHiddenRow(nextRow)) { + continue; + } + if (getProgressiveRowState(nextRow) === 'loading' || getProgressiveRowState(nextRow) === 'done') { + continue; + } + startProgressiveRowEnrichment(nextRow); + } + } + + function initProgressiveEnrichment(root) { + var scope = root || document; + var observer = getRowEnrichmentObserver(); + + getProgressiveRows(scope).forEach(function (row) { + if (boundProgressiveRows.has(row)) { + return; + } + boundProgressiveRows.add(row); + setProgressiveRowState(row, 'pending'); + if (observer) { + observer.observe(row); + } + queueProgressiveRow(row, { deferDrain: true }); + }); + + drainProgressiveRowQueue(); + updateProgressiveCounts(); + if (!hasPendingProgressiveRows(getResultsList())) { + ensurePageFilled(); + } + } + + function initActions(root) { + var requestFlow = getFlow(); + if (!requestFlow) { + return; + } + + var scope = root || document; + var actions = toArray(scope.querySelectorAll('.js-shelfmark-action')); + var statusNodes = toArray(scope.querySelectorAll('.js-shelfmark-request-status')); + + if (!actions.length && !statusNodes.length) { + return; + } + + var baseUrl = ''; + if (actions.length) { + baseUrl = actions[0].dataset.baseUrl || ''; + } + if (!baseUrl && statusNodes.length) { + baseUrl = statusNodes[0].dataset.baseUrl || ''; + } + if (!baseUrl) { + return; + } + + actions.forEach(function (node) { + var payload = parseJson(node.dataset.requestPayload); + if (payload) { + attachRequestHandler(node, payload, node.dataset.openUrl || node.getAttribute('href'), statusNodes); + } + }); + + var probePromise = attachProbe(actions, baseUrl, statusNodes); + attachWorkflowStatus(scope, baseUrl, probePromise); + } + + function initDetailModal(root) { + var scope = root || document; + var detailLinks = toArray(scope.querySelectorAll('.js-shelfmark-detail-link')); + var modalNode = typeof document.getElementById === 'function' + ? document.getElementById('shelfmarkDetailModal') + : null; + var modalBody = modalNode ? modalNode.querySelector('.js-shelfmark-detail-modal-body') : null; + var modalTitle = modalNode ? modalNode.querySelector('.shelfmark-detail-modal__title') : null; + + if (!detailLinks.length || !modalNode || !modalBody || !modalTitle) { + return; + } + + registerModalHiddenHandler(modalNode, function () { + var closingFromHistory = Boolean(detailModalState && detailModalState.closingFromHistory); + var restoreUrl = detailModalState && detailModalState.searchStateUrl; + var historyPushed = Boolean(detailModalState && detailModalState.historyPushed); + resetDetailModal(modalBody, modalTitle); + if (closingFromHistory || !restoreUrl || !currentUrlHasDetailState()) { + return; + } + if (window.history && typeof window.history.back === 'function' && historyPushed) { + window.history.back(); + return; + } + if (window.history && typeof window.history.replaceState === 'function') { + window.history.replaceState(window.history.state, '', restoreUrl); + } + }); + + if (!detailHistoryBound && window && typeof window.addEventListener === 'function') { + detailHistoryBound = true; + window.addEventListener('popstate', function () { + var state = parseDetailState(window.location.href); + if (state.provider && state.providerId) { + if (modalIsOpen(modalNode)) { + return; + } + var matchingLink = findMatchingDetailLink(detailLinks, state.provider, state.providerId); + if (matchingLink) { + openDetailLink(matchingLink, { + pushHistory: false, + searchStateUrl: buildSearchStateUrl(window.location.href) + }); + } + return; + } + + if (modalIsOpen(modalNode)) { + if (!detailModalState) { + detailModalState = {}; + } + detailModalState.closingFromHistory = true; + hideModal(modalNode); + } + }); + } + + function openDetailLink(link, options) { + var settings = options || {}; + var provider = toOptionalText(link.dataset.detailProvider); + var providerId = toOptionalText(link.dataset.detailProviderId); + var searchStateUrl = settings.searchStateUrl || getModalSearchStateUrl(modalNode); + var detailStateUrl = buildSearchStateUrlWithDetail(searchStateUrl, provider, providerId); + var cachedHtml = getCachedDetailHtml(provider, providerId); + + activeDetailLink = link; + abortActiveDetailRequest(); + detailModalState = { + provider: provider, + providerId: providerId, + searchStateUrl: searchStateUrl, + detailStateUrl: detailStateUrl, + historyPushed: false, + closingFromHistory: false + }; + + if (settings.pushHistory !== false && window.history && typeof window.history.pushState === 'function') { + window.history.pushState( + { + shelfmarkDetail: { + provider: provider, + providerId: providerId + } + }, + '', + detailStateUrl + ); + detailModalState.historyPushed = true; + } + + modalTitle.textContent = link.dataset.detailTitle || 'Shelfmark Details'; + showModal(modalNode); + + if (cachedHtml) { + modalBody.innerHTML = cachedHtml; + modalBody.setAttribute('aria-busy', 'false'); + var cachedTitleSource = modalBody.querySelector('[data-detail-title]'); + if (cachedTitleSource && cachedTitleSource.dataset && cachedTitleSource.dataset.detailTitle) { + modalTitle.textContent = cachedTitleSource.dataset.detailTitle; + } + initActions(modalBody); + return; + } + + modalBody.innerHTML = '
Loading Shelfmark details…
'; + modalBody.setAttribute('aria-busy', 'true'); + + var controller = typeof AbortController === 'function' ? new AbortController() : null; + activeDetailRequest = controller; + + fetch(buildDetailModalUrl(link.href), { + credentials: 'same-origin', + headers: { + 'Accept': 'text/html', + 'X-Requested-With': 'XMLHttpRequest' + }, + signal: controller ? controller.signal : undefined + }).then(function (response) { + if (!response.ok) { + throw new Error('Shelfmark detail modal request failed.'); + } + return response.text(); + }).then(function (html) { + if (controller && controller !== activeDetailRequest) { + return; + } + rememberDetailHtml(provider, providerId, html); + modalBody.innerHTML = html; + modalBody.setAttribute('aria-busy', 'false'); + var titleSource = modalBody.querySelector('[data-detail-title]'); + if (titleSource && titleSource.dataset && titleSource.dataset.detailTitle) { + modalTitle.textContent = titleSource.dataset.detailTitle; + } + initActions(modalBody); + }).catch(function (error) { + if (error && error.name === 'AbortError') { + return; + } + if (detailModalState) { + detailModalState.closingFromHistory = true; + if ( + detailModalState.searchStateUrl && + currentUrlHasDetailState() && + window.history && + typeof window.history.replaceState === 'function' + ) { + window.history.replaceState(window.history.state, '', detailModalState.searchStateUrl); + } + } + hideModal(modalNode); + window.location.assign(link.href); + }).finally(function () { + if (!controller || activeDetailRequest === controller) { + activeDetailRequest = null; + } + }); + } + + detailLinks.forEach(function (link) { + if (boundDetailLinks.has(link)) { + return; + } + boundDetailLinks.add(link); + + link.addEventListener('click', function (event) { + if (!isPlainLeftClick(event)) { + return; + } + + event.preventDefault(); + openDetailLink(link, { + pushHistory: true, + searchStateUrl: getModalSearchStateUrl(modalNode) + }); + }); + }); + + var initialState = parseDetailState(window.location.href); + if (initialState.provider && initialState.providerId && !modalIsOpen(modalNode)) { + var initialLink = findMatchingDetailLink(detailLinks, initialState.provider, initialState.providerId); + if (initialLink) { + openDetailLink(initialLink, { + pushHistory: false, + searchStateUrl: buildSearchStateUrl(window.location.href) + }); + } else if (window.history && typeof window.history.replaceState === 'function') { + window.history.replaceState(window.history.state, '', buildSearchStateUrl(window.location.href)); + } + } + } + + function bindBatchToggleNodes(scope, toolbar) { + getScopedClassNodes(scope, 'js-shelfmark-batch-toggle').forEach(function (toggleNode) { + if (!toggleNode || toggleNode.dataset.batchBound === '1') { + return; + } + toggleNode.dataset.batchBound = '1'; + toggleNode.addEventListener('change', function () { + clearBatchMessage(toolbar); + syncBatchUi(document); + }); + }); + } + + function initBatchToolbar(root) { + var scope = root || document; + var toolbar = getBatchToolbar(document); + if (!toolbar) { + syncBatchUi(document); + return; + } + + bindBatchToggleNodes(scope, toolbar); + + if (boundBatchToolbars.has(toolbar)) { + syncBatchUi(document); + return; + } + boundBatchToolbars.add(toolbar); + + var modeNodes = getBatchModeButtons(document); + var selectVisibleNode = toolbar.querySelector('.js-shelfmark-batch-select-visible'); + var clearNode = toolbar.querySelector('.js-shelfmark-batch-clear'); + var requestNode = toolbar.querySelector('.js-shelfmark-batch-request'); + + setBatchMode(toolbar, false); + + modeNodes.forEach(function (modeNode) { + if (!modeNode || modeNode.dataset.batchModeBound === '1') { + return; + } + modeNode.dataset.batchModeBound = '1'; + modeNode.addEventListener('click', function () { + if (modeNode.disabled || toolbar.dataset.pending === '1') { + return; + } + clearBatchMessage(toolbar); + setBatchMode(toolbar, !isBatchModeEnabled(toolbar)); + syncBatchUi(document); + }); + }); + + if (selectVisibleNode) { + selectVisibleNode.addEventListener('click', function () { + clearBatchMessage(toolbar); + getSelectableBatchRows(document).forEach(function (row) { + var toggleNode = getBatchToggleNode(row); + if (toggleNode && !toggleNode.disabled) { + toggleNode.checked = true; + } + }); + syncBatchUi(document); + }); + } + + if (clearNode) { + clearNode.addEventListener('click', function () { + clearBatchMessage(toolbar); + clearBatchSelections(document); + syncBatchUi(document); + }); + } + + if (requestNode) { + requestNode.addEventListener('click', function () { + if (requestNode.disabled || toolbar.dataset.pending === '1') { + return; + } + requestSelectedRows(toolbar); + }); + } + + syncBatchUi(document); + } + + function init(root) { + initActions(root); + initDetailModal(root); + initProgressiveEnrichment(root); + initBatchToolbar(root); + } + + window.CwaShelfmarkExternalSearch = { + init: init + }; + + if (document.readyState === 'loading') { + setTimeout(init, 0); + } else { + init(); + } +}()); diff --git a/cps/static/js/shelfmark_request_flow.js b/cps/static/js/shelfmark_request_flow.js new file mode 100644 index 000000000..1b4b9473b --- /dev/null +++ b/cps/static/js/shelfmark_request_flow.js @@ -0,0 +1,800 @@ +(function (root, factory) { + var api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (root) { + root.CwaShelfmarkRequestFlow = api; + } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + var REQUEST_MODE = 'request_book'; + var REQUEST_RELEASE_MODE = 'request_release'; + var DOWNLOAD_MODE = 'download'; + var BLOCKED_MODE = 'blocked'; + var DEFAULT_PREFERRED_RELEASE_CONTENT_TYPE = 'ebook'; + var DEFAULT_PREFERRED_RELEASE_RANKING = 'seeders_desc'; + var AUDIO_FORMATS = { + aac: true, + aax: true, + aaxc: true, + flac: true, + m4a: true, + m4b: true, + mp3: true, + ogg: true, + opus: true, + wav: true + }; + var EBOOK_FORMATS = { + azw: true, + azw3: true, + cb7: true, + cba: true, + cbr: true, + cbt: true, + cbz: true, + djvu: true, + doc: true, + docx: true, + epub: true, + fb2: true, + kepub: true, + kepub_epub: true, + lit: true, + lrf: true, + mobi: true, + pdf: true, + rtf: true, + txt: true + }; + var EBOOK_FORMAT_RANK = { + epub: 0, + azw3: 1, + kepub: 2, + kepub_epub: 2, + mobi: 3, + pdf: 4 + }; + var AUDIO_FORMAT_RANK = { + m4b: 0, + mp3: 1, + aax: 2, + aaxc: 3, + m4a: 4 + }; + var MODE_RANK = { + download: 0, + request_release: 1, + request_book: 2, + blocked: 3 + }; + var MATRIX_MODES = { + download: true, + request_release: true, + blocked: true + }; + + function normalizeMode(value) { + return (value || '').toString().trim().toLowerCase(); + } + + function normalizeContentType(value) { + return normalizeMode(value) === 'audiobook' ? 'audiobook' : 'ebook'; + } + + function normalizeSource(value) { + var source = normalizeMode(value); + return source || '*'; + } + + function normalizeText(value) { + return typeof value === 'string' ? value.trim() : ''; + } + + function normalizePreferredReleaseContentType(value) { + var normalized = normalizeMode(value); + if (normalized === 'audiobook') { + return 'audiobook'; + } + return DEFAULT_PREFERRED_RELEASE_CONTENT_TYPE; + } + + function normalizePreferredReleaseRanking(value) { + var normalized = normalizeMode(value); + if (normalized === DEFAULT_PREFERRED_RELEASE_RANKING) { + return normalized; + } + return DEFAULT_PREFERRED_RELEASE_RANKING; + } + + function normalizePreferredReleaseSettings(settings) { + var raw = settings || {}; + return { + enabled: Boolean(raw.enabled), + provider: normalizeText(raw.provider), + contentType: normalizePreferredReleaseContentType(raw.contentType), + ranking: normalizePreferredReleaseRanking(raw.ranking) + }; + } + + function isPreferredReleaseWorkflowEnabled(settings) { + return Boolean(normalizePreferredReleaseSettings(settings).enabled); + } + + function resolvePreferredReleaseSource(settings, policy) { + var normalizedSettings = normalizePreferredReleaseSettings(settings); + var preferred = normalizeSource(normalizedSettings.provider); + if (preferred === '*') { + return ''; + } + var sourceModes = Array.isArray(policy && policy.source_modes) ? policy.source_modes : []; + var matched = sourceModes.find(function (entry) { + return normalizeSource(entry && entry.source) === preferred; + }); + return matched ? preferred : ''; + } + + function normalizeReleaseFormat(value) { + return normalizeMode(value).replace(/\s+/g, '').replace(/\./g, ''); + } + + function resolveReleaseContentType(release) { + var releaseContentType = normalizeMode(release && release.content_type); + if (releaseContentType === 'ebook' || releaseContentType === 'audiobook') { + return releaseContentType; + } + + var extraContentType = normalizeMode( + release && release.extra && typeof release.extra === 'object' + ? release.extra.content_type + : '' + ); + if (extraContentType === 'ebook' || extraContentType === 'audiobook') { + return extraContentType; + } + + var format = normalizeReleaseFormat(release && release.format); + if (format && AUDIO_FORMATS[format]) { + return 'audiobook'; + } + if (format && EBOOK_FORMATS[format]) { + return 'ebook'; + } + + return ''; + } + + function releaseMatchesPreferredProvider(release, settings, policy) { + var normalizedSettings = normalizePreferredReleaseSettings(settings); + if (!normalizedSettings.provider) { + return true; + } + + var preferredSource = resolvePreferredReleaseSource(normalizedSettings, policy); + if (preferredSource) { + return normalizeSource(release && release.source) === preferredSource; + } + + return normalizeText(release && release.indexer).toLowerCase() === normalizedSettings.provider.toLowerCase(); + } + + function releaseMatchesPreferredContentType(release, settings) { + var normalizedSettings = normalizePreferredReleaseSettings(settings); + return resolveReleaseContentType(release) === normalizedSettings.contentType; + } + + function getReleaseSeeders(release) { + var raw = release && typeof release.seeders !== 'undefined' ? Number(release.seeders) : NaN; + return Number.isFinite(raw) ? raw : -1; + } + + function getReleaseFormatRank(release, settings) { + var normalizedSettings = normalizePreferredReleaseSettings(settings); + var format = normalizeReleaseFormat(release && release.format); + if (!format) { + return Number.MAX_SAFE_INTEGER; + } + if (normalizedSettings.contentType === 'audiobook') { + return Object.prototype.hasOwnProperty.call(AUDIO_FORMAT_RANK, format) + ? AUDIO_FORMAT_RANK[format] + : 999; + } + return Object.prototype.hasOwnProperty.call(EBOOK_FORMAT_RANK, format) + ? EBOOK_FORMAT_RANK[format] + : 999; + } + + function comparePreferredReleases(left, right, settings) { + var seederDelta = getReleaseSeeders(right) - getReleaseSeeders(left); + if (seederDelta !== 0) { + return seederDelta; + } + + var formatDelta = getReleaseFormatRank(left, settings) - getReleaseFormatRank(right, settings); + if (formatDelta !== 0) { + return formatDelta; + } + + var leftTitle = normalizeText(left && left.title).toLowerCase(); + var rightTitle = normalizeText(right && right.title).toLowerCase(); + if (leftTitle < rightTitle) { + return -1; + } + if (leftTitle > rightTitle) { + return 1; + } + return 0; + } + + function selectPreferredRelease(releases, settings, policy) { + var normalizedSettings = normalizePreferredReleaseSettings(settings); + if (!normalizedSettings.enabled) { + return null; + } + + var matches = (Array.isArray(releases) ? releases : []).filter(function (release) { + return releaseMatchesPreferredProvider(release, normalizedSettings, policy) + && releaseMatchesPreferredContentType(release, normalizedSettings); + }); + + if (!matches.length) { + return null; + } + + return matches.slice().sort(function (left, right) { + return comparePreferredReleases(left, right, normalizedSettings); + })[0] || null; + } + + function buildPreferredReleaseRequestPayload(requestPayload, release, settings) { + var normalizedSettings = normalizePreferredReleaseSettings(settings); + var basePayload = requestPayload && typeof requestPayload === 'object' ? requestPayload : {}; + var bookData = basePayload.book_data && typeof basePayload.book_data === 'object' + ? Object.assign({}, basePayload.book_data) + : {}; + var normalizedRelease = release && typeof release === 'object' ? release : {}; + var releaseTitle = normalizeText(normalizedRelease.title); + + if (normalizedSettings.contentType) { + bookData.content_type = normalizedSettings.contentType; + } + + return { + book_data: bookData, + note: basePayload.note, + on_behalf_of_user_id: basePayload.on_behalf_of_user_id, + release_data: { + source: normalizeSource(normalizedRelease.source), + source_id: normalizeText(normalizedRelease.source_id || normalizedRelease.id), + title: releaseTitle || bookData.title || 'Unknown title', + author: bookData.author, + year: bookData.year, + format: normalizeReleaseFormat(normalizedRelease.format) || normalizeText(normalizedRelease.format), + size: normalizedRelease.size, + size_bytes: normalizedRelease.size_bytes, + download_url: normalizedRelease.download_url, + protocol: normalizedRelease.protocol, + indexer: normalizedRelease.indexer, + seeders: typeof normalizedRelease.seeders === 'number' + ? normalizedRelease.seeders + : (Number.isFinite(Number(normalizedRelease.seeders)) ? Number(normalizedRelease.seeders) : undefined), + extra: normalizedRelease.extra, + preview: bookData.preview, + content_type: normalizedSettings.contentType, + series_name: bookData.series_name, + series_position: bookData.series_position, + series_count: bookData.series_count, + subtitle: bookData.subtitle + }, + context: { + source: normalizeSource(normalizedRelease.source), + content_type: normalizedSettings.contentType, + request_level: 'release' + } + }; + } + + function buildOpenState(hint, label, buttonClass, iconClass) { + return { + mode: 'open', + label: label || 'Open in Shelfmark', + hint: hint, + buttonClass: buttonClass || 'btn-default', + iconClass: iconClass || 'glyphicon glyphicon-new-window' + }; + } + + function buildRequestState(hint) { + return { + mode: 'request', + label: 'Request in Shelfmark', + hint: hint || '', + buttonClass: 'btn-primary', + iconClass: 'glyphicon glyphicon-send' + }; + } + + function buildQueuedState(hint) { + return { + mode: 'open', + label: 'In queue', + hint: hint || '', + buttonClass: 'btn-warning', + iconClass: 'glyphicon glyphicon-time' + }; + } + + function buildCheckingState(hint) { + return { + mode: 'open', + label: 'Checking...', + hint: hint || '', + buttonClass: 'btn-default', + iconClass: 'glyphicon glyphicon-time' + }; + } + + function createBrowserError(message, options) { + var error = new Error(message || 'Shelfmark request flow failed.'); + var data = options || {}; + error.name = 'ShelfmarkBrowserError'; + error.kind = data.kind || 'unknown'; + error.status = typeof data.status === 'number' ? data.status : null; + error.payload = data.payload || null; + error.code = data.code || null; + error.requiredMode = data.requiredMode || null; + error.isShelfmarkBrowserError = true; + return error; + } + + function capModeToCeiling(mode, ceiling) { + var modeRank = MODE_RANK[mode]; + var ceilingRank = MODE_RANK[ceiling]; + if (typeof modeRank !== 'number' || typeof ceilingRank !== 'number') { + return mode; + } + return modeRank < ceilingRank ? ceiling : mode; + } + + function normalizeReleaseResultMode(policy, source, mode) { + if (mode !== REQUEST_MODE) { + return mode; + } + var normalizedSource = normalizeSource(source); + var sourceModes = Array.isArray(policy && policy.source_modes) ? policy.source_modes : []; + var sourceMode = sourceModes.find(function (entry) { + return normalizeSource(entry && entry.source) === normalizedSource; + }); + return sourceMode && sourceMode.browse_results_are_releases ? REQUEST_RELEASE_MODE : mode; + } + + function normalizeRuleSource(value) { + if (typeof value !== 'string') { + return null; + } + var normalized = normalizeMode(value); + if (!normalized || normalized === 'any') { + return '*'; + } + return normalized; + } + + function normalizeRuleContentType(value) { + if (typeof value !== 'string') { + return null; + } + var normalized = normalizeMode(value); + if (!normalized || normalized === 'any' || normalized === '*') { + return '*'; + } + return normalizeContentType(normalized); + } + + function parseMatrixMode(value) { + var normalized = normalizeMode(value); + return MATRIX_MODES[normalized] ? normalized : null; + } + + function resolveDefaultModeFromPolicy(policy, contentType) { + if (!policy || !policy.requests_enabled) { + return DOWNLOAD_MODE; + } + var defaults = policy.defaults || {}; + return normalizeMode(defaults[normalizeContentType(contentType)]) || DOWNLOAD_MODE; + } + + function resolveSourceModeFromPolicy(policy, source, contentType, options) { + var settings = options || {}; + var normalizedSource = normalizeSource(source); + var normalizedContentType = normalizeContentType(contentType); + var defaultMode = resolveDefaultModeFromPolicy(policy, normalizedContentType); + var preferSourceSpecific = Boolean(settings.preferSourceSpecific && normalizedSource !== '*'); + if (defaultMode === DOWNLOAD_MODE && (!policy || !policy.requests_enabled)) { + return DOWNLOAD_MODE; + } + + var sourceModes = Array.isArray(policy && policy.source_modes) ? policy.source_modes : []; + var sourceMode = sourceModes.find(function (entry) { + return normalizeSource(entry && entry.source) === normalizedSource; + }); + if (sourceMode && sourceMode.modes) { + var fromSource = normalizeMode(sourceMode.modes[normalizedContentType]); + if (fromSource) { + return normalizeReleaseResultMode( + policy, + normalizedSource, + preferSourceSpecific ? fromSource : capModeToCeiling(fromSource, defaultMode) + ); + } + } + + var rules = Array.isArray(policy && policy.rules) ? policy.rules : []; + var precedence = [ + [normalizedSource, normalizedContentType], + [normalizedSource, '*'], + ['*', normalizedContentType], + ['*', '*'] + ]; + + for (var i = 0; i < precedence.length; i += 1) { + var sourceMatch = precedence[i][0]; + var contentTypeMatch = precedence[i][1]; + var matchedRule = rules.find(function (rule) { + if (!rule || typeof rule !== 'object') { + return false; + } + return normalizeRuleSource(rule.source) === sourceMatch + && normalizeRuleContentType(rule.content_type) === contentTypeMatch; + }); + + if (!matchedRule || typeof matchedRule !== 'object') { + continue; + } + + var parsedMode = parseMatrixMode(matchedRule.mode); + if (!parsedMode) { + continue; + } + + return normalizeReleaseResultMode( + policy, + normalizedSource, + preferSourceSpecific && sourceMatch === normalizedSource + ? parsedMode + : capModeToCeiling(parsedMode, defaultMode) + ); + } + + return normalizeReleaseResultMode(policy, normalizedSource, defaultMode); + } + + function getRequestPayloadContentType(requestPayload) { + return normalizeContentType( + requestPayload + && requestPayload.context + && requestPayload.context.content_type + || requestPayload && requestPayload.content_type + || requestPayload && requestPayload.book_data && requestPayload.book_data.content_type + || 'ebook' + ); + } + + function getRequestPayloadSource(requestPayload) { + return normalizeSource( + requestPayload + && requestPayload.context + && requestPayload.context.source + ); + } + + function getRequestPayloadLevel(requestPayload) { + var explicit = normalizeMode( + requestPayload + && requestPayload.context + && requestPayload.context.request_level + ); + if (explicit) { + return explicit; + } + return requestPayload && requestPayload.release_data ? 'release' : 'book'; + } + + function describeRequiredMode(mode) { + if (mode === REQUEST_RELEASE_MODE) { + return 'Shelfmark policy for this result requires selecting a concrete release in Shelfmark before requesting.'; + } + if (mode === DOWNLOAD_MODE) { + return 'Shelfmark policy for this result routes directly to download/release handling instead of a book-level request.'; + } + if (mode === BLOCKED_MODE) { + return 'Shelfmark policy blocks direct requests for this result.'; + } + return 'Shelfmark policy does not allow a direct book-level request for this result.'; + } + + function getOrigin(value, currentOrigin) { + if (!value) { + return null; + } + try { + return new URL(value, currentOrigin || 'http://localhost').origin; + } catch (err) { + return null; + } + } + + function isDirectRequestViable(baseUrl, currentOrigin) { + var browserOrigin = getOrigin(currentOrigin, currentOrigin); + var shelfmarkOrigin = getOrigin(baseUrl, currentOrigin); + if (!browserOrigin || !shelfmarkOrigin) { + return false; + } + return browserOrigin === shelfmarkOrigin; + } + + function resolveProbeState(options) { + var currentOrigin = options && options.currentOrigin ? options.currentOrigin : null; + var baseUrl = options && options.baseUrl ? options.baseUrl : null; + var authPayload = options && options.authPayload ? options.authPayload : null; + var policyPayload = options && options.policyPayload ? options.policyPayload : null; + var error = options && options.error ? options.error : null; + + if (!isDirectRequestViable(baseUrl, currentOrigin)) { + return { + kind: 'cross_origin', + bannerLevel: 'alert-warning', + bannerText: 'Direct Request in Shelfmark needs a same-origin or reverse-proxied browser URL for Shelfmark. Open in Shelfmark remains the safe fallback for this setup.', + actionState: buildOpenState('This CWA page and the browser-facing Shelfmark URL are on different origins. Configure Shelfmark Browser URL to the same-origin or reverse-proxied Shelfmark address if you want direct request buttons here.') + }; + } + + if (error) { + if (error.kind === 'timeout') { + return { + kind: 'probe_timeout', + bannerLevel: 'alert-warning', + bannerText: 'Timed out while checking your Shelfmark session. Open in Shelfmark to continue or retry after the network/proxy recovers.', + actionState: buildOpenState('Shelfmark did not respond before the browser timed out. Open Shelfmark directly or retry after the network/proxy recovers.') + }; + } + + if (error.kind === 'auth' || error.status === 401) { + return { + kind: 'probe_auth_failed', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark requires a live login in this browser before requests can be attributed to your user.', + actionState: buildOpenState('Shelfmark is not currently signed in for this browser. Log in there first, then return here to retry.') + }; + } + + if (error.status === 403) { + if (error.code === 'user_identity_unavailable') { + return { + kind: 'probe_identity_unavailable', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark is signed in in this browser, but that session is not mapped to a requestable Shelfmark user yet.', + actionState: buildOpenState('Shelfmark did not expose a request user identity for this browser session. Re-open Shelfmark, confirm the synced user is logged in there, and retry.') + }; + } + if (error.code === 'requests_unavailable') { + return { + kind: 'probe_requests_unavailable', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark reports that the request workflow is unavailable for the current auth mode or policy.', + actionState: buildOpenState('Shelfmark reports that the request workflow is unavailable for the current auth mode or policy.') + }; + } + return { + kind: 'probe_policy_failed', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark rejected the direct request workflow for this browser session or policy configuration.', + actionState: buildOpenState('Shelfmark rejected direct request actions for this browser session or policy. Open Shelfmark directly to review the current policy and login state.') + }; + } + + if (error.kind === 'network') { + return { + kind: 'probe_network_failed', + bannerLevel: 'alert-warning', + bannerText: 'The browser could not reach Shelfmark directly. Open in Shelfmark remains the safe fallback.', + actionState: buildOpenState('The browser could not reach Shelfmark directly. This usually means the network or proxy blocked the request, or the apps are not deployed under a compatible same-origin setup.') + }; + } + + return { + kind: 'probe_failed', + bannerLevel: 'alert-warning', + bannerText: error.message || 'Shelfmark request actions could not be verified from this browser.', + actionState: buildOpenState('Shelfmark request availability could not be confirmed from this browser. Open Shelfmark directly to continue.') + }; + } + + if (!authPayload || !authPayload.authenticated) { + var authRequired = authPayload ? Boolean(authPayload.auth_required) : true; + return { + kind: 'auth_required', + bannerLevel: 'alert-warning', + bannerText: authRequired + ? 'Shelfmark login is required in this browser before requests can be attributed to your user.' + : 'Shelfmark is not currently authenticated in this browser.', + actionState: buildOpenState(authRequired + ? 'Shelfmark is not currently signed in for this browser. Log in there first, then return here to retry.' + : 'Shelfmark did not report an active authenticated browser session.') + }; + } + + if (!policyPayload || !policyPayload.requests_enabled) { + return { + kind: 'policy_unavailable', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark is signed in, but the request workflow is disabled by the current Shelfmark policy.', + actionState: buildOpenState('Shelfmark is signed in, but the request workflow is disabled by the current Shelfmark policy.') + }; + } + + if (!options || !options.requestPayload) { + return { + kind: 'requestable', + bannerLevel: 'alert-success', + bannerText: 'Shelfmark session detected. Request buttons are enabled where the current policy allows direct book-level requests.', + actionState: buildRequestState('') + }; + } + + var requestPayload = options.requestPayload; + var effectiveMode = resolveSourceModeFromPolicy( + policyPayload, + getRequestPayloadSource(requestPayload), + getRequestPayloadContentType(requestPayload) + ); + var requestLevel = getRequestPayloadLevel(requestPayload); + if (effectiveMode !== REQUEST_MODE || requestLevel !== 'book') { + return { + kind: 'policy_blocked', + bannerLevel: 'alert-warning', + bannerText: describeRequiredMode(effectiveMode), + actionState: buildOpenState( + describeRequiredMode(effectiveMode), + 'Open in Shelfmark', + 'btn-default', + 'glyphicon glyphicon-new-window' + ) + }; + } + + return { + kind: 'requestable', + bannerLevel: 'alert-success', + bannerText: 'Shelfmark session detected. Direct requests will be attributed in Shelfmark as the current Shelfmark user.', + actionState: buildRequestState('') + }; + } + + function resolveRequestOutcome(options) { + var response = options && options.response ? options.response : null; + if (options && options.success) { + if (response && normalizeMode(response.kind) === 'download' && normalizeMode(response.status) === 'queued') { + return { + kind: 'release_queued', + bannerLevel: 'alert-success', + bannerText: 'Preferred release queued in Shelfmark.', + actionState: buildQueuedState('Shelfmark has queued the preferred release.') + }; + } + return { + kind: 'request_created', + bannerLevel: 'alert-success', + bannerText: 'Request sent to Shelfmark.', + actionState: buildOpenState( + '', + 'Requested', + 'btn-success', + 'glyphicon glyphicon-ok' + ) + }; + } + + var error = options && options.error ? options.error : null; + if (error) { + if (error.kind === 'timeout') { + return { + kind: 'request_confirmation_pending', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark has not confirmed this request yet. CWA will keep checking for queue or library updates.', + actionState: buildCheckingState('Shelfmark has not confirmed this request yet. CWA will keep checking for queue or library updates before you need to retry.') + }; + } + + if (error.kind === 'auth' || error.status === 401) { + return { + kind: 'request_auth_failed', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark no longer has a valid login in this browser, so the request could not be attributed.', + actionState: buildOpenState('Shelfmark login has expired or is missing for this browser. Log in there first, then retry the request.') + }; + } + + if (error.status === 403) { + if (error.code === 'user_identity_unavailable') { + return { + kind: 'request_identity_failed', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark is signed in, but that browser session is not mapped to a requestable Shelfmark user yet.', + actionState: buildOpenState('Shelfmark did not expose a request user identity for this browser session. Open Shelfmark directly, confirm the synced user is logged in there, and retry.') + }; + } + if (error.requiredMode === REQUEST_RELEASE_MODE || error.code === 'policy_requires_download') { + return { + kind: 'request_requires_release', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark policy requires a concrete release or download path for this result, so CWA cannot submit a direct book request here.', + actionState: buildOpenState('Shelfmark policy requires a concrete release or download path for this result. Open Shelfmark to choose the release there.') + }; + } + return { + kind: 'request_policy_failed', + bannerLevel: 'alert-warning', + bannerText: error.message || 'Shelfmark rejected the request under the current policy or browser session.', + actionState: buildOpenState('Shelfmark rejected the request under the current policy or browser session. Open Shelfmark directly to review policy and login state.') + }; + } + + if (error.kind === 'network') { + return { + kind: 'request_network_failed', + bannerLevel: 'alert-danger', + bannerText: 'The browser could not reach Shelfmark directly, so the request was not created here.', + actionState: buildOpenState('The browser could not reach Shelfmark directly. This usually means the network or proxy blocked the request, or the apps are not deployed under a compatible same-origin setup.') + }; + } + + return { + kind: 'request_failed', + bannerLevel: 'alert-danger', + bannerText: error.message || 'Shelfmark request creation failed.', + actionState: buildOpenState('Shelfmark request creation failed from this browser. Open Shelfmark directly to retry or inspect the error state.') + }; + } + + return { + kind: 'request_failed', + bannerLevel: 'alert-danger', + bannerText: 'Shelfmark request creation failed.', + actionState: buildOpenState('Shelfmark request creation failed from this browser. Open Shelfmark directly to retry or inspect the error state.') + }; + } + + return { + REQUEST_MODE: REQUEST_MODE, + REQUEST_RELEASE_MODE: REQUEST_RELEASE_MODE, + DOWNLOAD_MODE: DOWNLOAD_MODE, + BLOCKED_MODE: BLOCKED_MODE, + normalizeMode: normalizeMode, + normalizeContentType: normalizeContentType, + normalizeSource: normalizeSource, + normalizePreferredReleaseSettings: normalizePreferredReleaseSettings, + isPreferredReleaseWorkflowEnabled: isPreferredReleaseWorkflowEnabled, + resolvePreferredReleaseSource: resolvePreferredReleaseSource, + resolveReleaseContentType: resolveReleaseContentType, + releaseMatchesPreferredProvider: releaseMatchesPreferredProvider, + releaseMatchesPreferredContentType: releaseMatchesPreferredContentType, + selectPreferredRelease: selectPreferredRelease, + buildPreferredReleaseRequestPayload: buildPreferredReleaseRequestPayload, + buildOpenState: buildOpenState, + buildRequestState: buildRequestState, + buildQueuedState: buildQueuedState, + buildCheckingState: buildCheckingState, + createBrowserError: createBrowserError, + resolveDefaultModeFromPolicy: resolveDefaultModeFromPolicy, + resolveSourceModeFromPolicy: resolveSourceModeFromPolicy, + getRequestPayloadContentType: getRequestPayloadContentType, + getRequestPayloadSource: getRequestPayloadSource, + getRequestPayloadLevel: getRequestPayloadLevel, + describeRequiredMode: describeRequiredMode, + isDirectRequestViable: isDirectRequestViable, + resolveProbeState: resolveProbeState, + resolveRequestOutcome: resolveRequestOutcome + }; +})); diff --git a/cps/static/js/shelfmark_settings.js b/cps/static/js/shelfmark_settings.js new file mode 100644 index 000000000..15afa07e5 --- /dev/null +++ b/cps/static/js/shelfmark_settings.js @@ -0,0 +1,331 @@ +(function (root, factory) { + var api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (root) { + root.CwaShelfmarkSettings = api; + } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + var CUSTOM_PROVIDER_VALUE = '__custom__'; + + function toText(value) { + return typeof value === 'string' ? value.trim() : ''; + } + + function toArray(value) { + return Array.prototype.slice.call(value || []); + } + + function dedupeProviderOptions(options) { + var seen = new Set(); + return (Array.isArray(options) ? options : []).filter(function (option) { + var value = toText(option && option.value); + if (!value || seen.has(value)) { + return false; + } + seen.add(value); + return true; + }).map(function (option) { + return { + value: toText(option && option.value), + label: toText(option && option.label) || toText(option && option.value), + kind: toText(option && option.kind) || 'source' + }; + }); + } + + function clearChildren(node) { + if (!node) { + return; + } + while (node.firstChild) { + node.removeChild(node.firstChild); + } + } + + function appendOption(selectNode, value, label) { + var optionNode = document.createElement('option'); + optionNode.value = value; + optionNode.textContent = label; + selectNode.appendChild(optionNode); + } + + function optionValues(selectNode) { + return toArray(selectNode && selectNode.options).map(function (option) { + return toText(option.value); + }); + } + + function showManualProviderInput(selectNode, manualGroup, manualInput, hiddenInput) { + var selectedValue = toText(selectNode && selectNode.value); + var isCustom = selectedValue === CUSTOM_PROVIDER_VALUE; + if (manualGroup && manualGroup.classList) { + manualGroup.classList.toggle('is-hidden', !isCustom); + } + if (!hiddenInput) { + return; + } + if (isCustom) { + hiddenInput.value = toText(manualInput && manualInput.value); + return; + } + hiddenInput.value = selectedValue; + } + + function populatePreferredProviderSelect(selectNode, manualGroup, manualInput, hiddenInput, options) { + if (!selectNode || !hiddenInput) { + return; + } + + var currentValue = toText(hiddenInput.value); + var normalizedOptions = dedupeProviderOptions(options); + + clearChildren(selectNode); + appendOption(selectNode, '', 'Any compatible source or indexer'); + normalizedOptions.forEach(function (option) { + appendOption(selectNode, option.value, option.label); + }); + appendOption(selectNode, CUSTOM_PROVIDER_VALUE, 'Custom source or indexer…'); + + if (!currentValue) { + selectNode.value = ''; + } else if (optionValues(selectNode).indexOf(currentValue) !== -1) { + selectNode.value = currentValue; + } else { + selectNode.value = CUSTOM_PROVIDER_VALUE; + if (manualInput) { + manualInput.value = currentValue; + } + } + + showManualProviderInput(selectNode, manualGroup, manualInput, hiddenInput); + } + + function buildDiagnosticSummary(response) { + if (response && response.success) { + return 'Shelfmark connection test completed.'; + } + return 'Shelfmark connection test needs attention.'; + } + + function diagnosticTone(checks) { + var statuses = (Array.isArray(checks) ? checks : []).map(function (check) { + return toText(check && check.status); + }); + if (statuses.indexOf('error') !== -1) { + return 'danger'; + } + if (statuses.indexOf('warning') !== -1) { + return 'warning'; + } + return 'success'; + } + + function renderDiagnostics(container, response) { + if (!container) { + return; + } + + clearChildren(container); + container.classList.remove('is-hidden', 'shelfmark-settings-diagnostics--success', 'shelfmark-settings-diagnostics--warning', 'shelfmark-settings-diagnostics--danger'); + + var checks = Array.isArray(response && response.checks) ? response.checks : []; + var tone = diagnosticTone(checks); + container.classList.add('shelfmark-settings-diagnostics--' + tone); + + var summary = document.createElement('div'); + summary.className = 'shelfmark-settings-diagnostics__summary'; + summary.textContent = buildDiagnosticSummary(response); + container.appendChild(summary); + + var list = document.createElement('ul'); + list.className = 'shelfmark-settings-diagnostics__list'; + checks.forEach(function (check) { + var item = document.createElement('li'); + item.className = 'shelfmark-settings-diagnostics__item shelfmark-settings-diagnostics__item--' + (toText(check.status) || 'warning'); + + var label = document.createElement('strong'); + label.className = 'shelfmark-settings-diagnostics__label'; + label.textContent = toText(check.label); + item.appendChild(label); + + var summaryNode = document.createElement('span'); + summaryNode.className = 'shelfmark-settings-diagnostics__text'; + summaryNode.textContent = toText(check.summary); + item.appendChild(summaryNode); + + var detail = toText(check.detail); + if (detail) { + var detailNode = document.createElement('div'); + detailNode.className = 'shelfmark-settings-diagnostics__detail'; + detailNode.textContent = detail; + item.appendChild(detailNode); + } + + list.appendChild(item); + }); + container.appendChild(list); + + var providerMessage = toText(response && response.provider_options_message); + if (providerMessage) { + var note = document.createElement('div'); + note.className = 'shelfmark-settings-diagnostics__note'; + note.textContent = providerMessage; + container.appendChild(note); + } + } + + function renderDiagnosticError(container, message) { + renderDiagnostics(container, { + success: false, + checks: [ + { + label: 'Shelfmark connection', + status: 'error', + summary: toText(message) || 'Shelfmark connection test failed.' + } + ], + provider_options_message: '' + }); + } + + function collectDiagnosticPayload(root) { + var panel = root || document; + var hiddenProvider = panel.querySelector('#config_shelfmark_preferred_release_provider'); + var providerSelect = panel.querySelector('#config_shelfmark_preferred_release_provider_select'); + var providerManual = panel.querySelector('#config_shelfmark_preferred_release_provider_manual'); + var providerManualGroup = panel.querySelector('#config_shelfmark_preferred_release_provider_manual_group'); + + showManualProviderInput(providerSelect, providerManualGroup, providerManual, hiddenProvider); + + return { + config_shelfmark_search: Boolean(panel.querySelector('#config_shelfmark_search') && panel.querySelector('#config_shelfmark_search').checked), + config_shelfmark_url: toText(panel.querySelector('#config_shelfmark_url') && panel.querySelector('#config_shelfmark_url').value), + config_shelfmark_browser_url: toText(panel.querySelector('#config_shelfmark_browser_url') && panel.querySelector('#config_shelfmark_browser_url').value), + config_shelfmark_username: toText(panel.querySelector('#config_shelfmark_username') && panel.querySelector('#config_shelfmark_username').value), + config_shelfmark_password_e: toText(panel.querySelector('#config_shelfmark_password_e') && panel.querySelector('#config_shelfmark_password_e').value), + config_shelfmark_preferred_release_content_type: toText(panel.querySelector('#config_shelfmark_preferred_release_content_type') && panel.querySelector('#config_shelfmark_preferred_release_content_type').value) || 'ebook', + config_shelfmark_preferred_release_provider: toText(hiddenProvider && hiddenProvider.value) + }; + } + + function populateFromDiagnostics(root, response) { + var panel = root || document; + var selectNode = panel.querySelector('#config_shelfmark_preferred_release_provider_select'); + var manualGroup = panel.querySelector('#config_shelfmark_preferred_release_provider_manual_group'); + var manualInput = panel.querySelector('#config_shelfmark_preferred_release_provider_manual'); + var hiddenInput = panel.querySelector('#config_shelfmark_preferred_release_provider'); + populatePreferredProviderSelect( + selectNode, + manualGroup, + manualInput, + hiddenInput, + response && response.provider_options + ); + } + + async function runDiagnostics(root, button, container) { + var panel = root || document; + var endpoint = toText(container && container.dataset ? container.dataset.diagnosticsUrl : ''); + if (!endpoint) { + renderDiagnosticError(container, 'Shelfmark diagnostics endpoint is not configured.'); + return; + } + + var payload = collectDiagnosticPayload(panel); + var originalText = button.textContent; + button.disabled = true; + button.textContent = 'Testing...'; + renderDiagnostics(container, { + success: false, + checks: [ + { + label: 'Shelfmark connection', + status: 'warning', + summary: 'Testing current Shelfmark settings...' + } + ], + provider_options_message: '' + }); + + try { + var response = await fetch(endpoint, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + var data = await response.json(); + renderDiagnostics(container, data); + populateFromDiagnostics(panel, data); + } catch (error) { + renderDiagnosticError(container, error && error.message ? error.message : 'Shelfmark connection test failed.'); + } finally { + button.disabled = false; + button.textContent = originalText; + } + } + + function init(root) { + var panel = root || document; + var container = panel.querySelector('.js-shelfmark-settings-diagnostics'); + var button = panel.querySelector('.js-test-shelfmark-connection'); + var selectNode = panel.querySelector('#config_shelfmark_preferred_release_provider_select'); + var manualGroup = panel.querySelector('#config_shelfmark_preferred_release_provider_manual_group'); + var manualInput = panel.querySelector('#config_shelfmark_preferred_release_provider_manual'); + var hiddenInput = panel.querySelector('#config_shelfmark_preferred_release_provider'); + + if (!button || !container || !selectNode || !manualInput || !hiddenInput) { + return; + } + + populatePreferredProviderSelect(selectNode, manualGroup, manualInput, hiddenInput, []); + + if (selectNode.dataset.bound !== '1') { + selectNode.dataset.bound = '1'; + selectNode.addEventListener('change', function () { + showManualProviderInput(selectNode, manualGroup, manualInput, hiddenInput); + }); + } + + if (manualInput.dataset.bound !== '1') { + manualInput.dataset.bound = '1'; + manualInput.addEventListener('input', function () { + showManualProviderInput(selectNode, manualGroup, manualInput, hiddenInput); + }); + } + + if (button.dataset.bound !== '1') { + button.dataset.bound = '1'; + button.addEventListener('click', function () { + runDiagnostics(panel, button, container); + }); + } + } + + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + init(document); + }); + } else { + init(document); + } + } + + return { + CUSTOM_PROVIDER_VALUE: CUSTOM_PROVIDER_VALUE, + collectDiagnosticPayload: collectDiagnosticPayload, + dedupeProviderOptions: dedupeProviderOptions, + init: init, + populatePreferredProviderSelect: populatePreferredProviderSelect, + renderDiagnostics: renderDiagnostics, + showManualProviderInput: showManualProviderInput + }; +})); diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index 80b2777fb..a584eb098 100755 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -67,6 +67,11 @@ def __init__(self, book_id=-1, task_message=''): def run(self, worker_thread): try: + if not ub.ensure_thumbnail_table(self.app_db_session): + self.log.info('Thumbnail table unavailable; skipping cover thumbnail generation task.') + self.self_cleanup = True + self._handleSuccess() + return if use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED: self.message = 'Scanning Books' books_with_covers = self.get_books_with_covers(self.book_id) @@ -120,6 +125,8 @@ def get_books_with_covers(book_id=-1): return books_cover def get_book_cover_thumbnails(self, book_id): + if not ub.ensure_thumbnail_table(self.app_db_session): + return [] return self.app_db_session \ .query(ub.Thumbnail) \ .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \ @@ -318,6 +325,12 @@ def __init__(self, task_message=''): ] def run(self, worker_thread): + if not ub.ensure_thumbnail_table(self.app_db_session): + self.log.info('Thumbnail table unavailable; skipping series thumbnail generation task.') + self.self_cleanup = True + self._handleSuccess() + self.app_db_session.remove() + return if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED: self.message = 'Scanning Series' all_series = self.get_series_with_four_plus_books() @@ -388,6 +401,8 @@ def get_series_books(self, series_id): .all() def get_series_thumbnails(self, series_id): + if not ub.ensure_thumbnail_table(self.app_db_session): + return [] return (self.app_db_session .query(ub.Thumbnail) .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES) @@ -529,6 +544,12 @@ def __init__(self, book_id, task_message=N_('Clearing cover thumbnail cache')): self.cache = fs.FileSystem() def run(self, worker_thread): + if not ub.ensure_thumbnail_table(self.app_db_session): + self.log.info('Thumbnail table unavailable; skipping cover thumbnail cache clear task.') + self.self_cleanup = True + self._handleSuccess() + self.app_db_session.remove() + return if self.app_db_session: if self.book_id == 0: # delete superfluous thumbnails calibre_db = db.CalibreDB(expire_on_commit=False, init=True) @@ -548,6 +569,8 @@ def run(self, worker_thread): self.app_db_session.remove() def get_thumbnails_for_book(self, book_id): + if not ub.ensure_thumbnail_table(self.app_db_session): + return [] return self.app_db_session \ .query(ub.Thumbnail) \ .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \ @@ -568,6 +591,8 @@ def delete_thumbnail(self, thumbnail): self._handleError('Error deleting book thumbnail: ' + str(ex)) def delete_all_thumbnails(self): + if not ub.ensure_thumbnail_table(self.app_db_session): + return try: self.app_db_session.query(ub.Thumbnail).filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER).delete() self.app_db_session.commit() diff --git a/cps/templates/author.html b/cps/templates/author.html index 613a3615f..31e883845 100755 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -1,153 +1,204 @@ +{% import 'image.html' as image %} +{% import 'shelfmark_external_macros.html' as shelfmark_ui with context %} {% extends "layout.html" %} +{% set shelfmark = shelfmark_runtime if shelfmark_runtime is defined and shelfmark_runtime else None %} +{% set shelfmark_loader = shelfmark_loader if shelfmark_loader is defined and shelfmark_loader else None %} +{% block header %} +{% if shelfmark_loader or author_profile %} + +{% endif %} +{% endblock %} {% block body %} -

{{title}}

- -{% if author is not none %} -
- {%if author.image_url is not none %} - {{author.name}} - {% endif %} - - {%if author.safe_about is not none %} -

{{author.safe_about|safe}}

- {% endif %} +
+
+

{{title}}

- - {{_("via")}} Goodreads -
+ {% if author_profile is not none %} +
+ {% if author_profile.image_url %} +
+ {{ author_profile.name }} +
+ {% endif %} -
-{% endif %} +
+ {% if author_profile.safe_about %} + + + {% endif %} -
- {% if author is not none %} -

{{_("In Library")}}

- {% endif %} - -
- {% for entry in entries %} -
- -
- -

{{entry.Books.title|shortentitle}}

-
-

- {% for author in entry.Books.authors %} - {% if loop.index > g.config_authors_max and g.config_authors_max != 0 %} - {% if not loop.first %} - & - {% endif %} - {{author.name.replace('|',',')|shortentitle(30)}} - {% if loop.last %} - (...) - {% endif %} - {% else %} - {% if not loop.first %} - & - {% endif %} - {{author.name.replace('|',',')|shortentitle(30)}} - {% endif %} - {% endfor %} - {% if entry.Books.data|music %} - + {% if author_profile.source_label %} +

+ {{ _("via") }} + {% if author_profile.link %} + {{ author_profile.source_label }} + {% else %} + {{ author_profile.source_label }} {% endif %}

- {% if entry.Books.series.__len__() > 0 %} -

- - {{entry.Books.series[0].name}} - - ({{entry.Books.series_index|formatfloat(2)}}) -

- {% endif %} - {% if entry.Books.ratings.__len__() > 0 %} -
- {% for number in range((entry.Books.ratings[0].rating/2)|int(2)) %} - - {% if loop.last and loop.index < 5 %} - {% for numer in range(5 - loop.index) %} - - {% endfor %} - {% endif %} - {% endfor %} -
{% endif %}
-
- {% endfor %} -
-
+
+ {% endif %} + -{% if other_books and author is not none %} -
-

{{_("More by")}} {{ author.name.replace('|',',') }}

-
- {% for entry in other_books %} -
-
- - - +
+ {% if author is not none %} +

{{_("In Library")}}

+ {% endif %} + -
-

{{entry.title|shortentitle}}

-

- {% for author in entry.authors %} - {% if loop.index > g.config_authors_max and g.config_authors_max != 0 %} - {{author.name.replace('|',',')}} - {% if loop.last %} - (...) - {% endif %} - {% else %} - {{author.name.replace('|',',')}} - {% endif %} - {% endfor %} -

- {% if entry.series.__len__() > 0 %} -

- - {{entry.series[0].name}} +

+ {% for entry in entries %} +
+
+ + + {{ image.book_cover(entry.Books, alt=(author_profile.name if author_profile else entry.Books.title)|safe) }} + {% if entry[2] == True %}{% endif %} + - ({{entry.series_index|formatfloat(2)}}) -

- {% endif %} -
- {% for number in range((entry.average_rating)|float|round|int(2)) %} - - {% if loop.last and loop.index < 5 %} - {% for numer in range(5 - loop.index) %} - - {% endfor %} +
+
+ +

{{entry.Books.title|shortentitle}}

+
+

+ {% for author in entry.Books.authors %} + {% if loop.index > g.config_authors_max and g.config_authors_max != 0 %} + {% if not loop.first %} + & + {% endif %} + {{author.name.replace('|',',')|shortentitle(30)}} + {% if loop.last %} + (...) + {% endif %} + {% else %} + {% if not loop.first %} + & + {% endif %} + {{author.name.replace('|',',')|shortentitle(30)}} + {% endif %} + {% endfor %} + {% if entry.Books.data|music %} + + {% endif %} +

+ {% if entry.Books.series.__len__() > 0 %} +

+ + {{entry.Books.series[0].name}} + + ({{entry.Books.series_index|formatfloat(2)}}) +

+ {% endif %} + {% if entry.Books.ratings.__len__() > 0 %} +
+ {% for number in range((entry.Books.ratings[0].rating/2)|int(2)) %} + + {% if loop.last and loop.index < 5 %} + {% for numer in range(5 - loop.index) %} + + {% endfor %} + {% endif %} + {% endfor %} +
{% endif %} - {% endfor %}
+ {% endfor %}
- {% endfor %} -
+
- - Goodreads - + {% if shelfmark_loader %} +
+
+
+ + {{ shelfmark_loader.loading_message }} +
+
+
+ {% endif %}
+{% endblock %} + +{% block modal %} +{{ shelfmark_ui.render_shelfmark_modal(shelfmark) }} +{% endblock %} + +{% block js %} +{{ shelfmark_ui.render_shelfmark_scripts(shelfmark) }} +{% if author_profile and author_profile.safe_about %} + +{% endif %} +{% if shelfmark_loader %} + {% endif %} {% endblock %} diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 648b5ed2e..e68b97162 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -93,6 +93,69 @@ padding: 10px 20px; font-size: 14px; } + +.shelfmark-settings-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.shelfmark-settings-diagnostics { + border-radius: 4px; + border: 1px solid #444; + background: rgba(21, 30, 38, 0.6); + padding: 12px 14px; + margin-top: 12px; +} + +.shelfmark-settings-diagnostics.is-hidden { + display: none; +} + +.shelfmark-settings-diagnostics--success { + border-color: #3c763d; +} + +.shelfmark-settings-diagnostics--warning { + border-color: #8a6d3b; +} + +.shelfmark-settings-diagnostics--danger { + border-color: #a94442; +} + +.shelfmark-settings-diagnostics__summary { + font-weight: 700; + margin-bottom: 10px; +} + +.shelfmark-settings-diagnostics__list { + list-style: none; + margin: 0; + padding: 0; +} + +.shelfmark-settings-diagnostics__item { + padding: 8px 0; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.shelfmark-settings-diagnostics__item:first-child { + border-top: 0; + padding-top: 0; +} + +.shelfmark-settings-diagnostics__label { + display: block; + margin-bottom: 4px; +} + +.shelfmark-settings-diagnostics__detail, +.shelfmark-settings-diagnostics__note { + color: #c7cbd1; + margin-top: 6px; +} {% endblock %} {% block body %} @@ -243,6 +306,74 @@

{{_('Feature Configuration')}}

+
+ + +
{{_('Optionally shows Shelfmark results beside library search. Duplicate checks stay exact: metadata.db plus exact Hardcover ID only. Direct Request in Shelfmark remains a browser-side upgrade, so it depends on a same-origin or reverse-proxied Shelfmark Browser URL, a live Shelfmark login in that browser, and Shelfmark policy allowing a direct book request. When those conditions are not met, CWA falls back to Open in Shelfmark.')}}
+
+
+
+ + +
{{_('CWA uses this exact URL for server-side Shelfmark metadata requests. Local/private Shelfmark hosts are supported here without relaxing request validation globally, but requests remain scoped to this exact host and port.')}}
+
+
+ + +
{{_('Optional. Use this when CWA reaches Shelfmark server-side via a private/internal URL, but browsers should open and request against a reverse-proxied same-origin Shelfmark URL. If blank, browser actions use Shelfmark Base URL, which will keep direct request buttons in fallback mode whenever that URL is cross-origin or not browser-reachable.')}}
+
+
+ + +
{{_('Optional when Shelfmark allows anonymous metadata search. If Shelfmark protects /api/metadata/search, configure credentials here for CWA server-side metadata search. These fields are separate from the browser-side Shelfmark login used for Request in Shelfmark. If Shelfmark is using the mounted CWA app.db for auth, users can still log into Shelfmark with their own synced CWA credentials in the browser for per-user request attribution, while CWA can use a dedicated search account here if needed.')}}
+
+
+ + +
{{_('Leave blank to keep the current stored search password unchanged.')}}
+
+
+ + +
{{_('Optional browser-side upgrade for Request in Shelfmark. Use the test button below to verify the current Shelfmark settings and load discoverable source/indexer choices. When preferred routing is unavailable, CWA falls back to the normal request flow.') }}
+
+
+
+
+ + {{_('Checks the current Shelfmark settings, request policy, release lookup support, and discoverable source/indexer options.') }} +
+ +
+
+ + + + +
{{_('Shelfmark can directly expose release sources, and some sources can expose indexers during the connection test. If Shelfmark does not expose a global indexer list, custom fallback remains available.') }}
+
+
+ + +
+
+ + +
{{_('Exact Hardcover sidecar provenance still drives the later import metadata handoff. This setting only affects request-time release choice.') }}
+
+
+
@@ -627,6 +758,7 @@

{{_('Security Settings')}}

{% endblock %} {% block js %} + +{% endif %} {{ super() }} {% if page == 'magicshelf' %} +{% endif %} +{% endblock %} diff --git a/cps/templates/requests_workspace_macros.html b/cps/templates/requests_workspace_macros.html new file mode 100644 index 000000000..71c8c6159 --- /dev/null +++ b/cps/templates/requests_workspace_macros.html @@ -0,0 +1,108 @@ +{% macro render_requests_section_header(title, subtitle, see_more_url) -%} +
+
+

{{ title }}

+

{{ subtitle }}

+
+ {% if see_more_url %} + + {{ _('See more') }} + + {% endif %} +
+{%- endmacro %} + + +{% macro render_requests_section_shell(section) -%} +
+ {{ render_requests_section_header(section.title, section.subtitle, section.see_more_url) }} +
+
+ +
+
+

{{ section.loading_message }}

+
+
+
+{%- endmacro %} + + +{% macro render_requests_candidate(candidate, preferred_release, compact=False) -%} + {% set result = candidate.result %} + {% set row_classes = [ + 'shelfmark-result-card', + 'js-shelfmark-result-row', + 'shelfmark-result-card--' ~ (result.library_state.row_class or 'info') + ] %} + {% if result.workflow_state and not result.already_in_library %} + {% set row_classes = row_classes + ['js-shelfmark-status-target'] %} + {% endif %} +
+ {% include 'shelfmark_external_result_card_inner.html' %} +
+{%- endmacro %} + + +{% macro render_requests_section(section, preferred_release) -%} +
+ {{ render_requests_section_header(section.title, section.subtitle, section.see_more_url) }} + + {% if section.groups %} +
+ {% for group in section.groups %} +
+
+
+

{{ group.title }}

+ {% if group.hint %} +

{{ group.hint }}

+ {% endif %} +
+
+
+ {% for candidate in group.candidates %} + {{ render_requests_candidate(candidate, preferred_release, compact=section.compact) }} + {% endfor %} +
+
+ {% endfor %} +
+ {% elif section.candidates %} +
+ {% for candidate in section.candidates %} + {{ render_requests_candidate(candidate, preferred_release, compact=section.compact) }} + {% endfor %} +
+ {% else %} +
+
+ +
+
+

{{ section.empty_message }}

+
+
+ {% endif %} +
+{%- endmacro %} diff --git a/cps/templates/requests_workspace_section.html b/cps/templates/requests_workspace_section.html new file mode 100644 index 000000000..dfb358c2a --- /dev/null +++ b/cps/templates/requests_workspace_section.html @@ -0,0 +1,2 @@ +{% import 'requests_workspace_macros.html' as requests_ui with context %} +{{ requests_ui.render_requests_section(section, preferred_release_settings or {}) }} diff --git a/cps/templates/search.html b/cps/templates/search.html index 62fc48e5c..4433385b6 100755 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -1,110 +1,156 @@ {% import 'image.html' as image %} +{% import 'shelfmark_external_macros.html' as shelfmark_ui with context %} {% extends "layout.html" %} +{% set shelfmark = shelfmark_section if shelfmark_section is defined and shelfmark_section else None %} +{% set local_count = entries|length if entries is defined and entries else 0 %} +{% set shelfmark_has_page_shell = shelfmark and shelfmark.has_page_shell %} +{% block header %} + +{% endblock %} {% block body %} -
- {% if entries|length < 1 %} -

{{_('No Results Found')}}

-

{{_('Search Term:')}} {{adv_searchterm}}

- {% else %} -

{{result_count}} {{_('Results for:')}} {{adv_searchterm}}

- {% if current_user.is_authenticated %} - {% if current_user.shelf.all() or g.shelves_access %} -