diora-web/static/js/feeds.js
mrwnslz bd09d81366 Smarte Transkription D: Artikel-Endpunkt und Blog-Anbindung
POST/DELETE /podcasts/episodes/<id>/article/ (podcasts/article.py): legt die Quelle Podcast-Artikel an und erzeugt oder ersetzt das Item der Folge; Sanitizer behält href=#t=<Sekunden>; _item_data und Item-Ansicht liefern episode_id; feeds.js: Folge anhören und Zeitmarken-Links.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:36:34 +01:00

1562 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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.
* Marking read happens on the device first and is queued in localStorage when the server
* can't be reached (`diora_feeds_read_pending`, sent again on 'online' and on the next load),
* the same way as the issues' `diora_issue_pending` (static/js/app/54-issues.js).
* 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
async function api(path, body) {
const opts = {credentials: 'same-origin', headers: {}};
if (body !== undefined) {
opts.method = 'POST';
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
let data = null;
try { data = await r.json(); } catch (e) {}
if (!r.ok) throw new Error((data && data.error) || ('Fehler ' + r.status));
return data;
}
// ---- offline: marking read stays on the device and goes later ------------------------
//
// Telling the server was fire-and-forget before: a failure was swallowed, and offline
// (phone, train) the article came back unread after the next load. A failed call is now
// kept in localStorage and sent again when the connection is back — the same pattern as
// the issues' diora_issue_pending (static/js/app/54-issues.js). Chrome-61-safe.
const PENDING_READS = 'diora_feeds_read_pending';
function pendingReads() {
try { return JSON.parse(localStorage.getItem(PENDING_READS) || '{}') || {}; } catch (e) { return {}; }
}
function setPendingReads(all) {
try {
if (Object.keys(all).length) localStorage.setItem(PENDING_READS, JSON.stringify(all));
else localStorage.removeItem(PENDING_READS);
} catch (e) {}
}
// Send what is queued, drop only what the server took. false = still offline.
async function flushPendingReads() {
const all = pendingReads();
const keys = Object.keys(all);
if (!keys.length) return true;
const yes = keys.filter(k => all[k]).map(Number);
const no = keys.filter(k => !all[k]).map(Number);
try {
if (yes.length) await api('/feeds/items/read/', {ids: yes, read: true});
if (no.length) await api('/feeds/items/read/', {ids: no, read: false});
} catch (e) {
if (keepForRetry(e)) return false;
setPendingReads({}); // the server refused them: they would never go through
return true;
}
const now = pendingReads();
keys.forEach(k => { if (now[k] === all[k]) delete now[k]; });
setPendingReads(now);
return true;
}
// Mark on the device at once, then tell the server — and keep it when that fails.
// A 4xx is the server's answer, not a lost connection: the item may have gone away
// (old news is dropped), and repeating it would only fill the queue. Everything else
// — no connection, 5xx, a proxy in between — waits.
function keepForRetry(e) {
return !/^Fehler 4\d\d$/.test((e && e.message) || '');
}
async function markRead(ids, read) {
if (!ids || !ids.length) return;
markLocal(ids, read);
try {
await api('/feeds/items/read/', {ids: ids, read: read});
} catch (e) {
if (!keepForRetry(e)) return;
const all = pendingReads();
ids.forEach(id => { all[id] = read ? 1 : 0; });
setPendingReads(all);
}
}
window.addEventListener('online', () => { flushPendingReads(); });
// ---- 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();
if (!more) await flushPendingReads(); // what offline marking left behind
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 = '<p class="fd-note">Lädt …</p>';
else if (st.view === 'news') body = renderNews();
else body = renderBlogs();
tab.innerHTML =
'<div class="fd">' +
'<div class="fd-top">' +
(st.view === 'manage'
? '<button type="button" class="fd-back" data-act="close-manage">‹ Zurück</button><h2 class="fd-h">Feeds verwalten</h2>'
: '<div class="fd-seg">' +
'<button type="button" data-view="news" class="' + (st.view === 'news' ? 'on' : '') + '">Nachrichten' +
(newsN ? ' <span class="fd-count">' + newsN + '</span>' : '') + '</button>' +
'<button type="button" data-view="blogs" class="' + (st.view === 'blogs' ? 'on' : '') + '">Blogs' +
(blogN ? ' <span class="fd-count">' + blogN + '</span>' : '') + '</button>' +
'</div>' +
'<div class="fd-actions">' +
'<button type="button" class="fd-icon" data-act="refresh" title="Jetzt abrufen" aria-label="Jetzt abrufen">' + (st.loading ? '…' : '↻') + '</button>' +
// Ordering with Claude (docs/ai.md): Ressorts for the news, folders for the blogs.
(window.DioraAI && DioraAI.available() && sources().length
? '<button type="button" class="fd-icon" data-act="ai-organize" title="Mit KI ordnen" aria-label="Mit KI ordnen">✦</button>' : '') +
'<button type="button" class="fd-icon" data-act="manage" title="Feeds verwalten" aria-label="Feeds verwalten">⚙</button>' +
'</div>') +
'</div>' +
(st.error ? '<p class="fd-error">' + esc(st.error) + '</p>' : '') +
body + '</div>';
}
function storyMeta(x) {
const lead = x.lead || {};
const others = x.sources - 1;
return esc(lead.source_title) + (others > 0 ? ' <span class="fd-more-src">+' + others + '</span>' : '') +
' · ' + esc(when(x.last_at));
}
function storyRow(x) {
const lead = x.lead || {};
return '<button type="button" class="fd-row' + (x.unread ? '' : ' fd-read') + '" data-story="' + x.id + '">' +
'<span class="fd-row-text"><span class="fd-row-title">' + esc(lead.title) + '</span>' +
'<span class="fd-row-meta">' + storyMeta(x) + '</span></span>' +
(x.sources > 1 ? '<span class="fd-badge" title="' + x.sources + ' Quellen">' + x.sources + '</span>' : '') +
'</button>';
}
function renderBriefing() {
const b = st.briefing;
if (!b || !b.total) return '';
let h = '<section class="fd-brief">' +
'<div class="fd-brief-head"><h2>Seit letztem Mal</h2>' +
'<span class="fd-note">' + b.total + (b.total === 1 ? ' Meldung' : ' Meldungen') + ' seit ' + esc(when(b.since)) + '</span></div>';
if (b.top.length) {
h += '<h3>Top-Themen</h3>' + b.top.map(storyRow).join('');
}
b.sections.forEach(sec => {
h += '<h3>' + esc(sec.category) + ' <small>' + sec.count + '</small></h3>' + sec.stories.map(storyRow).join('');
if (sec.count > sec.stories.length) {
h += '<button type="button" class="fd-link" data-cat="' + esc(sec.category) + '">' +
(sec.count - sec.stories.length) + ' weitere in ' + esc(sec.category) + ' ›</button>';
}
});
h += '<button type="button" class="btn btn-primary fd-seen" data-act="seen">Alles gelesen ✓</button></section>';
return h;
}
function renderNews() {
if (!sources('news').length) {
return '<div class="fd-empty"><p>Noch keine Nachrichtenquellen.</p>' +
'<button type="button" class="btn btn-primary" data-act="manage">Feed hinzufügen</button></div>';
}
const cats = (st.state.categories || []);
let h = renderBriefing();
if (cats.length > 1) {
h += '<div class="fd-chips">' +
'<button type="button" class="fd-chip' + (st.category ? '' : ' on') + '" data-cat="">Alle</button>' +
cats.map(c => '<button type="button" class="fd-chip' + (st.category === c ? ' on' : '') + '" data-cat="' + esc(c) + '">' + esc(c) + '</button>').join('') +
'</div>';
}
if (st.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 += '<div class="fd-chips fd-topics"><span class="fd-note">Themen</span>' +
list.map((t, n) => '<button type="button" class="fd-chip' + (st.topic && st.topic.label === t.label ? ' on' : '') +
'" data-topic="' + n + '">' + esc(t.label) + ' <small>' + t.ids.length + '</small></button>').join('') + '</div>';
st.topicList = list;
}
if (!st.stories.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : 'Gerade keine Nachrichten.') + '</p>';
let day = '';
st.stories.forEach(x => {
const d = dayLabel(new Date(x.last_at));
if (d !== day) {
day = d;
h += '<h3 class="fd-day">' + esc(d) + '</h3>';
}
const lead = x.lead || {};
h += '<button type="button" class="fd-card' + (x.unread ? '' : ' fd-read') + '" data-story="' + x.id + '">' +
(lead.image_url ? '<img class="fd-thumb" loading="lazy" alt="" src="' + esc(lead.image_url) + '">' : '') +
'<span class="fd-card-text"><span class="fd-card-title">' + esc(lead.title) + '</span>' +
(lead.summary ? '<span class="fd-card-sum">' + esc(lead.summary) + '</span>' : '') +
'<span class="fd-row-meta">' + storyMeta(x) + '</span></span></button>';
});
if (st.storiesMore) h += '<button type="button" class="btn fd-loadmore" data-act="more">Ältere laden</button>';
return h;
}
// ---- 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 '<div class="fd-empty"><p>Noch keine Blogs abonniert.</p>' +
'<button type="button" class="btn btn-primary" data-act="manage">Feed hinzufügen</button></div>';
}
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) => '<button type="button" class="fd-chip' + (st.blogFolder === value ? ' on' : '') +
'" data-folder="' + (value === null ? '*' : esc(value)) + '">' +
(sum(list, 'new') ? '<span class="fd-dot"></span>' : '') + esc(label) +
(sum(list, 'unread') ? ' <small>' + sum(list, 'unread') + '</small>' : '') + '</button>';
h += '<div class="fd-chips">' + 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 ? '<button type="button" class="fd-chip" data-act="folders-manage" title="Ordner verwalten" aria-label="Ordner verwalten">⋯</button>' : '') +
'</div>';
}
const main = st.blogFolder === null;
const shown = main ? blogs : inFolder(st.blogFolder);
h += '<div class="fd-chips fd-modes">' +
'<button type="button" class="fd-chip' + (st.blogUnread ? ' on' : '') + '" data-act="mode-unread">Ungelesen</button>' +
'<button type="button" class="fd-chip' + (st.blogUnread ? '' : ' on') + '" data-act="mode-all">Alle</button>' +
(main ? '<button type="button" class="fd-chip' + (st.blogFav ? ' on' : '') + '" data-act="mode-fav">★ Favoriten</button>' : '') +
'<button type="button" class="fd-chip' + (st.rareFirst ? ' on' : '') + '" data-act="mode-rare" title="Blogs, die selten schreiben, zuerst">Seltene zuerst</button>' +
'</div>';
h += '<div class="fd-filter">' +
'<select class="search-input fd-select" data-act="blog-source"><option value="">' +
(main ? 'Alle Blogs' : 'Alle in ' + esc(st.blogFolder || 'Ohne Ordner')) + '</option>' +
shown.map(b => '<option value="' + b.id + '"' + (String(b.id) === String(st.blogSource) ? ' selected' : '') + '>' +
(b.fav ? '★ ' : '') + esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + (b.hidden ? ' – ausgeblendet' : '') +
'</option>').join('') + '</select>' +
(st.items.some(i => !i.read) ? '<button type="button" class="btn fd-allread" data-act="blogs-read">Alle gelesen</button>' : '') +
'</div>';
const one = st.blogSource ? byId[st.blogSource] : null;
if (one) {
// The selected blog's switches.
h += '<div class="fd-blogbar">' +
'<button type="button" class="fd-chip' + (one.fav ? ' on' : '') + '" data-flag="fav" data-id="' + one.id + '">' + (one.fav ? '★ Favorit' : '☆ Favorit') + '</button>' +
'<button type="button" class="fd-chip' + (one.hidden ? ' on' : '') + '" data-flag="hidden" data-id="' + one.id + '" title="Nicht unter „Alle“, nur im Ordner">' +
(one.hidden ? 'Im Hauptfeed ausgeblendet' : 'Im Hauptfeed ausblenden') + '</button>' +
'<button type="button" class="fd-chip' + (one.notify ? ' on' : '') + '" data-flag="notify" data-id="' + one.id + '" title="Benachrichtigung bei neuen Posts (App)">' +
(one.notify ? '🔔 Glocke an' : '🔕 Glocke') + '</button>' +
(window.DioraFolders ? '<button type="button" class="fd-chip" data-act="blog-folders" data-id="' + one.id + '">▤ In Ordner …</button>' : '') +
'<button type="button" class="fd-chip" data-edit-open="' + one.id + '">⚙</button></div>';
}
if (!st.items.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '</p>';
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 += '<h3 class="fd-day">Weiterlesen</h3>' + reading.map(i =>
'<button type="button" class="fd-post fd-continue" data-item="' + i.id + '">' +
'<span class="fd-card-title">' + esc(i.title) + '</span>' +
'<span class="fd-row-meta">' + esc(i.source_title) + ' · ' + Math.round(i.progress * 100) + ' % gelesen</span>' +
'<span class="fd-prog"><span style="width:' + Math.round(i.progress * 100) + '%"></span></span></button>').join('');
}
blogSections(st.items, byId).forEach(sec => {
h += '<h3 class="fd-day">' + esc(sec.label) + '</h3>';
h += sec.items.map(i => {
const src = byId[i.source];
const rt = readingTime(i);
return '<button type="button" class="fd-post' + (i.read ? ' fd-read' : '') + (src && src.hidden ? ' fd-hidden' : '') + '" data-item="' + i.id + '">' +
'<span class="fd-card-title">' + (isNew(i, src) ? '<span class="fd-new">NEU</span> ' : '') + (i.starred ? '★ ' : '') + esc(i.title) + '</span>' +
'<span class="fd-row-meta">' + (src && src.fav ? '★ ' : '') + esc(i.source_title) + ' · ' + esc(when(i.published)) + (rt ? ' · ' + rt : '') + '</span>' +
(i.summary ? '<span class="fd-card-sum">' + esc(i.summary) + '</span>' : '') +
(!i.read && i.progress > 0.02 ? '<span class="fd-prog"><span style="width:' + Math.round(i.progress * 100) + '%"></span></span>' : '') +
'</button>';
}).join('');
});
if (st.itemsMore) h += '<button type="button" class="btn fd-loadmore" data-act="more">Ältere laden</button>';
return h;
}
function kindSelect(name, value) {
return '<select class="search-input" name="' + name + '">' +
'<option value="news"' + (value === 'news' ? ' selected' : '') + '>Nachrichten</option>' +
'<option value="blog"' + (value === 'blog' ? ' selected' : '') + '>Blog</option></select>';
}
function categoryInput(value) {
const cats = ((st.state && st.state.categories) || []).concat((st.state && st.state.folders) || []);
return '<input class="search-input" name="category" list="fd-cats" placeholder="z. B. Politik / Philosophie" value="' + esc(value || '') + '">' +
'<datalist id="fd-cats">' + cats.map(c => '<option value="' + esc(c) + '">').join('') + '</datalist>';
}
function renderManage() {
const s = st.state || {sources: [], settings: {}};
let h = '<section class="fd-box"><h3>Feed hinzufügen</h3>' +
'<form class="fd-add" data-form="discover"><input class="search-input" name="url" type="url" inputmode="url" ' +
'placeholder="Adresse einer Seite oder eines Feeds" required>' +
'<button class="btn btn-primary" type="submit">Suchen</button></form>';
if (st.found) {
h += st.found.map((f, n) =>
'<form class="fd-found" data-form="subscribe" data-n="' + n + '">' +
'<div class="fd-found-title">' + esc(f.title) + '</div><div class="fd-note fd-url">' + esc(f.url) + '</div>' +
(f.samples.length ? '<ul class="fd-samples">' + f.samples.slice(0, 3).map(t => '<li>' + esc(t) + '</li>').join('') + '</ul>' : '') +
(f.subscribed ? '<p class="fd-note">Schon abonniert.</p>' :
'<div class="fd-grid"><label>Art ' + kindSelect('kind', f.suggested_kind) + '</label>' +
'<label class="fd-cat-field">' + (f.suggested_kind === 'news' ? 'Ressort' : 'Ordner') + ' ' + categoryInput('') + '</label></div>' +
'<button class="btn btn-primary" type="submit">Abonnieren</button>') +
'</form>').join('');
}
h += '</section>';
const news = s.sources.filter(x => x.kind === 'news');
const blogs = s.sources.filter(x => x.kind === 'blog');
const group = (title, list) => !list.length ? '' :
'<section class="fd-box"><h3>' + title + ' <small>' + list.length + '</small></h3>' +
list.map(x => x.id === st.editing ? editForm(x) :
'<button type="button" class="fd-src" data-edit="' + x.id + '">' +
'<span class="fd-src-title">' + (x.fav ? '★ ' : '') + (x.notify ? '🔔 ' : '') + esc(x.title) + (x.hidden ? ' <small class="fd-note">ausgeblendet</small>' : '') + '</span>' +
'<span class="fd-row-meta">' + (x.kind === 'news' ? esc(x.category || 'Allgemein') + ' · ' : ((x.folders || []).length ? esc(x.folders.join(', ')) + ' · ' : '')) +
(x.unread ? x.unread + ' ungelesen' : '') +
(x.error_count >= 3 ? ' <span class="fd-warn" title="' + esc(x.last_error) + '">⚠ Abruf schlägt fehl</span>' : '') +
'</span></button>').join('') + '</section>';
h += group('Nachrichten', news.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.title.localeCompare(b.title)));
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 += '<section class="fd-box"><h3>Ordner <small>' + folders.length + '</small></h3>' + folders.map(f =>
'<div class="fd-folder-row"><span>' + esc(f) + ' <small class="fd-note">' + blogs.filter(b => inFolderOf(b, f)).length + ' Blogs</small></span>' +
'<span class="fd-buttons"><button type="button" class="btn" data-act="folder-rename" data-name="' + esc(f) + '">Umbenennen</button>' +
'<button type="button" class="btn" data-act="folder-dissolve" data-name="' + esc(f) + '">Auflösen</button></span></div>').join('') +
'<p class="fd-note">Tipp: lange auf einen Ordner in der Blog-Ansicht drücken (oder Rechtsklick) öffnet dasselbe.</p></section>';
}
h += '<section class="fd-box"><h3>Import &amp; Einstellungen</h3>' +
'<label class="btn fd-file">OPML importieren …<input type="file" accept=".opml,.xml,text/xml,text/x-opml" data-act="opml" hidden></label> ' +
(window.DioraNative ? '' : '<a class="btn" href="/feeds/export.opml">OPML exportieren</a>');
if (st.opml) h += renderOpml();
const keep = (s.settings && s.settings.news_keep_days) || 2;
h += '<label class="fd-keep">Nachrichten behalten: <select class="search-input" data-act="keep">' +
[1, 2, 3, 5, 7, 14].map(d => '<option value="' + d + '"' + (d === keep ? ' selected' : '') + '>' + d + (d === 1 ? ' Tag' : ' Tage') + '</option>').join('') +
'</select></label><p class="fd-note">Ältere Nachrichten verschwinden von selbst (gemerkte bleiben). Blogartikel bleiben, bis sie gelesen sind.</p></section>';
return h;
}
function editForm(x) {
return '<form class="fd-edit" data-form="edit" data-id="' + x.id + '">' +
'<label>Name <input class="search-input" name="title" value="' + esc(x.title) + '"></label>' +
'<div class="fd-note fd-url">' + esc(x.url) + '</div>' +
(x.last_error ? '<p class="fd-warn">Letzter Fehler: ' + esc(x.last_error) + '</p>' : '') +
'<div class="fd-grid"><label>Art ' + kindSelect('kind', x.kind) + '</label>' +
(x.kind === 'news' ? '<label class="fd-cat-field">Ressort ' + categoryInput(x.category) + '</label>' : folderPicker(x)) +
(x.kind === 'blog' ? '<label class="fd-check"><input type="checkbox" name="fav"' + (x.fav ? ' checked' : '') + '> Favorit</label>' +
'<label class="fd-check"><input type="checkbox" name="hidden"' + (x.hidden ? ' checked' : '') + '> Im Hauptfeed ausblenden (nur im Ordner)</label>' +
'<label class="fd-check"><input type="checkbox" name="notify"' + (x.notify ? ' checked' : '') + '> Benachrichtigen bei neuen Posts</label>' : '') +
'<label>Ganzer Artikel <select class="search-input" name="fulltext">' +
[['auto', 'Automatisch'], ['always', 'Immer holen'], ['never', 'Nur Feed-Text']].map(o =>
'<option value="' + o[0] + '"' + (x.fulltext === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') +
'</select></label></div>' +
'<div class="fd-buttons"><button class="btn btn-primary" type="submit">Speichern</button>' +
'<button class="btn" type="button" data-act="cancel-edit">Abbrechen</button>' +
'<button class="btn btn-danger" type="button" data-act="delete" data-id="' + x.id + '">Abbestellen</button></div></form>' +
loginForm(x);
}
function folderPicker(x) {
const all = (st.state && st.state.folders) || [];
return '<fieldset class="fd-folders"><legend>Ordner</legend>' +
all.map(f => '<label class="fd-check"><input type="checkbox" name="folder" value="' + esc(f) + '"' +
(inFolderOf(x, f) ? ' checked' : '') + '> ' + esc(f) + '</label>').join('') +
'<input class="search-input" name="newfolder" maxlength="40" placeholder="Neuer Ordner"></fieldset>';
}
// 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 = '<form class="fd-edit fd-login" data-form="login" data-id="' + x.id + '">' +
'<h4>Abo-Anmeldung bei ' + esc(l.site) + '</h4>';
if (l.username) {
h += '<p>' + (l.ok ? '✓ Angemeldet als ' : '⚠ Anmeldung klappt nicht: ') + esc(l.ok ? l.username : (l.error || l.username)) + '</p>' +
'<div class="fd-buttons"><button class="btn" type="button" data-act="login-delete" data-id="' + x.id + '">Zugangsdaten löschen</button></div>';
if (l.ok) return h + '</form>';
h += '<p class="fd-note">Neu eingeben:</p>';
}
return h +
'<label>Benutzername <input class="search-input" name="username" autocomplete="off" autocapitalize="none" required></label>' +
'<label>Passwort <input class="search-input" name="password" type="password" autocomplete="new-password" required></label>' +
'<p class="fd-note">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.</p>' +
'<div class="fd-buttons"><button class="btn btn-primary" type="submit">Anmelden</button></div></form>';
}
function renderOpml() {
const groups = st.opml;
const n = groups.reduce((a, g) => a + (g.kind === 'skip' ? 0 : g.feeds.length), 0);
return '<div class="fd-opml"><p class="fd-note">Was ist was? Pro Ordner:</p>' +
groups.map((g, i) =>
'<div class="fd-opml-row"><span>' + esc(g.name || 'Ohne Ordner') + ' <small>(' + g.feeds.length + ')</small></span>' +
'<select class="search-input" data-opml="' + i + '">' +
[['news', 'Nachrichten'], ['blog', 'Blogs'], ['skip', 'Nicht übernehmen']].map(o =>
'<option value="' + o[0] + '"' + (g.kind === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') +
'</select></div>').join('') +
'<button type="button" class="btn btn-primary" data-act="opml-import"' + (n ? '' : ' disabled') + '>' + n + ' Feeds übernehmen</button> ' +
'<button type="button" class="btn" data-act="opml-cancel">Abbrechen</button></div>';
}
// ---- OPML
function parseOpml(text) {
const doc = new DOMParser().parseFromString(text, 'text/xml');
const groups = {};
const order = [];
Array.prototype.forEach.call(doc.querySelectorAll('outline[xmlUrl], outline[xmlurl]'), o => {
const url = o.getAttribute('xmlUrl') || o.getAttribute('xmlurl');
const parent = o.parentNode && o.parentNode.nodeName.toLowerCase() === 'outline' ? o.parentNode : null;
const name = parent ? (parent.getAttribute('text') || parent.getAttribute('title') || '') : '';
if (!groups[name]) {
groups[name] = {name, feeds: [],
kind: /nachricht|news|politik|zeitung|presse|aktuell|welt|wirtschaft|tech/i.test(name) ? 'news' : 'blog'};
order.push(name);
}
groups[name].feeds.push({url, title: o.getAttribute('title') || o.getAttribute('text') || url});
});
return order.map(n => groups[n]);
}
// ---- article view
// ---- reading: an article opens in the book reader (65-article.js, DioraArticle) —
// the same typography, colours, pages, search, read-aloud and full screen as books.
let readerList = []; // what "next" walks through (blog items)
let readerStory = null;
let readerItem = null;
let reading = false; // an article of ours is in the reader
function readerOpen() {
return reading && !!(window.DioraArticle && DioraArticle.isOpen());
}
function closeReader() {
if (window.DioraArticle) DioraArticle.close();
}
function articleClosed() {
reading = false;
readerItem = readerStory = null;
render();
}
// How far the open article is read (0-1, from the reader); saved now and then and when leaving it.
let progressSent = 0;
function saveProgress(f) {
const i = readerItem;
if (!i) return;
const p = Math.round(f * 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(() => {});
}
// Opens a link outside, like the article's own links (the app hands them to the browser).
function openOutside(url) {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
// The article's own buttons, in the reader's header (the apps: in its menu).
function articleActions(i) {
return [
{icon: i.starred ? '★' : '☆', label: i.starred ? 'Gemerkt' : 'Merken', active: !!i.starred, action: () => toggleStar(i)},
// Projekte (projects.js): this article as a source of a project.
window.DioraProjects && {icon: '◇', label: 'Mit Projekt verknüpfen', action: () => window.DioraProjects.link('article', i.id, {
title: i.title, source: i.source_title, author: i.author, date: i.published, url: i.url, news: i.kind === 'news'})},
i.url && {icon: '↗', label: 'Im Browser öffnen', action: () => openOutside(i.url)},
];
}
function toggleStar(i) {
i.starred = !i.starred;
api('/feeds/items/' + i.id + '/star/', {starred: i.starred}).catch(() => {});
st.items.forEach(x => { if (x.id === i.id) x.starred = i.starred; });
if (window.DioraArticle) DioraArticle.refreshActions();
}
async function openItem(id, story) {
if (!window.DioraArticle) return;
readerStory = story || null;
let item = null;
let error = '';
try {
item = (await api('/feeds/items/' + id + '/')).item;
} catch (e) {
error = e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message;
}
readerItem = item;
reading = true;
if (!item) {
DioraArticle.open({title: 'Artikel', html: '<p class="fd-error">' + esc(error) + '</p>',
onClick: onReaderClick, onClose: articleClosed});
return;
}
progressSent = item.progress || 0;
await DioraArticle.open({
title: item.source_title || item.title,
html: articleHtml(item),
// News is read once opened; a blog post once read to 90 % (the server keeps where).
startFraction: item.kind === 'news' ? 0 : progressSent,
onProgress: f => { if (readerItem === item) saveProgress(f); },
onClick: onReaderClick,
onClose: articleClosed,
actions: () => articleActions(item),
});
if (readerStory) loadOverview(readerStory);
if (item.kind === 'news' && !item.read) {
item.read = true;
markRead([id], true);
}
}
function articleHtml(i) {
let h = '';
const story = readerStory;
if (story && story.items.length > 1) {
h += '<div class="fd-sources"><span class="fd-note">' + story.items.length + ' Berichte:</span>' +
story.items.map(x => '<button type="button" class="fd-chip' + (x.id === i.id ? ' on' : '') + '" data-r="item" data-id="' + x.id + '">' +
esc(x.source_title) + '</button>').join('') + '</div>';
}
h += '<h1 class="fd-art-title">' + esc(i.title) + '</h1>' +
'<p class="fd-art-meta">' + [i.author, i.source_title, when(i.published)].filter(Boolean).map(esc).join(' · ') + '</p>';
if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>';
const content = i.content || '';
if (i.image_url && content.indexOf(i.image_url) < 0) h += '<img class="fd-art-img" alt="" src="' + esc(i.image_url) + '">';
if (i.episode_id) {
// The article of a podcast episode (docs/transcription.md): to the episode itself.
h += '<p class="fd-art-listen"><button type="button" class="btn" data-r="episode" data-id="' + i.episode_id + '">▶ Folge anhören</button></p>';
}
h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>';
if (i.paywalled) {
h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
') holt diora den ganzen.</p>';
} else if (!i.content_full && i.url && (content.replace(/<[^>]+>/g, '').length < 700)) {
h += '<p class="fd-note">Der Feed bringt nur einen Anriss, der ganze Artikel ließ sich nicht laden.</p>';
}
if (story && story.items.length > 1) {
h += '<h3 class="fd-art-more">Weitere Berichte</h3>' + story.items.filter(x => x.id !== i.id).map(x =>
'<button type="button" class="fd-other" data-r="item" data-id="' + x.id + '">' +
'<span class="fd-other-title">' + esc(x.title) + '</span>' +
'<span class="fd-row-meta">' + esc(x.source_title) + ' · ' + esc(when(x.published)) + '</span></button>').join('');
}
h += '<div class="fd-art-foot">';
if (i.url) h += '<a class="btn" href="' + esc(i.url) + '" target="_blank" rel="noopener">Original öffnen ↗</a>';
const next = nextItem(i);
if (next) h += '<button type="button" class="btn btn-primary" data-r="item" data-id="' + next.id + '">Nächster Artikel ›</button>';
h += '</div>';
return h;
}
// What the story's reports say together (GET /feeds/stories/<id>/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 += '<details class="fd-ov-box" open><summary>Überblick aus ' + story.sources + ' Quellen</summary>' +
o.summary.map(x => '<p>' + esc(x.text) + ' <span class="fd-ov-src">' + esc(x.source) + '</span></p>').join('');
const novel = Object.keys(o.novel || {});
if (novel.length) {
h += '<h4>Nur in einem Bericht</h4>' + novel.map(src => o.novel[src].map(t =>
'<p>' + esc(t) + ' <span class="fd-ov-src">' + esc(src) + '</span></p>').join('')).join('');
}
const words = Object.keys(o.words || {});
if (words.length) {
h += '<h4>Wortwahl</h4>' + words.map(src => '<p class="fd-ov-words"><b>' + esc(src) + ':</b> ' +
o.words[src].map(esc).join(', ') + '</p>').join('');
}
h += '</details>';
}
if (o.related && o.related.length) {
h += '<div class="fd-ov-rel"><h4>In der anderen Sprache</h4>' + o.related.map(x => {
const lead = x.lead || {};
return '<button type="button" class="fd-other" data-r="story" data-id="' + x.id + '">' +
'<span class="fd-other-title">' + esc(lead.title) + '</span>' +
'<span class="fd-row-meta">' + esc(lead.source_title) + ' · ' + esc(when(x.last_at)) + '</span></button>';
}).join('') + '</div>';
}
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 = readerOpen() && readerStory === story && document.querySelector('#reader-content .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;
});
}
// ---- the episode of a podcast article (docs/transcription.md)
function podcastHost() {
return window.DioraPodcastHost || window.DioraNative || null;
}
function parseJson(text, fallback) {
try { return JSON.parse(text); } catch (e) { return fallback; }
}
// Play the episode from `seconds` (null: open its page, where ▶ starts it). The browser and the
// diora desktop have the host of podcasts-web.js; the apps' bridge has the same calls
// (podcastPlayEpisodeAt is the one that starts at a place; without it the episode starts at its own place).
async function playEpisode(episodeId, seconds) {
const host = podcastHost();
if (!host || !host.playerState) { alert('Der Podcast-Bereich ist hier nicht verfügbar.'); return; }
const now = parseJson(host.playerState(), {});
const ms = seconds == null ? null : Math.max(0, Math.round(seconds * 1000));
let opened = false;
if (now.kind === 'podcast' && now.episodeId === episodeId) {
if (ms != null && host.podcastSeek) host.podcastSeek(ms);
if (ms != null && !now.playing && host.podcastToggle) host.podcastToggle();
opened = ms != null;
} else {
let d;
try {
d = await api('/podcasts/episodes/' + episodeId + '/');
} catch (e) {
alert(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : 'Die Folge ist nicht mehr da.');
return;
}
const row = {id: d.id, title: d.title, audio_url: d.audio_url, duration_seconds: d.duration_seconds,
artwork_url: d.artwork_url, pub_date: d.pub_date, position_seconds: d.position_seconds,
feed__id: d.feed.id, feed__title: d.feed.title, feed__artwork_url: d.feed.artwork_url};
closeReader();
if (ms != null && host.podcastPlayEpisodeAt) {
host.podcastPlayEpisodeAt(JSON.stringify(row), now.episodeId || 0, ms);
opened = true;
} else if (ms == null && window.__dioraPodcastOpenEpisode && window.__dioraPodcastOpenEpisode(row)) {
return;
} else if (host.podcastPlayEpisode) {
host.podcastPlayEpisode(JSON.stringify(row), now.episodeId || 0);
opened = true;
}
}
closeReader();
if (opened && window.__dioraOpenPlayer) window.__dioraOpenPlayer('podcast');
}
// Clicks in the article (65-article.js hands them over): its other reports, the next article.
function onReaderClick(e) {
// A time mark in a podcast article ("▶ ab 12:34", href="#t=754"; the reader keeps it as data-internal-href).
const t = e.target.closest('a[data-internal-href^="#t="], a[href^="#t="]');
if (t && readerItem && readerItem.episode_id) {
const sec = parseInt((t.dataset.internalHref || t.getAttribute('href')).slice(3), 10);
if (sec >= 0) {
e.preventDefault();
playEpisode(readerItem.episode_id, sec);
}
return;
}
const b = e.target.closest('[data-r]');
if (!b) return;
const r = b.dataset.r;
if (r === 'episode') playEpisode(parseInt(b.dataset.id, 10), null);
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);
}
}
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) {
markRead(unread, true);
story.unread = 0;
}
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 = '<div class="fd-sheet-box"><div class="fd-sheet-title">' + esc(name) + '</div>' +
'<button type="button" data-s="open">Ordner öffnen</button>' +
'<button type="button" data-s="rename">Umbenennen …</button>' +
'<button type="button" data-s="dissolve">Auflösen</button>' +
'<button type="button" data-s="cancel">Abbrechen</button></div>';
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 markRead(ids, true);
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;
};
// 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 id="nui-tabbar">' + (NAV
? NAV.sections().filter(s => !s.footer).map(s => '<button type="button" data-nav="' + s.id + '"><span class="nui-tab-icon">' +
(s.svg || esc(s.icon)) + '</span>' + esc(s.label) + '</button>').join('')
: '<button type="button" data-tab="books"><span class="nui-tab-icon">▤</span>Bücher</button>' +
'<button type="button" data-tab="feeds"><span class="nui-tab-icon">☰</span>Feeds</button>') + '</nav>';
document.body.appendChild(dock);
const mark = () => dock.querySelectorAll('button').forEach(b => b.classList.toggle('nui-active', 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-artview .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-artview .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-artview .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, in the book reader (65-article.js).
'.fd-artview .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
'.fd-artview .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
'.fd-artview .fd-art-img, .fd-artview .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }',
'.fd-artview .fd-art-listen { margin: 0 0 12px; }',
'.fd-artview .fd-art-body a { color: var(--accent, #e63946); }',
'.fd-artview .fd-art-body figure { margin: 12px 0; }',
'.fd-artview .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
'.fd-artview .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
'.fd-artview .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
'.fd-artview .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
'.fd-artview .fd-art-body td, .fd-artview .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
'.fd-artview .fd-sources { font-family: var(--font); }',
'#tab-feeds .fd-topics .fd-note { margin-right: 2px; }',
'#tab-feeds .fd-topics small { opacity: 0.6; }',
'.fd-artview .fd-ov { font-size: 0.9em; }',
'.fd-artview .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
'.fd-artview .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-artview .fd-ov p { margin: 8px 0; line-height: 1.45; }',
'.fd-artview .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-artview .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
'.fd-artview .fd-ov-words { font-family: var(--font); font-size: 13px; }',
'.fd-artview .fd-ov-rel { margin: 0 0 18px; }',
'.fd-artview .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }',
'@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-artview .fd-ov-box { border-color: #000; }',
'body.eink .fd-artview .fd-ov-src, body.eink .fd-artview .fd-ov h4, body.eink .fd-artview .fd-ov-box summary { color: #333; }',
'body.eink .fd-artview .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);
})();