diora-web/static/js/search.js
mrwnslz 8df8ae2be3
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Has been cancelled
Suche über ganz diora: eigener Abschnitt (Handy neben Home · Lesen · Hören, Seitenleiste unten über Abos), Bücher lokal, Rest über /search/; sw v139
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:09:07 +02:00

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 =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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);
})();