From b063e91113ef05155b5518126c6541fcb2743b85 Mon Sep 17 00:00:00 2001
From: mrwnslz
Date: Sun, 4 Oct 2026 21:32:15 +0200
Subject: [PATCH] Blog-Feed nach Kino: Abschnitte, Favoriten, Seltene zuerst,
Ausblenden, NEU
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Abschnitte Heute/Gestern/Diese Woche/Diesen Monat/Älter; je Abschnitt bis zu
5 Posts je Favoriten-Blog zuerst, dann „Seltene zuerst“ (mittlerer Abstand der
letzten 10 Posts, ein Post = 30 Tage) oder chronologisch
- Blog „im Hauptfeed ausblenden“: nicht unter Alle und nicht gezählt, im Ordner da
- NEU (jünger als 24 h, ungelesen) am Post, als Zahl am Tab, Punkt am Ordner
- Chips Ungelesen/Alle, ★ Favoriten, Seltene zuerst; Schalter je Blog
- Lesezeit (Wörter/230) an jedem Post; words wird beim Speichern gezählt
- sw v93
Co-Authored-By: Claude Opus 5.5
---
feeds/fetch.py | 5 +
feeds/migrations/0003_blog_flags_words.py | 42 ++++++
feeds/models.py | 6 +
feeds/refresh.py | 6 +-
feeds/tests.py | 43 ++++++
feeds/views.py | 56 +++++--
static/js/feeds.js | 170 +++++++++++++++++++---
static/js/sw.js | 2 +-
8 files changed, 294 insertions(+), 36 deletions(-)
create mode 100644 feeds/migrations/0003_blog_flags_words.py
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(/Lädt …
';
@@ -223,40 +228,120 @@
return h;
}
+ // ---- blogs: days, favorites first, rare blogs first (after Kino's feed)
+
+ const NEW_MS = 24 * 3600000;
+ const FAV_PER_DAY = 5; // posts of one favorite blog that go first per section
+
+ function isNew(i, src) {
+ return !i.read && Date.now() - new Date(i.published).getTime() < NEW_MS && !(src && src.hidden);
+ }
+
+ function readingTime(i) {
+ return i.words >= 120 ? Math.max(1, Math.round(i.words / 230)) + ' Min.' : '';
+ }
+
+ function section(iso) {
+ const d = new Date(iso);
+ const label = dayLabel(d);
+ if (label === 'Heute' || label === 'Gestern') return label;
+ const days = (Date.now() - d.getTime()) / 86400000;
+ if (days < 7) return 'Diese Woche';
+ if (days < 31) return 'Diesen Monat';
+ return 'Älter';
+ }
+
+ // Posts into sections (newest first); in each, up to FAV_PER_DAY posts per
+ // favorite blog first, then the rest — blogs that post seldom ahead of the
+ // busy ones ("Seltene zuerst"), or simply by time.
+ function blogSections(items, byId) {
+ const order = ['Heute', 'Gestern', 'Diese Woche', 'Diesen Monat', 'Älter'];
+ const groups = {};
+ items.forEach(i => { (groups[section(i.published)] = groups[section(i.published)] || []).push(i); });
+ const newest = (a, b) => new Date(b.published) - new Date(a.published);
+ return order.filter(k => groups[k]).map(k => {
+ const list = groups[k].slice().sort(newest);
+ const favCount = {};
+ const fav = [];
+ const rest = [];
+ list.forEach(i => {
+ const src = byId[i.source];
+ if (src && src.fav && (favCount[i.source] || 0) < FAV_PER_DAY) {
+ favCount[i.source] = (favCount[i.source] || 0) + 1;
+ fav.push(i);
+ } else {
+ rest.push(i);
+ }
+ });
+ if (st.rareFirst) {
+ const gap = i => { const src = byId[i.source]; return (src && src.interval_days) || 0; };
+ rest.sort((a, b) => (gap(b) - gap(a)) || newest(a, b));
+ }
+ return {label: k, items: fav.concat(rest)};
+ });
+ }
+
function renderBlogs() {
const blogs = sources('blog');
if (!blogs.length) {
return '
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 += '