From 13c6b120c91689bcc1ed6e3409ecc20ed854085c Mon Sep 17 00:00:00 2001 From: JB Date: Thu, 30 Oct 2025 08:27:21 -0700 Subject: [PATCH] feat: Add position handling in OPDSEntry and update navigation button behavior in the UI --- abogen/integrations/calibre_opds.py | 23 +++++++++++++++++++++ abogen/web/routes.py | 2 +- abogen/web/static/find_books.js | 32 +++++++++++++++++++---------- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py index dff4e52..0d10d76 100644 --- a/abogen/integrations/calibre_opds.py +++ b/abogen/integrations/calibre_opds.py @@ -50,6 +50,7 @@ class OPDSLink: class OPDSEntry: id: str title: str + position: Optional[int] = None authors: List[str] = field(default_factory=list) updated: Optional[str] = None summary: Optional[str] = None @@ -62,6 +63,7 @@ class OPDSEntry: return { "id": self.id, "title": self.title, + "position": self.position, "authors": list(self.authors), "updated": self.updated, "summary": self.summary, @@ -243,6 +245,7 @@ class CalibreOPDSClient: def _parse_entry(self, node: ET.Element, base_url: str) -> OPDSEntry: entry_id = node.findtext("atom:id", default="", namespaces=NS).strip() title = node.findtext("atom:title", default="Untitled", namespaces=NS).strip() or "Untitled" + position_value = self._extract_position(node) updated = node.findtext("atom:updated", default=None, namespaces=NS) summary = node.findtext("atom:summary", default=None, namespaces=NS) or node.findtext( "atom:content", default=None, namespaces=NS @@ -272,6 +275,7 @@ class CalibreOPDSClient: return OPDSEntry( id=entry_id or title, title=title, + position=position_value, authors=authors, updated=updated, summary=cleaned_summary, @@ -281,6 +285,25 @@ class CalibreOPDSClient: links=list(parsed_links.values()), ) + def _extract_position(self, node: ET.Element) -> Optional[int]: + candidates = [ + node.findtext("opds:position", default=None, namespaces=NS), + node.findtext("opds:groupPosition", default=None, namespaces=NS), + node.findtext("opds:order", default=None, namespaces=NS), + node.findtext("dc:identifier", default=None, namespaces=NS), + ] + for value in candidates: + if value is None: + continue + text = str(value).strip() + if not text: + continue + try: + return int(float(text)) + except (TypeError, ValueError): + continue + return None + def _parse_links(self, link_nodes: List[ET.Element], base_url: str) -> Dict[str, OPDSLink]: results: Dict[str, OPDSLink] = {} for link in link_nodes: diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 34fc7f9..12a4941 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -2871,7 +2871,7 @@ def calibre_opds_import() -> ResponseReturnValue: service.store_pending_job(pending) - redirect_url = url_for("web.prepare_job", pending_id=pending.id) + redirect_url = url_for("web.prepare_job", pending_id=pending.id, step="book") return jsonify({ "pending_id": pending.id, "redirect_url": redirect_url, diff --git a/abogen/web/static/find_books.js b/abogen/web/static/find_books.js index b767bb1..bfff53a 100644 --- a/abogen/web/static/find_books.js +++ b/abogen/web/static/find_books.js @@ -213,18 +213,23 @@ if (modal && browser) { ]; descriptors.forEach(({ key, label }) => { const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`); - if (!link || !link.href) { - return; - } const button = document.createElement('button'); button.type = 'button'; button.className = 'button button--ghost'; button.textContent = label; - 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) }); - }); + 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 { + return; + } navEl.appendChild(button); }); navEl.hidden = !navEl.childElementCount; @@ -237,9 +242,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'; - title.textContent = entry.title || 'Untitled'; + 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); @@ -375,6 +382,9 @@ if (modal && browser) { try { const target = new URL(redirectUrl, window.location.origin); target.searchParams.set('format', 'json'); + if (!target.searchParams.has('step')) { + target.searchParams.set('step', 'book'); + } await wizard.requestStep(target.toString(), { method: 'GET' }); } catch (wizardError) { console.error('Unable to open wizard via JSON payload', wizardError);