From 8f5be379fea8c75da4056c4f6a384dbd60128b2f Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 16 Oct 2026 20:44:23 +0200 Subject: [PATCH] =?UTF-8?q?Bibliothek=20neu:=20Ordner=20als=20Filter-Chips?= =?UTF-8?q?=20=C3=BCberall,=20Cover-Raster,=20Cover=20verschl=C3=BCsselt?= =?UTF-8?q?=20und=20generiert=20(noch=20nicht=20ver=C3=B6ffentlicht)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books//cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 --- books/migrations/0006_covers_and_prefs.py | 36 ++ books/models.py | 13 + books/tests.py | 39 ++ books/urls.py | 2 + books/views.py | 61 ++- docs/library.md | 61 +++ feeds/migrations/0006_folder_order.py | 18 + feeds/models.py | 2 + feeds/tests.py | 13 + feeds/urls.py | 1 + feeds/views.py | 24 +- podcasts/audiobooks.py | 2 +- podcasts/folders.py | 116 +++++ podcasts/migrations/0021_folders_list.py | 32 ++ podcasts/models.py | 6 +- podcasts/test_audiobooks.py | 34 ++ podcasts/tests.py | 4 +- podcasts/urls.py | 5 +- podcasts/views.py | 15 +- static/js/app/52-book-list.js | 23 +- static/js/app/53-library.js | 502 ++++++++++++++++++++++ static/js/audiobooks.js | 133 ++++-- static/js/covers.js | 223 ++++++++++ static/js/folders.js | 213 +++++++++ static/js/gencover.js | 396 +++++++++++++++++ static/js/sw.js | 6 +- templates/radio/player.html | 4 + 27 files changed, 1928 insertions(+), 56 deletions(-) create mode 100644 books/migrations/0006_covers_and_prefs.py create mode 100644 docs/library.md create mode 100644 feeds/migrations/0006_folder_order.py create mode 100644 podcasts/folders.py create mode 100644 podcasts/migrations/0021_folders_list.py create mode 100644 static/js/app/53-library.js create mode 100644 static/js/covers.js create mode 100644 static/js/folders.js create mode 100644 static/js/gencover.js diff --git a/books/migrations/0006_covers_and_prefs.py b/books/migrations/0006_covers_and_prefs.py new file mode 100644 index 0000000..488374e --- /dev/null +++ b/books/migrations/0006_covers_and_prefs.py @@ -0,0 +1,36 @@ +# Generated by Django 6.1 on 2026-10-16 18:21 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0005_progress_last_read_wins'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='ebook', + name='cover_ct', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='ebook', + name='cover_iv', + field=models.CharField(blank=True, default='', max_length=32), + ), + migrations.CreateModel( + name='LibraryPrefs', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('prefs_ct', models.TextField(blank=True, default='')), + ('prefs_iv', models.CharField(blank=True, default='', max_length=32)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='library_prefs', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/books/models.py b/books/models.py index ee1a91e..d50c5eb 100644 --- a/books/models.py +++ b/books/models.py @@ -10,6 +10,10 @@ class EBook(models.Model): data_iv = models.CharField(max_length=32) # hex IV for EPUB data uploaded_at = models.DateTimeField(auto_now_add=True) is_read = models.BooleanField(default=False) + # The cover, end-to-end encrypted like the rest (docs/library.md): a client that has + # the file extracts it, encrypts it with the user's key and sends it once. + cover_ct = models.TextField(blank=True, default='') + cover_iv = models.CharField(max_length=32, blank=True, default='') class Meta: ordering = ['uploaded_at'] @@ -18,6 +22,15 @@ class EBook(models.Model): return f"EBook #{self.pk} (user={self.user_id})" +class LibraryPrefs(models.Model): + """The library's own settings, encrypted with the user's key (the folder order: + folder names are private, docs/library.md). One blob, replaced as a whole.""" + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='library_prefs') + prefs_ct = models.TextField(blank=True, default='') + prefs_iv = models.CharField(max_length=32, blank=True, default='') + updated_at = models.DateTimeField(auto_now=True) + + class EBookProgress(models.Model): """Where to continue a book: the place it was last read, on any device (docs/progress-sync.md), plus the furthest place ever reached.""" diff --git a/books/tests.py b/books/tests.py index 93d9ac4..e98d3d1 100644 --- a/books/tests.py +++ b/books/tests.py @@ -160,3 +160,42 @@ class ProgressTests(TestCase): def test_a_never_opened_book(self): b = self.client.get('/books/', **self.auth).json()[0] self.assertEqual((b['read_at'], b['last_read'], b['position_anchor']), (None, None, '')) + + +class CoverAndPrefsTests(TestCase): + """Covers and the library's settings travel encrypted (docs/library.md).""" + + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.auth = {'HTTP_AUTHORIZATION': f'Bearer {ApiToken.objects.create(user=self.user).token}'} + self.book = EBook.objects.create(user=self.user, meta_ct='m', meta_iv='i', data_ct='d', data_iv='i') + + def post(self, url, body, **auth): + return self.client.post(url, json.dumps(body), content_type='application/json', **(auth or self.auth)) + + def test_a_cover_is_kept_and_listed(self): + url = f'/books/{self.book.id}/cover/' + self.assertEqual(self.client.get(url, **self.auth).status_code, 404) + self.assertFalse(self.client.get('/books/', **self.auth).json()[0]['has_cover']) + self.assertEqual(self.post(url, {'cover_ct': 'Q0lQSEVS', 'cover_iv': 'a1b2c3d4e5f60718293a4b5c'}).status_code, 200) + self.assertEqual(self.client.get(url, **self.auth).json(), + {'cover_ct': 'Q0lQSEVS', 'cover_iv': 'a1b2c3d4e5f60718293a4b5c'}) + self.assertTrue(self.client.get('/books/', **self.auth).json()[0]['has_cover']) + + def test_bad_or_huge_covers_are_refused(self): + url = f'/books/{self.book.id}/cover/' + self.assertEqual(self.post(url, {'cover_ct': 'x', 'cover_iv': 'not hex!'}).status_code, 400) + self.assertEqual(self.post(url, {'cover_ct': 'x' * 300_000, 'cover_iv': 'a1b2c3d4'}).status_code, 400) + + def test_covers_are_ones_own(self): + bob = User.objects.create_user(username='bob', password='pw12345678') + bob_auth = {'HTTP_AUTHORIZATION': f'Bearer {ApiToken.objects.create(user=bob).token}'} + url = f'/books/{self.book.id}/cover/' + self.assertEqual(self.post(url, {'cover_ct': 'x', 'cover_iv': 'a1b2c3d4'}, **bob_auth).status_code, 404) + self.assertEqual(self.client.get(url, **bob_auth).status_code, 404) + + def test_prefs_are_one_encrypted_blob(self): + self.assertEqual(self.client.get('/books/prefs/', **self.auth).json(), {'prefs_ct': '', 'prefs_iv': ''}) + self.post('/books/prefs/', {'prefs_ct': 'AAA', 'prefs_iv': 'a1b2c3d4e5f6'}) + self.post('/books/prefs/', {'prefs_ct': 'BBB', 'prefs_iv': 'a1b2c3d4e5f7'}) + self.assertEqual(self.client.get('/books/prefs/', **self.auth).json(), {'prefs_ct': 'BBB', 'prefs_iv': 'a1b2c3d4e5f7'}) diff --git a/books/urls.py b/books/urls.py index a9d738b..557354a 100644 --- a/books/urls.py +++ b/books/urls.py @@ -12,6 +12,8 @@ urlpatterns = [ path('/delete/', views.delete_book, name='delete_book'), path('/read/', views.set_book_read, name='set_book_read'), path('/meta/', views.update_book_meta, name='update_book_meta'), + path('/cover/', views.book_cover, name='book_cover'), + path('prefs/', views.library_prefs, name='library_prefs'), path('/replace-data/', views.replace_book_data, name='replace_book_data'), path('/rekey/', views.rekey_book, name='rekey_book'), path('/progress/', views.save_progress, name='save_book_progress'), diff --git a/books/views.py b/books/views.py index 41865ad..8ed0203 100644 --- a/books/views.py +++ b/books/views.py @@ -12,7 +12,7 @@ from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks +from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks, LibraryPrefs def _require_auth(request): @@ -291,11 +291,13 @@ def book_list(request): err = _require_auth(request) if err: return err + with_cover = set(request.user.ebooks.exclude(cover_ct='').values_list('id', flat=True)) books = list( request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read') ) for b in books: b['uploaded_at'] = b['uploaded_at'].isoformat() + b['has_cover'] = b['id'] in with_cover progress_map = {p.book_id: p for p in EBookProgress.objects.filter(user=request.user)} highlighted_ids = set( EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True) @@ -360,6 +362,63 @@ def update_book_meta(request, pk): return JsonResponse({'ok': True}) +MAX_COVER = 200_000 # characters of base64: a 300×450 JPEG is far below +MAX_PREFS = 100_000 + + +def _hex_iv(value): + return isinstance(value, str) and 8 <= len(value) <= 32 and all(c in '0123456789abcdefABCDEF' for c in value) + + +@csrf_exempt +@require_http_methods(['GET', 'POST']) +def book_cover(request, pk): + """A book's cover, encrypted on the device with the user's key (docs/library.md): + GET {cover_ct, cover_iv} (404 without one), POST the same to set it.""" + err = _require_auth(request) + if err: + return err + book = EBook.objects.filter(pk=pk, user=request.user).only('id', 'cover_ct', 'cover_iv').first() + if book is None: + return JsonResponse({'error': 'not found'}, status=404) + if request.method == 'GET': + if not book.cover_ct: + return JsonResponse({'error': 'no cover'}, status=404) + return JsonResponse({'cover_ct': book.cover_ct, 'cover_iv': book.cover_iv}) + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + ct, iv = body.get('cover_ct'), body.get('cover_iv') + if not isinstance(ct, str) or not ct or len(ct) > MAX_COVER or not _hex_iv(iv): + return JsonResponse({'error': 'cover_ct, cover_iv required (cover at most 150 KB)'}, status=400) + book.cover_ct, book.cover_iv = ct, iv + book.save(update_fields=['cover_ct', 'cover_iv']) + return JsonResponse({'ok': True}) + + +@csrf_exempt +@require_http_methods(['GET', 'POST']) +def library_prefs(request): + """The library's settings, encrypted with the user's key ({folder_order: [...]}): + GET {prefs_ct, prefs_iv} ('' while there are none), POST the same, replacing them.""" + err = _require_auth(request) + if err: + return err + if request.method == 'GET': + p = LibraryPrefs.objects.filter(user=request.user).first() + return JsonResponse({'prefs_ct': p.prefs_ct if p else '', 'prefs_iv': p.prefs_iv if p else ''}) + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + ct, iv = body.get('prefs_ct'), body.get('prefs_iv') + if not isinstance(ct, str) or not ct or len(ct) > MAX_PREFS or not _hex_iv(iv): + return JsonResponse({'error': 'prefs_ct, prefs_iv required'}, status=400) + LibraryPrefs.objects.update_or_create(user=request.user, defaults={'prefs_ct': ct, 'prefs_iv': iv}) + return JsonResponse({'ok': True}) + + @csrf_exempt @require_http_methods(['POST']) def upload_book(request): diff --git a/docs/library.md b/docs/library.md new file mode 100644 index 0000000..597eb08 --- /dev/null +++ b/docs/library.md @@ -0,0 +1,61 @@ +# Folders and the library (redesign) + +## Folders everywhere: tags shown as filter chips + +One idea of a folder for every area — Bücher, Zeitschriften, Hörbücher, +Podcasts, Blogs (and the news' Ressorts, which already work this way): + +- A thing can be in **several folders** (they work like tags); none is fine. +- Folders are **filters**, not rooms: a row of chips on top — + `Alle · n · … · Ohne Ordner` — tapping one filters in place; no + "inside a folder" view, no back button. On a wide window the row wraps. +- **Assigning**: mark one or more things (long press / ☐ select mode, or ⋮ on + one), then "In Ordner …": a sheet with every folder of the area as checkboxes + and "Neuer Ordner". No typing a name that must match. +- **Managing** (⋮ on the chip row → "Ordner verwalten"): rename, merge (rename + onto an existing name), delete (the things stay, only the folder goes), + reorder by dragging. The order is the chip order. +- **Automatic groups** stay automatic and look different from folders (no + chip; a stack/group heading): audiobook series, magazine periodicals. + +### Where folders are kept + +| Area | Field | Notes | +|------|-------|-------| +| E-books, magazines | encrypted meta `folders: [names]` | `folder` (the first) kept for older clients; read `folders` or else `[folder]` | +| Audiobooks, podcasts | `PodcastFeed.folders` (JSON list) | feed setting `folders`; `folder` (string) still accepted | +| Blogs | `Source.folders` (exists) | | +| News | `Source.category` (one Ressort) | already chips in Nachrichten | + +The folder order per area: e-books in the encrypted user settings blob (names +are private), the others on the server (`/podcasts/folders/`, `/feeds/folders/`, +`{order: [names]}`). + +### Shared code: `static/js/folders.js` (diora-web, every platform) + +``` +DioraFolders.chips(el, {folders: [{name, count}], active, none: count|0, onPick(name|''|null), onManage()}) +DioraFolders.assign({title, folders: [names], selected: [names], onDone(names)}) // the sheet +DioraFolders.manage({folders: [names], onRename(old, new), onDelete(name), onOrder(names)}) +``` +Each area keeps its own storage and calls these for the look and the dialogs. + +## The library (Bücher, Zeitschriften) + +- **Header**: title, search (title, author), sort (zuletzt gelesen · Titel · + Autor · hinzugefügt), ⊞/☰ grid or list, `+` (Buch hinzufügen, Aus Cloud). +- **Filter chips**: Alle · Angefangen · Ungelesen · Gelesen — then the folders. +- **Weiterlesen**: one card on top (cover, title, "Seite 132 von 186 · gestern", + ▶), not repeated in the list below. +- **Grid** (default on phone and desktop): covers with a progress line, title. + **List** (default on e-ink / diora Reader): one row per book, small cover, + title, author, % / ✓. + +### Covers, end-to-end encrypted + +A client that has a book's file extracts its cover (EPUB: the OPF cover image; +PDF: page 1), scales it to 240 px wide JPEG, **encrypts it with the user's key** +(AES-GCM like the meta) and uploads it once: `POST /books//cover/ +{cover_ct, cover_iv}`. `GET /books/` lists `has_cover`; `GET /books//cover/` +returns `{cover_ct, cover_iv}`. Clients cache the decrypted image locally. The +server never sees a cover. diff --git a/feeds/migrations/0006_folder_order.py b/feeds/migrations/0006_folder_order.py new file mode 100644 index 0000000..934ba3c --- /dev/null +++ b/feeds/migrations/0006_folder_order.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-10-16 18:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feeds', '0005_blog_folders_progress'), + ] + + operations = [ + migrations.AddField( + model_name='feedsettings', + name='folder_order', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/feeds/models.py b/feeds/models.py index 7ac837e..309e790 100644 --- a/feeds/models.py +++ b/feeds/models.py @@ -136,3 +136,5 @@ class FeedSettings(models.Model): news_seen_at = models.DateTimeField(null=True, blank=True) # News older than this goes (unless starred). news_keep_days = models.PositiveIntegerField(default=2) + # The order of the blog folders (their chips), names (docs/library.md). + folder_order = models.JSONField(default=list, blank=True) diff --git a/feeds/tests.py b/feeds/tests.py index 2ec8086..aa0969c 100644 --- a/feeds/tests.py +++ b/feeds/tests.py @@ -421,3 +421,16 @@ class DiscoverKindTest(TestCase): def test_text_entries_stay_news_or_a_blog(self): self.assertIn(self.discover([''] * 4)['suggested_kind'], ('news', 'blog')) + + +class FolderOrderTest(TestCase): + def test_blog_folders_come_in_the_users_order(self): + u = User.objects.create_user('u', password='pw12345678') + self.client.force_login(u) + Source.objects.create(user=u, url='https://a/', title='A', kind='blog', folders=['Kunst', 'Code']) + Source.objects.create(user=u, url='https://b/', title='B', kind='blog', folders=['Politik']) + self.assertEqual(self.client.get('/feeds/state/').json()['folders'], ['Code', 'Kunst', 'Politik']) + r = self.client.post('/feeds/folders/order/', json.dumps({'order': ['Politik', 'kunst']}), + content_type='application/json') + self.assertEqual(r.json()['folders'], ['Politik', 'Kunst', 'Code']) + self.assertEqual(self.client.get('/feeds/state/').json()['folders'], ['Politik', 'Kunst', 'Code']) diff --git a/feeds/urls.py b/feeds/urls.py index 9c8546c..c1aaf92 100644 --- a/feeds/urls.py +++ b/feeds/urls.py @@ -22,6 +22,7 @@ urlpatterns = [ 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/order/', views.folder_order, name='feeds_folder_order'), path('folders/dissolve/', views.dissolve_folder, name='feeds_dissolve_folder'), path('settings/', views.settings, name='feeds_settings'), path('notifications/', views.notifications, name='feeds_notifications'), diff --git a/feeds/views.py b/feeds/views.py index 3f2efa0..0068d2f 100644 --- a/feeds/views.py +++ b/feeds/views.py @@ -146,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': _all_folders(sources), + 'folders': _ordered(_all_folders(sources), s.folder_order), '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), @@ -168,6 +168,28 @@ def _all_folders(sources): return sorted(seen.values(), key=str.casefold) +def _ordered(names, order): + """The folders in the order the user gave them (docs/library.md), new ones after.""" + rank = {n.casefold(): i for i, n in enumerate(order or [])} + return sorted(names, key=lambda n: (rank.get(n.casefold(), len(rank)), n.casefold())) + + +@csrf_exempt +@require_http_methods(['POST']) +def folder_order(request): + """{order: [names]}: the blog folders' chip order.""" + if (err := _auth(request)): + return err + order = (_body(request) or {}).get('order') + if not isinstance(order, list) or not all(isinstance(n, str) for n in order): + return JsonResponse({'error': 'order: eine Liste von Namen'}, status=400) + s = refresh.settings_for(request.user) + s.folder_order = [_clean_folder(n) for n in order if _clean_folder(n)][:100] + s.save(update_fields=['folder_order']) + sources = Source.objects.filter(user=request.user, kind=Source.BLOG) + return JsonResponse({'folders': _ordered(_all_folders(sources), s.folder_order)}) + + 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))} diff --git a/podcasts/audiobooks.py b/podcasts/audiobooks.py index c29892a..3851c47 100644 --- a/podcasts/audiobooks.py +++ b/podcasts/audiobooks.py @@ -87,7 +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, + 'folder': feed.folder, 'folders': feed.folders or [], '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/folders.py b/podcasts/folders.py new file mode 100644 index 0000000..aa63a0c --- /dev/null +++ b/podcasts/folders.py @@ -0,0 +1,116 @@ +"""Folders of podcasts and audiobooks (docs/library.md): like tags, any number per +subscription (PodcastFeed.folders), shown as filter chips in their order +(PodcastSettings.folder_order). Renaming onto an existing name merges; deleting +a folder leaves its subscriptions where they are.""" +import json + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from .models import PodcastFeed, PodcastSettings + +MAX_FOLDERS = 20 +MAX_NAME = 100 + + +def clean_name(value): + return ' '.join(str(value or '').split())[:MAX_NAME] + + +def clean_list(value): + """A list of folder names: trimmed, without empties and doubles (first spelling wins).""" + if not isinstance(value, list): + raise ValueError + out, seen = [], set() + for v in value: + if not isinstance(v, str): + raise ValueError + name = clean_name(v) + if name and name.casefold() not in seen: + seen.add(name.casefold()) + out.append(name) + return out[:MAX_FOLDERS] + + +def set_folders(feed, names): + """The feed's folders, and `folder` (the first) for older clients.""" + feed.folders = names + feed.folder = names[0] if names else '' + + +def _body(request): + try: + body = json.loads(request.body or b'{}') + except (json.JSONDecodeError, ValueError): + return None + return body if isinstance(body, dict) else None + + +def _data(user): + counts = {} + for names in PodcastFeed.objects.filter(user=user).values_list('folders', flat=True): + for n in names or []: + counts[n] = counts.get(n, 0) + 1 + order = [n for n in PodcastSettings.for_user(user).folder_order if n in counts] + rest = sorted((n for n in counts if n not in order), key=str.casefold) + return {'order': order + rest, 'folders': [{'name': n, 'count': counts[n]} for n in order + rest]} + + +@csrf_exempt +@require_http_methods(['GET', 'POST']) +def folders(request): + """GET {order, folders: [{name, count}]}; POST {order: [names]} sets the chip order.""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + if request.method == 'POST': + body = _body(request) + try: + order = clean_list((body or {}).get('order')) + except ValueError: + return JsonResponse({'error': 'order: a list of names'}, status=400) + s = PodcastSettings.for_user(request.user) + s.folder_order = order + s.save(update_fields=['folder_order']) + return JsonResponse(_data(request.user)) + + +def _rewrite(user, fn): + for feed in PodcastFeed.objects.filter(user=user).only('id', 'folders', 'folder'): + names = fn(list(feed.folders or [])) + if names != (feed.folders or []): + set_folders(feed, clean_list(names)) + feed.save(update_fields=['folders', 'folder']) + + +@csrf_exempt +@require_http_methods(['POST']) +def rename_folder(request): + """{old, new}: every subscription in `old` is in `new` instead (merged if it exists).""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + body = _body(request) or {} + old, new = clean_name(body.get('old')), clean_name(body.get('new')) + if not old or not new: + return JsonResponse({'error': 'old and new required'}, status=400) + _rewrite(request.user, lambda names: [new if n == old else n for n in names]) + s = PodcastSettings.for_user(request.user) + s.folder_order = clean_list([new if n == old else n for n in s.folder_order]) + s.save(update_fields=['folder_order']) + return JsonResponse(_data(request.user)) + + +@csrf_exempt +@require_http_methods(['POST']) +def delete_folder(request): + """{name}: the folder goes, its subscriptions stay.""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + name = clean_name((_body(request) or {}).get('name')) + if not name: + return JsonResponse({'error': 'name required'}, status=400) + _rewrite(request.user, lambda names: [n for n in names if n != name]) + s = PodcastSettings.for_user(request.user) + s.folder_order = [n for n in s.folder_order if n != name] + s.save(update_fields=['folder_order']) + return JsonResponse(_data(request.user)) diff --git a/podcasts/migrations/0021_folders_list.py b/podcasts/migrations/0021_folders_list.py new file mode 100644 index 0000000..d05e057 --- /dev/null +++ b/podcasts/migrations/0021_folders_list.py @@ -0,0 +1,32 @@ +# Generated by Django 6.1 on 2026-10-16 18:21 + +from django.db import migrations, models + + +def folder_to_folders(apps, schema_editor): + """An audiobook's one folder becomes its first (and only) folder in the list.""" + PodcastFeed = apps.get_model('podcasts', 'PodcastFeed') + for feed in PodcastFeed.objects.exclude(folder=''): + feed.folders = [feed.folder] + feed.save(update_fields=['folders']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcasts', '0020_audiobook_folder'), + ] + + operations = [ + migrations.AddField( + model_name='podcastfeed', + name='folders', + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name='podcastsettings', + name='folder_order', + field=models.JSONField(blank=True, default=list), + ), + migrations.RunPython(folder_to_folders, migrations.RunPython.noop), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 222a4fc..798b0ab 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -75,7 +75,9 @@ 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). + # Its folders (docs/library.md): like tags, any number; shown as filter chips. `folder` + # is the first of them, for older clients. + folders = models.JSONField(default=list, blank=True) folder = models.CharField(max_length=100, blank=True) # The vocabulary of episode_sort and episode_filter: the podcast page's @@ -293,6 +295,8 @@ class PodcastSettings(models.Model): # imported: [{episode_id, size (bytes, 0 = unknown)}]. The app offers once # to download them again, then clears the list. import_downloads = models.JSONField(default=list, blank=True) + # The order of the podcast/audiobook folders (their chips), names. + folder_order = models.JSONField(default=list, blank=True) # Playback, the same on every device (AntennaPod's playback settings). # Null = not set yet: each device keeps its own value; the first app that diff --git a/podcasts/test_audiobooks.py b/podcasts/test_audiobooks.py index cc59cf8..4a404a1 100644 --- a/podcasts/test_audiobooks.py +++ b/podcasts/test_audiobooks.py @@ -198,3 +198,37 @@ class AudiobookFolderTests(TestCase): 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') + + +class FolderTagTests(TestCase): + """Folders of podcasts and audiobooks work like tags (docs/library.md).""" + + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.client.force_login(self.user) + self.a = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='A') + self.b = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='B') + + def post(self, url, body): + return self.client.post(url, json.dumps(body), content_type='application/json') + + def test_several_folders_and_the_old_single_one(self): + r = self.post(f'/podcasts/feeds/{self.a.pk}/settings/', {'folders': [' Politik ', 'Wissen', 'politik', '']}) + self.assertEqual((r.json()['folders'], r.json()['folder']), (['Politik', 'Wissen'], 'Politik')) + r = self.post(f'/podcasts/feeds/{self.a.pk}/settings/', {'folder': 'Romane'}) # an older client + self.assertEqual((r.json()['folders'], r.json()['folder']), (['Romane'], 'Romane')) + r = self.post(f'/podcasts/feeds/{self.a.pk}/settings/', {'folders': []}) + self.assertEqual((r.json()['folders'], r.json()['folder']), ([], '')) + + def test_order_rename_merge_delete(self): + self.post(f'/podcasts/feeds/{self.a.pk}/settings/', {'folders': ['Politik', 'Wissen']}) + self.post(f'/podcasts/feeds/{self.b.pk}/settings/', {'folders': ['Wissenschaft']}) + d = self.post('/podcasts/folders/', {'order': ['Wissen', 'Politik']}).json() + self.assertEqual(d['order'], ['Wissen', 'Politik', 'Wissenschaft']) + d = self.post('/podcasts/folders/rename/', {'old': 'Wissenschaft', 'new': 'Wissen'}).json() # merge + self.assertEqual(d['folders'], [{'name': 'Wissen', 'count': 2}, {'name': 'Politik', 'count': 1}]) + d = self.post('/podcasts/folders/delete/', {'name': 'Politik'}).json() + self.assertEqual(d['folders'], [{'name': 'Wissen', 'count': 2}]) + self.a.refresh_from_db() + self.assertEqual((self.a.folders, self.a.folder), (['Wissen'], 'Wissen')) + self.assertEqual(PodcastFeed.objects.count(), 2) # the subscriptions stay diff --git a/podcasts/tests.py b/podcasts/tests.py index 258b44f..43bed26 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -1074,7 +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': '', + 'folder': '', 'folders': [], } @@ -1101,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', 'folder': '', + 'episode_sort': '', 'episode_filter': [], 'skip_silence': None, 'kind': 'podcast', 'folder': '', 'folders': [], }) 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/urls.py b/podcasts/urls.py index ed6d08e..0fad4c6 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from . import audiobooks, views +from . import audiobooks, folders, views urlpatterns = [ path('search/', views.podcast_search, name='podcast_search'), @@ -16,6 +16,9 @@ urlpatterns = [ path('feeds//move/', views.move_feed, name='podcast_move_feed'), path('audiobooks/', audiobooks.audiobook_list, name='podcast_audiobooks'), path('audiobooks//', audiobooks.audiobook_detail, name='podcast_audiobook'), + path('folders/', folders.folders, name='podcast_folders'), + path('folders/rename/', folders.rename_folder, name='podcast_folder_rename'), + path('folders/delete/', folders.delete_folder, name='podcast_folder_delete'), path('bookmarks/', audiobooks.bookmark_add, name='podcast_bookmark_add'), path('bookmarks//', audiobooks.bookmark_update, name='podcast_bookmark_update'), path('bookmarks//delete/', audiobooks.bookmark_delete, name='podcast_bookmark_delete'), diff --git a/podcasts/views.py b/podcasts/views.py index 46d75da..c67299d 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -29,6 +29,7 @@ from . import silence as silence_mod from . import feedext from . import transcripts as transcripts_mod from . import youtube as youtube_mod +from . import folders as folders_mod from .models import ( EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played, @@ -787,7 +788,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', 'folder', + 'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder', 'folders', ) @@ -1025,7 +1026,8 @@ FEED_SETTING_PARSERS = { 'episode_sort': _episode_sort, 'episode_filter': _episode_filter, 'kind': _kind, - 'folder': lambda v: ' '.join(str(v or '').split())[:100], + 'folder': lambda v: folders_mod.clean_name(v), + 'folders': folders_mod.clean_list, 'skip_silence': _optional_boolean, } @@ -1117,6 +1119,15 @@ def feed_settings(request, pk): changes[name] = parse(body[name]) except (ValueError, TypeError, OverflowError): return JsonResponse({'error': f'invalid {name}', 'field': name}, status=400) + # Folders (docs/library.md): a list; `folder` alone (older clients) means that one only. + if 'folders' in changes or 'folder' in changes: + names = changes.pop('folders', None) + if names is None: + one = changes.pop('folder') + names = [one] if one else [] + changes.pop('folder', None) + folders_mod.set_folders(feed, names) + changes['folders'], changes['folder'] = feed.folders, feed.folder became_book = changes.get('kind') == PodcastFeed.AUDIOBOOK and feed.kind != PodcastFeed.AUDIOBOOK if changes: for name, value in changes.items(): diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index fe44304..0f592a1 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -1,5 +1,12 @@ 'use strict'; +// A book's folders: the list, or the single folder of older metas. +function _foldersOfMeta(meta) { + if (Array.isArray(meta.folders)) return meta.folders.filter(f => typeof f === 'string' && f.trim()); + return meta.folder ? [meta.folder] : []; +} +window._foldersOfMeta = _foldersOfMeta; + async function loadBookList() { if (!IS_AUTHENTICATED) return; const listEl = $('book-list'); @@ -41,8 +48,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, 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}); + bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', folders: _foldersOfMeta(meta), 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 || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, has_cover: !!b.has_cover, 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 +66,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, magazine: b.magazine}; + for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', folders: _foldersOfMeta(b), 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); @@ -144,6 +151,7 @@ function repairBook(bookId) { title, author, filename: file.name, type, isbn: isbn || prevMeta.isbn || '', folder: prevMeta.folder || '', + folders: prevMeta.folders || _foldersOfMeta(prevMeta), shelfTag: prevMeta.shelfTag || '', magazine: prevMeta.magazine, })); @@ -211,7 +219,8 @@ let _currentBookFolder = null; // null = root view (folder tiles + unfiled books // or, unmarked, a book in a folder called "Zeitschriften" (or "Magazine"). let _bookSection = 'books'; const _MAGAZINE_FOLDER = /^(zeitschriften|magazine|magazines)$/i; -const _isMagazine = b => b.magazine === true || (b.magazine !== false && _MAGAZINE_FOLDER.test((b.folder || '').trim())); +const _isMagazine = b => b.magazine === true || + (b.magazine !== false && _foldersOfMeta(b).some(f => _MAGAZINE_FOLDER.test(f.trim()))); window._isMagazine = _isMagazine; // The periodical an issue belongs to: its title up to the issue's number ("Jacobin #24", @@ -408,11 +417,15 @@ async function _updateBookMeta(bookId, overrides) { type: cached.type || book.type || 'epub', isbn: cached.isbn || book.isbn || '', folder: cached.folder || book.folder || '', + folders: cached.folders || book.folders || _foldersOfMeta(book), shelfTag: cached.shelfTag || book.shelfTag || '', pages: cached.pages || book.pages || 0, magazine: cached.magazine !== undefined ? cached.magazine : book.magazine, ...overrides, }; + // The list decides; `folder` follows it (the first), for older clients. + if (overrides.folders) merged.folder = overrides.folders[0] || ''; + else if ('folder' in overrides) merged.folders = overrides.folder ? [overrides.folder] : []; const key = await getOrCreateEncKey(); const metaJson = new TextEncoder().encode(JSON.stringify(merged)); const metaEnc = await encryptBytes(key, metaJson); @@ -423,7 +436,7 @@ async function _updateBookMeta(bookId, overrides) { }); const data = await res.json(); if (!data.ok) throw new Error('Serverfehler'); - Object.assign(book, overrides); + Object.assign(book, overrides, {folder: merged.folder, folders: merged.folders}); bookMetaCache[bookId] = merged; _saveBookMeta(_lastBookListData); return true; diff --git a/static/js/app/53-library.js b/static/js/app/53-library.js new file mode 100644 index 0000000..a826528 --- /dev/null +++ b/static/js/app/53-library.js @@ -0,0 +1,502 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// The library (docs/library.md): Bücher and Zeitschriften as one page — a header with +// search, sort, grid/list and +, one row of chips (Angefangen · Ungelesen · Gelesen, +// then the folders, docs/library.md: tags, any number per book), one "Weiterlesen" +// card, then the books as covers (grid) or rows (list; e-ink and diora Reader start +// with it). Folders live in each book's encrypted meta (`folders`), their order in the +// encrypted library prefs (/books/prefs/). Covers come from covers.js. +// Takes over renderBookList; loading, decrypting and the book actions stay in +// 52-book-list.js. +// --------------------------------------------------------------------------- + +const _LIB_EINK = () => document.documentElement.classList.contains('nui-reader-app') || + (document.body && document.body.classList.contains('eink')); + +const _lib = { + q: '', + searching: false, + sort: (() => { try { return localStorage.getItem('diora_lib_sort') || 'recent'; } catch (e) { return 'recent'; } })(), + layout: (() => { try { return localStorage.getItem('diora_lib_layout') || ''; } catch (e) { return ''; } })(), + status: 'all', // all | started | unread | read + folder: null, // null: all, '': without a folder, else a name + selecting: null, // a Set of book ids while choosing several + order: null, // the folders' order (decrypted prefs) +}; +window.__dioraLibraryV2 = true; +document.documentElement.classList.add('lib-v2'); + +const _libLayout = () => _lib.layout || (_LIB_EINK() ? 'list' : 'grid'); +const _libFolders = b => (typeof _foldersOfMeta === 'function' ? _foldersOfMeta(b) : (b.folder ? [b.folder] : [])); +const _libNorm = s => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); +const _libStarted = b => !b.is_read && (b.scroll_fraction || 0) > 0; +const _libUnread = b => !b.is_read && !(b.scroll_fraction > 0); + +// ---- the folders' order: encrypted prefs ------------------------------------------------ + +async function _libLoadPrefs() { + try { _lib.order = JSON.parse(localStorage.getItem('diora_lib_order') || 'null'); } catch (e) {} + try { + const r = await fetch('/books/prefs/', {cache: 'no-store'}); + const d = r.ok ? await r.json() : null; + if (d && d.prefs_ct) { + const plain = await decryptBytes(await getOrCreateEncKey(), d.prefs_iv, d.prefs_ct); + const prefs = JSON.parse(new TextDecoder().decode(plain)); + _lib.order = Array.isArray(prefs.folder_order) ? prefs.folder_order : []; + try { localStorage.setItem('diora_lib_order', JSON.stringify(_lib.order)); } catch (e) {} + if (_lastBookListData && _lastBookListData.length) renderBookList(_lastBookListData); + } + } catch (e) { /* offline: the order kept here */ } +} + +async function _libSaveOrder(order) { + _lib.order = order; + try { localStorage.setItem('diora_lib_order', JSON.stringify(order)); } catch (e) {} + const enc = await encryptBytes(await getOrCreateEncKey(), new TextEncoder().encode(JSON.stringify({folder_order: order}))); + await fetch('/books/prefs/', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({prefs_ct: enc.ciphertext, prefs_iv: enc.iv})}); +} + +// Every folder of the books shown (this section), in the user's order. +function _libFolderList(books) { + const counts = new Map(); + books.forEach(b => _libFolders(b).forEach(f => counts.set(f, (counts.get(f) || 0) + 1))); + const order = _lib.order || []; + const rank = n => { const i = order.indexOf(n); return i < 0 ? 1e6 : i; }; + return Array.from(counts.keys()).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b, 'de')) + .map(n => ({name: n, count: counts.get(n)})); +} + +// ---- which books, in which order --------------------------------------------------------------- + +function _libShown(books) { + const q = _libNorm(_lib.q).trim(); + let list = books.filter(b => + (_lib.status === 'all' || (_lib.status === 'started' ? _libStarted(b) : _lib.status === 'unread' ? _libUnread(b) : b.is_read)) && + (_lib.folder == null || (_lib.folder === '' ? !_libFolders(b).length : _libFolders(b).indexOf(_lib.folder) >= 0)) && + (!q || _libNorm(b.title + ' ' + b.author).indexOf(q) >= 0)); + const by = { + recent: (a, b) => (_bookRecency(b) - _bookRecency(a)) || String(b.uploaded_at || '').localeCompare(String(a.uploaded_at || '')), + title: (a, b) => a.title.localeCompare(b.title, 'de', {numeric: true}), + author: (a, b) => (a.author || '~').localeCompare(b.author || '~', 'de') || a.title.localeCompare(b.title, 'de', {numeric: true}), + added: (a, b) => String(b.uploaded_at || '').localeCompare(String(a.uploaded_at || '')), + }; + return list.slice().sort(by[_lib.sort] || by.recent); +} + +// ---- drawing ------------------------------------------------------------------------------------- + +function _libEsc(s) { + return typeof escapeHtml === 'function' ? escapeHtml(s) : String(s == null ? '' : s); +} + +function _libHue(s) { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; + return h; +} + +function _libCover(b, cls) { + const url = window.DioraCovers ? DioraCovers.url(b) : null; + if (url) return ``; + // None (yet): one drawn from the book's title, author, category and folders (gencover.js). + if (window.DioraGenCover) { + return ``; + } + const initials = (b.title || '?').trim().slice(0, 1).toUpperCase(); + return `${_libEsc(b.title || '')}${_libEsc(initials)}`; +} + +function _libPct(b) { + return b.is_read ? 100 : Math.round((b.scroll_fraction || 0) * 100); +} + +function _libState(b) { + if (b.keyOk === false) return '⚠ falscher Schlüssel'; + if (b.is_read) return '✓ gelesen'; + const p = _libPct(b); + return p > 0 ? p + ' %' : 'neu'; +} + +function _libCard(b) { + const sel = _lib.selecting && _lib.selecting.has(b.id); + const pct = _libPct(b); + if (_libLayout() === 'list') { + return `
+ ${_libCover(b, 'lib-thumb')} + ${_libEsc(b.title)} + ${_libEsc(b.author || '')} + ${_libEsc(_libState(b))} +
`; + } + return `
+ ${_libCover(b, 'lib-cover')} + ${b.is_read ? '✓' : ''} + + + ${_libEsc(b.title)} + ${_libEsc(b.author || '')}
`; +} + +function _libContinueBook(books) { + if (_lib.status !== 'all' || _lib.folder != null || _lib.q || _lib.selecting) return null; + return _recentlyReadBooks(books).filter(x => !x.is_read && x.keyOk !== false)[0] || null; +} + +function _libContinue(b) { + if (!b) return ''; + const pages = b.pages ? ` · Seite ${Math.max(1, Math.round(b.pages * (b.scroll_fraction || 0)))} von ${b.pages}` : ''; + return `
${_libCover(b, 'lib-thumb lib-thumb-l')} + Weiterlesen + ${_libEsc(b.title)} + ${_libEsc(b.author || '')} + + ${_libPct(b)} %${pages} +
`; +} + +// Zeitschriften: the issues under their periodical (automatic, not a folder). +function _libMagazines(list) { + const groups = new Map(); + list.forEach(b => { + const k = typeof _periodicalOf === 'function' ? _periodicalOf(b) : ''; + if (!groups.has(k)) groups.set(k, []); + groups.get(k).push(b); + }); + const names = Array.from(groups.keys()).sort((a, b) => a.localeCompare(b, 'de')); + return names.map(n => { + const issues = groups.get(n).sort((a, b) => b.title.localeCompare(a.title, 'de', {numeric: true})); + return `

${_libEsc(n)} ${issues.length}

` + + `
${issues.map(_libCard).join('')}
`; + }).join(''); +} + +function _libRender(allBooks) { + const listEl = $('book-list'); + if (!listEl) return; + _lastBookListData = allBooks; + const books = _bookSectionFilter(allBooks); + const magazines = window.DioraNav && _bookSection === 'magazines'; + const shown = _libShown(books); + const narrow = window.matchMedia && matchMedia('(max-width: 720px)').matches; + const sorts = [['recent', 'Zuletzt'], ['title', 'Titel'], ['author', 'Autor'], ['added', 'Neu hinzugefügt']]; + let h = `

${magazines ? 'Zeitschriften' : 'Bücher'}

+ ${!narrow || _lib.searching || _lib.q ? `` : + ''} + + +
+
`; + if (_lib.selecting) { + h += `
${_lib.selecting.size} ausgewählt + +
`; + } + if (!allBooks.length) { + h += '

Noch keine Bücher. Mit + eine .epub- oder .pdf-Datei hinzufügen.

'; + } else if (!books.length) { + h += `

${magazines ? 'Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.' : 'Alle Bücher stehen bei den Zeitschriften.'}

`; + } else { + const going = _libContinueBook(books); + h += _libContinue(going); + if (going) shown.splice(shown.indexOf(going), 1); // once, on top + if (!shown.length && !going) h += '

Nichts gefunden.

'; + else if (magazines && _lib.sort === 'recent' && !_lib.q) h += _libMagazines(shown); + else h += `
${shown.map(_libCard).join('')}
`; + } + const q = listEl.querySelector('.lib-q'); + const focused = q && document.activeElement === q; + listEl.innerHTML = h; + const status = document.getElementById('book-upload-status'); + if (status && status.textContent.trim()) listEl.querySelector('.lib-head').after(status); + _libChips(books); + if (focused || (_lib.searching && !_lib.q)) { + const n = listEl.querySelector('.lib-q'); + if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); } + } +} + +function _libChips(books) { + const el = document.querySelector('#book-list .lib-chips'); + if (!el || !window.DioraFolders) return; + const folders = _libFolderList(books); + const none = books.filter(b => !_libFolders(b).length).length; + DioraFolders.chips(el, { + lead: [ + {id: 'started', label: 'Angefangen', count: books.filter(_libStarted).length, on: _lib.status === 'started'}, + {id: 'unread', label: 'Ungelesen', count: books.filter(_libUnread).length, on: _lib.status === 'unread'}, + {id: 'read', label: 'Gelesen', count: books.filter(b => b.is_read).length, on: _lib.status === 'read'}, + ], + folders, active: _lib.folder, none: folders.length ? none : 0, + onPick(p) { + if (p && p.lead) _lib.status = _lib.status === p.lead ? 'all' : p.lead; + else _lib.folder = p; + renderBookList(_lastBookListData); + }, + onManage: folders.length ? _libManage : null, + }); +} + +window.renderBookList = _libRender; + +// ---- folders --------------------------------------------------------------------------------------- + +// Into folders: one book (its folders as they are), or several (the folders they share). +function _libAssign(ids) { + const books = ids.map(id => _lastBookListData.find(b => b.id === id)).filter(Boolean); + if (!books.length || !window.DioraFolders) return; + const common = _libFolders(books[0]).filter(f => books.every(b => _libFolders(b).indexOf(f) >= 0)); + DioraFolders.assign({ + title: books.length === 1 ? `Ordner für „${books[0].title}“` : `Ordner für ${books.length} Bücher`, + folders: _libFolderList(_lastBookListData).map(f => f.name), + selected: common, + async onDone(picked) { + try { + for (const b of books) { + const kept = _libFolders(b).filter(f => common.indexOf(f) < 0 || picked.indexOf(f) >= 0); + const next = kept.concat(picked.filter(f => kept.indexOf(f) < 0)); + if (next.join('\u0000') !== _libFolders(b).join('\u0000')) await _updateBookMeta(b.id, {folders: next}); + } + } catch (e) { + customAlert('Das ließ sich nicht speichern: ' + e.message); + } + _lib.selecting = null; + renderBookList(_lastBookListData); + }, + }); +} +window.assignBookFolder = id => _libAssign([Number(id)]); + +function _libManage() { + if (!window.DioraFolders) return; + const rewrite = async fn => { + for (const b of _lastBookListData) { + const was = _libFolders(b); + const next = fn(was).filter((f, i, all) => f && all.indexOf(f) === i); + if (next.join('\u0000') !== was.join('\u0000')) await _updateBookMeta(b.id, {folders: next}); + } + renderBookList(_lastBookListData); + }; + DioraFolders.manage({ + title: 'Ordner der Bücher', + folders: _libFolderList(_lastBookListData).map(f => f.name), + onRename: (old, n) => { + if (_lib.folder === old) _lib.folder = n; + return rewrite(fs => fs.map(f => (f === old ? n : f))).then(() => + _libSaveOrder((_lib.order || []).map(f => (f === old ? n : f)).filter((f, i, a) => a.indexOf(f) === i))); + }, + onDelete: name => { + if (_lib.folder === name) _lib.folder = null; + return rewrite(fs => fs.filter(f => f !== name)).then(() => _libSaveOrder((_lib.order || []).filter(f => f !== name))); + }, + onOrder: order => _libSaveOrder(order).then(() => renderBookList(_lastBookListData)).catch(() => {}), + }); +} + +// ---- a book's actions (the apps may bring their own sheet: __dioraBookSheet) ------------------------- + +function _libSheet(b) { + if (window.__dioraBookSheet && window.__dioraBookSheet(b) !== false) return; + const broken = typeof _brokenBooks !== 'undefined' && _brokenBooks.has(b.id); + const items = [ + b.keyOk !== false && ['Lesen', () => openBook(b.id)], + ['In Ordner …', () => _libAssign([b.id])], + [_isMagazine(b) ? 'Als Buch führen' : 'Als Zeitschrift führen', () => toggleBookMagazine(b.id)], + [b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren', () => toggleBookRead(b.id, !!b.is_read)], + b.has_highlights && ['Markierungen exportieren', () => downloadBookAnnotationsFromList(b.id, null)], + ['Regal-Kategorie abrufen', () => lookupBookMetadata(b.id)], + ['Mehrere auswählen', () => { _lib.selecting = new Set([b.id]); renderBookList(_lastBookListData); }], + broken && ['Reparieren', () => repairBook(b.id)], + ['Löschen', () => deleteBook(b.id), true], + ].filter(Boolean); + const wrap = document.createElement('div'); + wrap.className = 'dfo-scrim'; + wrap.innerHTML = ``; + const close = () => wrap.remove(); + wrap.addEventListener('click', e => { + const t = e.target.closest('[data-i]'); + if (e.target === wrap || e.target.closest('[data-x]')) { close(); return; } + if (t) { close(); items[Number(t.dataset.i)][1](); } + }); + document.body.appendChild(wrap); +} + +// ---- events ------------------------------------------------------------------------------------------ + +(function () { + const listEl = $('book-list'); + if (!listEl) return; + let pressTimer = 0; + let pressed = null; + // First on the list: the apps' own card clicks (native-ui.js) don't run for these cards. + listEl.addEventListener('click', e => { + const t = e.target; + const more = t.closest('[data-lib-more]'); + const act = t.closest('[data-lib]'); + const card = t.closest('[data-lib-book]'); + if (!more && !act && !card) return; + e.stopImmediatePropagation(); + if (pressed) { pressed = null; return; } // the long press already chose + if (more) { + const b = _lastBookListData.find(x => x.id === Number(more.dataset.libMore)); + if (b) _libSheet(b); + return; + } + if (act) { + const a = act.dataset.lib; + if (a === 'search') { _lib.searching = true; renderBookList(_lastBookListData); } + else if (a === 'layout') { + _lib.layout = _libLayout() === 'grid' ? 'list' : 'grid'; + try { localStorage.setItem('diora_lib_layout', _lib.layout); } catch (err) {} + renderBookList(_lastBookListData); + } else if (a === 'add') _libAddMenu(); + else if (a === 'sel-done') { _lib.selecting = null; renderBookList(_lastBookListData); } + else if (a === 'sel-folders') _libAssign(Array.from(_lib.selecting || [])); + return; + } + const id = Number(card.dataset.libBook); + if (_lib.selecting) { + if (_lib.selecting.has(id)) _lib.selecting.delete(id); else _lib.selecting.add(id); + renderBookList(_lastBookListData); + return; + } + const b = _lastBookListData.find(x => x.id === id); + if (b && b.keyOk === false) { + customAlert('Dieses Buch lässt sich mit deinem Schlüssel nicht entschlüsseln. Über ⋮ → „Schlüssel einfügen …“ den richtigen Schlüssel eintragen.'); + return; + } + openBook(id); + }, true); + // A long press on a book: choosing several. + listEl.addEventListener('pointerdown', e => { + const card = e.target.closest('[data-lib-book]'); + if (!card || e.target.closest('button')) return; + clearTimeout(pressTimer); + pressTimer = setTimeout(() => { + const id = Number(card.dataset.libBook); + _lib.selecting = _lib.selecting || new Set(); + _lib.selecting.add(id); + pressed = id; + renderBookList(_lastBookListData); + }, 550); + }); + ['pointerup', 'pointercancel', 'pointerleave'].forEach(n => listEl.addEventListener(n, () => clearTimeout(pressTimer))); + listEl.addEventListener('input', e => { + if (!e.target.classList.contains('lib-q')) return; + _lib.q = e.target.value; + renderBookList(_lastBookListData); + }); + listEl.addEventListener('change', e => { + if (!e.target.classList.contains('lib-sort')) return; + _lib.sort = e.target.value; + try { localStorage.setItem('diora_lib_sort', _lib.sort); } catch (err) {} + renderBookList(_lastBookListData); + }); + // Back (Android): out of choosing, out of a search. + const back = window.__dioraBack; + window.__dioraBack = function () { + const shown = $('tab-books') && $('tab-books').style.display !== 'none'; + if (shown && _lib.selecting) { _lib.selecting = null; renderBookList(_lastBookListData); return true; } + if (shown && (_lib.q || _lib.searching)) { _lib.q = ''; _lib.searching = false; renderBookList(_lastBookListData); return true; } + return back ? back() : false; + }; + if (window.DioraCovers) { + DioraCovers.onReady(id => { + const url = DioraCovers.url({id}); + document.querySelectorAll(`#book-list [data-cover-id="${id}"]`).forEach(el => { + if (el.tagName === 'IMG') { el.src = url; return; } + const img = document.createElement('img'); + img.className = el.className.replace('lib-nocover', '').trim(); + img.alt = ''; + img.dataset.coverId = id; + img.src = url; + el.replaceWith(img); + }); + }); + } + window.addEventListener('resize', () => { if (_lastBookListData && _lastBookListData.length) renderBookList(_lastBookListData); }); + _libLoadPrefs(); +})(); + +function _libAddMenu() { + const wrap = document.createElement('div'); + wrap.className = 'dfo-scrim'; + const cloud = !(window.DioraNative && DioraNative.isReaderApp && DioraNative.isReaderApp()); + wrap.innerHTML = ``; + wrap.addEventListener('click', e => { + const a = e.target.closest('[data-a]'); + if (e.target === wrap || e.target.closest('[data-x]')) { wrap.remove(); return; } + if (!a) return; + wrap.remove(); + if (a.dataset.a === 'file') { const f = $('book-file-input'); if (f) f.click(); } + else if (a.dataset.a === 'cloud') toggleCloudImport(); + else { _lib.selecting = new Set(); renderBookList(_lastBookListData); } + }); + document.body.appendChild(wrap); +} + +// ---- styles --------------------------------------------------------------------------------------------- + +(function () { + const style = document.createElement('style'); + style.textContent = [ + 'html.lib-v2 #tab-books .book-drop-zone, html.lib-v2 #tab-books .book-list-filter,', + 'html.lib-v2 #book-upload-area > button.btn, html.lib-v2 #book-upload-area > .nui-add-row { display: none !important; }', + '#book-list .lib-head { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }', + '#book-list .lib-h { margin: 0 auto 0 0; font-size: 18px; }', + '#book-list .lib-q { flex: 0 1 260px; min-width: 0; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }', + '#book-list .lib-sort { padding: 7px 6px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; font-size: 13px; max-width: 140px; }', + '#book-list .lib-icon { min-width: 38px; min-height: 38px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 17px; cursor: pointer; }', + '#book-list .lib-add { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 20px; }', + '#book-list .lib-chips { margin: 0 0 10px; }', + '#book-list .lib-selbar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; margin: 0 0 10px; border-radius: 8px; background: var(--raised, #222); }', + '#book-list .lib-selbar span { flex: 1; }', + '#book-list .lib-btn, .lib-sheet-item { min-height: 36px; padding: 0 14px; border-radius: 18px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }', + '.lib-sheet-item { display: block; width: 100%; text-align: left; border: none; border-radius: 0; border-bottom: 1px solid var(--border, #2a2a2a); min-height: 46px; }', + '.lib-danger { color: var(--accent, #e63946); }', + '#book-list .lib-continue { display: flex; align-items: center; gap: 12px; padding: 10px; margin: 0 0 16px; border: 1px solid var(--border, #2a2a2a); border-radius: 10px; background: var(--card, #0b0b0b); cursor: pointer; }', + '#book-list .lib-kicker { color: var(--accent, #e63946); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }', + '#book-list .lib-play { flex: none; width: 46px; height: 46px; border-radius: 50%; background: var(--accent, #e63946); color: var(--on-accent, #fff); display: flex; align-items: center; justify-content: center; font-size: 17px; }', + '#book-list .lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 18px 14px; }', + '@media (max-width: 480px) { #book-list .lib-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px 10px; } }', + '#book-list .lib-card { display: flex; flex-direction: column; gap: 3px; min-width: 0; cursor: pointer; }', + '#book-list .lib-coverbox { position: relative; display: block; }', + '#book-list .lib-cover { display: block; width: 100%; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 4px; background: var(--panel, #161616); box-shadow: 0 1px 3px rgba(0,0,0,.35); }', + '#book-list .lib-nocover { display: flex; flex-direction: column; justify-content: space-between; padding: 8px; box-sizing: border-box; overflow: hidden; background: hsl(var(--h), 28%, 24%); color: hsl(var(--h), 40%, 88%); }', + '#book-list .lib-nocover span { font-size: 12px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }', + '#book-list .lib-nocover b { font-size: 26px; opacity: .5; align-self: flex-end; }', + '#book-list .lib-thumb { width: 34px; height: 51px; flex: none; border-radius: 3px; object-fit: cover; }', + '#book-list .lib-thumb.lib-nocover { padding: 2px; }', + '#book-list .lib-thumb.lib-nocover span { display: none; }', + '#book-list .lib-thumb.lib-nocover b { font-size: 15px; align-self: center; margin: auto; }', + '#book-list .lib-thumb-l { width: 52px; height: 78px; }', + '#book-list .lib-badge { position: absolute; left: 6px; bottom: 6px; padding: 1px 6px; border-radius: 9px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 11px; }', + '#book-list .lib-card .lib-more { position: absolute; right: 2px; top: 2px; width: 30px; height: 30px; border-radius: 15px; border: none; background: rgba(0,0,0,.45); color: #fff; font-size: 16px; cursor: pointer; opacity: 0; }', + '#book-list .lib-card:hover .lib-more, #book-list .lib-card:focus-within .lib-more { opacity: 1; }', + '@media (hover: none) { #book-list .lib-card .lib-more { opacity: .85; } }', + '#book-list .lib-bar { display: block; height: 3px; border-radius: 2px; background: var(--border, #2a2a2a); overflow: hidden; margin-top: 4px; }', + '#book-list .lib-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }', + '#book-list .lib-title { font-size: 13px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }', + '#book-list .lib-meta { font-size: 12px; color: var(--muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', + '#book-list .lib-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }', + '#book-list .lib-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border, #2a2a2a); cursor: pointer; }', + '#book-list .lib-row .lib-more { background: none; border: none; color: inherit; font-size: 18px; min-width: 36px; min-height: 36px; cursor: pointer; }', + '#book-list .lib-state { font-size: 12px; color: var(--muted, #9a9a9a); white-space: nowrap; }', + '#book-list .lib-done { color: var(--accent, #e63946); }', + '#book-list .lib-sel .lib-cover, #book-list .lib-sel .lib-thumb { outline: 3px solid var(--accent, #e63946); outline-offset: 2px; }', + '#book-list .lib-row.lib-sel { background: var(--hover, rgba(255,255,255,.05)); }', + '#book-list .lib-group { margin: 18px 0 8px; font-size: 13px; font-weight: 400; color: var(--muted, #9a9a9a); }', + '#book-list .lib-count { opacity: .7; }', + '#book-list .lib-empty { color: var(--muted, #9a9a9a); font-size: 14px; }', + 'body.eink #book-list .lib-add, body.eink #book-list .lib-play { background: #000; color: #fff; }', + 'body.eink #book-list .lib-cover { box-shadow: none; border: 1px solid #000; }', + ].join('\n'); + document.head.appendChild(style); +})(); diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js index a923d2b..18f82e3 100644 --- a/static/js/audiobooks.js +++ b/static/js/audiobooks.js @@ -28,7 +28,8 @@ picking: false, // choosing a podcast to make an audiobook pickQuery: '', now: null, // playerState() of the last poll - folder: null, // the folder shown (null: all) + folder: null, // the folder chip chosen (null: all, '': without a folder) + folderOrder: [], // the podcasts' folder order (/podcasts/folders/) series: null, // the series shown (its key), inside the folder }; @@ -71,8 +72,11 @@ return b.chapters ? Math.round(100 * b.done / b.chapters) : 0; } - function cover(url, title, cls) { + function cover(url, title, cls, author) { const art = String(url || '').replace(/^http:/, 'https:'); + if (!art && window.DioraGenCover) { // none: one drawn from the title (gencover.js) + return ''; + } return art ? '' : '' + esc((title || '?').trim().charAt(0).toUpperCase()) + ''; @@ -141,7 +145,8 @@ if (!host()) { shelf.innerHTML = ''; return; } if (!st.books) { st.books = cached('books'); renderShelf(); } try { - st.books = (await api('/podcasts/audiobooks/')).books || []; + const got = await Promise.all([api('/podcasts/audiobooks/'), loadFolderOrder()]); + st.books = got[0].books || []; cached('books', st.books); } catch (e) { if (!st.books) st.books = []; @@ -198,6 +203,7 @@ } if (st.picking) html += pickHtml(pods); shelf.innerHTML = html; + drawChips(books); const input = shelf.querySelector('.ab-pick-q'); if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; } } @@ -282,49 +288,98 @@ ''; } + const foldersOf = b => (b.folders && b.folders.length ? b.folders : (b.folder ? [b.folder] : [])); + + // The audiobooks' folders as chips (docs/library.md), in the podcasts' folder order. + function folderChips(books) { + const counts = new Map(); + let none = 0; + books.forEach(b => { + const fs = foldersOf(b); + if (!fs.length) none++; + fs.forEach(f => counts.set(f, (counts.get(f) || 0) + 1)); + }); + const rank = n => { const i = st.folderOrder.indexOf(n); return i < 0 ? 1e6 : i; }; + const names = Array.from(counts.keys()).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b, 'de')); + if (st.folder && !counts.has(st.folder)) st.folder = null; + return {folders: names.map(n => ({name: n, count: counts.get(n)})), none: names.length ? none : 0}; + } + 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 here = books.filter(b => st.folder == null ? true + : st.folder === '' ? !foldersOf(b).length : foldersOf(b).indexOf(st.folder) >= 0); 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 '
' + esc(g.name) + '
' + + return '
' + + '' + esc(g.name) + '
' + '
' + g.books.map(b => bookCard(b, b._volume)).join('') + '
'; } st.series = null; } - if (st.folder != null) { - html += '
' + - '' + esc(st.folder) + '
'; - } else if (folders.size) { - html += '
' + Array.from(folders.keys()).sort((a, b) => a.localeCompare(b, 'de')).map(f => - '').join('') + '
'; - } - return html + '
' + list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '
'; + return '
' + + list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '
'; } - // Into a folder ('' = none): the server keeps it with the subscription. - async function assignFolder(feedId) { - const b = (st.books || []).filter(x => x.feed_id === feedId)[0]; - const names = Array.from(new Set((st.books || []).map(x => x.folder).filter(Boolean))).sort(); - const ask = 'Ordner für „' + (b ? b.title : 'Hörbuch') + '“' + (names.length ? ' (vorhanden: ' + names.join(', ') + ')' : '') + - ' – leer lassen für keinen:'; - const name = typeof customPrompt === 'function' ? await customPrompt(ask, b ? b.folder || '' : '') : window.prompt(ask, b ? b.folder || '' : ''); - if (name === null || name === undefined) return; + function drawChips(books) { + const el = shelf.querySelector('.ab-chips'); + if (!el || !window.DioraFolders) return; + const c = folderChips(books); + if (!c.folders.length) { el.innerHTML = ''; return; } + window.DioraFolders.chips(el, { + folders: c.folders, active: st.folder, none: c.none, + onPick(name) { st.folder = name; renderShelf(); }, + onManage: manageFolders, + }); + } + + async function loadFolderOrder() { + try { st.folderOrder = (await api('/podcasts/folders/')).order || []; } catch (e) {} + } + + // All folder names of the podcasts and audiobooks, in their order. + async function allFolderNames() { try { - await api('/podcasts/feeds/' + feedId + '/settings/', {folder: String(name)}); - toast(String(name).trim() ? 'In „' + String(name).trim() + '“' : 'Ohne Ordner'); + const d = await api('/podcasts/folders/'); + st.folderOrder = d.order || []; + return st.folderOrder; } catch (e) { - toast('Das braucht eine Verbindung.'); + return Array.from(new Set([].concat.apply([], (st.books || []).map(foldersOf)))); } - loadList(); - if (st.open === feedId) loadDetail(feedId); + } + + function manageFolders() { + if (!window.DioraFolders) return; + allFolderNames().then(names => window.DioraFolders.manage({ + title: 'Ordner der Podcasts und Hörbücher', + folders: names, + onRename: (old, n) => api('/podcasts/folders/rename/', {old, new: n}).then(loadList).catch(() => toast('Das braucht eine Verbindung.')), + onDelete: name => api('/podcasts/folders/delete/', {name}).then(loadList).catch(() => toast('Das braucht eine Verbindung.')), + onOrder: order => api('/podcasts/folders/', {order}).then(d => { st.folderOrder = d.order || order; renderShelf(); }).catch(() => {}), + })); + } + + // Into folders (any number): the server keeps them with the subscription. + async function assignFolder(feedId) { + if (!window.DioraFolders) return; + const b = (st.books || []).filter(x => x.feed_id === feedId)[0]; + const names = await allFolderNames(); + window.DioraFolders.assign({ + title: 'Ordner für „' + (b ? b.title : 'Hörbuch') + '“', + folders: names, + selected: b ? foldersOf(b) : [], + async onDone(picked) { + try { + await api('/podcasts/feeds/' + feedId + '/settings/', {folders: picked}); + toast(picked.length ? 'In ' + picked.map(n => '„' + n + '“').join(', ') : 'Ohne Ordner'); + } catch (e) { + toast('Das braucht eine Verbindung.'); + } + loadList(); + if (st.open === feedId) loadDetail(feedId); + }, + }); } function pickHtml(pods) { @@ -394,7 +449,7 @@ '' + '
' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') + '

' + esc(b.title) + '

' + @@ -596,10 +651,9 @@ if (!t) return; if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); return; } if (t.dataset.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; } - if (t.dataset.abFolder != null) { st.folder = t.dataset.abFolder; st.series = null; renderShelf(); return; } if (t.dataset.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; } if (t.dataset.ab === 'up') { - if (st.series != null) st.series = null; else st.folder = null; + st.series = null; renderShelf(); return; } @@ -679,8 +733,8 @@ return true; } if (st.picking && tab.style.display !== 'none') { st.picking = false; renderShelf(); return true; } - if ((st.series != null || st.folder != null) && tab.style.display !== 'none') { - if (st.series != null) st.series = null; else st.folder = null; + if (st.series != null && tab.style.display !== 'none') { + st.series = null; renderShelf(); return true; } @@ -753,10 +807,7 @@ '.ab-mark, .ab-chrow { display: flex; align-items: center; }', '.ab-place { display: flex; align-items: center; gap: 12px; margin: 0 0 10px; }', '.ab-place .ab-back { padding: 6px 0; }', - '.ab-folders { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-bottom: 14px; }', - '.ab-folder { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); color: inherit; font: inherit; cursor: pointer; text-align: left; }', - '.ab-folder .ab-title { flex: 1; min-width: 0; }', - '.ab-folder-icon { color: var(--muted, #9a9a9a); }', + '.ab-chips { margin: 0 0 10px; }', '.ab-stack-covers { position: relative; display: block; }', '.ab-stack-covers::before, .ab-stack-covers::after { content: ""; position: absolute; left: 6%; right: 6%; height: 100%; border-radius: 6px; border: 1px solid var(--border-strong, #444); background: var(--panel, #161616); }', '.ab-stack-covers::before { top: -8px; left: 12%; right: 12%; }', diff --git a/static/js/covers.js b/static/js/covers.js new file mode 100644 index 0000000..d1e02c8 --- /dev/null +++ b/static/js/covers.js @@ -0,0 +1,223 @@ +/* + * Book covers, end-to-end encrypted (docs/library.md). A cover the server has + * (has_cover) is fetched and decrypted here; a book without one gets it from its own + * file — on a device that keeps the books (the desktop and Android apps, or the + * browser's cache): the EPUB's cover image or a PDF's first page, scaled to 300 px, + * encrypted with the user's key and sent once. The server never sees a cover. + * + * Gently: one book at a time, when the page is idle, never while a book is open, and + * not at all on e-ink or in diora Reader (the list there has no covers). Decrypted + * covers are kept on this device (IndexedDB) and handed out as object URLs. + * + * DioraCovers.url(book) an object URL, or null (and it is looked for, once) + * DioraCovers.onReady(fn) fn(bookId) when one becomes available + * + * Chrome-61-safe: no ?. / ??. + */ +(function () { + 'use strict'; + const MAX_W = 300, MAX_H = 450, QUALITY = 0.72; + const html = document.documentElement; + const off = () => html.classList.contains('nui-reader-app') || (document.body && document.body.classList.contains('eink')); + const readerOpen = () => { + const o = document.getElementById('reader-overlay'); + return !!o && o.style.display !== 'none'; // open: '' (the markup starts with none) + }; + const localBooks = () => !!(window.dioraDesktop || window.DioraNative); // the app keeps every file + + const urls = new Map(); // id -> object URL + const tried = new Set(); // looked for this session + const queue = []; + const listeners = []; + let busy = false; + + // ---- this device's store --------------------------------------------------------------- + + let dbp = null; + function db() { + if (!dbp) { + dbp = new Promise(resolve => { + try { + const req = indexedDB.open('diora_covers', 1); + req.onupgradeneeded = () => req.result.createObjectStore('covers'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + } catch (e) { resolve(null); } + }); + } + return dbp; + } + + async function readLocal(id) { + const d = await db(); + if (!d) return null; + return new Promise(resolve => { + try { + const req = d.transaction('covers').objectStore('covers').get(String(id)); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => resolve(null); + } catch (e) { resolve(null); } + }); + } + + async function writeLocal(id, blob) { + const d = await db(); + if (!d) return; + try { d.transaction('covers', 'readwrite').objectStore('covers').put(blob, String(id)); } catch (e) {} + } + + function ready(id, blob) { + if (urls.has(id)) URL.revokeObjectURL(urls.get(id)); + urls.set(id, URL.createObjectURL(blob)); + listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} }); + } + + // ---- finding one ------------------------------------------------------------------------------ + + async function key() { + return typeof getOrCreateEncKey === 'function' ? getOrCreateEncKey() : null; + } + + async function fromServer(id) { + const r = await fetch('/books/' + id + '/cover/', {credentials: 'same-origin'}); + if (!r.ok) return null; + const d = await r.json(); + const plain = await decryptBytes(await key(), d.cover_iv, d.cover_ct); + return new Blob([plain], {type: 'image/jpeg'}); + } + + // The book's own file, decrypted: the browser's cache, or the app's store. + async function bookBytes(id) { + let rec = typeof _getCachedBook === 'function' ? await _getCachedBook(id) : null; + if (!rec && localBooks()) { + const r = await fetch('/books/' + id + '/data/', {credentials: 'same-origin'}); + if (r.ok) rec = await r.json(); + } + if (!rec || !rec.data_ct) return null; + return decryptBytes(await key(), rec.data_iv, rec.data_ct); + } + + async function epubCover(bytes) { + const zip = await JSZip.loadAsync(bytes); + const container = zip.file('META-INF/container.xml'); + if (!container) return null; + const cdoc = new DOMParser().parseFromString(await container.async('text'), 'application/xml'); + const root = cdoc.querySelector('rootfile'); + if (!root) return null; + const opfPath = root.getAttribute('full-path'); + const dir = opfPath.indexOf('/') >= 0 ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : ''; + const opf = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml'); + const items = Array.prototype.slice.call(opf.getElementsByTagName('item')); + const meta = Array.prototype.slice.call(opf.getElementsByTagName('meta')).filter(m => m.getAttribute('name') === 'cover')[0]; + const isImage = i => /^image\//.test(i.getAttribute('media-type') || ''); + let item = items.filter(i => /\bcover-image\b/.test(i.getAttribute('properties') || ''))[0] || + (meta ? items.filter(i => i.getAttribute('id') === meta.getAttribute('content') && isImage(i))[0] : null) || + items.filter(i => isImage(i) && /cover/i.test((i.getAttribute('id') || '') + (i.getAttribute('href') || '')))[0]; + if (!item) return null; + const path = decodeURIComponent(resolveEpubPath(dir, item.getAttribute('href'))); + const file = zip.file(path) || zip.file(path.replace(/^\//, '')); + if (!file) return null; + return new Blob([await file.async('arraybuffer')], {type: item.getAttribute('media-type')}); + } + + async function pdfCover(bytes) { + if (typeof pdfjsLib === 'undefined') return null; + const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise; + const page = await pdf.getPage(1); + const v = page.getViewport({scale: 1}); + const scale = Math.min(MAX_W / v.width, MAX_H / v.height); + const viewport = page.getViewport({scale}); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(viewport.width); + canvas.height = Math.round(viewport.height); + await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise; + pdf.destroy(); + return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY)); + } + + // Any image to a small JPEG. + async function shrink(blob) { + const url = URL.createObjectURL(blob); + try { + const img = await new Promise((resolve, reject) => { + const i = new Image(); + i.onload = () => resolve(i); + i.onerror = reject; + i.src = url; + }); + const scale = Math.min(1, MAX_W / img.naturalWidth, MAX_H / img.naturalHeight); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(img.naturalWidth * scale)); + canvas.height = Math.max(1, Math.round(img.naturalHeight * scale)); + canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); + return await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY)); + } finally { + URL.revokeObjectURL(url); + } + } + + async function extract(book) { + const bytes = await bookBytes(book.id); + if (!bytes) return null; + let blob = book.type === 'pdf' ? await pdfCover(bytes) : await epubCover(bytes); + if (!blob) return null; + if (book.type !== 'pdf') blob = await shrink(blob); + if (!blob) return null; + const enc = await encryptBytes(await key(), new Uint8Array(await blob.arrayBuffer())); + fetch('/books/' + book.id + '/cover/', { + method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({cover_ct: enc.ciphertext, cover_iv: enc.iv}), + }).then(() => { book.has_cover = true; }).catch(() => {}); + return blob; + } + + async function find(book) { + const local = await readLocal(book.id); + if (local) return local; + let blob = null; + if (book.has_cover) blob = await fromServer(book.id).catch(() => null); + if (!blob && !book.has_cover && book.keyOk !== false) blob = await extract(book).catch(() => null); + if (blob) writeLocal(book.id, blob); + return blob; + } + + // ---- the queue: one at a time, when idle ------------------------------------------------------- + + const idle = fn => (window.requestIdleCallback ? requestIdleCallback(fn, {timeout: 3000}) : setTimeout(fn, 400)); + + function pump() { + if (busy || !queue.length) return; + if (readerOpen()) { setTimeout(pump, 5000); return; } + busy = true; + idle(async () => { + const book = queue.shift(); + try { + const blob = await find(book); + if (blob) ready(book.id, blob); + } catch (e) {} + busy = false; + setTimeout(pump, 150); + }); + } + + function url(book) { + if (!book || off()) return null; + if (urls.has(book.id)) return urls.get(book.id); + if (!tried.has(book.id)) { + tried.add(book.id); + // Stored here already: at once, without waiting in the queue. + readLocal(book.id).then(blob => { + if (blob) ready(book.id, blob); + else { queue.push(book); pump(); } + }); + } + return null; + } + + // A book read just now: its file is open anyway (the reader calls this). + function forget(id) { + tried.delete(id); + } + + window.DioraCovers = {url, onReady(fn) { listeners.push(fn); }, forget}; +})(); diff --git a/static/js/folders.js b/static/js/folders.js new file mode 100644 index 0000000..8edf8b1 --- /dev/null +++ b/static/js/folders.js @@ -0,0 +1,213 @@ +/* + * Folders, the same everywhere (docs/library.md): like tags (a thing may be in several), + * shown as a row of filter chips; assigned with a sheet of checkboxes; managed in another + * (rename = merge onto an existing name, delete = the things stay, order by ↑ ↓). Each area + * keeps its folders where it keeps them and calls these for the look and the dialogs: + * + * DioraFolders.chips(el, {folders: [{name, count}], active, none, lead, onPick, onManage}) + * DioraFolders.assign({title, folders: [names], selected: [names], onDone(names)}) + * DioraFolders.manage({title, folders: [names], onRename(old, new), onDelete(name), onOrder(names)}) + * + * Runs everywhere (web, desktop, both Android apps). Chrome-61-safe: no ?. / ??. + */ +(function () { + 'use strict'; + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => + ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); + } + + // ---- the chip row ---------------------------------------------------------------------- + // lead: chips before the folders (the library's Angefangen/Ungelesen …): [{id, label, count, on}], + // picked with onPick({lead: id}). active: null = all, '' = without a folder, else a name. + + function chips(el, o) { + const lead = o.lead || []; + const leadOn = lead.some(c => c.on); + let h = ''; + el.onclick = e => { + const b = e.target.closest('button'); + if (!b) return; + if (b.dataset.dfoManage) { o.onManage(); return; } + if (b.dataset.dfoAll) { o.onPick(null); return; } + if (b.dataset.dfoLead != null) { o.onPick({lead: b.dataset.dfoLead}); return; } + if (b.dataset.dfoFolder != null) { + const name = b.dataset.dfoFolder; + o.onPick(o.active === name ? null : name); // a second tap shows everything again + } + }; + } + + function chip(cls, label, count, on, attrs) { + return ''; + } + + // ---- a sheet (above the page and the book views, below the page's dialogs) ------------------- + + let open = null; + function sheet(title, body, onClose) { + close(); + const wrap = document.createElement('div'); + wrap.className = 'dfo-scrim'; + wrap.innerHTML = ''; + wrap.querySelector('.dfo-body').appendChild(body); + wrap.addEventListener('click', e => { + if (e.target === wrap || e.target.closest('[data-dfo-close]')) close(); + }); + document.body.appendChild(wrap); + open = {wrap, onClose}; + return wrap; + } + + function close() { + if (!open) return; + const o = open; + open = null; + o.wrap.remove(); + if (o.onClose) o.onClose(); + } + + const back = window.__dioraBack; + window.__dioraBack = function () { + if (open) { close(); return true; } + return back ? back() : false; + }; + document.addEventListener('keydown', e => { if (e.key === 'Escape' && open) close(); }); + + // ---- "In Ordner …" --------------------------------------------------------------------------- + + function assign(o) { + const chosen = new Set(o.selected || []); + const names = (o.folders || []).slice(); + const body = document.createElement('div'); + const draw = () => { + body.innerHTML = '
' + (names.length ? names.map(n => + '').join('') : '

Noch keine Ordner.

') + '
' + + '
' + + '
' + + '
' + + '
'; + }; + draw(); + body.addEventListener('change', e => { + if (e.target.type !== 'checkbox') return; + if (e.target.checked) chosen.add(e.target.value); else chosen.delete(e.target.value); + }); + body.addEventListener('submit', e => { + e.preventDefault(); + const n = ' '.concat(e.target.n.value).split(/\s+/).filter(Boolean).join(' ').slice(0, 100); + if (!n) return; + const same = names.filter(x => x.toLowerCase() === n.toLowerCase())[0]; + if (!same) names.push(n); + chosen.add(same || n); + draw(); + }); + body.addEventListener('click', e => { + if (!e.target.closest('[data-dfo-ok]')) return; + const picked = names.filter(n => chosen.has(n)); + close(); + o.onDone(picked); + }); + sheet(o.title || 'In Ordner', body); + } + + // ---- "Ordner verwalten" --------------------------------------------------------------------------- + + function manage(o) { + let names = (o.folders || []).slice(); + const body = document.createElement('div'); + const draw = () => { + body.innerHTML = names.length ? '
' + names.map((n, i) => + '
' + esc(n) + '' + + '' + + '' + + '' + + '
').join('') + '
' + + '

Umbenennen auf einen vorhandenen Namen legt beide zusammen. Löschen entfernt nur den Ordner, nicht seinen Inhalt.

' + : '

Noch keine Ordner. Sie entstehen beim Einordnen („In Ordner …“).

'; + }; + draw(); + body.addEventListener('click', async e => { + const b = e.target.closest('button'); + if (!b) return; + const ask = (msg, value) => typeof customPrompt === 'function' ? customPrompt(msg, value) : Promise.resolve(window.prompt(msg, value)); + const sure = msg => typeof customConfirm === 'function' ? customConfirm(msg, {danger: true}) : Promise.resolve(window.confirm(msg)); + if (b.dataset.up != null || b.dataset.down != null) { + const i = Number(b.dataset.up != null ? b.dataset.up : b.dataset.down); + const j = b.dataset.up != null ? i - 1 : i + 1; + if (j < 0 || j >= names.length) return; + const t = names[i]; names[i] = names[j]; names[j] = t; + draw(); + o.onOrder(names.slice()); + } else if (b.dataset.rename != null) { + const old = names[Number(b.dataset.rename)]; + const value = await ask('Neuer Name für „' + old + '“:', old); + const n = value == null ? '' : String(value).split(/\s+/).filter(Boolean).join(' ').slice(0, 100); + if (!n || n === old) return; + await o.onRename(old, n); + const merged = names.filter(x => x.toLowerCase() === n.toLowerCase())[0]; + names = merged ? names.filter(x => x !== old) : names.map(x => (x === old ? n : x)); + draw(); + } else if (b.dataset.delete != null) { + const n = names[Number(b.dataset.delete)]; + if (!(await sure('Ordner „' + n + '“ löschen? Was darin liegt, bleibt.'))) return; + await o.onDelete(n); + names = names.filter(x => x !== n); + draw(); + } + }); + sheet(o.title || 'Ordner verwalten', body); + } + + window.DioraFolders = {chips, assign, manage, close}; + + // ---- styles ---------------------------------------------------------------------------------------- + + const style = document.createElement('style'); + style.textContent = [ + '.dfo-row { display: flex; gap: 6px; align-items: center; overflow-x: auto; padding: 2px 0 6px; scrollbar-width: none; }', + '.dfo-row::-webkit-scrollbar { display: none; }', + '@media (min-width: 721px) { .dfo-row { flex-wrap: wrap; overflow: visible; } }', + '.dfo-chip { flex: none; min-height: 34px; padding: 0 12px; border-radius: 17px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; white-space: nowrap; }', + '.dfo-chip.on { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }', + '.dfo-count { opacity: .7; font-size: 12px; }', + '.dfo-none { font-style: italic; }', + '.dfo-manage { min-width: 34px; padding: 0 8px; }', + '.dfo-sep { flex: none; width: 1px; height: 20px; background: var(--border-strong, #444); margin: 0 2px; }', + // Above the book view (900), below the page's dialogs (990): a name is asked for on top. + '.dfo-scrim { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 950; background: var(--scrim, rgba(0,0,0,.5)); display: flex; align-items: flex-end; justify-content: center; }', + '@media (min-width: 721px) { .dfo-scrim { align-items: center; } }', + '.dfo-sheet { width: 100%; max-width: 480px; max-height: 80vh; overflow-y: auto; background: var(--panel, #161616); color: var(--fg, #eee); border-radius: 14px 14px 0 0; padding: 12px 16px 18px; box-sizing: border-box; }', + '@media (min-width: 721px) { .dfo-sheet { border-radius: 14px; } }', + '.dfo-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }', + '.dfo-x, .dfo-icon { background: none; border: none; color: inherit; font: inherit; font-size: 17px; min-width: 38px; min-height: 38px; cursor: pointer; }', + '.dfo-icon[disabled] { opacity: .3; cursor: default; }', + '.dfo-check { display: flex; align-items: center; gap: 10px; padding: 9px 2px; border-bottom: 1px solid var(--border, #2a2a2a); cursor: pointer; }', + '.dfo-check input { width: 18px; height: 18px; }', + '.dfo-mrow { display: flex; align-items: center; border-bottom: 1px solid var(--border, #2a2a2a); }', + '.dfo-mname { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', + '.dfo-new { display: flex; gap: 8px; margin: 12px 0 4px; }', + '.dfo-input { flex: 1; min-width: 0; padding: 9px 10px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }', + '.dfo-btn { min-height: 38px; padding: 0 14px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }', + '.dfo-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }', + '.dfo-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }', + '.dfo-note { color: var(--muted, #9a9a9a); font-size: 13px; line-height: 1.45; }', + 'body.eink .dfo-chip.on, body.eink .dfo-primary { background: #000; color: #fff; }', + ].join('\n'); + document.head.appendChild(style); +})(); diff --git a/static/js/gencover.js b/static/js/gencover.js new file mode 100644 index 0000000..9f023aa --- /dev/null +++ b/static/js/gencover.js @@ -0,0 +1,396 @@ +/* + * Covers for books that have none (docs/library.md): drawn here, the same on every + * device, from what the book is called and where it stands — its title, author, shelf + * category and folders. Words in them point to a design concept (a motif, curated + * palettes, a typeface: Marx and Kapital to constructivism, Hegel and Dialektik to quiet + * geometry, Meer to waves, Krieg to map contours …); a hash of title and author picks + * the palette, the layout and every detail of the motif, so no two books look alike and + * each always looks the same. Nothing is stored or sent: it's an SVG, made when needed. + * + * DioraGenCover.url(book, {gray}) a data: URL (book: {title, author, shelfTag, folders}) + * DioraGenCover.theme(book) the concept chosen (for tests) + * + * Chrome-61-safe: no ?. / ??. + */ +(function () { + 'use strict'; + const W = 300, H = 450; + + // ---- chance, the same for the same book ------------------------------------------------ + + function hash(s) { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } + return h >>> 0; + } + function rng(seed) { + let a = seed >>> 0; + return function () { + a = (a + 0x6D2B79F5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + const pick = (r, list) => list[Math.floor(r() * list.length)]; + const between = (r, a, b) => a + r() * (b - a); + const f1 = n => Math.round(n * 10) / 10; + + // ---- the concepts --------------------------------------------------------------------------- + // words: stems found anywhere in the text (lower case, umlauts as they are); + // palettes: [background, ink, accent, second accent], curated to sit well together. + + const SERIF = "Georgia, 'Times New Roman', serif"; + const SANS = "'Helvetica Neue', Helvetica, Arial, sans-serif"; + const NARROW = "'Arial Narrow', 'Roboto Condensed', 'Helvetica Neue', Arial, sans-serif"; + const MONO = "'Courier New', Courier, monospace"; + + const THEMES = [ + {id: 'konstruktiv', font: NARROW, upper: true, + words: ['marx', 'kapital', 'capital', 'arbeit', 'labor', 'labour', 'revolution', 'sozialis', 'socialis', 'kommun', 'communis', + 'ökonom', 'econom', 'klasse', 'class', 'gewerkschaft', 'streik', 'strike', 'partei', 'lenin', 'luxemburg', 'engels', + 'proletar', 'faschis', 'fascis', 'jacobin', 'gegenstandpunkt', 'demokratie', 'herrschaft', 'massen'], + palettes: [['#efe6d2', '#1b1b1b', '#c8322b', '#1b1b1b'], ['#1d1d1d', '#f2ead7', '#d23a2c', '#e8b23a'], ['#e9dfc8', '#22252a', '#b5302a', '#7a8a8f']]}, + {id: 'geometrie', font: SERIF, + words: ['philosoph', 'hegel', 'kant', 'dialekt', 'dialect', 'vernunft', 'reason', 'geist', 'spirit', 'ethik', 'ethic', + 'logik', 'logic', 'metaphys', 'phänomen', 'phenomen', 'denken', 'wahrheit', 'truth', 'sein', 'nichts', 'nothing', + 'kritik', 'critique', 'theorie', 'theory', 'begriff', 'wissen'], + palettes: [['#f1ece1', '#2b2a27', '#b08a3e', '#6f7d72'], ['#26292e', '#ece4d4', '#c9a25a', '#8a9aa8'], ['#e7e1d6', '#3a3833', '#8c5a3c', '#5d6b74']]}, + {id: 'psyche', font: SERIF, + words: ['lacan', 'freud', 'psycho', 'sex', 'begehren', 'desire', 'disavowal', 'verleugn', 'unbewusst', 'unconscious', + 'comedy', 'komik', 'komöd', 'parallax', 'real', 'voice', 'stimme', 'žižek', 'zizek', 'zupan', 'dolar', 'traum', 'dream', + 'odd one', 'antigone', 'let them rot', 'klavierspielerin'], + palettes: [['#f3e3d9', '#3d2440', '#c45b6a', '#5c8a8a'], ['#2c2236', '#f2dfd2', '#e07a6f', '#7fb3ae'], ['#e8ddeb', '#2e2a3d', '#8d4f8a', '#d99a6c']]}, + {id: 'wellen', font: SERIF, + words: ['meer', 'sea', 'ozean', 'ocean', 'wasser', 'water', 'insel', 'island', 'fluss', 'river', 'flut', 'flood', 'deluge', + 'sintflut', 'schiff', 'ship', 'welle', 'wave', 'strand', 'küste', 'coast', 'regen', 'rain'], + palettes: [['#e5eef0', '#13324a', '#2f6f8f', '#8fb8c6'], ['#0f2a3d', '#e3edf0', '#4c95b0', '#a9d3dc'], ['#efe9dc', '#1f3b4d', '#3d7a8c', '#c98a5a']]}, + {id: 'karte', font: SERIF, upper: true, + words: ['krieg', 'war', 'geschichte', 'history', 'empire', 'imperium', 'reich', 'zerstörung', 'destruction', 'wages', + 'nazi', 'staat', 'state', 'nation', 'kolonial', 'colonial', 'weltkrieg', 'schlacht', 'battle', 'grenze', 'border', + 'seeing like', 'world-system', 'weltsystem'], + palettes: [['#e9e1cc', '#3b3a2c', '#8a4b2a', '#6d7a4f'], ['#2f3127', '#e6dcc4', '#b5763c', '#8e9a6b'], ['#ddd5bd', '#2c2a22', '#7a3b2e', '#5f6f63']]}, + {id: 'kosmos', font: SANS, upper: true, + words: ['stern', 'star', 'kosmos', 'cosmos', 'space', 'weltraum', 'planet', 'mond', 'moon', 'dune', 'nacht', 'night', + 'universum', 'universe', 'zeit', 'time', 'licht', 'light', 'galax', 'messiah', 'science fiction', 'sci-fi', 'zukunft', 'future'], + palettes: [['#141833', '#ece6d3', '#e3b65b', '#7d8bd4'], ['#0e1424', '#dfe6f2', '#d9824a', '#6fa3c4'], ['#1f1a33', '#efe7da', '#c9a8e0', '#e3c46b']]}, + {id: 'ornament', font: SERIF, + words: ['fantasy', 'drache', 'dragon', 'magie', 'magic', 'prophezei', 'prophecy', 'throne', 'thron', 'könig', 'king', + 'krone', 'crown', 'schwert', 'sword', 'warrior', 'krieger', 'cats', 'katze', 'legende', 'legend', 'märchen', 'fairy', + 'zauber', 'spell', 'ice and fire', 'bundle'], + palettes: [['#1e2a2a', '#efe3c4', '#c69a3c', '#8f3b46'], ['#2b1d2e', '#f0e2c8', '#d3a13f', '#3f7a6d'], ['#f0e6d0', '#2d2433', '#8c3a4a', '#2f6b65']]}, + {id: 'botanik', font: SERIF, + words: ['natur', 'nature', 'wald', 'forest', 'baum', 'tree', 'garten', 'garden', 'blume', 'flower', 'blatt', 'leaf', + 'frühling', 'spring', 'sommer', 'summer', 'pflanze', 'plant', 'wurzel', 'root', 'gras', 'grass', 'pilz', 'mushroom'], + palettes: [['#eef0e4', '#23301f', '#4f7a43', '#b7a35a'], ['#203024', '#e8ecdc', '#86a86b', '#d6b56a'], ['#f2ece0', '#2b3326', '#6f8f5a', '#c27c5a']]}, + {id: 'berge', font: SANS, upper: true, + words: ['berg', 'mountain', 'alpen', 'alps', 'gipfel', 'peak', 'zauberberg', 'hügel', 'hill', 'landschaft', 'landscape', + 'schnee', 'snow', 'winter', 'eis', 'ice', 'tal', 'valley', 'wandern'], + palettes: [['#e8ecef', '#22303b', '#5b7a8f', '#a9bccb'], ['#24323d', '#eef1f2', '#7f9fb3', '#c8d6de'], ['#efe9e0', '#2e3438', '#6f8590', '#c7a27a']]}, + {id: 'zellen', font: SANS, + words: ['biolog', 'gehirn', 'brain', 'mensch', 'human', 'behave', 'verhalten', 'behavio', 'sucht', 'addict', 'enough', + 'körper', 'body', 'medizin', 'medic', 'gen', 'evolution', 'neuro', 'zelle', 'cell', 'leben', 'life', 'tier', 'animal'], + palettes: [['#f1ebe4', '#26323a', '#e0765c', '#4f9a94'], ['#1f2b31', '#eef0ea', '#f08a6c', '#69b3aa'], ['#eaf0ee', '#22303a', '#d4634e', '#3f8f8a']]}, + {id: 'raster', font: SANS, + words: ['stadt', 'city', 'urban', 'risiko', 'risk', 'gesellschaft', 'society', 'moderne', 'modern', 'system', 'analys', + 'netz', 'network', 'technik', 'techn', 'smart', 'notes', 'notiz', 'daten', 'data', 'digital', 'maschine', 'machine', + 'architektur', 'architect', 'hyperpolitik', 'politik', 'politic', 'revolution of', 'end of the'], + palettes: [['#ecebe7', '#1f2328', '#d1453b', '#2f5f8f'], ['#22262b', '#ebe8e1', '#e2b33c', '#4f86b8'], ['#f0ede6', '#2a2d31', '#3f6fa0', '#d9a03a']]}, + {id: 'schatten', font: NARROW, upper: true, + words: ['tod', 'death', 'mord', 'murder', 'crime', 'verbrechen', 'schatten', 'shadow', 'dunkel', 'dark', 'angst', 'fear', + 'serotonin', 'blut', 'blood', 'gift', 'poison', 'detektiv', 'detective', 'kommissar', 'thriller'], + palettes: [['#151515', '#ecebe6', '#c2302f', '#5a5a5a'], ['#ecebe6', '#151515', '#a8241f', '#7c7c7c'], ['#1c1f24', '#e4e0d8', '#d0a04a', '#55606b']]}, + {id: 'typo', font: SERIF, + words: ['roman', 'novel', 'erzählung', 'stories', 'geschichten', 'gedicht', 'poem', 'poesie', 'poetry', 'brief', 'letters', + 'tagebuch', 'diary', 'molloy', 'beckett', 'manuskripte', 'literatur', 'literature', 'review', 'zeitschrift'], + palettes: [['#f2ede3', '#211f1c', '#9b3b2f', '#7d7466'], ['#24211e', '#f0e9dc', '#d39b5f', '#8f8578'], ['#e9e4f0', '#26222e', '#5b4a8a', '#a3909a']]}, + ]; + const DEFAULT = {id: 'bauhaus', font: SANS, + palettes: [['#efe9dd', '#24262a', '#d0533f', '#3f7fb0'], ['#26282d', '#efe8da', '#e3b04b', '#5b9aa8'], ['#e8ecef', '#22272c', '#c8553d', '#6b8f71'], + ['#f3e9e2', '#2a2523', '#b84a62', '#4c7a8a']]}; + + // ---- which concept: the words, weighted by where they stand ------------------------------- + + function norm(s) { return String(s || '').toLowerCase(); } + + function scored(book) { + // The title says most about a book; author and shelf category help, a folder only tips the scales. + const fields = [[norm(book.title), 3], [norm(book.shelfTag), 2], + [norm((book.folders || (book.folder ? [book.folder] : [])).join(' ')), 1], [norm(book.author), 2]]; + return THEMES.map(t => { + let score = 0; + t.words.forEach(w => fields.forEach(f => { if (f[0].indexOf(w) >= 0) score += f[1]; })); + return {t, score}; + }).filter(x => x.score > 0).sort((a, b) => b.score - a.score); + } + + function theme(book) { + const s = scored(book); + return s.length ? s[0].t : DEFAULT; + } + + // ---- motifs: each draws into the whole cover, the text gets its own space later ----------- + + const M = { + konstruktiv(r, p) { + let s = ''; + const a = pick(r, [-34, -28, 28, 34, -45]); + s += ``; + for (let i = 0; i < 3; i++) { + const y = between(r, 60, 380), h = between(r, 14, 46); + s += ``; + } + s += ''; + s += ``; + s += ``; + return s; + }, + geometrie(r, p) { + const cx = between(r, 100, 200), cy = between(r, 170, 300); + let s = ''; + const n = 4 + Math.floor(r() * 5); + for (let i = 1; i <= n; i++) s += ``; + const phi = 1.618; + s += ``; + s += ``; + s += ``; + return s; + }, + psyche(r, p) { + let s = ``; + const cy = between(r, 170, 290); + const rx = between(r, 60, 95), ry = between(r, 80, 130); + s += ``; + s += ``; + s += ``; + return s; + }, + wellen(r, p) { + let s = ''; + const n = 6 + Math.floor(r() * 5); + const amp = between(r, 6, 16), len = between(r, 60, 120); + for (let i = 0; i < n; i++) { + const y0 = 120 + i * (300 / n); + let d = `M0 ${f1(y0)}`; + for (let x = 0; x <= W; x += 10) d += ` L${x} ${f1(y0 + Math.sin((x / len) * Math.PI * 2 + i * 0.7) * amp)}`; + d += ` L${W} ${H} L0 ${H} Z`; + s += ``; + } + return s; + }, + karte(r, p) { + let s = ''; + const cx = between(r, 80, 220), cy = between(r, 160, 320); + for (let i = 1; i <= 9; i++) { + let d = ''; + for (let k = 0; k <= 36; k++) { + const a = (k / 36) * Math.PI * 2; + const rad = i * 15 * (1 + 0.18 * Math.sin(a * 3 + i) + 0.1 * Math.cos(a * 5 + r())); + d += (k ? ' L' : 'M') + f1(cx + Math.cos(a) * rad) + ' ' + f1(cy + Math.sin(a) * rad * 1.15); + } + s += ``; + } + s += ``; + return s; + }, + kosmos(r, p) { + let s = ''; + for (let i = 0; i < 70; i++) s += ``; + const cx = between(r, 90, 210), cy = between(r, 170, 290), pr = between(r, 30, 55); + for (let i = 1; i <= 3; i++) s += ``; + s += ``; + s += ``; + return s; + }, + ornament(r, p) { + const cx = W / 2, cy = between(r, 190, 260), n = pick(r, [8, 10, 12, 16]); + let s = ``; + const len = between(r, 70, 105), wid = between(r, 18, 30); + for (let i = 0; i < n; i++) { + s += ``; + } + s += ``; + s += ``; + return s; + }, + botanik(r, p) { + let s = ''; + const n = 5 + Math.floor(r() * 4); + for (let i = 0; i < n; i++) { + const x = between(r, 40, 260), y = between(r, 150, 420), len = between(r, 60, 130), a = between(r, -50, 50); + s += `` + + `` + + ``; + } + return s; + }, + berge(r, p) { + let s = ``; + for (let i = 0; i < 4; i++) { + const base = 260 + i * 45; + let d = `M0 ${H} L0 ${base}`; + for (let x = 0; x <= W; x += 30) d += ` L${x} ${f1(base - between(r, 10, 90 - i * 15))}`; + d += ` L${W} ${base} L${W} ${H} Z`; + s += ``; + } + return s; + }, + zellen(r, p) { + let s = ''; + for (let i = 0; i < 16; i++) { + const x = between(r, 20, 280), y = between(r, 140, 430), rad = between(r, 12, 42); + s += ``; + if (i % 4 === 0) s += ``; + } + return s; + }, + raster(r, p) { + if (r() < 0.45) { // or a skyline + let s = ''; + let x = 10; + while (x < W - 10) { + const w = between(r, 18, 46), h = between(r, 60, 230), v = r(); + s += ``; + for (let wy = H - 30 - h + 12; wy < H - 32; wy += 14) { + if (r() < 0.4) s += ``; + } + x += w + between(r, 2, 6); + } + return s; + } + let s = ''; + const cols = 4 + Math.floor(r() * 3), rows = 5 + Math.floor(r() * 3), gap = 6; + const cw = (W - 40 - gap * (cols - 1)) / cols, rh = (H - 200 - gap * (rows - 1)) / rows; + for (let i = 0; i < cols; i++) for (let j = 0; j < rows; j++) { + const v = r(); + const fill = v < 0.12 ? p[2] : v < 0.2 ? p[3] : p[1]; + s += ``; + } + return s; + }, + schatten(r, p) { + const x = between(r, 60, 240); + return `` + + ``; + }, + typo(r, p, book) { + const ch = (book.title || '?').replace(/^[^A-Za-zÄÖÜäöüß0-9]+/, '').charAt(0).toUpperCase() || '?'; + return `${esc(ch)}` + + ``; + }, + bauhaus(r, p) { + let s = ''; + for (let i = 0; i < 4; i++) { + const kind = Math.floor(r() * 3), x = between(r, 30, 270), y = between(r, 150, 410), size = between(r, 40, 110); + const fill = pick(r, [p[2], p[3], p[1]]); + if (kind === 0) s += ``; + else if (kind === 1) s += ``; + else s += ``; + } + return s; + }, + }; + + // ---- the text ------------------------------------------------------------------------------------- + + function esc(s) { + return String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); + } + + // The title's lines, for a size that fits; a word too long is broken. + function lines(text, size, max) { + const per = Math.max(4, Math.floor(250 / (size * 0.56))); + const words = String(text).split(/\s+/).filter(Boolean); + const out = []; + let cur = ''; + words.forEach(w => { + while (w.length > per) { if (cur) { out.push(cur); cur = ''; } out.push(w.slice(0, per - 1) + '‑'); w = w.slice(per - 1); } + if (!cur) cur = w; + else if ((cur + ' ' + w).length <= per) cur += ' ' + w; + else { out.push(cur); cur = w; } + }); + if (cur) out.push(cur); + if (out.length > max) { out.length = max; out[max - 1] = out[max - 1].replace(/\s*\S*$/, '') + ' …'; } + return out; + } + + // Only the main title ("Seeing Like a State: How …" → "Seeing Like a State"). + function mainTitle(t) { + let s = String(t || '').split(/\s[-:–—•·\/]\s|:\s/)[0].trim(); + // "Der Zauberberg. Erster Band" → "Der Zauberberg", but not at an initial ("George R. R. Martin"). + const re = /(\S+)\.\s+(?=[A-ZÄÖÜ])/g; + let m; + while ((m = re.exec(s))) { + if (m[1].length >= 3) { s = s.slice(0, m.index + m[1].length); break; } + } + return s.length >= 3 ? s : String(t || '?'); + } + + function textBlock(r, th, p, book) { + const title = mainTitle(book.title); + const upper = th.upper; + const t = upper ? title.toUpperCase() : title; + const len = t.length; + const longest = t.split(/\s+/).reduce((n, w) => Math.max(n, w.length), 0); + // The longest word stays whole: the type gets smaller rather than the word broken. + const size = Math.max(15, Math.min(len <= 10 ? 40 : len <= 18 ? 34 : len <= 30 ? 28 : len <= 48 ? 23 : 19, + Math.floor(250 / (longest * 0.58)))); + const ls = lines(t, size, 5); + const lh = size * 1.12; + const layout = pick(r, ['top', 'top', 'bottom', 'plate']); + const author = String(book.author || '').split(/[;,]/)[0].trim(); + const blockH = ls.length * lh + (author ? 30 : 0) + 40; + let y0, s = ''; + if (layout === 'top') { + y0 = 34; + s += ``; + } else if (layout === 'bottom') { + y0 = H - blockH + 20; + s += ``; + } else { + y0 = 70; + s += ``; + } + const x = 26; + ls.forEach((l, i) => { + s += `${esc(l)}`; + }); + if (author) { + s += `${esc(author.toUpperCase())}`; + } + return s; + } + + // ---- the cover --------------------------------------------------------------------------------------- + + function gray(hex) { + const n = parseInt(hex.slice(1), 16); + const y = Math.round(0.3 * (n >> 16) + 0.59 * ((n >> 8) & 255) + 0.11 * (n & 255)); + const h = (y < 16 ? '0' : '') + y.toString(16); + return '#' + h + h + h; + } + + function svg(book, opts) { + const th = theme(book); + const r = rng(hash(norm(book.title) + '|' + norm(book.author))); + let p = pick(r, th.palettes); + if (opts && opts.gray) p = p.map(gray); + const motif = (M[th.id] || M.bauhaus)(r, p, book); + return `` + + `${motif}${textBlock(r, th, p, book)}`; + } + + const cache = new Map(); + function url(book, opts) { + const key = (book.title || '') + '|' + (book.author || '') + '|' + (book.shelfTag || '') + '|' + + ((book.folders || []).join(',')) + '|' + (opts && opts.gray ? 1 : 0); + if (!cache.has(key)) cache.set(key, 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg(book, opts))); + return cache.get(key); + } + + window.DioraGenCover = {url, svg, theme: book => theme(book).id}; +})(); diff --git a/static/js/sw.js b/static/js/sw.js index d585cf5..33b9c63 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-v118'; +const CACHE = 'diora-v119'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', @@ -23,6 +23,7 @@ const SHELL = [ '/static/js/app/48-book-cache.js', '/static/js/app/50-reader-state.js', '/static/js/app/52-book-list.js', + '/static/js/app/53-library.js', '/static/js/app/56-cloud-import.js', '/static/js/app/58-pdf.js', '/static/js/app/60-tts.js', @@ -42,6 +43,9 @@ const SHELL = [ '/static/js/feeds.js', '/static/js/audiobooks.js', '/static/js/nav.js', + '/static/js/folders.js', + '/static/js/covers.js', + '/static/js/gencover.js', '/static/js/sources.js', '/static/podcasts/native-podcast.js', '/static/podcasts/podcast-ui.css', diff --git a/templates/radio/player.html b/templates/radio/player.html index afcba7e..0c38ddf 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -353,6 +353,9 @@ {# app.js, split by area (docs/refactoring.md): classic scripts sharing one global scope, in this order. #} + + + @@ -371,6 +374,7 @@ +