feat: Implement local search functionality with alphabet filtering in OPDS feed

This commit is contained in:
JB
2025-11-11 18:09:58 -08:00
parent aba72524ce
commit 166162931c
5 changed files with 570 additions and 35 deletions
+123 -5
View File
@@ -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:
+313 -24
View File
@@ -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 = '';
}
+38
View File
@@ -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;
+1
View File
@@ -72,6 +72,7 @@
<div class="opds-browser" data-role="opds-browser">
<div class="opds-modal__status" data-role="opds-status"></div>
<div class="opds-modal__nav" data-role="opds-nav"></div>
<div class="opds-modal__alpha" data-role="opds-alpha-picker"></div>
<div class="opds-modal__results-wrap">
<ul class="opds-modal__results" data-role="opds-results"></ul>
</div>