diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py index c8632fa..688181b 100644 --- a/abogen/integrations/calibre_opds.py +++ b/abogen/integrations/calibre_opds.py @@ -743,25 +743,102 @@ class CalibreOPDSClient: return None @staticmethod - def _has_navigation_link(entry: OPDSEntry) -> bool: - for link in entry.links: - href = (link.href or "").strip() - if not href: - continue - rel = (link.rel or "").strip().lower() - link_type = (link.type or "").strip().lower() - if "acquisition" in rel: - continue - if rel == "self": - continue - if "opds-catalog" in link_type: - return True - if rel.endswith("navigation") or rel.endswith("collection"): - return True - if rel.startswith("http://opds-spec.org/sort") or rel.startswith("http://opds-spec.org/group"): - return True + def _resolve_link(links: Optional[Mapping[str, OPDSLink]], rel: str) -> Optional[OPDSLink]: + if not links: + return None + if rel in links: + return links[rel] + rel_lower = rel.lower() + for key, link in links.items(): + key_lower = (key or "").strip().lower() + if key_lower == rel_lower or key_lower.endswith(rel_lower): + return link + return None + + @staticmethod + def _is_navigation_link(link: OPDSLink) -> bool: + href = (link.href or "").strip() + if not href: + return False + rel = (link.rel or "").strip().lower() + link_type = (link.type or "").strip().lower() + if "acquisition" in rel: + return False + if rel == "self": + return False + if "opds-catalog" in link_type: + return True + if rel.endswith("navigation") or rel.endswith("collection"): + return True + if rel.startswith("http://opds-spec.org/sort") or rel.startswith("http://opds-spec.org/group"): + return True return False + @staticmethod + def _has_navigation_link(entry: OPDSEntry) -> bool: + return any(CalibreOPDSClient._is_navigation_link(link) for link in entry.links) + + @staticmethod + def _browse_mode_for_title(title: Optional[str]) -> str: + if not title: + return "generic" + lowered = title.lower() + if "author" in lowered: + return "author" + if "series" in lowered: + return "series" + if "title" in lowered or "book" in lowered: + return "title" + return "generic" + + @staticmethod + def _strip_leading_article(text: str) -> str: + working = text.strip() + lowered = working.lower() + for article in ("the ", "a ", "an "): + if lowered.startswith(article): + return working[len(article):].strip() + return working + + @staticmethod + def _alphabet_source(entry: OPDSEntry, mode: str) -> str: + if mode == "author" and entry.authors: + candidate = entry.authors[0] or "" + if "," in candidate: + return candidate.split(",", 1)[0].strip() + parts = candidate.split() + if len(parts) > 1: + return parts[-1].strip() + return candidate.strip() + if mode == "series" and entry.series: + return entry.series.strip() + if entry.title: + return entry.title.strip() + if entry.series: + return entry.series.strip() + for link in entry.links: + if link.title: + return link.title.strip() + return "" + + @staticmethod + def _alphabet_letter_for_entry(entry: OPDSEntry, mode: str) -> Optional[str]: + source = CalibreOPDSClient._alphabet_source(entry, mode) + if not source: + return None + if mode == "title": + source = CalibreOPDSClient._strip_leading_article(source) + source = re.sub(r"^[^0-9A-Za-z]+", "", source) + if not source: + return "#" + initial = source[0] + if initial.isalpha(): + return initial.upper() + if initial.isdigit(): + return "#" + normalized = initial.upper() + return normalized if normalized.isalpha() else "#" + @staticmethod def _entry_matches_query(entry: OPDSEntry, tokens: List[str]) -> bool: if not tokens: @@ -794,6 +871,70 @@ class CalibreOPDSClient: filtered = [entry for entry in feed.entries if self._entry_matches_query(entry, tokens)] return dataclasses.replace(feed, entries=filtered) + def browse_letter( + self, + letter: str, + *, + start_href: Optional[str] = None, + max_pages: int = 40, + ) -> OPDSFeed: + normalized = (letter or "").strip() + if not normalized: + return self.fetch_feed(start_href) + key = normalized.upper() + if key in {"ALL", "*"}: + return self.fetch_feed(start_href) + if key in {"0-9", "NUMERIC"}: + key = "#" + if len(key) > 1 and key != "#": + key = key[0] + base_feed = self.fetch_feed(start_href) + mode = self._browse_mode_for_title(base_feed.title) + + def matches_letter(entry: OPDSEntry) -> bool: + letter_value = self._alphabet_letter_for_entry(entry, mode) + if not letter_value: + return False + if key == "#": + return letter_value == "#" + return letter_value == key + + def find_navigation(feed: OPDSFeed) -> Optional[OPDSLink]: + for entry in feed.entries: + for link in entry.links: + if not self._is_navigation_link(link): + continue + if matches_letter(entry): + return link + return None + + candidate = find_navigation(base_feed) + if candidate and candidate.href: + return self.fetch_feed(candidate.href) + + visited: Set[str] = set() + pages_examined = 0 + next_link = self._resolve_link(base_feed.links, "next") + while next_link and pages_examined < max_pages: + href = (next_link.href or "").strip() + if not href or href in visited: + break + visited.add(href) + pages_examined += 1 + try: + feed = self.fetch_feed(href) + except CalibreOPDSError: + break + candidate = find_navigation(feed) + if candidate and candidate.href: + return self.fetch_feed(candidate.href) + next_link = self._resolve_link(feed.links, "next") + + filtered_entries = [entry for entry in base_feed.entries if matches_letter(entry)] + if filtered_entries: + return dataclasses.replace(base_feed, entries=filtered_entries) + return dataclasses.replace(base_feed, entries=[]) + 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/routes.py b/abogen/web/routes.py index 83d40c2..2ee0ade 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -2909,8 +2909,11 @@ def calibre_opds_feed() -> ResponseReturnValue: href = request.args.get("href", type=str) query = request.args.get("q", type=str) + letter = request.args.get("letter", type=str) try: - if query: + if letter: + feed = client.browse_letter(letter, start_href=href) + elif query: feed = client.search(query) else: feed = client.fetch_feed(href) diff --git a/abogen/web/static/find_books.js b/abogen/web/static/find_books.js index 2c749f0..e3fbf1e 100644 --- a/abogen/web/static/find_books.js +++ b/abogen/web/static/find_books.js @@ -45,6 +45,7 @@ if (modal && browser) { status: { message: '', level: null }, baseStatus: null, lastContextKey: '', + currentLinks: {}, }; let isOpen = false; @@ -314,33 +315,45 @@ if (modal && browser) { button.textContent = letter === LETTER_ALL ? 'All' : letter === LETTER_NUMERIC ? '# / 0-9' : letter; const enabledCount = letter === LETTER_ALL ? list.length : counts.get(letter) || 0; if (letter !== LETTER_ALL && enabledCount === 0) { - button.disabled = true; - button.setAttribute('aria-disabled', 'true'); - } else { - button.addEventListener('click', () => handleAlphabetSelect(letter)); + button.dataset.empty = 'true'; } + button.title = `Show entries for ${describeAlphabetLetter(letter)} (${enabledCount} in view)`; + button.addEventListener('click', () => { + handleAlphabetSelect(letter).catch((error) => { + console.error('Alphabet picker failed', error); + }); + }); alphaPickerEl.appendChild(button); }); refreshAlphabetActiveState(); }; - const handleAlphabetSelect = (letter) => { + const handleAlphabetSelect = async (letter) => { const normalized = letter || LETTER_ALL; - if (!Array.isArray(state.lastEntries) || !state.lastEntries.length) { + if (normalized === LETTER_ALL) { + state.activeLetter = LETTER_ALL; + refreshAlphabetActiveState(); + const upLink = resolveRelLink(state.currentLinks, 'up') || resolveRelLink(state.currentLinks, '/up') || resolveRelLink(state.currentLinks, 'start'); + const targetHref = upLink?.href || ''; + await loadFeed({ href: targetHref, query: '', letter: '', activeTab: targetHref ? TabIds.CUSTOM : TabIds.ROOT, updateTabs: true }); return; } - if (normalized === state.activeLetter) { - return; + + if (!Array.isArray(state.lastEntries)) { + state.lastEntries = []; } + state.activeLetter = normalized; const filtered = applyAlphabetFilter(state.lastEntries); - state.filteredStats = renderEntries(filtered); - refreshAlphabetActiveState(); - if (!filtered.length) { - setStatus(`No entries found for ${describeAlphabetLetter(normalized)}.`, 'info'); - } else if (normalized === LETTER_ALL && state.baseStatus) { - restoreBaseStatus(); + if (filtered.length) { + state.filteredStats = renderEntries(filtered); + refreshAlphabetActiveState(); + setStatus(`Showing ${filtered.length} entr${filtered.length === 1 ? 'y' : 'ies'} for ${describeAlphabetLetter(normalized)}.`, 'info'); + return; } + + refreshAlphabetActiveState(); + await loadFeed({ href: state.currentHref || '', query: '', letter: normalized, activeTab: TabIds.CUSTOM, updateTabs: true }); }; const applyAlphabetFilter = (entries) => { @@ -359,13 +372,15 @@ if (modal && browser) { }); }; - const setEntries = (entries, { resetAlphabet = false } = {}) => { + const setEntries = (entries, { resetAlphabet = false, activeLetter = null } = {}) => { const list = Array.isArray(entries) ? entries.slice() : []; state.lastEntries = list; const totalStats = computeEntryStats(list); state.totalStats = totalStats; if (resetAlphabet) { state.activeLetter = LETTER_ALL; + } else if (activeLetter && activeLetter !== LETTER_ALL) { + state.activeLetter = activeLetter; } const filtered = applyAlphabetFilter(list); const filteredStats = renderEntries(filtered); @@ -815,13 +830,28 @@ if (modal && browser) { } }; - const loadFeed = async ({ href = '', query = '', activeTab = null, updateTabs = false } = {}) => { + const loadFeed = async ({ href = '', query = '', letter = '', activeTab = null, updateTabs = false } = {}) => { const params = new URLSearchParams(); - if (href) { - params.set('href', href); + const normalizedHref = href || ''; + const normalizedQuery = (query || '').trim(); + let normalizedLetter = (letter || '').trim(); + if (normalizedLetter === LETTER_ALL) { + normalizedLetter = ''; } - if (query) { - params.set('q', query); + if (normalizedQuery) { + normalizedLetter = ''; + } + if (normalizedLetter && normalizedLetter !== LETTER_NUMERIC) { + normalizedLetter = normalizedLetter.toUpperCase(); + } + if (normalizedHref) { + params.set('href', normalizedHref); + } + if (normalizedQuery) { + params.set('q', normalizedQuery); + } + if (!normalizedQuery && normalizedLetter) { + params.set('letter', normalizedLetter); } const requestId = ++state.requestToken; @@ -839,20 +869,27 @@ if (modal && browser) { } const feed = payload.feed || {}; state.feedTitle = feed.title || ''; - state.currentHref = href; - state.query = query; + state.currentHref = normalizedHref; + state.currentLinks = feed.links || {}; + const selfLink = resolveRelLink(state.currentLinks, 'self'); + if (selfLink?.href) { + state.currentHref = selfLink.href; + } + state.query = normalizedLetter ? '' : normalizedQuery; if (typeof activeTab === 'string') { state.activeTab = activeTab; - } else if (query) { + } else if (normalizedQuery) { state.activeTab = TabIds.SEARCH; - } else if (href) { - state.activeTab = resolveTabIdForHref(href) || TabIds.CUSTOM; + } else if (normalizedLetter) { + state.activeTab = TabIds.CUSTOM; + } else if (normalizedHref) { + state.activeTab = resolveTabIdForHref(normalizedHref) || TabIds.CUSTOM; } else { state.activeTab = TabIds.ROOT; } if (searchInput) { - searchInput.value = query || ''; + searchInput.value = state.query || ''; } if (updateTabs || !state.tabsReady) { @@ -862,17 +899,42 @@ if (modal && browser) { } renderNav(feed.links); - const { stats } = setEntries(feed.entries || [], { resetAlphabet: true }); + const { stats } = setEntries(feed.entries || [], { + resetAlphabet: !normalizedLetter, + activeLetter: normalizedLetter || null, + }); const books = stats?.[EntryTypes.BOOK] || 0; const views = stats?.[EntryTypes.NAVIGATION] || 0; - if (query) { - if (books) { - setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${query}".`, 'success', { persist: true }); + if (normalizedLetter) { + const letterDescription = describeAlphabetLetter(normalizedLetter); + if (books && views) { + setStatus( + `Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'} for ${letterDescription}.`, + 'success', + { persist: true }, + ); + } else if (books) { + setStatus(`Found ${books} book${books === 1 ? '' : 's'} for ${letterDescription}.`, 'success', { persist: true }); } else if (views) { - setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${query}".`, 'info', { persist: true }); + setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} for ${letterDescription}.`, 'info', { persist: true }); } else { - setStatus(`No results for "${query}".`, 'error', { persist: true }); + setStatus(`No catalog entries found for ${letterDescription}.`, 'info', { persist: true }); + } + return; + } + + if (normalizedQuery) { + if (books) { + setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${normalizedQuery}".`, 'success', { persist: true }); + } else if (views) { + setStatus( + `Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${normalizedQuery}".`, + 'info', + { persist: true }, + ); + } else { + setStatus(`No results for "${normalizedQuery}".`, 'error', { persist: true }); } return; } @@ -895,6 +957,7 @@ if (modal && browser) { if (navEl) { navEl.innerHTML = ''; } + state.currentLinks = {}; } }; diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index a127f33..765d195 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -688,6 +688,10 @@ body { cursor: default; } +.opds-alpha-picker__button[data-empty="true"] { + opacity: 0.6; +} + .opds-modal__results-wrap { flex: 1 1 auto; min-height: 0; diff --git a/tests/test_calibre_opds.py b/tests/test_calibre_opds.py index f7dc924..cdaf8ce 100644 --- a/tests/test_calibre_opds.py +++ b/tests/test_calibre_opds.py @@ -296,3 +296,84 @@ def test_calibre_opds_search_falls_back_to_local_search(monkeypatch) -> None: result = client.search("journey") assert [entry.id for entry in result.entries] == ["2"] + + +def test_calibre_opds_browse_letter_traverses_next(monkeypatch) -> None: + client = CalibreOPDSClient("http://example.com/catalog") + root_feed = OPDSFeed( + id="catalog", + title="Browse Authors", + entries=[ + OPDSEntry( + id="nav-a", + title="A", + links=[ + OPDSLink( + href="http://example.com/catalog/authors/a", + rel="http://opds-spec.org/navigation", + type="application/atom+xml;profile=opds-catalog", + ) + ], + ) + ], + links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, + ) + page_two = OPDSFeed( + id="catalog", + title="Browse Authors", + entries=[ + OPDSEntry( + id="nav-c", + title="C", + links=[ + OPDSLink( + href="http://example.com/catalog/authors/c", + rel="http://opds-spec.org/navigation", + type="application/atom+xml;profile=opds-catalog", + ) + ], + ) + ], + links={}, + ) + letter_feed = OPDSFeed( + id="authors-c", + title="Authors starting with C", + entries=[OPDSEntry(id="author-1", title="Clarke, Arthur C.")], + links={}, + ) + + def fake_fetch(href=None, params=None): + if not href: + return root_feed + if href == "http://example.com/catalog?page=2": + return page_two + if href == "http://example.com/catalog/authors/c": + return letter_feed + return root_feed + + monkeypatch.setattr(client, "fetch_feed", fake_fetch) + + result = client.browse_letter("C") + assert [entry.id for entry in result.entries] == ["author-1"] + + +def test_calibre_opds_browse_letter_filters_when_missing_navigation(monkeypatch) -> None: + client = CalibreOPDSClient("http://example.com/catalog") + titles_feed = OPDSFeed( + id="catalog", + title="Browse Titles", + entries=[ + OPDSEntry(id="book-1", title="The Moon is a Harsh Mistress"), + OPDSEntry(id="book-2", title="Another Story"), + ], + links={}, + ) + + def fake_fetch(href=None, params=None): + return titles_feed + + monkeypatch.setattr(client, "fetch_feed", fake_fetch) + + result = client.browse_letter("M") + assert [entry.id for entry in result.entries] == ["book-1"]