diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py index a83a107..c8632fa 100644 --- a/abogen/integrations/calibre_opds.py +++ b/abogen/integrations/calibre_opds.py @@ -3,9 +3,10 @@ from __future__ import annotations import dataclasses import html import re +from collections import deque from dataclasses import dataclass, field from pathlib import PurePosixPath -from typing import Any, Dict, Iterable, List, Mapping, Optional +from typing import Any, Deque, Dict, Iterable, List, Mapping, Optional, Set from urllib.parse import urljoin, urlparse from xml.etree import ElementTree as ET @@ -207,17 +208,134 @@ class CalibreOPDSClient: (None, {"search": cleaned}), ] last_error: Optional[Exception] = None + base_feed: Optional[OPDSFeed] = None for path, params in candidates: try: feed = self.fetch_feed(path, params=params) - return self._filter_feed_entries(feed, cleaned) except CalibreOPDSError as exc: last_error = exc continue - if last_error is not None: + filtered = self._filter_feed_entries(feed, cleaned) + if filtered.entries: + return filtered + fallback = self._local_search(cleaned, seed_feed=feed) + if fallback.entries: + return fallback + if base_feed is None: + try: + base_feed = self.fetch_feed() + except CalibreOPDSError: + base_feed = None + if base_feed is not None: + filtered_root = self._filter_feed_entries(base_feed, cleaned) + if filtered_root.entries: + return filtered_root + fallback_root = self._local_search(cleaned, seed_feed=base_feed) + if fallback_root.entries: + return fallback_root + if last_error is not None and base_feed is None: raise last_error - feed = self.fetch_feed() - return self._filter_feed_entries(feed, cleaned) + if base_feed is None: + base_feed = self.fetch_feed() + filtered = self._filter_feed_entries(base_feed, cleaned) + if filtered.entries: + return filtered + return self._local_search(cleaned, seed_feed=base_feed) + + def _local_search( + self, + query: str, + *, + seed_feed: Optional[OPDSFeed] = None, + max_pages: int = 40, + ) -> OPDSFeed: + normalized = (query or "").strip() + if not normalized: + return seed_feed or self.fetch_feed() + tokens = [token for token in re.split(r"\s+", normalized.lower()) if token] + if not tokens: + return seed_feed or self.fetch_feed() + + start_feed = seed_feed or self.fetch_feed() + initial_filtered = self._filter_feed_entries(start_feed, normalized) + if initial_filtered.entries: + return initial_filtered + + queue: Deque[str] = deque() + queued: Set[str] = set() + visited: Set[str] = set() + + def is_navigation_link(rel_hint: Optional[str], link: OPDSLink) -> bool: + rel_candidates: List[str] = [] + if rel_hint: + rel_candidates.append(rel_hint) + if link.rel and link.rel not in rel_candidates: + rel_candidates.append(link.rel) + rel_candidates = [(rel or "").strip().lower() for rel in rel_candidates if rel] + link_type = (link.type or "").strip().lower() + if link_type and "opds-catalog" in link_type: + return True + for rel_value in rel_candidates: + if not rel_value: + continue + if "acquisition" in rel_value: + return False + if rel_value == "self": + continue + if rel_value == "next": + return True + if rel_value in {"start", "up", "down"}: + return True + if rel_value.endswith("navigation") or rel_value.endswith("collection"): + return True + if rel_value.startswith("http://opds-spec.org/"): + if rel_value.startswith("http://opds-spec.org/group") or rel_value.startswith( + "http://opds-spec.org/sort" + ): + return True + if rel_value.endswith("navigation") or rel_value.endswith("collection"): + return True + return False + + def enqueue_link(link: OPDSLink, rel_hint: Optional[str] = None) -> None: + if not is_navigation_link(rel_hint, link): + return + href = (link.href or "").strip() + if not href: + return + absolute = self._make_url(href) + if absolute in queued or absolute in visited: + return + queued.add(absolute) + queue.append(absolute) + + for rel_key, link in (start_feed.links or {}).items(): + enqueue_link(link, rel_key) + for entry in start_feed.entries: + for link in entry.links: + enqueue_link(link, link.rel) + + pages_examined = 0 + while queue and pages_examined < max_pages: + href = queue.popleft() + if href in visited: + continue + visited.add(href) + pages_examined += 1 + try: + feed = self.fetch_feed(href) + except CalibreOPDSError: + continue + filtered = self._filter_feed_entries(feed, normalized) + if filtered.entries: + return filtered + for rel_key, link in (feed.links or {}).items(): + enqueue_link(link, rel_key) + for entry in feed.entries: + for link in entry.links: + enqueue_link(link, link.rel) + + return dataclasses.replace(start_feed, entries=[]) def download(self, href: str) -> DownloadedResource: if not href: diff --git a/abogen/web/static/find_books.js b/abogen/web/static/find_books.js index 23f0e1f..2c749f0 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 alphaPickerEl = browser.querySelector('[data-role="opds-alpha-picker"]'); const tabsEl = modal.querySelector('[data-role="opds-tabs"]'); const searchForm = modal.querySelector('[data-role="opds-search"]'); const searchInput = searchForm?.querySelector('input[name="q"]'); @@ -24,6 +25,10 @@ if (modal && browser) { OTHER: 'other', }; + const LETTER_ALL = 'ALL'; + const LETTER_NUMERIC = '#'; + const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + const state = { query: '', currentHref: '', @@ -31,6 +36,15 @@ if (modal && browser) { tabs: [], tabsReady: false, requestToken: 0, + feedTitle: '', + lastEntries: [], + activeLetter: LETTER_ALL, + availableLetters: new Set(), + totalStats: null, + filteredStats: null, + status: { message: '', level: null }, + baseStatus: null, + lastContextKey: '', }; let isOpen = false; @@ -102,7 +116,265 @@ if (modal && browser) { return ''; }; - const setStatus = (message, level) => { + const deriveBrowseMode = () => { + const title = (state.feedTitle || '').toLowerCase(); + if (!title) { + return 'generic'; + } + if (title.includes('author')) { + return 'author'; + } + if (title.includes('series')) { + return 'series'; + } + if (title.includes('title')) { + return 'title'; + } + if (title.includes('books')) { + return 'title'; + } + return 'generic'; + }; + + const stripLeadingArticle = (text) => text.replace(/^(?:the|a|an)\s+/i, '').trim(); + + const extractAlphabetSource = (entry) => { + if (!entry) { + return ''; + } + const mode = deriveBrowseMode(); + if (mode === 'author') { + if (Array.isArray(entry.authors) && entry.authors.length) { + return entry.authors[0] || ''; + } + } + if (mode === 'series' && entry.series) { + return entry.series; + } + if (entry.title) { + return entry.title; + } + if (entry.series) { + return entry.series; + } + const navLink = findNavigationLink(entry); + if (navLink?.title) { + return navLink.title; + } + return ''; + }; + + const deriveAlphabetKey = (entry) => { + const mode = deriveBrowseMode(); + let source = (extractAlphabetSource(entry) || '').trim(); + if (!source) { + return ''; + } + if (mode === 'author') { + if (source.includes(',')) { + source = source.split(',')[0]; + } else { + const parts = source.split(/\s+/); + if (parts.length > 1) { + source = parts[parts.length - 1]; + } + } + } else if (mode === 'title') { + source = stripLeadingArticle(source); + } + source = source.replace(/^[^\p{L}\p{N}]+/u, ''); + return source; + }; + + const deriveAlphabetLetter = (entry) => { + const key = deriveAlphabetKey(entry); + if (!key) { + return LETTER_NUMERIC; + } + const initial = key.charAt(0).toUpperCase(); + if (initial >= 'A' && initial <= 'Z') { + return initial; + } + return LETTER_NUMERIC; + }; + + const collectAlphabetCounts = (entries) => { + const counts = new Map(); + if (!Array.isArray(entries)) { + return counts; + } + entries.forEach((entry) => { + const letter = deriveAlphabetLetter(entry); + counts.set(letter, (counts.get(letter) || 0) + 1); + }); + return counts; + }; + + const detectEntryType = (entry, navigationLink) => { + if (!entry) { + return EntryTypes.OTHER; + } + const downloadLink = entry.download && entry.download.href ? entry.download.href : null; + if (downloadLink) { + return EntryTypes.BOOK; + } + const navLink = navigationLink === undefined ? findNavigationLink(entry) : navigationLink; + if (navLink && navLink.href) { + return EntryTypes.NAVIGATION; + } + return EntryTypes.OTHER; + }; + + const computeEntryStats = (entries) => { + const stats = { + [EntryTypes.BOOK]: 0, + [EntryTypes.NAVIGATION]: 0, + [EntryTypes.OTHER]: 0, + }; + if (!Array.isArray(entries)) { + return stats; + } + entries.forEach((entry) => { + const type = detectEntryType(entry); + stats[type] += 1; + }); + return stats; + }; + + const shouldShowAlphabetPicker = (entries, stats) => { + if (!alphaPickerEl || state.query) { + return false; + } + if (!Array.isArray(entries) || entries.length < 6) { + 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; + }; + + const refreshAlphabetActiveState = () => { + if (!alphaPickerEl) { + return; + } + const buttons = alphaPickerEl.querySelectorAll('button[data-letter]'); + buttons.forEach((button) => { + const value = button.dataset.letter || LETTER_ALL; + const isActive = value === state.activeLetter; + button.classList.toggle('is-active', isActive); + button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + }; + + const describeAlphabetLetter = (letter) => { + if (letter === LETTER_ALL) { + return 'all entries'; + } + if (letter === LETTER_NUMERIC) { + return 'numbers or symbols'; + } + return `letter ${letter}`; + }; + + const updateAlphabetPicker = (entries, { reset = false, stats = null } = {}) => { + if (!alphaPickerEl) { + return; + } + const list = Array.isArray(entries) ? entries : []; + if (reset) { + state.activeLetter = LETTER_ALL; + } + const counts = collectAlphabetCounts(list); + state.availableLetters = new Set(counts.keys()); + const shouldShow = shouldShowAlphabetPicker(list, stats); + if (!shouldShow) { + alphaPickerEl.innerHTML = ''; + alphaPickerEl.hidden = true; + state.activeLetter = LETTER_ALL; + return; + } + + if (state.activeLetter !== LETTER_ALL && !state.availableLetters.has(state.activeLetter)) { + state.activeLetter = LETTER_ALL; + } + + alphaPickerEl.hidden = false; + alphaPickerEl.innerHTML = ''; + const letters = [LETTER_ALL, ...ALPHABET, LETTER_NUMERIC]; + letters.forEach((letter) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'opds-alpha-picker__button'; + button.dataset.letter = letter; + 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)); + } + alphaPickerEl.appendChild(button); + }); + refreshAlphabetActiveState(); + }; + + const handleAlphabetSelect = (letter) => { + const normalized = letter || LETTER_ALL; + if (!Array.isArray(state.lastEntries) || !state.lastEntries.length) { + return; + } + if (normalized === state.activeLetter) { + return; + } + 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(); + } + }; + + const applyAlphabetFilter = (entries) => { + if (!Array.isArray(entries) || !entries.length) { + return []; + } + if (state.activeLetter === LETTER_ALL) { + return entries.slice(); + } + return entries.filter((entry) => { + const letter = deriveAlphabetLetter(entry); + if (state.activeLetter === LETTER_NUMERIC) { + return letter === LETTER_NUMERIC; + } + return letter === state.activeLetter; + }); + }; + + const setEntries = (entries, { resetAlphabet = false } = {}) => { + const list = Array.isArray(entries) ? entries.slice() : []; + state.lastEntries = list; + const totalStats = computeEntryStats(list); + state.totalStats = totalStats; + if (resetAlphabet) { + state.activeLetter = LETTER_ALL; + } + const filtered = applyAlphabetFilter(list); + const filteredStats = renderEntries(filtered); + state.filteredStats = filteredStats; + updateAlphabetPicker(list, { reset: resetAlphabet, stats: totalStats }); + return { stats: totalStats, filteredStats }; + }; + + const setStatus = (message, level, { persist = false } = {}) => { if (!statusEl) { return; } @@ -112,10 +384,22 @@ if (modal && browser) { } else { delete statusEl.dataset.state; } + state.status = { message: message || '', level: level || null }; + if (persist) { + state.baseStatus = { ...state.status }; + } }; const clearStatus = () => setStatus('', null); + const restoreBaseStatus = () => { + if (state.baseStatus) { + setStatus(state.baseStatus.message, state.baseStatus.level); + } else { + clearStatus(); + } + }; + const focusSearch = () => { if (!searchInput) { return; @@ -291,11 +575,11 @@ if (modal && browser) { const header = document.createElement('div'); header.className = 'opds-browser__entry-head'; - const title = document.createElement('h3'); - title.className = 'opds-browser__title'; - const positionLabel = Number.isFinite(entry?.position) ? Number(entry.position) : null; - const baseTitle = entry.title || 'Untitled'; - title.textContent = positionLabel !== null ? `${positionLabel}. ${baseTitle}` : baseTitle; + const title = document.createElement('h3'); + title.className = 'opds-browser__title'; + const positionLabel = Number.isFinite(entry?.position) ? Number(entry.position) : null; + const baseTitle = entry.title || 'Untitled'; + title.textContent = positionLabel !== null ? `${positionLabel}. ${baseTitle}` : baseTitle; header.appendChild(title); const authors = formatAuthors(entry.authors); @@ -345,12 +629,9 @@ if (modal && browser) { const downloadLink = entry.download && entry.download.href ? entry.download.href : null; const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null; const navigationLink = findNavigationLink(entry); + const entryType = detectEntryType(entry, navigationLink); - let entryType = EntryTypes.OTHER; - if (downloadLink) { - entryType = EntryTypes.BOOK; - } else if (navigationLink && navigationLink.href) { - entryType = EntryTypes.NAVIGATION; + if (entryType === EntryTypes.NAVIGATION && navigationLink) { item.classList.add('opds-browser__entry--navigation'); } @@ -400,10 +681,17 @@ if (modal && browser) { return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 }; } resultsEl.innerHTML = ''; - if (!Array.isArray(entries) || !entries.length) { + const list = Array.isArray(entries) ? entries : []; + if (!list.length) { const empty = document.createElement('li'); empty.className = 'opds-browser__empty'; - empty.textContent = 'No catalog entries found here yet.'; + if (state.activeLetter !== LETTER_ALL) { + empty.textContent = `No entries start with ${describeAlphabetLetter(state.activeLetter)}.`; + } else if (state.query) { + empty.textContent = 'No results returned for this view yet.'; + } else { + empty.textContent = 'No catalog entries found here yet.'; + } resultsEl.appendChild(empty); return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 }; } @@ -413,7 +701,7 @@ if (modal && browser) { [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0, }; - entries.forEach((entry) => { + list.forEach((entry) => { const { element, type } = createEntry(entry); stats[type] += 1; fragment.appendChild(element); @@ -550,6 +838,7 @@ if (modal && browser) { throw new Error(payload.error || 'Unable to load the Calibre catalog.'); } const feed = payload.feed || {}; + state.feedTitle = feed.title || ''; state.currentHref = href; state.query = query; if (typeof activeTab === 'string') { @@ -573,36 +862,36 @@ if (modal && browser) { } renderNav(feed.links); - const stats = renderEntries(feed.entries || []); + const { stats } = setEntries(feed.entries || [], { resetAlphabet: true }); 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'); + setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${query}".`, 'success', { persist: true }); } else if (views) { - setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${query}".`, 'info'); + setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${query}".`, 'info', { persist: true }); } else { - setStatus(`No results for "${query}".`, 'error'); + setStatus(`No results for "${query}".`, 'error', { persist: true }); } return; } if (books && views) { - setStatus(`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'}.`, 'success'); + setStatus(`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'}.`, 'success', { persist: true }); } else if (books) { - setStatus(`Found ${books} book${books === 1 ? '' : 's'} in this view.`, 'success'); + setStatus(`Found ${books} book${books === 1 ? '' : 's'} in this view.`, 'success', { persist: true }); } else if (views) { - setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} to drill deeper.`, 'info'); + setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} to drill deeper.`, 'info', { persist: true }); } else { - setStatus('No catalog entries found here yet.', 'info'); + setStatus('No catalog entries found here yet.', 'info', { persist: true }); } } catch (error) { if (requestId !== state.requestToken) { return; } - setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error'); - renderEntries([]); + setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error', { persist: true }); + setEntries([], { resetAlphabet: true }); if (navEl) { navEl.innerHTML = ''; } diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 76460cf..a127f33 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -650,6 +650,44 @@ body { gap: 0.5rem; } +.opds-modal__alpha { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0.35rem 0 0.65rem; +} + +.opds-alpha-picker__button { + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 999px; + background: rgba(15, 23, 42, 0.35); + color: rgba(226, 232, 240, 0.88); + padding: 0.3rem 0.65rem; + font-size: 0.82rem; + letter-spacing: 0.02em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.opds-alpha-picker__button:hover { + border-color: rgba(148, 163, 184, 0.55); + color: #f8fafc; +} + +.opds-alpha-picker__button.is-active { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.65); + color: #f0f9ff; + box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.25); +} + +.opds-alpha-picker__button:disabled, +.opds-alpha-picker__button[aria-disabled="true"] { + opacity: 0.45; + cursor: default; +} + .opds-modal__results-wrap { flex: 1 1 auto; min-height: 0; diff --git a/abogen/web/templates/find_books.html b/abogen/web/templates/find_books.html index 6ecb54c..1573d5a 100644 --- a/abogen/web/templates/find_books.html +++ b/abogen/web/templates/find_books.html @@ -72,6 +72,7 @@
+
diff --git a/tests/test_calibre_opds.py b/tests/test_calibre_opds.py index 30a3c9f..f7dc924 100644 --- a/tests/test_calibre_opds.py +++ b/tests/test_calibre_opds.py @@ -1,9 +1,4 @@ -from abogen.integrations.calibre_opds import ( - CalibreOPDSClient, - OPDSEntry, - OPDSFeed, - feed_to_dict, -) +from abogen.integrations.calibre_opds import CalibreOPDSClient, OPDSEntry, OPDSFeed, OPDSLink, feed_to_dict def test_calibre_opds_feed_exposes_series_metadata() -> None: @@ -207,3 +202,97 @@ def test_calibre_opds_search_filters_by_title_and_author() -> None: filtered = client._filter_feed_entries(feed, "missing") assert filtered.entries == [] + + +def test_calibre_opds_local_search_follows_next(monkeypatch) -> None: + client = CalibreOPDSClient("http://example.com/catalog") + page_one = OPDSFeed( + id="catalog", + title="Catalog", + entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])], + links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, + ) + page_two = OPDSFeed( + id="catalog", + title="Catalog", + entries=[OPDSEntry(id="2", title="The Journey Continues", authors=["Bob Johnson"])], + links={}, + ) + + def fake_fetch(href=None, params=None): + if href == "http://example.com/catalog?page=2": + return page_two + return page_one + + monkeypatch.setattr(client, "fetch_feed", fake_fetch) + + result = client._local_search("journey", seed_feed=page_one) + assert [entry.id for entry in result.entries] == ["2"] + + +def test_calibre_opds_local_search_traverses_navigation(monkeypatch) -> None: + client = CalibreOPDSClient("http://example.com/catalog") + root_feed = OPDSFeed( + id="catalog", + title="Catalog", + entries=[ + OPDSEntry( + id="nav-authors", + title="Browse Authors", + links=[ + OPDSLink( + href="http://example.com/catalog/authors", + rel="http://opds-spec.org/navigation", + type="application/atom+xml;profile=opds-catalog", + ) + ], + ) + ], + links={}, + ) + authors_feed = OPDSFeed( + id="authors", + title="Authors", + entries=[ + OPDSEntry(id="book-42", title="The Count of Monte Cristo", authors=["Alexandre Dumas"]) + ], + links={}, + ) + + def fake_fetch(href=None, params=None): + if href == "http://example.com/catalog/authors": + return authors_feed + return root_feed + + monkeypatch.setattr(client, "fetch_feed", fake_fetch) + + result = client._local_search("monte cristo", seed_feed=root_feed) + assert [entry.id for entry in result.entries] == ["book-42"] + + +def test_calibre_opds_search_falls_back_to_local_search(monkeypatch) -> None: + client = CalibreOPDSClient("http://example.com/catalog") + search_page = OPDSFeed( + id="catalog", + title="Catalog", + entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])], + links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, + ) + next_page = OPDSFeed( + id="catalog", + title="Catalog", + entries=[OPDSEntry(id="2", title="Journey in Space", authors=["Cara Nguyen"])], + links={}, + ) + + def fake_fetch(path=None, params=None): + if path == "search": + return search_page + if path == "http://example.com/catalog?page=2": + return next_page + return search_page + + monkeypatch.setattr(client, "fetch_feed", fake_fetch) + + result = client.search("journey") + assert [entry.id for entry in result.entries] == ["2"]