/* * 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 '
' + (extra || '') + '
'; } 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 '

' + esc(label) + '' + (total || items.length) + '

' + shown.map(draw).join('') + (rest > 0 && items.length > shown.length ? '' : '') + '
'; } 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 ? '
Zuletzt gesucht' + recent.map(q => '').join('') + '
' : '

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 += '
Darüber hinaus' + '' + '
'; } if (!field) { tab.innerHTML = '

Suche

' + '' + '
'; 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); })();