/*
* 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,
blogFav: false, // main feed: only the favorite blogs
rareFirst: true, // within a day: blogs that seldom post first
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;
if (localStorage.getItem('diora_feeds_rare') === '0') st.rareFirst = false;
if (localStorage.getItem('diora_feeds_unread') === '0') st.blogUnread = false;
} 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);
else if (st.blogFolder !== null) q.push('folder=' + encodeURIComponent(st.blogFolder));
if (st.blogUnread) q.push('unread=1');
if (st.blogFav && !st.blogSource && st.blogFolder === null) q.push('fav=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_new : 0; // new = younger than a day, unread (hidden blogs not counted)
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'
? '
‹ Zurück Feeds verwalten '
: '
' +
'Nachrichten' +
(newsN ? ' ' + newsN + ' ' : '') + ' ' +
'Blogs' +
(blogN ? ' ' + blogN + ' ' : '') + ' ' +
'
' +
'
' +
'' + (st.loading ? '…' : '↻') + ' ' +
'⚙ ' +
'
') +
'
' +
(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 '' +
'' + esc(lead.title) + ' ' +
'' + storyMeta(x) + ' ' +
(x.sources > 1 ? '' + x.sources + ' ' : '') +
' ';
}
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 += '' +
(sec.count - sec.stories.length) + ' weitere in ' + esc(sec.category) + ' › ';
}
});
h += 'Alles gelesen ✓ ';
return h;
}
function renderNews() {
if (!sources('news').length) {
return 'Noch keine Nachrichtenquellen.
' +
'
Feed hinzufügen ';
}
const cats = (st.state.categories || []);
let h = renderBriefing();
if (cats.length > 1) {
h += '' +
'Alle ' +
cats.map(c => '' + esc(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 += '' +
(lead.image_url ? ' ' : '') +
'' + esc(lead.title) + ' ' +
(lead.summary ? '' + esc(lead.summary) + ' ' : '') +
'' + storyMeta(x) + ' ';
});
if (st.storiesMore) h += 'Ältere laden ';
return h;
}
// ---- blogs: days, favorites first, rare blogs first (after Kino's feed)
const NEW_MS = 24 * 3600000;
const FAV_PER_DAY = 5; // posts of one favorite blog that go first per section
function isNew(i, src) {
return !i.read && Date.now() - new Date(i.published).getTime() < NEW_MS && !(src && src.hidden);
}
function readingTime(i) {
return i.words >= 120 ? Math.max(1, Math.round(i.words / 230)) + ' Min.' : '';
}
function section(iso) {
const d = new Date(iso);
const label = dayLabel(d);
if (label === 'Heute' || label === 'Gestern') return label;
const days = (Date.now() - d.getTime()) / 86400000;
if (days < 7) return 'Diese Woche';
if (days < 31) return 'Diesen Monat';
return 'Älter';
}
// Posts into sections (newest first); in each, up to FAV_PER_DAY posts per
// favorite blog first, then the rest — blogs that post seldom ahead of the
// busy ones ("Seltene zuerst"), or simply by time.
function blogSections(items, byId) {
const order = ['Heute', 'Gestern', 'Diese Woche', 'Diesen Monat', 'Älter'];
const groups = {};
items.forEach(i => { (groups[section(i.published)] = groups[section(i.published)] || []).push(i); });
const newest = (a, b) => new Date(b.published) - new Date(a.published);
return order.filter(k => groups[k]).map(k => {
const list = groups[k].slice().sort(newest);
const favCount = {};
const fav = [];
const rest = [];
list.forEach(i => {
const src = byId[i.source];
if (src && src.fav && (favCount[i.source] || 0) < FAV_PER_DAY) {
favCount[i.source] = (favCount[i.source] || 0) + 1;
fav.push(i);
} else {
rest.push(i);
}
});
if (st.rareFirst) {
const gap = i => { const src = byId[i.source]; return (src && src.interval_days) || 0; };
rest.sort((a, b) => (gap(b) - gap(a)) || newest(a, b));
}
return {label: k, items: fav.concat(rest)};
});
}
function renderBlogs() {
const blogs = sources('blog');
if (!blogs.length) {
return 'Noch keine Blogs abonniert.
' +
'
Feed hinzufügen ';
}
const byId = {};
blogs.forEach(b => { byId[b.id] = b; });
// Folders (a blog's category): chips with their unread count, a dot when something is new.
const folders = (st.state.folders || []);
const inFolder = f => blogs.filter(b => (b.category || '') === f);
const sum = (list, key) => list.reduce((n, b) => n + b[key], 0);
const visible = blogs.filter(b => !b.hidden);
let h = '';
if (folders.length) {
const chip = (value, label, list) => '' +
(sum(list, 'new') ? ' ' : '') + esc(label) +
(sum(list, 'unread') ? ' ' + sum(list, 'unread') + ' ' : '') + ' ';
h += '' + chip(null, 'Alle', visible) +
folders.map(f => chip(f, f, inFolder(f))).join('') +
(blogs.some(b => !b.category) ? chip('', 'Ohne Ordner', inFolder('')) : '') + '
';
}
const main = st.blogFolder === null;
const shown = main ? blogs : inFolder(st.blogFolder);
h += '' +
'Ungelesen ' +
'Alle ' +
(main ? '★ Favoriten ' : '') +
'Seltene zuerst ' +
'
';
h += '' +
'' +
(main ? 'Alle Blogs' : 'Alle in ' + esc(st.blogFolder || 'Ohne Ordner')) + ' ' +
shown.map(b => '' +
(b.fav ? '★ ' : '') + esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + (b.hidden ? ' – ausgeblendet' : '') +
' ').join('') + ' ' +
(st.items.some(i => !i.read) ? 'Alle gelesen ' : '') +
'
';
const one = st.blogSource ? byId[st.blogSource] : null;
if (one) {
// The selected blog's switches.
h += '' +
'' + (one.fav ? '★ Favorit' : '☆ Favorit') + ' ' +
'' +
(one.hidden ? 'Im Hauptfeed ausgeblendet' : 'Im Hauptfeed ausblenden') + ' ' +
'⚙
';
}
if (!st.items.length) return h + '' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '
';
blogSections(st.items, byId).forEach(sec => {
h += '' + esc(sec.label) + ' ';
h += sec.items.map(i => {
const src = byId[i.source];
const rt = readingTime(i);
return '' +
'' + (isNew(i, src) ? 'NEU ' : '') + (i.starred ? '★ ' : '') + esc(i.title) + ' ' +
'' + (src && src.fav ? '★ ' : '') + esc(i.source_title) + ' · ' + esc(when(i.published)) + (rt ? ' · ' + rt : '') + ' ' +
(i.summary ? '' + esc(i.summary) + ' ' : '') +
' ';
}).join('');
});
if (st.itemsMore) h += 'Ältere laden ';
return h;
}
function kindSelect(name, value) {
return '' +
'Nachrichten ' +
'Blog ';
}
function categoryInput(value) {
const cats = ((st.state && st.state.categories) || []).concat((st.state && st.state.folders) || []);
return ' ' +
'' + cats.map(c => '').join('') + ' ';
}
function renderManage() {
const s = st.state || {sources: [], settings: {}};
let h = 'Feed hinzufügen ' +
'';
if (st.found) {
h += st.found.map((f, n) =>
'').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) :
'' +
'' + (x.fav ? '★ ' : '') + esc(x.title) + (x.hidden ? ' ausgeblendet ' : '') + ' ' +
'' + (x.kind === 'news' ? esc(x.category || 'Allgemein') + ' · ' : (x.category ? esc(x.category) + ' · ' : '')) +
(x.unread ? x.unread + ' ungelesen' : '') +
(x.error_count >= 3 ? ' ⚠ Abruf schlägt fehl ' : '') +
' ').join('') + ' ';
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 += '';
return h;
}
function editForm(x) {
return '' +
loginForm(x);
}
// A paywalled site (junge Welt …): the subscriber login, kept encrypted on the
// server, which then fetches whole articles. The password never comes back.
function loginForm(x) {
const l = x.login;
if (!l) return '';
let h = '';
h += 'Neu eingeben:
';
}
return h +
'Benutzername ' +
'Passwort ' +
'Das Passwort liegt verschlüsselt auf dem diora-Server und wird nie wieder angezeigt. ' +
'Damit holt der Server die ganzen Artikel statt nur des freien Teils.
' +
'Anmelden
';
}
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 + ') ' +
'' +
[['news', 'Nachrichten'], ['blog', 'Blogs'], ['skip', 'Nicht übernehmen']].map(o =>
'' + o[1] + ' ').join('') +
'
').join('') +
'
' + n + ' Feeds übernehmen ' +
'
Abbrechen ';
}
// ---- 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 : '') + ' ' +
'
Aa ' +
(i ? '
' + (i.starred ? '★' : '☆') + ' ' : '') +
(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 => '' +
esc(x.source_title) + ' ').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.paywalled) {
h += 'Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
') holt diora den ganzen.
';
} else 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 =>
'' +
'' + esc(x.title) + ' ' +
'' + esc(x.source_title) + ' · ' + esc(when(x.published)) + ' ').join('');
}
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],[data-folder],[data-flag],[data-edit-open]');
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.flag) {
const src = sources().find(x => x.id === parseInt(t.dataset.id, 10));
if (!src) return;
src[t.dataset.flag] = !src[t.dataset.flag];
render();
await api('/feeds/sources/' + src.id + '/', {[t.dataset.flag]: src[t.dataset.flag]}).catch(() => {});
return load();
}
if (t.dataset.editOpen) {
st.view = 'manage';
st.editing = parseInt(t.dataset.editOpen, 10);
window.scrollTo(0, 0);
return render();
}
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 === 'mode-unread' || act === 'mode-all') {
st.blogUnread = act === 'mode-unread';
try { localStorage.setItem('diora_feeds_unread', st.blogUnread ? '1' : '0'); } catch (err) {}
return load();
}
if (act === 'mode-fav') {
st.blogFav = !st.blogFav;
return load();
}
if (act === 'mode-rare') {
st.rareFirst = !st.rareFirst;
try { localStorage.setItem('diora_feeds_rare', st.rareFirst ? '1' : '0'); } catch (err) {}
return render();
}
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 === 'login-delete') {
await api('/feeds/logins/delete/', {source: parseInt(t.dataset.id, 10)}).catch(() => {});
await loadState().catch(() => {});
return render();
}
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 === '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 === 'login') {
if (btn) btn.textContent = 'Meldet an …';
const r = await api('/feeds/logins/', {source: parseInt(f.dataset.id, 10), username: f.username.value, password: f.password.value});
f.password.value = '';
await loadState();
if (typeof customAlert === 'function') {
customAlert('Angemeldet.' + (r.refetched ? ' ' + r.refetched + ' Artikel jetzt vollständig.' : ''));
}
} else if (f.dataset.form === 'edit') {
const change = {title: f.title.value, kind: f.kind.value, category: f.category.value, fulltext: f.fulltext.value};
if (f.fav) change.fav = f.fav.checked;
if (f.hidden) change.hidden = f.hidden.checked;
await api('/feeds/sources/' + f.dataset.id + '/', change);
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 = '' +
'▤ Bücher ' +
'☰ Feeds ';
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; }',
'.fd-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #e63946); margin-right: 6px; vertical-align: middle; }',
'#tab-feeds .fd-modes .fd-chip { font-size: 12px; padding: 5px 10px; }',
'#tab-feeds .fd-blogbar { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; }',
'#tab-feeds .fd-new { display: inline-block; padding: 1px 5px; border-radius: 3px; background: var(--accent, #e63946); color: #fff; font-size: 10px; letter-spacing: 0.05em; vertical-align: 2px; }',
'#tab-feeds .fd-hidden { opacity: 0.6; }',
'body.eink #tab-feeds .fd-new, body.eink .fd-dot { background: #000; }',
'#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-login h4 { margin: 0; font-size: 13px; }',
'#tab-feeds .fd-login p { margin: 0; font-size: 13px; }',
'#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);
})();