diora-web/static/js/feeds.js

874 lines
45 KiB
JavaScript
Raw Normal View History

/*
* 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,
blogFolder: null, // null = all blogs, '' = blogs without a folder, else a folder name
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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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);
else if (st.blogFolder !== null) q.push('folder=' + encodeURIComponent(st.blogFolder));
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 = '<p class="fd-note">Lädt …</p>';
else if (st.view === 'news') body = renderNews();
else body = renderBlogs();
tab.innerHTML =
'<div class="fd">' +
'<div class="fd-top">' +
(st.view === 'manage'
? '<button type="button" class="fd-back" data-act="close-manage">‹ Zurück</button><h2 class="fd-h">Feeds verwalten</h2>'
: '<div class="fd-seg">' +
'<button type="button" data-view="news" class="' + (st.view === 'news' ? 'on' : '') + '">Nachrichten' +
(newsN ? ' <span class="fd-count">' + newsN + '</span>' : '') + '</button>' +
'<button type="button" data-view="blogs" class="' + (st.view === 'blogs' ? 'on' : '') + '">Blogs' +
(blogN ? ' <span class="fd-count">' + blogN + '</span>' : '') + '</button>' +
'</div>' +
'<div class="fd-actions">' +
'<button type="button" class="fd-icon" data-act="refresh" title="Jetzt abrufen" aria-label="Jetzt abrufen">' + (st.loading ? '…' : '↻') + '</button>' +
'<button type="button" class="fd-icon" data-act="manage" title="Feeds verwalten" aria-label="Feeds verwalten">⚙</button>' +
'</div>') +
'</div>' +
(st.error ? '<p class="fd-error">' + esc(st.error) + '</p>' : '') +
body + '</div>';
}
function storyMeta(x) {
const lead = x.lead || {};
const others = x.sources - 1;
return esc(lead.source_title) + (others > 0 ? ' <span class="fd-more-src">+' + others + '</span>' : '') +
' · ' + esc(when(x.last_at));
}
function storyRow(x) {
const lead = x.lead || {};
return '<button type="button" class="fd-row' + (x.unread ? '' : ' fd-read') + '" data-story="' + x.id + '">' +
'<span class="fd-row-text"><span class="fd-row-title">' + esc(lead.title) + '</span>' +
'<span class="fd-row-meta">' + storyMeta(x) + '</span></span>' +
(x.sources > 1 ? '<span class="fd-badge" title="' + x.sources + ' Quellen">' + x.sources + '</span>' : '') +
'</button>';
}
function renderBriefing() {
const b = st.briefing;
if (!b || !b.total) return '';
let h = '<section class="fd-brief">' +
'<div class="fd-brief-head"><h2>Seit letztem Mal</h2>' +
'<span class="fd-note">' + b.total + (b.total === 1 ? ' Meldung' : ' Meldungen') + ' seit ' + esc(when(b.since)) + '</span></div>';
if (b.top.length) {
h += '<h3>Top-Themen</h3>' + b.top.map(storyRow).join('');
}
b.sections.forEach(sec => {
h += '<h3>' + esc(sec.category) + ' <small>' + sec.count + '</small></h3>' + sec.stories.map(storyRow).join('');
if (sec.count > sec.stories.length) {
h += '<button type="button" class="fd-link" data-cat="' + esc(sec.category) + '">' +
(sec.count - sec.stories.length) + ' weitere in ' + esc(sec.category) + ' ›</button>';
}
});
h += '<button type="button" class="btn btn-primary fd-seen" data-act="seen">Alles gelesen ✓</button></section>';
return h;
}
function renderNews() {
if (!sources('news').length) {
return '<div class="fd-empty"><p>Noch keine Nachrichtenquellen.</p>' +
'<button type="button" class="btn btn-primary" data-act="manage">Feed hinzufügen</button></div>';
}
const cats = (st.state.categories || []);
let h = renderBriefing();
if (cats.length > 1) {
h += '<div class="fd-chips">' +
'<button type="button" class="fd-chip' + (st.category ? '' : ' on') + '" data-cat="">Alle</button>' +
cats.map(c => '<button type="button" class="fd-chip' + (st.category === c ? ' on' : '') + '" data-cat="' + esc(c) + '">' + esc(c) + '</button>').join('') +
'</div>';
}
if (!st.stories.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : 'Gerade keine Nachrichten.') + '</p>';
let day = '';
st.stories.forEach(x => {
const d = dayLabel(new Date(x.last_at));
if (d !== day) {
day = d;
h += '<h3 class="fd-day">' + esc(d) + '</h3>';
}
const lead = x.lead || {};
h += '<button type="button" class="fd-card' + (x.unread ? '' : ' fd-read') + '" data-story="' + x.id + '">' +
(lead.image_url ? '<img class="fd-thumb" loading="lazy" alt="" src="' + esc(lead.image_url) + '">' : '') +
'<span class="fd-card-text"><span class="fd-card-title">' + esc(lead.title) + '</span>' +
(lead.summary ? '<span class="fd-card-sum">' + esc(lead.summary) + '</span>' : '') +
'<span class="fd-row-meta">' + storyMeta(x) + '</span></span></button>';
});
if (st.storiesMore) h += '<button type="button" class="btn fd-loadmore" data-act="more">Ältere laden</button>';
return h;
}
function renderBlogs() {
const blogs = sources('blog');
if (!blogs.length) {
return '<div class="fd-empty"><p>Noch keine Blogs abonniert.</p>' +
'<button type="button" class="btn btn-primary" data-act="manage">Feed hinzufügen</button></div>';
}
// Folders (a blog's category): chips with their unread count.
const folders = (st.state.folders || []);
const unreadIn = f => blogs.filter(b => (b.category || '') === f).reduce((n, b) => n + b.unread, 0);
const total = blogs.reduce((n, b) => n + b.unread, 0);
let h = '';
if (folders.length) {
const chip = (value, label, n) => '<button type="button" class="fd-chip' + (st.blogFolder === value ? ' on' : '') +
'" data-folder="' + (value === null ? '*' : esc(value)) + '">' + esc(label) + (n ? ' <small>' + n + '</small>' : '') + '</button>';
h += '<div class="fd-chips">' + chip(null, 'Alle', total) +
folders.map(f => chip(f, f, unreadIn(f))).join('') +
(blogs.some(b => !b.category) ? chip('', 'Ohne Ordner', unreadIn('')) : '') + '</div>';
}
const shown = st.blogFolder === null ? blogs : blogs.filter(b => (b.category || '') === st.blogFolder);
h += '<div class="fd-filter">' +
'<select class="search-input fd-select" data-act="blog-source"><option value="">' +
(st.blogFolder === null ? 'Alle Blogs' : 'Alle in ' + esc(st.blogFolder || 'Ohne Ordner')) + '</option>' +
shown.map(b => '<option value="' + b.id + '"' + (String(b.id) === String(st.blogSource) ? ' selected' : '') + '>' +
esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + '</option>').join('') + '</select>' +
'<label class="fd-check"><input type="checkbox" data-act="blog-unread"' + (st.blogUnread ? ' checked' : '') + '> Nur ungelesene</label>' +
(st.items.some(i => !i.read) ? '<button type="button" class="btn fd-allread" data-act="blogs-read">Alle gelesen</button>' : '') +
'</div>';
if (!st.items.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '</p>';
h += st.items.map(i =>
'<button type="button" class="fd-post' + (i.read ? ' fd-read' : '') + '" data-item="' + i.id + '">' +
'<span class="fd-card-title">' + (i.starred ? '★ ' : '') + esc(i.title) + '</span>' +
'<span class="fd-row-meta">' + esc(i.source_title) + ' · ' + esc(when(i.published)) + '</span>' +
(i.summary ? '<span class="fd-card-sum">' + esc(i.summary) + '</span>' : '') +
'</button>').join('');
if (st.itemsMore) h += '<button type="button" class="btn fd-loadmore" data-act="more">Ältere laden</button>';
return h;
}
function kindSelect(name, value) {
return '<select class="search-input" name="' + name + '">' +
'<option value="news"' + (value === 'news' ? ' selected' : '') + '>Nachrichten</option>' +
'<option value="blog"' + (value === 'blog' ? ' selected' : '') + '>Blog</option></select>';
}
function categoryInput(value) {
const cats = ((st.state && st.state.categories) || []).concat((st.state && st.state.folders) || []);
return '<input class="search-input" name="category" list="fd-cats" placeholder="z. B. Politik / Philosophie" value="' + esc(value || '') + '">' +
'<datalist id="fd-cats">' + cats.map(c => '<option value="' + esc(c) + '">').join('') + '</datalist>';
}
function renderManage() {
const s = st.state || {sources: [], settings: {}};
let h = '<section class="fd-box"><h3>Feed hinzufügen</h3>' +
'<form class="fd-add" data-form="discover"><input class="search-input" name="url" type="url" inputmode="url" ' +
'placeholder="Adresse einer Seite oder eines Feeds" required>' +
'<button class="btn btn-primary" type="submit">Suchen</button></form>';
if (st.found) {
h += st.found.map((f, n) =>
'<form class="fd-found" data-form="subscribe" data-n="' + n + '">' +
'<div class="fd-found-title">' + esc(f.title) + '</div><div class="fd-note fd-url">' + esc(f.url) + '</div>' +
(f.samples.length ? '<ul class="fd-samples">' + f.samples.slice(0, 3).map(t => '<li>' + esc(t) + '</li>').join('') + '</ul>' : '') +
(f.subscribed ? '<p class="fd-note">Schon abonniert.</p>' :
'<div class="fd-grid"><label>Art ' + kindSelect('kind', f.suggested_kind) + '</label>' +
'<label class="fd-cat-field">' + (f.suggested_kind === 'news' ? 'Ressort' : 'Ordner') + ' ' + categoryInput('') + '</label></div>' +
'<button class="btn btn-primary" type="submit">Abonnieren</button>') +
'</form>').join('');
}
h += '</section>';
const news = s.sources.filter(x => x.kind === 'news');
const blogs = s.sources.filter(x => x.kind === 'blog');
const group = (title, list) => !list.length ? '' :
'<section class="fd-box"><h3>' + title + ' <small>' + list.length + '</small></h3>' +
list.map(x => x.id === st.editing ? editForm(x) :
'<button type="button" class="fd-src" data-edit="' + x.id + '">' +
'<span class="fd-src-title">' + esc(x.title) + '</span>' +
'<span class="fd-row-meta">' + (x.kind === 'news' ? esc(x.category || 'Allgemein') + ' · ' : (x.category ? esc(x.category) + ' · ' : '')) +
(x.unread ? x.unread + ' ungelesen' : '') +
(x.error_count >= 3 ? ' <span class="fd-warn" title="' + esc(x.last_error) + '">⚠ Abruf schlägt fehl</span>' : '') +
'</span></button>').join('') + '</section>';
h += group('Nachrichten', news.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.title.localeCompare(b.title)));
h += group('Blogs', blogs.sort((a, b) => (a.category || '~').localeCompare(b.category || '~') || a.title.localeCompare(b.title)));
h += '<section class="fd-box"><h3>Import &amp; Einstellungen</h3>' +
'<label class="btn fd-file">OPML importieren …<input type="file" accept=".opml,.xml,text/xml,text/x-opml" data-act="opml" hidden></label> ' +
(window.DioraNative ? '' : '<a class="btn" href="/feeds/export.opml">OPML exportieren</a>');
if (st.opml) h += renderOpml();
const keep = (s.settings && s.settings.news_keep_days) || 2;
h += '<label class="fd-keep">Nachrichten behalten: <select class="search-input" data-act="keep">' +
[1, 2, 3, 5, 7, 14].map(d => '<option value="' + d + '"' + (d === keep ? ' selected' : '') + '>' + d + (d === 1 ? ' Tag' : ' Tage') + '</option>').join('') +
'</select></label><p class="fd-note">Ältere Nachrichten verschwinden von selbst (gemerkte bleiben). Blogartikel bleiben, bis sie gelesen sind.</p></section>';
return h;
}
function editForm(x) {
return '<form class="fd-edit" data-form="edit" data-id="' + x.id + '">' +
'<label>Name <input class="search-input" name="title" value="' + esc(x.title) + '"></label>' +
'<div class="fd-note fd-url">' + esc(x.url) + '</div>' +
(x.last_error ? '<p class="fd-warn">Letzter Fehler: ' + esc(x.last_error) + '</p>' : '') +
'<div class="fd-grid"><label>Art ' + kindSelect('kind', x.kind) + '</label>' +
'<label class="fd-cat-field">' + (x.kind === 'news' ? 'Ressort' : 'Ordner') + ' ' + categoryInput(x.category) + '</label>' +
'<label>Ganzer Artikel <select class="search-input" name="fulltext">' +
[['auto', 'Automatisch'], ['always', 'Immer holen'], ['never', 'Nur Feed-Text']].map(o =>
'<option value="' + o[0] + '"' + (x.fulltext === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') +
'</select></label></div>' +
'<div class="fd-buttons"><button class="btn btn-primary" type="submit">Speichern</button>' +
'<button class="btn" type="button" data-act="cancel-edit">Abbrechen</button>' +
'<button class="btn btn-danger" type="button" data-act="delete" data-id="' + x.id + '">Abbestellen</button></div></form>';
}
function renderOpml() {
const groups = st.opml;
const n = groups.reduce((a, g) => a + (g.kind === 'skip' ? 0 : g.feeds.length), 0);
return '<div class="fd-opml"><p class="fd-note">Was ist was? Pro Ordner:</p>' +
groups.map((g, i) =>
'<div class="fd-opml-row"><span>' + esc(g.name || 'Ohne Ordner') + ' <small>(' + g.feeds.length + ')</small></span>' +
'<select class="search-input" data-opml="' + i + '">' +
[['news', 'Nachrichten'], ['blog', 'Blogs'], ['skip', 'Nicht übernehmen']].map(o =>
'<option value="' + o[0] + '"' + (g.kind === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') +
'</select></div>').join('') +
'<button type="button" class="btn btn-primary" data-act="opml-import"' + (n ? '' : ' disabled') + '>' + n + ' Feeds übernehmen</button> ' +
'<button type="button" class="btn" data-act="opml-cancel">Abbrechen</button></div>';
}
// ---- 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('<p class="fd-note">Lädt …</p>');
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('<p class="fd-error">' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '</p>');
}
}
function readerShell(inner) {
const i = readerItem;
return '<div class="fd-art-bar">' +
'<button type="button" class="fd-icon" data-r="close" aria-label="Zurück" title="Zurück">‹</button>' +
'<span class="fd-art-src">' + esc(i ? i.source_title : '') + '</span>' +
'<button type="button" class="fd-icon" data-r="font" aria-label="Schriftgröße" title="Schriftgröße">Aa</button>' +
(i ? '<button type="button" class="fd-icon' + (i.starred ? ' on' : '') + '" data-r="star" aria-label="Merken" title="Merken">' + (i.starred ? '★' : '☆') + '</button>' : '') +
(i && i.url ? '<a class="fd-icon" href="' + esc(i.url) + '" target="_blank" rel="noopener" aria-label="Im Browser öffnen" title="Im Browser öffnen">↗</a>' : '') +
'</div><div class="fd-art" style="font-size:' + fontSize() + 'px">' + inner + '</div>';
}
function articleHtml(i) {
let h = '';
const story = readerStory;
if (story && story.items.length > 1) {
h += '<div class="fd-sources"><span class="fd-note">' + story.items.length + ' Berichte:</span>' +
story.items.map(x => '<button type="button" class="fd-chip' + (x.id === i.id ? ' on' : '') + '" data-r="item" data-id="' + x.id + '">' +
esc(x.source_title) + '</button>').join('') + '</div>';
}
h += '<h1 class="fd-art-title">' + esc(i.title) + '</h1>' +
'<p class="fd-art-meta">' + [i.author, i.source_title, when(i.published)].filter(Boolean).map(esc).join(' · ') + '</p>';
const content = i.content || '';
if (i.image_url && content.indexOf(i.image_url) < 0) h += '<img class="fd-art-img" alt="" src="' + esc(i.image_url) + '">';
h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>';
if (!i.content_full && i.url && (content.replace(/<[^>]+>/g, '').length < 700)) {
h += '<p class="fd-note">Der Feed bringt nur einen Anriss, der ganze Artikel ließ sich nicht laden.</p>';
}
if (story && story.items.length > 1) {
h += '<h3 class="fd-art-more">Weitere Berichte</h3>' + story.items.filter(x => x.id !== i.id).map(x =>
'<button type="button" class="fd-other" data-r="item" data-id="' + x.id + '">' +
'<span class="fd-other-title">' + esc(x.title) + '</span>' +
'<span class="fd-row-meta">' + esc(x.source_title) + ' · ' + esc(when(x.published)) + '</span></button>').join('');
}
h += '<div class="fd-art-foot">';
if (i.url) h += '<a class="btn" href="' + esc(i.url) + '" target="_blank" rel="noopener">Original öffnen ↗</a>';
const next = nextItem(i);
if (next) h += '<button type="button" class="btn btn-primary" data-r="item" data-id="' + next.id + '">Nächster Artikel ›</button>';
h += '</div>';
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],[data-folder]');
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.folder !== undefined) {
st.blogFolder = t.dataset.folder === '*' ? null : t.dataset.folder;
st.blogSource = '';
window.scrollTo(0, 0);
return load();
}
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.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.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 same field is the Ressort of news and the folder of a blog.
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.firstChild) cat.firstChild.nodeValue = (e.target.value === 'news' ? 'Ressort' : 'Ordner') + ' ';
});
// ---- 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 = '<nav id="nui-tabbar">' +
'<button type="button" data-tab="books"><span class="nui-tab-icon">▤</span>Bücher</button>' +
'<button type="button" data-tab="feeds"><span class="nui-tab-icon">☰</span>Feeds</button></nav>';
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); }',
'.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }',
'#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);
})();