Blogs: eigene Ordner (mehrere je Blog, umbenennen, auflösen), Lesefortschritt
- 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:
parent
74bb3a44df
commit
886c526201
7 changed files with 381 additions and 25 deletions
46
feeds/migrations/0005_blog_folders_progress.py
Normal file
46
feeds/migrations/0005_blog_folders_progress.py
Normal 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),
|
||||
]
|
||||
|
|
@ -26,6 +26,8 @@ class Source(models.Model):
|
|||
kind = models.CharField(max_length=10, choices=KINDS, default=NEWS)
|
||||
# News: the section it belongs to (Politik, Tech …), groups the briefing.
|
||||
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)
|
||||
added_at = models.DateTimeField(auto_now_add=True)
|
||||
# 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.
|
||||
notify = models.BooleanField(default=False, db_index=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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -298,8 +298,8 @@ class BlogFeedTest(TestCase):
|
|||
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')
|
||||
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', folders=['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='<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'], [])
|
||||
Source.objects.filter(pk=self.src.pk).update(notify=False)
|
||||
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'])
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ urlpatterns = [
|
|||
path('items/read/', views.mark_read, name='feeds_mark_read'),
|
||||
path('items/<int:pk>/', views.item, name='feeds_item'),
|
||||
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('notifications/', views.notifications, name='feeds_notifications'),
|
||||
path('logins/', views.save_login, name='feeds_save_login'),
|
||||
|
|
|
|||
143
feeds/views.py
143
feeds/views.py
|
|
@ -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/sources/add/ {url, kind, category, title?}
|
||||
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/
|
||||
GET /feeds/export.opml
|
||||
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
|
||||
GET /feeds/news/?before=&category= stories, newest first
|
||||
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)
|
||||
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/<id>/star/ {starred}
|
||||
POST /feeds/items/<id>/progress/ {progress} 0-1; from 0.9 on it's read
|
||||
GET /feeds/starred/
|
||||
Settings: POST /feeds/settings/ {news_keep_days}
|
||||
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
|
||||
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
|
||||
FOLDER_MAX = 40 # characters of a folder name
|
||||
READ_AT = 0.9 # read this far, a post counts as read
|
||||
BRIEFING_TOP = 8
|
||||
BRIEFING_PER_CATEGORY = 5
|
||||
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):
|
||||
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, '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,
|
||||
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
|
||||
'interval_days': interval,
|
||||
|
|
@ -86,7 +92,7 @@ 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), '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,
|
||||
}
|
||||
if content:
|
||||
|
|
@ -140,7 +146,7 @@ def state(request):
|
|||
return JsonResponse({
|
||||
'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}),
|
||||
'folders': _all_folders(sources),
|
||||
'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_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):
|
||||
"""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."""
|
||||
|
|
@ -218,6 +255,16 @@ def discover(request):
|
|||
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):
|
||||
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)
|
||||
source, created = Source.objects.get_or_create(
|
||||
user=request.user, url=url[:1000],
|
||||
defaults={'title': (body.get('title') or url)[:300], 'kind': kind,
|
||||
'category': (body.get('category') or '').strip()[:100]})
|
||||
defaults=_place(request.user, kind, body.get('category'), body.get('folders'),
|
||||
title=(body.get('title') or url)[:300]))
|
||||
if created:
|
||||
refresh.refresh([source], deadline=time.monotonic() + 25)
|
||||
source.refresh_from_db()
|
||||
|
|
@ -258,8 +305,8 @@ def add_sources(request):
|
|||
continue
|
||||
_, created = Source.objects.get_or_create(
|
||||
user=request.user, url=url[:1000],
|
||||
defaults={'title': (x.get('title') or url)[:300], 'kind': kind,
|
||||
'category': (x.get('category') or '').strip()[:100]})
|
||||
defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
|
||||
title=(x.get('title') or url)[:300]))
|
||||
added += created
|
||||
sources = Source.objects.filter(user=request.user, last_refreshed_at=None)
|
||||
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
|
||||
|
|
@ -280,12 +327,23 @@ def edit_source(request, pk):
|
|||
for flag in ('fav', 'hidden', 'notify'):
|
||||
if isinstance(body.get(flag), bool):
|
||||
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]
|
||||
if body.get('fulltext') in dict(Source.FULLTEXTS):
|
||||
source.fulltext = body['fulltext']
|
||||
new_kind = _kind(body.get('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
|
||||
# Its items follow; as blog articles they leave their stories.
|
||||
source.items.update(kind=new_kind, **({'story': None} if new_kind == Source.BLOG else {}))
|
||||
|
|
@ -312,7 +370,8 @@ def export_opml(request):
|
|||
groups = {}
|
||||
for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'):
|
||||
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)
|
||||
lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<opml version="2.0">',
|
||||
'<head><title>diora Feeds</title></head>', '<body>']
|
||||
|
|
@ -418,7 +477,9 @@ def blogs(request):
|
|||
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'])
|
||||
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:
|
||||
qs = qs.filter(source__hidden=False)
|
||||
if request.GET.get('fav') == '1':
|
||||
|
|
@ -469,6 +530,64 @@ def mark_read(request):
|
|||
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
|
||||
@require_http_methods(['POST'])
|
||||
def star(request, pk):
|
||||
|
|
|
|||
|
|
@ -233,6 +233,13 @@
|
|||
const NEW_MS = 24 * 3600000;
|
||||
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) {
|
||||
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; });
|
||||
// Folders (a blog's category): chips with their unread count, a dot when something is new.
|
||||
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 visible = blogs.filter(b => !b.hidden);
|
||||
let h = '';
|
||||
|
|
@ -302,7 +309,7 @@
|
|||
(sum(list, 'unread') ? ' <small>' + sum(list, 'unread') + '</small>' : '') + '</button>';
|
||||
h += '<div class="fd-chips">' + chip(null, 'Alle', visible) +
|
||||
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 shown = main ? blogs : inFolder(st.blogFolder);
|
||||
|
|
@ -332,6 +339,15 @@
|
|||
'<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>';
|
||||
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 => {
|
||||
h += '<h3 class="fd-day">' + esc(sec.label) + '</h3>';
|
||||
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-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.read && i.progress > 0.02 ? '<span class="fd-prog"><span style="width:' + Math.round(i.progress * 100) + '%"></span></span>' : '') +
|
||||
'</button>';
|
||||
}).join('');
|
||||
});
|
||||
|
|
@ -386,12 +403,22 @@
|
|||
list.map(x => x.id === st.editing ? editForm(x) :
|
||||
'<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-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.error_count >= 3 ? ' <span class="fd-warn" title="' + esc(x.last_error) + '">⚠ Abruf schlägt fehl</span>' : '') +
|
||||
'</span></button>').join('') + '</section>';
|
||||
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 & 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> ' +
|
||||
|
|
@ -410,7 +437,7 @@
|
|||
'<div class="fd-note fd-url">' + esc(x.url) + '</div>' +
|
||||
(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>' +
|
||||
'<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>' +
|
||||
'<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>' : '') +
|
||||
|
|
@ -424,6 +451,14 @@
|
|||
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
|
||||
// server, which then fetches whole articles. The password never comes back.
|
||||
function loginForm(x) {
|
||||
|
|
@ -498,6 +533,10 @@
|
|||
reader.id = 'fd-article';
|
||||
reader.hidden = true;
|
||||
reader.addEventListener('click', onReaderClick);
|
||||
reader.addEventListener('scroll', () => {
|
||||
clearTimeout(progressTimer);
|
||||
progressTimer = setTimeout(saveProgress, 1200);
|
||||
}, {passive: true});
|
||||
document.body.appendChild(reader);
|
||||
return reader;
|
||||
}
|
||||
|
|
@ -508,6 +547,7 @@
|
|||
|
||||
function closeReader() {
|
||||
if (!reader) return;
|
||||
saveProgress();
|
||||
reader.hidden = true;
|
||||
reader.innerHTML = '';
|
||||
document.documentElement.classList.remove('fd-reading');
|
||||
|
|
@ -515,8 +555,34 @@
|
|||
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) {
|
||||
ensureReader();
|
||||
if (readerItem) saveProgress();
|
||||
readerStory = story || null;
|
||||
reader.hidden = false;
|
||||
document.documentElement.classList.add('fd-reading');
|
||||
|
|
@ -527,10 +593,21 @@
|
|||
readerItem = data.item;
|
||||
reader.innerHTML = readerShell(articleHtml(readerItem));
|
||||
reader.scrollTop = 0;
|
||||
if (!readerItem.read) {
|
||||
readerItem.read = true;
|
||||
markLocal([id], true);
|
||||
api('/feeds/items/read/', {ids: [id], read: true}).catch(() => {});
|
||||
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) {
|
||||
readerItem.read = true;
|
||||
markLocal([id], true);
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
|
||||
tab.addEventListener('click', async e => {
|
||||
|
|
@ -734,6 +862,7 @@
|
|||
await api('/feeds/items/read/', {ids, read: true}).catch(() => {});
|
||||
return load();
|
||||
}
|
||||
if (act === 'folder-rename' || act === 'folder-dissolve') return folderAction(act, t.dataset.name);
|
||||
if (act === 'login-delete') {
|
||||
await api('/feeds/logins/delete/', {source: parseInt(t.dataset.id, 10)}).catch(() => {});
|
||||
await loadState().catch(() => {});
|
||||
|
|
@ -834,7 +963,12 @@
|
|||
customAlert('Angemeldet.' + (r.refetched ? ' ' + r.refetched + ' Artikel jetzt vollständig.' : ''));
|
||||
}
|
||||
} 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.hidden) change.hidden = f.hidden.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-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-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; }',
|
||||
'#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; }',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v94';
|
||||
const CACHE = 'diora-v95';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app.js',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue