mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
976 lines
30 KiB
JavaScript
976 lines
30 KiB
JavaScript
const modal = document.querySelector('[data-role="opds-modal"]');
|
|
const browser = modal?.querySelector('[data-role="opds-browser"]') || null;
|
|
|
|
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"]');
|
|
const refreshButton = searchForm?.querySelector('[data-action="opds-refresh"]');
|
|
const openButtons = document.querySelectorAll('[data-action="open-opds-modal"]');
|
|
const closeTargets = modal.querySelectorAll('[data-role="opds-modal-close"]');
|
|
|
|
const TabIds = {
|
|
ROOT: 'root',
|
|
SEARCH: 'search',
|
|
CUSTOM: 'custom',
|
|
};
|
|
|
|
const EntryTypes = {
|
|
BOOK: 'book',
|
|
NAVIGATION: 'navigation',
|
|
OTHER: 'other',
|
|
};
|
|
|
|
const LETTER_ALL = 'ALL';
|
|
const LETTER_NUMERIC = '#';
|
|
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
|
|
|
const state = {
|
|
query: '',
|
|
currentHref: '',
|
|
activeTab: TabIds.ROOT,
|
|
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;
|
|
let lastTrigger = null;
|
|
|
|
const truncate = (text, limit = 160) => {
|
|
if (!text || typeof text !== 'string') {
|
|
return '';
|
|
}
|
|
if (text.length <= limit) {
|
|
return text;
|
|
}
|
|
return `${text.slice(0, limit - 1).trim()}…`;
|
|
};
|
|
|
|
const formatAuthors = (authors) => {
|
|
if (!Array.isArray(authors) || !authors.length) {
|
|
return '';
|
|
}
|
|
return authors.filter((author) => !!author).join(', ');
|
|
};
|
|
|
|
const formatSeriesIndex = (position) => {
|
|
if (position === null || position === undefined) {
|
|
return '';
|
|
}
|
|
const numeric = Number(position);
|
|
if (Number.isFinite(numeric)) {
|
|
if (Math.abs(numeric - Math.round(numeric)) < 0.01) {
|
|
return String(Math.round(numeric));
|
|
}
|
|
return numeric.toLocaleString(undefined, {
|
|
minimumFractionDigits: 1,
|
|
maximumFractionDigits: 2,
|
|
});
|
|
}
|
|
if (typeof position === 'string') {
|
|
const trimmed = position.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const formatSeriesLabel = (entry) => {
|
|
if (!entry) {
|
|
return '';
|
|
}
|
|
const rawSeries = typeof entry.series === 'string' ? entry.series.trim() : '';
|
|
const rawIndex =
|
|
entry.series_index ??
|
|
entry.seriesIndex ??
|
|
entry.series_position ??
|
|
entry.seriesPosition ??
|
|
entry.book_number ??
|
|
entry.bookNumber ??
|
|
null;
|
|
const indexLabel = formatSeriesIndex(rawIndex);
|
|
if (rawSeries && indexLabel) {
|
|
return `${rawSeries} · Book ${indexLabel}`;
|
|
}
|
|
if (rawSeries) {
|
|
return rawSeries;
|
|
}
|
|
if (indexLabel) {
|
|
return `Book ${indexLabel}`;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
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;
|
|
}
|
|
statusEl.textContent = message || '';
|
|
if (level) {
|
|
statusEl.dataset.state = level;
|
|
} 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;
|
|
}
|
|
window.requestAnimationFrame(() => {
|
|
try {
|
|
searchInput.focus({ preventScroll: true });
|
|
} catch (error) {
|
|
// Ignore focus issues
|
|
}
|
|
});
|
|
};
|
|
|
|
const resolveRelLink = (links, rel) => {
|
|
if (!links) {
|
|
return null;
|
|
}
|
|
if (links[rel]) {
|
|
return links[rel];
|
|
}
|
|
const key = Object.keys(links).find((entry) => entry === rel || entry.endsWith(rel));
|
|
return key ? links[key] : null;
|
|
};
|
|
|
|
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 resolveTabIdForHref = (href) => {
|
|
if (!href) {
|
|
return TabIds.ROOT;
|
|
}
|
|
const matching = state.tabs.find((tab) => tab.href === href);
|
|
return matching ? matching.id : null;
|
|
};
|
|
|
|
const buildTabsFromFeed = (feed) => {
|
|
if (!feed || !Array.isArray(feed.entries)) {
|
|
return;
|
|
}
|
|
const seen = new Set();
|
|
const nextTabs = [];
|
|
feed.entries.forEach((entry) => {
|
|
const navLink = findNavigationLink(entry);
|
|
if (!navLink || !navLink.href) {
|
|
return;
|
|
}
|
|
if (seen.has(navLink.href)) {
|
|
return;
|
|
}
|
|
seen.add(navLink.href);
|
|
const label = entry.title || navLink.title || 'Catalog view';
|
|
nextTabs.push({
|
|
id: navLink.href,
|
|
label,
|
|
href: navLink.href,
|
|
});
|
|
});
|
|
state.tabs = nextTabs;
|
|
state.tabsReady = true;
|
|
renderTabs();
|
|
};
|
|
|
|
const renderTabs = () => {
|
|
if (!tabsEl) {
|
|
return;
|
|
}
|
|
tabsEl.innerHTML = '';
|
|
const tabs = [];
|
|
tabs.push({ id: TabIds.ROOT, label: 'Catalog home', href: '' });
|
|
state.tabs.forEach((tab) => tabs.push(tab));
|
|
if (state.activeTab === TabIds.SEARCH && state.query) {
|
|
tabs.push({
|
|
id: TabIds.SEARCH,
|
|
label: `Search: "${truncate(state.query, 32)}"`,
|
|
href: '',
|
|
isSearch: true,
|
|
});
|
|
}
|
|
tabs.forEach((tab) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'opds-tab';
|
|
if (tab.isSearch) {
|
|
button.classList.add('opds-tab--search');
|
|
}
|
|
if (state.activeTab === tab.id || (tab.id !== TabIds.SEARCH && state.activeTab === tab.href)) {
|
|
button.classList.add('is-active');
|
|
}
|
|
button.textContent = tab.label;
|
|
button.addEventListener('click', () => {
|
|
if (tab.id === TabIds.SEARCH) {
|
|
loadFeed({ href: '', query: state.query, activeTab: TabIds.SEARCH });
|
|
return;
|
|
}
|
|
if (tab.id === TabIds.ROOT) {
|
|
loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true });
|
|
return;
|
|
}
|
|
loadFeed({ href: tab.href, query: '', activeTab: tab.id });
|
|
});
|
|
tabsEl.appendChild(button);
|
|
});
|
|
tabsEl.classList.toggle('is-empty', tabs.length <= 1);
|
|
};
|
|
|
|
const renderNav = (links) => {
|
|
if (!navEl) {
|
|
return;
|
|
}
|
|
navEl.innerHTML = '';
|
|
const descriptors = [
|
|
{ key: 'up', label: 'Up one level' },
|
|
{ key: 'previous', label: 'Previous page' },
|
|
{ key: 'next', label: 'Next page' },
|
|
];
|
|
descriptors.forEach(({ key, label }) => {
|
|
const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`);
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'button button--ghost';
|
|
button.textContent = label;
|
|
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;
|
|
};
|
|
|
|
const createEntry = (entry) => {
|
|
const item = document.createElement('li');
|
|
item.className = 'opds-browser__entry';
|
|
|
|
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;
|
|
header.appendChild(title);
|
|
|
|
const authors = formatAuthors(entry.authors);
|
|
if (authors) {
|
|
const meta = document.createElement('p');
|
|
meta.className = 'opds-browser__meta';
|
|
meta.textContent = authors;
|
|
header.appendChild(meta);
|
|
}
|
|
|
|
const seriesMetaText = formatSeriesLabel(entry);
|
|
if (seriesMetaText) {
|
|
const seriesMeta = document.createElement('p');
|
|
seriesMeta.className = 'opds-browser__meta';
|
|
seriesMeta.textContent = seriesMetaText;
|
|
header.appendChild(seriesMeta);
|
|
}
|
|
|
|
if (entry.rating !== null && entry.rating !== undefined && entry.rating !== '') {
|
|
const ratingMeta = document.createElement('p');
|
|
ratingMeta.className = 'opds-browser__meta';
|
|
const ratingMax = entry.rating_max ?? entry.ratingMax ?? 5;
|
|
ratingMeta.textContent = `Rating: ${entry.rating}${ratingMax ? ` / ${ratingMax}` : ''}`;
|
|
header.appendChild(ratingMeta);
|
|
}
|
|
|
|
if (Array.isArray(entry.tags) && entry.tags.length > 0) {
|
|
const tagsMeta = document.createElement('p');
|
|
tagsMeta.className = 'opds-browser__meta';
|
|
tagsMeta.textContent = `Tags: ${entry.tags.join(', ')}`;
|
|
header.appendChild(tagsMeta);
|
|
}
|
|
|
|
item.appendChild(header);
|
|
|
|
const summarySource = entry.summary || entry?.alternate?.title || entry?.download?.title || '';
|
|
if (summarySource) {
|
|
const summary = document.createElement('p');
|
|
summary.className = 'opds-browser__summary';
|
|
summary.textContent = truncate(summarySource, 420);
|
|
item.appendChild(summary);
|
|
}
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'opds-browser__actions';
|
|
|
|
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);
|
|
|
|
if (entryType === EntryTypes.NAVIGATION && navigationLink) {
|
|
item.classList.add('opds-browser__entry--navigation');
|
|
}
|
|
|
|
if (entryType === EntryTypes.BOOK) {
|
|
const queueButton = document.createElement('button');
|
|
queueButton.type = 'button';
|
|
queueButton.className = 'button';
|
|
queueButton.textContent = 'Configure conversion';
|
|
queueButton.addEventListener('click', () => importEntry(entry, queueButton));
|
|
actions.appendChild(queueButton);
|
|
} else if (entryType === EntryTypes.NAVIGATION && navigationLink) {
|
|
const browseButton = document.createElement('button');
|
|
browseButton.type = 'button';
|
|
browseButton.className = 'button button--ghost';
|
|
browseButton.textContent = 'Browse view';
|
|
browseButton.addEventListener('click', () => {
|
|
clearStatus();
|
|
const tabId = resolveTabIdForHref(navigationLink.href);
|
|
loadFeed({ href: navigationLink.href, query: '', activeTab: tabId || TabIds.CUSTOM });
|
|
});
|
|
actions.appendChild(browseButton);
|
|
}
|
|
|
|
if (alternateLink && entryType !== EntryTypes.NAVIGATION) {
|
|
const previewLink = document.createElement('a');
|
|
previewLink.className = 'button button--ghost';
|
|
previewLink.href = alternateLink;
|
|
previewLink.target = '_blank';
|
|
previewLink.rel = 'noreferrer';
|
|
previewLink.textContent = 'Open in Calibre';
|
|
actions.appendChild(previewLink);
|
|
}
|
|
|
|
if (!actions.childElementCount) {
|
|
const fallback = document.createElement('span');
|
|
fallback.className = 'opds-browser__hint';
|
|
fallback.textContent = 'No downloadable formats exposed.';
|
|
actions.appendChild(fallback);
|
|
}
|
|
|
|
item.appendChild(actions);
|
|
return { element: item, type: entryType };
|
|
};
|
|
|
|
const renderEntries = (entries) => {
|
|
if (!resultsEl) {
|
|
return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 };
|
|
}
|
|
resultsEl.innerHTML = '';
|
|
const list = Array.isArray(entries) ? entries : [];
|
|
if (!list.length) {
|
|
const empty = document.createElement('li');
|
|
empty.className = 'opds-browser__empty';
|
|
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 };
|
|
}
|
|
const fragment = document.createDocumentFragment();
|
|
const stats = {
|
|
[EntryTypes.BOOK]: 0,
|
|
[EntryTypes.NAVIGATION]: 0,
|
|
[EntryTypes.OTHER]: 0,
|
|
};
|
|
list.forEach((entry) => {
|
|
const { element, type } = createEntry(entry);
|
|
stats[type] += 1;
|
|
fragment.appendChild(element);
|
|
});
|
|
resultsEl.appendChild(fragment);
|
|
return stats;
|
|
};
|
|
|
|
const importEntry = async (entry, trigger) => {
|
|
if (!entry?.download?.href) {
|
|
setStatus('This entry cannot be imported automatically.', 'error');
|
|
return;
|
|
}
|
|
const button = trigger;
|
|
const originalLabel = button ? button.textContent : '';
|
|
if (button) {
|
|
button.disabled = true;
|
|
button.dataset.loading = 'true';
|
|
button.textContent = 'Preparing…';
|
|
}
|
|
setStatus('Downloading book from Calibre. This can take a minute…', 'loading');
|
|
try {
|
|
const requestPayload = {
|
|
href: entry.download.href,
|
|
title: entry.title || '',
|
|
};
|
|
const metadata = {};
|
|
if (entry.series) {
|
|
metadata.series = entry.series;
|
|
metadata.series_name = entry.series;
|
|
}
|
|
const seriesIndex = entry.series_index ?? entry.seriesIndex ?? null;
|
|
if (seriesIndex !== null && seriesIndex !== undefined && seriesIndex !== '') {
|
|
metadata.series_index = seriesIndex;
|
|
metadata.series_position = seriesIndex;
|
|
metadata.book_number = seriesIndex;
|
|
}
|
|
if (Array.isArray(entry.tags) && entry.tags.length > 0) {
|
|
const tagsText = entry.tags.join(', ');
|
|
metadata.tags = tagsText;
|
|
metadata.keywords = tagsText;
|
|
metadata.genre = tagsText;
|
|
}
|
|
if (typeof entry.summary === 'string' && entry.summary.trim()) {
|
|
metadata.description = entry.summary;
|
|
metadata.summary = entry.summary;
|
|
}
|
|
if (entry.rating !== null && entry.rating !== undefined && entry.rating !== '') {
|
|
metadata.rating = String(entry.rating);
|
|
}
|
|
if (entry.rating_max !== null && entry.rating_max !== undefined && entry.rating_max !== '') {
|
|
metadata.rating_max = String(entry.rating_max);
|
|
}
|
|
if (entry.published) {
|
|
metadata.published = entry.published;
|
|
metadata.publication_date = entry.published;
|
|
try {
|
|
const publishedDate = new Date(entry.published);
|
|
if (!Number.isNaN(publishedDate.getTime())) {
|
|
const year = String(publishedDate.getUTCFullYear());
|
|
metadata.publication_year = year;
|
|
metadata.year = year;
|
|
}
|
|
} catch (error) {
|
|
// Ignore invalid date parsing issues
|
|
}
|
|
}
|
|
if (Object.keys(metadata).length > 0) {
|
|
requestPayload.metadata = metadata;
|
|
}
|
|
const response = await fetch('/api/integrations/calibre-opds/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(requestPayload),
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(payload.error || 'Unable to queue this book.');
|
|
}
|
|
setStatus('Preparing the conversion wizard…', 'success');
|
|
closeModal();
|
|
const redirectUrl = payload.redirect_url || '';
|
|
if (redirectUrl) {
|
|
const wizard = window.AbogenWizard;
|
|
if (wizard?.requestStep) {
|
|
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);
|
|
window.location.assign(redirectUrl);
|
|
}
|
|
} else {
|
|
window.location.assign(redirectUrl);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message : 'Unable to queue this book.', 'error');
|
|
} finally {
|
|
if (button) {
|
|
button.disabled = false;
|
|
delete button.dataset.loading;
|
|
if (originalLabel) {
|
|
button.textContent = originalLabel;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const loadFeed = async ({ href = '', query = '', activeTab = null, updateTabs = false } = {}) => {
|
|
const params = new URLSearchParams();
|
|
if (href) {
|
|
params.set('href', href);
|
|
}
|
|
if (query) {
|
|
params.set('q', query);
|
|
}
|
|
|
|
const requestId = ++state.requestToken;
|
|
setStatus('Loading catalog…', 'loading');
|
|
|
|
try {
|
|
const url = `/api/integrations/calibre-opds/feed${params.toString() ? `?${params.toString()}` : ''}`;
|
|
const response = await fetch(url);
|
|
const payload = await response.json();
|
|
if (requestId !== state.requestToken) {
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
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') {
|
|
state.activeTab = activeTab;
|
|
} else if (query) {
|
|
state.activeTab = TabIds.SEARCH;
|
|
} else if (href) {
|
|
state.activeTab = resolveTabIdForHref(href) || TabIds.CUSTOM;
|
|
} else {
|
|
state.activeTab = TabIds.ROOT;
|
|
}
|
|
|
|
if (searchInput) {
|
|
searchInput.value = query || '';
|
|
}
|
|
|
|
if (updateTabs || !state.tabsReady) {
|
|
buildTabsFromFeed(feed);
|
|
} else {
|
|
renderTabs();
|
|
}
|
|
|
|
renderNav(feed.links);
|
|
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', { persist: true });
|
|
} else if (views) {
|
|
setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${query}".`, 'info', { persist: true });
|
|
} else {
|
|
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', { persist: true });
|
|
} else if (books) {
|
|
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', { persist: true });
|
|
} else {
|
|
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', { persist: true });
|
|
setEntries([], { resetAlphabet: true });
|
|
if (navEl) {
|
|
navEl.innerHTML = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const openModal = (trigger) => {
|
|
if (isOpen) {
|
|
focusSearch();
|
|
return;
|
|
}
|
|
isOpen = true;
|
|
lastTrigger = trigger || null;
|
|
modal.hidden = false;
|
|
modal.dataset.open = 'true';
|
|
document.body.classList.add('modal-open');
|
|
focusSearch();
|
|
loadFeed({ href: state.currentHref || '', query: state.query || '', activeTab: state.activeTab || TabIds.ROOT, updateTabs: !state.tabsReady });
|
|
};
|
|
|
|
const closeModal = () => {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
isOpen = false;
|
|
modal.hidden = true;
|
|
delete modal.dataset.open;
|
|
document.body.classList.remove('modal-open');
|
|
if (lastTrigger instanceof HTMLElement) {
|
|
lastTrigger.focus({ preventScroll: true });
|
|
}
|
|
};
|
|
|
|
const handleKeydown = (event) => {
|
|
if (event.key === 'Escape' && isOpen) {
|
|
event.preventDefault();
|
|
closeModal();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeydown);
|
|
|
|
openButtons.forEach((button) => {
|
|
button.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
openModal(button);
|
|
});
|
|
});
|
|
|
|
closeTargets.forEach((target) => {
|
|
target.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
closeModal();
|
|
});
|
|
});
|
|
|
|
modal.addEventListener('click', (event) => {
|
|
if (event.target === modal) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
if (searchForm && searchInput) {
|
|
searchForm.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
const query = searchInput.value.trim();
|
|
if (!query) {
|
|
loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true });
|
|
} else {
|
|
loadFeed({ href: '', query, activeTab: TabIds.SEARCH });
|
|
}
|
|
});
|
|
}
|
|
|
|
if (refreshButton && searchInput) {
|
|
refreshButton.addEventListener('click', () => {
|
|
searchInput.value = '';
|
|
loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true });
|
|
});
|
|
}
|
|
}
|