Suche über ganz diora: eigener Abschnitt (Handy neben Home · Lesen · Hören, Seitenleiste unten über Abos), Bücher lokal, Rest über /search/; sw v139
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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-19 19:09:07 +02:00
parent 410d4806ca
commit 8df8ae2be3
10 changed files with 670 additions and 10 deletions

101
diora/search.py Normal file
View file

@ -0,0 +1,101 @@
"""The search across diora (docs/search.md): one query, what the server knows of every area —
podcasts and audiobooks one follows, podcast episodes (the full-text index, podcasts/discover.py),
news and blog sources, their articles, saved radio stations. The books aren't here: their titles
are encrypted, the page searches them itself (static/js/search.js).
Every word of the query has to be there, as the start of a word ("plattf" finds "Plattform");
case and diacritics don't count."""
from django.db.models import Q
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from feeds.models import Item, Source
from podcasts import discover
from podcasts.models import PodcastFeed
from radio.models import SavedStation
LIMIT = 8 # per area; the area's own view has the rest
ARTICLE_SCAN = 400 # articles looked at after the database's rough match
def _words(q):
return [w for w in discover._words(q) if w][:8]
def _matches(words, *texts):
"""Every word as the start of a word in the texts ("plattf" finds "Plattform")."""
have = discover._words(' '.join(t or '' for t in texts))
return bool(words) and all(any(h.startswith(w) for h in have) for w in words)
def _rank(words, title):
"""Title starting with the query first, then title words, then the rest."""
t = discover._words(title)
if t[:len(words)] == words or (t and t[0].startswith(words[0])):
return 0
return 1 if all(any(h.startswith(w) for h in t) for w in words) else 2
def _feeds(user, words):
out = {'podcasts': [], 'audiobooks': []}
rows = PodcastFeed.objects.filter(user=user).values('id', 'title', 'author', 'artwork_url', 'kind', 'rss_url',
'favorite', 'new_episodes')
found = [f for f in rows if _matches(words, f['title'], f['author'])]
found.sort(key=lambda f: (_rank(words, f['title']), (f['title'] or '').lower()))
for f in found:
key = 'audiobooks' if f['kind'] == PodcastFeed.AUDIOBOOK else 'podcasts'
out[key].append(f)
return {k: v[:LIMIT] for k, v in out.items()} | {'podcasts_total': len(out['podcasts']),
'audiobooks_total': len(out['audiobooks'])}
def _sources(user, words):
rows = Source.objects.filter(user=user).values('id', 'title', 'url', 'kind', 'icon_url', 'category')
found = [s for s in rows if _matches(words, s['title'], s['url'], s['category'])]
found.sort(key=lambda s: (_rank(words, s['title']), (s['title'] or '').lower()))
return found[:LIMIT]
def _articles(user, words):
"""Articles whose title or author has every word, the newest first. The database narrows by
the longest word (LIKE, as case-blind as SQLite is), Python decides."""
longest = max(words, key=len)
rough = Q(title__icontains=longest) | Q(author__icontains=longest)
if longest != longest.capitalize():
rough |= Q(title__contains=longest.capitalize())
rows = (Item.objects.filter(user=user, gone=False).filter(rough).order_by('-published')
.values('id', 'title', 'author', 'kind', 'published', 'image_url', 'read_at', 'source__title')[:ARTICLE_SCAN])
found = [i for i in rows if _matches(words, i['title'], i['author'])]
return [{'id': i['id'], 'title': i['title'], 'author': i['author'], 'kind': i['kind'],
'published': i['published'].isoformat() if i['published'] else None, 'image_url': i['image_url'],
'read': i['read_at'] is not None, 'source': i['source__title']} for i in found[:LIMIT * 2]], len(found)
def _stations(user, words):
rows = SavedStation.objects.filter(user=user).values('id', 'name', 'url', 'tags', 'country', 'favicon_url',
'is_favorite')
found = [s for s in rows if _matches(words, s['name'], s['tags'], s['country'])]
found.sort(key=lambda s: (_rank(words, s['name']), not s['is_favorite'], s['name'].lower()))
return found[:LIMIT]
def search(user, q):
words = _words(q)
if not words or len(''.join(words)) < 2:
return {'q': q, 'empty': True}
out = {'q': q}
out.update(_feeds(user, words))
ids = discover.search_ids(user, q, limit=LIMIT * 2)
out['episodes'] = discover._rows(user, ids)
out['sources'] = _sources(user, words)
out['articles'], out['articles_total'] = _articles(user, words)
out['stations'] = _stations(user, words)
return out
@require_http_methods(['GET'])
def search_view(request):
"""?q=…: what matches in every area (see the module's text)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return JsonResponse(search(request.user, request.GET.get('q', '')[:200]))

62
diora/test_search.py Normal file
View file

@ -0,0 +1,62 @@
"""The search across diora (diora/search.py)."""
from datetime import datetime, timezone
from django.contrib.auth.models import User
from django.test import TestCase
from feeds.models import Item, Source
from podcasts import discover
from podcasts.models import PodcastEpisode, PodcastFeed
from radio.models import SavedStation
class SearchTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='sucher', password='pw12345678')
self.client.force_login(self.user)
other = User.objects.create_user(username='andere', password='pw12345678')
when = datetime(2026, 3, 1, tzinfo=timezone.utc)
self.pod = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/p', title='Ökonomie heute',
author='Peter Decker')
self.book = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Das Kapital',
author='Karl Marx', kind=PodcastFeed.AUDIOBOOK)
PodcastFeed.objects.create(user=other, rss_url='https://example.org/p', title='Ökonomie heute')
self.ep = PodcastEpisode.objects.create(feed=self.pod, guid='e1', title='Die Ökonomie der Plattformen',
audio_url='https://example.org/e1.mp3', pub_date=when)
PodcastEpisode.objects.create(feed=self.book, guid='k1', title='Kapitel 1: Die Ware',
audio_url='https://example.org/k1.mp3', pub_date=when)
discover.index_new()
blog = Source.objects.create(user=self.user, url='https://blog.example/feed', title='Plattform-Blog', kind=Source.BLOG)
Item.objects.create(source=blog, user=self.user, kind=Source.BLOG, guid_hash='a', title='Plattformarbeit und Streik',
published=when)
Item.objects.create(source=blog, user=self.user, kind=Source.BLOG, guid_hash='b', title='Gartenarbeit', published=when)
Item.objects.create(source=blog, user=self.user, kind=Source.BLOG, guid_hash='c', title='Plattformen, alt',
published=when, gone=True)
SavedStation.objects.create(user=self.user, name='Radio Plattform', url='https://radio.example/s', tags='jazz')
def find(self, q):
return self.client.get('/search/', {'q': q}).json()
def test_every_area(self):
r = self.find('plattf')
self.assertEqual([e['id'] for e in r['episodes']], [self.ep.id])
self.assertEqual([a['title'] for a in r['articles']], ['Plattformarbeit und Streik'])
self.assertEqual([s['title'] for s in r['sources']], ['Plattform-Blog'])
self.assertEqual([s['name'] for s in r['stations']], ['Radio Plattform'])
def test_podcasts_and_audiobooks_apart_case_and_accents_blind(self):
r = self.find('okonomie')
self.assertEqual([f['id'] for f in r['podcasts']], [self.pod.id])
self.assertEqual(r['audiobooks'], [])
r = self.find('marx kap')
self.assertEqual([f['id'] for f in r['audiobooks']], [self.book.id])
self.assertEqual(r['episodes'], [], 'an audiobook\'s chapters are no podcast episodes')
def test_every_word_and_nothing_for_too_little(self):
self.assertEqual(self.find('plattform garten')['articles'], [])
self.assertTrue(self.find('a')['empty'])
self.assertEqual(self.client.get('/search/', {'q': 'x'}).status_code, 200)
def test_only_signed_in(self):
self.client.logout()
self.assertEqual(self.client.get('/search/', {'q': 'plattform'}).status_code, 401)

View file

@ -5,6 +5,7 @@ from django.urls import path, include
from django.views.static import serve as serve_static
from accounts.sync import sync_snapshot
from diora.search import search_view
urlpatterns = [
path('admin/', admin.site.urls),
@ -15,6 +16,7 @@ urlpatterns = [
path('tts/', include('tts.urls')),
path('api/2/', include('gpodder.urls')),
path('api/sync/', sync_snapshot, name='api_sync'),
path('search/', search_view, name='search'),
# Served at the root (not /static/js/sw.js) so its default scope covers
# the whole app — a service worker's scope is limited to its own script's
# directory unless the server sends Service-Worker-Allowed, so registering

32
docs/search.md Normal file
View file

@ -0,0 +1,32 @@
# Suche
One field for all of diora: a section of its own in the navigation (`static/js/nav.js`,
section and group `suche`, flag `side`):
- phone (the apps' tab bar, the web page's narrow bar): beside Home · Lesen · Hören;
- the apps' sidebar and the web page's wide bar: at the foot, above Abos & Quellen.
It's never the section the app opens in next time (`diora_nav_section` isn't written for it).
## What is searched where
| Area | Where | Opens |
|---|---|---|
| Bücher, Zeitschriften | on the device (`_lastBookListData` / `bookMetaCache`): titles are encrypted, the server never sees them; title, author, series, file name, folders; editions of one work once | `openBook(id)` |
| Hörbücher, Podcasts (Abos) | server: title, author | `__dioraAudiobookOpen(id)` / `__dioraPodcastOpenFeed(id)` |
| Folgen | server: the full-text index (`podcasts/discover.py`, titles and shownotes, podcasts only) | `__dioraPodcastOpenEpisode(row)` if the podcast UI has it, else the podcast's page; ▶ plays (`podcastPlayEpisode`) |
| Artikel | server: titles and authors of news and blog items not expired | `__dioraFeedsOpenItem(id)` |
| Nachrichtenquellen, Blogs | server: title, address, category | the news / blogs |
| Radio | server: saved stations, name, tags, country | `playStation()` |
Every word has to be there as the start of a word ("plattf" finds "Plattform"); case and
diacritics don't count. "Darüber hinaus" hands the query to Abos & Quellen (new podcasts,
YouTube, feeds) or the radio directory search. The last eight queries (Enter or a hit opened)
are kept on the device. `DioraSearch.open(q)` opens it from anywhere.
## API
`GET /search/?q=…` (signed in) →
`{q, podcasts, podcasts_total, audiobooks, audiobooks_total, episodes, sources, articles,
articles_total, stations}`; `{q, empty: true}` for less than two letters. Episodes come as the
episode lists give them (`_episode_list_data`). Eight per area (articles sixteen).

View file

@ -14,7 +14,7 @@ test('wide: every section in the bar under its group, each shows its panel and v
const wide = page.locator('#tabs .nav-wide');
await expect(wide.locator('.nav-group-label')).toHaveText(['Lesen', 'Hören']);
await expect(wide.locator('[data-nav]')).toHaveText(
['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus', 'Abos & Quellen']);
['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus', 'Suche', 'Abos & Quellen']);
await wide.locator('[data-nav="blogs"]').click();
await expect(page).toHaveURL(/#blogs$/);
@ -33,7 +33,7 @@ test('narrow: three groups, the sections as a row on top, each group remembers i
await page.setViewportSize({ width: 390, height: 800 });
await page.goto('/#news');
const narrow = page.locator('#tabs .nav-narrow');
await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören', '']);
await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören', 'Suche', '']);
await expect(narrow.locator('[data-nav="sources"] svg.dnav-ic')).toBeVisible();
await expect(narrow.locator('[data-nav="lesen"]')).toHaveClass(/active/);
const segs = page.locator('#nav-segments');
@ -61,6 +61,34 @@ test('old links and direct panel switches land in their section', async ({ page
expect(await visiblePanel(page)).toEqual(['tab-podcasts']); // remembered
});
test('Suche: books from this device, the rest from the server, a hit opens where it lives', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 });
await page.route('**/search/?q=*', route => route.fulfill({json: {
q: 'kapital', podcasts: [{id: 7, title: 'Kapital-Lesekreis', author: 'Rote Hilfe', artwork_url: '', favorite: true}],
podcasts_total: 1, audiobooks: [], audiobooks_total: 0,
episodes: [{id: 70, feed__id: 7, feed__title: 'Kapital-Lesekreis', title: 'Folge 3: Das Kapital, Band 1', duration_seconds: 3600}],
sources: [], articles: [{id: 5, title: 'Kapital und Krise', source: 'Ein Blog', kind: 'blog', published: null, read: false}],
articles_total: 1, stations: []}}));
await page.goto('/#home');
await page.locator('#tabs .nav-narrow [data-nav="suche"]').click();
await expect(page).toHaveURL(/#suche$/);
expect(await visiblePanel(page)).toEqual(['tab-suche']);
await expect(page.locator('#tab-suche .sx-q')).toBeFocused();
await page.evaluate(() => {
_lastBookListData = [{id: 901, title: 'Das Kapital', author: 'Karl Marx', type: 'epub', keyOk: true},
{id: 902, title: 'Gartenbuch', author: 'Jemand', type: 'epub', keyOk: true}];
});
await page.locator('#tab-suche .sx-q').fill('kapital');
const groups = page.locator('#tab-suche .sx-h');
await expect(groups).toHaveText([/Bücher/, /Podcasts/, /Folgen/, /Artikel/]);
await expect(page.locator('#tab-suche .sx-group').first().locator('mark')).toHaveText('Kapital');
await page.locator('#tab-suche .sx-q').press('Enter');
await page.locator('#tab-suche .sx-q').fill('');
await expect(page.locator('#tab-suche .sx-recent .sx-chip')).toHaveText(['kapital']);
// Not where the app opens next time.
expect(await page.evaluate(() => localStorage.getItem('diora_nav_section'))).not.toBe('suche');
});
test('Abos & Quellen: one page for every subscription, reached from the feeds too', async ({ page }) => {
await page.setViewportSize({ width: 1200, height: 800 });
await page.goto('/#news');

View file

@ -4,7 +4,7 @@
// Tabs
// ---------------------------------------------------------------------------
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks', 'sources'];
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks', 'sources', 'suche'];
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
// ---------------------------------------------------------------------------

View file

@ -1,7 +1,7 @@
/*
* The navigation (docs/navigation.md): diora's sections, sorted by what one does —
* Home; Lesen: Bücher, Zeitschriften, Nachrichten, Blogs; Hören: Hörbücher, Podcasts,
* Radio, Ambient. One definition for every platform: the web page's #tabs are drawn
* Radio, Ambient; Suche across all of them. One definition for every platform: the web page's #tabs are drawn
* here, the apps' tab bar and sidebar (native-radio.js) from DioraNav.groups() and
* DioraNav.sections(). A section is a panel (showTab, as before) plus a view in it.
* On a phone, Lesen and Hören show their sections as a row on top (#nav-segments).
@ -25,6 +25,9 @@
{id: 'ambient', group: 'hoeren', label: 'Ambient', icon: '∿', panel: 'ambient'},
// The focus timer: the web page's own (the apps never had it).
{id: 'focus', group: 'hoeren', label: 'Fokus', icon: '◷', panel: 'focus', webOnly: true},
// The search across diora (docs/search.md, search.js): beside Home · Lesen · Hören on a
// phone, at the foot of the sidebar (above Abos & Quellen) and of the wide bar ("side").
{id: 'suche', group: 'suche', label: 'Suche', icon: '⌕', panel: 'suche', account: true, side: true},
// Managing every subscription (docs/sources.md): at the foot of the sidebar, in the app
// menu on a phone — not a tab of its own.
{id: 'sources', group: 'more', label: 'Abos & Quellen', icon: '⚙', panel: 'sources', account: true, footer: true},
@ -33,6 +36,7 @@
{id: 'home', label: 'Home', icon: '⌂'},
{id: 'lesen', label: 'Lesen', icon: '▤'},
{id: 'hoeren', label: 'Hören', icon: '◉'},
{id: 'suche', label: 'Suche', icon: '⌕'},
];
const byId = {};
SECTIONS.forEach(s => { byId[s.id] = s; });
@ -59,6 +63,7 @@
'<path d="M3 9.5c2-2.6 4-2.6 6 0s4 2.6 6 0 4-2.6 6 0"/><path d="M3 14.5c2-2.6 4-2.6 6 0s4 2.6 6 0 4-2.6 6 0"/>',
focus: '<circle class="f" cx="12" cy="13" r="8"/><circle cx="12" cy="13" r="8"/><path d="M12 9v4l3 2M9 3h6"/>',
sources: '<circle class="f" cx="6" cy="18" r="2"/><circle cx="6" cy="18" r="1.6"/><path d="M5 11a8 8 0 0 1 8 8"/><path d="M5 5a14 14 0 0 1 14 14"/>',
suche: '<circle class="f" cx="10.5" cy="10.5" r="6.5"/><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5L21 21"/>',
menu: '<rect class="f" x="10.5" y="4" width="3" height="3"/><rect x="10.5" y="4" width="3" height="3"/>' +
'<rect x="10.5" y="10.5" width="3" height="3"/><rect x="10.5" y="17" width="3" height="3"/>',
};
@ -73,6 +78,8 @@
const nativeUi = () => html.classList.contains('nui-radio'); // the apps' own bars (native-radio.js)
const readerApp = () => html.classList.contains('nui-reader-app');
const noRead = () => html.classList.contains('nui-noread'); // TV
// The apps' sidebar: a "side" section stands at its foot, not as a group.
const sidebar = () => html.classList.contains('nui-sidebar');
// Audiobooks need the podcast player: the page has them where it loads audiobooks.js
// (diora Reader's build leaves it out). Known before the player's script has run.
const podcastHost = () => !!document.querySelector('script[src$="/static/js/audiobooks.js"]');
@ -89,7 +96,7 @@
function sections() {
return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon, svg: icon(s.id),
footer: !!s.footer}));
footer: !!s.footer || (!!s.side && sidebar())}));
}
function groups() {
@ -135,7 +142,7 @@
function setCurrent(id) {
current = id;
const s = byId[id];
save('diora_nav_section', id);
if (!s.side) save('diora_nav_section', id); // the app opens where one was, not in the search
if (!s.footer) save('diora_nav_last_' + s.group, id);
syncHash();
render();
@ -227,13 +234,16 @@
const gs = groups();
const foot = sections().filter(s => s.footer);
if (tabs && !nativeUi()) {
// Wide: every section under its group; narrow: the groups (their sections on top).
// Wide: every section under its group, the "side" ones at the end with the footer's;
// narrow: the groups (their sections on top).
const side = id => !!byId[id].side;
const footWide = sections().filter(s => side(s.id)).concat(foot);
tabs.innerHTML =
'<div class="nav-wide">' + gs.map(g => g.id === 'home'
'<div class="nav-wide">' + gs.filter(g => !g.sections.every(side)).map(g => g.id === 'home'
? button('home', 'Home', 'tab-btn', current === 'home')
: '<span class="nav-group-label">' + esc(g.label) + '</span>' +
g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') +
(foot.length ? '<span class="nav-foot">' + foot.map(s => button(s.id, s.label, 'tab-btn', current === s.id)).join('') + '</span>' : '') +
(footWide.length ? '<span class="nav-foot">' + footWide.map(s => button(s.id, s.label, 'tab-btn', current === s.id)).join('') + '</span>' : '') +
'</div>' +
'<div class="nav-narrow">' + gs.map(g =>
button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') +

421
static/js/search.js Normal file
View file

@ -0,0 +1,421 @@
/*
* 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);
})();

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v138';
const CACHE = 'diora-v139';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',
@ -48,6 +48,7 @@ const SHELL = [
'/static/js/covers.js',
'/static/js/gencover.js',
'/static/js/sources.js',
'/static/js/search.js',
'/static/podcasts/native-podcast.js',
'/static/podcasts/podcast-ui.css',
'/static/podcasts/podcasts-web.css',

View file

@ -267,6 +267,8 @@
{% if user.is_authenticated %}<section class="tab-panel" id="tab-audiobooks" style="display:none;"></section>{% endif %}
{% comment %}Abos & Quellen, every subscription in one place (docs/sources.md): filled by static/js/sources.js.{% endcomment %}
{% if user.is_authenticated %}<section class="tab-panel" id="tab-sources" style="display:none;"></section>{% endif %}
{% comment %}The search across diora (docs/search.md): filled by static/js/search.js.{% endcomment %}
{% if user.is_authenticated %}<section class="tab-panel" id="tab-suche" style="display:none;"></section>{% endif %}
{% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %}
<section class="tab-panel" id="tab-feeds" style="display:none;"></section>
@ -395,4 +397,5 @@
<script src="/static/js/feeds.js"></script>
<script src="/static/js/audiobooks.js"></script>
<script src="/static/js/sources.js"></script>
<script src="/static/js/search.js"></script>
{% endblock %}