diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py index 3ff999e..7faa131 100644 --- a/abogen/integrations/calibre_opds.py +++ b/abogen/integrations/calibre_opds.py @@ -6,8 +6,8 @@ import re from collections import deque from dataclasses import dataclass, field from pathlib import PurePosixPath -from typing import Any, Deque, Dict, Iterable, Iterator, List, Mapping, Optional, Set -from urllib.parse import urljoin, urlparse +from typing import Any, Deque, Dict, Iterable, Iterator, List, Mapping, Optional, Set, Tuple +from urllib.parse import quote, urljoin, urlparse from xml.etree import ElementTree as ET import httpx @@ -202,13 +202,26 @@ class CalibreOPDSClient: cleaned = (query or "").strip() if not cleaned: return self.fetch_feed() - candidates = [ + + base_feed: Optional[OPDSFeed] = None + try: + base_feed = self.fetch_feed() + except CalibreOPDSError: + pass + + candidates: List[Tuple[Optional[str], Optional[Mapping[str, Any]]]] = [] + if base_feed: + search_url = self._resolve_search_url(base_feed, cleaned) + if search_url: + candidates.append((search_url, None)) + + candidates.extend([ ("search", {"query": cleaned}), ("search", {"q": cleaned}), (None, {"search": cleaned}), - ] + ]) + last_error: Optional[Exception] = None - base_feed: Optional[OPDSFeed] = None base_combined: Optional[OPDSFeed] = None for path, params in candidates: try: @@ -1037,6 +1050,20 @@ class CalibreOPDSClient: return dataclasses.replace(template, entries=collected) return dataclasses.replace(template, entries=[]) + def _resolve_search_url(self, feed: OPDSFeed, query: str) -> Optional[str]: + link = self._resolve_link(feed.links, "search") + if not link: + link = self._resolve_link(feed.links, "http://opds-spec.org/search") + + if not link or not link.href: + return None + + href = link.href.strip() + if "{searchTerms}" in href: + return href.replace("{searchTerms}", quote(query)) + + return href + def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]: """Helper used by APIs to convert a feed into JSON-serialisable payloads.""" diff --git a/abogen/web/static/find_books.js b/abogen/web/static/find_books.js index a2c7590..26335ec 100644 --- a/abogen/web/static/find_books.js +++ b/abogen/web/static/find_books.js @@ -5,6 +5,7 @@ if (modal && browser) { const statusEl = browser.querySelector('[data-role="opds-status"]'); const resultsEl = browser.querySelector('[data-role="opds-results"]'); const navEl = browser.querySelector('[data-role="opds-nav"]'); + const navBottomEl = browser.querySelector('[data-role="opds-nav-bottom"]'); const alphaPickerEl = browser.querySelector('[data-role="opds-alpha-picker"]'); const tabsEl = modal.querySelector('[data-role="opds-tabs"]'); const searchForm = modal.querySelector('[data-role="opds-search"]'); @@ -247,17 +248,10 @@ if (modal && browser) { if (!alphaPickerEl || state.query) { return false; } - if (!Array.isArray(entries) || entries.length < 6) { + if (!Array.isArray(entries) || entries.length === 0) { return false; } - const mode = deriveBrowseMode(); - const counts = stats || computeEntryStats(entries); - const hasNavigation = counts[EntryTypes.NAVIGATION] > 0; - if (mode === 'generic' && !hasNavigation) { - return false; - } - const alphabetCounts = collectAlphabetCounts(entries); - return alphabetCounts.size > 1; + return true; }; const refreshAlphabetActiveState = () => { @@ -542,10 +536,14 @@ if (modal && browser) { }; const renderNav = (links) => { - if (!navEl) { + const targets = [navEl, navBottomEl].filter(Boolean); + if (!targets.length) { return; } - navEl.innerHTML = ''; + targets.forEach((el) => { + el.innerHTML = ''; + }); + const descriptors = [ { key: 'up', label: 'Up one level' }, { key: 'previous', label: 'Previous page' }, @@ -556,26 +554,35 @@ if (modal && browser) { return; } const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`); - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'button button--ghost'; - button.textContent = label; const hasLink = Boolean(link && link.href); - if (hasLink) { - button.addEventListener('click', () => { - const targetQuery = key === 'up' ? '' : state.query; - const tabId = resolveTabIdForHref(link.href); - loadFeed({ href: link.href, query: targetQuery, activeTab: tabId || (targetQuery ? TabIds.SEARCH : TabIds.CUSTOM) }); - }); - } else if (key === 'previous') { - button.disabled = true; - button.setAttribute('aria-disabled', 'true'); - } else { + + if (!hasLink && key !== 'previous') { return; } - navEl.appendChild(button); + + targets.forEach((targetEl) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'button button--ghost'; + button.textContent = label; + + if (hasLink) { + button.addEventListener('click', () => { + const targetQuery = key === 'up' ? '' : state.query; + const tabId = resolveTabIdForHref(link.href); + loadFeed({ href: link.href, query: targetQuery, activeTab: tabId || (targetQuery ? TabIds.SEARCH : TabIds.CUSTOM) }); + }); + } else if (key === 'previous') { + button.disabled = true; + button.setAttribute('aria-disabled', 'true'); + } + targetEl.appendChild(button); + }); + }); + + targets.forEach((el) => { + el.hidden = !el.childElementCount; }); - navEl.hidden = !navEl.childElementCount; }; const createEntry = (entry) => { diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 765d195..1676941 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -654,7 +654,22 @@ body { display: flex; flex-wrap: wrap; gap: 0.35rem; - margin: 0.35rem 0 0.65rem; + margin: 0; + padding: 0.75rem 0 1rem; + position: sticky; + top: 0; + z-index: 20; + background: var(--bg); + box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.2); + margin-bottom: 1rem; + border-bottom: 1px solid var(--panel-border); +} + +.opds-modal__nav--bottom { + margin-top: 1rem; + padding-top: 1.5rem; + border-top: 1px solid var(--panel-border); + justify-content: center; } .opds-alpha-picker__button { diff --git a/abogen/web/templates/find_books.html b/abogen/web/templates/find_books.html index 1573d5a..1e79651 100644 --- a/abogen/web/templates/find_books.html +++ b/abogen/web/templates/find_books.html @@ -76,6 +76,7 @@