Blog-Feed nach Kino: Abschnitte, Favoriten, Seltene zuerst, Ausblenden, NEU
- Abschnitte Heute/Gestern/Diese Woche/Diesen Monat/Älter; je Abschnitt bis zu 5 Posts je Favoriten-Blog zuerst, dann „Seltene zuerst“ (mittlerer Abstand der letzten 10 Posts, ein Post = 30 Tage) oder chronologisch - Blog „im Hauptfeed ausblenden“: nicht unter Alle und nicht gezählt, im Ordner da - NEU (jünger als 24 h, ungelesen) am Post, als Zahl am Tab, Punkt am Ordner - Chips Ungelesen/Alle, ★ Favoriten, Seltene zuerst; Schalter je Blog - Lesezeit (Wörter/230) an jedem Post; words wird beim Speichern gezählt - sw v93 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bc1b7cb25e
commit
b063e91113
8 changed files with 294 additions and 36 deletions
|
|
@ -141,10 +141,15 @@ def parse_entry(entry, feed_url):
|
|||
'dated': when is not None, # not an Item field: refresh._apply uses and drops it
|
||||
'summary': _short(summary),
|
||||
'content': content,
|
||||
'words': word_count(content),
|
||||
'image_url': _image(entry, content_html, base)[:2000],
|
||||
}
|
||||
|
||||
|
||||
def word_count(content_html):
|
||||
return len(plain(content_html).split())
|
||||
|
||||
|
||||
def is_teaser(content_html):
|
||||
return len(plain(content_html)) < TEASER_CHARS
|
||||
|
||||
|
|
|
|||
42
feeds/migrations/0003_blog_flags_words.py
Normal file
42
feeds/migrations/0003_blog_flags_words.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Generated by Django 6.1 on 2026-10-04 19:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def count_words(apps, schema_editor):
|
||||
"""The reading time of the articles already there."""
|
||||
from feeds.fetch import word_count
|
||||
Item = apps.get_model('feeds', 'Item')
|
||||
for item in Item.objects.exclude(content='').only('id', 'content').iterator():
|
||||
Item.objects.filter(pk=item.pk).update(words=word_count(item.content))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feeds', '0002_item_paywalled_sitelogin'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='words',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='fav',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='hidden',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='notify',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(count_words, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -28,6 +28,11 @@ class Source(models.Model):
|
|||
category = models.CharField(max_length=100, 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
|
||||
# counts) but shows in its folder; notify = a notification for new posts.
|
||||
fav = models.BooleanField(default=False)
|
||||
hidden = models.BooleanField(default=False)
|
||||
notify = models.BooleanField(default=False)
|
||||
|
||||
# Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed).
|
||||
last_refreshed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
|
@ -82,6 +87,7 @@ class Item(models.Model):
|
|||
# a subscriber login for the site (logins.py).
|
||||
paywalled = models.BooleanField(default=False)
|
||||
image_url = models.URLField(max_length=2000, blank=True)
|
||||
words = models.PositiveIntegerField(default=0) # of the content: the reading time
|
||||
read_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')
|
||||
|
|
|
|||
|
|
@ -146,20 +146,22 @@ def _store_article(item, got):
|
|||
item.paywalled = True
|
||||
if longer:
|
||||
item.content = content
|
||||
item.words = fetch.word_count(content)
|
||||
if not item.image_url and image:
|
||||
item.image_url = image[:2000]
|
||||
item.save(update_fields=['paywalled', 'content', 'image_url'])
|
||||
item.save(update_fields=['paywalled', 'content', 'words', 'image_url'])
|
||||
return
|
||||
if not longer:
|
||||
item.fulltext_failed = True # the feed had more
|
||||
item.save(update_fields=['fulltext_failed'])
|
||||
return
|
||||
item.content = content
|
||||
item.words = fetch.word_count(content)
|
||||
item.content_full = True
|
||||
item.paywalled = False
|
||||
if not item.image_url and image:
|
||||
item.image_url = image[:2000]
|
||||
item.save(update_fields=['content', 'content_full', 'paywalled', 'image_url'])
|
||||
item.save(update_fields=['content', 'words', 'content_full', 'paywalled', 'image_url'])
|
||||
|
||||
|
||||
def _articles(items, deadline):
|
||||
|
|
|
|||
|
|
@ -291,3 +291,46 @@ class SiteLoginTest(TestCase):
|
|||
by_id = {s['id']: s for s in state['sources']}
|
||||
self.assertIsNone(by_id[other.id]['login'])
|
||||
self.assertIsNotNone(by_id[self.src.id]['login'])
|
||||
|
||||
|
||||
class BlogFeedTest(TestCase):
|
||||
def setUp(self):
|
||||
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')
|
||||
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))
|
||||
Item.objects.create(source=self.rare, user=self.u, kind='blog', guid_hash='r', title='Rare essay',
|
||||
published=now - timedelta(hours=5))
|
||||
|
||||
def post(self, url, data):
|
||||
return self.client.post(url, json.dumps(data), content_type='application/json')
|
||||
|
||||
def by_id(self):
|
||||
return {s['id']: s for s in self.client.get('/feeds/state/').json()['sources']}
|
||||
|
||||
def test_interval_new_and_words(self):
|
||||
st = self.by_id()
|
||||
self.assertLess(st[self.busy.id]['interval_days'], 1)
|
||||
self.assertEqual(st[self.rare.id]['interval_days'], 30.0) # one post: counts as rare
|
||||
self.assertEqual(st[self.busy.id]['new'], 10)
|
||||
items = self.client.get('/feeds/blogs/?unread=1').json()['items']
|
||||
self.assertEqual(next(i for i in items if i['title'] == 'Busy 0')['words'], 460)
|
||||
|
||||
def test_hidden_blog_only_in_its_folder_and_not_counted(self):
|
||||
self.post(f'/feeds/sources/{self.busy.id}/', {'hidden': True})
|
||||
state = self.client.get('/feeds/state/').json()
|
||||
self.assertEqual((state['blogs_unread'], state['blogs_new']), (1, 1))
|
||||
main = [i['title'] for i in self.client.get('/feeds/blogs/?unread=1').json()['items']]
|
||||
self.assertEqual(main, ['Rare essay'])
|
||||
folder = self.client.get('/feeds/blogs/?unread=1&folder=Politik').json()['items']
|
||||
self.assertEqual(len(folder), 11)
|
||||
|
||||
def test_favorites_filter(self):
|
||||
self.post(f'/feeds/sources/{self.rare.id}/', {'fav': True})
|
||||
self.assertTrue(self.by_id()[self.rare.id]['fav'])
|
||||
favs = [i['title'] for i in self.client.get('/feeds/blogs/?fav=1').json()['items']]
|
||||
self.assertEqual(favs, ['Rare essay'])
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ 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&before= (a blog's category is its folder)
|
||||
Blogs: GET /feeds/blogs/?folder=&source=&unread=1&fav=1&before= (a blog's category is its folder;
|
||||
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}
|
||||
|
|
@ -40,6 +41,10 @@ from . import fetch, logins, refresh
|
|||
from .models import Item, SiteLogin, Source, Story
|
||||
|
||||
PAGE = 40
|
||||
BLOG_PAGE = 150 # blog posts per page (all unread ones come at once, up to BLOG_UNREAD_MAX)
|
||||
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
|
||||
BRIEFING_TOP = 8
|
||||
BRIEFING_PER_CATEGORY = 5
|
||||
REFRESH_WAIT = 40 # seconds an explicit refresh may take
|
||||
|
|
@ -63,10 +68,13 @@ def _iso(dt):
|
|||
return dt.isoformat() if dt else None
|
||||
|
||||
|
||||
def _source_data(s, unread=0):
|
||||
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,
|
||||
'kind': s.kind, 'category': s.category, '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,
|
||||
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
|
||||
'login': logins.info(s.user_id, s.url),
|
||||
}
|
||||
|
|
@ -75,7 +83,7 @@ def _source_data(s, unread=0):
|
|||
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),
|
||||
'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,
|
||||
'starred': i.starred, 'story': i.story_id,
|
||||
}
|
||||
|
|
@ -117,21 +125,40 @@ def state(request):
|
|||
return err
|
||||
unread = dict(Item.objects.filter(user=request.user, gone=False, read_at=None)
|
||||
.values_list('source').annotate(n=Count('id')))
|
||||
now = timezone.now()
|
||||
new = dict(Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False, read_at=None,
|
||||
published__gte=now - timedelta(hours=NEW_HOURS))
|
||||
.values_list('source').annotate(n=Count('id')))
|
||||
sources = list(Source.objects.filter(user=request.user))
|
||||
intervals = _intervals(request.user, [x for x in sources if x.kind == Source.BLOG], now)
|
||||
s = refresh.settings_for(request.user)
|
||||
since = s.news_seen_at or (timezone.now() - timedelta(days=1))
|
||||
new_news = Story.objects.filter(user=request.user, last_at__gt=since,
|
||||
items__gone=False, items__read_at=None).distinct().count()
|
||||
return JsonResponse({
|
||||
'sources': [_source_data(x, unread.get(x.id, 0)) 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}),
|
||||
'folders': sorted({x.category for x in sources if x.kind == Source.BLOG and x.category}),
|
||||
'news_new': new_news,
|
||||
'blogs_unread': sum(n for sid, n in unread.items() if any(x.id == sid and x.kind == Source.BLOG for x in sources)),
|
||||
'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),
|
||||
'settings': {'news_keep_days': s.news_keep_days, 'news_seen_at': _iso(s.news_seen_at)},
|
||||
})
|
||||
|
||||
|
||||
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."""
|
||||
out = {}
|
||||
for s in blogs:
|
||||
dates = list(s.items.filter(gone=False).order_by('-published').values_list('published', flat=True)[:RARE_POSTS])
|
||||
if not dates:
|
||||
continue
|
||||
days = (now - dates[-1]).total_seconds() / 86400 / len(dates)
|
||||
out[s.id] = round(max(days, 30.0) if len(dates) == 1 else days, 2)
|
||||
return out
|
||||
|
||||
|
||||
def _probe(url):
|
||||
"""(feed url, title, entries) if `url` is a feed, else ([candidate feeds], icon)."""
|
||||
r = requests.get(url, timeout=fetch.PAGE_TIMEOUT, headers={'User-Agent': fetch.USER_AGENT})
|
||||
|
|
@ -248,6 +275,9 @@ def edit_source(request, pk):
|
|||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
if 'title' in body and (body['title'] or '').strip():
|
||||
source.title = body['title'].strip()[:300]
|
||||
for flag in ('fav', 'hidden', 'notify'):
|
||||
if isinstance(body.get(flag), bool):
|
||||
setattr(source, flag, body[flag])
|
||||
if 'category' in body:
|
||||
source.category = (body['category'] or '').strip()[:100]
|
||||
if body.get('fulltext') in dict(Source.FULLTEXTS):
|
||||
|
|
@ -385,14 +415,22 @@ def blogs(request):
|
|||
if request.GET.get('source', '').isdigit():
|
||||
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'])
|
||||
if request.GET.get('unread') == '1':
|
||||
else:
|
||||
qs = qs.filter(source__hidden=False)
|
||||
if request.GET.get('fav') == '1':
|
||||
qs = qs.filter(source__fav=True)
|
||||
unread_only = request.GET.get('unread') == '1'
|
||||
if unread_only:
|
||||
qs = qs.filter(read_at=None)
|
||||
before = parse_datetime(request.GET.get('before') or '')
|
||||
if before:
|
||||
qs = qs.filter(published__lt=before)
|
||||
items = list(qs.order_by('-published')[:PAGE])
|
||||
return JsonResponse({'items': [_item_data(i) for i in items], 'more': len(items) == PAGE})
|
||||
# Unread: all of them at once (the page sorts them into days, favorites, rare blogs).
|
||||
limit = BLOG_UNREAD_MAX if unread_only else BLOG_PAGE
|
||||
items = list(qs.order_by('-published')[:limit])
|
||||
return JsonResponse({'items': [_item_data(i) for i in items], 'more': len(items) == limit})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
category: '',
|
||||
items: [], itemsMore: false,
|
||||
blogSource: '', blogUnread: true,
|
||||
blogFav: false, // main feed: only the favorite blogs
|
||||
rareFirst: true, // within a day: blogs that seldom post first
|
||||
blogFolder: null, // null = all blogs, '' = blogs without a folder, else a folder name
|
||||
found: null, // discover results
|
||||
opml: null, // parsed OPML import
|
||||
|
|
@ -33,6 +35,8 @@
|
|||
try {
|
||||
const saved = localStorage.getItem('diora_feeds_view');
|
||||
if (saved === 'news' || saved === 'blogs') st.view = saved;
|
||||
if (localStorage.getItem('diora_feeds_rare') === '0') st.rareFirst = false;
|
||||
if (localStorage.getItem('diora_feeds_unread') === '0') st.blogUnread = false;
|
||||
} catch (e) {}
|
||||
|
||||
const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<')
|
||||
|
|
@ -99,6 +103,7 @@
|
|||
if (st.blogSource) q.push('source=' + st.blogSource);
|
||||
else if (st.blogFolder !== null) q.push('folder=' + encodeURIComponent(st.blogFolder));
|
||||
if (st.blogUnread) q.push('unread=1');
|
||||
if (st.blogFav && !st.blogSource && st.blogFolder === null) q.push('fav=1');
|
||||
if (more && st.items.length) q.push('before=' + encodeURIComponent(st.items[st.items.length - 1].published));
|
||||
const data = await api('/feeds/blogs/' + (q.length ? '?' + q.join('&') : ''));
|
||||
st.items = more ? st.items.concat(data.items) : data.items;
|
||||
|
|
@ -129,7 +134,7 @@
|
|||
function render() {
|
||||
const s = st.state;
|
||||
const newsN = s ? s.news_new : 0;
|
||||
const blogN = s ? s.blogs_unread : 0;
|
||||
const blogN = s ? s.blogs_new : 0; // new = younger than a day, unread (hidden blogs not counted)
|
||||
let body;
|
||||
if (st.view === 'manage') body = renderManage();
|
||||
else if (!s && st.loading) body = '<p class="fd-note">Lädt …</p>';
|
||||
|
|
@ -223,40 +228,120 @@
|
|||
return h;
|
||||
}
|
||||
|
||||
// ---- blogs: days, favorites first, rare blogs first (after Kino's feed)
|
||||
|
||||
const NEW_MS = 24 * 3600000;
|
||||
const FAV_PER_DAY = 5; // posts of one favorite blog that go first per section
|
||||
|
||||
function isNew(i, src) {
|
||||
return !i.read && Date.now() - new Date(i.published).getTime() < NEW_MS && !(src && src.hidden);
|
||||
}
|
||||
|
||||
function readingTime(i) {
|
||||
return i.words >= 120 ? Math.max(1, Math.round(i.words / 230)) + ' Min.' : '';
|
||||
}
|
||||
|
||||
function section(iso) {
|
||||
const d = new Date(iso);
|
||||
const label = dayLabel(d);
|
||||
if (label === 'Heute' || label === 'Gestern') return label;
|
||||
const days = (Date.now() - d.getTime()) / 86400000;
|
||||
if (days < 7) return 'Diese Woche';
|
||||
if (days < 31) return 'Diesen Monat';
|
||||
return 'Älter';
|
||||
}
|
||||
|
||||
// Posts into sections (newest first); in each, up to FAV_PER_DAY posts per
|
||||
// favorite blog first, then the rest — blogs that post seldom ahead of the
|
||||
// busy ones ("Seltene zuerst"), or simply by time.
|
||||
function blogSections(items, byId) {
|
||||
const order = ['Heute', 'Gestern', 'Diese Woche', 'Diesen Monat', 'Älter'];
|
||||
const groups = {};
|
||||
items.forEach(i => { (groups[section(i.published)] = groups[section(i.published)] || []).push(i); });
|
||||
const newest = (a, b) => new Date(b.published) - new Date(a.published);
|
||||
return order.filter(k => groups[k]).map(k => {
|
||||
const list = groups[k].slice().sort(newest);
|
||||
const favCount = {};
|
||||
const fav = [];
|
||||
const rest = [];
|
||||
list.forEach(i => {
|
||||
const src = byId[i.source];
|
||||
if (src && src.fav && (favCount[i.source] || 0) < FAV_PER_DAY) {
|
||||
favCount[i.source] = (favCount[i.source] || 0) + 1;
|
||||
fav.push(i);
|
||||
} else {
|
||||
rest.push(i);
|
||||
}
|
||||
});
|
||||
if (st.rareFirst) {
|
||||
const gap = i => { const src = byId[i.source]; return (src && src.interval_days) || 0; };
|
||||
rest.sort((a, b) => (gap(b) - gap(a)) || newest(a, b));
|
||||
}
|
||||
return {label: k, items: fav.concat(rest)};
|
||||
});
|
||||
}
|
||||
|
||||
function renderBlogs() {
|
||||
const blogs = sources('blog');
|
||||
if (!blogs.length) {
|
||||
return '<div class="fd-empty"><p>Noch keine Blogs abonniert.</p>' +
|
||||
'<button type="button" class="btn btn-primary" data-act="manage">Feed hinzufügen</button></div>';
|
||||
}
|
||||
// Folders (a blog's category): chips with their unread count.
|
||||
const byId = {};
|
||||
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 unreadIn = f => blogs.filter(b => (b.category || '') === f).reduce((n, b) => n + b.unread, 0);
|
||||
const total = blogs.reduce((n, b) => n + b.unread, 0);
|
||||
const inFolder = f => blogs.filter(b => (b.category || '') === f);
|
||||
const sum = (list, key) => list.reduce((n, b) => n + b[key], 0);
|
||||
const visible = blogs.filter(b => !b.hidden);
|
||||
let h = '';
|
||||
if (folders.length) {
|
||||
const chip = (value, label, n) => '<button type="button" class="fd-chip' + (st.blogFolder === value ? ' on' : '') +
|
||||
'" data-folder="' + (value === null ? '*' : esc(value)) + '">' + esc(label) + (n ? ' <small>' + n + '</small>' : '') + '</button>';
|
||||
h += '<div class="fd-chips">' + chip(null, 'Alle', total) +
|
||||
folders.map(f => chip(f, f, unreadIn(f))).join('') +
|
||||
(blogs.some(b => !b.category) ? chip('', 'Ohne Ordner', unreadIn('')) : '') + '</div>';
|
||||
const chip = (value, label, list) => '<button type="button" class="fd-chip' + (st.blogFolder === value ? ' on' : '') +
|
||||
'" data-folder="' + (value === null ? '*' : esc(value)) + '">' +
|
||||
(sum(list, 'new') ? '<span class="fd-dot"></span>' : '') + esc(label) +
|
||||
(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>';
|
||||
}
|
||||
const shown = st.blogFolder === null ? blogs : blogs.filter(b => (b.category || '') === st.blogFolder);
|
||||
const main = st.blogFolder === null;
|
||||
const shown = main ? blogs : inFolder(st.blogFolder);
|
||||
h += '<div class="fd-chips fd-modes">' +
|
||||
'<button type="button" class="fd-chip' + (st.blogUnread ? ' on' : '') + '" data-act="mode-unread">Ungelesen</button>' +
|
||||
'<button type="button" class="fd-chip' + (st.blogUnread ? '' : ' on') + '" data-act="mode-all">Alle</button>' +
|
||||
(main ? '<button type="button" class="fd-chip' + (st.blogFav ? ' on' : '') + '" data-act="mode-fav">★ Favoriten</button>' : '') +
|
||||
'<button type="button" class="fd-chip' + (st.rareFirst ? ' on' : '') + '" data-act="mode-rare" title="Blogs, die selten schreiben, zuerst">Seltene zuerst</button>' +
|
||||
'</div>';
|
||||
h += '<div class="fd-filter">' +
|
||||
'<select class="search-input fd-select" data-act="blog-source"><option value="">' +
|
||||
(st.blogFolder === null ? 'Alle Blogs' : 'Alle in ' + esc(st.blogFolder || 'Ohne Ordner')) + '</option>' +
|
||||
(main ? 'Alle Blogs' : 'Alle in ' + esc(st.blogFolder || 'Ohne Ordner')) + '</option>' +
|
||||
shown.map(b => '<option value="' + b.id + '"' + (String(b.id) === String(st.blogSource) ? ' selected' : '') + '>' +
|
||||
esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + '</option>').join('') + '</select>' +
|
||||
'<label class="fd-check"><input type="checkbox" data-act="blog-unread"' + (st.blogUnread ? ' checked' : '') + '> Nur ungelesene</label>' +
|
||||
(b.fav ? '★ ' : '') + esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + (b.hidden ? ' – ausgeblendet' : '') +
|
||||
'</option>').join('') + '</select>' +
|
||||
(st.items.some(i => !i.read) ? '<button type="button" class="btn fd-allread" data-act="blogs-read">Alle gelesen</button>' : '') +
|
||||
'</div>';
|
||||
const one = st.blogSource ? byId[st.blogSource] : null;
|
||||
if (one) {
|
||||
// The selected blog's switches.
|
||||
h += '<div class="fd-blogbar">' +
|
||||
'<button type="button" class="fd-chip' + (one.fav ? ' on' : '') + '" data-flag="fav" data-id="' + one.id + '">' + (one.fav ? '★ Favorit' : '☆ Favorit') + '</button>' +
|
||||
'<button type="button" class="fd-chip' + (one.hidden ? ' on' : '') + '" data-flag="hidden" data-id="' + one.id + '" title="Nicht unter „Alle“, nur im Ordner">' +
|
||||
(one.hidden ? 'Im Hauptfeed ausgeblendet' : 'Im Hauptfeed ausblenden') + '</button>' +
|
||||
'<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>';
|
||||
h += st.items.map(i =>
|
||||
'<button type="button" class="fd-post' + (i.read ? ' fd-read' : '') + '" data-item="' + i.id + '">' +
|
||||
'<span class="fd-card-title">' + (i.starred ? '★ ' : '') + esc(i.title) + '</span>' +
|
||||
'<span class="fd-row-meta">' + esc(i.source_title) + ' · ' + esc(when(i.published)) + '</span>' +
|
||||
(i.summary ? '<span class="fd-card-sum">' + esc(i.summary) + '</span>' : '') +
|
||||
'</button>').join('');
|
||||
blogSections(st.items, byId).forEach(sec => {
|
||||
h += '<h3 class="fd-day">' + esc(sec.label) + '</h3>';
|
||||
h += sec.items.map(i => {
|
||||
const src = byId[i.source];
|
||||
const rt = readingTime(i);
|
||||
return '<button type="button" class="fd-post' + (i.read ? ' fd-read' : '') + (src && src.hidden ? ' fd-hidden' : '') + '" data-item="' + i.id + '">' +
|
||||
'<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>' : '') +
|
||||
'</button>';
|
||||
}).join('');
|
||||
});
|
||||
if (st.itemsMore) h += '<button type="button" class="btn fd-loadmore" data-act="more">Ältere laden</button>';
|
||||
return h;
|
||||
}
|
||||
|
|
@ -298,7 +383,7 @@
|
|||
'<section class="fd-box"><h3>' + title + ' <small>' + list.length + '</small></h3>' +
|
||||
list.map(x => x.id === st.editing ? editForm(x) :
|
||||
'<button type="button" class="fd-src" data-edit="' + x.id + '">' +
|
||||
'<span class="fd-src-title">' + esc(x.title) + '</span>' +
|
||||
'<span class="fd-src-title">' + (x.fav ? '★ ' : '') + 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) + ' · ' : '')) +
|
||||
(x.unread ? x.unread + ' ungelesen' : '') +
|
||||
(x.error_count >= 3 ? ' <span class="fd-warn" title="' + esc(x.last_error) + '">⚠ Abruf schlägt fehl</span>' : '') +
|
||||
|
|
@ -324,6 +409,8 @@
|
|||
(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 === '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>Ganzer Artikel <select class="search-input" name="fulltext">' +
|
||||
[['auto', 'Automatisch'], ['always', 'Immer holen'], ['never', 'Nur Feed-Text']].map(o =>
|
||||
'<option value="' + o[0] + '"' + (x.fulltext === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') +
|
||||
|
|
@ -555,7 +642,7 @@
|
|||
// ---- events
|
||||
|
||||
tab.addEventListener('click', async e => {
|
||||
const t = e.target.closest('[data-view],[data-act],[data-story],[data-item],[data-cat],[data-edit],[data-folder]');
|
||||
const t = e.target.closest('[data-view],[data-act],[data-story],[data-item],[data-cat],[data-edit],[data-folder],[data-flag],[data-edit-open]');
|
||||
if (!t || t.tagName === 'SELECT' || (t.tagName === 'INPUT' && t.type !== 'button')) return;
|
||||
if (t.dataset.view) {
|
||||
st.view = t.dataset.view;
|
||||
|
|
@ -563,6 +650,20 @@
|
|||
load();
|
||||
return;
|
||||
}
|
||||
if (t.dataset.flag) {
|
||||
const src = sources().find(x => x.id === parseInt(t.dataset.id, 10));
|
||||
if (!src) return;
|
||||
src[t.dataset.flag] = !src[t.dataset.flag];
|
||||
render();
|
||||
await api('/feeds/sources/' + src.id + '/', {[t.dataset.flag]: src[t.dataset.flag]}).catch(() => {});
|
||||
return load();
|
||||
}
|
||||
if (t.dataset.editOpen) {
|
||||
st.view = 'manage';
|
||||
st.editing = parseInt(t.dataset.editOpen, 10);
|
||||
window.scrollTo(0, 0);
|
||||
return render();
|
||||
}
|
||||
if (t.dataset.folder !== undefined) {
|
||||
st.blogFolder = t.dataset.folder === '*' ? null : t.dataset.folder;
|
||||
st.blogSource = '';
|
||||
|
|
@ -610,6 +711,20 @@
|
|||
return load();
|
||||
}
|
||||
if (act === 'more') return load(true);
|
||||
if (act === 'mode-unread' || act === 'mode-all') {
|
||||
st.blogUnread = act === 'mode-unread';
|
||||
try { localStorage.setItem('diora_feeds_unread', st.blogUnread ? '1' : '0'); } catch (err) {}
|
||||
return load();
|
||||
}
|
||||
if (act === 'mode-fav') {
|
||||
st.blogFav = !st.blogFav;
|
||||
return load();
|
||||
}
|
||||
if (act === 'mode-rare') {
|
||||
st.rareFirst = !st.rareFirst;
|
||||
try { localStorage.setItem('diora_feeds_rare', st.rareFirst ? '1' : '0'); } catch (err) {}
|
||||
return render();
|
||||
}
|
||||
if (act === 'blogs-read') {
|
||||
const ids = st.items.filter(i => !i.read).map(i => i.id);
|
||||
if (!ids.length) return;
|
||||
|
|
@ -665,7 +780,6 @@
|
|||
tab.addEventListener('change', async e => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === 'blog-source') { st.blogSource = t.value; return load(); }
|
||||
if (t.dataset.act === 'blog-unread') { st.blogUnread = t.checked; return load(); }
|
||||
if (t.dataset.act === 'keep') {
|
||||
await api('/feeds/settings/', {news_keep_days: parseInt(t.value, 10)}).catch(() => {});
|
||||
return;
|
||||
|
|
@ -717,8 +831,10 @@
|
|||
customAlert('Angemeldet.' + (r.refetched ? ' ' + r.refetched + ' Artikel jetzt vollständig.' : ''));
|
||||
}
|
||||
} else if (f.dataset.form === 'edit') {
|
||||
await api('/feeds/sources/' + f.dataset.id + '/', {title: f.title.value, kind: f.kind.value,
|
||||
category: f.category.value, fulltext: f.fulltext.value});
|
||||
const change = {title: f.title.value, kind: f.kind.value, category: f.category.value, fulltext: f.fulltext.value};
|
||||
if (f.fav) change.fav = f.fav.checked;
|
||||
if (f.hidden) change.hidden = f.hidden.checked;
|
||||
await api('/feeds/sources/' + f.dataset.id + '/', change);
|
||||
st.editing = null;
|
||||
await loadState();
|
||||
}
|
||||
|
|
@ -851,6 +967,12 @@
|
|||
'.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
|
||||
'.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }',
|
||||
'.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }',
|
||||
'.fd-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #e63946); margin-right: 6px; vertical-align: middle; }',
|
||||
'#tab-feeds .fd-modes .fd-chip { font-size: 12px; padding: 5px 10px; }',
|
||||
'#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; }',
|
||||
'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; }',
|
||||
'#tab-feeds .fd-thumb { width: 92px; height: 66px; flex: none; object-fit: cover; border-radius: 4px; background: #1a1a1a; order: 2; }',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v92';
|
||||
const CACHE = 'diora-v93';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app.js',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue