feat: Enhance OPDS browser with navigation link handling and update UI for Calibre catalog

This commit is contained in:
JB
2025-10-29 06:01:21 -07:00
parent 0fc32e9f3a
commit eb4704bc26
3 changed files with 127 additions and 24 deletions
+2 -1
View File
@@ -320,7 +320,8 @@ class CalibreOPDSClient:
mime = (link.type or "").lower() mime = (link.type or "").lower()
if mime in _EPUB_MIME_TYPES: if mime in _EPUB_MIME_TYPES:
return link return link
return iterable[0] if iterable else None # No valid acquisition-style link exposed
return None
def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]: def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]:
+80 -8
View File
@@ -12,6 +12,12 @@ if (browser) {
query: '', query: '',
}; };
const ENTRY_TYPES = {
BOOK: 'book',
NAVIGATION: 'navigation',
OTHER: 'other',
};
const setStatus = (message, level) => { const setStatus = (message, level) => {
if (!statusEl) { if (!statusEl) {
return; return;
@@ -54,6 +60,36 @@ if (browser) {
return authors.filter((author) => !!author).join(', '); return authors.filter((author) => !!author).join(', ');
}; };
const findNavigationLink = (entry) => {
if (!entry || !Array.isArray(entry.links)) {
return null;
}
const candidates = entry.links.filter((link) => link && link.href);
return candidates.find((link) => {
const rel = (link.rel || '').toLowerCase();
const type = (link.type || '').toLowerCase();
if (!link.href) {
return false;
}
if (rel.includes('acquisition')) {
return false;
}
if (rel === 'self') {
return false;
}
if (type.includes('opds-catalog')) {
return true;
}
if (rel.includes('subsection') || rel.includes('collection')) {
return true;
}
if (rel.startsWith('http://opds-spec.org/sort') || rel.startsWith('http://opds-spec.org/group')) {
return true;
}
return false;
}) || null;
};
const renderNav = (links) => { const renderNav = (links) => {
if (!navEl) { if (!navEl) {
return; return;
@@ -117,17 +153,36 @@ if (browser) {
const downloadLink = entry.download && entry.download.href ? entry.download.href : null; const downloadLink = entry.download && entry.download.href ? entry.download.href : null;
const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null; const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null;
const navigationLink = findNavigationLink(entry);
let entryType = ENTRY_TYPES.OTHER;
if (downloadLink) { if (downloadLink) {
entryType = ENTRY_TYPES.BOOK;
} else if (navigationLink && navigationLink.href) {
entryType = ENTRY_TYPES.NAVIGATION;
item.classList.add('opds-browser__entry--navigation');
}
if (entryType === ENTRY_TYPES.BOOK) {
const queueButton = document.createElement('button'); const queueButton = document.createElement('button');
queueButton.type = 'button'; queueButton.type = 'button';
queueButton.className = 'button'; queueButton.className = 'button';
queueButton.textContent = 'Queue for conversion'; queueButton.textContent = 'Queue for conversion';
queueButton.addEventListener('click', () => importEntry(entry, queueButton)); queueButton.addEventListener('click', () => importEntry(entry, queueButton));
actions.appendChild(queueButton); actions.appendChild(queueButton);
} else if (entryType === ENTRY_TYPES.NAVIGATION && navigationLink) {
const browseButton = document.createElement('button');
browseButton.type = 'button';
browseButton.className = 'button button--ghost';
browseButton.textContent = 'Browse view';
browseButton.addEventListener('click', () => {
clearStatus();
loadFeed({ href: navigationLink.href, query: '' });
});
actions.appendChild(browseButton);
} }
if (alternateLink) { if (alternateLink && entryType !== ENTRY_TYPES.NAVIGATION) {
const previewLink = document.createElement('a'); const previewLink = document.createElement('a');
previewLink.className = 'button button--ghost'; previewLink.className = 'button button--ghost';
previewLink.href = alternateLink; previewLink.href = alternateLink;
@@ -145,12 +200,12 @@ if (browser) {
} }
item.appendChild(actions); item.appendChild(actions);
return item; return { element: item, type: entryType };
}; };
const renderEntries = (entries) => { const renderEntries = (entries) => {
if (!resultsEl) { if (!resultsEl) {
return; return { [ENTRY_TYPES.BOOK]: 0, [ENTRY_TYPES.NAVIGATION]: 0, [ENTRY_TYPES.OTHER]: 0 };
} }
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
if (!Array.isArray(entries) || !entries.length) { if (!Array.isArray(entries) || !entries.length) {
@@ -158,13 +213,21 @@ if (browser) {
empty.className = 'opds-browser__empty'; empty.className = 'opds-browser__empty';
empty.textContent = 'No books found in this view.'; empty.textContent = 'No books found in this view.';
resultsEl.appendChild(empty); resultsEl.appendChild(empty);
return; return { [ENTRY_TYPES.BOOK]: 0, [ENTRY_TYPES.NAVIGATION]: 0, [ENTRY_TYPES.OTHER]: 0 };
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
const stats = {
[ENTRY_TYPES.BOOK]: 0,
[ENTRY_TYPES.NAVIGATION]: 0,
[ENTRY_TYPES.OTHER]: 0,
};
entries.forEach((entry) => { entries.forEach((entry) => {
fragment.appendChild(createEntry(entry)); const { element, type } = createEntry(entry);
stats[type] += 1;
fragment.appendChild(element);
}); });
resultsEl.appendChild(fragment); resultsEl.appendChild(fragment);
return stats;
}; };
const importEntry = async (entry, trigger) => { const importEntry = async (entry, trigger) => {
@@ -220,10 +283,19 @@ if (browser) {
} }
const feed = payload.feed || {}; const feed = payload.feed || {};
state.query = query; state.query = query;
if (searchInput && typeof query === 'string') {
searchInput.value = query;
}
renderNav(feed.links); renderNav(feed.links);
renderEntries(feed.entries || []); const stats = renderEntries(feed.entries || []);
if (Array.isArray(feed.entries) && feed.entries.length) { const books = stats?.[ENTRY_TYPES.BOOK] || 0;
setStatus(`Showing ${feed.entries.length} item${feed.entries.length === 1 ? '' : 's'}.`, 'success'); const views = stats?.[ENTRY_TYPES.NAVIGATION] || 0;
if (books && views) {
setStatus(`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'}.`, 'success');
} else if (books) {
setStatus(`Found ${books} book${books === 1 ? '' : 's'} in this view.`, 'success');
} else if (views) {
setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} to drill deeper.`, 'info');
} else { } else {
setStatus('No books found in this view.', 'info'); setStatus('No books found in this view.', 'info');
} }
+31 -1
View File
@@ -5,10 +5,38 @@
{% block content %} {% block content %}
<section class="card"> <section class="card">
<div class="card__title">Find Books</div> <div class="card__title">Find Books</div>
<p class="card__subtitle">Browse your Calibre OPDS catalog and queue downloads directly into the conversion wizard.</p> <p class="card__subtitle">Browse trusted public-domain libraries or drill into your Calibre catalog without leaving abogen.</p>
<div class="card__body find-books__body">
<div class="find-books__resources">
<article class="resource-tile">
<h2>Standard Ebooks</h2>
<p>Discover meticulously produced public-domain ebooks with consistent typography, metadata, and clean EPUB sources.</p>
<p>
<a class="button" href="https://standardebooks.org/ebooks" target="_blank" rel="noopener">
Visit standardebooks.org
</a>
</p>
<p class="hint">Opens in a new tab so you can grab EPUB downloads and drag them into abogen.</p>
</article>
<article class="resource-tile">
<h2>Project Gutenberg</h2>
<p>The worlds largest public-domain ebook library, offering thousands of EPUB and plain-text editions sourced from volunteer transcriptions.</p>
<p>
<a class="button" href="https://gutenberg.org/ebooks/" target="_blank" rel="noopener">
Visit gutenberg.org
</a>
</p>
<p class="hint">Download the EPUB version for best results, then import it into abogen.</p>
</article>
</div>
{% if not opds_available %} {% if not opds_available %}
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div> <div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
{% else %} {% else %}
<div class="find-books__opds">
<h2>Calibre catalog</h2>
<p class="hint">Navigate Calibre shelves and queue downloadable titles directly into the conversion wizard.</p>
<div class="opds-browser" data-role="opds-browser"> <div class="opds-browser" data-role="opds-browser">
<form class="opds-browser__search" data-role="opds-search"> <form class="opds-browser__search" data-role="opds-search">
<label for="opds-search-input">Search your catalog</label> <label for="opds-search-input">Search your catalog</label>
@@ -20,7 +48,9 @@
<div class="opds-browser__nav" data-role="opds-nav"></div> <div class="opds-browser__nav" data-role="opds-nav"></div>
<ul class="opds-browser__results" data-role="opds-results"></ul> <ul class="opds-browser__results" data-role="opds-results"></ul>
</div> </div>
</div>
{% endif %} {% endif %}
</div>
</section> </section>
{% endblock %} {% endblock %}