diff --git a/feeds/fetch.py b/feeds/fetch.py index 7c3f050..fa25a1b 100644 --- a/feeds/fetch.py +++ b/feeds/fetch.py @@ -141,10 +141,15 @@ def parse_entry(entry, feed_url): 'dated': when is not None, # not an Item field: refresh._apply uses and drops it 'summary': _short(summary), 'content': content, + 'words': word_count(content), 'image_url': _image(entry, content_html, base)[:2000], } +def word_count(content_html): + return len(plain(content_html).split()) + + def is_teaser(content_html): return len(plain(content_html)) < TEASER_CHARS diff --git a/feeds/migrations/0003_blog_flags_words.py b/feeds/migrations/0003_blog_flags_words.py new file mode 100644 index 0000000..a3625cf --- /dev/null +++ b/feeds/migrations/0003_blog_flags_words.py @@ -0,0 +1,42 @@ +# Generated by Django 6.1 on 2026-10-04 19:28 + +from django.db import migrations, models + + +def count_words(apps, schema_editor): + """The reading time of the articles already there.""" + from feeds.fetch import word_count + Item = apps.get_model('feeds', 'Item') + for item in Item.objects.exclude(content='').only('id', 'content').iterator(): + Item.objects.filter(pk=item.pk).update(words=word_count(item.content)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('feeds', '0002_item_paywalled_sitelogin'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='words', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='source', + name='fav', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='source', + name='hidden', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='source', + name='notify', + field=models.BooleanField(default=False), + ), + migrations.RunPython(count_words, migrations.RunPython.noop), + ] diff --git a/feeds/models.py b/feeds/models.py index 1775467..7b81112 100644 --- a/feeds/models.py +++ b/feeds/models.py @@ -28,6 +28,11 @@ class Source(models.Model): 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) + # Blogs: favorites come first; a hidden blog stays out of "Alle" (and its + # counts) but shows in its folder; notify = a notification for new posts. + fav = models.BooleanField(default=False) + hidden = models.BooleanField(default=False) + notify = models.BooleanField(default=False) # Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed). last_refreshed_at = models.DateTimeField(null=True, blank=True) @@ -82,6 +87,7 @@ class Item(models.Model): # a subscriber login for the site (logins.py). paywalled = models.BooleanField(default=False) image_url = models.URLField(max_length=2000, blank=True) + words = models.PositiveIntegerField(default=0) # of the content: the reading time 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') diff --git a/feeds/refresh.py b/feeds/refresh.py index 6314e74..5f96656 100644 --- a/feeds/refresh.py +++ b/feeds/refresh.py @@ -146,20 +146,22 @@ def _store_article(item, got): item.paywalled = True if longer: item.content = content + item.words = fetch.word_count(content) if not item.image_url and image: item.image_url = image[:2000] - item.save(update_fields=['paywalled', 'content', 'image_url']) + item.save(update_fields=['paywalled', 'content', 'words', 'image_url']) return if not longer: item.fulltext_failed = True # the feed had more item.save(update_fields=['fulltext_failed']) return item.content = content + item.words = fetch.word_count(content) item.content_full = True item.paywalled = False if not item.image_url and image: item.image_url = image[:2000] - item.save(update_fields=['content', 'content_full', 'paywalled', 'image_url']) + item.save(update_fields=['content', 'words', 'content_full', 'paywalled', 'image_url']) def _articles(items, deadline): diff --git a/feeds/tests.py b/feeds/tests.py index b0d8e2f..4245fb8 100644 --- a/feeds/tests.py +++ b/feeds/tests.py @@ -291,3 +291,46 @@ class SiteLoginTest(TestCase): by_id = {s['id']: s for s in state['sources']} self.assertIsNone(by_id[other.id]['login']) self.assertIsNotNone(by_id[self.src.id]['login']) + + +class BlogFeedTest(TestCase): + def setUp(self): + self.u = User.objects.create_user('tester', password='pw12345678') + self.client.force_login(self.u) + now = timezone.now() + self.busy = Source.objects.create(user=self.u, url='https://busy/', title='Busy', kind='blog', category='Politik') + self.rare = Source.objects.create(user=self.u, url='https://rare/', title='Rare', kind='blog', category='Politik') + for n in range(10): # a post every two hours + Item.objects.create(source=self.busy, user=self.u, kind='blog', guid_hash=f'b{n}', title=f'Busy {n}', + content='

' + 'wort ' * 460 + '

', words=460, published=now - timedelta(hours=2 * n)) + Item.objects.create(source=self.rare, user=self.u, kind='blog', guid_hash='r', title='Rare essay', + published=now - timedelta(hours=5)) + + def post(self, url, data): + return self.client.post(url, json.dumps(data), content_type='application/json') + + def by_id(self): + return {s['id']: s for s in self.client.get('/feeds/state/').json()['sources']} + + def test_interval_new_and_words(self): + st = self.by_id() + self.assertLess(st[self.busy.id]['interval_days'], 1) + self.assertEqual(st[self.rare.id]['interval_days'], 30.0) # one post: counts as rare + self.assertEqual(st[self.busy.id]['new'], 10) + items = self.client.get('/feeds/blogs/?unread=1').json()['items'] + self.assertEqual(next(i for i in items if i['title'] == 'Busy 0')['words'], 460) + + def test_hidden_blog_only_in_its_folder_and_not_counted(self): + self.post(f'/feeds/sources/{self.busy.id}/', {'hidden': True}) + state = self.client.get('/feeds/state/').json() + self.assertEqual((state['blogs_unread'], state['blogs_new']), (1, 1)) + main = [i['title'] for i in self.client.get('/feeds/blogs/?unread=1').json()['items']] + self.assertEqual(main, ['Rare essay']) + folder = self.client.get('/feeds/blogs/?unread=1&folder=Politik').json()['items'] + self.assertEqual(len(folder), 11) + + def test_favorites_filter(self): + self.post(f'/feeds/sources/{self.rare.id}/', {'fav': True}) + self.assertTrue(self.by_id()[self.rare.id]['fav']) + favs = [i['title'] for i in self.client.get('/feeds/blogs/?fav=1').json()['items']] + self.assertEqual(favs, ['Rare essay']) diff --git a/feeds/views.py b/feeds/views.py index 6a676a4..de64433 100644 --- a/feeds/views.py +++ b/feeds/views.py @@ -12,7 +12,8 @@ News: GET /feeds/briefing/ what's new since the briefing was POST /feeds/briefing/seen/ seen: everything up to now counts as read GET /feeds/news/?before=&category= stories, newest first GET /feeds/stories// one story with all its items -Blogs: GET /feeds/blogs/?folder=&source=&unread=1&before= (a blog's category is its folder) +Blogs: GET /feeds/blogs/?folder=&source=&unread=1&fav=1&before= (a blog's category is its folder; + without folder/source the hidden blogs stay out) Items: GET /feeds/items// the article (fetches the page if the feed had a teaser) POST /feeds/items/read/ {ids, read} POST /feeds/items//star/ {starred} @@ -40,6 +41,10 @@ from . import fetch, logins, refresh from .models import Item, SiteLogin, Source, Story PAGE = 40 +BLOG_PAGE = 150 # blog posts per page (all unread ones come at once, up to BLOG_UNREAD_MAX) +BLOG_UNREAD_MAX = 600 +NEW_HOURS = 24 # a blog post this young and unread is "new" +RARE_POSTS = 10 # how often a blog posts: the mean gap over its last this many posts BRIEFING_TOP = 8 BRIEFING_PER_CATEGORY = 5 REFRESH_WAIT = 40 # seconds an explicit refresh may take @@ -63,10 +68,13 @@ def _iso(dt): return dt.isoformat() if dt else None -def _source_data(s, unread=0): +def _source_data(s, unread=0, new=0, interval=None): 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, + 'kind': s.kind, 'category': s.category, 'fulltext': s.fulltext, 'unread': unread, 'new': new, + 'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, + # Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare. + 'interval_days': interval, 'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count, 'login': logins.info(s.user_id, s.url), } @@ -75,7 +83,7 @@ def _source_data(s, unread=0): 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), + 'url': i.url, 'title': i.title, 'author': i.author, 'published': _iso(i.published), 'words': i.words, 'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'starred': i.starred, 'story': i.story_id, } @@ -117,21 +125,40 @@ def state(request): return err unread = dict(Item.objects.filter(user=request.user, gone=False, read_at=None) .values_list('source').annotate(n=Count('id'))) + now = timezone.now() + new = dict(Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False, read_at=None, + published__gte=now - timedelta(hours=NEW_HOURS)) + .values_list('source').annotate(n=Count('id'))) sources = list(Source.objects.filter(user=request.user)) + intervals = _intervals(request.user, [x for x in sources if x.kind == Source.BLOG], now) 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], + 'sources': [_source_data(x, unread.get(x.id, 0), new.get(x.id, 0), intervals.get(x.id)) for x in sources], 'categories': sorted({x.category for x in sources if x.kind == Source.NEWS and x.category}), 'folders': sorted({x.category for x in sources if x.kind == Source.BLOG 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)), + 'blogs_unread': sum(unread.get(x.id, 0) for x in sources if x.kind == Source.BLOG and not x.hidden), + 'blogs_new': sum(new.get(x.id, 0) for x in sources if x.kind == Source.BLOG and not x.hidden), 'settings': {'news_keep_days': s.news_keep_days, 'news_seen_at': _iso(s.news_seen_at)}, }) +def _intervals(user, blogs, now): + """Mean days between the last RARE_POSTS posts of each blog, up to now: + (now - the oldest of them) / their number; one post counts as 30 days.""" + out = {} + for s in blogs: + dates = list(s.items.filter(gone=False).order_by('-published').values_list('published', flat=True)[:RARE_POSTS]) + if not dates: + continue + days = (now - dates[-1]).total_seconds() / 86400 / len(dates) + out[s.id] = round(max(days, 30.0) if len(dates) == 1 else days, 2) + return out + + 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}) @@ -248,6 +275,9 @@ def edit_source(request, pk): return JsonResponse({'error': 'not found'}, status=404) if 'title' in body and (body['title'] or '').strip(): source.title = body['title'].strip()[:300] + for flag in ('fav', 'hidden', 'notify'): + if isinstance(body.get(flag), bool): + setattr(source, flag, body[flag]) if 'category' in body: source.category = (body['category'] or '').strip()[:100] if body.get('fulltext') in dict(Source.FULLTEXTS): @@ -385,14 +415,22 @@ def blogs(request): if request.GET.get('source', '').isdigit(): qs = qs.filter(source_id=int(request.GET['source'])) elif 'folder' in request.GET: + # A folder shows all its blogs, the hidden ones too. qs = qs.filter(source__category=request.GET['folder']) - if request.GET.get('unread') == '1': + else: + qs = qs.filter(source__hidden=False) + if request.GET.get('fav') == '1': + qs = qs.filter(source__fav=True) + unread_only = request.GET.get('unread') == '1' + if unread_only: 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}) + # Unread: all of them at once (the page sorts them into days, favorites, rare blogs). + limit = BLOG_UNREAD_MAX if unread_only else BLOG_PAGE + items = list(qs.order_by('-published')[:limit]) + return JsonResponse({'items': [_item_data(i) for i in items], 'more': len(items) == limit}) @csrf_exempt diff --git a/static/js/feeds.js b/static/js/feeds.js index b591a33..35c7352 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -23,6 +23,8 @@ category: '', items: [], itemsMore: false, blogSource: '', blogUnread: true, + blogFav: false, // main feed: only the favorite blogs + rareFirst: true, // within a day: blogs that seldom post first blogFolder: null, // null = all blogs, '' = blogs without a folder, else a folder name found: null, // discover results opml: null, // parsed OPML import @@ -33,6 +35,8 @@ try { const saved = localStorage.getItem('diora_feeds_view'); if (saved === 'news' || saved === 'blogs') st.view = saved; + if (localStorage.getItem('diora_feeds_rare') === '0') st.rareFirst = false; + if (localStorage.getItem('diora_feeds_unread') === '0') st.blogUnread = false; } catch (e) {} const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(/= 120 ? Math.max(1, Math.round(i.words / 230)) + ' Min.' : ''; + } + + function section(iso) { + const d = new Date(iso); + const label = dayLabel(d); + if (label === 'Heute' || label === 'Gestern') return label; + const days = (Date.now() - d.getTime()) / 86400000; + if (days < 7) return 'Diese Woche'; + if (days < 31) return 'Diesen Monat'; + return 'Älter'; + } + + // Posts into sections (newest first); in each, up to FAV_PER_DAY posts per + // favorite blog first, then the rest — blogs that post seldom ahead of the + // busy ones ("Seltene zuerst"), or simply by time. + function blogSections(items, byId) { + const order = ['Heute', 'Gestern', 'Diese Woche', 'Diesen Monat', 'Älter']; + const groups = {}; + items.forEach(i => { (groups[section(i.published)] = groups[section(i.published)] || []).push(i); }); + const newest = (a, b) => new Date(b.published) - new Date(a.published); + return order.filter(k => groups[k]).map(k => { + const list = groups[k].slice().sort(newest); + const favCount = {}; + const fav = []; + const rest = []; + list.forEach(i => { + const src = byId[i.source]; + if (src && src.fav && (favCount[i.source] || 0) < FAV_PER_DAY) { + favCount[i.source] = (favCount[i.source] || 0) + 1; + fav.push(i); + } else { + rest.push(i); + } + }); + if (st.rareFirst) { + const gap = i => { const src = byId[i.source]; return (src && src.interval_days) || 0; }; + rest.sort((a, b) => (gap(b) - gap(a)) || newest(a, b)); + } + return {label: k, items: fav.concat(rest)}; + }); + } + function renderBlogs() { const blogs = sources('blog'); if (!blogs.length) { return '

Noch keine Blogs abonniert.

' + '
'; } - // Folders (a blog's category): chips with their unread count. + const byId = {}; + blogs.forEach(b => { byId[b.id] = b; }); + // Folders (a blog's category): chips with their unread count, a dot when something is new. const folders = (st.state.folders || []); - const unreadIn = f => blogs.filter(b => (b.category || '') === f).reduce((n, b) => n + b.unread, 0); - const total = blogs.reduce((n, b) => n + b.unread, 0); + const inFolder = f => blogs.filter(b => (b.category || '') === f); + const sum = (list, key) => list.reduce((n, b) => n + b[key], 0); + const visible = blogs.filter(b => !b.hidden); let h = ''; if (folders.length) { - const chip = (value, label, n) => ''; - h += '
' + chip(null, 'Alle', total) + - folders.map(f => chip(f, f, unreadIn(f))).join('') + - (blogs.some(b => !b.category) ? chip('', 'Ohne Ordner', unreadIn('')) : '') + '
'; + const chip = (value, label, list) => ''; + h += '
' + chip(null, 'Alle', visible) + + folders.map(f => chip(f, f, inFolder(f))).join('') + + (blogs.some(b => !b.category) ? chip('', 'Ohne Ordner', inFolder('')) : '') + '
'; } - const shown = st.blogFolder === null ? blogs : blogs.filter(b => (b.category || '') === st.blogFolder); + const main = st.blogFolder === null; + const shown = main ? blogs : inFolder(st.blogFolder); + h += '
' + + '' + + '' + + (main ? '' : '') + + '' + + '
'; h += '
' + '' + - '' + + (b.fav ? '★ ' : '') + esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + (b.hidden ? ' – ausgeblendet' : '') + + '').join('') + '' + (st.items.some(i => !i.read) ? '' : '') + '
'; + const one = st.blogSource ? byId[st.blogSource] : null; + if (one) { + // The selected blog's switches. + h += '
' + + '' + + '' + + '
'; + } if (!st.items.length) return h + '

' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '

'; - h += st.items.map(i => - '').join(''); + blogSections(st.items, byId).forEach(sec => { + h += '

' + esc(sec.label) + '

'; + h += sec.items.map(i => { + const src = byId[i.source]; + const rt = readingTime(i); + return ''; + }).join(''); + }); if (st.itemsMore) h += ''; return h; } @@ -298,7 +383,7 @@ '

' + title + ' ' + list.length + '

' + list.map(x => x.id === st.editing ? editForm(x) : '