421 lines
19 KiB
JavaScript
421 lines
19 KiB
JavaScript
/*
|
|
* Suche (docs/search.md): one field for all of diora — books and magazines (searched here:
|
|
* their titles are encrypted, the server never sees them), audiobooks, podcasts and their
|
|
* episodes, news and blog articles and sources, saved stations (the server's /search/).
|
|
* A section of its own in the navigation (nav.js): on a phone beside Home · Lesen · Hören,
|
|
* in the sidebar at its foot above Abos & Quellen. A hit opens where it lives.
|
|
* Runs in the web page, the desktop app and the Android apps. Chrome-61-safe: no ?. / ??.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
const tab = document.getElementById('tab-suche');
|
|
if (!tab) return;
|
|
|
|
const RECENT_KEY = 'diora_search_recent';
|
|
const st = {q: '', server: null, loading: false, error: '', more: {}};
|
|
let timer = 0;
|
|
let asked = 0; // the newest request; older answers are dropped
|
|
|
|
// ---- helpers -------------------------------------------------------------------------
|
|
|
|
function esc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
|
({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
|
}
|
|
|
|
// As the server folds them (diora/search.py): lower case, no diacritics, words.
|
|
function fold(s) {
|
|
s = String(s || '').toLowerCase();
|
|
if (s.normalize) s = s.normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
|
return s;
|
|
}
|
|
// Letters and digits after fold() (no \p{L}: Chrome 61).
|
|
const WORD = '0-9a-z\u00df-\u024f\u0370-\u03ff\u0400-\u04ff';
|
|
const NOT_WORD = new RegExp('[^' + WORD + ']+');
|
|
const words = s => fold(s).split(NOT_WORD).filter(Boolean);
|
|
function matches(qw, text) {
|
|
const have = words(text);
|
|
return qw.every(w => have.some(h => h.indexOf(w) === 0));
|
|
}
|
|
function rank(qw, title) {
|
|
const t = words(title);
|
|
if (t.length && t[0].indexOf(qw[0]) === 0) return 0;
|
|
return qw.every(w => t.some(h => h.indexOf(w) === 0)) ? 1 : 2;
|
|
}
|
|
|
|
// The query's words marked in a title.
|
|
function mark(text, qw) {
|
|
const s = String(text || '');
|
|
if (!qw.length) return esc(s);
|
|
const f = fold(s);
|
|
const hits = [];
|
|
const re = new RegExp('[' + WORD + ']+', 'g');
|
|
let m;
|
|
while ((m = re.exec(f))) {
|
|
if (qw.some(w => m[0].indexOf(w) === 0)) hits.push([m.index, m.index + m[0].length]);
|
|
}
|
|
// fold() keeps the length for nearly everything; if not, no marks rather than wrong ones.
|
|
if (f.length !== s.length) return esc(s);
|
|
let out = '', at = 0;
|
|
for (const h of hits) {
|
|
out += esc(s.slice(at, h[0])) + '<mark>' + esc(s.slice(h[0], h[1])) + '</mark>';
|
|
at = h[1];
|
|
}
|
|
return out + esc(s.slice(at));
|
|
}
|
|
|
|
function go(id) {
|
|
if (window.DioraNav) window.DioraNav.go(id);
|
|
else if (typeof showTab === 'function') showTab(id);
|
|
}
|
|
|
|
function host() {
|
|
const n = window.DioraNative;
|
|
if (n && typeof n.podcastPlayEpisode === 'function') return n;
|
|
return window.DioraPodcastHost || null;
|
|
}
|
|
|
|
function load(key) {
|
|
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; }
|
|
}
|
|
function save(key, v) {
|
|
try { localStorage.setItem(key, JSON.stringify(v)); } catch (e) {}
|
|
}
|
|
|
|
function remember(q) {
|
|
q = q.trim();
|
|
if (q.length < 2) return;
|
|
const list = (load(RECENT_KEY) || []).filter(x => fold(x) !== fold(q));
|
|
list.unshift(q);
|
|
save(RECENT_KEY, list.slice(0, 8));
|
|
}
|
|
|
|
function when(iso) {
|
|
if (!iso) return '';
|
|
const d = new Date(iso);
|
|
if (isNaN(d)) return '';
|
|
const days = Math.floor((Date.now() - d.getTime()) / 864e5);
|
|
if (days < 1) return 'heute';
|
|
if (days < 2) return 'gestern';
|
|
if (days < 7) return 'vor ' + days + ' Tagen';
|
|
return d.toLocaleDateString('de-DE', {day: 'numeric', month: 'short', year: days > 300 ? 'numeric' : undefined});
|
|
}
|
|
|
|
function minutes(sec) {
|
|
const m = Math.round((sec || 0) / 60);
|
|
if (!m) return '';
|
|
return m < 60 ? m + ' Min' : Math.floor(m / 60) + ' Std' + (m % 60 ? ' ' + (m % 60) + ' Min' : '');
|
|
}
|
|
|
|
// ---- the books: from this device ------------------------------------------------------
|
|
|
|
function libraryBooks() {
|
|
const list = typeof _lastBookListData !== 'undefined' && _lastBookListData && _lastBookListData.length
|
|
? _lastBookListData : null;
|
|
if (list) return list;
|
|
if (typeof bookMetaCache === 'undefined') return [];
|
|
return Object.keys(bookMetaCache).map(id => Object.assign({id: Number(id)}, bookMetaCache[id]));
|
|
}
|
|
|
|
function isMagazine(b) {
|
|
return typeof window._isMagazine === 'function' ? window._isMagazine(b) : b.magazine === true;
|
|
}
|
|
|
|
function findBooks(qw) {
|
|
const found = libraryBooks().filter(b => b.keyOk !== false &&
|
|
matches(qw, [b.title, b.author, b.series, b.filename, (b.folders || []).join(' ')].join(' ')));
|
|
// One work in several editions (PDF and EPUB): once.
|
|
const seen = {};
|
|
const out = [];
|
|
for (const b of found) {
|
|
if (b.work) {
|
|
if (seen[b.work]) continue;
|
|
seen[b.work] = true;
|
|
}
|
|
out.push(b);
|
|
}
|
|
out.sort((a, b) => rank(qw, a.title) - rank(qw, b.title) ||
|
|
String(b.last_read || '').localeCompare(String(a.last_read || '')) ||
|
|
String(a.title).localeCompare(String(b.title), 'de'));
|
|
return {books: out.filter(b => !isMagazine(b)), magazines: out.filter(isMagazine)};
|
|
}
|
|
|
|
// ---- the server's part ----------------------------------------------------------------
|
|
|
|
async function ask(q) {
|
|
const n = ++asked;
|
|
st.loading = true;
|
|
st.error = '';
|
|
render();
|
|
try {
|
|
const r = await fetch('/search/?q=' + encodeURIComponent(q), {credentials: 'same-origin', cache: 'no-store'});
|
|
const d = await r.json().catch(() => null);
|
|
if (n !== asked) return;
|
|
if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status));
|
|
st.server = d;
|
|
} catch (e) {
|
|
if (n !== asked) return;
|
|
st.server = null;
|
|
st.error = navigator.onLine === false ? 'Offline: nur die Bücher auf diesem Gerät.' : 'Suche gerade nicht erreichbar.';
|
|
}
|
|
st.loading = false;
|
|
render(true);
|
|
}
|
|
|
|
function changed(q) {
|
|
st.q = q;
|
|
st.more = {};
|
|
clearTimeout(timer);
|
|
const qw = words(q);
|
|
if (!qw.length || qw.join('').length < 2) {
|
|
asked++;
|
|
st.server = null;
|
|
st.loading = false;
|
|
st.error = '';
|
|
render(true);
|
|
return;
|
|
}
|
|
render(true); // the books at once
|
|
timer = setTimeout(() => ask(q), 250);
|
|
}
|
|
|
|
// ---- drawing ---------------------------------------------------------------------------
|
|
|
|
function art(url, glyph) {
|
|
return url
|
|
? '<img class="sx-art" src="' + esc(url) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
|
|
: '<span class="sx-art sx-noart">' + glyph + '</span>';
|
|
}
|
|
|
|
function bookArt(b) {
|
|
const url = window.DioraCovers && b.has_cover !== undefined ? window.DioraCovers.url(b) : null;
|
|
return art(url, b.type === 'pdf' ? 'PDF' : '▤');
|
|
}
|
|
|
|
function row(key, artHtml, title, meta, extra) {
|
|
return '<div class="sx-row"><button type="button" class="sx-main" data-open="' + esc(key) + '">' + artHtml +
|
|
'<span class="sx-text"><span class="sx-title">' + title + '</span>' +
|
|
(meta ? '<span class="sx-meta">' + meta + '</span>' : '') + '</span></button>' + (extra || '') + '</div>';
|
|
}
|
|
|
|
function group(id, label, items, total, draw) {
|
|
if (!items.length) return '';
|
|
const open = !!st.more[id];
|
|
const shown = open ? items : items.slice(0, 4);
|
|
const rest = (total || items.length) - shown.length;
|
|
return '<section class="sx-group"><h3 class="sx-h">' + esc(label) +
|
|
'<span class="sx-count">' + (total || items.length) + '</span></h3>' + shown.map(draw).join('') +
|
|
(rest > 0 && items.length > shown.length
|
|
? '<button type="button" class="sx-more" data-more="' + id + '">' + (items.length - shown.length) + ' weitere</button>'
|
|
: '') + '</section>';
|
|
}
|
|
|
|
let index = {}; // data-open key -> what to open
|
|
function render(keepFocus) {
|
|
const qw = words(st.q);
|
|
const active = qw.length && qw.join('').length >= 2;
|
|
index = {};
|
|
const field = tab.querySelector('.sx-q');
|
|
let h = '';
|
|
if (!active) {
|
|
const recent = load(RECENT_KEY) || [];
|
|
h += recent.length
|
|
? '<div class="sx-recent"><span class="sx-hint">Zuletzt gesucht</span>' + recent.map(q =>
|
|
'<button type="button" class="sx-chip" data-q="' + esc(q) + '">' + esc(q) + '</button>').join('') + '</div>'
|
|
: '<p class="sx-hint">Bücher, Zeitschriften, Hörbücher, Podcasts und ihre Folgen, Artikel, Quellen und Sender.</p>';
|
|
} else {
|
|
const local = findBooks(qw);
|
|
const s = st.server || {};
|
|
let any = false;
|
|
const add = html => { if (html) { any = true; h += html; } };
|
|
add(group('books', 'Bücher', local.books, 0, b => {
|
|
index['b' + b.id] = () => { if (typeof openBook === 'function') openBook(b.id); };
|
|
const meta = [b.author, b.series ? b.series + (b.seriesIndex ? ' ' + b.seriesIndex : '') : ''].filter(Boolean).join(' · ');
|
|
return row('b' + b.id, bookArt(b), mark(b.title, qw), mark(meta, qw));
|
|
}));
|
|
add(group('magazines', 'Zeitschriften', local.magazines, 0, b => {
|
|
index['b' + b.id] = () => { if (typeof openBook === 'function') openBook(b.id); };
|
|
return row('b' + b.id, bookArt(b), mark(b.title, qw), esc(b.author || ''));
|
|
}));
|
|
add(group('audiobooks', 'Hörbücher', s.audiobooks || [], s.audiobooks_total, f => {
|
|
index['a' + f.id] = () => {
|
|
go('audiobooks');
|
|
if (window.__dioraAudiobookOpen) window.__dioraAudiobookOpen(f.id);
|
|
};
|
|
return row('a' + f.id, art(f.artwork_url, '◐'), mark(f.title, qw), mark(f.author, qw));
|
|
}));
|
|
add(group('podcasts', 'Podcasts', s.podcasts || [], s.podcasts_total, f => {
|
|
index['p' + f.id] = () => openFeed(f.id);
|
|
return row('p' + f.id, art(f.artwork_url, '◎'), (f.favorite ? '★ ' : '') + mark(f.title, qw), mark(f.author, qw));
|
|
}));
|
|
add(group('episodes', 'Folgen', s.episodes || [], 0, e => {
|
|
index['e' + e.id] = () => openEpisode(e);
|
|
index['play' + e.id] = () => playEpisode(e);
|
|
const meta = [esc(e.feed__title), when(e.pub_date), minutes(e.duration_seconds)].filter(Boolean).join(' · ');
|
|
return row('e' + e.id, art(e.artwork_url || e.feed__artwork_url, '◎'), mark(e.title, qw), meta,
|
|
host() ? '<button type="button" class="sx-play" data-open="play' + e.id + '" aria-label="Abspielen">▶</button>' : '');
|
|
}));
|
|
add(group('articles', 'Artikel', s.articles || [], s.articles_total, a => {
|
|
index['i' + a.id] = () => { if (window.__dioraFeedsOpenItem) window.__dioraFeedsOpenItem(a.id); };
|
|
const meta = [esc(a.source), when(a.published), a.read ? 'gelesen' : ''].filter(Boolean).join(' · ');
|
|
return row('i' + a.id, art(a.image_url, a.kind === 'news' ? '☰' : '✎'), mark(a.title, qw), meta);
|
|
}));
|
|
add(group('sources', 'Nachrichtenquellen und Blogs', s.sources || [], 0, src => {
|
|
index['s' + src.id] = () => go(src.kind === 'news' ? 'news' : 'blogs');
|
|
return row('s' + src.id, art(src.icon_url, src.kind === 'news' ? '☰' : '✎'), mark(src.title, qw),
|
|
esc(src.kind === 'news' ? 'Nachrichten' + (src.category ? ' · ' + src.category : '') : 'Blog'));
|
|
}));
|
|
add(group('stations', 'Radio', s.stations || [], 0, r => {
|
|
index['r' + r.id] = () => {
|
|
if (typeof playStation === 'function') playStation(r.url, r.name, r.id);
|
|
};
|
|
return row('r' + r.id, art(r.favicon_url, '◉'), (r.is_favorite ? '★ ' : '') + mark(r.name, qw),
|
|
esc([r.country, r.tags].filter(Boolean).join(' · ')));
|
|
}));
|
|
if (st.loading && !st.server) h += '<p class="sx-hint">Sucht …</p>';
|
|
else if (st.error) h += '<p class="sx-hint">' + esc(st.error) + '</p>';
|
|
else if (!any && !st.loading) h += '<p class="sx-hint">Nichts gefunden.</p>';
|
|
// Beyond what one has: the directories.
|
|
h += '<div class="sx-beyond"><span class="sx-hint">Darüber hinaus</span>' +
|
|
'<button type="button" class="sx-chip" data-beyond="podcast">Neue Podcasts suchen</button>' +
|
|
'<button type="button" class="sx-chip" data-beyond="radio">Im Radioverzeichnis suchen</button></div>';
|
|
}
|
|
if (!field) {
|
|
tab.innerHTML = '<div class="sx"><h2 class="sx-h1">Suche</h2>' +
|
|
'<input type="search" class="sx-q" placeholder="Suchen in diora …" autocomplete="off" enterkeyhint="search">' +
|
|
'<div class="sx-out"></div></div>';
|
|
const q = tab.querySelector('.sx-q');
|
|
q.value = st.q;
|
|
q.addEventListener('input', () => changed(q.value));
|
|
q.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { remember(q.value); q.blur(); }
|
|
if (e.key === 'Escape') { q.value = ''; changed(''); }
|
|
});
|
|
}
|
|
tab.querySelector('.sx-out').innerHTML = h;
|
|
if (!keepFocus && field) field.value = st.q;
|
|
}
|
|
|
|
// ---- opening ---------------------------------------------------------------------------
|
|
|
|
function openFeed(id) {
|
|
if (window.__dioraPodcastOpenFeed && window.__dioraPodcastOpenFeed(id)) return;
|
|
go('podcasts');
|
|
}
|
|
|
|
function openEpisode(e) {
|
|
if (window.__dioraPodcastOpenEpisode && window.__dioraPodcastOpenEpisode(e)) return;
|
|
openFeed(e.feed__id);
|
|
}
|
|
|
|
function playEpisode(e) {
|
|
const h = host();
|
|
if (!h) return;
|
|
h.podcastPlayEpisode(JSON.stringify(e), 0);
|
|
}
|
|
|
|
function beyond(kind) {
|
|
const q = st.q.trim();
|
|
if (kind === 'radio') {
|
|
go('radio');
|
|
if (typeof showRadioTab === 'function') showRadioTab('search');
|
|
const f = document.getElementById('search-input');
|
|
if (f && q) {
|
|
f.value = q;
|
|
if (typeof doSearch === 'function') doSearch();
|
|
}
|
|
return;
|
|
}
|
|
go('sources');
|
|
// Abos & Quellen's field: searches the directories for it (podcasts, YouTube, feeds).
|
|
const f = document.querySelector('#tab-sources .src-add-q');
|
|
if (f && q) {
|
|
f.value = q;
|
|
if (f.form) f.form.dispatchEvent(new Event('submit', {bubbles: true, cancelable: true}));
|
|
}
|
|
}
|
|
|
|
tab.addEventListener('click', e => {
|
|
const t = e.target.closest('button');
|
|
if (!t) return;
|
|
if (t.dataset.open) {
|
|
remember(st.q);
|
|
const fn = index[t.dataset.open];
|
|
if (fn) fn();
|
|
} else if (t.dataset.more) {
|
|
st.more[t.dataset.more] = true;
|
|
render(true);
|
|
} else if (t.dataset.q != null) {
|
|
const q = tab.querySelector('.sx-q');
|
|
q.value = t.dataset.q;
|
|
changed(t.dataset.q);
|
|
} else if (t.dataset.beyond) {
|
|
remember(st.q);
|
|
beyond(t.dataset.beyond);
|
|
}
|
|
});
|
|
|
|
// A cover that comes in later.
|
|
if (window.DioraCovers && window.DioraCovers.onReady) {
|
|
window.DioraCovers.onReady(() => { if (tab.style.display !== 'none' && st.q) render(true); });
|
|
}
|
|
|
|
function show() {
|
|
render();
|
|
const q = tab.querySelector('.sx-q');
|
|
// On a phone the keyboard comes up with the field; a search one returns to stays as it was.
|
|
if (q && !st.q) setTimeout(() => { try { q.focus(); } catch (e) {} }, 60);
|
|
}
|
|
|
|
const showTab = window.showTab;
|
|
if (typeof showTab === 'function') {
|
|
window.showTab = function (name) {
|
|
const r = showTab.apply(this, arguments);
|
|
if (name === 'suche') show();
|
|
return r;
|
|
};
|
|
}
|
|
if (tab.style.display !== 'none') show();
|
|
// From anywhere: DioraSearch.open('Decker').
|
|
window.DioraSearch = {
|
|
open(q) {
|
|
go('suche');
|
|
if (q != null) {
|
|
const f = tab.querySelector('.sx-q');
|
|
if (f) f.value = q;
|
|
changed(String(q));
|
|
}
|
|
},
|
|
};
|
|
|
|
// ---- styles --------------------------------------------------------------------------------------
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = [
|
|
'#tab-suche .sx { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
|
|
'#tab-suche .sx-h1 { font-size: 18px; margin: 4px 0 12px; }',
|
|
'#tab-suche .sx-q { width: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 8px; border: 1px solid var(--border-strong, #444); background: var(--bg, #000); color: inherit; font: inherit; font-size: 16px; }',
|
|
'#tab-suche .sx-hint { display: block; color: var(--muted, #9a9a9a); font-size: 13px; margin: 14px 0 6px; }',
|
|
'#tab-suche .sx-recent, #tab-suche .sx-beyond { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }',
|
|
'#tab-suche .sx-recent .sx-hint, #tab-suche .sx-beyond .sx-hint { width: 100%; margin-bottom: 0; }',
|
|
'#tab-suche .sx-beyond { margin-top: 22px; }',
|
|
'#tab-suche .sx-chip { min-height: 34px; padding: 0 12px; border-radius: 17px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
|
|
'#tab-suche .sx-group { margin-top: 18px; }',
|
|
'#tab-suche .sx-h { display: flex; align-items: baseline; gap: 8px; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted, #9a9a9a); margin: 0 0 4px; font-weight: 600; }',
|
|
'#tab-suche .sx-count { font-weight: 400; letter-spacing: 0; }',
|
|
'#tab-suche .sx-row { display: flex; align-items: center; border-bottom: 1px solid var(--border, #2a2a2a); }',
|
|
'#tab-suche .sx-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 8px 0; background: none; border: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
|
|
'#tab-suche .sx-main:hover { background: var(--hover, rgba(255,255,255,.04)); }',
|
|
'#tab-suche .sx-art { width: 40px; height: 40px; flex: none; border-radius: 6px; object-fit: cover; background: var(--panel, #161616); }',
|
|
'#tab-suche .sx-noart { display: flex; align-items: center; justify-content: center; color: var(--muted, #9a9a9a); font-size: 12px; }',
|
|
'#tab-suche .sx-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
|
|
'#tab-suche .sx-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }',
|
|
'#tab-suche .sx-meta { color: var(--muted, #9a9a9a); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
|
|
'#tab-suche mark { background: none; color: inherit; font-weight: 700; text-decoration: underline; text-decoration-color: var(--accent, #e63946); text-underline-offset: 2px; }',
|
|
'#tab-suche .sx-play { flex: none; width: 40px; height: 40px; border-radius: 20px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font-size: 13px; cursor: pointer; margin-left: 6px; }',
|
|
'#tab-suche .sx-more { margin: 6px 0 0; padding: 4px 0; background: none; border: none; color: var(--accent, #e63946); font: inherit; font-size: 13px; cursor: pointer; }',
|
|
'body.eink #tab-suche mark { text-decoration-color: #000; }',
|
|
'body.eink #tab-suche .sx-more { color: #000; text-decoration: underline; }',
|
|
].join('\n');
|
|
document.head.appendChild(style);
|
|
})();
|