/* * 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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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 = '
'; h += '

Abos & Quellen

'; h += addHtml(); h += '
' + FILTERS.filter(f => f[0] === 'all' || f[0] === st.filter || count(f[0])) .map(f => '').join('') + '
'; h += ''; if (st.feeds === null && !all.length) h += '

Wird geladen …

'; else if (!list.length) h += '

' + (all.length ? 'Nichts gefunden.' : 'Noch keine Abos. Oben eine Adresse oder einen Namen eingeben.') + '

'; h += '
' + list.map(rowHtml).join('') + '
'; h += ioHtml(); h += '
'; 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 = '
' + '
'; if (!a) return h; if (a.error) h += '

' + esc(a.error) + '

'; if (a.found && a.found.length) { h += '
' + a.found.map((c, n) => '
' + art(c.art, c.title, 'src-art') + '
' + esc(c.title) + '
' + '
' + esc(c.sub || c.url) + '
' + (c.samples && c.samples.length ? '
' + c.samples.slice(0, 2).map(esc).join(' · ') + '
' : '') + (c.subscribed ? '
✓ schon abonniert
' : '
' + c.kinds.map(k => '').join('') + '
') + '
').join('') + '
'; } return h; } function art(url, title, cls) { const u = String(url || '').replace(/^http:/, 'https:'); return u ? '' : '' + esc((title || '?').trim().charAt(0).toUpperCase()) + ''; } function rowHtml(r) { const open = st.open === r.key; let h = '
'; if (open) h += actionsHtml(r); return h + '
'; } function actionsHtml(r) { const a = (act, label, cls) => ''; 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 '
' + esc(r.url) + '
' + list.join('') + '
'; } function ioHtml() { const h = host(); const web = !window.DioraNative; return '
Import & Export' + (h ? '' : '') + (h && web ? 'Podcasts als OPML' : '') + '' + (web ? 'Feeds als OPML' : '') + '

Feeds aus einer OPML-Datei werden in der Feed-Verwaltung übernommen (mit Vorschau).

'; } // ---- 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); })();