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>
This commit is contained in:
parent
410d4806ca
commit
8df8ae2be3
10 changed files with 670 additions and 10 deletions
101
diora/search.py
Normal file
101
diora/search.py
Normal 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
62
diora/test_search.py
Normal 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)
|
||||
|
|
@ -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
32
docs/search.md
Normal 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).
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
421
static/js/search.js
Normal 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 =>
|
||||
({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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);
|
||||
})();
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue