mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Implement local search functionality with alphabet filtering in OPDS feed
This commit is contained in:
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import PurePosixPath
|
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 urllib.parse import urljoin, urlparse
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
@@ -207,17 +208,134 @@ class CalibreOPDSClient:
|
|||||||
(None, {"search": cleaned}),
|
(None, {"search": cleaned}),
|
||||||
]
|
]
|
||||||
last_error: Optional[Exception] = None
|
last_error: Optional[Exception] = None
|
||||||
|
base_feed: Optional[OPDSFeed] = None
|
||||||
for path, params in candidates:
|
for path, params in candidates:
|
||||||
try:
|
try:
|
||||||
feed = self.fetch_feed(path, params=params)
|
feed = self.fetch_feed(path, params=params)
|
||||||
return self._filter_feed_entries(feed, cleaned)
|
|
||||||
except CalibreOPDSError as exc:
|
except CalibreOPDSError as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
continue
|
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
|
raise last_error
|
||||||
feed = self.fetch_feed()
|
if base_feed is None:
|
||||||
return self._filter_feed_entries(feed, cleaned)
|
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:
|
def download(self, href: str) -> DownloadedResource:
|
||||||
if not href:
|
if not href:
|
||||||
|
|||||||
+313
-24
@@ -5,6 +5,7 @@ if (modal && browser) {
|
|||||||
const statusEl = browser.querySelector('[data-role="opds-status"]');
|
const statusEl = browser.querySelector('[data-role="opds-status"]');
|
||||||
const resultsEl = browser.querySelector('[data-role="opds-results"]');
|
const resultsEl = browser.querySelector('[data-role="opds-results"]');
|
||||||
const navEl = browser.querySelector('[data-role="opds-nav"]');
|
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 tabsEl = modal.querySelector('[data-role="opds-tabs"]');
|
||||||
const searchForm = modal.querySelector('[data-role="opds-search"]');
|
const searchForm = modal.querySelector('[data-role="opds-search"]');
|
||||||
const searchInput = searchForm?.querySelector('input[name="q"]');
|
const searchInput = searchForm?.querySelector('input[name="q"]');
|
||||||
@@ -24,6 +25,10 @@ if (modal && browser) {
|
|||||||
OTHER: 'other',
|
OTHER: 'other',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LETTER_ALL = 'ALL';
|
||||||
|
const LETTER_NUMERIC = '#';
|
||||||
|
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
query: '',
|
query: '',
|
||||||
currentHref: '',
|
currentHref: '',
|
||||||
@@ -31,6 +36,15 @@ if (modal && browser) {
|
|||||||
tabs: [],
|
tabs: [],
|
||||||
tabsReady: false,
|
tabsReady: false,
|
||||||
requestToken: 0,
|
requestToken: 0,
|
||||||
|
feedTitle: '',
|
||||||
|
lastEntries: [],
|
||||||
|
activeLetter: LETTER_ALL,
|
||||||
|
availableLetters: new Set(),
|
||||||
|
totalStats: null,
|
||||||
|
filteredStats: null,
|
||||||
|
status: { message: '', level: null },
|
||||||
|
baseStatus: null,
|
||||||
|
lastContextKey: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
@@ -102,7 +116,265 @@ if (modal && browser) {
|
|||||||
return '';
|
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) {
|
if (!statusEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -112,10 +384,22 @@ if (modal && browser) {
|
|||||||
} else {
|
} else {
|
||||||
delete statusEl.dataset.state;
|
delete statusEl.dataset.state;
|
||||||
}
|
}
|
||||||
|
state.status = { message: message || '', level: level || null };
|
||||||
|
if (persist) {
|
||||||
|
state.baseStatus = { ...state.status };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearStatus = () => setStatus('', null);
|
const clearStatus = () => setStatus('', null);
|
||||||
|
|
||||||
|
const restoreBaseStatus = () => {
|
||||||
|
if (state.baseStatus) {
|
||||||
|
setStatus(state.baseStatus.message, state.baseStatus.level);
|
||||||
|
} else {
|
||||||
|
clearStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const focusSearch = () => {
|
const focusSearch = () => {
|
||||||
if (!searchInput) {
|
if (!searchInput) {
|
||||||
return;
|
return;
|
||||||
@@ -291,11 +575,11 @@ if (modal && browser) {
|
|||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'opds-browser__entry-head';
|
header.className = 'opds-browser__entry-head';
|
||||||
|
|
||||||
const title = document.createElement('h3');
|
const title = document.createElement('h3');
|
||||||
title.className = 'opds-browser__title';
|
title.className = 'opds-browser__title';
|
||||||
const positionLabel = Number.isFinite(entry?.position) ? Number(entry.position) : null;
|
const positionLabel = Number.isFinite(entry?.position) ? Number(entry.position) : null;
|
||||||
const baseTitle = entry.title || 'Untitled';
|
const baseTitle = entry.title || 'Untitled';
|
||||||
title.textContent = positionLabel !== null ? `${positionLabel}. ${baseTitle}` : baseTitle;
|
title.textContent = positionLabel !== null ? `${positionLabel}. ${baseTitle}` : baseTitle;
|
||||||
header.appendChild(title);
|
header.appendChild(title);
|
||||||
|
|
||||||
const authors = formatAuthors(entry.authors);
|
const authors = formatAuthors(entry.authors);
|
||||||
@@ -345,12 +629,9 @@ if (modal && 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);
|
const navigationLink = findNavigationLink(entry);
|
||||||
|
const entryType = detectEntryType(entry, navigationLink);
|
||||||
|
|
||||||
let entryType = EntryTypes.OTHER;
|
if (entryType === EntryTypes.NAVIGATION && navigationLink) {
|
||||||
if (downloadLink) {
|
|
||||||
entryType = EntryTypes.BOOK;
|
|
||||||
} else if (navigationLink && navigationLink.href) {
|
|
||||||
entryType = EntryTypes.NAVIGATION;
|
|
||||||
item.classList.add('opds-browser__entry--navigation');
|
item.classList.add('opds-browser__entry--navigation');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,10 +681,17 @@ if (modal && browser) {
|
|||||||
return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 };
|
return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 };
|
||||||
}
|
}
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
if (!Array.isArray(entries) || !entries.length) {
|
const list = Array.isArray(entries) ? entries : [];
|
||||||
|
if (!list.length) {
|
||||||
const empty = document.createElement('li');
|
const empty = document.createElement('li');
|
||||||
empty.className = 'opds-browser__empty';
|
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);
|
resultsEl.appendChild(empty);
|
||||||
return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 };
|
return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 };
|
||||||
}
|
}
|
||||||
@@ -413,7 +701,7 @@ if (modal && browser) {
|
|||||||
[EntryTypes.NAVIGATION]: 0,
|
[EntryTypes.NAVIGATION]: 0,
|
||||||
[EntryTypes.OTHER]: 0,
|
[EntryTypes.OTHER]: 0,
|
||||||
};
|
};
|
||||||
entries.forEach((entry) => {
|
list.forEach((entry) => {
|
||||||
const { element, type } = createEntry(entry);
|
const { element, type } = createEntry(entry);
|
||||||
stats[type] += 1;
|
stats[type] += 1;
|
||||||
fragment.appendChild(element);
|
fragment.appendChild(element);
|
||||||
@@ -550,6 +838,7 @@ if (modal && browser) {
|
|||||||
throw new Error(payload.error || 'Unable to load the Calibre catalog.');
|
throw new Error(payload.error || 'Unable to load the Calibre catalog.');
|
||||||
}
|
}
|
||||||
const feed = payload.feed || {};
|
const feed = payload.feed || {};
|
||||||
|
state.feedTitle = feed.title || '';
|
||||||
state.currentHref = href;
|
state.currentHref = href;
|
||||||
state.query = query;
|
state.query = query;
|
||||||
if (typeof activeTab === 'string') {
|
if (typeof activeTab === 'string') {
|
||||||
@@ -573,36 +862,36 @@ if (modal && browser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderNav(feed.links);
|
renderNav(feed.links);
|
||||||
const stats = renderEntries(feed.entries || []);
|
const { stats } = setEntries(feed.entries || [], { resetAlphabet: true });
|
||||||
const books = stats?.[EntryTypes.BOOK] || 0;
|
const books = stats?.[EntryTypes.BOOK] || 0;
|
||||||
const views = stats?.[EntryTypes.NAVIGATION] || 0;
|
const views = stats?.[EntryTypes.NAVIGATION] || 0;
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
if (books) {
|
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) {
|
} 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 {
|
} else {
|
||||||
setStatus(`No results for "${query}".`, 'error');
|
setStatus(`No results for "${query}".`, 'error', { persist: true });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (books && views) {
|
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) {
|
} 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) {
|
} 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 {
|
} else {
|
||||||
setStatus('No catalog entries found here yet.', 'info');
|
setStatus('No catalog entries found here yet.', 'info', { persist: true });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== state.requestToken) {
|
if (requestId !== state.requestToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error');
|
setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error', { persist: true });
|
||||||
renderEntries([]);
|
setEntries([], { resetAlphabet: true });
|
||||||
if (navEl) {
|
if (navEl) {
|
||||||
navEl.innerHTML = '';
|
navEl.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -650,6 +650,44 @@ body {
|
|||||||
gap: 0.5rem;
|
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 {
|
.opds-modal__results-wrap {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
<div class="opds-browser" data-role="opds-browser">
|
<div class="opds-browser" data-role="opds-browser">
|
||||||
<div class="opds-modal__status" data-role="opds-status"></div>
|
<div class="opds-modal__status" data-role="opds-status"></div>
|
||||||
<div class="opds-modal__nav" data-role="opds-nav"></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">
|
<div class="opds-modal__results-wrap">
|
||||||
<ul class="opds-modal__results" data-role="opds-results"></ul>
|
<ul class="opds-modal__results" data-role="opds-results"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
from abogen.integrations.calibre_opds import (
|
from abogen.integrations.calibre_opds import CalibreOPDSClient, OPDSEntry, OPDSFeed, OPDSLink, feed_to_dict
|
||||||
CalibreOPDSClient,
|
|
||||||
OPDSEntry,
|
|
||||||
OPDSFeed,
|
|
||||||
feed_to_dict,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_calibre_opds_feed_exposes_series_metadata() -> None:
|
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")
|
filtered = client._filter_feed_entries(feed, "missing")
|
||||||
assert filtered.entries == []
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user