Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht)

- 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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-16 20:44:23 +02:00
parent d7930fcdb9
commit 8f5be379fe
27 changed files with 1928 additions and 56 deletions

View file

@ -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)),
],
),
]

View file

@ -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."""

View file

@ -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'})

View file

@ -12,6 +12,8 @@ urlpatterns = [
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
path('<int:pk>/read/', views.set_book_read, name='set_book_read'),
path('<int:pk>/meta/', views.update_book_meta, name='update_book_meta'),
path('<int:pk>/cover/', views.book_cover, name='book_cover'),
path('prefs/', views.library_prefs, name='library_prefs'),
path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'),
path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'),
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),

View file

@ -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):

61
docs/library.md Normal file
View file

@ -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 · <folder> 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/<id>/cover/
{cover_ct, cover_iv}`. `GET /books/` lists `has_cover`; `GET /books/<id>/cover/`
returns `{cover_ct, cover_iv}`. Clients cache the decrypted image locally. The
server never sees a cover.

View file

@ -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),
),
]

View file

@ -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)

View file

@ -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'])

View file

@ -22,6 +22,7 @@ urlpatterns = [
path('items/<int:pk>/star/', views.star, name='feeds_star'),
path('items/<int:pk>/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'),

View file

@ -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))}

View file

@ -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,
}

116
podcasts/folders.py Normal file
View file

@ -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))

View file

@ -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),
]

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -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/<int:pk>/move/', views.move_feed, name='podcast_move_feed'),
path('audiobooks/', audiobooks.audiobook_list, name='podcast_audiobooks'),
path('audiobooks/<int:feed_id>/', 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/<int:pk>/', audiobooks.bookmark_update, name='podcast_bookmark_update'),
path('bookmarks/<int:pk>/delete/', audiobooks.bookmark_delete, name='podcast_bookmark_delete'),

View file

@ -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():

View file

@ -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;

502
static/js/app/53-library.js Normal file
View file

@ -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 `<img class="${cls}" src="${url}" alt="" data-cover-id="${b.id}">`;
// None (yet): one drawn from the book's title, author, category and folders (gencover.js).
if (window.DioraGenCover) {
return `<img class="${cls}" src="${DioraGenCover.url(b, {gray: _LIB_EINK()})}" alt="" data-cover-id="${b.id}">`;
}
const initials = (b.title || '?').trim().slice(0, 1).toUpperCase();
return `<span class="${cls} lib-nocover" data-cover-id="${b.id}" style="--h:${_libHue(b.title || '')}"><span>${_libEsc(b.title || '')}</span><b>${_libEsc(initials)}</b></span>`;
}
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 `<div class="lib-row${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
${_libCover(b, 'lib-thumb')}
<span class="lib-text"><span class="lib-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${_libEsc(b.author || '')}</span></span>
<span class="lib-state${b.is_read ? ' lib-done' : ''}">${_libEsc(_libState(b))}</span>
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></div>`;
}
return `<div class="lib-card${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
<span class="lib-coverbox">${_libCover(b, 'lib-cover')}
${b.is_read ? '<span class="lib-badge">✓</span>' : ''}
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></span>
<span class="lib-bar"><span style="width:${pct}%"></span></span>
<span class="lib-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${_libEsc(b.author || '')}</span></div>`;
}
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 `<div class="lib-continue" data-lib-book="${b.id}">${_libCover(b, 'lib-thumb lib-thumb-l')}
<span class="lib-text"><span class="lib-kicker">Weiterlesen</span>
<span class="lib-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${_libEsc(b.author || '')}</span>
<span class="lib-bar"><span style="width:${_libPct(b)}%"></span></span>
<span class="lib-meta">${_libPct(b)} %${pages}</span></span>
<span class="lib-play" aria-hidden="true">▶</span></div>`;
}
// 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 `<h3 class="lib-group">${_libEsc(n)} <span class="lib-count">${issues.length}</span></h3>` +
`<div class="${_libLayout() === 'list' ? 'lib-list' : 'lib-grid'}">${issues.map(_libCard).join('')}</div>`;
}).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 = `<div class="lib-head"><h2 class="lib-h">${magazines ? 'Zeitschriften' : 'Bücher'}</h2>
${!narrow || _lib.searching || _lib.q ? `<input class="lib-q" type="search" placeholder="Titel, Autor …" value="${_libEsc(_lib.q)}">` :
'<button type="button" class="lib-icon" data-lib="search" aria-label="Suchen">⌕</button>'}
<select class="lib-sort" aria-label="Sortieren">${sorts.map(s => `<option value="${s[0]}"${_lib.sort === s[0] ? ' selected' : ''}>${s[1]}</option>`).join('')}</select>
<button type="button" class="lib-icon" data-lib="layout" aria-label="${_libLayout() === 'grid' ? 'Als Liste' : 'Als Raster'}">${_libLayout() === 'grid' ? '☰' : '⊞'}</button>
<button type="button" class="lib-icon lib-add" data-lib="add" aria-label="Hinzufügen">+</button></div>
<div class="lib-chips"></div>`;
if (_lib.selecting) {
h += `<div class="lib-selbar"><span>${_lib.selecting.size} ausgewählt</span>
<button type="button" class="lib-btn" data-lib="sel-folders"${_lib.selecting.size ? '' : ' disabled'}>In Ordner …</button>
<button type="button" class="lib-btn" data-lib="sel-done">Fertig</button></div>`;
}
if (!allBooks.length) {
h += '<p class="lib-empty">Noch keine Bücher. Mit + eine .epub- oder .pdf-Datei hinzufügen.</p>';
} else if (!books.length) {
h += `<p class="lib-empty">${magazines ? 'Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.' : 'Alle Bücher stehen bei den Zeitschriften.'}</p>`;
} else {
const going = _libContinueBook(books);
h += _libContinue(going);
if (going) shown.splice(shown.indexOf(going), 1); // once, on top
if (!shown.length && !going) h += '<p class="lib-empty">Nichts gefunden.</p>';
else if (magazines && _lib.sort === 'recent' && !_lib.q) h += _libMagazines(shown);
else h += `<div class="${_libLayout() === 'list' ? 'lib-list' : 'lib-grid'}">${shown.map(_libCard).join('')}</div>`;
}
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 = `<div class="dfo-sheet" role="dialog"><div class="dfo-head"><strong>${_libEsc(b.title)}</strong>
<button type="button" class="dfo-x" data-x="1" aria-label="Schließen">✕</button></div>
${items.map((it, i) => `<button type="button" class="lib-sheet-item${it[2] ? ' lib-danger' : ''}" data-i="${i}">${it[0]}</button>`).join('')}</div>`;
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 = `<div class="dfo-sheet" role="dialog"><div class="dfo-head"><strong>Hinzufügen</strong>
<button type="button" class="dfo-x" data-x="1" aria-label="Schließen">✕</button></div>
<button type="button" class="lib-sheet-item" data-a="file">E-Book-Datei (.epub, .pdf) …</button>
${cloud ? '<button type="button" class="lib-sheet-item" data-a="cloud">Aus der Cloud (WebDAV, Nextcloud) …</button>' : ''}
<button type="button" class="lib-sheet-item" data-a="select">Mehrere Bücher auswählen</button></div>`;
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);
})();

View file

@ -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 '<img class="' + cls + '" src="' + DioraGenCover.url({title: title || '', author: author || ''}) + '" alt="">';
}
return art
? '<img class="' + cls + '" src="' + esc(art) + '" alt="" loading="lazy">'
: '<span class="' + cls + ' ab-nocover">' + esc((title || '?').trim().charAt(0).toUpperCase()) + '</span>';
@ -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 @@
'<span class="ab-bar"><span style="width:' + (total ? Math.round(100 * heard / total) : 0) + '%"></span></span></span></button>';
}
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 '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ ' +
esc(st.folder == null ? 'Alle Hörbücher' : st.folder) + '</button><strong>' + esc(g.name) + '</strong></div>' +
return '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ Hörbücher</button>' +
'<strong>' + esc(g.name) + '</strong></div>' +
'<div class="ab-grid">' + g.books.map(b => bookCard(b, b._volume)).join('') + '</div>';
}
st.series = null;
}
if (st.folder != null) {
html += '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ Alle Hörbücher</button>' +
'<strong>' + esc(st.folder) + '</strong></div>';
} else if (folders.size) {
html += '<div class="ab-folders">' + Array.from(folders.keys()).sort((a, b) => a.localeCompare(b, 'de')).map(f =>
'<button type="button" class="ab-folder" data-ab-folder="' + esc(f) + '"><span class="ab-folder-icon">▤</span>' +
'<span class="ab-title">' + esc(f) + '</span><span class="ab-meta">' + folders.get(f) + '</span></button>').join('') + '</div>';
}
return html + '<div class="ab-grid">' + list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
return '<div class="ab-chips"></div><div class="ab-grid">' +
list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
}
// 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 @@
'<div class="ab-menu" hidden>' +
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
'<button type="button" data-ab="played">Alles als gehört markieren</button>' +
'<button type="button" data-ab="folder">Ordner zuweisen …</button>' +
'<button type="button" data-ab="folder">In Ordner …</button>' +
'<button type="button" data-ab="podcast">Wieder als Podcast führen</button></div>' +
'<div class="ab-hero">' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') +
'<div class="ab-hero-text"><h2 class="ab-book-title">' + esc(b.title) + '</h2>' +
@ -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%; }',

223
static/js/covers.js Normal file
View file

@ -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};
})();

213
static/js/folders.js Normal file
View file

@ -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 =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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 = '<div class="dfo-row" role="toolbar">';
if (!lead.length) h += chip('dfo-all', 'Alle', null, o.active == null && !leadOn, 'data-dfo-all="1"');
lead.forEach(c => { h += chip('', c.label, c.count, !!c.on, 'data-dfo-lead="' + esc(c.id) + '"'); });
if (lead.length && (o.folders || []).length) h += '<span class="dfo-sep" aria-hidden="true"></span>';
(o.folders || []).forEach(f => {
h += chip('', f.name, f.count, o.active === f.name, 'data-dfo-folder="' + esc(f.name) + '"');
});
if (o.none) h += chip('dfo-none', 'Ohne Ordner', o.none, o.active === '', 'data-dfo-folder=""');
if (o.onManage) h += '<button type="button" class="dfo-chip dfo-manage" data-dfo-manage="1" title="Ordner verwalten" aria-label="Ordner verwalten">⋯</button>';
el.innerHTML = h + '</div>';
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 '<button type="button" class="dfo-chip ' + cls + (on ? ' on' : '') + '" ' + attrs + '>' + esc(label) +
(count != null ? ' <span class="dfo-count">' + count + '</span>' : '') + '</button>';
}
// ---- 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 = '<div class="dfo-sheet" role="dialog" aria-label="' + esc(title) + '">' +
'<div class="dfo-head"><strong>' + esc(title) + '</strong>' +
'<button type="button" class="dfo-x" data-dfo-close="1" aria-label="Schließen">✕</button></div>' +
'<div class="dfo-body"></div></div>';
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 = '<div class="dfo-list">' + (names.length ? names.map(n =>
'<label class="dfo-check"><input type="checkbox" value="' + esc(n) + '"' + (chosen.has(n) ? ' checked' : '') + '> ' +
esc(n) + '</label>').join('') : '<p class="dfo-note">Noch keine Ordner.</p>') + '</div>' +
'<form class="dfo-new"><input class="dfo-input" name="n" maxlength="100" placeholder="Neuer Ordner" autocomplete="off">' +
'<button type="submit" class="dfo-btn">Anlegen</button></form>' +
'<div class="dfo-actions"><button type="button" class="dfo-btn" data-dfo-close="1">Abbrechen</button>' +
'<button type="button" class="dfo-btn dfo-primary" data-dfo-ok="1">Übernehmen</button></div>';
};
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 ? '<div class="dfo-list">' + names.map((n, i) =>
'<div class="dfo-mrow"><span class="dfo-mname">' + esc(n) + '</span>' +
'<button type="button" class="dfo-icon" data-up="' + i + '" aria-label="Nach oben"' + (i ? '' : ' disabled') + '>↑</button>' +
'<button type="button" class="dfo-icon" data-down="' + i + '" aria-label="Nach unten"' + (i < names.length - 1 ? '' : ' disabled') + '>↓</button>' +
'<button type="button" class="dfo-icon" data-rename="' + i + '" aria-label="Umbenennen">✎</button>' +
'<button type="button" class="dfo-icon" data-delete="' + i + '" aria-label="Löschen">✕</button></div>').join('') + '</div>' +
'<p class="dfo-note">Umbenennen auf einen vorhandenen Namen legt beide zusammen. Löschen entfernt nur den Ordner, nicht seinen Inhalt.</p>'
: '<p class="dfo-note">Noch keine Ordner. Sie entstehen beim Einordnen („In Ordner …“).</p>';
};
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);
})();

396
static/js/gencover.js Normal file
View file

@ -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 += `<g transform="rotate(${a} 150 225)">`;
for (let i = 0; i < 3; i++) {
const y = between(r, 60, 380), h = between(r, 14, 46);
s += `<rect x="-120" y="${f1(y)}" width="540" height="${f1(h)}" fill="${i ? p[1] : p[2]}" opacity="${i ? 0.9 : 1}"/>`;
}
s += '</g>';
s += `<circle cx="${f1(between(r, 70, 230))}" cy="${f1(between(r, 120, 320))}" r="${f1(between(r, 38, 78))}" fill="${p[2]}"/>`;
s += `<rect x="${f1(between(r, 20, 200))}" y="${f1(between(r, 40, 360))}" width="${f1(between(r, 30, 80))}" height="${f1(between(r, 30, 80))}" fill="none" stroke="${p[1]}" stroke-width="6"/>`;
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 += `<circle cx="${f1(cx)}" cy="${f1(cy)}" r="${f1(i * between(r, 14, 22))}" fill="none" stroke="${i % 3 ? p[1] : p[2]}" stroke-width="${i % 3 ? 1 : 2.4}" opacity="${i % 3 ? 0.55 : 0.9}"/>`;
const phi = 1.618;
s += `<line x1="0" y1="${f1(H / phi)}" x2="${W}" y2="${f1(H / phi)}" stroke="${p[1]}" stroke-width="1" opacity=".5"/>`;
s += `<line x1="${f1(W / phi)}" y1="0" x2="${f1(W / phi)}" y2="${H}" stroke="${p[1]}" stroke-width="1" opacity=".5"/>`;
s += `<circle cx="${f1(cx + between(r, -40, 40))}" cy="${f1(cy + between(r, -40, 40))}" r="${f1(between(r, 8, 18))}" fill="${p[2]}"/>`;
return s;
},
psyche(r, p) {
let s = `<rect x="${W / 2}" y="0" width="${W / 2}" height="${H}" fill="${p[3]}" opacity=".35"/>`;
const cy = between(r, 170, 290);
const rx = between(r, 60, 95), ry = between(r, 80, 130);
s += `<ellipse cx="${f1(W / 2 - rx * 0.45)}" cy="${f1(cy)}" rx="${f1(rx)}" ry="${f1(ry)}" fill="${p[2]}" opacity=".85"/>`;
s += `<ellipse cx="${f1(W / 2 + rx * 0.45)}" cy="${f1(cy)}" rx="${f1(rx)}" ry="${f1(ry)}" fill="none" stroke="${p[1]}" stroke-width="2.5"/>`;
s += `<line x1="${W / 2}" y1="40" x2="${W / 2}" y2="${H - 40}" stroke="${p[1]}" stroke-width="1" opacity=".6"/>`;
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 += `<path d="${d}" fill="${i % 2 ? p[2] : p[3]}" opacity="${f1(0.18 + i * 0.07)}"/>`;
}
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 += `<path d="${d} Z" fill="none" stroke="${i % 4 ? p[1] : p[2]}" stroke-width="${i % 4 ? 0.8 : 1.8}" opacity="${i % 4 ? 0.45 : 0.9}"/>`;
}
s += `<g stroke="${p[2]}" stroke-width="1.4"><line x1="${f1(cx)}" y1="${f1(cy - 30)}" x2="${f1(cx)}" y2="${f1(cy + 30)}"/><line x1="${f1(cx - 30)}" y1="${f1(cy)}" x2="${f1(cx + 30)}" y2="${f1(cy)}"/></g>`;
return s;
},
kosmos(r, p) {
let s = '';
for (let i = 0; i < 70; i++) s += `<circle cx="${f1(r() * W)}" cy="${f1(r() * H)}" r="${f1(between(r, 0.4, 1.5))}" fill="${p[1]}" opacity="${f1(between(r, 0.3, 0.9))}"/>`;
const cx = between(r, 90, 210), cy = between(r, 170, 290), pr = between(r, 30, 55);
for (let i = 1; i <= 3; i++) s += `<ellipse cx="${f1(cx)}" cy="${f1(cy)}" rx="${f1(pr + i * 28)}" ry="${f1((pr + i * 28) * 0.38)}" fill="none" stroke="${p[3]}" stroke-width="1" opacity=".6" transform="rotate(-18 ${f1(cx)} ${f1(cy)})"/>`;
s += `<circle cx="${f1(cx)}" cy="${f1(cy)}" r="${f1(pr)}" fill="${p[2]}"/>`;
s += `<circle cx="${f1(cx + pr * 0.35)}" cy="${f1(cy - pr * 0.2)}" r="${f1(pr)}" fill="${p[0]}" opacity=".35"/>`;
return s;
},
ornament(r, p) {
const cx = W / 2, cy = between(r, 190, 260), n = pick(r, [8, 10, 12, 16]);
let s = `<g transform="translate(${cx} ${f1(cy)})">`;
const len = between(r, 70, 105), wid = between(r, 18, 30);
for (let i = 0; i < n; i++) {
s += `<path d="M0 0 C ${f1(wid)} ${f1(-len * 0.4)} ${f1(wid * 0.5)} ${f1(-len * 0.8)} 0 ${f1(-len)} C ${f1(-wid * 0.5)} ${f1(-len * 0.8)} ${f1(-wid)} ${f1(-len * 0.4)} 0 0 Z" ` +
`fill="${i % 2 ? p[2] : p[3]}" opacity=".85" transform="rotate(${f1(i * 360 / n)})"/>`;
}
s += `<circle r="${f1(between(r, 12, 20))}" fill="${p[1]}"/><circle r="${f1(len + 12)}" fill="none" stroke="${p[2]}" stroke-width="1.5"/></g>`;
s += `<rect x="14" y="14" width="${W - 28}" height="${H - 28}" fill="none" stroke="${p[2]}" stroke-width="1.5" opacity=".8"/>`;
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 += `<g transform="translate(${f1(x)} ${f1(y)}) rotate(${f1(a)})">` +
`<path d="M0 0 C ${f1(len * 0.35)} ${f1(-len * 0.35)} ${f1(len * 0.3)} ${f1(-len * 0.8)} 0 ${f1(-len)} C ${f1(-len * 0.3)} ${f1(-len * 0.8)} ${f1(-len * 0.35)} ${f1(-len * 0.35)} 0 0 Z" fill="${i % 3 ? p[2] : p[3]}" opacity=".8"/>` +
`<line x1="0" y1="0" x2="0" y2="${f1(-len * 0.95)}" stroke="${p[0]}" stroke-width="1.2" opacity=".7"/></g>`;
}
return s;
},
berge(r, p) {
let s = `<circle cx="${f1(between(r, 60, 240))}" cy="${f1(between(r, 120, 190))}" r="${f1(between(r, 22, 38))}" fill="${p[3]}"/>`;
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 += `<path d="${d}" fill="${i % 2 ? p[1] : p[2]}" opacity="${f1(0.35 + i * 0.18)}"/>`;
}
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 += `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(rad)}" fill="${i % 3 ? p[3] : p[2]}" opacity="${f1(between(r, 0.25, 0.75))}"/>`;
if (i % 4 === 0) s += `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(rad * 0.3)}" fill="${p[1]}" opacity=".7"/>`;
}
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 += `<rect x="${f1(x)}" y="${f1(H - 20 - h)}" width="${f1(w)}" height="${f1(h)}" fill="${v < 0.15 ? p[2] : v < 0.25 ? p[3] : p[1]}" opacity="${v < 0.25 ? 0.95 : f1(0.12 + v * 0.18)}"/>`;
for (let wy = H - 30 - h + 12; wy < H - 32; wy += 14) {
if (r() < 0.4) s += `<rect x="${f1(x + 5)}" y="${f1(wy)}" width="${f1(Math.max(3, w - 10))}" height="3" fill="${p[0]}" opacity=".5"/>`;
}
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 += `<rect x="${f1(20 + i * (cw + gap))}" y="${f1(180 + j * (rh + gap))}" width="${f1(cw)}" height="${f1(rh)}" fill="${fill}" opacity="${v < 0.2 ? 0.95 : f1(0.08 + v * 0.12)}"/>`;
}
return s;
},
schatten(r, p) {
const x = between(r, 60, 240);
return `<rect x="${f1(x)}" y="0" width="${f1(between(r, 3, 8))}" height="${H}" fill="${p[2]}"/>` +
`<circle cx="${f1(x)}" cy="${f1(between(r, 200, 330))}" r="${f1(between(r, 50, 90))}" fill="${p[3]}" opacity=".35"/>`;
},
typo(r, p, book) {
const ch = (book.title || '?').replace(/^[^A-Za-zÄÖÜäöüß0-9]+/, '').charAt(0).toUpperCase() || '?';
return `<text x="${f1(between(r, 20, 60))}" y="${f1(between(r, 380, 430))}" font-family="${SERIF}" font-size="${f1(between(r, 240, 320))}" ` +
`fill="${p[2]}" opacity=".9">${esc(ch)}</text>` +
`<line x1="20" y1="${f1(between(r, 120, 160))}" x2="${W - 20}" y2="${f1(between(r, 120, 160))}" stroke="${p[1]}" stroke-width="1" opacity=".5"/>`;
},
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 += `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(size / 2)}" fill="${fill}" opacity=".9"/>`;
else if (kind === 1) s += `<rect x="${f1(x - size / 2)}" y="${f1(y - size / 2)}" width="${f1(size)}" height="${f1(size)}" fill="${fill}" opacity=".9" transform="rotate(${f1(between(r, -20, 20))} ${f1(x)} ${f1(y)})"/>`;
else s += `<path d="M${f1(x)} ${f1(y - size / 2)} L${f1(x + size / 2)} ${f1(y + size / 2)} L${f1(x - size / 2)} ${f1(y + size / 2)} Z" fill="${fill}" opacity=".9"/>`;
}
return s;
},
};
// ---- the text -------------------------------------------------------------------------------------
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'}[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 += `<rect x="0" y="0" width="${W}" height="${f1(blockH)}" fill="${p[0]}" opacity=".94"/>`;
} else if (layout === 'bottom') {
y0 = H - blockH + 20;
s += `<rect x="0" y="${f1(H - blockH)}" width="${W}" height="${f1(blockH)}" fill="${p[0]}" opacity=".94"/>`;
} else {
y0 = 70;
s += `<rect x="18" y="${f1(y0 - 34)}" width="${W - 36}" height="${f1(blockH)}" rx="3" fill="${p[0]}" opacity=".95"/>`;
}
const x = 26;
ls.forEach((l, i) => {
s += `<text x="${x}" y="${f1(y0 + size * 0.85 + i * lh)}" font-family="${th.font}" font-size="${size}" font-weight="${th.font === SERIF ? 400 : 700}" ` +
`fill="${p[1]}" letter-spacing="${upper ? 0.5 : 0}">${esc(l)}</text>`;
});
if (author) {
s += `<text x="${x}" y="${f1(y0 + ls.length * lh + 22)}" font-family="${SANS}" font-size="12" letter-spacing="2" fill="${p[2]}">${esc(author.toUpperCase())}</text>`;
}
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 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
`<rect width="${W}" height="${H}" fill="${p[0]}"/>${motif}${textBlock(r, th, p, book)}</svg>`;
}
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};
})();

View file

@ -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',

View file

@ -353,6 +353,9 @@
</script>
{# app.js, split by area (docs/refactoring.md): classic scripts sharing one global scope, in this order. #}
<script src="/static/js/nav.js"></script>
<script src="/static/js/folders.js"></script>
<script src="/static/js/covers.js"></script>
<script src="/static/js/gencover.js"></script>
<script src="/static/js/app/01-core.js"></script>
<script src="/static/js/app/10-radio.js"></script>
<script src="/static/js/app/14-focus.js"></script>
@ -371,6 +374,7 @@
<script src="/static/js/app/48-book-cache.js"></script>
<script src="/static/js/app/50-reader-state.js"></script>
<script src="/static/js/app/52-book-list.js"></script>
<script src="/static/js/app/53-library.js"></script>
<script src="/static/js/app/56-cloud-import.js"></script>
<script src="/static/js/app/58-pdf.js"></script>
<script src="/static/js/app/60-tts.js"></script>