mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance OPDS browser with navigation link handling and update UI for Calibre catalog
This commit is contained in:
@@ -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]:
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,52 @@
|
|||||||
{% 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>
|
||||||
{% if not opds_available %}
|
<div class="card__body find-books__body">
|
||||||
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
|
<div class="find-books__resources">
|
||||||
{% else %}
|
<article class="resource-tile">
|
||||||
<div class="opds-browser" data-role="opds-browser">
|
<h2>Standard Ebooks</h2>
|
||||||
<form class="opds-browser__search" data-role="opds-search">
|
<p>Discover meticulously produced public-domain ebooks with consistent typography, metadata, and clean EPUB sources.</p>
|
||||||
<label for="opds-search-input">Search your catalog</label>
|
<p>
|
||||||
<input type="search" id="opds-search-input" name="q" placeholder="Title, author, or keyword">
|
<a class="button" href="https://standardebooks.org/ebooks" target="_blank" rel="noopener">
|
||||||
<button type="submit" class="button">Search</button>
|
Visit standardebooks.org
|
||||||
<button type="button" class="button button--ghost" data-action="opds-refresh">Reset</button>
|
</a>
|
||||||
</form>
|
</p>
|
||||||
<div class="opds-browser__status" data-role="opds-status"></div>
|
<p class="hint">Opens in a new tab so you can grab EPUB downloads and drag them into abogen.</p>
|
||||||
<div class="opds-browser__nav" data-role="opds-nav"></div>
|
</article>
|
||||||
<ul class="opds-browser__results" data-role="opds-results"></ul>
|
|
||||||
|
<article class="resource-tile">
|
||||||
|
<h2>Project Gutenberg</h2>
|
||||||
|
<p>The world’s 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>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
{% if not opds_available %}
|
||||||
|
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
|
||||||
|
{% 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">
|
||||||
|
<form class="opds-browser__search" data-role="opds-search">
|
||||||
|
<label for="opds-search-input">Search your catalog</label>
|
||||||
|
<input type="search" id="opds-search-input" name="q" placeholder="Title, author, or keyword">
|
||||||
|
<button type="submit" class="button">Search</button>
|
||||||
|
<button type="button" class="button button--ghost" data-action="opds-refresh">Reset</button>
|
||||||
|
</form>
|
||||||
|
<div class="opds-browser__status" data-role="opds-status"></div>
|
||||||
|
<div class="opds-browser__nav" data-role="opds-nav"></div>
|
||||||
|
<ul class="opds-browser__results" data-role="opds-results"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user