Feeds: Glocke je Blog (Benachrichtigung bei neuen Posts)
- 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:
parent
b063e91113
commit
74bb3a44df
8 changed files with 84 additions and 3 deletions
18
feeds/migrations/0004_item_notify.py
Normal file
18
feeds/migrations/0004_item_notify.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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'], [])
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue