From 35701260e0cf565ef9a31305e7c91acd1ce6ca06 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 4 Oct 2026 20:16:24 +0200 Subject: [PATCH] =?UTF-8?q?Feed-Reader:=20Tab=20=E2=80=9EFeeds=E2=80=9C=20?= =?UTF-8?q?mit=20Nachrichten=20(Briefing,=20Stories)=20und=20Blogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit static/js/feeds.js für Web, Desktop und beide Android-Apps: - Nachrichten: „Seit letztem Mal“ (Top-Themen, dann nach Ressort, „Alles gelesen“), Stories nach Tag, Ressort-Filter - Blogs: Ungelesen-Liste, je Blog filterbar, „Alle gelesen“ - Artikel im eigenen Lesemodus (Volltext, Schriftgröße, merken, alle Berichte einer Story), Browser nur auf Wunsch - Feeds verwalten: Seite oder Feed suchen, als Nachrichten/Blog abonnieren, Ressort, Volltext-Regel, OPML-Import pro Ordner, Export, Aufbewahrung - diora Reader (ohne Tab-Leiste) bekommt eine kleine Leiste Bücher/Feeds Server: Feed-Namen ohne Slogan, Feeds mit einem Zeitstempel für alle Einträge (Deutschlandfunk) zählen ab dem ersten Sehen; sw v89 Co-Authored-By: Claude Opus 5.5 --- feeds/fetch.py | 10 + feeds/refresh.py | 13 +- feeds/tests.py | 13 + feeds/views.py | 2 +- static/js/app.js | 3 +- static/js/feeds.js | 850 ++++++++++++++++++++++++++++++++++++ static/js/sw.js | 3 +- templates/radio/player.html | 6 + 8 files changed, 894 insertions(+), 6 deletions(-) create mode 100644 static/js/feeds.js diff --git a/feeds/fetch.py b/feeds/fetch.py index b535224..4b23c75 100644 --- a/feeds/fetch.py +++ b/feeds/fetch.py @@ -224,6 +224,16 @@ def discover(page_html, base): return out, (urljoin(base, p.icon) if p.icon else '') +def short_title(title): + """A feed's name without the slogan: "tagesschau.de - die erste Adresse für …" + -> "tagesschau.de", "Aktuell - FAZ.NET" -> "FAZ.NET", "DIE ZEIT | Nachrichten, …" -> "DIE ZEIT".""" + parts = [p.strip() for p in re.split(r'\s+[-–—|:]\s+', title or '') if p.strip()] + if len(parts) < 2: + return (title or '').strip() + branded = [p for p in parts if re.search(r'\w\.[a-z]{2,}$', p, re.I)] + return (branded or parts)[0] + + def site_icon(site_url): if not site_url: return '' diff --git a/feeds/refresh.py b/feeds/refresh.py index aa13999..d82796f 100644 --- a/feeds/refresh.py +++ b/feeds/refresh.py @@ -3,6 +3,7 @@ news is bundled into stories, old news expires.""" import logging import time +from collections import Counter from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait from datetime import timedelta @@ -41,7 +42,7 @@ def _apply(source, result, now, keep_days): parsed = result.parsed feed = parsed.feed if feed.get('title') and (not source.title or source.title == source.url): - source.title = fetch.plain(feed.title)[:300] + source.title = fetch.short_title(fetch.plain(feed.title))[:300] if feed.get('link'): source.site_url = feed.link[:1000] if not source.icon_url: @@ -64,8 +65,14 @@ def _apply(source, result, now, keep_days): oldest_kept = source.items.order_by('published').values_list('published', flat=True).first() news_cutoff = now - timedelta(days=keep_days) new = [] - for entry in parsed.entries: - fields = fetch.parse_entry(entry, source.url) + entries = [fetch.parse_entry(e, source.url) for e in parsed.entries] + # Some feeds (Deutschlandfunk) give every entry the time the feed was made: + # then the dates say nothing, and the time an entry first shows up counts. + stamps = Counter(f['published'] for f in entries) + bogus_dates = len(entries) >= 5 and stamps.most_common(1)[0][1] >= 0.8 * len(entries) + for fields in entries: + if bogus_dates: + fields['published'] = now if fields['guid_hash'] in known: continue known.add(fields['guid_hash']) diff --git a/feeds/tests.py b/feeds/tests.py index ee4ba97..3f0dbf8 100644 --- a/feeds/tests.py +++ b/feeds/tests.py @@ -49,6 +49,13 @@ class FetchTest(TestCase): def test_drop_repeated_headline(self): self.assertEqual(fetch._drop_title('

Titel

Text

'), '

Text

') + def test_short_title(self): + self.assertEqual(fetch.short_title('tagesschau.de - die erste Adresse für Nachrichten'), 'tagesschau.de') + self.assertEqual(fetch.short_title('Aktuell - FAZ.NET'), 'FAZ.NET') + self.assertEqual(fetch.short_title('DIE ZEIT | Nachrichten, News, Hintergründe'), 'DIE ZEIT') + self.assertEqual(fetch.short_title('Golem.de'), 'Golem.de') + self.assertEqual(fetch.short_title('Kotlin-Blog'), 'Kotlin-Blog') + def test_discover(self): feeds, icon = fetch.discover('' '', 'https://ex.org/') @@ -150,6 +157,12 @@ class ApiTest(TestCase): refresh.refresh([source]) self.assertEqual(Item.objects.filter(user=self.u, gone=False).count(), 0) + def test_feed_wide_timestamp_means_first_seen(self): + stamp = (timezone.now() - timedelta(hours=5)).strftime('%a, %d %b %Y %H:%M:%S +0000') + self.add('news', rss([{'guid': f'g{n}', 'title': f'Meldung {n}', 'text': LONG, 'date': stamp} for n in range(6)])) + for i in Item.objects.filter(user=self.u): + self.assertLess((timezone.now() - i.published).total_seconds(), 60) + def test_switching_kind_moves_items(self): src = self.add('news', rss([{'guid': 'a', 'title': 'Etwas', 'text': LONG}])) self.post(f'/feeds/sources/{src["id"]}/', {'kind': 'blog'}) diff --git a/feeds/views.py b/feeds/views.py index 5e90f11..5bb5471 100644 --- a/feeds/views.py +++ b/feeds/views.py @@ -137,7 +137,7 @@ def _probe(url): parsed = feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers}) if parsed.get('bozo') and not parsed.entries and not parsed.feed.get('title'): return None, ([], '') - return (r.url, fetch.plain(parsed.feed.get('title') or '') or r.url, parsed), None + return (r.url, fetch.short_title(fetch.plain(parsed.feed.get('title') or '')) or r.url, parsed), None @csrf_exempt diff --git a/static/js/app.js b/static/js/app.js index b1c5e61..d12f1a5 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1179,7 +1179,7 @@ async function editNotes(pk, current) { // Tabs // --------------------------------------------------------------------------- -const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'ambient']; +const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient']; const RADIO_SUB_TABS = ['search', 'saved', 'history']; // --------------------------------------------------------------------------- @@ -1238,6 +1238,7 @@ function showTab(name) { if (name === 'books') loadBookList(); if (name === 'ambient') _ambientLoad(); if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js + if (name === 'feeds' && window.__dioraFeedsShow) window.__dioraFeedsShow(); // static/js/feeds.js } // --------------------------------------------------------------------------- diff --git a/static/js/feeds.js b/static/js/feeds.js new file mode 100644 index 0000000..c4d71ad --- /dev/null +++ b/static/js/feeds.js @@ -0,0 +1,850 @@ +/* + * The feed reader (the server side is the feeds app, /feeds/…): the "Feeds" tab + * with two parts that work differently on purpose — + * Nachrichten: stories (the same event from several sources is one card), + * the "Seit letztem Mal" briefing on top, old news simply goes away; + * Blogs: a plain list of unread articles, per blog if wanted. + * Articles open in the page (full text from the server), the browser only on request. + * Runs in the web page, the desktop app and both Android apps (in diora Reader, + * which has no tab bar, it brings a small Bücher/Feeds bar). Chrome-61-safe: + * the e-reader app transpiles it (compat61), so no ?. / ?? here. + */ +(function () { + 'use strict'; + const tab = document.getElementById('tab-feeds'); + if (!tab) return; + const READER_APP = document.documentElement.classList.contains('nui-reader-app'); + + const st = { + view: 'news', // news | blogs | manage + state: null, // /feeds/state/ + briefing: null, + stories: [], storiesMore: false, + category: '', + items: [], itemsMore: false, + blogSource: '', blogUnread: true, + found: null, // discover results + opml: null, // parsed OPML import + editing: null, // source id being edited + loading: false, + error: '', + }; + try { + const saved = localStorage.getItem('diora_feeds_view'); + if (saved === 'news' || saved === 'blogs') st.view = saved; + } catch (e) {} + + const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + + async function api(path, body) { + const opts = {credentials: 'same-origin', headers: {}}; + if (body !== undefined) { + opts.method = 'POST'; + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + const r = await fetch(path, opts); + let data = null; + try { data = await r.json(); } catch (e) {} + if (!r.ok) throw new Error((data && data.error) || ('Fehler ' + r.status)); + return data; + } + + // ---- time + + function when(iso) { + if (!iso) return ''; + const d = new Date(iso); + const mins = Math.round((Date.now() - d.getTime()) / 60000); + if (mins < 1) return 'gerade eben'; + if (mins < 60) return 'vor ' + mins + ' Min.'; + if (mins < 6 * 60) return 'vor ' + Math.round(mins / 60) + ' Std.'; + const hm = ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2); + const day = dayLabel(d); + return (day === 'Heute' ? '' : day + ', ') + hm; + } + + function dayLabel(d) { + const today = new Date(); today.setHours(0, 0, 0, 0); + const that = new Date(d.getTime()); that.setHours(0, 0, 0, 0); + const diff = Math.round((today - that) / 86400000); + if (diff === 0) return 'Heute'; + if (diff === 1) return 'Gestern'; + return d.toLocaleDateString('de-DE', {weekday: 'short', day: 'numeric', month: 'short'}); + } + + // ---- loading + + async function loadState() { + st.state = await api('/feeds/state/'); + } + + async function loadNews(more) { + const q = []; + if (more && st.stories.length) q.push('before=' + encodeURIComponent(st.stories[st.stories.length - 1].last_at)); + if (st.category) q.push('category=' + encodeURIComponent(st.category)); + const [news, briefing] = await Promise.all([ + api('/feeds/news/' + (q.length ? '?' + q.join('&') : '')), + more ? Promise.resolve(st.briefing) : api('/feeds/briefing/'), + ]); + st.stories = more ? st.stories.concat(news.stories) : news.stories; + st.storiesMore = news.more; + st.briefing = briefing; + } + + async function loadBlogs(more) { + const q = []; + if (st.blogSource) q.push('source=' + st.blogSource); + if (st.blogUnread) q.push('unread=1'); + if (more && st.items.length) q.push('before=' + encodeURIComponent(st.items[st.items.length - 1].published)); + const data = await api('/feeds/blogs/' + (q.length ? '?' + q.join('&') : '')); + st.items = more ? st.items.concat(data.items) : data.items; + st.itemsMore = data.more; + } + + async function load(more) { + st.loading = true; + st.error = ''; + render(); + try { + if (!more) await loadState(); + if (st.view === 'news') await loadNews(more); + else if (st.view === 'blogs') await loadBlogs(more); + } catch (e) { + st.error = e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message; + } + st.loading = false; + render(); + } + + // ---- rendering + + function sources(kind) { + return ((st.state && st.state.sources) || []).filter(s => !kind || s.kind === kind); + } + + function render() { + const s = st.state; + const newsN = s ? s.news_new : 0; + const blogN = s ? s.blogs_unread : 0; + let body; + if (st.view === 'manage') body = renderManage(); + else if (!s && st.loading) body = '

Lädt …

'; + else if (st.view === 'news') body = renderNews(); + else body = renderBlogs(); + tab.innerHTML = + '
' + + '
' + + (st.view === 'manage' + ? '

Feeds verwalten

' + : '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
') + + '
' + + (st.error ? '

' + esc(st.error) + '

' : '') + + body + '
'; + } + + function storyMeta(x) { + const lead = x.lead || {}; + const others = x.sources - 1; + return esc(lead.source_title) + (others > 0 ? ' +' + others + '' : '') + + ' · ' + esc(when(x.last_at)); + } + + function storyRow(x) { + const lead = x.lead || {}; + return ''; + } + + function renderBriefing() { + const b = st.briefing; + if (!b || !b.total) return ''; + let h = '
' + + '

Seit letztem Mal

' + + '' + b.total + (b.total === 1 ? ' Meldung' : ' Meldungen') + ' seit ' + esc(when(b.since)) + '
'; + if (b.top.length) { + h += '

Top-Themen

' + b.top.map(storyRow).join(''); + } + b.sections.forEach(sec => { + h += '

' + esc(sec.category) + ' ' + sec.count + '

' + sec.stories.map(storyRow).join(''); + if (sec.count > sec.stories.length) { + h += ''; + } + }); + h += '
'; + return h; + } + + function renderNews() { + if (!sources('news').length) { + return '

Noch keine Nachrichtenquellen.

' + + '
'; + } + const cats = (st.state.categories || []); + let h = renderBriefing(); + if (cats.length > 1) { + h += '
' + + '' + + cats.map(c => '').join('') + + '
'; + } + if (!st.stories.length) return h + '

' + (st.loading ? 'Lädt …' : 'Gerade keine Nachrichten.') + '

'; + let day = ''; + st.stories.forEach(x => { + const d = dayLabel(new Date(x.last_at)); + if (d !== day) { + day = d; + h += '

' + esc(d) + '

'; + } + const lead = x.lead || {}; + h += ''; + }); + if (st.storiesMore) h += ''; + return h; + } + + function renderBlogs() { + const blogs = sources('blog'); + if (!blogs.length) { + return '

Noch keine Blogs abonniert.

' + + '
'; + } + let h = '
' + + '' + + '' + + (st.items.some(i => !i.read) ? '' : '') + + '
'; + if (!st.items.length) return h + '

' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '

'; + h += st.items.map(i => + '').join(''); + if (st.itemsMore) h += ''; + return h; + } + + function kindSelect(name, value) { + return ''; + } + + function categoryInput(value) { + const cats = (st.state && st.state.categories) || []; + return '' + + '' + cats.map(c => ''; + } + + function renderManage() { + const s = st.state || {sources: [], settings: {}}; + let h = '

Feed hinzufügen

' + + '
' + + '
'; + if (st.found) { + h += st.found.map((f, n) => + '
' + + '
' + esc(f.title) + '
' + esc(f.url) + '
' + + (f.samples.length ? '
    ' + f.samples.slice(0, 3).map(t => '
  • ' + esc(t) + '
  • ').join('') + '
' : '') + + (f.subscribed ? '

Schon abonniert.

' : + '
' + + '
' + + '') + + '
').join(''); + } + h += '
'; + + const news = s.sources.filter(x => x.kind === 'news'); + const blogs = s.sources.filter(x => x.kind === 'blog'); + const group = (title, list) => !list.length ? '' : + '

' + title + ' ' + list.length + '

' + + list.map(x => x.id === st.editing ? editForm(x) : + '').join('') + '
'; + h += group('Nachrichten', news.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.title.localeCompare(b.title))); + h += group('Blogs', blogs); + + h += '

Import & Einstellungen

' + + ' ' + + (window.DioraNative ? '' : 'OPML exportieren'); + if (st.opml) h += renderOpml(); + const keep = (s.settings && s.settings.news_keep_days) || 2; + h += '

Ältere Nachrichten verschwinden von selbst (gemerkte bleiben). Blogartikel bleiben, bis sie gelesen sind.

'; + return h; + } + + function editForm(x) { + return '
' + + '' + + '
' + esc(x.url) + '
' + + (x.last_error ? '

Letzter Fehler: ' + esc(x.last_error) + '

' : '') + + '
' + + '' + + '
' + + '
' + + '' + + '
'; + } + + function renderOpml() { + const groups = st.opml; + const n = groups.reduce((a, g) => a + (g.kind === 'skip' ? 0 : g.feeds.length), 0); + return '

Was ist was? Pro Ordner:

' + + groups.map((g, i) => + '
' + esc(g.name || 'Ohne Ordner') + ' (' + g.feeds.length + ')' + + '
').join('') + + ' ' + + '
'; + } + + // ---- OPML + + function parseOpml(text) { + const doc = new DOMParser().parseFromString(text, 'text/xml'); + const groups = {}; + const order = []; + Array.prototype.forEach.call(doc.querySelectorAll('outline[xmlUrl], outline[xmlurl]'), o => { + const url = o.getAttribute('xmlUrl') || o.getAttribute('xmlurl'); + const parent = o.parentNode && o.parentNode.nodeName.toLowerCase() === 'outline' ? o.parentNode : null; + const name = parent ? (parent.getAttribute('text') || parent.getAttribute('title') || '') : ''; + if (!groups[name]) { + groups[name] = {name, feeds: [], + kind: /nachricht|news|politik|zeitung|presse|aktuell|welt|wirtschaft|tech/i.test(name) ? 'news' : 'blog'}; + order.push(name); + } + groups[name].feeds.push({url, title: o.getAttribute('title') || o.getAttribute('text') || url}); + }); + return order.map(n => groups[n]); + } + + // ---- article view + + let reader = null; // the overlay element + let readerList = []; // what "next" walks through (blog items) + let readerStory = null; + let readerItem = null; + + function fontSize() { + let v = 18; + try { v = parseInt(localStorage.getItem('diora_feeds_font') || '18', 10) || 18; } catch (e) {} + return Math.max(14, Math.min(26, v)); + } + + function ensureReader() { + if (reader) return reader; + reader = document.createElement('div'); + reader.id = 'fd-article'; + reader.hidden = true; + reader.addEventListener('click', onReaderClick); + document.body.appendChild(reader); + return reader; + } + + function readerOpen() { + return !!(reader && !reader.hidden); + } + + function closeReader() { + if (!reader) return; + reader.hidden = true; + reader.innerHTML = ''; + document.documentElement.classList.remove('fd-reading'); + readerItem = readerStory = null; + render(); + } + + async function openItem(id, story) { + ensureReader(); + readerStory = story || null; + reader.hidden = false; + document.documentElement.classList.add('fd-reading'); + reader.innerHTML = readerShell('

Lädt …

'); + reader.scrollTop = 0; + try { + const data = await api('/feeds/items/' + id + '/'); + readerItem = data.item; + reader.innerHTML = readerShell(articleHtml(readerItem)); + reader.scrollTop = 0; + if (!readerItem.read) { + readerItem.read = true; + markLocal([id], true); + api('/feeds/items/read/', {ids: [id], read: true}).catch(() => {}); + } + } catch (e) { + reader.innerHTML = readerShell('

' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '

'); + } + } + + function readerShell(inner) { + const i = readerItem; + return '
' + + '' + + '' + esc(i ? i.source_title : '') + '' + + '' + + (i ? '' : '') + + (i && i.url ? '↗' : '') + + '
' + inner + '
'; + } + + function articleHtml(i) { + let h = ''; + const story = readerStory; + if (story && story.items.length > 1) { + h += '
' + story.items.length + ' Berichte:' + + story.items.map(x => '').join('') + '
'; + } + h += '

' + esc(i.title) + '

' + + '

' + [i.author, i.source_title, when(i.published)].filter(Boolean).map(esc).join(' · ') + '

'; + const content = i.content || ''; + if (i.image_url && content.indexOf(i.image_url) < 0) h += ''; + h += '
' + (content || '

' + esc(i.summary) + '

') + '
'; + if (!i.content_full && i.url && (content.replace(/<[^>]+>/g, '').length < 700)) { + h += '

Der Feed bringt nur einen Anriss, der ganze Artikel ließ sich nicht laden.

'; + } + if (story && story.items.length > 1) { + h += '

Weitere Berichte

' + story.items.filter(x => x.id !== i.id).map(x => + '').join(''); + } + h += '
'; + if (i.url) h += 'Original öffnen ↗'; + const next = nextItem(i); + if (next) h += ''; + h += '
'; + return h; + } + + function nextItem(i) { + if (readerStory) return null; + const list = readerList; + const at = list.findIndex(x => x.id === i.id); + for (let n = at + 1; n < list.length; n++) if (!list[n].read) return list[n]; + return null; + } + + function markLocal(ids, read) { + st.items.forEach(i => { if (ids.indexOf(i.id) >= 0) i.read = read; }); + st.stories.forEach(s => { + s.items.forEach(i => { if (ids.indexOf(i.id) >= 0) i.read = read; }); + s.unread = s.items.filter(i => !i.read).length; + }); + } + + function onReaderClick(e) { + const b = e.target.closest('[data-r]'); + if (!b) { + // Links in the article open outside (the app hands them to the browser). + const a = e.target.closest('.fd-art-body a[href]'); + if (a) { a.target = '_blank'; a.rel = 'noopener'; } + return; + } + const r = b.dataset.r; + if (r === 'close') closeReader(); + else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory); + else if (r === 'font') { + let v = fontSize() + 2; + if (v > 26) v = 14; + try { localStorage.setItem('diora_feeds_font', String(v)); } catch (err) {} + const art = reader.querySelector('.fd-art'); + if (art) art.style.fontSize = v + 'px'; + } else if (r === 'star' && readerItem) { + readerItem.starred = !readerItem.starred; + api('/feeds/items/' + readerItem.id + '/star/', {starred: readerItem.starred}).catch(() => {}); + b.textContent = readerItem.starred ? '★' : '☆'; + b.classList.toggle('on', readerItem.starred); + st.items.forEach(i => { if (i.id === readerItem.id) i.starred = readerItem.starred; }); + } + } + + async function openStory(id) { + let story = st.stories.find(x => x.id === id); + if (!story && st.briefing) { + const all = [].concat(st.briefing.top, ...st.briefing.sections.map(s => s.stories)); + story = all.find(x => x.id === id); + } + if (!story) return; + readerList = []; + const lead = story.lead || story.items[0]; + // Opening a story reads it: all its reports count as read. + const unread = story.items.filter(i => !i.read).map(i => i.id); + if (unread.length) { + markLocal(unread, true); + story.unread = 0; + api('/feeds/items/read/', {ids: unread, read: true}).catch(() => {}); + } + openItem(lead.id, story); + } + + // ---- events + + tab.addEventListener('click', async e => { + const t = e.target.closest('[data-view],[data-act],[data-story],[data-item],[data-cat],[data-edit]'); + if (!t || t.tagName === 'SELECT' || (t.tagName === 'INPUT' && t.type !== 'button')) return; + if (t.dataset.view) { + st.view = t.dataset.view; + try { localStorage.setItem('diora_feeds_view', st.view); } catch (err) {} + load(); + return; + } + if (t.dataset.story) return openStory(parseInt(t.dataset.story, 10)); + if (t.dataset.item) { + readerList = st.items; + return openItem(parseInt(t.dataset.item, 10), null); + } + if (t.dataset.cat !== undefined && !t.dataset.act) { + st.category = t.dataset.cat; + window.scrollTo(0, 0); + return load(); + } + if (t.dataset.edit) { + st.editing = parseInt(t.dataset.edit, 10); + return render(); + } + const act = t.dataset.act; + if (act === 'refresh') { + if (st.loading) return; + st.loading = true; + render(); + try { await api('/feeds/refresh/', {}); } catch (err) {} + return load(); + } + if (act === 'manage') { + st.view = 'manage'; + st.found = null; + window.scrollTo(0, 0); + if (!st.state) await loadState().catch(() => {}); + return render(); + } + if (act === 'close-manage') { + try { st.view = localStorage.getItem('diora_feeds_view') || 'news'; } catch (err) { st.view = 'news'; } + st.editing = null; + st.opml = null; + return load(); + } + if (act === 'seen') { + t.disabled = true; + try { await api('/feeds/briefing/seen/', {}); } catch (err) {} + return load(); + } + if (act === 'more') return load(true); + if (act === 'blogs-read') { + const ids = st.items.filter(i => !i.read).map(i => i.id); + if (!ids.length) return; + await api('/feeds/items/read/', {ids, read: true}).catch(() => {}); + return load(); + } + if (act === 'cancel-edit') { + st.editing = null; + return render(); + } + if (act === 'delete') { + const src = sources().find(x => x.id === parseInt(t.dataset.id, 10)); + const ok = typeof customConfirm === 'function' + ? await customConfirm('„' + (src ? src.title : 'Feed') + '“ abbestellen? Seine Artikel verschwinden mit.', {danger: true}) + : window.confirm('Abbestellen?'); + if (!ok) return; + await api('/feeds/sources/' + t.dataset.id + '/delete/', {}).catch(() => {}); + st.editing = null; + await loadState().catch(() => {}); + return render(); + } + if (act === 'opml-cancel') { + st.opml = null; + return render(); + } + if (act === 'opml-import') { + const list = []; + st.opml.forEach(g => { + if (g.kind === 'skip') return; + g.feeds.forEach(f => list.push({url: f.url, title: f.title, kind: g.kind, category: g.kind === 'news' ? g.name : ''})); + }); + t.disabled = true; + t.textContent = 'Wird übernommen …'; + try { + const r = await api('/feeds/sources/bulk/', {sources: list}); + st.opml = null; + st.error = ''; + await loadState(); + render(); + if (typeof customAlert === 'function') customAlert(r.added + ' Feeds übernommen.'); + } catch (err) { + st.error = err.message; + render(); + } + } + }); + + tab.addEventListener('change', async e => { + const t = e.target; + if (t.dataset.act === 'blog-source') { st.blogSource = t.value; return load(); } + if (t.dataset.act === 'blog-unread') { st.blogUnread = t.checked; return load(); } + if (t.dataset.act === 'keep') { + await api('/feeds/settings/', {news_keep_days: parseInt(t.value, 10)}).catch(() => {}); + return; + } + if (t.dataset.opml !== undefined) { + st.opml[parseInt(t.dataset.opml, 10)].kind = t.value; + return render(); + } + if (t.dataset.act === 'opml' && t.files && t.files[0]) { + // FileReader rather than File.text(): the e-reader's Chrome 61 lacks the latter. + const text = await new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(fr.result); + fr.onerror = () => reject(fr.error); + fr.readAsText(t.files[0]); + }); + const groups = parseOpml(text); + st.opml = groups.length ? groups : null; + st.error = groups.length ? '' : 'In der Datei sind keine Feeds.'; + render(); + } + }); + + tab.addEventListener('submit', async e => { + e.preventDefault(); + const f = e.target; + const btn = f.querySelector('[type=submit]'); + if (btn) btn.disabled = true; + try { + if (f.dataset.form === 'discover') { + st.found = null; + st.error = ''; + if (btn) btn.textContent = 'Sucht …'; + const r = await api('/feeds/discover/', {url: f.url.value}); + st.found = r.feeds; + } else if (f.dataset.form === 'subscribe') { + const found = st.found[parseInt(f.dataset.n, 10)]; + if (btn) btn.textContent = 'Lädt Artikel …'; + await api('/feeds/sources/add/', {url: found.url, title: found.title, kind: f.kind.value, + category: f.kind.value === 'news' ? f.category.value : ''}); + found.subscribed = true; + await loadState(); + } else if (f.dataset.form === 'edit') { + await api('/feeds/sources/' + f.dataset.id + '/', {title: f.title.value, kind: f.kind.value, + category: f.category.value, fulltext: f.fulltext.value}); + st.editing = null; + await loadState(); + } + st.error = ''; + } catch (err) { + st.error = err.message; + } + render(); + }); + + // The Ressort field only means something for news. + tab.addEventListener('input', e => { + if (e.target.name !== 'kind') return; + const form = e.target.form; + const cat = form && form.querySelector('.fd-cat-field'); + if (cat) cat.style.display = e.target.value === 'news' ? '' : 'none'; + }); + + // ---- showing the tab + + let shownAt = 0; + window.__dioraFeedsShow = function () { + // Back after a while: fresh state (new briefing); otherwise as it was. + if (!st.state || Date.now() - shownAt > 5 * 60000) load(); + else render(); + shownAt = Date.now(); + }; + // What other parts (Home) may show: the briefing and the unread blog posts. + window.__dioraFeedsSummary = async function () { + const [state, briefing] = await Promise.all([api('/feeds/state/'), api('/feeds/briefing/')]); + return {state, briefing}; + }; + window.__dioraFeedsOpenStory = function (story) { + if (!st.stories.some(x => x.id === story.id)) st.stories.push(story); + openStory(story.id); + }; + + setInterval(() => { + if (tab.style.display !== 'none' && !readerOpen() && st.view !== 'manage' && !st.loading) load(); + }, 10 * 60000); + + // Back (Android): the article, then the management page, then (diora Reader) to the books. + const back = window.__dioraBack; + window.__dioraBack = function () { + if (readerOpen()) { closeReader(); return true; } + if (tab.style.display !== 'none' && st.view === 'manage') { + tab.querySelector('[data-act="close-manage"]').click(); + return true; + } + if (back && back()) return true; + if (READER_APP && typeof _currentTopTab !== 'undefined' && _currentTopTab === 'feeds') { + showTab('books'); + return true; + } + return false; + }; + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && readerOpen()) closeReader(); + }); + + // diora Reader has no tab bar of its own: a small one for books and feeds. + if (READER_APP && !document.getElementById('nui-tabbar')) { + const dock = document.createElement('div'); + dock.id = 'nui-dock'; + dock.innerHTML = ''; + document.body.appendChild(dock); + const mark = () => dock.querySelectorAll('button').forEach(b => + b.classList.toggle('nui-active', typeof _currentTopTab !== 'undefined' && b.dataset.tab === _currentTopTab)); + dock.addEventListener('click', e => { + const b = e.target.closest('button'); + if (!b) return; + showTab(b.dataset.tab); + window.scrollTo(0, 0); + }); + const orig = window.showTab; + window.showTab = function (name) { + orig.apply(this, arguments); + mark(); + }; + document.documentElement.style.setProperty('--nui-dock-h', '56px'); + const overlay = document.getElementById('reader-overlay'); + if (overlay) { + new MutationObserver(() => document.documentElement.classList.toggle('nui-reading', overlay.style.display !== 'none')) + .observe(overlay, {attributes: true, attributeFilter: ['style']}); + } + mark(); + } + + // ---- styles + + const style = document.createElement('style'); + style.textContent = [ + '#tab-feeds .fd { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }', + '#tab-feeds .fd-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 4px 0 14px; }', + '#tab-feeds .fd-seg { display: flex; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; overflow: hidden; }', + '#tab-feeds .fd-seg button { background: none; border: none; color: inherit; font: inherit; padding: 9px 14px; cursor: pointer; }', + '#tab-feeds .fd-seg button.on { background: var(--accent, #e63946); color: #fff; }', + '#tab-feeds .fd-count { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px; background: rgba(255,255,255,0.18); font-size: 11px; text-align: center; }', + '#tab-feeds .fd-actions { display: flex; gap: 4px; }', + '.fd-icon { background: none; border: none; color: inherit; font: inherit; font-size: 20px; min-width: 40px; min-height: 40px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }', + '.fd-icon.on { color: var(--accent, #e63946); }', + '#tab-feeds .fd-back { background: none; border: none; color: inherit; font: inherit; padding: 8px 0; cursor: pointer; }', + '#tab-feeds .fd-h { margin: 0; font-size: 15px; }', + '.fd-note { color: var(--nui-muted, #9a9a9a); font-size: 13px; font-weight: 400; }', + '.fd-error, .fd-warn { color: var(--accent, #e63946); font-size: 13px; }', + '#tab-feeds .fd-empty { text-align: center; padding: 40px 0; }', + '#tab-feeds h3 { margin: 18px 0 6px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }', + '#tab-feeds h3 small { font-size: 11px; }', + '#tab-feeds .fd-brief { border: 1px solid var(--border, #2a2a2a); border-radius: 6px; padding: 12px 14px 14px; margin-bottom: 18px; background: #0b0b0b; }', + '#tab-feeds .fd-brief-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; flex-wrap: wrap; }', + '#tab-feeds .fd-brief-head h2 { margin: 0; font-size: 16px; color: var(--accent, #e63946); }', + '#tab-feeds .fd-seen { width: 100%; margin-top: 14px; min-height: 42px; }', + '#tab-feeds .fd-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', + '#tab-feeds .fd-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }', + '#tab-feeds .fd-row-title { font-size: 14px; line-height: 1.35; }', + '.fd-row-meta { font-size: 12px; font-weight: 400; color: var(--nui-muted, #9a9a9a); }', + '#tab-feeds .fd-more-src { color: var(--accent, #e63946); }', + '#tab-feeds .fd-badge { flex: none; min-width: 26px; height: 26px; border-radius: 13px; background: var(--accent, #e63946); color: #fff; font-size: 12px; display: inline-flex; align-items: center; justify-content: center; }', + '#tab-feeds .fd-link { background: none; border: none; color: var(--nui-muted, #9a9a9a); font: inherit; font-size: 12px; padding: 8px 0; cursor: pointer; }', + '#tab-feeds .fd-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 10px; align-items: center; }', + // The reports of a story: one row that scrolls sideways, however many there are. + '.fd-sources { display: flex; gap: 6px; margin: 4px -18px 10px; padding: 0 18px; align-items: center; overflow-x: auto; white-space: nowrap; scrollbar-width: none; }', + '.fd-sources::-webkit-scrollbar { display: none; }', + '.fd-sources .fd-chip { flex: none; }', + '#fd-article .fd-art-more { font-family: var(--font); margin: 30px 0 4px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }', + '#fd-article .fd-other { display: flex; flex-direction: column; gap: 3px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 0.85em; text-align: left; cursor: pointer; }', + '#fd-article .fd-other-title { font-weight: 600; line-height: 1.35; }', + '.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }', + '.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }', + '#tab-feeds .fd-day { margin-top: 16px; }', + '#tab-feeds .fd-card { display: flex; gap: 12px; width: 100%; padding: 12px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', + '#tab-feeds .fd-thumb { width: 92px; height: 66px; flex: none; object-fit: cover; border-radius: 4px; background: #1a1a1a; order: 2; }', + '#tab-feeds .fd-card-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }', + '#tab-feeds .fd-card-title { font-size: 15px; font-weight: 700; line-height: 1.35; }', + '#tab-feeds .fd-card-sum { font-size: 13px; font-weight: 400; line-height: 1.4; color: var(--nui-muted, #bdbdbd); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }', + '#tab-feeds .fd-read .fd-card-title, #tab-feeds .fd-read .fd-row-title { font-weight: 400; color: var(--nui-muted, #9a9a9a); }', + '#tab-feeds .fd-post { display: flex; flex-direction: column; gap: 4px; width: 100%; padding: 12px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', + '#tab-feeds .fd-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }', + '#tab-feeds .fd-select { max-width: 60%; }', + '#tab-feeds .fd-check { font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }', + '#tab-feeds .fd-allread { margin-left: auto; }', + '#tab-feeds .fd-loadmore { width: 100%; margin-top: 14px; }', + '#tab-feeds .fd-box { border: 1px solid var(--border, #2a2a2a); border-radius: 6px; padding: 4px 14px 14px; margin-bottom: 14px; }', + '#tab-feeds .fd-add { display: flex; gap: 8px; }', + '#tab-feeds .fd-add input { flex: 1; min-width: 0; }', + '#tab-feeds .fd-found, #tab-feeds .fd-edit { border-top: 1px solid var(--border, #2a2a2a); margin-top: 12px; padding-top: 10px; display: flex; flex-direction: column; gap: 8px; }', + '#tab-feeds .fd-found-title { font-weight: 700; }', + '#tab-feeds .fd-url { word-break: break-all; }', + '#tab-feeds .fd-samples { margin: 0; padding-left: 18px; font-size: 13px; font-weight: 400; }', + '#tab-feeds .fd-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; }', + '#tab-feeds .fd-grid label, #tab-feeds .fd-edit > label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--nui-muted, #9a9a9a); }', + '#tab-feeds .fd-buttons { display: flex; flex-wrap: wrap; gap: 8px; }', + '#tab-feeds .fd-src { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', + '#tab-feeds .fd-opml { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }', + '#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }', + '#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }', + '#tab-feeds .fd-file { display: inline-block; }', + // The article: a page over everything, like the book reader. + '#fd-article { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; background: var(--bg, #000); color: var(--fg, #fff); overflow-y: auto; -webkit-overflow-scrolling: touch; }', + '#fd-article[hidden] { display: none; }', + '#fd-article .fd-art-bar { position: sticky; top: 0; display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: var(--bg, #000); border-bottom: 1px solid var(--border, #2a2a2a); padding-top: calc(4px + env(safe-area-inset-top, 0px)); }', + '#fd-article .fd-art-src { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--nui-muted, #9a9a9a); }', + '#fd-article .fd-art { max-width: 68ch; margin: 0 auto; padding: 12px 18px 48px; font-family: "Source Serif 4", Georgia, serif; font-weight: 380; line-height: 1.65; }', + '#fd-article .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }', + '#fd-article .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }', + '#fd-article .fd-art-img, #fd-article .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }', + '#fd-article .fd-art-body a { color: var(--accent, #e63946); }', + '#fd-article .fd-art-body figure { margin: 12px 0; }', + '#fd-article .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }', + '#fd-article .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border, #444); }', + '#fd-article .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }', + '#fd-article .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }', + '#fd-article .fd-art-body td, #fd-article .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }', + '#fd-article .fd-sources { font-family: var(--font); }', + '#fd-article .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }', + 'html.fd-reading #nui-dock { display: none; }', + '@media (hover: hover) { #tab-feeds .fd-card:hover, #tab-feeds .fd-row:hover, #tab-feeds .fd-post:hover, #tab-feeds .fd-src:hover { background: #111; } }', + // Bright pages and e-ink: black on white, no grey boxes, no thumbnails to flicker. + 'body.bright-bg #tab-feeds .fd-brief, body.eink #tab-feeds .fd-brief { background: #fff; }', + 'body.eink #tab-feeds .fd-thumb { display: none; }', + 'body.eink #tab-feeds .fd-seg button.on, body.eink #tab-feeds .fd-badge { background: #000; color: #fff; }', + 'body.eink .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }', + 'body.eink #fd-article, body.eink #fd-article .fd-art-bar { background: #fff; color: #000; }', + 'body.eink #fd-article .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }', + ].join('\n'); + document.head.appendChild(style); +})(); diff --git a/static/js/sw.js b/static/js/sw.js index e347250..a35056d 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,12 +2,13 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v88'; +const CACHE = 'diora-v89'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', '/static/js/podcasts-web.js', '/static/js/home.js', + '/static/js/feeds.js', '/static/podcasts/native-podcast.js', '/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css', diff --git a/templates/radio/player.html b/templates/radio/player.html index 25356cd..07474ad 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -53,6 +53,7 @@ + @@ -261,6 +262,10 @@ {% comment %}Filled by static/js/home.js: the book in reading, the next episode, stations, Ambient.{% endcomment %} + +{% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %} + + {% comment %}"Drift", a generative ambient synthesizer (static/ambient/), in a frame of its own: its script and styles stay apart from the page's. Loaded the first time the tab opens.{% endcomment %} @@ -345,4 +350,5 @@ + {% endblock %}