Compare commits
2 commits
4256bf6b29
...
35701260e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 35701260e0 | |||
| 08baf970ca |
22 changed files with 2394 additions and 4 deletions
|
|
@ -31,6 +31,7 @@ INSTALLED_APPS = [
|
||||||
'books',
|
'books',
|
||||||
'gpodder',
|
'gpodder',
|
||||||
'tts',
|
'tts',
|
||||||
|
'feeds',
|
||||||
]
|
]
|
||||||
|
|
||||||
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
|
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ urlpatterns = [
|
||||||
path('accounts/', include('accounts.urls')),
|
path('accounts/', include('accounts.urls')),
|
||||||
path('podcasts/', include('podcasts.urls')),
|
path('podcasts/', include('podcasts.urls')),
|
||||||
path('books/', include('books.urls')),
|
path('books/', include('books.urls')),
|
||||||
|
path('feeds/', include('feeds.urls')),
|
||||||
path('tts/', include('tts.urls')),
|
path('tts/', include('tts.urls')),
|
||||||
path('api/2/', include('gpodder.urls')),
|
path('api/2/', include('gpodder.urls')),
|
||||||
path('api/sync/', sync_snapshot, name='api_sync'),
|
path('api/sync/', sync_snapshot, name='api_sync'),
|
||||||
|
|
|
||||||
0
feeds/__init__.py
Normal file
0
feeds/__init__.py
Normal file
6
feeds/apps.py
Normal file
6
feeds/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FeedsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'feeds'
|
||||||
149
feeds/cluster.py
Normal file
149
feeds/cluster.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""Bundling news items about the same event into stories.
|
||||||
|
|
||||||
|
Each item is described by the words of its title (counted twice) and summary,
|
||||||
|
without stop words and with a crude German/English suffix cut. A new item joins
|
||||||
|
the most similar story of the last WINDOW hours (cosine of tf-idf vectors,
|
||||||
|
document frequencies over those stories) if that similarity reaches THRESHOLD
|
||||||
|
and they share at least MIN_SHARED words; otherwise it starts a story of its own.
|
||||||
|
A story many sources report on is a top story.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from .models import Item, Story
|
||||||
|
|
||||||
|
WINDOW = timedelta(hours=36)
|
||||||
|
THRESHOLD = 0.25
|
||||||
|
MIN_SHARED = 2
|
||||||
|
|
||||||
|
STOP = set('''
|
||||||
|
aber alle allem allen aller alles also als am an andere anderen auch auf aus bei beim bereits bis bisher
|
||||||
|
da dabei damit dann darauf darf darum das dass dem den denen der deren des deshalb dessen die dies diese
|
||||||
|
diesem diesen dieser dieses doch dort durch ein eine einem einen einer eines einige er es etwa euch für
|
||||||
|
gegen geht gibt hab habe haben hat hatte hatten hier hin hinter ich ihr ihre ihrem ihren ihrer im immer
|
||||||
|
in ins ist ja jahr jahre jahren jetzt kann kein keine können könnte man mehr mit muss müssen nach neue neuen
|
||||||
|
nicht nichts noch nun nur ob oder ohne schon sei seid sein seine seinem seinen seiner seit sich sie sind
|
||||||
|
so soll sollen sollte sondern sowie über um und uns unter viel viele vom von vor war waren was weil weiter
|
||||||
|
weitere wenn wer werde werden wie wieder will wir wird wo worden wurde wurden zu zum zur zwei drei vier
|
||||||
|
heute gestern morgen laut sagt sagte erst mal neu alt video live ticker liveblog news meldung bericht
|
||||||
|
the and for with from that this have has are was were will would been into about after over their they
|
||||||
|
his her its our you your not but all can new says said more than when what who how why off out
|
||||||
|
tot tote toten getötet sterben stirbt gestorben menschen mensch leute millionen milliarden prozent euro
|
||||||
|
deutschland deutsche deutschen land länder stadt welt woche wochen monat monate tag tage tagen uhr
|
||||||
|
frau frauen mann männer kinder erste ersten zweite neuer neues großen große groß kleine wegen gibt geben
|
||||||
|
'''.split())
|
||||||
|
|
||||||
|
_WORD = re.compile(r'[a-zäöüß][a-zäöüß0-9\-]+')
|
||||||
|
_SUFFIXES = ('ungen', 'ung', 'en', 'er', 'es', 'e', 's', 'n')
|
||||||
|
|
||||||
|
|
||||||
|
def _stem(w):
|
||||||
|
if len(w) > 5:
|
||||||
|
for s in _SUFFIXES:
|
||||||
|
if w.endswith(s) and len(w) - len(s) >= 4:
|
||||||
|
return w[:-len(s)]
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def words(text):
|
||||||
|
out = []
|
||||||
|
for w in _WORD.findall((text or '').lower()):
|
||||||
|
w = w.strip('-')
|
||||||
|
if len(w) < 3 or w in STOP:
|
||||||
|
continue
|
||||||
|
out.append(_stem(w))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def item_terms(title, summary):
|
||||||
|
return Counter(words(title) * 2 + words(summary[:300]))
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine(a, b, idf):
|
||||||
|
dot = sum(a[w] * b[w] * idf.get(w, 1.0) ** 2 for w in a.keys() & b.keys())
|
||||||
|
if not dot:
|
||||||
|
return 0.0
|
||||||
|
na = math.sqrt(sum((n * idf.get(w, 1.0)) ** 2 for w, n in a.items()))
|
||||||
|
nb = math.sqrt(sum((n * idf.get(w, 1.0)) ** 2 for w, n in b.items()))
|
||||||
|
return dot / (na * nb)
|
||||||
|
|
||||||
|
|
||||||
|
class Clusterer:
|
||||||
|
"""Assigns one user's new news items to stories (load once, add items, done).
|
||||||
|
|
||||||
|
An item is compared with every item already in a story: the story's score
|
||||||
|
is the mean of the best match and the average match, so one stray shared
|
||||||
|
word can't pull an item in, and a story doesn't grow into a magnet the way
|
||||||
|
a pooled word list would. Items of the same source join only when very
|
||||||
|
close (a source rarely reports one event twice, but often two similar ones)."""
|
||||||
|
|
||||||
|
SAME_SOURCE = 0.5
|
||||||
|
|
||||||
|
def __init__(self, user, now):
|
||||||
|
self.user = user
|
||||||
|
self.stories = list(Story.objects.filter(user=user, last_at__gte=now - WINDOW))
|
||||||
|
self.members = {s.pk: [] for s in self.stories} # story id -> [(source id, terms)]
|
||||||
|
for i in Item.objects.filter(story__in=self.stories, gone=False).only('story_id', 'source_id', 'title', 'summary'):
|
||||||
|
self.members[i.story_id].append((i.source_id, item_terms(i.title, i.summary)))
|
||||||
|
self._df = Counter()
|
||||||
|
for ms in self.members.values():
|
||||||
|
for _, t in ms:
|
||||||
|
self._df.update(t.keys())
|
||||||
|
self._n = sum(len(ms) for ms in self.members.values())
|
||||||
|
|
||||||
|
def _idf(self):
|
||||||
|
n = self._n + 1
|
||||||
|
return {w: math.log((n + 1) / (d + 1)) + 1 for w, d in self._df.items()}
|
||||||
|
|
||||||
|
def score(self, t, source_id, members, idf):
|
||||||
|
sims = []
|
||||||
|
for sid, mt in members:
|
||||||
|
if len(t.keys() & mt.keys()) < MIN_SHARED:
|
||||||
|
sims.append(0.0)
|
||||||
|
continue
|
||||||
|
sim = _cosine(t, mt, idf)
|
||||||
|
if sid == source_id and sim < self.SAME_SOURCE:
|
||||||
|
sim = 0.0
|
||||||
|
sims.append(sim)
|
||||||
|
if not sims or max(sims) == 0:
|
||||||
|
return 0.0
|
||||||
|
return (max(sims) + sum(sims) / len(sims)) / 2
|
||||||
|
|
||||||
|
def add(self, item):
|
||||||
|
t = item_terms(item.title, item.summary)
|
||||||
|
best, best_sim = None, 0.0
|
||||||
|
if t:
|
||||||
|
idf = self._idf()
|
||||||
|
for s in self.stories:
|
||||||
|
if abs((s.last_at - item.published).total_seconds()) > WINDOW.total_seconds():
|
||||||
|
continue
|
||||||
|
sim = self.score(t, item.source_id, self.members[s.pk], idf)
|
||||||
|
if sim > best_sim:
|
||||||
|
best, best_sim = s, sim
|
||||||
|
if best is not None and best_sim >= THRESHOLD:
|
||||||
|
self._join(best, item)
|
||||||
|
else:
|
||||||
|
best = Story.objects.create(user=self.user, lead=item, first_at=item.published,
|
||||||
|
last_at=item.published, size=1, sources=1)
|
||||||
|
self.stories.append(best)
|
||||||
|
self.members[best.pk] = []
|
||||||
|
item.story = best
|
||||||
|
item.save(update_fields=['story'])
|
||||||
|
self.members[best.pk].append((item.source_id, t))
|
||||||
|
self._df.update(t.keys())
|
||||||
|
self._n += 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
def _join(self, s, item):
|
||||||
|
item.story = s
|
||||||
|
item.save(update_fields=['story'])
|
||||||
|
s.size = Item.objects.filter(story=s).count()
|
||||||
|
s.sources = Item.objects.filter(story=s).values('source').distinct().count()
|
||||||
|
s.first_at = min(s.first_at, item.published)
|
||||||
|
s.last_at = max(s.last_at, item.published)
|
||||||
|
if s.lead is None or (not s.lead.image_url and item.image_url):
|
||||||
|
s.lead = item
|
||||||
|
s.save(update_fields=['size', 'sources', 'first_at', 'last_at', 'lead'])
|
||||||
241
feeds/fetch.py
Normal file
241
feeds/fetch.py
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
"""Reading feeds: entries into items, the whole article from its page, finding a
|
||||||
|
site's feed. Downloading the feed itself is podcasts.views._fetch_feed (ETag,
|
||||||
|
body hash, redirects), shared with the podcast refresh."""
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone as dt_timezone
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from podcasts.shownotes import _Sanitizer, html_to_text
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
USER_AGENT = 'Mozilla/5.0 (compatible; diora feed reader; +https://diora.creamfresh.xyz)'
|
||||||
|
PAGE_TIMEOUT = 15
|
||||||
|
# Shorter text than this in the feed counts as a teaser: the article is fetched ("auto").
|
||||||
|
TEASER_CHARS = 700
|
||||||
|
SUMMARY_CHARS = 400
|
||||||
|
|
||||||
|
|
||||||
|
class _AbsoluteSanitizer(_Sanitizer):
|
||||||
|
"""The podcast sanitizer, with links and images made absolute first
|
||||||
|
(article HTML is full of relative ones, which the allowlist would drop)."""
|
||||||
|
|
||||||
|
def __init__(self, base):
|
||||||
|
super().__init__()
|
||||||
|
self.base = base
|
||||||
|
|
||||||
|
def _start(self, tag, attrs, closed):
|
||||||
|
if tag == 'img':
|
||||||
|
# Lazy-loaded images keep the real address in data-src, src is a placeholder.
|
||||||
|
a = dict(attrs)
|
||||||
|
real = a.get('data-src') or a.get('data-original')
|
||||||
|
if real:
|
||||||
|
attrs = [(n, v) for n, v in attrs if n not in ('src', 'data-src', 'data-original')] + [('src', real)]
|
||||||
|
fixed = []
|
||||||
|
for name, value in attrs:
|
||||||
|
if name in ('href', 'src') and value and not value.strip().startswith(('data:', 'javascript:', '#')):
|
||||||
|
value = urljoin(self.base, value.strip())
|
||||||
|
fixed.append((name, value))
|
||||||
|
super()._start(tag, fixed, closed)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_html(text, base=''):
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
s = _AbsoluteSanitizer(base)
|
||||||
|
s.feed(text)
|
||||||
|
return s.result().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def plain(text):
|
||||||
|
return re.sub(r'\s+', ' ', html_to_text(text or '')).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _short(text, n=SUMMARY_CHARS):
|
||||||
|
if len(text) <= n:
|
||||||
|
return text
|
||||||
|
cut = text[:n].rsplit(' ', 1)[0]
|
||||||
|
return cut + ' …'
|
||||||
|
|
||||||
|
|
||||||
|
def _when(entry):
|
||||||
|
"""The entry's time (published, else updated), never in the future."""
|
||||||
|
now = timezone.now()
|
||||||
|
for key in ('published_parsed', 'updated_parsed', 'created_parsed'):
|
||||||
|
t = entry.get(key)
|
||||||
|
if t:
|
||||||
|
try:
|
||||||
|
when = datetime.fromtimestamp(calendar.timegm(t), dt_timezone.utc)
|
||||||
|
except (OverflowError, ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return min(when, now)
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
class _FirstImage(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.src = ''
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag == 'img' and not self.src:
|
||||||
|
a = dict(attrs)
|
||||||
|
w, h = a.get('width', ''), a.get('height', '')
|
||||||
|
if w in ('1', '0') or h in ('1', '0'):
|
||||||
|
return # tracking pixel
|
||||||
|
self.src = a.get('src') or ''
|
||||||
|
|
||||||
|
|
||||||
|
def _image(entry, content_html, base):
|
||||||
|
for m in entry.get('media_thumbnail') or []:
|
||||||
|
if m.get('url'):
|
||||||
|
return m['url']
|
||||||
|
for m in entry.get('media_content') or []:
|
||||||
|
if m.get('url') and (m.get('medium') == 'image' or (m.get('type') or '').startswith('image/')):
|
||||||
|
return m['url']
|
||||||
|
for enc in entry.get('enclosures') or []:
|
||||||
|
if (enc.get('type') or '').startswith('image/') and enc.get('href'):
|
||||||
|
return enc['href']
|
||||||
|
p = _FirstImage()
|
||||||
|
try:
|
||||||
|
p.feed(content_html or '')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return urljoin(base, p.src) if p.src else ''
|
||||||
|
|
||||||
|
|
||||||
|
def guid_hash(entry):
|
||||||
|
key = entry.get('id') or entry.get('link') or f"{entry.get('title', '')}|{entry.get('published', '')}"
|
||||||
|
return hashlib.sha256(key.encode('utf-8', 'replace')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_entry(entry, feed_url):
|
||||||
|
"""A feedparser entry -> the fields of an Item (without source/user)."""
|
||||||
|
link = entry.get('link') or ''
|
||||||
|
base = link or feed_url
|
||||||
|
contents = [c.get('value') or '' for c in entry.get('content') or []]
|
||||||
|
content_html = max(contents, key=len) if contents else ''
|
||||||
|
summary_html = entry.get('summary') or ''
|
||||||
|
if len(plain(summary_html)) > len(plain(content_html)):
|
||||||
|
content_html = summary_html
|
||||||
|
content = clean_html(content_html, base)
|
||||||
|
text = plain(content)
|
||||||
|
summary = plain(summary_html) or text
|
||||||
|
title = plain(html.unescape(entry.get('title') or '')) or _short(text, 80) or 'Ohne Titel'
|
||||||
|
return {
|
||||||
|
'guid_hash': guid_hash(entry),
|
||||||
|
'url': link[:2000],
|
||||||
|
'title': title[:500],
|
||||||
|
'author': plain(entry.get('author') or '')[:300],
|
||||||
|
'published': _when(entry),
|
||||||
|
'summary': _short(summary),
|
||||||
|
'content': content,
|
||||||
|
'image_url': _image(entry, content_html, base)[:2000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_teaser(content_html):
|
||||||
|
return len(plain(content_html)) < TEASER_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_title(html_text):
|
||||||
|
"""trafilatura repeats the headline as the first <h1>; the reader shows it already."""
|
||||||
|
return re.sub(r'^\s*(<img[^>]*>\s*)?<h1>.*?</h1>', lambda m: m.group(1) or '', html_text, count=1, flags=re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_article(url):
|
||||||
|
"""The article on `url` as (clean HTML, image URL), or None. trafilatura
|
||||||
|
finds the main text and leaves out navigation, ads, comments."""
|
||||||
|
import trafilatura # heavy import, only where articles are fetched
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.get(url, timeout=PAGE_TIMEOUT, headers={'User-Agent': USER_AGENT})
|
||||||
|
except requests.RequestException as e:
|
||||||
|
log.info('article %s: %s', url, type(e).__name__)
|
||||||
|
return None
|
||||||
|
if r.status_code >= 400 or 'html' not in r.headers.get('Content-Type', 'text/html'):
|
||||||
|
return None
|
||||||
|
page = r.text
|
||||||
|
try:
|
||||||
|
body = trafilatura.extract(page, url=r.url, output_format='html', include_images=True,
|
||||||
|
include_links=True, include_tables=True, favor_precision=True)
|
||||||
|
except Exception:
|
||||||
|
log.exception('article %s: extraction failed', url)
|
||||||
|
return None
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
body = re.sub(r'^\s*<html>\s*<body>|</body>\s*</html>\s*$', '', body.strip())
|
||||||
|
content = clean_html(_drop_title(body), r.url)
|
||||||
|
if len(plain(content)) < 200:
|
||||||
|
return None
|
||||||
|
image = ''
|
||||||
|
try:
|
||||||
|
meta = trafilatura.extract_metadata(page, default_url=r.url)
|
||||||
|
image = (meta.image if meta else '') or ''
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return content, image
|
||||||
|
|
||||||
|
|
||||||
|
# ---- finding a site's feed
|
||||||
|
|
||||||
|
FEED_TYPES = ('application/rss+xml', 'application/atom+xml', 'application/feed+json', 'application/xml', 'text/xml')
|
||||||
|
|
||||||
|
|
||||||
|
class _FeedLinks(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.feeds = []
|
||||||
|
self.icon = ''
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag != 'link':
|
||||||
|
return
|
||||||
|
a = {k.lower(): (v or '') for k, v in attrs}
|
||||||
|
rel = a.get('rel', '').lower().split()
|
||||||
|
if 'alternate' in rel and a.get('type', '').lower() in FEED_TYPES and a.get('href'):
|
||||||
|
self.feeds.append((a['href'], a.get('title', '')))
|
||||||
|
if ('icon' in rel or 'apple-touch-icon' in rel) and a.get('href') and not self.icon:
|
||||||
|
self.icon = a['href']
|
||||||
|
|
||||||
|
|
||||||
|
def discover(page_html, base):
|
||||||
|
"""[(feed url, title)] a page links to, and its icon URL."""
|
||||||
|
p = _FeedLinks()
|
||||||
|
try:
|
||||||
|
p.feed(page_html)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
seen, out = set(), []
|
||||||
|
for href, title in p.feeds:
|
||||||
|
u = urljoin(base, href)
|
||||||
|
if u not in seen:
|
||||||
|
seen.add(u)
|
||||||
|
out.append((u, title))
|
||||||
|
return out, (urljoin(base, p.icon) if p.icon else '')
|
||||||
|
|
||||||
|
|
||||||
|
def short_title(title):
|
||||||
|
"""A feed's name without the slogan: "tagesschau.de - die erste Adresse für …"
|
||||||
|
-> "tagesschau.de", "Aktuell - FAZ.NET" -> "FAZ.NET", "DIE ZEIT | Nachrichten, …" -> "DIE ZEIT"."""
|
||||||
|
parts = [p.strip() for p in re.split(r'\s+[-–—|:]\s+', title or '') if p.strip()]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return (title or '').strip()
|
||||||
|
branded = [p for p in parts if re.search(r'\w\.[a-z]{2,}$', p, re.I)]
|
||||||
|
return (branded or parts)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def site_icon(site_url):
|
||||||
|
if not site_url:
|
||||||
|
return ''
|
||||||
|
u = urlparse(site_url)
|
||||||
|
return f'{u.scheme}://{u.netloc}/favicon.ico' if u.scheme and u.netloc else ''
|
||||||
0
feeds/management/__init__.py
Normal file
0
feeds/management/__init__.py
Normal file
0
feeds/management/commands/__init__.py
Normal file
0
feeds/management/commands/__init__.py
Normal file
25
feeds/management/commands/refresh_newsfeeds.py
Normal file
25
feeds/management/commands/refresh_newsfeeds.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from feeds.models import Source
|
||||||
|
from feeds.refresh import refresh
|
||||||
|
from podcasts.views import _in_backoff_q
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Refresh the feed reader\'s sources (news and blogs)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
qs = Source.objects.select_related('user').order_by('last_refreshed_at')
|
||||||
|
if options['source_id']:
|
||||||
|
qs = qs.filter(pk=options['source_id'])
|
||||||
|
else:
|
||||||
|
qs = qs.exclude(_in_backoff_q(timezone.now()))
|
||||||
|
if not qs.exists():
|
||||||
|
self.stdout.write('No sources to refresh.')
|
||||||
|
return
|
||||||
|
refreshed, failed, new = refresh(qs)
|
||||||
|
self.stdout.write(f'{refreshed} sources refreshed, {failed} failed, +{new} items')
|
||||||
111
feeds/migrations/0001_initial.py
Normal file
111
feeds/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Generated by Django 6.1 on 2026-10-04 17:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FeedSettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('news_seen_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('news_keep_days', models.PositiveIntegerField(default=2)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feed_settings', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Source',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('url', models.URLField(max_length=1000)),
|
||||||
|
('title', models.CharField(max_length=300)),
|
||||||
|
('site_url', models.URLField(blank=True, max_length=1000)),
|
||||||
|
('icon_url', models.URLField(blank=True, max_length=1000)),
|
||||||
|
('kind', models.CharField(choices=[('news', 'Nachrichten'), ('blog', 'Blog')], default='news', max_length=10)),
|
||||||
|
('category', models.CharField(blank=True, max_length=100)),
|
||||||
|
('fulltext', models.CharField(choices=[('auto', 'Automatisch'), ('always', 'Immer'), ('never', 'Nie')], default='auto', max_length=10)),
|
||||||
|
('added_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('last_refreshed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('http_etag', models.CharField(blank=True, max_length=500)),
|
||||||
|
('http_last_modified', models.CharField(blank=True, max_length=100)),
|
||||||
|
('http_body_hash', models.CharField(blank=True, max_length=64)),
|
||||||
|
('last_error', models.CharField(blank=True, max_length=500)),
|
||||||
|
('last_error_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('error_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_sources', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['title'],
|
||||||
|
'unique_together': {('user', 'url')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Item',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('kind', models.CharField(choices=[('news', 'Nachrichten'), ('blog', 'Blog')], max_length=10)),
|
||||||
|
('guid_hash', models.CharField(max_length=64)),
|
||||||
|
('url', models.URLField(blank=True, max_length=2000)),
|
||||||
|
('title', models.CharField(max_length=500)),
|
||||||
|
('author', models.CharField(blank=True, max_length=300)),
|
||||||
|
('published', models.DateTimeField(db_index=True)),
|
||||||
|
('fetched_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('summary', models.TextField(blank=True)),
|
||||||
|
('content', models.TextField(blank=True)),
|
||||||
|
('content_full', models.BooleanField(default=False)),
|
||||||
|
('fulltext_failed', models.BooleanField(default=False)),
|
||||||
|
('image_url', models.URLField(blank=True, max_length=2000)),
|
||||||
|
('read_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('starred', models.BooleanField(default=False)),
|
||||||
|
('gone', models.BooleanField(default=False)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_items', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='feeds.source')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-published'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Story',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('first_at', models.DateTimeField()),
|
||||||
|
('last_at', models.DateTimeField(db_index=True)),
|
||||||
|
('size', models.PositiveIntegerField(default=1)),
|
||||||
|
('sources', models.PositiveIntegerField(default=1)),
|
||||||
|
('terms', models.TextField(blank=True)),
|
||||||
|
('lead', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='feeds.item')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_stories', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-last_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='story',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items', to='feeds.story'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='item',
|
||||||
|
index=models.Index(fields=['user', 'kind', 'published'], name='feeds_item_user_id_1a8bce_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='item',
|
||||||
|
index=models.Index(fields=['user', 'kind', 'read_at'], name='feeds_item_user_id_0007dd_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='item',
|
||||||
|
unique_together={('source', 'guid_hash')},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
feeds/migrations/__init__.py
Normal file
0
feeds/migrations/__init__.py
Normal file
106
feeds/models.py
Normal file
106
feeds/models.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Source(models.Model):
|
||||||
|
"""A subscribed feed. Every source is either news or a blog, and that shapes
|
||||||
|
everything after it: news gets bundled into stories, shows up in the
|
||||||
|
"Seit letztem Mal" briefing and expires after a few days; blog articles stay
|
||||||
|
until read and are listed per blog."""
|
||||||
|
NEWS = 'news'
|
||||||
|
BLOG = 'blog'
|
||||||
|
KINDS = [(NEWS, 'Nachrichten'), (BLOG, 'Blog')]
|
||||||
|
|
||||||
|
# Whether the whole article is fetched from its page: "auto" when the feed
|
||||||
|
# only carries a teaser.
|
||||||
|
FULLTEXT_AUTO = 'auto'
|
||||||
|
FULLTEXT_ALWAYS = 'always'
|
||||||
|
FULLTEXT_NEVER = 'never'
|
||||||
|
FULLTEXTS = [(FULLTEXT_AUTO, 'Automatisch'), (FULLTEXT_ALWAYS, 'Immer'), (FULLTEXT_NEVER, 'Nie')]
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feed_sources')
|
||||||
|
url = models.URLField(max_length=1000)
|
||||||
|
title = models.CharField(max_length=300)
|
||||||
|
site_url = models.URLField(max_length=1000, blank=True)
|
||||||
|
icon_url = models.URLField(max_length=1000, blank=True)
|
||||||
|
kind = models.CharField(max_length=10, choices=KINDS, default=NEWS)
|
||||||
|
# News: the section it belongs to (Politik, Tech …), groups the briefing.
|
||||||
|
category = models.CharField(max_length=100, blank=True)
|
||||||
|
fulltext = models.CharField(max_length=10, choices=FULLTEXTS, default=FULLTEXT_AUTO)
|
||||||
|
added_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed).
|
||||||
|
last_refreshed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
http_etag = models.CharField(max_length=500, blank=True)
|
||||||
|
http_last_modified = models.CharField(max_length=100, blank=True)
|
||||||
|
http_body_hash = models.CharField(max_length=64, blank=True)
|
||||||
|
last_error = models.CharField(max_length=500, blank=True)
|
||||||
|
last_error_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
error_count = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'url')
|
||||||
|
ordering = ['title']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.title} ({self.kind}, user={self.user_id})'
|
||||||
|
|
||||||
|
|
||||||
|
class Story(models.Model):
|
||||||
|
"""News items about the same event from one or more sources (see cluster.py)."""
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feed_stories')
|
||||||
|
# The item the story is shown with (the first one, or the longest text).
|
||||||
|
lead = models.ForeignKey('Item', null=True, blank=True, on_delete=models.SET_NULL, related_name='+')
|
||||||
|
first_at = models.DateTimeField()
|
||||||
|
last_at = models.DateTimeField(db_index=True)
|
||||||
|
size = models.PositiveIntegerField(default=1) # items in it
|
||||||
|
sources = models.PositiveIntegerField(default=1) # distinct sources among them
|
||||||
|
# Words that describe it (space-separated, for matching new items).
|
||||||
|
terms = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-last_at']
|
||||||
|
|
||||||
|
|
||||||
|
class Item(models.Model):
|
||||||
|
source = models.ForeignKey(Source, on_delete=models.CASCADE, related_name='items')
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feed_items')
|
||||||
|
kind = models.CharField(max_length=10, choices=Source.KINDS) # the source's, at the time
|
||||||
|
guid_hash = models.CharField(max_length=64) # sha256 of the entry's id (or link)
|
||||||
|
url = models.URLField(max_length=2000, blank=True)
|
||||||
|
title = models.CharField(max_length=500)
|
||||||
|
author = models.CharField(max_length=300, blank=True)
|
||||||
|
published = models.DateTimeField(db_index=True)
|
||||||
|
fetched_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
summary = models.TextField(blank=True) # plain text, short
|
||||||
|
content = models.TextField(blank=True) # sanitized HTML
|
||||||
|
# The content came from the article's page, not the feed.
|
||||||
|
content_full = models.BooleanField(default=False)
|
||||||
|
# Fetching the page failed or gave nothing: don't try on every open.
|
||||||
|
fulltext_failed = models.BooleanField(default=False)
|
||||||
|
image_url = models.URLField(max_length=2000, blank=True)
|
||||||
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
starred = models.BooleanField(default=False)
|
||||||
|
story = models.ForeignKey(Story, null=True, blank=True, on_delete=models.SET_NULL, related_name='items')
|
||||||
|
# Expired news: the row stays a while without its text, so the entry isn't
|
||||||
|
# taken in again as new while the feed still carries it (refresh.expire).
|
||||||
|
gone = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('source', 'guid_hash')
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['user', 'kind', 'published']),
|
||||||
|
models.Index(fields=['user', 'kind', 'read_at']),
|
||||||
|
]
|
||||||
|
ordering = ['-published']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class FeedSettings(models.Model):
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='feed_settings')
|
||||||
|
# The last time the news briefing was seen; what came after is "Seit letztem Mal".
|
||||||
|
news_seen_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
# News older than this goes (unless starred).
|
||||||
|
news_keep_days = models.PositiveIntegerField(default=2)
|
||||||
229
feeds/refresh.py
Normal file
229
feeds/refresh.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
"""Refreshing sources: new entries become items, teasers get their article,
|
||||||
|
news is bundled into stories, old news expires."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from podcasts.views import FeedFetchError, _clear_error, _error_text, _fetch_feed
|
||||||
|
|
||||||
|
from . import fetch
|
||||||
|
from .cluster import Clusterer
|
||||||
|
from .models import FeedSettings, Item, Source, Story
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FEED_WORKERS = 12
|
||||||
|
ARTICLE_WORKERS = 8
|
||||||
|
ARTICLES_PER_RUN = 80 # articles fetched per refresh at most; the rest when opened
|
||||||
|
BLOG_KEEP = 1000 # items kept per blog
|
||||||
|
BLOG_FIRST_UNREAD_DAYS = 14 # a new blog's older articles start out read
|
||||||
|
GONE_KEEP_DAYS = 30 # expired news rows kept (without text) against re-adding
|
||||||
|
|
||||||
|
|
||||||
|
def settings_for(user):
|
||||||
|
s, _ = FeedSettings.objects.get_or_create(user=user)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(source, result, now, keep_days):
|
||||||
|
"""Write one download; returns the new items."""
|
||||||
|
if result.status == 304:
|
||||||
|
source.last_refreshed_at = now
|
||||||
|
_clear_error(source)
|
||||||
|
source.save(update_fields=['last_refreshed_at', 'last_error', 'last_error_at', 'error_count',
|
||||||
|
'http_etag', 'http_last_modified'])
|
||||||
|
return []
|
||||||
|
parsed = result.parsed
|
||||||
|
feed = parsed.feed
|
||||||
|
if feed.get('title') and (not source.title or source.title == source.url):
|
||||||
|
source.title = fetch.short_title(fetch.plain(feed.title))[:300]
|
||||||
|
if feed.get('link'):
|
||||||
|
source.site_url = feed.link[:1000]
|
||||||
|
if not source.icon_url:
|
||||||
|
icon = (feed.get('image') or {}).get('href') or feed.get('icon') or fetch.site_icon(source.site_url)
|
||||||
|
source.icon_url = (icon or '')[:1000]
|
||||||
|
if result.permanent_url and result.permanent_url != source.url and \
|
||||||
|
not Source.objects.filter(user=source.user, url=result.permanent_url).exists():
|
||||||
|
source.url = result.permanent_url[:1000]
|
||||||
|
source.http_etag = result.etag
|
||||||
|
source.http_last_modified = result.last_modified
|
||||||
|
source.http_body_hash = result.body_hash
|
||||||
|
source.last_refreshed_at = now
|
||||||
|
_clear_error(source)
|
||||||
|
first = not source.items.exists()
|
||||||
|
source.save()
|
||||||
|
|
||||||
|
known = set(source.items.values_list('guid_hash', flat=True))
|
||||||
|
oldest_kept = None
|
||||||
|
if source.kind == Source.BLOG and len(known) >= BLOG_KEEP:
|
||||||
|
oldest_kept = source.items.order_by('published').values_list('published', flat=True).first()
|
||||||
|
news_cutoff = now - timedelta(days=keep_days)
|
||||||
|
new = []
|
||||||
|
entries = [fetch.parse_entry(e, source.url) for e in parsed.entries]
|
||||||
|
# Some feeds (Deutschlandfunk) give every entry the time the feed was made:
|
||||||
|
# then the dates say nothing, and the time an entry first shows up counts.
|
||||||
|
stamps = Counter(f['published'] for f in entries)
|
||||||
|
bogus_dates = len(entries) >= 5 and stamps.most_common(1)[0][1] >= 0.8 * len(entries)
|
||||||
|
for fields in entries:
|
||||||
|
if bogus_dates:
|
||||||
|
fields['published'] = now
|
||||||
|
if fields['guid_hash'] in known:
|
||||||
|
continue
|
||||||
|
known.add(fields['guid_hash'])
|
||||||
|
if source.kind == Source.NEWS and fields['published'] < news_cutoff:
|
||||||
|
continue
|
||||||
|
if oldest_kept and fields['published'] < oldest_kept:
|
||||||
|
continue
|
||||||
|
item = Item(source=source, user=source.user, kind=source.kind, **fields)
|
||||||
|
if source.kind == Source.BLOG and first and fields['published'] < now - timedelta(days=BLOG_FIRST_UNREAD_DAYS):
|
||||||
|
item.read_at = now
|
||||||
|
new.append(item)
|
||||||
|
if new:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
Item.objects.bulk_create(new)
|
||||||
|
except IntegrityError:
|
||||||
|
# Two entries of one feed with the same id: one by one, skipping the doubles.
|
||||||
|
kept = []
|
||||||
|
for item in new:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
item.save()
|
||||||
|
kept.append(item)
|
||||||
|
except IntegrityError:
|
||||||
|
pass
|
||||||
|
new = kept
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
def needs_article(item, source):
|
||||||
|
if not item.url or item.content_full or item.fulltext_failed:
|
||||||
|
return False
|
||||||
|
if source.fulltext == Source.FULLTEXT_NEVER:
|
||||||
|
return False
|
||||||
|
return source.fulltext == Source.FULLTEXT_ALWAYS or fetch.is_teaser(item.content)
|
||||||
|
|
||||||
|
|
||||||
|
def load_article(item):
|
||||||
|
"""Fetch the article for one item now (when opened); saves it."""
|
||||||
|
got = fetch.fetch_article(item.url)
|
||||||
|
_store_article(item, got)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _store_article(item, got):
|
||||||
|
if got is None:
|
||||||
|
item.fulltext_failed = True
|
||||||
|
item.save(update_fields=['fulltext_failed'])
|
||||||
|
return
|
||||||
|
content, image = got
|
||||||
|
if len(fetch.plain(content)) <= len(fetch.plain(item.content)):
|
||||||
|
item.fulltext_failed = True # the feed had more
|
||||||
|
item.save(update_fields=['fulltext_failed'])
|
||||||
|
return
|
||||||
|
item.content = content
|
||||||
|
item.content_full = True
|
||||||
|
if not item.image_url and image:
|
||||||
|
item.image_url = image[:2000]
|
||||||
|
item.save(update_fields=['content', 'content_full', 'image_url'])
|
||||||
|
|
||||||
|
|
||||||
|
def _articles(items, deadline):
|
||||||
|
todo = [i for i in items if needs_article(i, i.source)][:ARTICLES_PER_RUN]
|
||||||
|
if not todo:
|
||||||
|
return
|
||||||
|
pool = ThreadPoolExecutor(ARTICLE_WORKERS)
|
||||||
|
futures = {pool.submit(fetch.fetch_article, i.url): i for i in todo}
|
||||||
|
pending = set(futures)
|
||||||
|
try:
|
||||||
|
while pending:
|
||||||
|
left = None if deadline is None else deadline - time.monotonic()
|
||||||
|
if left is not None and left <= 0:
|
||||||
|
break
|
||||||
|
done, pending = wait(pending, timeout=left, return_when=FIRST_COMPLETED)
|
||||||
|
for f in done:
|
||||||
|
try:
|
||||||
|
_store_article(futures[f], f.result())
|
||||||
|
except Exception:
|
||||||
|
log.exception('article for item %s', futures[f].pk)
|
||||||
|
finally:
|
||||||
|
pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
|
|
||||||
|
def expire(user, now=None):
|
||||||
|
"""News past the user's keep time loses its text (rows stay GONE_KEEP_DAYS
|
||||||
|
as markers), stories without visible items go."""
|
||||||
|
now = now or timezone.now()
|
||||||
|
keep = settings_for(user).news_keep_days
|
||||||
|
Item.objects.filter(user=user, kind=Source.NEWS, gone=False, starred=False,
|
||||||
|
published__lt=now - timedelta(days=keep)).update(
|
||||||
|
gone=True, content='', summary='', story=None)
|
||||||
|
Item.objects.filter(user=user, gone=True, published__lt=now - timedelta(days=GONE_KEEP_DAYS)).delete()
|
||||||
|
Story.objects.filter(user=user).exclude(items__gone=False).delete()
|
||||||
|
# Blogs: the oldest read articles beyond BLOG_KEEP per blog.
|
||||||
|
for source in Source.objects.filter(user=user, kind=Source.BLOG):
|
||||||
|
ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
|
||||||
|
if ids:
|
||||||
|
Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete()
|
||||||
|
|
||||||
|
|
||||||
|
def refresh(sources, deadline=None):
|
||||||
|
"""Refresh `sources` (downloads in parallel, writes one by one).
|
||||||
|
Returns (refreshed, failed, new items)."""
|
||||||
|
sources = list(sources)
|
||||||
|
now = timezone.now()
|
||||||
|
keep = {}
|
||||||
|
refreshed = failed = 0
|
||||||
|
new_items = []
|
||||||
|
pool = ThreadPoolExecutor(FEED_WORKERS)
|
||||||
|
try:
|
||||||
|
futures = {
|
||||||
|
pool.submit(_fetch_feed, s.url, s.http_etag, s.http_last_modified, s.http_body_hash,
|
||||||
|
user_agent=fetch.USER_AGENT): s
|
||||||
|
for s in sources
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
timeout = None if deadline is None else max(0.0, deadline - time.monotonic())
|
||||||
|
for f in as_completed(futures, timeout=timeout):
|
||||||
|
s = futures[f]
|
||||||
|
try:
|
||||||
|
result = f.result()
|
||||||
|
if s.user_id not in keep:
|
||||||
|
keep[s.user_id] = settings_for(s.user).news_keep_days
|
||||||
|
new_items += _apply(s, result, now, keep[s.user_id])
|
||||||
|
refreshed += 1
|
||||||
|
except Exception as e:
|
||||||
|
if not isinstance(e, FeedFetchError):
|
||||||
|
log.exception('feed source %s', s.pk)
|
||||||
|
s.last_error = _error_text(e)
|
||||||
|
s.last_error_at = now
|
||||||
|
s.error_count += 1
|
||||||
|
s.save(update_fields=['last_error', 'last_error_at', 'error_count'])
|
||||||
|
failed += 1
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
# Past the deadline: don't wait for the slow ones (they stay as they are).
|
||||||
|
pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
|
for item in new_items:
|
||||||
|
item.source = next(s for s in sources if s.pk == item.source_id)
|
||||||
|
_articles(new_items, deadline)
|
||||||
|
|
||||||
|
by_user = {}
|
||||||
|
for item in new_items:
|
||||||
|
if item.kind == Source.NEWS:
|
||||||
|
by_user.setdefault(item.user_id, []).append(item)
|
||||||
|
for user_id, items in by_user.items():
|
||||||
|
c = Clusterer(items[0].user, now)
|
||||||
|
for item in sorted(items, key=lambda i: i.published):
|
||||||
|
c.add(item)
|
||||||
|
for user_id in {s.user_id for s in sources}:
|
||||||
|
expire(next(s.user for s in sources if s.user_id == user_id), now)
|
||||||
|
return refreshed, failed, len(new_items)
|
||||||
192
feeds/tests.py
Normal file
192
feeds/tests.py
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from podcasts.views import FeedFetch
|
||||||
|
|
||||||
|
from . import cluster, fetch, refresh
|
||||||
|
from .models import FeedSettings, Item, Source, Story
|
||||||
|
|
||||||
|
|
||||||
|
def rss(items, title='Testfeed', link='https://example.org/'):
|
||||||
|
body = ''.join(
|
||||||
|
f'<item><guid>{i["guid"]}</guid><title>{i["title"]}</title><link>{i.get("link", "https://example.org/" + i["guid"])}</link>'
|
||||||
|
f'<pubDate>{i.get("date", timezone.now().strftime("%a, %d %b %Y %H:%M:%S +0000"))}</pubDate>'
|
||||||
|
f'<description><![CDATA[{i.get("text", "")}]]></description></item>'
|
||||||
|
for i in items)
|
||||||
|
return f'<?xml version="1.0"?><rss version="2.0"><channel><title>{title}</title><link>{link}</link>{body}</channel></rss>'.encode()
|
||||||
|
|
||||||
|
|
||||||
|
def fetched(content, url='https://example.org/feed'):
|
||||||
|
return FeedFetch(200, feedparser.parse(content), 'etag', '', url, url, 'hash')
|
||||||
|
|
||||||
|
|
||||||
|
LONG = '<p>' + 'Ein langer Absatz mit sehr viel Text. ' * 40 + '</p>'
|
||||||
|
|
||||||
|
|
||||||
|
class FetchTest(TestCase):
|
||||||
|
def test_relative_links_become_absolute_and_scripts_go(self):
|
||||||
|
out = fetch.clean_html('<p>Hi <a href="/a">x</a><img src="i.jpg"><script>bad()</script>'
|
||||||
|
'<img data-src="/real.jpg" src="placeholder.gif"></p>', 'https://ex.org/post/1')
|
||||||
|
self.assertIn('href="https://ex.org/a"', out)
|
||||||
|
self.assertIn('src="https://ex.org/post/i.jpg"', out)
|
||||||
|
self.assertIn('src="https://ex.org/real.jpg"', out)
|
||||||
|
self.assertNotIn('placeholder', out)
|
||||||
|
self.assertNotIn('bad()', out)
|
||||||
|
|
||||||
|
def test_parse_entry(self):
|
||||||
|
e = feedparser.parse(rss([{'guid': 'g1', 'title': 'A & B', 'text': '<p>Kurz <img src="/p.jpg"></p>'}])).entries[0]
|
||||||
|
f = fetch.parse_entry(e, 'https://example.org/feed')
|
||||||
|
self.assertEqual(f['title'], 'A & B')
|
||||||
|
self.assertEqual(f['image_url'], 'https://example.org/p.jpg')
|
||||||
|
self.assertTrue(fetch.is_teaser(f['content']))
|
||||||
|
|
||||||
|
def test_drop_repeated_headline(self):
|
||||||
|
self.assertEqual(fetch._drop_title('<img src="x"><h1>Titel</h1><p>Text</p>'), '<img src="x"><p>Text</p>')
|
||||||
|
|
||||||
|
def test_short_title(self):
|
||||||
|
self.assertEqual(fetch.short_title('tagesschau.de - die erste Adresse für Nachrichten'), 'tagesschau.de')
|
||||||
|
self.assertEqual(fetch.short_title('Aktuell - FAZ.NET'), 'FAZ.NET')
|
||||||
|
self.assertEqual(fetch.short_title('DIE ZEIT | Nachrichten, News, Hintergründe'), 'DIE ZEIT')
|
||||||
|
self.assertEqual(fetch.short_title('Golem.de'), 'Golem.de')
|
||||||
|
self.assertEqual(fetch.short_title('Kotlin-Blog'), 'Kotlin-Blog')
|
||||||
|
|
||||||
|
def test_discover(self):
|
||||||
|
feeds, icon = fetch.discover('<link rel="alternate" type="application/rss+xml" href="/feed" title="F">'
|
||||||
|
'<link rel="icon" href="/fav.png">', 'https://ex.org/')
|
||||||
|
self.assertEqual(feeds, [('https://ex.org/feed', 'F')])
|
||||||
|
self.assertEqual(icon, 'https://ex.org/fav.png')
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.u = User.objects.create_user('u', password='pw12345678')
|
||||||
|
self.a = Source.objects.create(user=self.u, url='https://a/', title='A')
|
||||||
|
self.b = Source.objects.create(user=self.u, url='https://b/', title='B')
|
||||||
|
self.c = Source.objects.create(user=self.u, url='https://c/', title='C')
|
||||||
|
|
||||||
|
def item(self, source, title, summary=''):
|
||||||
|
return Item.objects.create(source=source, user=self.u, kind='news', guid_hash=title[:60],
|
||||||
|
title=title, summary=summary, published=timezone.now())
|
||||||
|
|
||||||
|
def test_same_event_bundled_other_apart(self):
|
||||||
|
c = cluster.Clusterer(self.u, timezone.now())
|
||||||
|
s1 = c.add(self.item(self.a, 'Oktoberfest endet mit neuem Besucherrekord in München'))
|
||||||
|
s2 = c.add(self.item(self.b, 'Rekord-Wiesn: 7,4 Millionen Besucher auf dem Oktoberfest in München'))
|
||||||
|
s3 = c.add(self.item(self.c, 'Bahn führt Familienticket dauerhaft ein'))
|
||||||
|
self.assertEqual(s1, s2)
|
||||||
|
self.assertNotEqual(s1, s3)
|
||||||
|
s1.refresh_from_db()
|
||||||
|
self.assertEqual((s1.size, s1.sources), (2, 2))
|
||||||
|
|
||||||
|
def test_same_source_needs_to_be_very_close(self):
|
||||||
|
c = cluster.Clusterer(self.u, timezone.now())
|
||||||
|
s1 = c.add(self.item(self.a, 'Nations League: Pressekonferenz vor Irland gegen Israel abgebrochen'))
|
||||||
|
s2 = c.add(self.item(self.a, 'Nations League: England schlägt Kroatien deutlich'))
|
||||||
|
self.assertNotEqual(s1, s2)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.u = User.objects.create_user('tester', password='pw12345678')
|
||||||
|
self.client.force_login(self.u)
|
||||||
|
|
||||||
|
def post(self, url, data=None):
|
||||||
|
return self.client.post(url, json.dumps(data or {}), content_type='application/json')
|
||||||
|
|
||||||
|
def add(self, kind, feed, url='https://example.org/feed'):
|
||||||
|
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(feed, url)), \
|
||||||
|
mock.patch('feeds.fetch.fetch_article', return_value=(LONG, 'https://example.org/img.jpg')):
|
||||||
|
r = self.post('/feeds/sources/add/', {'url': url, 'kind': kind, 'category': 'Politik'})
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
return r.json()['source']
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(self.client.get('/feeds/state/').status_code, 401)
|
||||||
|
|
||||||
|
def test_add_news_fetches_articles_and_bundles(self):
|
||||||
|
s = self.add('news', rss([{'guid': 'a', 'title': 'Merz besucht Kiew und sagt Hilfe zu', 'text': 'Kurz.'},
|
||||||
|
{'guid': 'b', 'title': 'Bahn Familienticket dauerhaft', 'text': 'Kurz.'}]))
|
||||||
|
self.assertEqual(s['title'], 'Testfeed')
|
||||||
|
self.assertEqual(s['unread'], 2)
|
||||||
|
items = Item.objects.filter(user=self.u)
|
||||||
|
self.assertTrue(all(i.content_full for i in items))
|
||||||
|
self.assertEqual(Story.objects.filter(user=self.u).count(), 2)
|
||||||
|
news = self.client.get('/feeds/news/').json()
|
||||||
|
self.assertEqual(len(news['stories']), 2)
|
||||||
|
self.assertEqual(news['stories'][0]['category'], 'Politik')
|
||||||
|
b = self.client.get('/feeds/briefing/').json()
|
||||||
|
self.assertEqual(b['total'], 2)
|
||||||
|
self.assertEqual(b['sections'][0]['category'], 'Politik')
|
||||||
|
self.post('/feeds/briefing/seen/')
|
||||||
|
self.assertFalse(Item.objects.filter(user=self.u, read_at=None).exists())
|
||||||
|
self.assertEqual(self.client.get('/feeds/briefing/').json()['total'], 0)
|
||||||
|
|
||||||
|
def test_blog_keeps_full_feed_text_and_lists(self):
|
||||||
|
self.add('blog', rss([{'guid': 'p1', 'title': 'Ein Artikel', 'text': LONG}]))
|
||||||
|
item = Item.objects.get(user=self.u)
|
||||||
|
self.assertFalse(item.content_full) # the feed had the whole text
|
||||||
|
self.assertIsNone(item.story)
|
||||||
|
blogs = self.client.get('/feeds/blogs/?unread=1').json()
|
||||||
|
self.assertEqual([i['title'] for i in blogs['items']], ['Ein Artikel'])
|
||||||
|
detail = self.client.get(f'/feeds/items/{item.id}/').json()['item']
|
||||||
|
self.assertIn('langer Absatz', detail['content'])
|
||||||
|
self.post('/feeds/items/read/', {'ids': [item.id]})
|
||||||
|
self.assertEqual(self.client.get('/feeds/blogs/?unread=1').json()['items'], [])
|
||||||
|
self.post(f'/feeds/items/{item.id}/star/', {'starred': True})
|
||||||
|
self.assertEqual(len(self.client.get('/feeds/starred/').json()['items']), 1)
|
||||||
|
|
||||||
|
def test_old_news_expires_and_is_not_taken_in_again(self):
|
||||||
|
old = (timezone.now() - timedelta(days=3)).strftime('%a, %d %b %Y %H:%M:%S +0000')
|
||||||
|
feed = rss([{'guid': 'new', 'title': 'Heute passiert', 'text': LONG},
|
||||||
|
{'guid': 'old', 'title': 'Vorgestern passiert', 'text': LONG, 'date': old}])
|
||||||
|
src = self.add('news', feed)
|
||||||
|
self.assertEqual(Item.objects.filter(user=self.u).count(), 1) # too old to take in
|
||||||
|
Item.objects.filter(user=self.u).update(published=timezone.now() - timedelta(days=3))
|
||||||
|
refresh.expire(self.u)
|
||||||
|
self.assertEqual(Item.objects.filter(user=self.u, gone=False).count(), 0)
|
||||||
|
self.assertEqual(Story.objects.filter(user=self.u).count(), 0)
|
||||||
|
source = Source.objects.get(pk=src['id'])
|
||||||
|
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(feed)):
|
||||||
|
refresh.refresh([source])
|
||||||
|
self.assertEqual(Item.objects.filter(user=self.u, gone=False).count(), 0)
|
||||||
|
|
||||||
|
def test_feed_wide_timestamp_means_first_seen(self):
|
||||||
|
stamp = (timezone.now() - timedelta(hours=5)).strftime('%a, %d %b %Y %H:%M:%S +0000')
|
||||||
|
self.add('news', rss([{'guid': f'g{n}', 'title': f'Meldung {n}', 'text': LONG, 'date': stamp} for n in range(6)]))
|
||||||
|
for i in Item.objects.filter(user=self.u):
|
||||||
|
self.assertLess((timezone.now() - i.published).total_seconds(), 60)
|
||||||
|
|
||||||
|
def test_switching_kind_moves_items(self):
|
||||||
|
src = self.add('news', rss([{'guid': 'a', 'title': 'Etwas', 'text': LONG}]))
|
||||||
|
self.post(f'/feeds/sources/{src["id"]}/', {'kind': 'blog'})
|
||||||
|
item = Item.objects.get(user=self.u)
|
||||||
|
self.assertEqual((item.kind, item.story), ('blog', None))
|
||||||
|
self.assertEqual(Story.objects.filter(user=self.u).count(), 0)
|
||||||
|
|
||||||
|
def test_other_users_items_invisible(self):
|
||||||
|
self.add('blog', rss([{'guid': 'p1', 'title': 'Privat', 'text': LONG}]))
|
||||||
|
item = Item.objects.get(user=self.u)
|
||||||
|
other = User.objects.create_user('o', password='pw12345678')
|
||||||
|
self.client.force_login(other)
|
||||||
|
self.assertEqual(self.client.get(f'/feeds/items/{item.id}/').status_code, 404)
|
||||||
|
self.assertEqual(self.client.get('/feeds/blogs/').json()['items'], [])
|
||||||
|
self.post('/feeds/items/read/', {'ids': [item.id]})
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertIsNone(item.read_at)
|
||||||
|
|
||||||
|
def test_opml_export(self):
|
||||||
|
self.add('blog', rss([]), url='https://blog.example/feed')
|
||||||
|
r = self.client.get('/feeds/export.opml')
|
||||||
|
self.assertIn(b'xmlUrl="https://blog.example/feed"', r.content)
|
||||||
|
self.assertIn(b'text="Blogs"', r.content)
|
||||||
|
|
||||||
|
def test_settings(self):
|
||||||
|
self.post('/feeds/settings/', {'news_keep_days': 5})
|
||||||
|
self.assertEqual(FeedSettings.objects.get(user=self.u).news_keep_days, 5)
|
||||||
24
feeds/urls.py
Normal file
24
feeds/urls.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('state/', views.state, name='feeds_state'),
|
||||||
|
path('discover/', views.discover, name='feeds_discover'),
|
||||||
|
path('sources/add/', views.add_source, name='feeds_add_source'),
|
||||||
|
path('sources/bulk/', views.add_sources, name='feeds_add_sources'),
|
||||||
|
path('sources/<int:pk>/', views.edit_source, name='feeds_edit_source'),
|
||||||
|
path('sources/<int:pk>/delete/', views.delete_source, name='feeds_delete_source'),
|
||||||
|
path('export.opml', views.export_opml, name='feeds_export_opml'),
|
||||||
|
path('refresh/', views.refresh_now, name='feeds_refresh'),
|
||||||
|
path('briefing/', views.briefing, name='feeds_briefing'),
|
||||||
|
path('briefing/seen/', views.briefing_seen, name='feeds_briefing_seen'),
|
||||||
|
path('news/', views.news, name='feeds_news'),
|
||||||
|
path('stories/<int:pk>/', views.story, name='feeds_story'),
|
||||||
|
path('blogs/', views.blogs, name='feeds_blogs'),
|
||||||
|
path('starred/', views.starred, name='feeds_starred'),
|
||||||
|
path('items/read/', views.mark_read, name='feeds_mark_read'),
|
||||||
|
path('items/<int:pk>/', views.item, name='feeds_item'),
|
||||||
|
path('items/<int:pk>/star/', views.star, name='feeds_star'),
|
||||||
|
path('settings/', views.settings, name='feeds_settings'),
|
||||||
|
]
|
||||||
445
feeds/views.py
Normal file
445
feeds/views.py
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
"""The feed reader's JSON API (session or Bearer token, like the podcast API).
|
||||||
|
|
||||||
|
Sources: GET /feeds/state/ sources, unread counts, settings
|
||||||
|
POST /feeds/discover/ {url} -> the feeds a page or URL offers
|
||||||
|
POST /feeds/sources/add/ {url, kind, category, title?}
|
||||||
|
POST /feeds/sources/bulk/ {sources: [{url, title, kind, category}]} (OPML import)
|
||||||
|
POST /feeds/sources/<id>/ {title, kind, category, fulltext}
|
||||||
|
POST /feeds/sources/<id>/delete/
|
||||||
|
GET /feeds/export.opml
|
||||||
|
POST /feeds/refresh/ refresh the account's sources now
|
||||||
|
News: GET /feeds/briefing/ what's new since the briefing was last seen
|
||||||
|
POST /feeds/briefing/seen/ seen: everything up to now counts as read
|
||||||
|
GET /feeds/news/?before=&category= stories, newest first
|
||||||
|
GET /feeds/stories/<id>/ one story with all its items
|
||||||
|
Blogs: GET /feeds/blogs/?source=&unread=1&before=
|
||||||
|
Items: GET /feeds/items/<id>/ the article (fetches the page if the feed had a teaser)
|
||||||
|
POST /feeds/items/read/ {ids, read}
|
||||||
|
POST /feeds/items/<id>/star/ {starred}
|
||||||
|
GET /feeds/starred/
|
||||||
|
Settings: POST /feeds/settings/ {news_keep_days}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
from xml.sax.saxutils import quoteattr
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
import requests
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from . import fetch, refresh
|
||||||
|
from .models import Item, Source, Story
|
||||||
|
|
||||||
|
PAGE = 40
|
||||||
|
BRIEFING_TOP = 8
|
||||||
|
BRIEFING_PER_CATEGORY = 5
|
||||||
|
REFRESH_WAIT = 40 # seconds an explicit refresh may take
|
||||||
|
CATEGORY_NONE = 'Allgemein'
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _body(request):
|
||||||
|
try:
|
||||||
|
return json.loads(request.body or b'{}')
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(dt):
|
||||||
|
return dt.isoformat() if dt else None
|
||||||
|
|
||||||
|
|
||||||
|
def _source_data(s, unread=0):
|
||||||
|
return {
|
||||||
|
'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url,
|
||||||
|
'kind': s.kind, 'category': s.category, 'fulltext': s.fulltext, 'unread': unread,
|
||||||
|
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _item_data(i, content=False):
|
||||||
|
d = {
|
||||||
|
'id': i.id, 'source': i.source_id, 'source_title': i.source.title, 'kind': i.kind,
|
||||||
|
'url': i.url, 'title': i.title, 'author': i.author, 'published': _iso(i.published),
|
||||||
|
'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None,
|
||||||
|
'starred': i.starred, 'story': i.story_id,
|
||||||
|
}
|
||||||
|
if content:
|
||||||
|
d['content'] = i.content
|
||||||
|
d['content_full'] = i.content_full
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _story_data(s, items):
|
||||||
|
items = sorted(items, key=lambda i: i.published)
|
||||||
|
lead = next((i for i in items if i.id == s.lead_id), items[0] if items else None)
|
||||||
|
return {
|
||||||
|
'id': s.id, 'first_at': _iso(s.first_at), 'last_at': _iso(s.last_at),
|
||||||
|
'size': len(items), 'sources': len({i.source_id for i in items}),
|
||||||
|
'category': (lead.source.category if lead else '') or CATEGORY_NONE,
|
||||||
|
'unread': sum(1 for i in items if i.read_at is None),
|
||||||
|
'lead': _item_data(lead) if lead else None,
|
||||||
|
'items': [_item_data(i) for i in items],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stories(user, qs):
|
||||||
|
"""Story dicts for a Story queryset (items fetched in one go)."""
|
||||||
|
stories = list(qs)
|
||||||
|
items = {}
|
||||||
|
for i in Item.objects.filter(story__in=stories, gone=False).select_related('source'):
|
||||||
|
items.setdefault(i.story_id, []).append(i)
|
||||||
|
return [_story_data(s, items[s.id]) for s in stories if s.id in items]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- sources
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def state(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
unread = dict(Item.objects.filter(user=request.user, gone=False, read_at=None)
|
||||||
|
.values_list('source').annotate(n=Count('id')))
|
||||||
|
sources = list(Source.objects.filter(user=request.user))
|
||||||
|
s = refresh.settings_for(request.user)
|
||||||
|
since = s.news_seen_at or (timezone.now() - timedelta(days=1))
|
||||||
|
new_news = Story.objects.filter(user=request.user, last_at__gt=since,
|
||||||
|
items__gone=False, items__read_at=None).distinct().count()
|
||||||
|
return JsonResponse({
|
||||||
|
'sources': [_source_data(x, unread.get(x.id, 0)) for x in sources],
|
||||||
|
'categories': sorted({x.category for x in sources if x.kind == Source.NEWS and x.category}),
|
||||||
|
'news_new': new_news,
|
||||||
|
'blogs_unread': sum(n for sid, n in unread.items() if any(x.id == sid and x.kind == Source.BLOG for x in sources)),
|
||||||
|
'settings': {'news_keep_days': s.news_keep_days, 'news_seen_at': _iso(s.news_seen_at)},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(url):
|
||||||
|
"""(feed url, title, entries) if `url` is a feed, else ([candidate feeds], icon)."""
|
||||||
|
r = requests.get(url, timeout=fetch.PAGE_TIMEOUT, headers={'User-Agent': fetch.USER_AGENT})
|
||||||
|
r.raise_for_status()
|
||||||
|
ctype = r.headers.get('Content-Type', '')
|
||||||
|
if 'html' in ctype and b'<rss' not in r.content[:2000] and b'<feed' not in r.content[:2000]:
|
||||||
|
return None, fetch.discover(r.text, r.url)
|
||||||
|
parsed = feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
|
||||||
|
if parsed.get('bozo') and not parsed.entries and not parsed.feed.get('title'):
|
||||||
|
return None, ([], '')
|
||||||
|
return (r.url, fetch.short_title(fetch.plain(parsed.feed.get('title') or '')) or r.url, parsed), None
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def discover(request):
|
||||||
|
"""The feeds behind a URL: itself if it's a feed, else the ones the page links to."""
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
body = _body(request) or {}
|
||||||
|
url = (body.get('url') or '').strip()
|
||||||
|
if not url:
|
||||||
|
return JsonResponse({'error': 'Adresse fehlt'}, status=400)
|
||||||
|
if not url.startswith(('http://', 'https://')):
|
||||||
|
url = 'https://' + url
|
||||||
|
try:
|
||||||
|
feed, page = _probe(url)
|
||||||
|
if feed is None:
|
||||||
|
candidates, _icon = page
|
||||||
|
found = []
|
||||||
|
for u, title in candidates[:6]:
|
||||||
|
try:
|
||||||
|
f, _ = _probe(u)
|
||||||
|
except requests.RequestException:
|
||||||
|
continue
|
||||||
|
if f:
|
||||||
|
found.append(f)
|
||||||
|
if not found:
|
||||||
|
return JsonResponse({'error': 'Dort ist kein Feed zu finden.'}, status=404)
|
||||||
|
else:
|
||||||
|
found = [feed]
|
||||||
|
except requests.RequestException:
|
||||||
|
return JsonResponse({'error': 'Die Adresse ist nicht erreichbar.'}, status=502)
|
||||||
|
out = []
|
||||||
|
for u, title, parsed in found:
|
||||||
|
entries = parsed.entries[:5]
|
||||||
|
teaser = sum(1 for e in entries if fetch.is_teaser(fetch.parse_entry(e, u)['content']))
|
||||||
|
out.append({
|
||||||
|
'url': u, 'title': title,
|
||||||
|
'subscribed': Source.objects.filter(user=request.user, url=u).exists(),
|
||||||
|
'samples': [fetch.plain(e.get('title') or '') for e in entries],
|
||||||
|
# Many entries a day suggests news, few long ones a blog.
|
||||||
|
'suggested_kind': Source.NEWS if len(parsed.entries) >= 25 or teaser >= 3 else Source.BLOG,
|
||||||
|
})
|
||||||
|
return JsonResponse({'feeds': out})
|
||||||
|
|
||||||
|
|
||||||
|
def _kind(v):
|
||||||
|
return v if v in (Source.NEWS, Source.BLOG) else None
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def add_source(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
body = _body(request) or {}
|
||||||
|
url = (body.get('url') or '').strip()
|
||||||
|
kind = _kind(body.get('kind'))
|
||||||
|
if not url.startswith(('http://', 'https://')) or not kind:
|
||||||
|
return JsonResponse({'error': 'Adresse und Art (Nachrichten/Blog) nötig'}, status=400)
|
||||||
|
source, created = Source.objects.get_or_create(
|
||||||
|
user=request.user, url=url[:1000],
|
||||||
|
defaults={'title': (body.get('title') or url)[:300], 'kind': kind,
|
||||||
|
'category': (body.get('category') or '').strip()[:100]})
|
||||||
|
if created:
|
||||||
|
refresh.refresh([source], deadline=time.monotonic() + 25)
|
||||||
|
source.refresh_from_db()
|
||||||
|
return JsonResponse({'source': _source_data(source, source.items.filter(read_at=None, gone=False).count()),
|
||||||
|
'created': created})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def add_sources(request):
|
||||||
|
"""Many at once (an OPML file the page read); refreshed by the next run."""
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
body = _body(request) or {}
|
||||||
|
added = 0
|
||||||
|
for x in body.get('sources') or []:
|
||||||
|
url = (x.get('url') or '').strip()
|
||||||
|
kind = _kind(x.get('kind'))
|
||||||
|
if not url.startswith(('http://', 'https://')) or not kind:
|
||||||
|
continue
|
||||||
|
_, created = Source.objects.get_or_create(
|
||||||
|
user=request.user, url=url[:1000],
|
||||||
|
defaults={'title': (x.get('title') or url)[:300], 'kind': kind,
|
||||||
|
'category': (x.get('category') or '').strip()[:100]})
|
||||||
|
added += created
|
||||||
|
sources = Source.objects.filter(user=request.user, last_refreshed_at=None)
|
||||||
|
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
|
||||||
|
return JsonResponse({'added': added})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def edit_source(request, pk):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
source = Source.objects.filter(user=request.user, pk=pk).first()
|
||||||
|
body = _body(request)
|
||||||
|
if source is None or body is None:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
if 'title' in body and (body['title'] or '').strip():
|
||||||
|
source.title = body['title'].strip()[:300]
|
||||||
|
if 'category' in body:
|
||||||
|
source.category = (body['category'] or '').strip()[:100]
|
||||||
|
if body.get('fulltext') in dict(Source.FULLTEXTS):
|
||||||
|
source.fulltext = body['fulltext']
|
||||||
|
new_kind = _kind(body.get('kind'))
|
||||||
|
if new_kind and new_kind != source.kind:
|
||||||
|
source.kind = new_kind
|
||||||
|
# Its items follow; as blog articles they leave their stories.
|
||||||
|
source.items.update(kind=new_kind, **({'story': None} if new_kind == Source.BLOG else {}))
|
||||||
|
Story.objects.filter(user=request.user).exclude(items__gone=False).delete()
|
||||||
|
source.save()
|
||||||
|
return JsonResponse({'source': _source_data(source)})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def delete_source(request, pk):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
Source.objects.filter(user=request.user, pk=pk).delete()
|
||||||
|
Story.objects.filter(user=request.user).exclude(items__gone=False).delete()
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def export_opml(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
groups = {}
|
||||||
|
for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'):
|
||||||
|
label = ('Nachrichten' + (f' – {s.category}' if s.category else '')) if s.kind == Source.NEWS else 'Blogs'
|
||||||
|
groups.setdefault(label, []).append(s)
|
||||||
|
lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<opml version="2.0">',
|
||||||
|
'<head><title>diora Feeds</title></head>', '<body>']
|
||||||
|
for label, sources in groups.items():
|
||||||
|
lines.append(f'<outline text={quoteattr(label)}>')
|
||||||
|
for s in sources:
|
||||||
|
lines.append(f'<outline type="rss" text={quoteattr(s.title)} xmlUrl={quoteattr(s.url)}'
|
||||||
|
+ (f' htmlUrl={quoteattr(s.site_url)}' if s.site_url else '') + '/>')
|
||||||
|
lines.append('</outline>')
|
||||||
|
lines += ['</body>', '</opml>']
|
||||||
|
resp = HttpResponse('\n'.join(lines), content_type='text/x-opml; charset=utf-8')
|
||||||
|
resp['Content-Disposition'] = 'attachment; filename="diora-feeds.opml"'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def refresh_now(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user),
|
||||||
|
deadline=time.monotonic() + REFRESH_WAIT)
|
||||||
|
return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- news
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def briefing(request):
|
||||||
|
"""Since the briefing was last seen (else the last day): the top stories
|
||||||
|
(reported by several sources), then the rest by section, newest first."""
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
s = refresh.settings_for(request.user)
|
||||||
|
since = s.news_seen_at or (timezone.now() - timedelta(days=1))
|
||||||
|
qs = Story.objects.filter(user=request.user, last_at__gt=since,
|
||||||
|
items__gone=False, items__read_at=None).distinct()
|
||||||
|
stories = _stories(request.user, qs)
|
||||||
|
stories.sort(key=lambda x: (-x['sources'], -x['size'], x['last_at']), reverse=False)
|
||||||
|
top = [x for x in stories if x['sources'] >= 2][:BRIEFING_TOP]
|
||||||
|
top_ids = {x['id'] for x in top}
|
||||||
|
sections = {}
|
||||||
|
for x in sorted(stories, key=lambda x: x['last_at'], reverse=True):
|
||||||
|
if x['id'] not in top_ids:
|
||||||
|
sections.setdefault(x['category'], []).append(x)
|
||||||
|
return JsonResponse({
|
||||||
|
'since': _iso(since), 'total': len(stories), 'top': top,
|
||||||
|
'sections': [{'category': c, 'count': len(v), 'stories': v[:BRIEFING_PER_CATEGORY]}
|
||||||
|
for c, v in sorted(sections.items(), key=lambda kv: (kv[0] == CATEGORY_NONE, kv[0]))],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def briefing_seen(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
now = timezone.now()
|
||||||
|
s = refresh.settings_for(request.user)
|
||||||
|
Item.objects.filter(user=request.user, kind=Source.NEWS, read_at=None, fetched_at__lte=now).update(read_at=now)
|
||||||
|
s.news_seen_at = now
|
||||||
|
s.save(update_fields=['news_seen_at'])
|
||||||
|
return JsonResponse({'news_seen_at': _iso(now)})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def news(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
qs = Story.objects.filter(user=request.user, items__gone=False).distinct().order_by('-last_at')
|
||||||
|
before = parse_datetime(request.GET.get('before') or '')
|
||||||
|
if before:
|
||||||
|
qs = qs.filter(last_at__lt=before)
|
||||||
|
category = request.GET.get('category')
|
||||||
|
if category:
|
||||||
|
qs = qs.filter(lead__source__category='' if category == CATEGORY_NONE else category)
|
||||||
|
stories = _stories(request.user, qs[:PAGE])
|
||||||
|
return JsonResponse({'stories': stories, 'more': len(stories) == PAGE})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def story(request, pk):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
found = _stories(request.user, Story.objects.filter(user=request.user, pk=pk))
|
||||||
|
if not found:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
return JsonResponse({'story': found[0]})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- blogs, items
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def blogs(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source')
|
||||||
|
if request.GET.get('source', '').isdigit():
|
||||||
|
qs = qs.filter(source_id=int(request.GET['source']))
|
||||||
|
if request.GET.get('unread') == '1':
|
||||||
|
qs = qs.filter(read_at=None)
|
||||||
|
before = parse_datetime(request.GET.get('before') or '')
|
||||||
|
if before:
|
||||||
|
qs = qs.filter(published__lt=before)
|
||||||
|
items = list(qs.order_by('-published')[:PAGE])
|
||||||
|
return JsonResponse({'items': [_item_data(i) for i in items], 'more': len(items) == PAGE})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def starred(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
items = Item.objects.filter(user=request.user, starred=True).select_related('source').order_by('-published')
|
||||||
|
return JsonResponse({'items': [_item_data(i) for i in items[:200]]})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def item(request, pk):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source').first()
|
||||||
|
if i is None:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
if refresh.needs_article(i, i.source):
|
||||||
|
refresh.load_article(i)
|
||||||
|
return JsonResponse({'item': _item_data(i, content=True)})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def mark_read(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
body = _body(request) or {}
|
||||||
|
ids = [int(x) for x in body.get('ids') or [] if str(x).isdigit()]
|
||||||
|
qs = Item.objects.filter(user=request.user, id__in=ids)
|
||||||
|
n = qs.update(read_at=timezone.now() if body.get('read', True) else None)
|
||||||
|
return JsonResponse({'updated': n})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def star(request, pk):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
body = _body(request) or {}
|
||||||
|
n = Item.objects.filter(user=request.user, pk=pk).update(starred=bool(body.get('starred', True)))
|
||||||
|
return JsonResponse({'updated': n})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def settings(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
body = _body(request) or {}
|
||||||
|
s = refresh.settings_for(request.user)
|
||||||
|
days = body.get('news_keep_days')
|
||||||
|
if isinstance(days, int) and 1 <= days <= 30:
|
||||||
|
s.news_keep_days = days
|
||||||
|
s.save(update_fields=['news_keep_days'])
|
||||||
|
return JsonResponse({'news_keep_days': s.news_keep_days})
|
||||||
|
|
@ -322,13 +322,13 @@ def _body_hash(content):
|
||||||
PERMANENT_REDIRECTS = (301, 308)
|
PERMANENT_REDIRECTS = (301, 308)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_feed(url, etag='', last_modified='', body_hash=''):
|
def _fetch_feed(url, etag='', last_modified='', body_hash='', user_agent='diora podcast refresh'):
|
||||||
"""Download and parse a feed (with a timeout, unlike feedparser's own
|
"""Download and parse a feed (with a timeout, unlike feedparser's own
|
||||||
fetch). With etag/last_modified it is a conditional GET; with body_hash a
|
fetch). With etag/last_modified it is a conditional GET; with body_hash a
|
||||||
body like the last one counts as unchanged too (a 304), unparsed —
|
body like the last one counts as unchanged too (a 304), unparsed —
|
||||||
parsing is what takes the time (seconds for a big feed). Returns a
|
parsing is what takes the time (seconds for a big feed). Returns a
|
||||||
FeedFetch; raises FeedFetchError when there is nothing usable."""
|
FeedFetch; raises FeedFetchError when there is nothing usable."""
|
||||||
headers = {'User-Agent': 'diora podcast refresh'}
|
headers = {'User-Agent': user_agent}
|
||||||
if etag:
|
if etag:
|
||||||
headers['If-None-Match'] = etag
|
headers['If-None-Match'] = etag
|
||||||
if last_modified:
|
if last_modified:
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,5 @@ packaging==26.3
|
||||||
piper-tts==1.7.0
|
piper-tts==1.7.0
|
||||||
# ffmpeg (a static binary in the wheel) finds the pauses for skipping silence in the browser.
|
# ffmpeg (a static binary in the wheel) finds the pauses for skipping silence in the browser.
|
||||||
imageio-ffmpeg==0.6.0
|
imageio-ffmpeg==0.6.0
|
||||||
|
# The feed reader takes the article out of a news page (main text without menus, ads, comments).
|
||||||
|
trafilatura==2.3.0
|
||||||
|
|
|
||||||
|
|
@ -1179,7 +1179,7 @@ async function editNotes(pk, current) {
|
||||||
// Tabs
|
// Tabs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'ambient'];
|
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient'];
|
||||||
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
|
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1238,6 +1238,7 @@ function showTab(name) {
|
||||||
if (name === 'books') loadBookList();
|
if (name === 'books') loadBookList();
|
||||||
if (name === 'ambient') _ambientLoad();
|
if (name === 'ambient') _ambientLoad();
|
||||||
if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js
|
if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js
|
||||||
|
if (name === 'feeds' && window.__dioraFeedsShow) window.__dioraFeedsShow(); // static/js/feeds.js
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
850
static/js/feeds.js
Normal file
850
static/js/feeds.js
Normal file
|
|
@ -0,0 +1,850 @@
|
||||||
|
/*
|
||||||
|
* The feed reader (the server side is the feeds app, /feeds/…): the "Feeds" tab
|
||||||
|
* with two parts that work differently on purpose —
|
||||||
|
* Nachrichten: stories (the same event from several sources is one card),
|
||||||
|
* the "Seit letztem Mal" briefing on top, old news simply goes away;
|
||||||
|
* Blogs: a plain list of unread articles, per blog if wanted.
|
||||||
|
* Articles open in the page (full text from the server), the browser only on request.
|
||||||
|
* Runs in the web page, the desktop app and both Android apps (in diora Reader,
|
||||||
|
* which has no tab bar, it brings a small Bücher/Feeds bar). Chrome-61-safe:
|
||||||
|
* the e-reader app transpiles it (compat61), so no ?. / ?? here.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const tab = document.getElementById('tab-feeds');
|
||||||
|
if (!tab) return;
|
||||||
|
const READER_APP = document.documentElement.classList.contains('nui-reader-app');
|
||||||
|
|
||||||
|
const st = {
|
||||||
|
view: 'news', // news | blogs | manage
|
||||||
|
state: null, // /feeds/state/
|
||||||
|
briefing: null,
|
||||||
|
stories: [], storiesMore: false,
|
||||||
|
category: '',
|
||||||
|
items: [], itemsMore: false,
|
||||||
|
blogSource: '', blogUnread: true,
|
||||||
|
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;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const opts = {credentials: 'same-origin', headers: {}};
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.method = 'POST';
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
let data = null;
|
||||||
|
try { data = await r.json(); } catch (e) {}
|
||||||
|
if (!r.ok) throw new Error((data && data.error) || ('Fehler ' + r.status));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- time
|
||||||
|
|
||||||
|
function when(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
const mins = Math.round((Date.now() - d.getTime()) / 60000);
|
||||||
|
if (mins < 1) return 'gerade eben';
|
||||||
|
if (mins < 60) return 'vor ' + mins + ' Min.';
|
||||||
|
if (mins < 6 * 60) return 'vor ' + Math.round(mins / 60) + ' Std.';
|
||||||
|
const hm = ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2);
|
||||||
|
const day = dayLabel(d);
|
||||||
|
return (day === 'Heute' ? '' : day + ', ') + hm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayLabel(d) {
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
|
const that = new Date(d.getTime()); that.setHours(0, 0, 0, 0);
|
||||||
|
const diff = Math.round((today - that) / 86400000);
|
||||||
|
if (diff === 0) return 'Heute';
|
||||||
|
if (diff === 1) return 'Gestern';
|
||||||
|
return d.toLocaleDateString('de-DE', {weekday: 'short', day: 'numeric', month: 'short'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- loading
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
st.state = await api('/feeds/state/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNews(more) {
|
||||||
|
const q = [];
|
||||||
|
if (more && st.stories.length) q.push('before=' + encodeURIComponent(st.stories[st.stories.length - 1].last_at));
|
||||||
|
if (st.category) q.push('category=' + encodeURIComponent(st.category));
|
||||||
|
const [news, briefing] = await Promise.all([
|
||||||
|
api('/feeds/news/' + (q.length ? '?' + q.join('&') : '')),
|
||||||
|
more ? Promise.resolve(st.briefing) : api('/feeds/briefing/'),
|
||||||
|
]);
|
||||||
|
st.stories = more ? st.stories.concat(news.stories) : news.stories;
|
||||||
|
st.storiesMore = news.more;
|
||||||
|
st.briefing = briefing;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBlogs(more) {
|
||||||
|
const q = [];
|
||||||
|
if (st.blogSource) q.push('source=' + st.blogSource);
|
||||||
|
if (st.blogUnread) q.push('unread=1');
|
||||||
|
if (more && st.items.length) q.push('before=' + encodeURIComponent(st.items[st.items.length - 1].published));
|
||||||
|
const data = await api('/feeds/blogs/' + (q.length ? '?' + q.join('&') : ''));
|
||||||
|
st.items = more ? st.items.concat(data.items) : data.items;
|
||||||
|
st.itemsMore = data.more;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(more) {
|
||||||
|
st.loading = true;
|
||||||
|
st.error = '';
|
||||||
|
render();
|
||||||
|
try {
|
||||||
|
if (!more) await loadState();
|
||||||
|
if (st.view === 'news') await loadNews(more);
|
||||||
|
else if (st.view === 'blogs') await loadBlogs(more);
|
||||||
|
} catch (e) {
|
||||||
|
st.error = e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message;
|
||||||
|
}
|
||||||
|
st.loading = false;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rendering
|
||||||
|
|
||||||
|
function sources(kind) {
|
||||||
|
return ((st.state && st.state.sources) || []).filter(s => !kind || s.kind === kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const s = st.state;
|
||||||
|
const newsN = s ? s.news_new : 0;
|
||||||
|
const blogN = s ? s.blogs_unread : 0;
|
||||||
|
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>' +
|
||||||
|
'<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.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
let h = '<div class="fd-filter">' +
|
||||||
|
'<select class="search-input fd-select" data-act="blog-source"><option value="">Alle Blogs</option>' +
|
||||||
|
blogs.map(b => '<option value="' + b.id + '"' + (String(b.id) === String(st.blogSource) ? ' selected' : '') + '>' +
|
||||||
|
esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + '</option>').join('') + '</select>' +
|
||||||
|
'<label class="fd-check"><input type="checkbox" data-act="blog-unread"' + (st.blogUnread ? ' checked' : '') + '> Nur ungelesene</label>' +
|
||||||
|
(st.items.some(i => !i.read) ? '<button type="button" class="btn fd-allread" data-act="blogs-read">Alle gelesen</button>' : '') +
|
||||||
|
'</div>';
|
||||||
|
if (!st.items.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '</p>';
|
||||||
|
h += st.items.map(i =>
|
||||||
|
'<button type="button" class="fd-post' + (i.read ? ' fd-read' : '') + '" data-item="' + i.id + '">' +
|
||||||
|
'<span class="fd-card-title">' + (i.starred ? '★ ' : '') + esc(i.title) + '</span>' +
|
||||||
|
'<span class="fd-row-meta">' + esc(i.source_title) + ' · ' + esc(when(i.published)) + '</span>' +
|
||||||
|
(i.summary ? '<span class="fd-card-sum">' + esc(i.summary) + '</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) || [];
|
||||||
|
return '<input class="search-input" name="category" list="fd-cats" placeholder="Ressort, z. B. Politik" 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">Ressort (Nachrichten) ' + 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">' + esc(x.title) + '</span>' +
|
||||||
|
'<span class="fd-row-meta">' + (x.kind === 'news' ? esc(x.category || 'Allgemein') + ' · ' : '') +
|
||||||
|
(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)));
|
||||||
|
h += group('Blogs', blogs);
|
||||||
|
|
||||||
|
h += '<section class="fd-box"><h3>Import & 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>' +
|
||||||
|
'<label class="fd-cat-field">Ressort ' + categoryInput(x.category) + '</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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
let reader = null; // the overlay element
|
||||||
|
let readerList = []; // what "next" walks through (blog items)
|
||||||
|
let readerStory = null;
|
||||||
|
let readerItem = null;
|
||||||
|
|
||||||
|
function fontSize() {
|
||||||
|
let v = 18;
|
||||||
|
try { v = parseInt(localStorage.getItem('diora_feeds_font') || '18', 10) || 18; } catch (e) {}
|
||||||
|
return Math.max(14, Math.min(26, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReader() {
|
||||||
|
if (reader) return reader;
|
||||||
|
reader = document.createElement('div');
|
||||||
|
reader.id = 'fd-article';
|
||||||
|
reader.hidden = true;
|
||||||
|
reader.addEventListener('click', onReaderClick);
|
||||||
|
document.body.appendChild(reader);
|
||||||
|
return reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readerOpen() {
|
||||||
|
return !!(reader && !reader.hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReader() {
|
||||||
|
if (!reader) return;
|
||||||
|
reader.hidden = true;
|
||||||
|
reader.innerHTML = '';
|
||||||
|
document.documentElement.classList.remove('fd-reading');
|
||||||
|
readerItem = readerStory = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openItem(id, story) {
|
||||||
|
ensureReader();
|
||||||
|
readerStory = story || null;
|
||||||
|
reader.hidden = false;
|
||||||
|
document.documentElement.classList.add('fd-reading');
|
||||||
|
reader.innerHTML = readerShell('<p class="fd-note">Lädt …</p>');
|
||||||
|
reader.scrollTop = 0;
|
||||||
|
try {
|
||||||
|
const data = await api('/feeds/items/' + id + '/');
|
||||||
|
readerItem = data.item;
|
||||||
|
reader.innerHTML = readerShell(articleHtml(readerItem));
|
||||||
|
reader.scrollTop = 0;
|
||||||
|
if (!readerItem.read) {
|
||||||
|
readerItem.read = true;
|
||||||
|
markLocal([id], true);
|
||||||
|
api('/feeds/items/read/', {ids: [id], read: true}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reader.innerHTML = readerShell('<p class="fd-error">' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '</p>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readerShell(inner) {
|
||||||
|
const i = readerItem;
|
||||||
|
return '<div class="fd-art-bar">' +
|
||||||
|
'<button type="button" class="fd-icon" data-r="close" aria-label="Zurück" title="Zurück">‹</button>' +
|
||||||
|
'<span class="fd-art-src">' + esc(i ? i.source_title : '') + '</span>' +
|
||||||
|
'<button type="button" class="fd-icon" data-r="font" aria-label="Schriftgröße" title="Schriftgröße">Aa</button>' +
|
||||||
|
(i ? '<button type="button" class="fd-icon' + (i.starred ? ' on' : '') + '" data-r="star" aria-label="Merken" title="Merken">' + (i.starred ? '★' : '☆') + '</button>' : '') +
|
||||||
|
(i && i.url ? '<a class="fd-icon" href="' + esc(i.url) + '" target="_blank" rel="noopener" aria-label="Im Browser öffnen" title="Im Browser öffnen">↗</a>' : '') +
|
||||||
|
'</div><div class="fd-art" style="font-size:' + fontSize() + 'px">' + inner + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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>';
|
||||||
|
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) + '">';
|
||||||
|
h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextItem(i) {
|
||||||
|
if (readerStory) return null;
|
||||||
|
const list = readerList;
|
||||||
|
const at = list.findIndex(x => x.id === i.id);
|
||||||
|
for (let n = at + 1; n < list.length; n++) if (!list[n].read) return list[n];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markLocal(ids, read) {
|
||||||
|
st.items.forEach(i => { if (ids.indexOf(i.id) >= 0) i.read = read; });
|
||||||
|
st.stories.forEach(s => {
|
||||||
|
s.items.forEach(i => { if (ids.indexOf(i.id) >= 0) i.read = read; });
|
||||||
|
s.unread = s.items.filter(i => !i.read).length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReaderClick(e) {
|
||||||
|
const b = e.target.closest('[data-r]');
|
||||||
|
if (!b) {
|
||||||
|
// Links in the article open outside (the app hands them to the browser).
|
||||||
|
const a = e.target.closest('.fd-art-body a[href]');
|
||||||
|
if (a) { a.target = '_blank'; a.rel = 'noopener'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = b.dataset.r;
|
||||||
|
if (r === 'close') closeReader();
|
||||||
|
else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
|
||||||
|
else if (r === 'font') {
|
||||||
|
let v = fontSize() + 2;
|
||||||
|
if (v > 26) v = 14;
|
||||||
|
try { localStorage.setItem('diora_feeds_font', String(v)); } catch (err) {}
|
||||||
|
const art = reader.querySelector('.fd-art');
|
||||||
|
if (art) art.style.fontSize = v + 'px';
|
||||||
|
} else if (r === 'star' && readerItem) {
|
||||||
|
readerItem.starred = !readerItem.starred;
|
||||||
|
api('/feeds/items/' + readerItem.id + '/star/', {starred: readerItem.starred}).catch(() => {});
|
||||||
|
b.textContent = readerItem.starred ? '★' : '☆';
|
||||||
|
b.classList.toggle('on', readerItem.starred);
|
||||||
|
st.items.forEach(i => { if (i.id === readerItem.id) i.starred = readerItem.starred; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openStory(id) {
|
||||||
|
let story = st.stories.find(x => x.id === id);
|
||||||
|
if (!story && st.briefing) {
|
||||||
|
const all = [].concat(st.briefing.top, ...st.briefing.sections.map(s => s.stories));
|
||||||
|
story = all.find(x => x.id === id);
|
||||||
|
}
|
||||||
|
if (!story) return;
|
||||||
|
readerList = [];
|
||||||
|
const lead = story.lead || story.items[0];
|
||||||
|
// Opening a story reads it: all its reports count as read.
|
||||||
|
const unread = story.items.filter(i => !i.read).map(i => i.id);
|
||||||
|
if (unread.length) {
|
||||||
|
markLocal(unread, true);
|
||||||
|
story.unread = 0;
|
||||||
|
api('/feeds/items/read/', {ids: unread, read: true}).catch(() => {});
|
||||||
|
}
|
||||||
|
openItem(lead.id, story);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- events
|
||||||
|
|
||||||
|
tab.addEventListener('click', async e => {
|
||||||
|
const t = e.target.closest('[data-view],[data-act],[data-story],[data-item],[data-cat],[data-edit]');
|
||||||
|
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.story) return openStory(parseInt(t.dataset.story, 10));
|
||||||
|
if (t.dataset.item) {
|
||||||
|
readerList = st.items;
|
||||||
|
return openItem(parseInt(t.dataset.item, 10), null);
|
||||||
|
}
|
||||||
|
if (t.dataset.cat !== undefined && !t.dataset.act) {
|
||||||
|
st.category = t.dataset.cat;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
if (t.dataset.edit) {
|
||||||
|
st.editing = parseInt(t.dataset.edit, 10);
|
||||||
|
return render();
|
||||||
|
}
|
||||||
|
const act = t.dataset.act;
|
||||||
|
if (act === 'refresh') {
|
||||||
|
if (st.loading) return;
|
||||||
|
st.loading = true;
|
||||||
|
render();
|
||||||
|
try { await api('/feeds/refresh/', {}); } catch (err) {}
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
if (act === 'manage') {
|
||||||
|
st.view = 'manage';
|
||||||
|
st.found = null;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
if (!st.state) await loadState().catch(() => {});
|
||||||
|
return render();
|
||||||
|
}
|
||||||
|
if (act === 'close-manage') {
|
||||||
|
try { st.view = localStorage.getItem('diora_feeds_view') || 'news'; } catch (err) { st.view = 'news'; }
|
||||||
|
st.editing = null;
|
||||||
|
st.opml = null;
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
if (act === 'seen') {
|
||||||
|
t.disabled = true;
|
||||||
|
try { await api('/feeds/briefing/seen/', {}); } catch (err) {}
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
if (act === 'more') return load(true);
|
||||||
|
if (act === 'blogs-read') {
|
||||||
|
const ids = st.items.filter(i => !i.read).map(i => i.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
await api('/feeds/items/read/', {ids, read: true}).catch(() => {});
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
if (act === '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.kind === 'news' ? 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 === 'blog-unread') { st.blogUnread = t.checked; 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.kind.value === 'news' ? f.category.value : ''});
|
||||||
|
found.subscribed = true;
|
||||||
|
await loadState();
|
||||||
|
} else if (f.dataset.form === 'edit') {
|
||||||
|
await api('/feeds/sources/' + f.dataset.id + '/', {title: f.title.value, kind: f.kind.value,
|
||||||
|
category: f.category.value, fulltext: f.fulltext.value});
|
||||||
|
st.editing = null;
|
||||||
|
await loadState();
|
||||||
|
}
|
||||||
|
st.error = '';
|
||||||
|
} catch (err) {
|
||||||
|
st.error = err.message;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Ressort field only means something for news.
|
||||||
|
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.style.display = e.target.value === 'news' ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- showing the tab
|
||||||
|
|
||||||
|
let shownAt = 0;
|
||||||
|
window.__dioraFeedsShow = function () {
|
||||||
|
// Back after a while: fresh state (new briefing); otherwise as it was.
|
||||||
|
if (!st.state || Date.now() - shownAt > 5 * 60000) load();
|
||||||
|
else render();
|
||||||
|
shownAt = Date.now();
|
||||||
|
};
|
||||||
|
// What other parts (Home) may show: the briefing and the unread blog posts.
|
||||||
|
window.__dioraFeedsSummary = async function () {
|
||||||
|
const [state, briefing] = await Promise.all([api('/feeds/state/'), api('/feeds/briefing/')]);
|
||||||
|
return {state, briefing};
|
||||||
|
};
|
||||||
|
window.__dioraFeedsOpenStory = function (story) {
|
||||||
|
if (!st.stories.some(x => x.id === story.id)) st.stories.push(story);
|
||||||
|
openStory(story.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (tab.style.display !== 'none' && !readerOpen() && st.view !== 'manage' && !st.loading) load();
|
||||||
|
}, 10 * 60000);
|
||||||
|
|
||||||
|
// Back (Android): the article, then the management page, then (diora Reader) to the books.
|
||||||
|
const back = window.__dioraBack;
|
||||||
|
window.__dioraBack = function () {
|
||||||
|
if (readerOpen()) { closeReader(); return true; }
|
||||||
|
if (tab.style.display !== 'none' && st.view === 'manage') {
|
||||||
|
tab.querySelector('[data-act="close-manage"]').click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (back && back()) return true;
|
||||||
|
if (READER_APP && typeof _currentTopTab !== 'undefined' && _currentTopTab === 'feeds') {
|
||||||
|
showTab('books');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && readerOpen()) closeReader();
|
||||||
|
});
|
||||||
|
|
||||||
|
// diora Reader has no tab bar of its own: a small one for books and feeds.
|
||||||
|
if (READER_APP && !document.getElementById('nui-tabbar')) {
|
||||||
|
const dock = document.createElement('div');
|
||||||
|
dock.id = 'nui-dock';
|
||||||
|
dock.innerHTML = '<nav id="nui-tabbar">' +
|
||||||
|
'<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', typeof _currentTopTab !== 'undefined' && b.dataset.tab === _currentTopTab));
|
||||||
|
dock.addEventListener('click', e => {
|
||||||
|
const b = e.target.closest('button');
|
||||||
|
if (!b) return;
|
||||||
|
showTab(b.dataset.tab);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
const orig = window.showTab;
|
||||||
|
window.showTab = function (name) {
|
||||||
|
orig.apply(this, arguments);
|
||||||
|
mark();
|
||||||
|
};
|
||||||
|
document.documentElement.style.setProperty('--nui-dock-h', '56px');
|
||||||
|
const overlay = document.getElementById('reader-overlay');
|
||||||
|
if (overlay) {
|
||||||
|
new MutationObserver(() => document.documentElement.classList.toggle('nui-reading', overlay.style.display !== 'none'))
|
||||||
|
.observe(overlay, {attributes: true, attributeFilter: ['style']});
|
||||||
|
}
|
||||||
|
mark();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- styles
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = [
|
||||||
|
'#tab-feeds .fd { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
|
||||||
|
'#tab-feeds .fd-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 4px 0 14px; }',
|
||||||
|
'#tab-feeds .fd-seg { display: flex; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; overflow: hidden; }',
|
||||||
|
'#tab-feeds .fd-seg button { background: none; border: none; color: inherit; font: inherit; padding: 9px 14px; cursor: pointer; }',
|
||||||
|
'#tab-feeds .fd-seg button.on { background: var(--accent, #e63946); color: #fff; }',
|
||||||
|
'#tab-feeds .fd-count { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px; background: rgba(255,255,255,0.18); font-size: 11px; text-align: center; }',
|
||||||
|
'#tab-feeds .fd-actions { display: flex; gap: 4px; }',
|
||||||
|
'.fd-icon { background: none; border: none; color: inherit; font: inherit; font-size: 20px; min-width: 40px; min-height: 40px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }',
|
||||||
|
'.fd-icon.on { color: var(--accent, #e63946); }',
|
||||||
|
'#tab-feeds .fd-back { background: none; border: none; color: inherit; font: inherit; padding: 8px 0; cursor: pointer; }',
|
||||||
|
'#tab-feeds .fd-h { margin: 0; font-size: 15px; }',
|
||||||
|
'.fd-note { color: var(--nui-muted, #9a9a9a); font-size: 13px; font-weight: 400; }',
|
||||||
|
'.fd-error, .fd-warn { color: var(--accent, #e63946); font-size: 13px; }',
|
||||||
|
'#tab-feeds .fd-empty { text-align: center; padding: 40px 0; }',
|
||||||
|
'#tab-feeds h3 { margin: 18px 0 6px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
|
||||||
|
'#tab-feeds h3 small { font-size: 11px; }',
|
||||||
|
'#tab-feeds .fd-brief { border: 1px solid var(--border, #2a2a2a); border-radius: 6px; padding: 12px 14px 14px; margin-bottom: 18px; background: #0b0b0b; }',
|
||||||
|
'#tab-feeds .fd-brief-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; flex-wrap: wrap; }',
|
||||||
|
'#tab-feeds .fd-brief-head h2 { margin: 0; font-size: 16px; color: var(--accent, #e63946); }',
|
||||||
|
'#tab-feeds .fd-seen { width: 100%; margin-top: 14px; min-height: 42px; }',
|
||||||
|
'#tab-feeds .fd-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
|
||||||
|
'#tab-feeds .fd-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
|
||||||
|
'#tab-feeds .fd-row-title { font-size: 14px; line-height: 1.35; }',
|
||||||
|
'.fd-row-meta { font-size: 12px; font-weight: 400; color: var(--nui-muted, #9a9a9a); }',
|
||||||
|
'#tab-feeds .fd-more-src { color: var(--accent, #e63946); }',
|
||||||
|
'#tab-feeds .fd-badge { flex: none; min-width: 26px; height: 26px; border-radius: 13px; background: var(--accent, #e63946); color: #fff; font-size: 12px; display: inline-flex; align-items: center; justify-content: center; }',
|
||||||
|
'#tab-feeds .fd-link { background: none; border: none; color: var(--nui-muted, #9a9a9a); font: inherit; font-size: 12px; padding: 8px 0; cursor: pointer; }',
|
||||||
|
'#tab-feeds .fd-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 10px; align-items: center; }',
|
||||||
|
// The reports of a story: one row that scrolls sideways, however many there are.
|
||||||
|
'.fd-sources { display: flex; gap: 6px; margin: 4px -18px 10px; padding: 0 18px; align-items: center; overflow-x: auto; white-space: nowrap; scrollbar-width: none; }',
|
||||||
|
'.fd-sources::-webkit-scrollbar { display: none; }',
|
||||||
|
'.fd-sources .fd-chip { flex: none; }',
|
||||||
|
'#fd-article .fd-art-more { font-family: var(--font); margin: 30px 0 4px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
|
||||||
|
'#fd-article .fd-other { display: flex; flex-direction: column; gap: 3px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 0.85em; text-align: left; cursor: pointer; }',
|
||||||
|
'#fd-article .fd-other-title { font-weight: 600; line-height: 1.35; }',
|
||||||
|
'.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
|
||||||
|
'.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }',
|
||||||
|
'#tab-feeds .fd-day { margin-top: 16px; }',
|
||||||
|
'#tab-feeds .fd-card { display: flex; gap: 12px; width: 100%; padding: 12px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
|
||||||
|
'#tab-feeds .fd-thumb { width: 92px; height: 66px; flex: none; object-fit: cover; border-radius: 4px; background: #1a1a1a; order: 2; }',
|
||||||
|
'#tab-feeds .fd-card-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }',
|
||||||
|
'#tab-feeds .fd-card-title { font-size: 15px; font-weight: 700; line-height: 1.35; }',
|
||||||
|
'#tab-feeds .fd-card-sum { font-size: 13px; font-weight: 400; line-height: 1.4; color: var(--nui-muted, #bdbdbd); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }',
|
||||||
|
'#tab-feeds .fd-read .fd-card-title, #tab-feeds .fd-read .fd-row-title { font-weight: 400; color: var(--nui-muted, #9a9a9a); }',
|
||||||
|
'#tab-feeds .fd-post { display: flex; flex-direction: column; gap: 4px; width: 100%; padding: 12px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
|
||||||
|
'#tab-feeds .fd-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }',
|
||||||
|
'#tab-feeds .fd-select { max-width: 60%; }',
|
||||||
|
'#tab-feeds .fd-check { font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }',
|
||||||
|
'#tab-feeds .fd-allread { margin-left: auto; }',
|
||||||
|
'#tab-feeds .fd-loadmore { width: 100%; margin-top: 14px; }',
|
||||||
|
'#tab-feeds .fd-box { border: 1px solid var(--border, #2a2a2a); border-radius: 6px; padding: 4px 14px 14px; margin-bottom: 14px; }',
|
||||||
|
'#tab-feeds .fd-add { display: flex; gap: 8px; }',
|
||||||
|
'#tab-feeds .fd-add input { flex: 1; min-width: 0; }',
|
||||||
|
'#tab-feeds .fd-found, #tab-feeds .fd-edit { border-top: 1px solid var(--border, #2a2a2a); margin-top: 12px; padding-top: 10px; display: flex; flex-direction: column; gap: 8px; }',
|
||||||
|
'#tab-feeds .fd-found-title { font-weight: 700; }',
|
||||||
|
'#tab-feeds .fd-url { word-break: break-all; }',
|
||||||
|
'#tab-feeds .fd-samples { margin: 0; padding-left: 18px; font-size: 13px; font-weight: 400; }',
|
||||||
|
'#tab-feeds .fd-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; }',
|
||||||
|
'#tab-feeds .fd-grid label, #tab-feeds .fd-edit > label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--nui-muted, #9a9a9a); }',
|
||||||
|
'#tab-feeds .fd-buttons { display: flex; flex-wrap: wrap; gap: 8px; }',
|
||||||
|
'#tab-feeds .fd-src { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
|
||||||
|
'#tab-feeds .fd-opml { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }',
|
||||||
|
'#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }',
|
||||||
|
'#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }',
|
||||||
|
'#tab-feeds .fd-file { display: inline-block; }',
|
||||||
|
// The article: a page over everything, like the book reader.
|
||||||
|
'#fd-article { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; background: var(--bg, #000); color: var(--fg, #fff); overflow-y: auto; -webkit-overflow-scrolling: touch; }',
|
||||||
|
'#fd-article[hidden] { display: none; }',
|
||||||
|
'#fd-article .fd-art-bar { position: sticky; top: 0; display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: var(--bg, #000); border-bottom: 1px solid var(--border, #2a2a2a); padding-top: calc(4px + env(safe-area-inset-top, 0px)); }',
|
||||||
|
'#fd-article .fd-art-src { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--nui-muted, #9a9a9a); }',
|
||||||
|
'#fd-article .fd-art { max-width: 68ch; margin: 0 auto; padding: 12px 18px 48px; font-family: "Source Serif 4", Georgia, serif; font-weight: 380; line-height: 1.65; }',
|
||||||
|
'#fd-article .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
|
||||||
|
'#fd-article .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
|
||||||
|
'#fd-article .fd-art-img, #fd-article .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }',
|
||||||
|
'#fd-article .fd-art-body a { color: var(--accent, #e63946); }',
|
||||||
|
'#fd-article .fd-art-body figure { margin: 12px 0; }',
|
||||||
|
'#fd-article .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
|
||||||
|
'#fd-article .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border, #444); }',
|
||||||
|
'#fd-article .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
|
||||||
|
'#fd-article .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
|
||||||
|
'#fd-article .fd-art-body td, #fd-article .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
|
||||||
|
'#fd-article .fd-sources { font-family: var(--font); }',
|
||||||
|
'#fd-article .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }',
|
||||||
|
'html.fd-reading #nui-dock { display: none; }',
|
||||||
|
'@media (hover: hover) { #tab-feeds .fd-card:hover, #tab-feeds .fd-row:hover, #tab-feeds .fd-post:hover, #tab-feeds .fd-src:hover { background: #111; } }',
|
||||||
|
// Bright pages and e-ink: black on white, no grey boxes, no thumbnails to flicker.
|
||||||
|
'body.bright-bg #tab-feeds .fd-brief, body.eink #tab-feeds .fd-brief { background: #fff; }',
|
||||||
|
'body.eink #tab-feeds .fd-thumb { display: none; }',
|
||||||
|
'body.eink #tab-feeds .fd-seg button.on, body.eink #tab-feeds .fd-badge { background: #000; color: #fff; }',
|
||||||
|
'body.eink .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }',
|
||||||
|
'body.eink #fd-article, body.eink #fd-article .fd-art-bar { background: #fff; color: #000; }',
|
||||||
|
'body.eink #fd-article .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }',
|
||||||
|
].join('\n');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
})();
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v88';
|
const CACHE = 'diora-v89';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app.js',
|
'/static/js/app.js',
|
||||||
'/static/js/podcasts-web.js',
|
'/static/js/podcasts-web.js',
|
||||||
'/static/js/home.js',
|
'/static/js/home.js',
|
||||||
|
'/static/js/feeds.js',
|
||||||
'/static/podcasts/native-podcast.js',
|
'/static/podcasts/native-podcast.js',
|
||||||
'/static/podcasts/podcast-ui.css',
|
'/static/podcasts/podcast-ui.css',
|
||||||
'/static/podcasts/podcasts-web.css',
|
'/static/podcasts/podcasts-web.css',
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
<button class="tab-btn" onclick="showTab('focus')">Fokus</button>
|
<button class="tab-btn" onclick="showTab('focus')">Fokus</button>
|
||||||
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
|
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
|
||||||
<button class="tab-btn" onclick="showTab('books')">Bücher</button>
|
<button class="tab-btn" onclick="showTab('books')">Bücher</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('feeds')"{% if not user.is_authenticated %} style="display:none;"{% endif %}>Feeds</button>
|
||||||
<button class="tab-btn" onclick="showTab('ambient')">Ambient</button>
|
<button class="tab-btn" onclick="showTab('ambient')">Ambient</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -261,6 +262,10 @@
|
||||||
{% comment %}Filled by static/js/home.js: the book in reading, the next episode, stations, Ambient.{% endcomment %}
|
{% comment %}Filled by static/js/home.js: the book in reading, the next episode, stations, Ambient.{% endcomment %}
|
||||||
<section class="tab-panel" id="tab-home" style="display:none;"></section>
|
<section class="tab-panel" id="tab-home" style="display:none;"></section>
|
||||||
|
|
||||||
|
<!-- ===== FEEDS TAB ===== -->
|
||||||
|
{% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %}
|
||||||
|
<section class="tab-panel" id="tab-feeds" style="display:none;"></section>
|
||||||
|
|
||||||
<!-- ===== AMBIENT TAB ===== -->
|
<!-- ===== AMBIENT TAB ===== -->
|
||||||
{% comment %}"Drift", a generative ambient synthesizer (static/ambient/), in a frame of its own: its
|
{% comment %}"Drift", a generative ambient synthesizer (static/ambient/), in a frame of its own: its
|
||||||
script and styles stay apart from the page's. Loaded the first time the tab opens.{% endcomment %}
|
script and styles stay apart from the page's. Loaded the first time the tab opens.{% endcomment %}
|
||||||
|
|
@ -345,4 +350,5 @@
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
<script src="/static/js/podcasts-web.js"></script>
|
<script src="/static/js/podcasts-web.js"></script>
|
||||||
<script src="/static/js/home.js"></script>
|
<script src="/static/js/home.js"></script>
|
||||||
|
<script src="/static/js/feeds.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue