Feeds: Glocke je Blog (Benachrichtigung bei neuen Posts)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 7m42s

- Source.notify; ein neuer Post wird beim Eintreffen markiert (Item.notify),
  nie beim ersten Abruf eines Blogs, nie älter als 2 Tage
- GET /feeds/notifications/?after=<id>: ungelesene markierte Posts von Blogs mit
  Glocke nach der zuletzt gesehenen id; ohne after nur der Startpunkt
- Glocken-Schalter in der Blog-Leiste und in den Einstellungen des Blogs;
  window.__dioraFeedsOpenItem öffnet einen Post (Antippen der Benachrichtigung)
- sw v94

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-04 21:40:27 +02:00
parent b063e91113
commit 74bb3a44df
8 changed files with 84 additions and 3 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-10-04 19:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feeds', '0003_blog_flags_words'),
]
operations = [
migrations.AddField(
model_name='item',
name='notify',
field=models.BooleanField(db_index=True, default=False),
),
]

View file

@ -88,6 +88,9 @@ class Item(models.Model):
paywalled = models.BooleanField(default=False) paywalled = models.BooleanField(default=False)
image_url = models.URLField(max_length=2000, blank=True) image_url = models.URLField(max_length=2000, blank=True)
words = models.PositiveIntegerField(default=0) # of the content: the reading time words = models.PositiveIntegerField(default=0) # of the content: the reading time
# To be announced (a blog with the bell on): set when the item came in, not on a
# source's first fetch and not for posts older than refresh.NOTIFY_MAX_AGE.
notify = models.BooleanField(default=False, db_index=True)
read_at = models.DateTimeField(null=True, blank=True) read_at = models.DateTimeField(null=True, blank=True)
starred = models.BooleanField(default=False) starred = models.BooleanField(default=False)
story = models.ForeignKey(Story, null=True, blank=True, on_delete=models.SET_NULL, related_name='items') story = models.ForeignKey(Story, null=True, blank=True, on_delete=models.SET_NULL, related_name='items')

View file

@ -25,6 +25,7 @@ BLOG_KEEP = 1000 # items kept per blog
BLOG_FIRST_UNREAD_DAYS = 7 # a new blog's older articles start out read BLOG_FIRST_UNREAD_DAYS = 7 # a new blog's older articles start out read
UNDATED_FIRST_UNREAD = 3 # of a new feed without dates, only the first few UNDATED_FIRST_UNREAD = 3 # of a new feed without dates, only the first few
GONE_KEEP_DAYS = 30 # expired news rows kept (without text) against re-adding GONE_KEEP_DAYS = 30 # expired news rows kept (without text) against re-adding
NOTIFY_MAX_AGE = timedelta(days=2) # older posts are never announced (feeds dig up old ones)
def settings_for(user): def settings_for(user):
@ -91,6 +92,8 @@ def _apply(source, result, now, keep_days):
if source.kind == Source.BLOG and first and ( if source.kind == Source.BLOG and first and (
old_undated or fields['published'] < now - timedelta(days=BLOG_FIRST_UNREAD_DAYS)): old_undated or fields['published'] < now - timedelta(days=BLOG_FIRST_UNREAD_DAYS)):
item.read_at = now item.read_at = now
# The bell: never for what a first fetch brings, never for old posts.
item.notify = source.notify and not first and not undated and fields['published'] >= now - NOTIFY_MAX_AGE
new.append(item) new.append(item)
if new: if new:
try: try:

View file

@ -334,3 +334,29 @@ class BlogFeedTest(TestCase):
self.assertTrue(self.by_id()[self.rare.id]['fav']) self.assertTrue(self.by_id()[self.rare.id]['fav'])
favs = [i['title'] for i in self.client.get('/feeds/blogs/?fav=1').json()['items']] favs = [i['title'] for i in self.client.get('/feeds/blogs/?fav=1').json()['items']]
self.assertEqual(favs, ['Rare essay']) self.assertEqual(favs, ['Rare essay'])
class NotifyTest(TestCase):
def setUp(self):
self.u = User.objects.create_user('tester', password='pw12345678')
self.client.force_login(self.u)
self.src = Source.objects.create(user=self.u, url='https://example.org/feed', title='Blog', kind='blog', notify=True)
def fetch(self, items):
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss(items))):
refresh.refresh([self.src])
def test_rules(self):
old = (timezone.now() - timedelta(days=5)).strftime('%a, %d %b %Y %H:%M:%S +0000')
self.fetch([{'guid': 'a', 'title': 'Schon da', 'text': LONG}]) # first fetch: never
start = self.client.get('/feeds/notifications/').json()
self.assertEqual(start['items'], [])
self.fetch([{'guid': 'a', 'title': 'Schon da', 'text': LONG},
{'guid': 'b', 'title': 'Neuer Post', 'text': LONG},
{'guid': 'c', 'title': 'Alter Post', 'text': LONG, 'date': old}])
got = self.client.get(f"/feeds/notifications/?after={start['last']}").json()
self.assertEqual([i['title'] for i in got['items']], ['Neuer Post'])
# Seen up to there: nothing again; read or bell off: nothing either.
self.assertEqual(self.client.get(f"/feeds/notifications/?after={got['last']}").json()['items'], [])
Source.objects.filter(pk=self.src.pk).update(notify=False)
self.assertEqual(self.client.get(f"/feeds/notifications/?after={start['last']}").json()['items'], [])

View file

@ -21,6 +21,7 @@ urlpatterns = [
path('items/<int:pk>/', views.item, name='feeds_item'), path('items/<int:pk>/', views.item, name='feeds_item'),
path('items/<int:pk>/star/', views.star, name='feeds_star'), path('items/<int:pk>/star/', views.star, name='feeds_star'),
path('settings/', views.settings, name='feeds_settings'), path('settings/', views.settings, name='feeds_settings'),
path('notifications/', views.notifications, name='feeds_notifications'),
path('logins/', views.save_login, name='feeds_save_login'), path('logins/', views.save_login, name='feeds_save_login'),
path('logins/delete/', views.delete_login, name='feeds_delete_login'), path('logins/delete/', views.delete_login, name='feeds_delete_login'),
] ]

View file

@ -19,6 +19,8 @@ Items: GET /feeds/items/<id>/ the article (fetches the page if t
POST /feeds/items/<id>/star/ {starred} POST /feeds/items/<id>/star/ {starred}
GET /feeds/starred/ GET /feeds/starred/
Settings: POST /feeds/settings/ {news_keep_days} Settings: POST /feeds/settings/ {news_keep_days}
Notices: GET /feeds/notifications/?after=<item id> new posts of blogs with the bell on (devices
remember the last id; without after: only that id)
Logins: POST /feeds/logins/ {source, username, password} subscriber login for its site Logins: POST /feeds/logins/ {source, username, password} subscriber login for its site
POST /feeds/logins/delete/ {source} POST /feeds/logins/delete/ {source}
""" """
@ -477,6 +479,24 @@ def star(request, pk):
return JsonResponse({'updated': n}) return JsonResponse({'updated': n})
@csrf_exempt
@require_http_methods(['GET'])
def notifications(request):
"""What a device should announce: posts marked when they came in, still
unread, of blogs that still have the bell on, after the id the device saw
last. Without `after` (a device's first time) nothing, only where to start."""
if (err := _auth(request)):
return err
qs = Item.objects.filter(user=request.user, notify=True)
last = qs.order_by('-id').values_list('id', flat=True).first() or 0
after = request.GET.get('after', '')
if not after.isdigit():
return JsonResponse({'items': [], 'last': last})
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
.select_related('source').order_by('-published')[:50])
return JsonResponse({'items': [_item_data(i) for i in items], 'last': last})
@csrf_exempt @csrf_exempt
@require_http_methods(['POST']) @require_http_methods(['POST'])
def save_login(request): def save_login(request):

View file

@ -327,6 +327,8 @@
'<button type="button" class="fd-chip' + (one.fav ? ' on' : '') + '" data-flag="fav" data-id="' + one.id + '">' + (one.fav ? '★ Favorit' : '☆ Favorit') + '</button>' + '<button type="button" class="fd-chip' + (one.fav ? ' on' : '') + '" data-flag="fav" data-id="' + one.id + '">' + (one.fav ? '★ Favorit' : '☆ Favorit') + '</button>' +
'<button type="button" class="fd-chip' + (one.hidden ? ' on' : '') + '" data-flag="hidden" data-id="' + one.id + '" title="Nicht unter „Alle“, nur im Ordner">' + '<button type="button" class="fd-chip' + (one.hidden ? ' on' : '') + '" data-flag="hidden" data-id="' + one.id + '" title="Nicht unter „Alle“, nur im Ordner">' +
(one.hidden ? 'Im Hauptfeed ausgeblendet' : 'Im Hauptfeed ausblenden') + '</button>' + (one.hidden ? 'Im Hauptfeed ausgeblendet' : 'Im Hauptfeed ausblenden') + '</button>' +
'<button type="button" class="fd-chip' + (one.notify ? ' on' : '') + '" data-flag="notify" data-id="' + one.id + '" title="Benachrichtigung bei neuen Posts (App)">' +
(one.notify ? '🔔 Glocke an' : '🔕 Glocke') + '</button>' +
'<button type="button" class="fd-chip" data-edit-open="' + one.id + '">⚙</button></div>'; '<button type="button" class="fd-chip" data-edit-open="' + one.id + '">⚙</button></div>';
} }
if (!st.items.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '</p>'; if (!st.items.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '</p>';
@ -383,7 +385,7 @@
'<section class="fd-box"><h3>' + title + ' <small>' + list.length + '</small></h3>' + '<section class="fd-box"><h3>' + title + ' <small>' + list.length + '</small></h3>' +
list.map(x => x.id === st.editing ? editForm(x) : list.map(x => x.id === st.editing ? editForm(x) :
'<button type="button" class="fd-src" data-edit="' + x.id + '">' + '<button type="button" class="fd-src" data-edit="' + x.id + '">' +
'<span class="fd-src-title">' + (x.fav ? '★ ' : '') + esc(x.title) + (x.hidden ? ' <small class="fd-note">ausgeblendet</small>' : '') + '</span>' + '<span class="fd-src-title">' + (x.fav ? '★ ' : '') + (x.notify ? '🔔 ' : '') + esc(x.title) + (x.hidden ? ' <small class="fd-note">ausgeblendet</small>' : '') + '</span>' +
'<span class="fd-row-meta">' + (x.kind === 'news' ? esc(x.category || 'Allgemein') + ' · ' : (x.category ? esc(x.category) + ' · ' : '')) + '<span class="fd-row-meta">' + (x.kind === 'news' ? esc(x.category || 'Allgemein') + ' · ' : (x.category ? esc(x.category) + ' · ' : '')) +
(x.unread ? x.unread + ' ungelesen' : '') + (x.unread ? x.unread + ' ungelesen' : '') +
(x.error_count >= 3 ? ' <span class="fd-warn" title="' + esc(x.last_error) + '">⚠ Abruf schlägt fehl</span>' : '') + (x.error_count >= 3 ? ' <span class="fd-warn" title="' + esc(x.last_error) + '">⚠ Abruf schlägt fehl</span>' : '') +
@ -410,7 +412,8 @@
'<div class="fd-grid"><label>Art ' + kindSelect('kind', x.kind) + '</label>' + '<div class="fd-grid"><label>Art ' + kindSelect('kind', x.kind) + '</label>' +
'<label class="fd-cat-field">' + (x.kind === 'news' ? 'Ressort' : 'Ordner') + ' ' + categoryInput(x.category) + '</label>' + '<label class="fd-cat-field">' + (x.kind === 'news' ? 'Ressort' : 'Ordner') + ' ' + categoryInput(x.category) + '</label>' +
(x.kind === 'blog' ? '<label class="fd-check"><input type="checkbox" name="fav"' + (x.fav ? ' checked' : '') + '> Favorit</label>' + (x.kind === 'blog' ? '<label class="fd-check"><input type="checkbox" name="fav"' + (x.fav ? ' checked' : '') + '> Favorit</label>' +
'<label class="fd-check"><input type="checkbox" name="hidden"' + (x.hidden ? ' checked' : '') + '> Im Hauptfeed ausblenden (nur im Ordner)</label>' : '') + '<label class="fd-check"><input type="checkbox" name="hidden"' + (x.hidden ? ' checked' : '') + '> Im Hauptfeed ausblenden (nur im Ordner)</label>' +
'<label class="fd-check"><input type="checkbox" name="notify"' + (x.notify ? ' checked' : '') + '> Benachrichtigen bei neuen Posts</label>' : '') +
'<label>Ganzer Artikel <select class="search-input" name="fulltext">' + '<label>Ganzer Artikel <select class="search-input" name="fulltext">' +
[['auto', 'Automatisch'], ['always', 'Immer holen'], ['never', 'Nur Feed-Text']].map(o => [['auto', 'Automatisch'], ['always', 'Immer holen'], ['never', 'Nur Feed-Text']].map(o =>
'<option value="' + o[0] + '"' + (x.fulltext === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') + '<option value="' + o[0] + '"' + (x.fulltext === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') +
@ -834,6 +837,7 @@
const change = {title: f.title.value, kind: f.kind.value, category: f.category.value, fulltext: f.fulltext.value}; const change = {title: f.title.value, kind: f.kind.value, category: f.category.value, fulltext: f.fulltext.value};
if (f.fav) change.fav = f.fav.checked; if (f.fav) change.fav = f.fav.checked;
if (f.hidden) change.hidden = f.hidden.checked; if (f.hidden) change.hidden = f.hidden.checked;
if (f.notify) change.notify = f.notify.checked;
await api('/feeds/sources/' + f.dataset.id + '/', change); await api('/feeds/sources/' + f.dataset.id + '/', change);
st.editing = null; st.editing = null;
await loadState(); await loadState();
@ -867,6 +871,12 @@
const [state, briefing] = await Promise.all([api('/feeds/state/'), api('/feeds/briefing/')]); const [state, briefing] = await Promise.all([api('/feeds/state/'), api('/feeds/briefing/')]);
return {state, briefing}; return {state, briefing};
}; };
// A notification about a new post was tapped (the app, MainActivity): open it.
window.__dioraFeedsOpenItem = function (id) {
showTab('feeds');
readerList = st.items;
openItem(id, null);
};
window.__dioraFeedsOpenStory = function (story) { window.__dioraFeedsOpenStory = function (story) {
if (!st.stories.some(x => x.id === story.id)) st.stories.push(story); if (!st.stories.some(x => x.id === story.id)) st.stories.push(story);
openStory(story.id); openStory(story.id);

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v93'; const CACHE = 'diora-v94';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app.js', '/static/js/app.js',