/*
* 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: '',
topics: [], // the day's topics (/feeds/news/ first page): [{label, ids}]
topic: null, // the one shown, or null
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 (st.topic) q.push('ids=' + st.topic.ids.join(','));
else {
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;
if (news.topics) st.topics = news.topics;
}
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 ? '…' : '↻') + ' ' +
// Ordering with Claude (docs/ai.md): Ressorts for the news, folders for the blogs.
(window.DioraAI && DioraAI.available() && sources().length
? '✦ ' : '') +
'⚙ ' +
'
') +
'
' +
(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.topics.length || st.topic) {
// Topics: stories of the last days about one matter (feeds/textproc.py).
const list = st.topic && !st.topics.some(t => t.label === st.topic.label) ? [st.topic].concat(st.topics) : st.topics;
h += 'Themen ' +
list.map((t, n) => '' + esc(t.label) + ' ' + t.ids.length + ' ').join('') + '
';
st.topicList = list;
}
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 inFolderOf(b, f) {
const list = b.folders || [];
if (f === '') return !list.length;
const k = f.toLowerCase();
return list.some(x => x.toLowerCase() === k);
}
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 => inFolderOf(b, 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.folders || []).length) ? chip('', 'Ohne Ordner', inFolder('')) : '') +
(window.DioraFolders ? '⋯ ' : '') +
'
';
}
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') + ' ' +
'' +
(one.notify ? '🔔 Glocke an' : '🔕 Glocke') + ' ' +
(window.DioraFolders ? '▤ In Ordner … ' : '') +
'⚙
';
}
if (!st.items.length) return h + '' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '
';
const reading = st.items.filter(i => !i.read && i.progress >= 0.05 && i.progress < 0.9)
.sort((a, b) => b.progress - a.progress).slice(0, 3);
if (reading.length) {
h += 'Weiterlesen ' + reading.map(i =>
'' +
'' + esc(i.title) + ' ' +
'' + esc(i.source_title) + ' · ' + Math.round(i.progress * 100) + ' % gelesen ' +
' ').join('');
}
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) + ' ' : '') +
(!i.read && i.progress > 0.02 ? ' ' : '') +
' ';
}).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 ? '★ ' : '') + (x.notify ? '🔔 ' : '') + esc(x.title) + (x.hidden ? ' ausgeblendet ' : '') + ' ' +
'' + (x.kind === 'news' ? esc(x.category || 'Allgemein') + ' · ' : ((x.folders || []).length ? esc(x.folders.join(', ')) + ' · ' : '')) +
(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)));
const firstFolder = b => ((b.folders || [])[0] || '~').toLowerCase();
h += group('Blogs', blogs.sort((a, b) => firstFolder(a).localeCompare(firstFolder(b)) || a.title.localeCompare(b.title)));
// The folders themselves: rename, dissolve (the blogs stay).
const folders = (s.folders || []);
if (folders.length) {
h += 'Ordner ' + folders.length + ' ' + folders.map(f =>
'' + esc(f) + ' ' + blogs.filter(b => inFolderOf(b, f)).length + ' Blogs ' +
'Umbenennen ' +
'Auflösen
').join('') +
'Tipp: lange auf einen Ordner in der Blog-Ansicht drücken (oder Rechtsklick) öffnet dasselbe.
';
}
h += '';
return h;
}
function editForm(x) {
return '' +
loginForm(x);
}
function folderPicker(x) {
const all = (st.state && st.state.folders) || [];
return 'Ordner ' +
all.map(f => ' ' + esc(f) + ' ').join('') +
' ';
}
// 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);
reader.addEventListener('scroll', () => {
clearTimeout(progressTimer);
progressTimer = setTimeout(saveProgress, 1200);
}, {passive: true});
document.body.appendChild(reader);
return reader;
}
function readerOpen() {
return !!(reader && !reader.hidden);
}
function closeReader() {
if (!reader) return;
saveProgress();
reader.hidden = true;
reader.innerHTML = '';
document.documentElement.classList.remove('fd-reading');
readerItem = readerStory = null;
render();
}
// How far the open article is read (0-1); saved now and then and when leaving it.
let progressSent = 0;
let progressTimer = null;
function readProgress() {
if (!reader || reader.hidden) return 0;
const room = reader.scrollHeight - reader.clientHeight;
return room <= 40 ? 1 : Math.min(1, reader.scrollTop / room);
}
function saveProgress() {
const i = readerItem;
if (!i) return;
const p = Math.round(readProgress() * 100) / 100;
if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return;
progressSent = p;
i.progress = p;
st.items.forEach(x => { if (x.id === i.id) x.progress = p; });
if (p >= 0.9 && !i.read) {
i.read = true;
markLocal([i.id], true);
}
api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {});
}
async function openItem(id, story) {
ensureReader();
if (readerItem) saveProgress();
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 (readerStory) loadOverview(readerStory);
progressSent = readerItem.progress || 0;
if (readerItem.kind === 'news') {
// News is read once opened; a blog post once read to 90 % (the server keeps where).
if (!readerItem.read) {
readerItem.read = true;
markLocal([id], true);
api('/feeds/items/read/', {ids: [id], read: true}).catch(() => {});
}
} else {
if (progressSent > 0.02 && progressSent < 0.9) {
// Back where reading stopped (on any device).
requestAnimationFrame(() => { reader.scrollTop = progressSent * (reader.scrollHeight - reader.clientHeight); });
} else {
setTimeout(saveProgress, 1500); // short enough to fit: read at once
}
}
} 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 ? '★' : '☆') + ' ' : '') +
// Projekte (projects.js): this article as a source of a project.
(i && window.DioraProjects ? '
◇ ' : '') +
(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(' · ') + '
';
if (story) h += '' + (story.overview ? overviewHtml(story) : '') + '
';
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;
}
// What the story's reports say together (GET /feeds/stories//overview/, feeds/textproc.py):
// a summary from all of them, what only one says, each source's words, the same story in the
// other language.
function overviewHtml(story) {
const o = story.overview || {};
let h = '';
if (o.summary && o.summary.length) {
h += 'Überblick aus ' + story.sources + ' Quellen ' +
o.summary.map(x => '' + esc(x.text) + ' ' + esc(x.source) + '
').join('');
const novel = Object.keys(o.novel || {});
if (novel.length) {
h += 'Nur in einem Bericht ' + novel.map(src => o.novel[src].map(t =>
'' + esc(t) + ' ' + esc(src) + '
').join('')).join('');
}
const words = Object.keys(o.words || {});
if (words.length) {
h += 'Wortwahl ' + words.map(src => '' + esc(src) + ': ' +
o.words[src].map(esc).join(', ') + '
').join('');
}
h += ' ';
}
if (o.related && o.related.length) {
h += '
In der anderen Sprache ' + o.related.map(x => {
const lead = x.lead || {};
return '' +
'' + esc(lead.title) + ' ' +
'' + esc(lead.source_title) + ' · ' + esc(when(x.last_at)) + ' ';
}).join('') + '';
}
return h;
}
async function loadOverview(story) {
if (story.overview) return;
try {
story.overview = await api('/feeds/stories/' + story.id + '/overview/');
} catch (e) {
return; // offline and never fetched: the article alone
}
const box = reader && !reader.hidden && readerStory === story && reader.querySelector('.fd-ov');
if (box) box.innerHTML = overviewHtml(story);
}
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 === 'story') {
const other = readerStory && readerStory.overview && (readerStory.overview.related || []).find(x => x.id === parseInt(b.dataset.id, 10));
if (other) openItem((other.lead || other.items[0]).id, other);
}
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 === 'project' && readerItem && window.DioraProjects) {
window.DioraProjects.link('article', readerItem.id, {title: readerItem.title, source: readerItem.source_title,
author: readerItem.author, date: readerItem.published, url: readerItem.url, news: readerItem.kind === 'news'});
} 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);
}
// ---- folders: rename, dissolve
async function folderAction(act, name) {
if (act === 'folder-rename') {
const neu = typeof customPrompt === 'function' ? await customPrompt('Ordner „' + name + '“ umbenennen in:', name) : window.prompt('Neuer Name', name);
if (!neu || !neu.trim() || neu.trim() === name) return;
await api('/feeds/folders/rename/', {name, new: neu.trim()}).catch(() => {});
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = neu.trim().slice(0, 40);
} else {
const ok = typeof customConfirm === 'function'
? await customConfirm('Ordner „' + name + '“ auflösen? Die Blogs bleiben abonniert.')
: window.confirm('Auflösen?');
if (!ok) return;
await api('/feeds/folders/dissolve/', {name}).catch(() => {});
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = null;
}
await loadState().catch(() => {});
if (st.view === 'blogs') return load();
render();
}
tab.addEventListener('contextmenu', e => {
const chip = e.target.closest('[data-folder]');
if (!chip || chip.dataset.folder === '*' || chip.dataset.folder === '') return;
e.preventDefault();
folderSheet(chip.dataset.folder);
});
// The long-press menu of a folder: open, rename, dissolve.
function folderSheet(name) {
const old = document.getElementById('fd-sheet');
if (old) old.remove();
const sheet = document.createElement('div');
sheet.id = 'fd-sheet';
sheet.innerHTML = '' + esc(name) + '
' +
'
Ordner öffnen ' +
'
Umbenennen … ' +
'
Auflösen ' +
'
Abbrechen ';
sheet.addEventListener('click', e => {
const b = e.target.closest('[data-s]');
if (!b && e.target !== sheet) return;
sheet.remove();
const what = b ? b.dataset.s : 'cancel';
if (what === 'open') { st.blogFolder = name; st.blogSource = ''; load(); }
if (what === 'rename') folderAction('folder-rename', name);
if (what === 'dissolve') folderAction('folder-dissolve', name);
});
document.body.appendChild(sheet);
}
// ---- ✦ Ordnen with Claude (docs/ai.md) ---------------------------------------------
const FEEDS_AI_SYSTEM = `Du hilfst, die Feed-Quellen eines Lesers zu ordnen: Nachrichtenquellen (kind "news") haben
genau ein Ressort (category), Blogs (kind "blog") haben Ordner wie Schlagwörter (mehrere möglich). Du bekommst die
Quellen als JSON (id, Titel, Adresse, kind, Ressort bzw. Ordner, Titel der letzten Beiträge) und die vorhandenen Ressorts
und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne sinnvolle Änderung lässt du weg.
- Ressort (nur news): eines der vorhandenen, wenn es passt; ein neues nur, wenn mehrere Quellen hineinpassen. Kurze
deutsche Namen (Deutschland, International, Wirtschaft, Technik, Kultur …). Nur setzen, wenn es fehlt oder klar falsch ist.
- Ordner (nur blog): vorhandene bevorzugt, neue nur für mindestens drei Blogs; höchstens zwei vorschlagen, nur hinzufügen.
- kind: nur wenn eine Quelle offensichtlich falsch eingeordnet ist (tägliche Meldungen → news, Essays/persönliche
Beiträge → blog), sonst null.
- warum: ein kurzer deutscher Satz.`;
const FEEDS_AI_SCHEMA = {
type: 'object',
properties: {
vorschlaege: {
type: 'array',
items: {
type: 'object',
properties: {
id: {type: 'integer'},
ressort: {type: ['string', 'null']},
ordner_hinzu: {type: ['array', 'null'], items: {type: 'string'}},
kind: {type: ['string', 'null']},
warum: {type: 'string'},
},
required: ['id', 'ressort', 'ordner_hinzu', 'kind', 'warum'],
additionalProperties: false,
},
},
},
required: ['vorschlaege'],
additionalProperties: false,
};
function feedsOrganize() {
DioraAI.withKey(async () => {
const list = sources();
if (!list.length) return;
DioraAI.progress('Mit KI ordnen', `Claude sieht sich ${list.length} Quellen an …`);
// A few recent titles per source say more than a name (what is loaded already).
const recent = new Map();
const note = (id, title) => { const a = recent.get(id) || []; if (a.length < 3 && title) { a.push(title); recent.set(id, a); } };
st.items.forEach(i => note(i.source, i.title));
st.stories.forEach(x => (x.items || []).forEach(i => note(i.source, i.title)));
const data = list.map(x => ({id: x.id, titel: x.title, adresse: x.site_url || x.url, kind: x.kind,
ressort: x.kind === 'news' ? (x.category || '') : undefined, ordner: x.kind === 'blog' ? (x.folders || []) : undefined,
letzte: recent.get(x.id) || []}));
const cats = st.state.categories || [];
const folders = st.state.folders || [];
let answer;
try {
answer = await DioraAI.ask({
system: FEEDS_AI_SYSTEM,
prompt: `Vorhandene Ressorts: ${JSON.stringify(cats)}\nVorhandene Blog-Ordner: ${JSON.stringify(folders)}\n\nQuellen:\n${JSON.stringify(data)}`,
schema: FEEDS_AI_SCHEMA,
});
} catch (e) {
DioraAI.showError('Mit KI ordnen', e);
return;
}
const byId = new Map(list.map(x => [x.id, x]));
const items = [];
for (const v of answer.vorschlaege || []) {
const x = byId.get(v.id);
if (!x) continue;
const change = {};
const lines = [];
const kind = v.kind === 'news' || v.kind === 'blog' ? v.kind : x.kind;
if (kind !== x.kind) { change.kind = kind; lines.push(kind === 'news' ? 'als Nachrichtenquelle führen' : 'als Blog führen'); }
if (kind === 'news' && v.ressort && v.ressort.trim() && v.ressort.trim() !== (x.category || '')) {
change.category = v.ressort.trim();
lines.push('Ressort: ' + change.category);
}
if (kind === 'blog') {
const have = x.folders || [];
const add = (v.ordner_hinzu || []).map(n => String(n).trim()).filter(n => n && !have.some(h => h.toLowerCase() === n.toLowerCase()));
if (add.length) { change.folders = have.concat(add); lines.push('Ordner + ' + add.join(', ')); }
}
if (!lines.length) continue;
if (v.warum) lines.push('– ' + v.warum);
items.push({id: x.id, title: x.title, group: x.kind === 'news' ? 'Nachrichten' : 'Blogs', lines, change});
}
items.sort((a, b) => a.group.localeCompare(b.group));
DioraAI.review({
title: 'Mit KI ordnen',
intro: 'Vorschläge von Claude. Was angehakt ist, wird übernommen.',
items,
onApply: async ids => {
for (const id of ids) {
const it = items.find(i => i.id === id);
if (it) await api('/feeds/sources/' + id + '/', it.change);
}
load();
return `${ids.length} ${ids.length === 1 ? 'Quelle' : 'Quellen'} geordnet`;
},
});
});
}
// ---- events
tab.addEventListener('click', async e => {
const t = e.target.closest('[data-view],[data-act],[data-story],[data-item],[data-cat],[data-topic],[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.topic !== undefined) {
const topic = (st.topicList || [])[parseInt(t.dataset.topic, 10)];
st.topic = st.topic && topic && st.topic.label === topic.label ? null : topic || null;
window.scrollTo(0, 0);
return load();
}
if (t.dataset.cat !== undefined && !t.dataset.act) {
st.topic = null;
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();
}
// Folders the same way as everywhere (folders.js, docs/library.md).
if (act === 'folders-manage' && window.DioraFolders) {
const done = () => loadState().catch(() => {}).then(() => (st.view === 'blogs' ? load() : render()));
window.DioraFolders.manage({
title: 'Ordner der Blogs',
folders: (st.state && st.state.folders) || [],
onRename: (name, neu) => api('/feeds/folders/rename/', {name, new: neu}).catch(() => {}).then(() => {
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = neu;
return done();
}),
onDelete: name => api('/feeds/folders/dissolve/', {name}).catch(() => {}).then(() => {
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = null;
return done();
}),
onOrder: order => api('/feeds/folders/order/', {order}).then(d => {
if (st.state && d && d.folders) st.state.folders = d.folders;
render();
}).catch(() => {}),
});
return;
}
if (act === 'blog-folders' && window.DioraFolders) {
const src = sources().find(x => x.id === parseInt(t.dataset.id, 10));
if (!src) return;
window.DioraFolders.assign({
title: 'Ordner für „' + src.title + '“',
folders: (st.state && st.state.folders) || [],
selected: src.folders || [],
onDone: async names => {
await api('/feeds/sources/' + src.id + '/', {folders: names}).catch(() => {});
await loadState().catch(() => {});
load();
},
});
return;
}
if (act === 'ai-organize') return feedsOrganize();
if (act === 'manage') {
// Managing every subscription has its place (Abos & Quellen, docs/sources.md).
const nav = window.DioraNav;
if (nav && nav.sections().some(s => s.id === 'sources')) {
try { localStorage.setItem('diora_sources_filter', st.view === 'blogs' ? 'blog' : 'news'); } catch (err) {}
nav.go('sources');
return;
}
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;
if (st.fromSources && window.DioraNav) { st.fromSources = false; window.DioraNav.go('sources'); return; }
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 === 'folder-rename' || act === 'folder-dissolve') return folderAction(act, t.dataset.name);
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, fulltext: f.fulltext.value};
if (f.category) change.category = f.category.value;
if (f.newfolder) {
change.folders = Array.prototype.filter.call(f.querySelectorAll('input[name=folder]'), c => c.checked).map(c => c.value);
if (f.newfolder.value.trim()) change.folders.push(f.newfolder.value.trim());
}
if (f.fav) change.fav = f.fav.checked;
if (f.hidden) change.hidden = f.hidden.checked;
if (f.notify) change.notify = f.notify.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;
// Abos & Quellen (sources.js): a source's editor (null: the management page itself, with
// the OPML import), and "the sources changed, load again".
window.__dioraFeedsEdit = async function (id) {
if (!st.state) await loadState().catch(() => {});
const src = id == null ? null : sources().find(x => x.id === Number(id));
const section = src && src.kind === 'blog' ? 'blogs' : 'news';
if (window.DioraNav) window.DioraNav.go(section); else showTab('feeds');
st.view = 'manage';
st.editing = src ? src.id : null;
st.found = null;
st.fromSources = true;
window.scrollTo(0, 0);
render();
};
window.__dioraFeedsStale = function () {
st.state = null;
shownAt = 0;
};
// Nachrichten and Blogs are sections of Lesen (static/js/nav.js): it chooses, before the
// tab is shown; its own switch is then hidden.
window.__dioraFeedsView = function (view) {
if (view !== 'news' && view !== 'blogs' || view === st.view) return;
st.view = view;
try { localStorage.setItem('diora_feeds_view', view); } catch (err) {}
shownAt = 0; // shown next: loaded for this view
};
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};
};
// A notification about a new post was tapped (the app, MainActivity): open it.
window.__dioraFeedsOpenItem = function (id) {
showTab('feeds');
readerList = st.items;
openItem(id, null);
};
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. With the navigation (nav.js) it
// holds the sections of Lesen itself — a row of them to swipe is no good on e-ink.
if (READER_APP && !document.getElementById('nui-tabbar')) {
const NAV = window.DioraNav;
const dock = document.createElement('div');
dock.id = 'nui-dock';
dock.innerHTML = '' + (NAV
? NAV.sections().filter(s => !s.footer).map(s => '' +
(s.svg || esc(s.icon)) + ' ' + esc(s.label) + ' ').join('')
: '▤ Bücher ' +
'☰ Feeds ') + ' ';
document.body.appendChild(dock);
const mark = () => dock.querySelectorAll('button').forEach(b => b.classList.toggle('nui-active', NAV
? b.dataset.nav === NAV.current()
: typeof _currentTopTab !== 'undefined' && b.dataset.tab === _currentTopTab));
dock.addEventListener('click', e => {
const b = e.target.closest('button');
if (!b) return;
if (NAV) NAV.go(b.dataset.nav);
else showTab(b.dataset.tab);
window.scrollTo(0, 0);
});
if (NAV) {
document.documentElement.classList.add('nav-flat'); // every section in the bar: no row on top
NAV.onChange(mark);
NAV.barsReady();
}
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: var(--on-accent, #fff); }',
'html.nav-bars #tab-feeds .fd-seg { display: none; }',
'#tab-feeds .fd-count { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px; background: var(--raised, 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: var(--card, #0b0b0b); box-shadow: var(--shadow, none); }',
'#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: var(--on-accent, #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: var(--on-accent, #fff); font-size: 10px; letter-spacing: 0.05em; vertical-align: 2px; }',
'#tab-feeds .fd-hidden { opacity: 0.6; }',
'#tab-feeds .fd-prog { display: block; height: 3px; margin-top: 4px; background: var(--raised, #2a2a2a); border-radius: 2px; overflow: hidden; }',
'#tab-feeds .fd-prog > span { display: block; height: 100%; background: var(--accent, #e63946); }',
'#tab-feeds .fd-folders { border: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; }',
'#tab-feeds .fd-folders legend { font-size: 12px; color: var(--nui-muted, #9a9a9a); margin-bottom: 4px; }',
'#tab-feeds .fd-folders input[name=newfolder] { flex-basis: 100%; }',
'#tab-feeds .fd-folder-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border, #2a2a2a); flex-wrap: wrap; }',
'#fd-sheet { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 995; background: var(--scrim, rgba(0,0,0,0.5)); display: flex; align-items: flex-end; justify-content: center; }',
'#fd-sheet .fd-sheet-box { width: 100%; max-width: 480px; background: var(--panel, #000); border: 1px solid var(--border, #2a2a2a); border-bottom: none; border-radius: 10px 10px 0 0; padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px)); }',
'#fd-sheet .fd-sheet-title { padding: 10px 18px; font-size: 13px; color: var(--nui-muted, #9a9a9a); }',
'#fd-sheet button { display: block; width: 100%; padding: 14px 18px; background: none; border: none; color: var(--fg, #fff); font: inherit; text-align: left; cursor: pointer; }',
'body.eink #tab-feeds .fd-prog > span { background: #000; }',
'body.eink #fd-sheet .fd-sheet-box { background: #fff; }',
'body.eink #fd-sheet button { color: #000; }',
'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: var(--raised, #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-strong, #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); }',
'#tab-feeds .fd-topics .fd-note { margin-right: 2px; }',
'#tab-feeds .fd-topics small { opacity: 0.6; }',
'#fd-article .fd-ov { font-size: 0.9em; }',
'#fd-article .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
'#fd-article .fd-ov-box summary { font-family: var(--font); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); cursor: pointer; }',
'#fd-article .fd-ov p { margin: 8px 0; line-height: 1.45; }',
'#fd-article .fd-ov h4 { font-family: var(--font); font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); margin: 14px 0 4px; }',
'#fd-article .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
'#fd-article .fd-ov-words { font-family: var(--font); font-size: 13px; }',
'#fd-article .fd-ov-rel { margin: 0 0 18px; }',
'#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: var(--hover, #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-ov-box { border-color: #000; }',
'body.eink #fd-article .fd-ov-src, body.eink #fd-article .fd-ov h4, body.eink #fd-article .fd-ov-box summary { color: #333; }',
'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);
})();