diff --git a/docs/navigation.md b/docs/navigation.md index 627de39..2e77a86 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -83,5 +83,8 @@ A tab/rail button calls `DioraNav.go(id)`; the active state comes from 1. Navigation (this document), Hörbücher as their own panel, Zeitschriften by folder. 2. "Abos & Quellen": podcasts, YouTube channels, news sources, blogs and the audiobook mark in one place; the everyday views without managing. -3. Library: audiobook series (e.g. Das Kapital, Band 1–3) and folders, - magazines marked per book. +3. Library (done): audiobook series are found in the titles ("Das Kapital – + Erster Band" …) and shown as one stack; audiobooks have folders + (`PodcastFeed.folder`); a book is marked as a magazine in its encrypted + meta (`magazine: true/false`; unset: the "Zeitschriften" folder decides), and + Zeitschriften groups the issues by periodical. diff --git a/podcasts/audiobooks.py b/podcasts/audiobooks.py index 707ccf9..c29892a 100644 --- a/podcasts/audiobooks.py +++ b/podcasts/audiobooks.py @@ -87,6 +87,7 @@ def _book(feed, chapters, last_played_at): nxt = _next(chapters) return { 'feed_id': feed.id, 'title': feed.title, 'author': feed.author, 'artwork_url': feed.artwork_url, + 'folder': feed.folder, 'chapters': len(chapters), 'done': done, 'total_seconds': total, 'heard_seconds': heard, 'next': nxt, 'last_played_at': last_played_at.isoformat() if last_played_at else None, } diff --git a/podcasts/migrations/0020_audiobook_folder.py b/podcasts/migrations/0020_audiobook_folder.py new file mode 100644 index 0000000..1f0133a --- /dev/null +++ b/podcasts/migrations/0020_audiobook_folder.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-10-16 13:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcasts', '0019_audiobooks'), + ] + + operations = [ + migrations.AddField( + model_name='podcastfeed', + name='folder', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 28dc2fd..222a4fc 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -75,6 +75,8 @@ class PodcastFeed(models.Model): # Skip silence in this podcast's episodes: null = as the device is set. skip_silence = models.BooleanField(null=True, blank=True) kind = models.CharField(max_length=10, choices=KINDS, default=PODCAST) + # An audiobook's folder in the library ('' = none; one level, like the e-books' folders). + folder = models.CharField(max_length=100, blank=True) # The vocabulary of episode_sort and episode_filter: the podcast page's # sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS). diff --git a/podcasts/test_audiobooks.py b/podcasts/test_audiobooks.py index aa4631d..cc59cf8 100644 --- a/podcasts/test_audiobooks.py +++ b/podcasts/test_audiobooks.py @@ -187,3 +187,14 @@ class UnplayedTests(TestCase): content_type='application/json') self.assertEqual(EpisodeProgress.objects.get(episode=ep).position_seconds, 0) self.assertEqual(EpisodeProgress.objects.get(episode=half).position_seconds, 200) # its place stays + + +class AudiobookFolderTests(TestCase): + def test_a_book_has_a_folder(self): + user = User.objects.create_user(username='alice', password='pw12345678') + self.client.force_login(user) + feed = PodcastFeed.objects.create(user=user, rss_url='https://example.org/b', title='B', kind='audiobook') + r = self.client.post(f'/podcasts/feeds/{feed.pk}/settings/', json.dumps({'folder': ' Marx und Engels '}), + content_type='application/json') + self.assertEqual(r.json()['folder'], 'Marx und Engels') + self.assertEqual(self.client.get('/podcasts/audiobooks/').json()['books'][0]['folder'], 'Marx und Engels') diff --git a/podcasts/tests.py b/podcasts/tests.py index a11bfb4..258b44f 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -1074,6 +1074,7 @@ FEED_DEFAULTS = { 'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox', 'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False, 'notify': False, 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', + 'folder': '', } @@ -1100,7 +1101,7 @@ class FeedSettingsTests(TestCase): 'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0, 'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10, 'auto_queue': False, 'auto_download': False, 'notify': False, - 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', + 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', 'folder': '', }) self.feed.refresh_from_db() self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration), diff --git a/podcasts/views.py b/podcasts/views.py index 5320b31..46d75da 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -787,7 +787,7 @@ def feed_list(request): FEED_SETTING_FIELDS = ( 'playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download', - 'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', + 'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder', ) @@ -1025,6 +1025,7 @@ FEED_SETTING_PARSERS = { 'episode_sort': _episode_sort, 'episode_filter': _episode_filter, 'kind': _kind, + 'folder': lambda v: ' '.join(str(v or '').split())[:100], 'skip_silence': _optional_boolean, } diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index 32f4f79..fe44304 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -41,8 +41,8 @@ async function loadBookList() { try { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); - bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0}; - decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine}; + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); @@ -59,7 +59,7 @@ async function loadBookList() { const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress); if (cachedBooks.length) { cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || '')); - for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0}; + for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; renderBookList(cachedBooks); @@ -145,6 +145,7 @@ function repairBook(bookId) { isbn: isbn || prevMeta.isbn || '', folder: prevMeta.folder || '', shelfTag: prevMeta.shelfTag || '', + magazine: prevMeta.magazine, })); const [metaEnc, dataEnc] = await Promise.all([ encryptBytes(key, metaJson), @@ -205,11 +206,33 @@ async function toggleBookRead(bookId, currentlyRead) { // free in /api/sync/. One level only: folders don't nest. let _currentBookFolder = null; // null = root view (folder tiles + unfiled books) -// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: the -// magazines are the books in a folder called "Zeitschriften" (or "Magazine"). +// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: a +// magazine is an issue marked as one (⋮ → Als Zeitschrift, kept in the encrypted meta), +// or, unmarked, a book in a folder called "Zeitschriften" (or "Magazine"). let _bookSection = 'books'; const _MAGAZINE_FOLDER = /^(zeitschriften|magazine|magazines)$/i; -const _isMagazine = b => _MAGAZINE_FOLDER.test((b.folder || '').trim()); +const _isMagazine = b => b.magazine === true || (b.magazine !== false && _MAGAZINE_FOLDER.test((b.folder || '').trim())); +window._isMagazine = _isMagazine; + +// The periodical an issue belongs to: its title up to the issue's number ("Jacobin #24", +// "New Left Review 157", "LuXemburg 1/2026"), else its author ("Vol 9 – Issue 2–3" by Catalyst). +function _periodicalOf(b) { + const m = /^(.*?)\s*(?:#|\bno\.|\bnr\.|\bheft\b|\bausgabe\b|\bissue\b|\bvol\b|\d)/i.exec(b.title || ''); + const name = (m ? m[1] : '').replace(/[\s–—:,-]+$/, '').trim(); + return name.length >= 3 ? name : (b.author || '').trim() || (b.title || '').trim(); +} + +async function toggleBookMagazine(bookId) { + const book = _lastBookListData.find(b => b.id === bookId); + if (!book) return; + try { + await _updateBookMeta(bookId, {magazine: !_isMagazine(book)}); + renderBookList(_lastBookListData); + } catch (e) { + customAlert('Das ließ sich nicht speichern: ' + e.message); + } +} +window.toggleBookMagazine = toggleBookMagazine; // The books of the section shown (all of them where there are no sections). function _bookSectionFilter(books) { @@ -254,6 +277,7 @@ function _renderBookItemHtml(b) { ${broken ? `` : ''} ${b.has_highlights ? `` : ''} + @@ -295,7 +319,7 @@ function renderBookList(allBooks) { if (!books.length) { listEl.innerHTML = window.DioraNav && allBooks.length ? (_bookSection === 'magazines' - ? '
Noch keine Zeitschriften. Hefte, die in einem Ordner „Zeitschriften“ liegen, stehen hier (⋮ → Ordner zuweisen).
' + ? 'Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.
' : 'Alle Bücher liegen bei den Zeitschriften.
') : ''; return; @@ -324,11 +348,22 @@ function renderBookList(allBooks) { } const grouped = new Map(); + const magazines = _bookSection === 'magazines' && !!window.DioraNav; for (const b of visible) { - const key = _bookSection === 'magazines' && _isMagazine(b) ? '' : (b.folder || ''); + const key = magazines ? _periodicalOf(b) : (b.folder || ''); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(b); } + if (magazines) { + // A periodical with one issue is no tile; issues newest first. + const loose = grouped.get('') || []; + for (const [k, list] of [...grouped]) { + if (k !== '' && list.length < 2) { loose.push(...list); grouped.delete(k); } + } + grouped.set('', loose); + const issue = (a, b) => b.title.localeCompare(a.title, 'de', {numeric: true}); + for (const list of grouped.values()) list.sort(issue); + } // Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root. if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) { _currentBookFolder = null; @@ -339,7 +374,7 @@ function renderBookList(allBooks) { if (folderNames.length) { html += 'Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' + 'wie ein Buch hören: in Kapitelreihenfolge, mit Lesezeichen. „+ Hörbuch“ wählt einen aus.
'; @@ -206,6 +202,131 @@ if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; } } + // ---- organising: folders (set per book) and series (found in the titles) ---------------- + + // "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12": + // the series' name and the volume's number, or null. + const VOLUME_WORD = /\b(band|bd\.|teil|buch|folge|staffel|episode|vol\.?|volume|part|book)\b/i; + const ORDINALS = ['erst', 'zweit', 'dritt', 'viert', 'fünft', 'sechst', 'siebt', 'acht', 'neunt', 'zehnt', + 'elft', 'zwölft']; + const ORDINALS_EN = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth']; + const ROMAN = {i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6, vii: 7, viii: 8, ix: 9, x: 10, xi: 11, xii: 12}; + + function volumeNumber(text) { + const t = text.toLowerCase(); + const digits = /(\d+)/.exec(t); + if (digits) return parseInt(digits[1], 10); + for (const word of t.split(/[^a-zäöüß]+/)) { + if (ROMAN[word]) return ROMAN[word]; + for (let i = 0; i < ORDINALS.length; i++) if (word.indexOf(ORDINALS[i]) === 0) return i + 1; + for (let i = 0; i < ORDINALS_EN.length; i++) if (word === ORDINALS_EN[i]) return i + 1; + } + return 999; + } + + function seriesOf(b) { + const m = /^(.+?)\s+[–—-]\s+(.+)$/.exec(b.title) || /^(.+?):\s+(.+)$/.exec(b.title) || + /^(.+?)\.\s+(.+)$/.exec(b.title); // "Der Zauberberg. Erster Band" + if (!m || !(VOLUME_WORD.test(m[2]) || /^\s*\d+\s*$/.test(m[2]))) return null; + const name = m[1].trim(); + return {key: (name + '|' + (b.author || '')).toLowerCase(), name, number: volumeNumber(m[2]), volume: m[2].trim()}; + } + + // The books of one level (all, or one folder) as cards: series of two or more volumes + // become one stack; folders come first (at the top level). + function entries(books) { + const groups = new Map(); + const single = []; + books.forEach(b => { + const sr = seriesOf(b); + if (!sr) { single.push({kind: 'book', b, sort: b.title}); return; } + if (!groups.has(sr.key)) groups.set(sr.key, {kind: 'series', key: sr.key, name: sr.name, books: [], sort: sr.name}); + groups.get(sr.key).books.push(Object.assign({}, b, {_volume: sr.volume, _number: sr.number})); + }); + const out = single; + groups.forEach(g => { + if (g.books.length < 2) { out.push({kind: 'book', b: g.books[0], sort: g.books[0].title}); return; } + g.books.sort((x, y) => x._number - y._number || x.title.localeCompare(y.title)); + out.push(g); + }); + // The last listened first (as the list comes), then the rest by name. + const recent = e => { + const list = e.kind === 'series' ? e.books : [e.b]; + return list.map(x => x.last_played_at || '').sort().pop() || ''; + }; + return out.sort((x, y) => (recent(y) > recent(x) ? 1 : recent(y) < recent(x) ? -1 : 0) || + x.sort.localeCompare(y.sort, 'de')); + } + + function bookCard(b, label) { + return ''; + } + + function seriesCard(g) { + const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0); + const heard = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0); + const going = g.books.filter(b => b.next && b.last_played_at)[0]; + const done = g.books.filter(b => !b.next).length; + const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume + : done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : ''); + return ''; + } + + function gridHtml(books) { + const folders = new Map(); + books.forEach(b => { if (b.folder) folders.set(b.folder, (folders.get(b.folder) || 0) + 1); }); + if (st.folder != null && !folders.has(st.folder)) st.folder = null; + const here = books.filter(b => st.folder == null ? !b.folder : b.folder === st.folder); + const list = entries(here); + let html = ''; + if (st.series != null) { + const g = list.filter(e => e.kind === 'series' && e.key === st.series)[0]; + if (g) { + return '