diora-web/static/js/sources.js

483 lines
23 KiB
JavaScript
Raw Permalink Normal View History

/*
* Abos & Quellen (docs/sources.md): every subscription in one place — podcasts, YouTube
* channels, audiobooks, news sources, blogs. One field adds any of them (an address or
* search words), one list shows them all, a source's actions open its content, its
* settings (the existing editors, through their hooks), change its kind or unsubscribe.
* Runs in the web page, the desktop app and the Android apps (diora Reader: news and
* blogs only, there are no podcasts). Chrome-61-safe: no ?. / ??.
*/
(function () {
'use strict';
const tab = document.getElementById('tab-sources');
if (!tab) return;
const st = {
filter: 'all',
q: '',
feeds: null, // /feeds/state/ sources
adding: null, // {loading, error, found: [candidate…]}
open: null, // key of the source whose actions are shown
busy: false,
};
try {
const f = localStorage.getItem('diora_sources_filter');
if (f) st.filter = f;
} catch (e) {}
const FILTERS = [
['all', 'Alle'], ['podcast', 'Podcasts'], ['youtube', 'YouTube'], ['audiobook', 'Hörbücher'],
['news', 'Nachrichten'], ['blog', 'Blogs'], ['problem', 'Probleme'],
];
const KIND_LABEL = {podcast: 'Podcast', youtube: 'YouTube', audiobook: 'Hörbuch', news: 'Nachrichten', blog: 'Blog'};
// ---- helpers -------------------------------------------------------------------------
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
function host() {
const n = window.DioraNative;
if (n && typeof n.podcastState === 'function') return n;
return window.DioraPodcastHost || null;
}
function podcastFeeds() {
const h = host();
if (!h) return [];
try { return (JSON.parse(h.podcastState()) || {}).feeds || []; } catch (e) { return []; }
}
async function api(path, body) {
const opts = {credentials: 'same-origin', cache: 'no-store'};
if (body !== undefined) {
opts.method = 'POST';
opts.headers = {'Content-Type': 'application/json'};
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
const d = await r.json().catch(() => null);
if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status));
return d;
}
function toast(text) {
let t = document.getElementById('src-toast');
if (!t) {
t = document.createElement('div');
t.id = 'src-toast';
document.body.appendChild(t);
}
t.textContent = text;
t.classList.add('src-show');
clearTimeout(toast.timer);
toast.timer = setTimeout(() => t.classList.remove('src-show'), 2800);
}
const isYouTubeFeed = url => /^https?:\/\/(www\.)?youtube\.com\/feeds\/videos\.xml/.test(url || '');
const isYouTubeAddress = s => /^(https?:\/\/)?((www|m|music)\.)?(youtube\.com|youtu\.be)\//i.test(s.trim());
const looksLikeAddress = s => !/\s/.test(s.trim()) && /\.[a-z]{2,}/i.test(s);
function go(id) {
if (window.DioraNav) window.DioraNav.go(id);
else if (typeof showTab === 'function') showTab(id);
}
// ---- all sources as one list -----------------------------------------------------------
function rows() {
const out = [];
podcastFeeds().forEach(f => {
const type = f.kind === 'audiobook' ? 'audiobook' : isYouTubeFeed(f.rss_url) ? 'youtube' : 'podcast';
out.push({
key: 'p' + f.id, type, id: f.id, title: f.title || f.rss_url, sub: f.author || '',
art: f.artwork_url || '', url: f.rss_url,
meta: (f.episode_count ? f.episode_count + ' Folgen' : '') + (f.new_count ? ' · ' + f.new_count + ' neu' : ''),
problem: (f.error_count || 0) >= 3 ? (f.last_error || 'Abruf schlägt fehl') : '',
});
});
(st.feeds || []).forEach(s => {
out.push({
key: 'f' + s.id, type: s.kind === 'news' ? 'news' : 'blog', id: s.id, title: s.title || s.url,
sub: s.kind === 'news' ? (s.category || 'Allgemein') : (s.folders || []).join(', '),
art: s.icon_url || '', url: s.url,
meta: s.unread ? s.unread + ' ungelesen' : '',
problem: (s.error_count || 0) >= 3 ? (s.last_error || 'Abruf schlägt fehl') : '',
});
});
return out.sort((a, b) => a.title.localeCompare(b.title, 'de', {sensitivity: 'base'}));
}
function shown(list) {
const q = st.q.trim().toLowerCase();
return list.filter(r =>
(st.filter === 'all' || (st.filter === 'problem' ? !!r.problem : r.type === st.filter)) &&
(!q || (r.title + ' ' + r.sub + ' ' + r.url).toLowerCase().indexOf(q) >= 0));
}
// ---- drawing ---------------------------------------------------------------------------
function render() {
const all = rows();
const list = shown(all);
const count = id => id === 'all' ? all.length
: id === 'problem' ? all.filter(r => r.problem).length : all.filter(r => r.type === id).length;
let h = '<div class="src">';
h += '<h2 class="src-h1">Abos &amp; Quellen</h2>';
h += addHtml();
h += '<div class="src-chips">' + FILTERS.filter(f => f[0] === 'all' || f[0] === st.filter || count(f[0]))
.map(f => '<button type="button" class="src-chip' + (st.filter === f[0] ? ' on' : '') + '" data-filter="' + f[0] + '">' +
esc(f[1]) + ' <span class="src-count">' + count(f[0]) + '</span></button>').join('') + '</div>';
h += '<input type="search" class="src-q" placeholder="In ' + all.length + ' Abos suchen …" value="' + esc(st.q) + '">';
if (st.feeds === null && !all.length) h += '<p class="src-note">Wird geladen …</p>';
else if (!list.length) h += '<p class="src-note">' + (all.length ? 'Nichts gefunden.' : 'Noch keine Abos. Oben eine Adresse oder einen Namen eingeben.') + '</p>';
h += '<div class="src-list">' + list.map(rowHtml).join('') + '</div>';
h += ioHtml();
h += '</div>';
const q = tab.querySelector('.src-q');
const focused = q && document.activeElement === q;
tab.innerHTML = h;
if (focused) {
const n = tab.querySelector('.src-q');
n.focus();
n.setSelectionRange(n.value.length, n.value.length);
}
}
function addHtml() {
const a = st.adding;
let h = '<form class="src-add" data-form="add"><input class="src-add-q" name="q" autocomplete="off" ' +
'placeholder="Adresse (Seite, Feed, YouTube) oder Name eines Podcasts" value="' + esc(a && a.query || '') + '">' +
'<button class="src-btn src-primary" type="submit"' + (a && a.loading ? ' disabled' : '') + '>' +
(a && a.loading ? '…' : 'Suchen') + '</button></form>';
if (!a) return h;
if (a.error) h += '<p class="src-warn">' + esc(a.error) + '</p>';
if (a.found && a.found.length) {
h += '<div class="src-found">' + a.found.map((c, n) =>
'<div class="src-cand">' + art(c.art, c.title, 'src-art') +
'<div class="src-text"><div class="src-title">' + esc(c.title) + '</div>' +
'<div class="src-meta">' + esc(c.sub || c.url) + '</div>' +
(c.samples && c.samples.length ? '<div class="src-meta">' + c.samples.slice(0, 2).map(esc).join(' · ') + '</div>' : '') +
(c.subscribed ? '<div class="src-meta">✓ schon abonniert</div>' :
'<div class="src-kinds">' + c.kinds.map(k =>
'<button type="button" class="src-btn' + (k === c.kind ? ' src-primary' : '') + '" data-sub="' + n + '" data-kind="' + k + '">' +
(k === c.kind ? '+ ' : '') + esc(KIND_LABEL[k]) + '</button>').join('') + '</div>') +
'</div></div>').join('') + '</div>';
}
return h;
}
function art(url, title, cls) {
const u = String(url || '').replace(/^http:/, 'https:');
return u ? '<img class="' + cls + '" src="' + esc(u) + '" alt="" loading="lazy">'
: '<span class="' + cls + ' src-noart">' + esc((title || '?').trim().charAt(0).toUpperCase()) + '</span>';
}
function rowHtml(r) {
const open = st.open === r.key;
let h = '<div class="src-row' + (open ? ' src-open' : '') + '"><button type="button" class="src-main" data-row="' + r.key + '">' +
art(r.art, r.title, 'src-art') + '<span class="src-text"><span class="src-title">' + esc(r.title) + '</span>' +
'<span class="src-meta"><span class="src-kind src-kind-' + r.type + '">' + esc(KIND_LABEL[r.type]) + '</span>' +
(r.sub ? ' ' + esc(r.sub) : '') + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' +
(r.problem ? '<span class="src-warn">⚠ ' + esc(r.problem) + '</span>' : '') + '</span></button>';
if (open) h += actionsHtml(r);
return h + '</div>';
}
function actionsHtml(r) {
const a = (act, label, cls) => '<button type="button" class="src-btn' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" data-key="' + r.key + '">' + label + '</button>';
const list = [];
if (r.type === 'audiobook') list.push(a('open', '▶ Hörbuch öffnen'));
else if (r.type === 'news' || r.type === 'blog') list.push(a('open', r.type === 'news' ? 'Nachrichten lesen' : 'Blog lesen'));
else list.push(a('open', 'Folgen ansehen'));
list.push(a('settings', 'Einstellungen …'));
if (r.type === 'podcast' || r.type === 'youtube') list.push(a('to-audiobook', 'Als Hörbuch führen'));
if (r.type === 'audiobook') list.push(a('to-podcast', 'Als Podcast führen'));
if (r.type === 'news') list.push(a('to-blog', 'Als Blog führen'));
if (r.type === 'blog') list.push(a('to-news', 'Als Nachrichten führen'));
list.push(a('remove', 'Abbestellen', 'src-danger'));
return '<div class="src-actions"><div class="src-meta src-url">' + esc(r.url) + '</div>' + list.join('') + '</div>';
}
function ioHtml() {
const h = host();
const web = !window.DioraNative;
return '<details class="src-io"><summary>Import &amp; Export</summary>' +
(h ? '<label class="src-btn src-file">Podcasts aus OPML …<input type="file" accept=".opml,.xml,text/xml,text/x-opml" data-io="podcasts" hidden></label>' : '') +
(h && web ? '<a class="src-btn" href="/podcasts/feeds/export.opml">Podcasts als OPML</a>' : '') +
'<button type="button" class="src-btn" data-io="feeds-opml">Feeds aus OPML …</button>' +
(web ? '<a class="src-btn" href="/feeds/export.opml">Feeds als OPML</a>' : '') +
'<p class="src-note">Feeds aus einer OPML-Datei werden in der Feed-Verwaltung übernommen (mit Vorschau).</p></details>';
}
// ---- adding --------------------------------------------------------------------------------
async function find(query) {
const q = query.trim();
if (!q) return;
st.adding = {query: q, loading: true};
render();
try {
let found = [];
const podcasts = !!host();
if (isYouTubeAddress(q) && podcasts) {
const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
found = (d.results || []).map(r => ({title: r.title, sub: 'YouTube', url: r.rss_url, art: r.artwork_url,
kind: 'podcast', kinds: ['podcast', 'audiobook'], subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
if (!found.length) throw new Error('Dort ist kein YouTube-Kanal zu finden.');
} else if (looksLikeAddress(q)) {
const d = await api('/feeds/discover/', {url: q});
found = (d.feeds || []).map(f => {
const kind = f.suggested_kind === 'podcast' && !podcasts ? 'blog' : f.suggested_kind;
return {title: f.title, url: f.url, samples: f.samples, kind,
kinds: podcasts ? ['podcast', 'audiobook', 'news', 'blog'] : ['news', 'blog'],
subscribed: kind === 'podcast' || kind === 'audiobook' ? f.podcast_subscribed : f.subscribed};
});
} else if (podcasts) {
const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
found = (d.results || []).filter(r => r.rss_url).slice(0, 12).map(r => ({title: r.title, sub: r.author,
url: r.rss_url, art: r.artwork_url, kind: 'podcast', kinds: ['podcast', 'audiobook'],
subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
if (!found.length) throw new Error('Kein Podcast dieses Namens gefunden.');
} else {
throw new Error('Bitte die Adresse einer Seite oder eines Feeds eingeben.');
}
st.adding = {query: q, found};
} catch (e) {
st.adding = {query: q, error: navigator.onLine ? (e.message || 'Hat nicht geklappt.') : 'Das braucht eine Verbindung.'};
}
render();
}
async function subscribe(c, kind) {
if (st.busy) return;
st.busy = true;
toast('Wird abonniert …');
try {
if (kind === 'podcast' || kind === 'audiobook') {
const d = await api('/podcasts/feeds/add/', {rss_url: c.url, title: c.title});
if (kind === 'audiobook') await api('/podcasts/feeds/' + d.feed_id + '/settings/', {kind: 'audiobook'});
refreshPodcasts();
toast('„' + (d.title || c.title) + '“ abonniert' + (kind === 'audiobook' ? ' (Hörbuch)' : ''));
} else {
await api('/feeds/sources/add/', {url: c.url, title: c.title, kind});
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
toast('„' + c.title + '“ abonniert');
await loadFeeds();
}
c.subscribed = true;
} catch (e) {
toast(navigator.onLine ? 'Das ließ sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.');
}
st.busy = false;
render();
}
function refreshPodcasts() {
const h = host();
// The server read the new feed already: the host only needs its state again.
if (h && typeof h.podcastRefresh === 'function') h.podcastRefresh(false);
// The host's state comes a moment later.
setTimeout(render, 1500);
setTimeout(render, 4000);
}
// ---- a source's actions -----------------------------------------------------------------------
async function act(name, r) {
const h = host();
if (name === 'open') {
if (r.type === 'audiobook') {
go('audiobooks');
if (window.__dioraAudiobookOpen) window.__dioraAudiobookOpen(r.id);
} else if (r.type === 'news' || r.type === 'blog') {
go(r.type === 'news' ? 'news' : 'blogs');
} else if (window.__dioraPodcastOpenFeed) {
window.__dioraPodcastOpenFeed(r.id);
} else {
go('podcasts');
}
return;
}
if (name === 'settings') {
if (r.type === 'news' || r.type === 'blog') {
if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(r.id);
else go(r.type === 'news' ? 'news' : 'blogs');
} else if (window.__dioraPodcastFeedSettings) {
window.__dioraPodcastFeedSettings(r.id);
} else if (window.__dioraPodcastOpenFeed) {
window.__dioraPodcastOpenFeed(r.id);
} else {
go('podcasts');
}
return;
}
if (name === 'to-audiobook' || name === 'to-podcast') {
if (!h) return;
h.podcastFeedSettings(r.id, JSON.stringify({kind: name === 'to-audiobook' ? 'audiobook' : 'podcast'}));
toast(name === 'to-audiobook' ? '„' + r.title + '“ ist jetzt ein Hörbuch' : '„' + r.title + '“ ist wieder ein Podcast');
st.open = null;
render();
setTimeout(render, 600);
return;
}
if (name === 'to-blog' || name === 'to-news') {
try {
await api('/feeds/sources/' + r.id + '/', {kind: name === 'to-blog' ? 'blog' : 'news'});
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
await loadFeeds();
} catch (e) { toast('Das braucht eine Verbindung.'); }
return;
}
if (name === 'remove') {
const ok = typeof customConfirm === 'function'
? await customConfirm('„' + r.title + '“ abbestellen?', {danger: true})
: window.confirm('„' + r.title + '“ abbestellen?');
if (!ok) return;
try {
if (r.type === 'news' || r.type === 'blog') {
await api('/feeds/sources/' + r.id + '/delete/', {});
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
await loadFeeds();
} else {
await api('/podcasts/feeds/' + r.id + '/remove/', {});
if (h && typeof h.podcastFeedRemoved === 'function') h.podcastFeedRemoved();
setTimeout(render, 1500);
}
st.open = null;
toast('„' + r.title + '“ abbestellt');
} catch (e) { toast('Das braucht eine Verbindung.'); }
render();
}
}
async function importPodcasts(file) {
const form = new FormData();
form.append('file', file);
toast('Wird übernommen …');
try {
const r = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form, credentials: 'same-origin'});
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.status);
toast((d.added || 0) + ' Podcasts übernommen');
refreshPodcasts();
} catch (e) {
toast('Die Datei ließ sich nicht übernehmen.');
}
}
// ---- events ---------------------------------------------------------------------------------
tab.addEventListener('submit', e => {
const form = e.target.closest('[data-form="add"]');
if (!form) return;
e.preventDefault();
find(form.q.value);
});
tab.addEventListener('input', e => {
if (!e.target.classList.contains('src-q')) return;
st.q = e.target.value;
render();
});
tab.addEventListener('change', e => {
const io = e.target.dataset && e.target.dataset.io;
if (io === 'podcasts' && e.target.files && e.target.files[0]) importPodcasts(e.target.files[0]);
});
tab.addEventListener('click', e => {
const t = e.target.closest('button, label');
if (!t) return;
if (t.dataset.filter) {
st.filter = t.dataset.filter;
try { localStorage.setItem('diora_sources_filter', st.filter); } catch (err) {}
render();
} else if (t.dataset.row) {
st.open = st.open === t.dataset.row ? null : t.dataset.row;
render();
} else if (t.dataset.act) {
const r = rows().filter(x => x.key === t.dataset.key)[0];
if (r) act(t.dataset.act, r);
} else if (t.dataset.sub != null) {
const c = st.adding && st.adding.found && st.adding.found[Number(t.dataset.sub)];
if (c) subscribe(c, t.dataset.kind);
} else if (t.dataset.io === 'feeds-opml') {
if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(null);
else go('news');
}
});
// ---- loading, showing --------------------------------------------------------------------------
async function loadFeeds() {
try {
const d = await api('/feeds/state/');
st.feeds = d.sources || [];
} catch (e) {
if (st.feeds === null) st.feeds = [];
}
render();
}
function show() {
// Another view may have chosen what to show (feeds.js: its own kind).
try {
const f = localStorage.getItem('diora_sources_filter');
if (f && FILTERS.some(x => x[0] === f)) st.filter = f;
} catch (e) {}
render();
loadFeeds();
}
const showTab = window.showTab;
if (typeof showTab === 'function') {
window.showTab = function (name) {
const r = showTab.apply(this, arguments);
if (name === 'sources') show();
return r;
};
}
if (tab.style.display !== 'none') show();
// ---- styles --------------------------------------------------------------------------------------
const style = document.createElement('style');
style.textContent = [
'#tab-sources .src { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
'#tab-sources .src-h1 { font-size: 18px; margin: 4px 0 12px; }',
'#tab-sources .src-add { display: flex; gap: 8px; }',
'#tab-sources .src-add-q, #tab-sources .src-q { flex: 1; min-width: 0; box-sizing: border-box; padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }',
'#tab-sources .src-q { width: 100%; margin: 8px 0 4px; }',
'#tab-sources .src-btn { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; text-decoration: none; margin: 4px 6px 0 0; }',
'#tab-sources .src-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-sources .src-danger { color: var(--accent, #e63946); }',
'#tab-sources .src-btn[disabled] { opacity: .5; }',
'#tab-sources .src-found { margin: 10px 0 4px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); }',
'#tab-sources .src-cand { display: flex; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border, #2a2a2a); }',
'#tab-sources .src-cand:last-child { border-bottom: none; }',
'#tab-sources .src-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0 4px; }',
'#tab-sources .src-chip { min-height: 34px; padding: 0 12px; border-radius: 17px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'#tab-sources .src-chip.on { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-sources .src-count { opacity: .7; font-size: 12px; }',
'#tab-sources .src-row { border-bottom: 1px solid var(--border, #2a2a2a); }',
'#tab-sources .src-main { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; background: none; border: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-sources .src-main:hover { background: var(--hover, rgba(255,255,255,.04)); }',
'#tab-sources .src-art { width: 40px; height: 40px; flex: none; border-radius: 6px; object-fit: cover; background: var(--panel, #161616); }',
'#tab-sources .src-noart { display: flex; align-items: center; justify-content: center; color: var(--muted, #9a9a9a); }',
'#tab-sources .src-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
'#tab-sources .src-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-sources .src-meta, #tab-sources .src-note { color: var(--muted, #9a9a9a); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }',
'#tab-sources .src-url { white-space: nowrap; margin-bottom: 4px; }',
'#tab-sources .src-warn { color: var(--accent, #e63946); font-size: 12px; }',
'#tab-sources .src-kind { display: inline-block; padding: 0 6px; border-radius: 8px; background: var(--raised, rgba(255,255,255,.12)); color: var(--fg, #eee); font-size: 11px; }',
'#tab-sources .src-kind-audiobook, #tab-sources .src-kind-youtube { background: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-sources .src-actions { padding: 0 0 12px 50px; }',
'#tab-sources .src-io { margin-top: 22px; }',
'#tab-sources .src-io summary { cursor: pointer; color: var(--muted, #9a9a9a); font-size: 13px; padding: 6px 0; }',
'#src-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
'#src-toast.src-show { opacity: 1; }',
'body.eink #tab-sources .src-primary, body.eink #tab-sources .src-chip.on { background: #000; color: #fff; }',
'body.eink #tab-sources .src-kind { background: none; color: #000; border: 1px solid #000; }',
].join('\n');
document.head.appendChild(style);
})();