From 886c526201d36dce083722b37d24b6652496d19f Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 4 Oct 2026 21:54:04 +0200 Subject: [PATCH] =?UTF-8?q?Blogs:=20eigene=20Ordner=20(mehrere=20je=20Blog?= =?UTF-8?q?,=20umbenennen,=20aufl=C3=B6sen),=20Lesefortschritt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migrations/0005_blog_folders_progress.py | 46 +++++ feeds/models.py | 6 + feeds/tests.py | 39 +++- feeds/urls.py | 3 + feeds/views.py | 143 +++++++++++++-- static/js/feeds.js | 167 ++++++++++++++++-- static/js/sw.js | 2 +- 7 files changed, 381 insertions(+), 25 deletions(-) create mode 100644 feeds/migrations/0005_blog_folders_progress.py diff --git a/feeds/migrations/0005_blog_folders_progress.py b/feeds/migrations/0005_blog_folders_progress.py new file mode 100644 index 0000000..c5b5250 --- /dev/null +++ b/feeds/migrations/0005_blog_folders_progress.py @@ -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), + ] diff --git a/feeds/models.py b/feeds/models.py index 5665cfe..7ac837e 100644 --- a/feeds/models.py +++ b/feeds/models.py @@ -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 diff --git a/feeds/tests.py b/feeds/tests.py index 8cc483f..5464e39 100644 --- a/feeds/tests.py +++ b/feeds/tests.py @@ -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='

' + 'wort ' * 460 + '

', 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']) diff --git a/feeds/urls.py b/feeds/urls.py index a3b5271..9c8546c 100644 --- a/feeds/urls.py +++ b/feeds/urls.py @@ -20,6 +20,9 @@ urlpatterns = [ path('items/read/', views.mark_read, name='feeds_mark_read'), path('items//', views.item, name='feeds_item'), path('items//star/', views.star, name='feeds_star'), + path('items//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'), diff --git a/feeds/views.py b/feeds/views.py index 9caed23..055b4b0 100644 --- a/feeds/views.py +++ b/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// {title, kind, category, fulltext} + POST /feeds/sources// {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//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// 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// the article (fetches the page if the feed had a teaser) POST /feeds/items/read/ {ids, read} POST /feeds/items//star/ {starred} + POST /feeds/items//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= 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 = ['', '', 'diora Feeds', ''] @@ -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): diff --git a/static/js/feeds.js b/static/js/feeds.js index 80f9fa4..20f144c 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -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') ? ' ' + sum(list, 'unread') + '' : '') + ''; h += '
' + chip(null, 'Alle', visible) + folders.map(f => chip(f, f, inFolder(f))).join('') + - (blogs.some(b => !b.category) ? chip('', 'Ohne Ordner', inFolder('')) : '') + '
'; + (blogs.some(b => !(b.folders || []).length) ? chip('', 'Ohne Ordner', inFolder('')) : '') + ''; } const main = st.blogFolder === null; const shown = main ? blogs : inFolder(st.blogFolder); @@ -332,6 +339,15 @@ ''; } if (!st.items.length) return h + '

' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '

'; + 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 += '

Weiterlesen

' + reading.map(i => + '').join(''); + } blogSections(st.items, byId).forEach(sec => { h += '

' + esc(sec.label) + '

'; h += sec.items.map(i => { @@ -341,6 +357,7 @@ '' + (isNew(i, src) ? 'NEU ' : '') + (i.starred ? '★ ' : '') + esc(i.title) + '' + '' + (src && src.fav ? '★ ' : '') + esc(i.source_title) + ' · ' + esc(when(i.published)) + (rt ? ' · ' + rt : '') + '' + (i.summary ? '' + esc(i.summary) + '' : '') + + (!i.read && i.progress > 0.02 ? '' : '') + ''; }).join(''); }); @@ -386,12 +403,22 @@ list.map(x => x.id === st.editing ? editForm(x) : '').join('') + ''; 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 += '

Ordner ' + folders.length + '

' + folders.map(f => + '
' + esc(f) + ' ' + blogs.filter(b => inFolderOf(b, f)).length + ' Blogs' + + '' + + '
').join('') + + '

Tipp: lange auf einen Ordner in der Blog-Ansicht drücken (oder Rechtsklick) öffnet dasselbe.

'; + } h += '

Import & Einstellungen

' + ' ' + @@ -410,7 +437,7 @@ '
' + esc(x.url) + '
' + (x.last_error ? '

Letzter Fehler: ' + esc(x.last_error) + '

' : '') + '
' + - '' + + (x.kind === 'news' ? '' : folderPicker(x)) + (x.kind === 'blog' ? '' + '' + '' : '') + @@ -424,6 +451,14 @@ loginForm(x); } + function folderPicker(x) { + const all = (st.state && st.state.folders) || []; + return '
Ordner' + + all.map(f => '').join('') + + '
'; + } + // 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('

' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '

'); @@ -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 = '
' + esc(name) + '
' + + '' + + '' + + '' + + '
'; + 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; }', diff --git a/static/js/sw.js b/static/js/sw.js index 73e0028..dda6cf9 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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',