/*
* 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])) + '' + esc(s.slice(h[0], h[1])) + '';
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
? ''
: '' + glyph + '';
}
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 '
Bücher, Zeitschriften, Hörbücher, Podcasts und ihre Folgen, Artikel, Quellen und Sender.
'; } 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() ? '' : ''); })); 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 += 'Sucht …
'; else if (st.error) h += '' + esc(st.error) + '
'; else if (!any && !st.loading) h += 'Nichts gefunden.
'; // Beyond what one has: the directories. h += '