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/' + (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 += 'Tipp: lange auf einen Ordner in der Blog-Ansicht drücken (oder Rechtsklick) öffnet dasselbe.
Letzter Fehler: ' + esc(x.last_error) + '
' : '') + '' + 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 = '