Blogs: eigene Ordner (mehrere je Blog, umbenennen, auflösen), Lesefortschritt
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 7m47s

- Source.folders (Etiketten, Groß/Klein egal, max. 40 Zeichen); die bisherige
  Kategorie eines Blogs wird sein erster Ordner (Migration)
- /feeds/folders/rename/ und /dissolve/; in den Blog-Einstellungen Häkchen je
  Ordner plus „Neuer Ordner“; langer Druck/Rechtsklick auf einen Ordner: öffnen,
  umbenennen, auflösen; Ordnerliste in „Feeds verwalten“
- Lesefortschritt je Post (Item.progress, geräteübergreifend): die Leseansicht
  springt zurück an die Stelle, ab 90 % gilt ein Blogpost als gelesen (Nachrichten
  weiter beim Öffnen); Fortschrittsbalken und „Weiterlesen“ oben in den Blogs
- sw v95

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-04 21:54:04 +02:00
parent 74bb3a44df
commit 886c526201
7 changed files with 381 additions and 25 deletions

View file

@ -0,0 +1,46 @@
# Generated by Django 6.1 on 2026-10-04 19:50
from django.db import migrations, models
def category_to_folders(apps, schema_editor):
"""A blog's folder was its category: now its first (only) folder."""
Source = apps.get_model('feeds', 'Source')
for s in Source.objects.filter(kind='blog').exclude(category=''):
s.folders = [s.category[:40]]
s.category = ''
s.save(update_fields=['folders', 'category'])
def folders_to_category(apps, schema_editor):
Source = apps.get_model('feeds', 'Source')
for s in Source.objects.filter(kind='blog'):
if s.folders:
s.category = s.folders[0]
s.save(update_fields=['category'])
class Migration(migrations.Migration):
dependencies = [
('feeds', '0004_item_notify'),
]
operations = [
migrations.AddField(
model_name='item',
name='progress',
field=models.FloatField(default=0),
),
migrations.AddField(
model_name='item',
name='progress_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='source',
name='folders',
field=models.JSONField(blank=True, default=list),
),
migrations.RunPython(category_to_folders, folders_to_category),
]

View file

@ -26,6 +26,8 @@ class Source(models.Model):
kind = models.CharField(max_length=10, choices=KINDS, default=NEWS) kind = models.CharField(max_length=10, choices=KINDS, default=NEWS)
# News: the section it belongs to (Politik, Tech …), groups the briefing. # News: the section it belongs to (Politik, Tech …), groups the briefing.
category = models.CharField(max_length=100, blank=True) category = models.CharField(max_length=100, blank=True)
# Blogs: the folders it's in (labels, any number; a folder exists while a blog carries it).
folders = models.JSONField(default=list, blank=True)
fulltext = models.CharField(max_length=10, choices=FULLTEXTS, default=FULLTEXT_AUTO) fulltext = models.CharField(max_length=10, choices=FULLTEXTS, default=FULLTEXT_AUTO)
added_at = models.DateTimeField(auto_now_add=True) added_at = models.DateTimeField(auto_now_add=True)
# Blogs: favorites come first; a hidden blog stays out of "Alle" (and its # Blogs: favorites come first; a hidden blog stays out of "Alle" (and its
@ -92,6 +94,10 @@ class Item(models.Model):
# source's first fetch and not for posts older than refresh.NOTIFY_MAX_AGE. # source's first fetch and not for posts older than refresh.NOTIFY_MAX_AGE.
notify = models.BooleanField(default=False, db_index=True) notify = models.BooleanField(default=False, db_index=True)
read_at = models.DateTimeField(null=True, blank=True) read_at = models.DateTimeField(null=True, blank=True)
# How far it was read (0-1, the article's scroll position), for every device;
# from READ_AT on it counts as read (views.item_progress).
progress = models.FloatField(default=0)
progress_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')
# Expired news: the row stays a while without its text, so the entry isn't # Expired news: the row stays a while without its text, so the entry isn't

View file

@ -298,8 +298,8 @@ class BlogFeedTest(TestCase):
self.u = User.objects.create_user('tester', password='pw12345678') self.u = User.objects.create_user('tester', password='pw12345678')
self.client.force_login(self.u) self.client.force_login(self.u)
now = timezone.now() now = timezone.now()
self.busy = Source.objects.create(user=self.u, url='https://busy/', title='Busy', kind='blog', category='Politik') self.busy = Source.objects.create(user=self.u, url='https://busy/', title='Busy', kind='blog', folders=['Politik'])
self.rare = Source.objects.create(user=self.u, url='https://rare/', title='Rare', kind='blog', category='Politik') self.rare = Source.objects.create(user=self.u, url='https://rare/', title='Rare', kind='blog', folders=['Politik'])
for n in range(10): # a post every two hours 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}', Item.objects.create(source=self.busy, user=self.u, kind='blog', guid_hash=f'b{n}', title=f'Busy {n}',
content='<p>' + 'wort ' * 460 + '</p>', words=460, published=now - timedelta(hours=2 * n)) content='<p>' + 'wort ' * 460 + '</p>', words=460, published=now - timedelta(hours=2 * n))
@ -360,3 +360,38 @@ class NotifyTest(TestCase):
self.assertEqual(self.client.get(f"/feeds/notifications/?after={got['last']}").json()['items'], []) self.assertEqual(self.client.get(f"/feeds/notifications/?after={got['last']}").json()['items'], [])
Source.objects.filter(pk=self.src.pk).update(notify=False) Source.objects.filter(pk=self.src.pk).update(notify=False)
self.assertEqual(self.client.get(f"/feeds/notifications/?after={start['last']}").json()['items'], []) self.assertEqual(self.client.get(f"/feeds/notifications/?after={start['last']}").json()['items'], [])
class FolderAndProgressTest(TestCase):
def setUp(self):
self.u = User.objects.create_user('tester', password='pw12345678')
self.client.force_login(self.u)
self.a = Source.objects.create(user=self.u, url='https://a/', title='A', kind='blog', folders=['Philosophie'])
self.b = Source.objects.create(user=self.u, url='https://b/', title='B', kind='blog', folders=['philosophie', 'Rust'])
self.post_a = Item.objects.create(source=self.a, user=self.u, kind='blog', guid_hash='a', title='Essay',
published=timezone.now())
def post(self, url, data):
return self.client.post(url, json.dumps(data), content_type='application/json')
def test_folders_case_insensitive_rename_dissolve(self):
self.assertEqual(self.client.get('/feeds/state/').json()['folders'], ['Philosophie', 'Rust'])
self.post(f'/feeds/sources/{self.a.id}/', {'folders': [' rust ', 'Neu', 'neu']})
self.a.refresh_from_db()
self.assertEqual(self.a.folders, ['Rust', 'Neu']) # spelled as the existing one, no doubles
self.post('/feeds/folders/rename/', {'name': 'rust', 'new': 'Rust & Linux'})
self.a.refresh_from_db(); self.b.refresh_from_db()
self.assertEqual(self.a.folders, ['Neu', 'Rust & Linux'])
self.assertEqual(self.b.folders, ['philosophie', 'Rust & Linux'])
self.post('/feeds/folders/dissolve/', {'name': 'Rust & Linux'})
self.b.refresh_from_db()
self.assertEqual(self.b.folders, ['philosophie'])
self.assertTrue(Source.objects.filter(pk=self.b.pk).exists())
titles = [i['title'] for i in self.client.get('/feeds/blogs/?folder=Neu').json()['items']]
self.assertEqual(titles, ['Essay'])
def test_progress_marks_read_at_ninety_percent(self):
r = self.post(f'/feeds/items/{self.post_a.id}/progress/', {'progress': 0.4}).json()
self.assertEqual(r, {'progress': 0.4, 'read': False})
self.assertEqual(self.client.get('/feeds/blogs/').json()['items'][0]['progress'], 0.4)
self.assertTrue(self.post(f'/feeds/items/{self.post_a.id}/progress/', {'progress': 0.95}).json()['read'])

View file

@ -20,6 +20,9 @@ urlpatterns = [
path('items/read/', views.mark_read, name='feeds_mark_read'), path('items/read/', views.mark_read, name='feeds_mark_read'),
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('items/<int:pk>/progress/', views.item_progress, name='feeds_item_progress'),
path('folders/rename/', views.rename_folder, name='feeds_rename_folder'),
path('folders/dissolve/', views.dissolve_folder, name='feeds_dissolve_folder'),
path('settings/', views.settings, name='feeds_settings'), path('settings/', views.settings, name='feeds_settings'),
path('notifications/', views.notifications, name='feeds_notifications'), 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'),

View file

@ -4,7 +4,9 @@ Sources: GET /feeds/state/ sources, unread counts, settings
POST /feeds/discover/ {url} -> the feeds a page or URL offers POST /feeds/discover/ {url} -> the feeds a page or URL offers
POST /feeds/sources/add/ {url, kind, category, title?} POST /feeds/sources/add/ {url, kind, category, title?}
POST /feeds/sources/bulk/ {sources: [{url, title, kind, category}]} (OPML import) POST /feeds/sources/bulk/ {sources: [{url, title, kind, category}]} (OPML import)
POST /feeds/sources/<id>/ {title, kind, category, fulltext} POST /feeds/sources/<id>/ {title, kind, category (news), folders (blogs), fulltext, fav, hidden, notify}
POST /feeds/folders/rename/ {name, new} (all blogs in it)
POST /feeds/folders/dissolve/ {name} (the blogs stay)
POST /feeds/sources/<id>/delete/ POST /feeds/sources/<id>/delete/
GET /feeds/export.opml GET /feeds/export.opml
POST /feeds/refresh/ refresh the account's sources now POST /feeds/refresh/ refresh the account's sources now
@ -12,11 +14,12 @@ News: GET /feeds/briefing/ what's new since the briefing was
POST /feeds/briefing/seen/ seen: everything up to now counts as read POST /feeds/briefing/seen/ seen: everything up to now counts as read
GET /feeds/news/?before=&category= stories, newest first GET /feeds/news/?before=&category= stories, newest first
GET /feeds/stories/<id>/ one story with all its items GET /feeds/stories/<id>/ one story with all its items
Blogs: GET /feeds/blogs/?folder=&source=&unread=1&fav=1&before= (a blog's category is its folder; Blogs: GET /feeds/blogs/?folder=&source=&unread=1&fav=1&before= (folder= alone: blogs without one;
without folder/source the hidden blogs stay out) without folder/source the hidden blogs stay out)
Items: GET /feeds/items/<id>/ the article (fetches the page if the feed had a teaser) 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/read/ {ids, read}
POST /feeds/items/<id>/star/ {starred} POST /feeds/items/<id>/star/ {starred}
POST /feeds/items/<id>/progress/ {progress} 0-1; from 0.9 on it's read
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 Notices: GET /feeds/notifications/?after=<item id> new posts of blogs with the bell on (devices
@ -47,6 +50,8 @@ BLOG_PAGE = 150 # blog posts per page (all unread ones come at once, up t
BLOG_UNREAD_MAX = 600 BLOG_UNREAD_MAX = 600
NEW_HOURS = 24 # a blog post this young and unread is "new" 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 RARE_POSTS = 10 # how often a blog posts: the mean gap over its last this many posts
FOLDER_MAX = 40 # characters of a folder name
READ_AT = 0.9 # read this far, a post counts as read
BRIEFING_TOP = 8 BRIEFING_TOP = 8
BRIEFING_PER_CATEGORY = 5 BRIEFING_PER_CATEGORY = 5
REFRESH_WAIT = 40 # seconds an explicit refresh may take REFRESH_WAIT = 40 # seconds an explicit refresh may take
@ -73,7 +78,8 @@ def _iso(dt):
def _source_data(s, unread=0, new=0, interval=None): def _source_data(s, unread=0, new=0, interval=None):
return { return {
'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url, '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, 'new': new, 'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext,
'unread': unread, 'new': new,
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, 'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify,
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare. # Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
'interval_days': interval, 'interval_days': interval,
@ -86,7 +92,7 @@ def _item_data(i, content=False):
d = { d = {
'id': i.id, 'source': i.source_id, 'source_title': i.source.title, 'kind': i.kind, '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), 'words': i.words, '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, 'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'progress': i.progress,
'starred': i.starred, 'story': i.story_id, 'starred': i.starred, 'story': i.story_id,
} }
if content: if content:
@ -140,7 +146,7 @@ def state(request):
return JsonResponse({ return JsonResponse({
'sources': [_source_data(x, unread.get(x.id, 0), new.get(x.id, 0), intervals.get(x.id)) 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}), '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}), 'folders': _all_folders(sources),
'news_new': new_news, 'news_new': new_news,
'blogs_unread': sum(unread.get(x.id, 0) for x in sources if x.kind == Source.BLOG and not x.hidden), '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), 'blogs_new': sum(new.get(x.id, 0) for x in sources if x.kind == Source.BLOG and not x.hidden),
@ -148,6 +154,37 @@ def state(request):
}) })
def _clean_folder(name):
return ' '.join(str(name or '').split())[:FOLDER_MAX]
def _all_folders(sources):
"""The folders the blogs carry, each once (case doesn't matter), sorted."""
seen = {}
for s in sources:
if s.kind == Source.BLOG:
for f in s.folders or []:
seen.setdefault(f.casefold(), f)
return sorted(seen.values(), key=str.casefold)
def _folders_for(user, names):
"""Folder names cleaned, without doubles, spelled as an existing folder is."""
existing = {f.casefold(): f for f in _all_folders(Source.objects.filter(user=user, kind=Source.BLOG))}
out = []
for n in names or []:
n = _clean_folder(n)
if n and n.casefold() not in {o.casefold() for o in out}:
out.append(existing.get(n.casefold(), n))
return out
def _in_folder(source, name):
if name == '':
return not source.folders
return name.casefold() in {f.casefold() for f in source.folders or []}
def _intervals(user, blogs, now): def _intervals(user, blogs, now):
"""Mean days between the last RARE_POSTS posts of each blog, up to 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.""" (now - the oldest of them) / their number; one post counts as 30 days."""
@ -218,6 +255,16 @@ def discover(request):
return JsonResponse({'feeds': out}) return JsonResponse({'feeds': out})
def _place(user, kind, category, folders, **more):
"""Where a new source goes: news into a section (category), a blog into
folders (the category of an OPML folder or the add dialog is its folder)."""
category = (category or '').strip()[:100]
if kind == Source.NEWS:
return dict(kind=kind, category=category, **more)
names = folders if isinstance(folders, list) else ([category] if category else [])
return dict(kind=kind, category='', folders=_folders_for(user, names), **more)
def _kind(v): def _kind(v):
return v if v in (Source.NEWS, Source.BLOG) else None return v if v in (Source.NEWS, Source.BLOG) else None
@ -234,8 +281,8 @@ def add_source(request):
return JsonResponse({'error': 'Adresse und Art (Nachrichten/Blog) nötig'}, status=400) return JsonResponse({'error': 'Adresse und Art (Nachrichten/Blog) nötig'}, status=400)
source, created = Source.objects.get_or_create( source, created = Source.objects.get_or_create(
user=request.user, url=url[:1000], user=request.user, url=url[:1000],
defaults={'title': (body.get('title') or url)[:300], 'kind': kind, defaults=_place(request.user, kind, body.get('category'), body.get('folders'),
'category': (body.get('category') or '').strip()[:100]}) title=(body.get('title') or url)[:300]))
if created: if created:
refresh.refresh([source], deadline=time.monotonic() + 25) refresh.refresh([source], deadline=time.monotonic() + 25)
source.refresh_from_db() source.refresh_from_db()
@ -258,8 +305,8 @@ def add_sources(request):
continue continue
_, created = Source.objects.get_or_create( _, created = Source.objects.get_or_create(
user=request.user, url=url[:1000], user=request.user, url=url[:1000],
defaults={'title': (x.get('title') or url)[:300], 'kind': kind, defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
'category': (x.get('category') or '').strip()[:100]}) title=(x.get('title') or url)[:300]))
added += created added += created
sources = Source.objects.filter(user=request.user, last_refreshed_at=None) sources = Source.objects.filter(user=request.user, last_refreshed_at=None)
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT) refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
@ -280,12 +327,23 @@ def edit_source(request, pk):
for flag in ('fav', 'hidden', 'notify'): for flag in ('fav', 'hidden', 'notify'):
if isinstance(body.get(flag), bool): if isinstance(body.get(flag), bool):
setattr(source, flag, body[flag]) setattr(source, flag, body[flag])
if 'category' in body: if isinstance(body.get('folders'), list):
source.folders = _folders_for(request.user, body['folders'])
elif 'category' in body and source.kind == Source.BLOG and _kind(body.get('kind')) != Source.NEWS:
# Older clients: one folder in the category field.
cat = _clean_folder(body['category'])
source.folders = _folders_for(request.user, [cat]) if cat else []
if 'category' in body and (source.kind == Source.NEWS or _kind(body.get('kind')) == Source.NEWS):
source.category = (body['category'] or '').strip()[:100] source.category = (body['category'] or '').strip()[:100]
if body.get('fulltext') in dict(Source.FULLTEXTS): if body.get('fulltext') in dict(Source.FULLTEXTS):
source.fulltext = body['fulltext'] source.fulltext = body['fulltext']
new_kind = _kind(body.get('kind')) new_kind = _kind(body.get('kind'))
if new_kind and new_kind != source.kind: if new_kind and new_kind != source.kind:
# A section becomes a folder and back.
if new_kind == Source.BLOG and source.category and not source.folders:
source.folders, source.category = _folders_for(request.user, [source.category]), ''
elif new_kind == Source.NEWS and source.folders and not source.category:
source.category = source.folders[0][:100]
source.kind = new_kind source.kind = new_kind
# Its items follow; as blog articles they leave their stories. # Its items follow; as blog articles they leave their stories.
source.items.update(kind=new_kind, **({'story': None} if new_kind == Source.BLOG else {})) source.items.update(kind=new_kind, **({'story': None} if new_kind == Source.BLOG else {}))
@ -312,7 +370,8 @@ def export_opml(request):
groups = {} groups = {}
for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'): for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'):
kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs' kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs'
label = kind + (f' – {s.category}' if s.category else '') place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0])
label = kind + (f' – {place}' if place else '')
groups.setdefault(label, []).append(s) groups.setdefault(label, []).append(s)
lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<opml version="2.0">', lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<opml version="2.0">',
'<head><title>diora Feeds</title></head>', '<body>'] '<head><title>diora Feeds</title></head>', '<body>']
@ -418,7 +477,9 @@ def blogs(request):
qs = qs.filter(source_id=int(request.GET['source'])) qs = qs.filter(source_id=int(request.GET['source']))
elif 'folder' in request.GET: elif 'folder' in request.GET:
# A folder shows all its blogs, the hidden ones too. # A folder shows all its blogs, the hidden ones too.
qs = qs.filter(source__category=request.GET['folder']) name = request.GET['folder']
ids = [s.id for s in Source.objects.filter(user=request.user, kind=Source.BLOG) if _in_folder(s, name)]
qs = qs.filter(source_id__in=ids)
else: else:
qs = qs.filter(source__hidden=False) qs = qs.filter(source__hidden=False)
if request.GET.get('fav') == '1': if request.GET.get('fav') == '1':
@ -469,6 +530,64 @@ def mark_read(request):
return JsonResponse({'updated': n}) return JsonResponse({'updated': n})
@csrf_exempt
@require_http_methods(['POST'])
def item_progress(request, pk):
"""How far the article was read (from any device); from READ_AT on it's read."""
if (err := _auth(request)):
return err
body = _body(request) or {}
try:
progress = min(1.0, max(0.0, float(body.get('progress'))))
except (TypeError, ValueError):
return JsonResponse({'error': 'progress 0-1'}, status=400)
i = Item.objects.filter(user=request.user, pk=pk).first()
if i is None:
return JsonResponse({'error': 'not found'}, status=404)
i.progress = progress
i.progress_at = timezone.now()
if progress >= READ_AT and i.read_at is None:
i.read_at = i.progress_at
i.save(update_fields=['progress', 'progress_at', 'read_at'])
return JsonResponse({'progress': i.progress, 'read': i.read_at is not None})
@csrf_exempt
@require_http_methods(['POST'])
def rename_folder(request):
"""A folder's new name, on every blog that carries it."""
if (err := _auth(request)):
return err
body = _body(request) or {}
old, new = _clean_folder(body.get('name')), _clean_folder(body.get('new'))
if not old or not new:
return JsonResponse({'error': 'Name fehlt'}, status=400)
n = 0
for s in Source.objects.filter(user=request.user, kind=Source.BLOG):
if _in_folder(s, old):
kept = [f for f in s.folders if f.casefold() != old.casefold() and f.casefold() != new.casefold()]
s.folders = kept + [new]
s.save(update_fields=['folders'])
n += 1
return JsonResponse({'blogs': n})
@csrf_exempt
@require_http_methods(['POST'])
def dissolve_folder(request):
"""The folder goes from every blog; the blogs stay subscribed."""
if (err := _auth(request)):
return err
name = _clean_folder((_body(request) or {}).get('name'))
n = 0
for s in Source.objects.filter(user=request.user, kind=Source.BLOG):
if name and _in_folder(s, name):
s.folders = [f for f in s.folders if f.casefold() != name.casefold()]
s.save(update_fields=['folders'])
n += 1
return JsonResponse({'blogs': n})
@csrf_exempt @csrf_exempt
@require_http_methods(['POST']) @require_http_methods(['POST'])
def star(request, pk): def star(request, pk):

View file

@ -233,6 +233,13 @@
const NEW_MS = 24 * 3600000; const NEW_MS = 24 * 3600000;
const FAV_PER_DAY = 5; // posts of one favorite blog that go first per section const FAV_PER_DAY = 5; // posts of one favorite blog that go first per section
function inFolderOf(b, f) {
const list = b.folders || [];
if (f === '') return !list.length;
const k = f.toLowerCase();
return list.some(x => x.toLowerCase() === k);
}
function isNew(i, src) { function isNew(i, src) {
return !i.read && Date.now() - new Date(i.published).getTime() < NEW_MS && !(src && src.hidden); return !i.read && Date.now() - new Date(i.published).getTime() < NEW_MS && !(src && src.hidden);
} }
@ -291,7 +298,7 @@
blogs.forEach(b => { byId[b.id] = b; }); blogs.forEach(b => { byId[b.id] = b; });
// Folders (a blog's category): chips with their unread count, a dot when something is new. // Folders (a blog's category): chips with their unread count, a dot when something is new.
const folders = (st.state.folders || []); const folders = (st.state.folders || []);
const inFolder = f => blogs.filter(b => (b.category || '') === f); const inFolder = f => blogs.filter(b => inFolderOf(b, f));
const sum = (list, key) => list.reduce((n, b) => n + b[key], 0); const sum = (list, key) => list.reduce((n, b) => n + b[key], 0);
const visible = blogs.filter(b => !b.hidden); const visible = blogs.filter(b => !b.hidden);
let h = ''; let h = '';
@ -302,7 +309,7 @@
(sum(list, 'unread') ? ' <small>' + sum(list, 'unread') + '</small>' : '') + '</button>'; (sum(list, 'unread') ? ' <small>' + sum(list, 'unread') + '</small>' : '') + '</button>';
h += '<div class="fd-chips">' + chip(null, 'Alle', visible) + h += '<div class="fd-chips">' + chip(null, 'Alle', visible) +
folders.map(f => chip(f, f, inFolder(f))).join('') + folders.map(f => chip(f, f, inFolder(f))).join('') +
(blogs.some(b => !b.category) ? chip('', 'Ohne Ordner', inFolder('')) : '') + '</div>'; (blogs.some(b => !(b.folders || []).length) ? chip('', 'Ohne Ordner', inFolder('')) : '') + '</div>';
} }
const main = st.blogFolder === null; const main = st.blogFolder === null;
const shown = main ? blogs : inFolder(st.blogFolder); const shown = main ? blogs : inFolder(st.blogFolder);
@ -332,6 +339,15 @@
'<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>';
const reading = st.items.filter(i => !i.read && i.progress >= 0.05 && i.progress < 0.9)
.sort((a, b) => b.progress - a.progress).slice(0, 3);
if (reading.length) {
h += '<h3 class="fd-day">Weiterlesen</h3>' + reading.map(i =>
'<button type="button" class="fd-post fd-continue" data-item="' + i.id + '">' +
'<span class="fd-card-title">' + esc(i.title) + '</span>' +
'<span class="fd-row-meta">' + esc(i.source_title) + ' · ' + Math.round(i.progress * 100) + ' % gelesen</span>' +
'<span class="fd-prog"><span style="width:' + Math.round(i.progress * 100) + '%"></span></span></button>').join('');
}
blogSections(st.items, byId).forEach(sec => { blogSections(st.items, byId).forEach(sec => {
h += '<h3 class="fd-day">' + esc(sec.label) + '</h3>'; h += '<h3 class="fd-day">' + esc(sec.label) + '</h3>';
h += sec.items.map(i => { h += sec.items.map(i => {
@ -341,6 +357,7 @@
'<span class="fd-card-title">' + (isNew(i, src) ? '<span class="fd-new">NEU</span> ' : '') + (i.starred ? '★ ' : '') + esc(i.title) + '</span>' + '<span class="fd-card-title">' + (isNew(i, src) ? '<span class="fd-new">NEU</span> ' : '') + (i.starred ? '★ ' : '') + esc(i.title) + '</span>' +
'<span class="fd-row-meta">' + (src && src.fav ? '★ ' : '') + esc(i.source_title) + ' · ' + esc(when(i.published)) + (rt ? ' · ' + rt : '') + '</span>' + '<span class="fd-row-meta">' + (src && src.fav ? '★ ' : '') + esc(i.source_title) + ' · ' + esc(when(i.published)) + (rt ? ' · ' + rt : '') + '</span>' +
(i.summary ? '<span class="fd-card-sum">' + esc(i.summary) + '</span>' : '') + (i.summary ? '<span class="fd-card-sum">' + esc(i.summary) + '</span>' : '') +
(!i.read && i.progress > 0.02 ? '<span class="fd-prog"><span style="width:' + Math.round(i.progress * 100) + '%"></span></span>' : '') +
'</button>'; '</button>';
}).join(''); }).join('');
}); });
@ -386,12 +403,22 @@
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 ? '★ ' : '') + (x.notify ? '🔔 ' : '') + 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.folders || []).length ? esc(x.folders.join(', ')) + ' · ' : '')) +
(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>' : '') +
'</span></button>').join('') + '</section>'; '</span></button>').join('') + '</section>';
h += group('Nachrichten', news.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.title.localeCompare(b.title))); h += group('Nachrichten', news.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.title.localeCompare(b.title)));
h += group('Blogs', blogs.sort((a, b) => (a.category || '~').localeCompare(b.category || '~') || a.title.localeCompare(b.title))); const firstFolder = b => ((b.folders || [])[0] || '~').toLowerCase();
h += group('Blogs', blogs.sort((a, b) => firstFolder(a).localeCompare(firstFolder(b)) || a.title.localeCompare(b.title)));
// The folders themselves: rename, dissolve (the blogs stay).
const folders = (s.folders || []);
if (folders.length) {
h += '<section class="fd-box"><h3>Ordner <small>' + folders.length + '</small></h3>' + folders.map(f =>
'<div class="fd-folder-row"><span>' + esc(f) + ' <small class="fd-note">' + blogs.filter(b => inFolderOf(b, f)).length + ' Blogs</small></span>' +
'<span class="fd-buttons"><button type="button" class="btn" data-act="folder-rename" data-name="' + esc(f) + '">Umbenennen</button>' +
'<button type="button" class="btn" data-act="folder-dissolve" data-name="' + esc(f) + '">Auflösen</button></span></div>').join('') +
'<p class="fd-note">Tipp: lange auf einen Ordner in der Blog-Ansicht drücken (oder Rechtsklick) öffnet dasselbe.</p></section>';
}
h += '<section class="fd-box"><h3>Import &amp; Einstellungen</h3>' + h += '<section class="fd-box"><h3>Import &amp; 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> ' + '<label class="btn fd-file">OPML importieren …<input type="file" accept=".opml,.xml,text/xml,text/x-opml" data-act="opml" hidden></label> ' +
@ -410,7 +437,7 @@
'<div class="fd-note fd-url">' + esc(x.url) + '</div>' + '<div class="fd-note fd-url">' + esc(x.url) + '</div>' +
(x.last_error ? '<p class="fd-warn">Letzter Fehler: ' + esc(x.last_error) + '</p>' : '') + (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>' + '<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>' + (x.kind === 'news' ? '<label class="fd-cat-field">Ressort ' + categoryInput(x.category) + '</label>' : folderPicker(x)) +
(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 class="fd-check"><input type="checkbox" name="notify"' + (x.notify ? ' checked' : '') + '> Benachrichtigen bei neuen Posts</label>' : '') +
@ -424,6 +451,14 @@
loginForm(x); loginForm(x);
} }
function folderPicker(x) {
const all = (st.state && st.state.folders) || [];
return '<fieldset class="fd-folders"><legend>Ordner</legend>' +
all.map(f => '<label class="fd-check"><input type="checkbox" name="folder" value="' + esc(f) + '"' +
(inFolderOf(x, f) ? ' checked' : '') + '> ' + esc(f) + '</label>').join('') +
'<input class="search-input" name="newfolder" maxlength="40" placeholder="Neuer Ordner"></fieldset>';
}
// A paywalled site (junge Welt …): the subscriber login, kept encrypted on the // A paywalled site (junge Welt …): the subscriber login, kept encrypted on the
// server, which then fetches whole articles. The password never comes back. // server, which then fetches whole articles. The password never comes back.
function loginForm(x) { function loginForm(x) {
@ -498,6 +533,10 @@
reader.id = 'fd-article'; reader.id = 'fd-article';
reader.hidden = true; reader.hidden = true;
reader.addEventListener('click', onReaderClick); reader.addEventListener('click', onReaderClick);
reader.addEventListener('scroll', () => {
clearTimeout(progressTimer);
progressTimer = setTimeout(saveProgress, 1200);
}, {passive: true});
document.body.appendChild(reader); document.body.appendChild(reader);
return reader; return reader;
} }
@ -508,6 +547,7 @@
function closeReader() { function closeReader() {
if (!reader) return; if (!reader) return;
saveProgress();
reader.hidden = true; reader.hidden = true;
reader.innerHTML = ''; reader.innerHTML = '';
document.documentElement.classList.remove('fd-reading'); document.documentElement.classList.remove('fd-reading');
@ -515,8 +555,34 @@
render(); render();
} }
// How far the open article is read (0-1); saved now and then and when leaving it.
let progressSent = 0;
let progressTimer = null;
function readProgress() {
if (!reader || reader.hidden) return 0;
const room = reader.scrollHeight - reader.clientHeight;
return room <= 40 ? 1 : Math.min(1, reader.scrollTop / room);
}
function saveProgress() {
const i = readerItem;
if (!i) return;
const p = Math.round(readProgress() * 100) / 100;
if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return;
progressSent = p;
i.progress = p;
st.items.forEach(x => { if (x.id === i.id) x.progress = p; });
if (p >= 0.9 && !i.read) {
i.read = true;
markLocal([i.id], true);
}
api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {});
}
async function openItem(id, story) { async function openItem(id, story) {
ensureReader(); ensureReader();
if (readerItem) saveProgress();
readerStory = story || null; readerStory = story || null;
reader.hidden = false; reader.hidden = false;
document.documentElement.classList.add('fd-reading'); document.documentElement.classList.add('fd-reading');
@ -527,11 +593,22 @@
readerItem = data.item; readerItem = data.item;
reader.innerHTML = readerShell(articleHtml(readerItem)); reader.innerHTML = readerShell(articleHtml(readerItem));
reader.scrollTop = 0; reader.scrollTop = 0;
progressSent = readerItem.progress || 0;
if (readerItem.kind === 'news') {
// News is read once opened; a blog post once read to 90 % (the server keeps where).
if (!readerItem.read) { if (!readerItem.read) {
readerItem.read = true; readerItem.read = true;
markLocal([id], true); markLocal([id], true);
api('/feeds/items/read/', {ids: [id], read: true}).catch(() => {}); api('/feeds/items/read/', {ids: [id], read: true}).catch(() => {});
} }
} else {
if (progressSent > 0.02 && progressSent < 0.9) {
// Back where reading stopped (on any device).
requestAnimationFrame(() => { reader.scrollTop = progressSent * (reader.scrollHeight - reader.clientHeight); });
} else {
setTimeout(saveProgress, 1500); // short enough to fit: read at once
}
}
} catch (e) { } catch (e) {
reader.innerHTML = readerShell('<p class="fd-error">' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '</p>'); reader.innerHTML = readerShell('<p class="fd-error">' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '</p>');
} }
@ -642,6 +719,57 @@
openItem(lead.id, story); openItem(lead.id, story);
} }
// ---- folders: rename, dissolve
async function folderAction(act, name) {
if (act === 'folder-rename') {
const neu = typeof customPrompt === 'function' ? await customPrompt('Ordner „' + name + '“ umbenennen in:', name) : window.prompt('Neuer Name', name);
if (!neu || !neu.trim() || neu.trim() === name) return;
await api('/feeds/folders/rename/', {name, new: neu.trim()}).catch(() => {});
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = neu.trim().slice(0, 40);
} else {
const ok = typeof customConfirm === 'function'
? await customConfirm('Ordner „' + name + '“ auflösen? Die Blogs bleiben abonniert.')
: window.confirm('Auflösen?');
if (!ok) return;
await api('/feeds/folders/dissolve/', {name}).catch(() => {});
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = null;
}
await loadState().catch(() => {});
if (st.view === 'blogs') return load();
render();
}
tab.addEventListener('contextmenu', e => {
const chip = e.target.closest('[data-folder]');
if (!chip || chip.dataset.folder === '*' || chip.dataset.folder === '') return;
e.preventDefault();
folderSheet(chip.dataset.folder);
});
// The long-press menu of a folder: open, rename, dissolve.
function folderSheet(name) {
const old = document.getElementById('fd-sheet');
if (old) old.remove();
const sheet = document.createElement('div');
sheet.id = 'fd-sheet';
sheet.innerHTML = '<div class="fd-sheet-box"><div class="fd-sheet-title">' + esc(name) + '</div>' +
'<button type="button" data-s="open">Ordner öffnen</button>' +
'<button type="button" data-s="rename">Umbenennen …</button>' +
'<button type="button" data-s="dissolve">Auflösen</button>' +
'<button type="button" data-s="cancel">Abbrechen</button></div>';
sheet.addEventListener('click', e => {
const b = e.target.closest('[data-s]');
if (!b && e.target !== sheet) return;
sheet.remove();
const what = b ? b.dataset.s : 'cancel';
if (what === 'open') { st.blogFolder = name; st.blogSource = ''; load(); }
if (what === 'rename') folderAction('folder-rename', name);
if (what === 'dissolve') folderAction('folder-dissolve', name);
});
document.body.appendChild(sheet);
}
// ---- events // ---- events
tab.addEventListener('click', async e => { tab.addEventListener('click', async e => {
@ -734,6 +862,7 @@
await api('/feeds/items/read/', {ids, read: true}).catch(() => {}); await api('/feeds/items/read/', {ids, read: true}).catch(() => {});
return load(); return load();
} }
if (act === 'folder-rename' || act === 'folder-dissolve') return folderAction(act, t.dataset.name);
if (act === 'login-delete') { if (act === 'login-delete') {
await api('/feeds/logins/delete/', {source: parseInt(t.dataset.id, 10)}).catch(() => {}); await api('/feeds/logins/delete/', {source: parseInt(t.dataset.id, 10)}).catch(() => {});
await loadState().catch(() => {}); await loadState().catch(() => {});
@ -834,7 +963,12 @@
customAlert('Angemeldet.' + (r.refetched ? ' ' + r.refetched + ' Artikel jetzt vollständig.' : '')); customAlert('Angemeldet.' + (r.refetched ? ' ' + r.refetched + ' Artikel jetzt vollständig.' : ''));
} }
} else if (f.dataset.form === 'edit') { } else if (f.dataset.form === 'edit') {
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, fulltext: f.fulltext.value};
if (f.category) change.category = f.category.value;
if (f.newfolder) {
change.folders = Array.prototype.filter.call(f.querySelectorAll('input[name=folder]'), c => c.checked).map(c => c.value);
if (f.newfolder.value.trim()) change.folders.push(f.newfolder.value.trim());
}
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; if (f.notify) change.notify = f.notify.checked;
@ -982,6 +1116,19 @@
'#tab-feeds .fd-blogbar { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; }', '#tab-feeds .fd-blogbar { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; }',
'#tab-feeds .fd-new { display: inline-block; padding: 1px 5px; border-radius: 3px; background: var(--accent, #e63946); color: #fff; font-size: 10px; letter-spacing: 0.05em; vertical-align: 2px; }', '#tab-feeds .fd-new { display: inline-block; padding: 1px 5px; border-radius: 3px; background: var(--accent, #e63946); color: #fff; font-size: 10px; letter-spacing: 0.05em; vertical-align: 2px; }',
'#tab-feeds .fd-hidden { opacity: 0.6; }', '#tab-feeds .fd-hidden { opacity: 0.6; }',
'#tab-feeds .fd-prog { display: block; height: 3px; margin-top: 4px; background: #2a2a2a; border-radius: 2px; overflow: hidden; }',
'#tab-feeds .fd-prog > span { display: block; height: 100%; background: var(--accent, #e63946); }',
'#tab-feeds .fd-folders { border: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; }',
'#tab-feeds .fd-folders legend { font-size: 12px; color: var(--nui-muted, #9a9a9a); margin-bottom: 4px; }',
'#tab-feeds .fd-folders input[name=newfolder] { flex-basis: 100%; }',
'#tab-feeds .fd-folder-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border, #2a2a2a); flex-wrap: wrap; }',
'#fd-sheet { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 995; background: rgba(0,0,0,0.5); display: flex; align-items: flex-end; justify-content: center; }',
'#fd-sheet .fd-sheet-box { width: 100%; max-width: 480px; background: var(--bg, #000); border: 1px solid var(--border, #2a2a2a); border-bottom: none; border-radius: 10px 10px 0 0; padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px)); }',
'#fd-sheet .fd-sheet-title { padding: 10px 18px; font-size: 13px; color: var(--nui-muted, #9a9a9a); }',
'#fd-sheet button { display: block; width: 100%; padding: 14px 18px; background: none; border: none; color: var(--fg, #fff); font: inherit; text-align: left; cursor: pointer; }',
'body.eink #tab-feeds .fd-prog > span { background: #000; }',
'body.eink #fd-sheet .fd-sheet-box { background: #fff; }',
'body.eink #fd-sheet button { color: #000; }',
'body.eink #tab-feeds .fd-new, body.eink .fd-dot { background: #000; }', 'body.eink #tab-feeds .fd-new, body.eink .fd-dot { background: #000; }',
'#tab-feeds .fd-day { margin-top: 16px; }', '#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-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; }',

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-v94'; const CACHE = 'diora-v95';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app.js', '/static/js/app.js',