From 73c00fab0b66b16b22f06df094fd8255615bd515 Mon Sep 17 00:00:00 2001 From: Ed Laughton Date: Thu, 9 Apr 2026 03:28:37 +0100 Subject: [PATCH 01/44] Add Shelfmark external search and request integration to CWA --- README.md | 35 +- cps/admin.py | 11 +- cps/config_sql.py | 4 + cps/search.py | 102 +++ cps/services/shelfmark_search.py | 812 ++++++++++++++++++ cps/static/css/shelfmark_search.css | 361 ++++++++ cps/static/js/shelfmark_external_search.js | 280 ++++++ cps/static/js/shelfmark_request_flow.js | 253 ++++++ cps/templates/config_edit.html | 21 + cps/templates/search.html | 392 +++++++-- cps/templates/shelfmark_external_detail.html | 197 +++++ .../test_shelfmark_external_search_dom.js | 305 +++++++ tests/unit/test_shelfmark_request_flow.js | 86 ++ tests/unit/test_shelfmark_search.py | 374 ++++++++ tests/unit/test_shelfmark_search_ui.py | 344 ++++++++ 15 files changed, 3485 insertions(+), 92 deletions(-) create mode 100644 cps/services/shelfmark_search.py create mode 100644 cps/static/css/shelfmark_search.css create mode 100644 cps/static/js/shelfmark_external_search.js create mode 100644 cps/static/js/shelfmark_request_flow.js create mode 100644 cps/templates/shelfmark_external_detail.html create mode 100644 tests/unit/test_shelfmark_external_search_dom.js create mode 100644 tests/unit/test_shelfmark_request_flow.js create mode 100644 tests/unit/test_shelfmark_search.py create mode 100644 tests/unit/test_shelfmark_search_ui.py diff --git a/README.md b/README.md index a23e8aa4f..2f297c934 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,39 @@ 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 the browser sees Shelfmark as cross-origin, CWA falls back to `Open in Shelfmark` rather than trying to fake request attribution +- Admin configuration: + - enable `Shelfmark External Search` + - set `Shelfmark Base URL` + - optionally set a dedicated Shelfmark search username/password if Shelfmark metadata search requires authentication + #### **Library Auto-Detect** 📚🕵️ - Made to **MASSIVELY** simplify the setup process for both **new and existing users** alike - **New Users without existing Libraries:** 🆕 @@ -486,4 +519,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..fa411b59f 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -2353,6 +2353,15 @@ 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_username") + if to_save.get("config_shelfmark_password_e", ""): + _config_string(to_save, "config_shelfmark_password_e") + 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 @@ -3113,4 +3122,4 @@ def restore_calibre_db(): except Exception: pass except Exception: - pass \ No newline at end of file + pass diff --git a/cps/config_sql.py b/cps/config_sql.py index 7a9fea256..b8d1a941a 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -114,6 +114,10 @@ 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_username = Column(String, default="") + config_shelfmark_password_e = Column(String) config_register_email = Column(Boolean, default=False) config_login_type = Column(Integer, default=0) diff --git a/cps/search.py b/cps/search.py index f5dda4192..7e30dfe33 100755 --- a/cps/search.py +++ b/cps/search.py @@ -20,6 +20,12 @@ from .usermanagement import login_required_if_no_ano from .render_template import render_title_template from .pagination import Pagination +from .services.shelfmark_search import ( + ShelfmarkIntegrationError, + build_shelfmark_advanced_query, + fetch_shelfmark_detail, + search_shelfmark_results, +) search = Blueprint('search', __name__) @@ -376,10 +382,28 @@ 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 = search_shelfmark_results( + shelfmark_query, + detail_url_builder=lambda book: _build_shelfmark_detail_url( + book, + query=shelfmark_query or search_term, + return_to=_current_request_path(), + ), + query_label=_("Advanced external query"), + context_hint=( + _("Built from %(fields)s only. Other advanced filters stay local to CWA.", fields=", ".join(shelfmark_fields)) + if shelfmark_query + else _("Advanced external search only uses title, author, and publisher fields when present.") + ), + empty_message=_("Add a title, author, or publisher filter to include Shelfmark external results in advanced search."), + ).to_template_dict() 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 +441,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 +450,15 @@ def render_search_results(term, offset=None, order=None, limit=None): order, limit, *join) + shelfmark_section = search_shelfmark_results( + term, + detail_url_builder=lambda book: _build_shelfmark_detail_url( + book, + query=term, + return_to=_current_request_path(), + ), + query_label=_("External lookup query"), + ).to_template_dict() else: entries = list() order = [None, None] @@ -436,9 +470,77 @@ 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")) + 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() + 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, + ) + except ShelfmarkIntegrationError as exc: + flash(str(exc), category="error") + 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), + ) + + +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 _current_request_path(): + if not request.query_string: + return request.path + return request.full_path.rstrip("?") + + +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 diff --git a/cps/services/shelfmark_search.py b/cps/services/shelfmark_search.py new file mode 100644 index 000000000..1b254c738 --- /dev/null +++ b/cps/services/shelfmark_search.py @@ -0,0 +1,812 @@ +from __future__ import annotations +from dataclasses import asdict, dataclass, field +from typing import Any, Callable, Iterable, Mapping, Sequence +from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit + +from flask import url_for +from flask_babel import gettext as _ +from sqlalchemy.sql.expression import func + +from cps import calibre_db, config, db, logger +from cps.cw_advocate import Session as SafeSession + +log = logger.create() + +DEFAULT_SHELFMARK_TIMEOUT_SECONDS = 15 +DEFAULT_SHELFMARK_LIMIT = 12 +SHELFMARK_METADATA_PROVIDER = "hardcover" +SHELFMARK_CONTENT_TYPE = "ebook" +SHELFMARK_REQUEST_MODE = "request_book" +SHELFMARK_REQUEST_KIND = "book" + + +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 + hint: str + row_class: str + badge_class: str + panel_class: str + summary_class: str + icon_class: str + + +@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], ...] + 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 + + def to_template_dict(self) -> dict[str, Any]: + payload = asdict(self) + payload["authors"] = list(self.authors) + payload["display_fields"] = list(self.display_fields) + payload["library_state"] = asdict(self.library_state) + payload["action"] = asdict(self.action) + return payload + + +@dataclass(frozen=True) +class ShelfmarkResultSummary: + total_results: int = 0 + 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 + query_label: str | None = None + context_hint: str | None = None + message: str | None = None + message_level: str = "info" + 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, + "query_label": self.query_label, + "context_hint": self.context_hint, + "message": self.message, + "message_level": self.message_level, + "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 + username: str | None + password: str | None + timeout_seconds: int = DEFAULT_SHELFMARK_TIMEOUT_SECONDS + + +def get_shelfmark_client_config() -> ShelfmarkClientConfig: + base_url = str(getattr(config, "config_shelfmark_url", "") or "").strip() + username = str(getattr(config, "config_shelfmark_username", "") or "").strip() or None + password = str(getattr(config, "config_shelfmark_password_e", "") or "").strip() or None + enabled = bool(getattr(config, "config_shelfmark_search", False) and base_url) + return ShelfmarkClientConfig( + enabled=enabled, + base_url=base_url, + username=username, + password=password, + ) + + +def build_shelfmark_open_url( + base_url: str, + *, + title: str, + authors: Sequence[str], + hardcover_id: str | None, + content_type: str = SHELFMARK_CONTENT_TYPE, +) -> str: + search_query = f"hardcover-id:{hardcover_id}" if hardcover_id else title + params: dict[str, str] = { + "query": search_query, + "content_type": content_type, + } + if not hardcover_id and authors: + params["author"] = authors[0] + 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 = _normalize_text(book.get("title")) + authors = _normalize_authors(book.get("authors")) + 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": { + "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 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, + probe_state: ShelfmarkProbeState | None, +) -> ShelfmarkActionState: + if already_in_library and library_book_url: + return ShelfmarkActionState( + mode="view_library", + label=_("Open existing CWA book"), + hint=_("Exact hardcover-id already exists in your library."), + button_class="btn-success", + icon_class="glyphicon glyphicon-book", + ) + + if not hardcover_id: + return ShelfmarkActionState( + mode="open", + label=_("Open in Shelfmark"), + hint=_("Direct request actions are unavailable because this result has no exact hardcover-id metadata."), + button_class="btn-default", + icon_class="glyphicon glyphicon-new-window", + ) + + if request_payload is None: + return ShelfmarkActionState( + mode="open", + label=_("Open in Shelfmark"), + hint=_("Direct request actions are unavailable because Shelfmark did not return enough exact metadata to build a safe book request."), + button_class="btn-default", + icon_class="glyphicon glyphicon-new-window", + ) + + if probe_state is None or not probe_state.probe_available: + return ShelfmarkActionState( + mode="open", + label=_("Open in Shelfmark"), + hint=_("Request availability is checked in your browser. Until Shelfmark confirms a live session and compatible policy, opening Shelfmark remains the safe fallback."), + button_class="btn-default", + icon_class="glyphicon glyphicon-new-window", + ) + + 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 session was not found for this browser. %(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=_("This browser already has a Shelfmark session and the current policy allows book-level requests."), + button_class="btn-primary", + icon_class="glyphicon glyphicon-send", + ) + + return ShelfmarkActionState( + mode="open", + label=_("Open in Shelfmark"), + hint=_("Shelfmark is signed in, but the current Shelfmark policy does not allow direct book-level requests for this result."), + 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=_("Already in library"), + hint=_("Exact hardcover-id match in metadata.db."), + row_class="success", + badge_class="label-success", + panel_class="panel-success", + summary_class="shelfmark-summary-card--success", + icon_class="glyphicon glyphicon-ok-circle", + ) + + if hardcover_id: + return ShelfmarkLibraryState( + key="external_candidate", + label=_("External candidate"), + hint=_("No exact hardcover-id match was found in metadata.db."), + row_class="info", + badge_class="label-info", + panel_class="panel-info", + summary_class="shelfmark-summary-card--info", + icon_class="glyphicon glyphicon-cloud-download", + ) + + return ShelfmarkLibraryState( + key="library_match_unavailable", + label=_("Library match unavailable"), + hint=_("No exact hardcover-id is available from this external result, so CWA does not guess duplicates."), + row_class="warning", + badge_class="label-warning", + panel_class="panel-warning", + summary_class="shelfmark-summary-card--warning", + icon_class="glyphicon glyphicon-question-sign", + ) + + +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 build_shelfmark_result_view( + book: Mapping[str, Any], + *, + library_match: ShelfmarkLibraryMatch | None, + detail_url: str | None, + shelfmark_base_url: str, + probe_state: ShelfmarkProbeState | None = None, +) -> ShelfmarkResultView: + title = _normalize_text(book.get("title")) or _("Unknown title") + authors = tuple(_normalize_authors(book.get("authors"))) + hardcover_id = _extract_hardcover_id(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) + 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, + 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=_normalize_text(book.get("subtitle")), + authors=authors, + cover_url=_normalize_text(book.get("cover_url")), + description=_normalize_text(book.get("description")), + publish_year=_normalize_int(book.get("publish_year")), + source_url=_normalize_text(book.get("source_url")), + display_fields=_normalize_display_fields(book.get("display_fields")), + 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_base_url, + shelfmark_open_url=build_shelfmark_open_url( + shelfmark_base_url, + title=title, + authors=authors, + hardcover_id=hardcover_id, + ), + request_payload=request_payload, + library_state=library_state, + action=action, + ) + + +def summarize_shelfmark_results(results: Sequence[ShelfmarkResultView]) -> 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, + 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"), + _("These external hits already exist in metadata.db via an exact hardcover-id match."), + ), + ( + "external_candidate", + _("External Candidates"), + _("These results have an exact hardcover-id, but no matching library record yet."), + ), + ( + "library_match_unavailable", + _("External Results Without Exact Hardcover ID"), + _("CWA does not guess duplicate status 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 search_shelfmark_results( + query: str | None, + *, + detail_url_builder: Callable[[Mapping[str, Any]], str | None], + query_label: str | None = None, + context_hint: str | None = None, + empty_message: str | None = None, +) -> ShelfmarkSearchSection: + normalized_query = _normalize_text(query) + 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="", + 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: + books = client.search_books(normalized_query) + hardcover_ids = [value for value in (_extract_hardcover_id(book) for book in 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_base_url=config_data.base_url, + ) + for book in books + ) + summary = summarize_shelfmark_results(results) + return ShelfmarkSearchSection( + enabled=True, + available=True, + query=normalized_query, + query_label=query_label, + context_hint=context_hint, + 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) + library_match = None + if hardcover_id: + library_match = lookup_visible_library_matches([hardcover_id]).get(hardcover_id) + return build_shelfmark_result_view( + book, + library_match=library_match, + detail_url=detail_url, + shelfmark_base_url=config_data.base_url, + probe_state=probe_state, + ) + + +class ShelfmarkClient: + def __init__(self, config_data: ShelfmarkClientConfig, session: SafeSession | None = None): + self.config = config_data + self.session = session or SafeSession() + self._authenticated = False + + def search_books( + self, + query: str, + *, + limit: int = DEFAULT_SHELFMARK_LIMIT, + provider: str = SHELFMARK_METADATA_PROVIDER, + content_type: str = SHELFMARK_CONTENT_TYPE, + ) -> list[dict[str, Any]]: + self._ensure_authenticated() + response = self.session.get( + _join_base_url(self.config.base_url, "/api/metadata/search"), + params={ + "query": query, + "limit": limit, + "provider": provider, + "content_type": content_type, + }, + timeout=self.config.timeout_seconds, + ) + payload = self._parse_json_response(response, _("Shelfmark search failed.")) + books = payload.get("books") + if not isinstance(books, list): + return [] + return [book for book in books if isinstance(book, dict)] + + def fetch_book(self, provider: str, provider_id: str) -> dict[str, Any]: + self._ensure_authenticated() + response = self.session.get( + _join_base_url( + self.config.base_url, + f"/api/metadata/book/{provider}/{provider_id}", + ), + timeout=self.config.timeout_seconds, + ) + 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 payload + + 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.session.post( + _join_base_url(self.config.base_url, "/api/auth/login"), + json={ + "username": username, + "password": password, + "remember_me": False, + }, + timeout=self.config.timeout_seconds, + ) + 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 + + @staticmethod + def _parse_json_response(response: Any, default_message: str) -> Mapping[str, Any]: + try: + payload = response.json() + except ValueError as exc: + raise ShelfmarkIntegrationError(default_message) from exc + + if response.ok: + return payload if isinstance(payload, Mapping) else {} + + 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) + + +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_int(value: Any) -> int | None: + try: + if value in (None, ""): + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _normalize_authors(value: Any) -> list[str]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + return [] + authors: list[str] = [] + for author in value: + normalized = _normalize_text(author) + if normalized: + authors.append(normalized) + return authors + + +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: + provider = _normalize_text(book.get("provider")) + if provider != SHELFMARK_METADATA_PROVIDER: + return None + return _normalize_text(book.get("provider_id")) + + +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/static/css/shelfmark_search.css b/cps/static/css/shelfmark_search.css new file mode 100644 index 000000000..6fb08235f --- /dev/null +++ b/cps/static/css/shelfmark_search.css @@ -0,0 +1,361 @@ +.shelfmark-search-page { + margin-bottom: 24px; +} + +.shelfmark-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + margin-bottom: 16px; + border: 1px solid #dfe6ee; + border-radius: 10px; + background: linear-gradient(180deg, #fbfdff 0%, #f4f8fc 100%); +} + +.shelfmark-section-heading--local { + border-left: 5px solid #61748a; +} + +.shelfmark-section-heading--external { + border-left: 5px solid #3c78b4; + background: linear-gradient(180deg, #fdfefe 0%, #eef6fc 100%); +} + +.shelfmark-section-heading__title { + margin: 0 0 4px; + font-size: 22px; +} + +.shelfmark-section-heading__title .glyphicon { + margin-right: 8px; +} + +.shelfmark-section-heading__subtitle { + margin: 0; + color: #607284; +} + +.shelfmark-section-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + height: 40px; + padding: 0 12px; + border-radius: 999px; + font-weight: 700; + font-size: 16px; + line-height: 1; +} + +.shelfmark-section-pill--local { + background: #eff3f7; + color: #33485b; +} + +.shelfmark-section-pill--external { + background: #dfeffc; + color: #1f5f96; +} + +.shelfmark-external-header__copy, +.shelfmark-external-header__query, +.shelfmark-external-header__hint { + margin-bottom: 12px; +} + +.shelfmark-summary-grid { + margin-top: 16px; + margin-bottom: 0; +} + +.shelfmark-summary-card { + min-height: 126px; + margin-bottom: 16px; + padding: 18px 16px; + border: 1px solid #dfe6ee; + border-radius: 10px; + background: #fff; + text-align: center; + box-shadow: 0 1px 1px rgba(25, 35, 45, 0.04); +} + +.shelfmark-summary-card__icon { + display: block; + margin-bottom: 10px; + font-size: 24px; +} + +.shelfmark-summary-card__count { + font-size: 30px; + font-weight: 700; + line-height: 1.1; +} + +.shelfmark-summary-card__label { + margin-top: 8px; + color: #56697c; + font-weight: 600; +} + +.shelfmark-summary-card--success { + border-top: 4px solid #4d9d67; +} + +.shelfmark-summary-card--success .shelfmark-summary-card__icon, +.shelfmark-summary-card--success .shelfmark-summary-card__count { + color: #2f7b47; +} + +.shelfmark-summary-card--info { + border-top: 4px solid #4c8ec7; +} + +.shelfmark-summary-card--info .shelfmark-summary-card__icon, +.shelfmark-summary-card--info .shelfmark-summary-card__count { + color: #2e6d9e; +} + +.shelfmark-summary-card--warning { + border-top: 4px solid #cf8b1b; +} + +.shelfmark-summary-card--warning .shelfmark-summary-card__icon, +.shelfmark-summary-card--warning .shelfmark-summary-card__count { + color: #9a650f; +} + +.shelfmark-status-banner { + margin-bottom: 18px; +} + +.shelfmark-group-panel { + margin-bottom: 20px; + border-radius: 10px; + box-shadow: 0 1px 2px rgba(25, 35, 45, 0.05); +} + +.shelfmark-group-panel__heading { + padding-top: 14px; + padding-bottom: 14px; +} + +.shelfmark-group-panel__title { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 16px; +} + +.shelfmark-group-panel__count { + float: right; + margin-top: -2px; +} + +.shelfmark-group-panel__hint { + margin-top: 6px; +} + +.shelfmark-group-panel__list .list-group-item:first-child { + border-top: 0; +} + +.shelfmark-result-card { + padding: 18px; + border-left-width: 5px; +} + +.shelfmark-result-card--success { + border-left-color: #4d9d67; + background: linear-gradient(180deg, #ffffff 0%, #f5fbf7 100%); +} + +.shelfmark-result-card--info { + border-left-color: #4c8ec7; + background: linear-gradient(180deg, #ffffff 0%, #f5f9fd 100%); +} + +.shelfmark-result-card--warning { + border-left-color: #cf8b1b; + background: linear-gradient(180deg, #ffffff 0%, #fff9ef 100%); +} + +.shelfmark-result-card__cover-image { + width: 84px; + max-width: 84px; +} + +.shelfmark-result-card__cover-fallback { + width: 84px; + height: 112px; + padding-top: 36px; +} + +.shelfmark-result-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.shelfmark-result-card__title { + margin: 0 0 6px; +} + +.shelfmark-result-card__subtitle { + margin: 0; +} + +.shelfmark-result-card__badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + min-width: 170px; +} + +.shelfmark-result-card__badges .label .glyphicon { + margin-right: 4px; +} + +.shelfmark-result-card__authors, +.shelfmark-result-card__status, +.shelfmark-result-card__description, +.shelfmark-result-card__action-hint { + margin-bottom: 8px; +} + +.shelfmark-result-card__existing { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 10px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.75); +} + +.shelfmark-result-card__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.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 { + font-weight: 600; +} + +.shelfmark-detail-page__title { + display: flex; + align-items: center; + gap: 10px; +} + +.shelfmark-detail-hero { + border-radius: 12px; + box-shadow: 0 2px 6px rgba(25, 35, 45, 0.06); +} + +.shelfmark-detail-hero__body { + padding: 20px 22px; +} + +.shelfmark-detail-hero__cover-image { + width: 100%; + max-width: 180px; +} + +.shelfmark-detail-hero__cover-fallback { + padding: 24px 12px; +} + +.shelfmark-detail-hero__book-title { + margin-top: 0; + margin-bottom: 8px; +} + +.shelfmark-detail-hero__subtitle { + margin-bottom: 8px; +} + +.shelfmark-detail-hero__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.shelfmark-detail-hero__badges .label .glyphicon { + margin-right: 4px; +} + +.shelfmark-detail-hero__authors { + margin-bottom: 12px; +} + +.shelfmark-detail-hero__status { + margin-bottom: 14px; +} + +.shelfmark-detail-hero__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.shelfmark-detail-hero__action-label { + margin-top: 12px; + margin-bottom: 4px; +} + +.shelfmark-detail-panel { + border-radius: 10px; + box-shadow: 0 1px 2px rgba(25, 35, 45, 0.05); +} + +.shelfmark-detail-panel__existing-link { + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.75); +} + +@media (max-width: 767px) { + .shelfmark-section-heading { + flex-direction: column; + align-items: stretch; + } + + .shelfmark-section-pill { + align-self: flex-start; + } + + .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__actions .btn, + .shelfmark-detail-hero__actions .btn { + width: 100%; + } +} diff --git a/cps/static/js/shelfmark_external_search.js b/cps/static/js/shelfmark_external_search.js new file mode 100644 index 000000000..c5eacded4 --- /dev/null +++ b/cps/static/js/shelfmark_external_search.js @@ -0,0 +1,280 @@ +(function () { + 'use strict'; + + var flow = window.CwaShelfmarkRequestFlow; + var DEFAULT_TIMEOUT_MS = 10000; + + if (!flow) { + return; + } + + function parseJson(value) { + if (!value) { + return null; + } + try { + return JSON.parse(value); + } catch (err) { + return null; + } + } + + function stripTrailingSlash(value) { + return (value || '').replace(/\/+$/, ''); + } + + function setStatusText(text, level) { + var nodes = document.querySelectorAll('.js-shelfmark-request-status'); + nodes.forEach(function (node) { + node.textContent = text; + node.classList.remove('alert-info', 'alert-success', 'alert-warning', 'alert-danger'); + node.classList.add(level || 'alert-info'); + }); + } + + 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; + 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; + } + } + + function setPending(node, isPending) { + var iconNode = node.querySelector('.js-shelfmark-action-icon'); + var labelNode = node.querySelector('.js-shelfmark-action-label'); + + if (isPending) { + node.classList.add('disabled'); + node.setAttribute('aria-disabled', 'true'); + if (iconNode) { + iconNode.className = 'glyphicon glyphicon-refresh js-shelfmark-action-icon'; + } + if (labelNode) { + labelNode.textContent = 'Requesting...'; + } else { + node.textContent = 'Requesting...'; + } + return; + } + node.classList.remove('disabled'); + node.removeAttribute('aria-disabled'); + } + + function createRequestError(message, options) { + return flow.createBrowserError(message, options || {}); + } + + function fetchJson(url, options, timeoutMs) { + var controller = new AbortController(); + var timeoutId = timeoutMs && timeoutMs > 0 + ? setTimeout(function () { controller.abort(); }, timeoutMs) + : null; + + return fetch(url, { + ...options, + credentials: 'include', + signal: controller.signal, + 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 applyActionState(actions, nextState) { + actions.forEach(function (node) { + if (!parseJson(node.dataset.requestPayload)) { + return; + } + updateActionNode(node, nextState); + }); + } + + function applyProbeOutcome(actions, outcome) { + applyActionState(actions, outcome.actionState); + setStatusText(outcome.bannerText, outcome.bannerLevel); + } + + async function attachProbe(actions, baseUrl) { + var currentOrigin = window.location.origin; + var probeOutcome; + + if (!flow.isDirectRequestViable(baseUrl, currentOrigin)) { + probeOutcome = flow.resolveProbeState({ + baseUrl: baseUrl, + currentOrigin: currentOrigin + }); + applyProbeOutcome(actions, probeOutcome); + return; + } + + setStatusText('Checking your Shelfmark session for direct request availability…', 'alert-info'); + + try { + var authPayload = await fetchJson(stripTrailingSlash(baseUrl) + '/api/auth/check', {}, DEFAULT_TIMEOUT_MS); + if (!authPayload || !authPayload.authenticated) { + probeOutcome = flow.resolveProbeState({ + baseUrl: baseUrl, + currentOrigin: currentOrigin, + authPayload: authPayload + }); + applyProbeOutcome(actions, probeOutcome); + return; + } + + var policyPayload = await fetchJson(stripTrailingSlash(baseUrl) + '/api/request-policy', {}, DEFAULT_TIMEOUT_MS); + probeOutcome = flow.resolveProbeState({ + baseUrl: baseUrl, + currentOrigin: currentOrigin, + authPayload: authPayload, + policyPayload: policyPayload + }); + applyProbeOutcome(actions, probeOutcome); + } catch (error) { + probeOutcome = flow.resolveProbeState({ + baseUrl: baseUrl, + currentOrigin: currentOrigin, + error: error + }); + applyProbeOutcome(actions, probeOutcome); + } + } + + function attachRequestHandler(node, payload, openUrl) { + node.addEventListener('click', async function (event) { + if (flow.normalizeMode(node.dataset.mode) !== 'request') { + return; + } + + event.preventDefault(); + setPending(node, true); + setStatusText('Creating request in Shelfmark…', 'alert-info'); + + try { + await fetchJson(stripTrailingSlash(node.dataset.baseUrl) + '/api/requests', { + method: 'POST', + body: JSON.stringify(payload) + }, DEFAULT_TIMEOUT_MS); + + var successOutcome = flow.resolveRequestOutcome({ success: true }); + updateActionNode(node, successOutcome.actionState); + node.setAttribute('href', openUrl); + setStatusText(successOutcome.bannerText, successOutcome.bannerLevel); + } catch (error) { + var failureOutcome = flow.resolveRequestOutcome({ success: false, error: error }); + updateActionNode(node, failureOutcome.actionState); + node.setAttribute('href', openUrl); + setStatusText(failureOutcome.bannerText, failureOutcome.bannerLevel); + } finally { + setPending(node, false); + } + }); + } + + function init() { + var actions = Array.prototype.slice.call(document.querySelectorAll('.js-shelfmark-action')); + var statusNodes = Array.prototype.slice.call(document.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')); + } + }); + + attachProbe(actions, baseUrl); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } 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..27f81855c --- /dev/null +++ b/cps/static/js/shelfmark_request_flow.js @@ -0,0 +1,253 @@ +(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'; + + function normalizeMode(value) { + return (value || '').toString().trim().toLowerCase(); + } + + 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 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 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 Shelfmark deployment. Open in Shelfmark remains the safe fallback for this setup.', + actionState: buildOpenState('This CWA page and Shelfmark are on different browser origins. Direct browser requests are not reliable across origins, so opening Shelfmark is the safe fallback.') + }; + } + + 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) { + 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.') + }; + } + + var defaults = policyPayload.defaults || {}; + var ebookMode = normalizeMode(defaults.ebook); + if (ebookMode !== REQUEST_MODE) { + return { + kind: 'policy_blocked', + bannerLevel: 'alert-warning', + bannerText: 'Shelfmark is signed in, but the current policy does not allow direct book-level requests here.', + actionState: buildOpenState('Shelfmark is signed in, but the current policy does not allow direct book-level requests for ebook results in this browser flow.') + }; + } + + return { + kind: 'requestable', + bannerLevel: 'alert-success', + bannerText: 'Shelfmark session detected. Direct requests will be attributed in Shelfmark as the current Shelfmark user.', + actionState: buildRequestState('This browser already has a valid Shelfmark session and the current Shelfmark policy allows direct book-level requests.') + }; + } + + function resolveRequestOutcome(options) { + if (options && options.success) { + return { + kind: 'request_created', + bannerLevel: 'alert-success', + bannerText: 'Request created in Shelfmark as your current Shelfmark user.', + actionState: buildOpenState( + 'The request was created in Shelfmark. Open Shelfmark to review request status.', + 'Requested in Shelfmark', + 'btn-success', + 'glyphicon glyphicon-ok' + ) + }; + } + + var error = options && options.error ? options.error : null; + if (error) { + if (error.kind === 'timeout') { + return { + kind: 'request_timeout', + bannerLevel: 'alert-danger', + bannerText: 'Shelfmark did not confirm the request before the browser timed out.', + actionState: buildOpenState('Shelfmark did not confirm the request before the browser timed out. Open Shelfmark to retry or verify whether the request was created.') + }; + } + + 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) { + 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, + normalizeMode: normalizeMode, + buildOpenState: buildOpenState, + buildRequestState: buildRequestState, + createBrowserError: createBrowserError, + isDirectRequestViable: isDirectRequestViable, + resolveProbeState: resolveProbeState, + resolveRequestOutcome: resolveRequestOutcome + }; +})); diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 648b5ed2e..804312e3f 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -243,6 +243,27 @@

{{_('Feature Configuration')}}

+
+ + +
{{_('Adds Shelfmark external results alongside library search. Duplicate awareness is exact metadata.db hardcover-id matching only, and request attribution stays on the Shelfmark/browser side. Advanced search only sends title, author, and publisher metadata to Shelfmark; all other advanced filters stay local to CWA. The UI groups exact duplicates, external candidates, and results without exact Hardcover IDs separately. Direct Request in Shelfmark works best when CWA and Shelfmark are served from the same browser origin via a reverse proxy.')}}
+
+
+
+ + +
+
+ + +
{{_('Optional. Use a dedicated Shelfmark account if integrated external search needs authentication.')}}
+
+
+ + +
{{_('Leave blank to keep the current stored password unchanged.')}}
+
+
diff --git a/cps/templates/search.html b/cps/templates/search.html index 62fc48e5c..033ebd8a7 100755 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -1,110 +1,322 @@ {% import 'image.html' as image %} {% extends "layout.html" %} +{% 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 %} -