Compare commits

...
Sign in to create a new pull request.

43 commits

Author SHA1 Message Date
0a9b618858 Podcast-Oberfläche: wartender Transkriptionsauftrag zeigt den Grund (sw CACHE v171)
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 13m32s
Test / e2e (push) Failing after 1m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:17:23 +01:00
55239f71a0 Transkription: eingebaute Instanz läuft auf dem PC im Tailnet; ist sie nicht erreichbar, wartet der Auftrag und versucht es alle 10 Minuten neu (höchstens zwei Tage); Whisper-Server kann CUDA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:17:23 +01:00
e8bb70feb0 Eingebaute Whisper-Instanz: kleiner OpenAI-kompatibler Server (lädt das Modell bei Bedarf, gibt es nach Leerlauf frei)
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 19s
Test / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:10:15 +01:00
21cf91a875 Podcast-Oberfläche aus diora-android (Teilen, Transkript erstellen, ✦ Artikel, Artikel-Tab, Einstellungen Transkription), sw CACHE v170
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:21:42 +01:00
d4a47064b2 Podcast-Artikel: Reservierung mit Gerätenamen (claim/release, deviceId pro Browser oder App)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:03:19 +01:00
cedc14f714 Merge branch 'wt-tr-pipeline' into wt-tr-int 2026-11-09 10:02:00 +01:00
ae5341d441 Smarte Transkription C: Transkriptions-Pipeline (podcasts/transcribe.py), Endpunkte /transcribe/, /transcription/pending/, /article/claim/, Transkript-Rückfall auf Whisper, transcripts in /feeds/notifications/
Die Folge wird geladen, zu 16 kHz mono dekodiert und an Pausen in Stücke von etwa acht Minuten geschnitten (FLAC für die eingebaute, Opus für fremde Instanzen), parallel an Whisper geschickt (drei Wiederholungen mit wachsender Pause) und per transcripts.merge geglättet. Ein Daemon-Thread arbeitet die Warteschlange ab, ein Auftrag zugleich; hängengebliebene Aufträge starten nach einem Neustart beim ersten Aufruf neu. Abbrechen löscht die Zeile des Auftrags. Gerät der Reservierung: device im Body oder Header X-Diora-Device (docs/transcription.md).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:01:35 +01:00
9659140f30 Migration der Transkription nach 0028 (nach dem Episodenlink aus dem Teilen-Schritt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:47:20 +01:00
d059ed82c8 Merge branch 'wt-tr-article' into wt-tr-int 2026-11-09 09:39:08 +01:00
efdc0ffc11 Merge branch 'wt-tr-blog' into wt-tr-int 2026-11-09 09:39:08 +01:00
bd09d81366 Smarte Transkription D: Artikel-Endpunkt und Blog-Anbindung
POST/DELETE /podcasts/episodes/<id>/article/ (podcasts/article.py): legt die Quelle Podcast-Artikel an und erzeugt oder ersetzt das Item der Folge; Sanitizer behält href=#t=<Sekunden>; _item_data und Item-Ansicht liefern episode_id; feeds.js: Folge anhören und Zeitmarken-Links.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:36:34 +01:00
a937ed8b3e Folgen haben einen Link: PodcastEpisode.link aus dem <link> des Feed-Eintrags (neu und beim Abruf nachgetragen), in episode_detail und allen Folgenlisten
Grundlage fürs Teilen einer Folge; Migration 0027.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:32:13 +01:00
69442f4a4d Smarte Transkription B: Datenmodell, Einstellungen und Test-Endpunkt
TranscriptionSettings (Token Fernet-verschluesselt wie SiteLogin), TranscriptionJob und EpisodeArticle in podcasts; Source.synthetic in feeds, das Refresh, Aufraeumen, OPML-Export, Kommando und Umstellen auf Nachrichten ueberspringt bzw. verweigert. GET/POST /podcasts/transcription/settings/ und POST /podcasts/transcription/test/ (SSRF-Pruefung ueber chapters._open, jetzt auch fuer POST; eingebaute Instanz aus DIORA_WHISPER_URL ausgenommen).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:27:01 +01:00
810cf7741c Podcast-Artikel: Benachrichtigungsziel episode:<id> wie in der Android-Bridge, im Vertrag festgehalten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:22:06 +01:00
dbd4fb6e3a Podcast-Artikel auf dem Gerät: podcast-article.js schreibt den Artikel mit dem eigenen Key und holt offene Wünsche ab; DioraAI.ask mit onProgress und signal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:21:24 +01:00
f242605a97 Smarte Transkription: Vertrag und Prompt (docs/transcription.md), Abschnitt Transkripte in docs/podcasts.md, Podcast-Artikel in docs/ai.md
Transkript und Artikel nur auf Knopfdruck; der Server transkribiert mit Whisper (eingebaut oder eigene Instanz), das Gerät schreibt den Artikel mit dem eigenen Schlüssel, sofort oder beim nächsten Öffnen; Benachrichtigung über /feeds/notifications/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:16:16 +01:00
1e36d8600b PDF-Zoom neu: Pinch zoomt um die Fingermitte und zeichnet scharf neu (Scrollen: an Ort und Stelle, die Stelle bleibt; Seiten: vergrößerte Seite mit einem Finger verschiebbar, am Rand weiterwischen blättert); pdfSmartZoomPage gab es nicht mehr; Seiten werden auf eigener Canvas gezeichnet und erst fertig eingesetzt (sw CACHE v169)
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 11m29s
Test / e2e (push) Failing after 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:37:12 +01:00
0d712a7f21 PDF: Öffnen an der gespeicherten Seite zeigt kein „Weit gescrollt“ mehr (sw CACHE v168)
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 11m29s
Test / e2e (push) Failing after 1m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:35:03 +01:00
066d0052d2 PDF-Seiten so breit wie gezeichnet (nicht auf die Zeilenlänge des Textes begrenzt); Notizspalte geht beim Öffnen nicht mehr von selbst auf (sw CACHE v167)
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:32:24 +01:00
429b875faf PDF: öffnet an der gespeicherten Seite, Leserfarben, Seitenzahlen wie im Buch (sw CACHE v166)
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 11m30s
Test / e2e (push) Failing after 1m30s
Der Lesestand wird gleich beim Öffnen erfragt (parallel zur Datei), ein PDF zeichnet zuerst
die Seite, an der es weitergeht, statt Seite 1; Titel, Inhaltsverzeichnis und Seitenzahlen
kommen nebenher und halten das Öffnen nicht mehr auf. Eine Zeichnung, die schon läuft, wird
abgewartet statt neu begonnen.

„Leserfarben“ (neben Originalfarben und Invertieren): die Seite in den Farben des Lesers
(Dunkel, Sepia) per SVG-Filter, Bilder darüber wie gedruckt (ihre Lage aus den
Zeichenbefehlen); ein Bild, das die halbe Seite füllt, ist ein Scan und wird mit umgefärbt.
pdf.js' eigene pageColors färben die Bilder mit um. Ein anderes Design ändert nur die Farben
des Filters.

Seitenzahlen des Buchs (PageLabels: „iv“, „A-3“) im Kopf mit der Seite der Datei daneben,
eingeben lässt sich beides; Lesezeichen und der Zurück-Knopf nennen sie. Das Test-PDF hat
dafür Seitenzahlen und ein farbiges Bild bekommen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:42:26 +01:00
65a01569f9 PDF: markierbarer Text, Suche im ganzen Dokument, anklickbare Links mit Zurück (sw CACHE v165)
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 11m32s
Test / e2e (push) Failing after 1m30s
Über jeder gezeichneten Seite liegt die Textebene von pdf.js (auswählen, kopieren) und eine
Ebene mit den Links: Sprünge im Dokument gehen dorthin, ein Knopf führt zurück; Web-Adressen
öffnen außerhalb. Die Suche geht durch den Text aller Seiten (je Dokument einmal geholt, auch
für den Zuschnitt im Seitenmodus) statt nur durch die gerade gezeichneten, markiert den Treffer
genau und springt in beiden Modi hin. Tippen und Wischen auf Text blättern weiter, außer
nach einer Auswahl; Markierungen bleiben EPUBs vorbehalten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:16:27 +01:00
6a3cd23095 PDF-Reader überarbeitet: ein Zeichenweg, Seitenmodus nach Textbox, Seitensprung, Inhalt und Lesezeichen in beiden Modi (sw CACHE v164)
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:09:05 +01:00
ec704a086f PDF Seitenmodus: Seite füllt den Lesebereich und wird nach ihrer Textbox platziert
Die Leser-Regel „.reader-content > * { max-width: 65ch }“ verengte auch #pdf-viewport, im
Querformat saß die Seite deshalb seitlich versetzt; dazu kamen Polsterung und Flex-Zentrierung
oben drauf. Seitenweise misst 66-pdf-paginated.js den Bereich und setzt die Seite selbst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:07:18 +01:00
a963841bc4 Test-PDF als Binärdatei (keine Zeilenend-Umwandlung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:39 +01:00
c2a9873b0d PDF-Reader: ein Zeichenweg, Seitenmodus neu, Seitensprung für beide Modi (WIP, ungetestet)
- 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird
  abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die
  gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker
  (_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts-
  verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel;
  ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite.
- 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in
  Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen
  Seite (eine fast leere Seite wird nicht unendlich groß); nächste und
  vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten
  nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die
  Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn'
  gemeldet (E-Ink-Refresh).
- pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf,
  Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab).
- Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten
  immer „Seite 1“).
- Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu,
  wenn er ruht.
- openBook hängt seine Listener über _readerListen an; closeReader nimmt
  sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und
  ein zweites Buch geben das alte Dokument frei; der entschlüsselte
  PDF-Puffer wird nicht mehr gehalten.
- Invertieren dreht die Farbtöne zurück (Fotos keine Negative).
- e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten,
  Outline, Links und einer fast leeren Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:28 +01:00
04548171bf Anmeldung mit Authelia für die Apps: Server gibt nach geprüftem Authelia-Login den App-Zugang heraus (sw CACHE v163)
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 11m35s
Test / e2e (push) Failing after 1m29s
Android und Desktop melden sich selbst bei Authelia an. Mit dem
geprüften Ergebnis bekommen sie von diora den Zugang des verknüpften
Kontos, so wie bisher nach der Anmeldung mit Passwort. Den Bücher-Schlüssel
öffnen sie selbst, der Server sieht ihn nicht. Details: docs/accounts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 20:12:43 +01:00
67f983dd1c Nach dem Authelia-Login sind die Bücher gleich offen, ohne Code und Passwort (sw CACHE v162)
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 11m27s
Test / e2e (push) Failing after 1m30s
Authelia gibt dem Gerät (nicht dem diora-Server) ein geheimes Attribut
heraus. Damit legt das Gerät beim Verknüpfen den Bücher-Schlüssel
verschlüsselt bei diora ab und holt ihn auf neuen Geräten nach dem
Authelia-Login unsichtbar zurück. Code und Passwort bleiben als Notweg, wenn
Authelia nichts liefert. Aktiv, sobald DIORA_OIDC_APP_CLIENT_ID gesetzt ist.

Details und Grenzen: docs/accounts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:35:21 +01:00
077e6d4543 Anmelden mit Authelia als zweiter Weg, Bücher auf neuen Geräten per Code entsperren (sw CACHE v161)
Some checks failed
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 11m22s
Test / e2e (push) Failing after 1m29s
Auf der Login-Seite gibt es „Mit Authelia anmelden“, wenn der Server dafür
eingerichtet ist. Das eigene Konto wird einmal in den Einstellungen verknüpft,
die Anmeldung mit Passwort bleibt. Fremde Authelia-Konten kommen nicht hinein,
neue Konten entstehen darüber nicht.

Fehlt einem Gerät der Bücher-Schlüssel (nach der Authelia-Anmeldung oder nach
gelöschtem Speicher), erzeugt diora keinen neuen mehr, der die Bücher
unlesbar machen würde. Stattdessen fragt es: Ein schon entsperrtes Gerät zeigt
denselben Code und gibt den Schlüssel verschlüsselt frei, oder man gibt einmal
das diora-Passwort ein. Der Server sieht den Schlüssel dabei nie.

Details: docs/accounts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:01:25 +01:00
3eca4d8404 Ordner-Chips standardmäßig eingeklappt – aufgeklappt nur, wenn auf dem Gerät so gewählt (sw CACHE v160)
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 11m4s
Test / e2e (push) Failing after 1m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:18:32 +01:00
d702a27d16 Ordner-Chips einklappbar: „Ordner ▾/▸“ nach den Status-Chips, eingeklappt bleibt nur der gewählte Ordner sichtbar; je Bereich auf dem Gerät gemerkt (sw CACHE v159)
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 11m1s
Test / e2e (push) Failing after 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:40:10 +01:00
e8b525a3de Nachrichten und Blogs im Buch-Reader, Doppelklick für Vollbild, Hörbücher wie die Bücher-Bibliothek
Some checks failed
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Successful in 11m2s
Test / e2e (push) Failing after 1m36s
65-article.js (DioraArticle): Artikel öffnen im selben Reader wie Bücher – Schrift, Farben,
Seiten/Doppelseite mit Fußnoten am Seitenfuß, Suche, Vorlesen, Vollbild. Lesezeichen,
Markierungen und Notizspalte bleiben Büchern vorbehalten. Merken, Projekt und Original
stehen in der Kopfzeile (Apps: im Lesemenü). feeds.js gibt sein eigenes Overlay auf;
Fortschritt wie bisher (bis zum unteren Bildschirmrand gelesen, ab 90 % gelesen).
Einstellungen der Artikel gelten für alle Artikel zusammen.

62-reader.js: Doppelklick (Maus) auf den Text schaltet Vollbild an/aus, die Leisten gehen mit.

audiobooks.js: die Hörbücher sind gebaut wie die Bücher – Kopf mit Suche, Sortierung und +,
Chips Angefangen/Ungehört/Gehört und Ordner, „Weiterhören“ wie „Weiterlesen“, Kacheln aus
denselben Bausteinen (53-library.js gibt seine Styles auch an #audiobook-shelf), Cover quadratisch.

63-pages.js: bei nur einem Kapitel zeigt die Fußzeile nur die Seiten.

sw CACHE v158

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:15:00 +01:00
6bb774adb9 Lesen in Seiten auch im Browser und auf dem Desktop, mit Fußnoten unten auf der Seite und Doppelseite
Some checks failed
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 11m2s
Test / e2e (push) Failing after 1m29s
63-pages.js (DioraPages): die Seitenansicht des E-Reader-Modus der Android-Apps, jetzt für alle –
Seitenumbrüche vor angeschnittenen Zeilen, ein Kapitel auf einmal, Fußnoten am Seitenfuß wie im
gedruckten Buch, Blättern per Tippen/Klick am Rand, Wischen, Pfeil-/Bild-/Leertaste und Mausrad.
Neu: Doppelseite (zwei Seiten nebeneinander ab 900 px Breite). Einstellung „Scrollen | Seiten“
(+ „Fußnoten unten“, „Doppelseite“) im Schrift-&-Layout-Panel, je Gerät.

Fußnoten: Erkennung und Auflösung aus diora-android native-ui.js übernommen (40-epub.js,
78-footnotes.js) – die Notiz wird im richtigen Kapitel gesucht (IDs wiederholen sich je Datei),
auch das Popover zeigt jetzt genau die Notiz. Vorbereitete Bücher werden einmal neu aufbereitet.

Behoben gegenüber der App-Fassung: ein Verweis in der ersten Zeile einer Seite (hochgestellt)
zählte zur Vorseite, und ein Verweis, der samt Notiz nicht mehr passte, blieb ohne Notiz stehen –
jetzt wandert er mit ihr auf die nächste Seite. Schnelles Blättern löst „Weit gescrollt“ nicht mehr aus.

sw CACHE v157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:41:27 +01:00
308eed150a Podcasts: Shownotes überdecken die „Ähnlichen Folgen“ nicht mehr (podcast-ui.css aus diora-android 5227c26, sw CACHE v156)
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 10m57s
Test / e2e (push) Failing after 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:57:29 +01:00
marwin
14a45f4cb4 Merge origin/master in master
Some checks failed
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 10m58s
Test / e2e (push) Failing after 1m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:48:23 +01:00
marwin
593b72f17f Merge worktree-tui-reader: diora-tui (Terminal-EPUB-Reader) nach master
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:46:32 +01:00
marwin
e9683fbdbd TUI: Zeichen am rechten Rand bei schmalen Terminals nicht mehr abgeschnitten
ContinuousBookViews vertikale Scrollbar reserviert 2 Spalten, die vorher
nicht aus der Wrap-Breite herausgerechnet wurden — der Text wurde also
2 Zeichen breiter gewrappt, als tatsächlich sichtbar war, wodurch die
Scrollbar die letzten 1-2 Buchstaben jeder Zeile überdeckt hat. Bei
schmalen Terminals (großer Font, wenig Spalten) war das besonders
auffällig, betraf strukturell aber jede Breite.

Fix: overflow-x: hidden (eine ungewollte horizontale Scrollbar hat
zusätzlich eine Zeile unten geklaut) + overflow-y: scroll (hält die
Scrollbar-Breite von Anfang an konstant, kein Rätselraten je nach
Inhaltsgröße) in book_view.py. reader_screen.py misst die Wrap-Breite
jetzt am Container statt an book_view selbst (vermeidet einen
Miss-nach-Einschränken-Zirkelbezug bei wiederholten Resizes) und rechnet
die feste Scrollbar-Breite (SCROLLBAR_GUTTER=2) heraus; beim Anwenden
des Layouts wird sie wieder daraufgerechnet, damit book_view.styles.width
weiterhin fürs Zentrieren passt.

Getestet: content_region-Breite stimmt jetzt exakt mit der Wrap-Breite
über sechs verschiedene Terminalbreiten (60-200 Spalten, inkl. des
120-Zeichen-Cap-Bereichs) überein — vorher lag sie durchgehend 2 Spalten
darunter. Voller Regressionstest (Scroll, Fußnote, Resize) läuft weiter
fehlerfrei.
2026-08-15 21:07:41 +02:00
marwin
20d04361bc TUI: Statusleiste mit Akkustand und Uhrzeit
Neues diora_tui/statusbar.py: StatusBar-Widget, unten rechts auf Bibliotheks-
und Reader-Screen, zeigt Akkustand (psutil.sensors_battery(), degradiert
sauber zu reiner Uhrzeit auf Geräten ohne Akku) + aktuelle Uhrzeit,
sekündlich aktualisiert.

Getestet: Anzeige und Aktualisierung auf beiden Screens verifiziert.
2026-08-15 20:36:54 +02:00
marwin
b1a04d2a65 TUI: Auto-Sync bei Start/Beenden, Sortierung nach zuletzt geöffnet, Gelesen-Filter
Library-Screen synct jetzt selbstständig statt den expliziten `sync`-Befehl
vorauszusetzen: einmal im Hintergrund direkt nach dem Start (neue Bücher/
Fortschritt erscheinen, sobald fertig), einmal abgewartet beim Beenden über
q (DioraTuiApp.action_quit). Beides best-effort — ohne gespeicherte
Zugangsdaten oder bei Netzwerkfehlern bleibt die lokale Bibliothek
unangetastet nutzbar.

Bibliotheksliste sortiert jetzt nach zuletzt geöffnetem Buch (progress.json
updated_at, neueste zuerst) und blendet gelesene Bücher standardmäßig aus
(EBook.is_read aus dem Sync-Snapshot, lokal in library.json gespiegelt) —
r-Taste zeigt sie für die Sitzung wieder an.

Getestet: vollständig isoliert (Pfad-Konstanten gemonkeypatcht statt echter
~/.config-/~/.local/share-Dateien) gegen einen echten Dev-Server mit zwei
Büchern unterschiedlichen Gelesen-Status und Fortschritts-Zeitstempeln —
Auto-Sync, Filter-Default, Toggle und Sortierreihenfolge korrekt bestätigt;
Sync-vor-dem-Beenden separat verifiziert (kein echter Hänger, nur ein
Test-Timing-Artefakt ohne pilot.pause()).
2026-08-15 20:30:09 +02:00
marwin
db3f520632 TUI: durchgehende Leseansicht mit web-kompatiblen Anchors, Fußnoten, bidirektionalem Progress-Sync
Reader zeigt Bücher jetzt als eine fortlaufende Ansicht über alle Kapitel
statt Kapitel für Kapitel (diora_tui/blocks.py, layout.py, book_view.py,
reader_screen.py). blockIndex wird exakt wie app.js' EPUB_BLOCK_SELECTOR
gezählt (p/h1-6/li/blockquote/dt/dd/figcaption + kindlose divs), inklusive
linear="no"-Spine-Einträgen — app.js filtert die nicht, und Skippen hätte
sowohl Fußnoten-Ziele verfehlt als auch alle folgenden Blockindizes gegen
den Web-Reader verschoben. innerFraction ist eine zeilenbasierte Näherung
(Terminal hat keine Pixel-Geometrie), was funktioniert, weil der
Furthest-Wins-Vergleich primär nach blockIndex sortiert.

Rendering nutzt Textuals Line-API (ContinuousBookView.render_line) statt
eines einzelnen riesigen Static — bei großen Büchern (mehrere reale
heruntergeladene Bücher haben zehntausende Blocks) hätte ein Static den
Layout/Paint-Pass auf über eine Minute gebracht. Zusätzlich cached
diora_tui/cache.py das (Buch, Layout)-Paar pro (Datei, Breite) auf Platte
für schnelles Wiederöffnen. Text ist auf 120 Zeichen begrenzt und
zentriert.

Fußnoten (f-Taste, FootnoteScreen): Erkennung wie app.js'
_looksLikeFootnoteLink; Ziel-Auflösung sammelt IDs aus dem ganzen
Block-Teilbaum (nicht nur vom Block-Tag selbst), weil Fußnoten-Ziele
häufig auf einem inneren <a> statt dem umschließenden <p> sitzen.

Progress-Sync ist jetzt bidirektional, ohne Übersetzungsschicht nötig, da
beide Seiten dasselbe Anchor-Format nutzen: sync zieht book_progress aus
dem Snapshot in den lokalen Store (furthest-wins); der Reader schickt bei
offenen server-verknüpften Büchern Updates zurück (force: false, im
Hintergrund-Worker).

Verschlüsselungs-Key-Beschaffung ergänzt um den Fallback
localStorage.getItem(...) falls die Clipboard-API in der Konsole
verweigert wird.

Getestet: Blockindex-/Fußnoten-Korrektheit gegen reale Bücher (u.a.
3686/3686 aufgelöste Fußnoten bei einem Zizek-Band), Anchor-Mathematik
per Unit-Test, vollständiger Pilot-Test (Navigation, Scroll,
Kapitelsprung, Fußnoten-Peek, Resize), Performance-Messung über mehrere
Buchgrößen inkl. Cache-Effekt (größtes Buch: ~34k Blocks, kalt ~20-30s,
warm ~5s), Save/Restore-Round-Trip 5x wiederholt gegen eine
Race-Condition beim ersten Post-Load-Scroll, und Progress-Push
End-to-End gegen einen echten Dev-Server verifiziert.
2026-08-15 17:59:46 +02:00
marwin
50f711831c TUI: Verschlüsselungs-Key per Benutzername+Passwort ableiten statt Konsolen-Export
diora_tui/crypto.py:derive_key_b64 reproduziert app.js' deriveAndStoreKey()
(PBKDF2-HMAC-SHA256, 200000 Iterationen, Salt "diora:"+username) — der
`sync`-Prompt bietet das jetzt als Standardweg zum Schlüssel an, als
Alternative zum bisherigen Weg über die Browser-Konsole (await
exportEncKey()). Funktioniert nur für Accounts, die den Key je über
diora's "Unlock with password"-Formular abgeleitet haben; ein falsches
Passwort/Account führt zu einem abgefangenen DecryptError ("falscher
Key?"), nie zu stillem Fehlverhalten.

Getestet: zwei unabhängige PBKDF2-Implementierungen (hashlib,
cryptography) stimmen für die verwendeten Parameter byte-genau überein;
vollständiger sync-Lauf gegen einen echten Dev-Server mit einem Buch, das
unter einem exakt so abgeleiteten Key verschlüsselt wurde, entschlüsselt
korrekt; falsches Passwort wird sauber als Fehler gemeldet statt
abzustürzen.
2026-08-15 16:55:03 +02:00
marwin
a4b10ae265 TUI: Bücher per diora-tui sync vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.

Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.

Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
marwin
3fcb74631c Merge master (Sync-API) into worktree-tui-reader 2026-08-15 16:22:54 +02:00
marwin
f63bd1f879 TUI: lokaler EPUB-Reader als erste Stufe eines diora-TUI-Clients
Python + Textual, unter tui/. Liest EPUBs aus einem Bibliotheksordner,
zeigt Kapitel als Fließtext, j/k/Pfeiltasten zum Scrollen, n/p für
Kapitelwechsel. Fortschritt wird lokal (progress.json via platformdirs)
nach demselben "furthest wins"-Prinzip wie diora's Web-Reader gespeichert,
damit ein Sync-Layer gegen die künftige diora-API später ohne Rewrite
andocken kann. Vorerst rein lokal, kein Server-Kontakt, keine
Verschlüsselung.
2026-08-15 16:04:18 +02:00
93 changed files with 10954 additions and 699 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.pdf binary

134
accounts/keyshare.py Normal file
View file

@ -0,0 +1,134 @@
"""Passing the book key from an unlocked device to a new one (models.KeyShare).
The book key never reaches the server in the clear: the new device posts an ECDH P-256
public key, an unlocked device of the same account answers with its own public key and
the book key AES-GCM-encrypted under the shared secret. Both devices show a code made
from the request's public key (static/js/app/33-unlock.js), so the user approves the
right device. Requests run out after a few minutes; an answer is handed out once.
POST /accounts/keyshare/ {pub, device} -> {token}
GET /accounts/keyshare/<token>/ -> {state, pub, iv, ct}
GET /accounts/keyshare/pending/ -> {requests: [...]}
POST /accounts/keyshare/<token>/answer/ {pub, iv, ct} | {decline: true}
"""
import base64
import binascii
import json
from datetime import timedelta
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from .models import KeyShare
LIFETIME = timedelta(minutes=10)
# Only requests whose device asked lately are offered: a closed tab drops out quickly.
ALIVE = timedelta(seconds=20)
MAX_OPEN = 5
def _auth(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return None
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 _b64(value, low, high):
"""A base64 string whose decoded length lies in [low, high], else None."""
if not isinstance(value, str) or len(value) > 4 * high:
return None
try:
raw = base64.b64decode(value, validate=True)
except (binascii.Error, ValueError):
return None
return value if low <= len(raw) <= high else None
def _purge(user):
KeyShare.objects.filter(user=user, created_at__lt=timezone.now() - LIFETIME).delete()
@require_http_methods(['POST'])
def create(request):
if (r := _auth(request)):
return r
body = _body(request)
pub = _b64((body or {}).get('pub'), 65, 65) # uncompressed P-256 point
if pub is None:
return JsonResponse({'error': 'pub required'}, status=400)
_purge(request.user)
# A device that reloads asks anew: the oldest requests make room instead of blocking it.
stale = KeyShare.objects.filter(user=request.user).order_by('-created_at').values_list('pk', flat=True)[MAX_OPEN - 1:]
KeyShare.objects.filter(pk__in=list(stale)).delete()
device = str(body.get('device') or '')[:100]
share = KeyShare.objects.create(user=request.user, request_pub=pub, device=device,
polled_at=timezone.now())
return JsonResponse({'token': share.token})
@require_http_methods(['GET'])
def status(request, token):
if (r := _auth(request)):
return r
_purge(request.user)
share = KeyShare.objects.filter(user=request.user, token=token).first()
if share is None:
return JsonResponse({'state': 'expired'})
if share.declined:
share.delete()
return JsonResponse({'state': 'declined'})
if not share.answer_ct:
share.polled_at = timezone.now()
share.save(update_fields=['polled_at'])
return JsonResponse({'state': 'pending'})
data = {'state': 'answered', 'pub': share.answer_pub, 'iv': share.answer_iv, 'ct': share.answer_ct}
share.delete() # handed out once
return JsonResponse(data)
@require_http_methods(['GET'])
def pending(request):
if (r := _auth(request)):
return r
_purge(request.user)
open_ = KeyShare.objects.filter(user=request.user, declined=False, answer_ct='',
polled_at__gte=timezone.now() - ALIVE).order_by('-created_at')
return JsonResponse({'requests': [
{'token': s.token, 'pub': s.request_pub, 'device': s.device,
'created_at': s.created_at.isoformat()} for s in open_]})
@require_http_methods(['POST'])
def answer(request, token):
if (r := _auth(request)):
return r
_purge(request.user)
share = KeyShare.objects.filter(user=request.user, token=token).first()
if share is None:
return JsonResponse({'error': 'expired'}, status=404)
if share.answer_ct or share.declined:
return JsonResponse({'error': 'already answered'}, status=409)
body = _body(request) or {}
if body.get('decline'):
share.declined = True
share.answered_at = timezone.now()
share.save(update_fields=['declined', 'answered_at'])
return JsonResponse({'ok': True})
pub = _b64(body.get('pub'), 65, 65)
iv = _b64(body.get('iv'), 12, 12)
ct = _b64(body.get('ct'), 32 + 16, 32 + 16) # 256-bit key + GCM tag
if not (pub and iv and ct):
return JsonResponse({'error': 'pub, iv and ct required'}, status=400)
share.answer_pub, share.answer_iv, share.answer_ct = pub, iv, ct
share.answered_at = timezone.now()
share.save(update_fields=['answer_pub', 'answer_iv', 'answer_ct', 'answered_at'])
return JsonResponse({'ok': True})

View file

@ -0,0 +1,50 @@
# Generated by Django 6.1 on 2026-11-01 13:35
import accounts.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0007_uservault'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='KeyShare',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(default=accounts.models._generate_token, max_length=64, unique=True)),
('device', models.CharField(blank=True, max_length=100)),
('request_pub', models.TextField()),
('answer_pub', models.TextField(blank=True)),
('answer_iv', models.CharField(blank=True, max_length=64)),
('answer_ct', models.TextField(blank=True)),
('declined', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('polled_at', models.DateTimeField(blank=True, null=True)),
('answered_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='key_shares', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='OidcIdentity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('issuer', models.CharField(max_length=200)),
('sub', models.CharField(max_length=255)),
('username', models.CharField(blank=True, max_length=150)),
('email', models.CharField(blank=True, max_length=254)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_login_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oidc_identities', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('issuer', 'sub')},
},
),
]

View file

@ -142,3 +142,40 @@ class ApiToken(models.Model):
def __str__(self): def __str__(self):
return f"ApiToken({self.user.username})" return f"ApiToken({self.user.username})"
class OidcIdentity(models.Model):
"""An account of the sign-in provider (Authelia) linked to a diora account
(accounts/oidc.py). `sub` is the provider's stable id; name and mail are only shown."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='oidc_identities')
issuer = models.CharField(max_length=200)
sub = models.CharField(max_length=255)
username = models.CharField(max_length=150, blank=True)
email = models.CharField(max_length=254, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
last_login_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ('issuer', 'sub')
def __str__(self):
return f"OidcIdentity({self.username or self.sub} -> {self.user_id})"
class KeyShare(models.Model):
"""A device without the book key asks an unlocked device of the same account for it
(accounts/keyshare.py). The server only passes on public keys and ciphertext: the new
device sends an ECDH public key, the unlocked one answers with its own public key and
the book key encrypted for the shared secret. Both show a code made from the request's
public key, so the user can see the answer goes to the right device."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='key_shares')
token = models.CharField(max_length=64, unique=True, default=_generate_token)
device = models.CharField(max_length=100, blank=True)
request_pub = models.TextField() # base64 raw P-256 point of the asking device
answer_pub = models.TextField(blank=True)
answer_iv = models.CharField(max_length=64, blank=True)
answer_ct = models.TextField(blank=True)
declined = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
polled_at = models.DateTimeField(null=True, blank=True) # the asking device still waits
answered_at = models.DateTimeField(null=True, blank=True)

165
accounts/oidc.py Normal file
View file

@ -0,0 +1,165 @@
"""Sign-in with an OpenID Connect provider (Authelia, docs/accounts.md).
An extra way in next to username + password: an account is linked once in the settings
while signed in normally, after that the provider's login leads into it. Nobody gets a new
account from the provider — an unlinked identity is turned away.
Authorization code flow with PKCE (S256) and a confidential client (client_secret_basic).
The id_token is checked here (RS256 against the provider's JWKS, iss, aud, exp, nonce)
with `cryptography`, so no OIDC library is needed.
The book key is not touched: it is derived from the diora password on the device. A device
that signs in this way gets it from an unlocked device or by typing the password once
(accounts/keyshare.py, static/js/app/33-unlock.js).
"""
import base64
import hashlib
import json
import secrets
import time
from urllib.parse import urlencode
import requests
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from django.conf import settings
TIMEOUT = 10
_CACHE_SECONDS = 3600
_cache = {} # url -> (fetched_at, json)
class OidcError(Exception):
pass
def enabled() -> bool:
return bool(settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID and settings.OIDC_CLIENT_SECRET)
def key_enabled() -> bool:
return enabled() and bool(settings.OIDC_APP_CLIENT_ID)
def _b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
def _b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b'=').decode('ascii')
def _get_json(url: str, fresh: bool = False) -> dict:
hit = _cache.get(url)
if hit and not fresh and time.time() - hit[0] < _CACHE_SECONDS:
return hit[1]
try:
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
data = resp.json()
except (requests.RequestException, ValueError) as exc:
raise OidcError(f'{url} nicht erreichbar: {exc}') from exc
_cache[url] = (time.time(), data)
return data
def discovery() -> dict:
issuer = settings.OIDC_ISSUER.rstrip('/')
return _get_json(issuer + '/.well-known/openid-configuration')
def new_flow() -> dict:
"""Random state, nonce and PKCE verifier for one sign-in (kept in the session)."""
return {
'state': secrets.token_urlsafe(24),
'nonce': secrets.token_urlsafe(24),
'verifier': secrets.token_urlsafe(48),
}
def authorize_url(flow: dict, redirect_uri: str) -> str:
challenge = _b64url(hashlib.sha256(flow['verifier'].encode('ascii')).digest())
params = {
'response_type': 'code',
'client_id': settings.OIDC_CLIENT_ID,
'redirect_uri': redirect_uri,
'scope': 'openid profile email groups',
'state': flow['state'],
'nonce': flow['nonce'],
'code_challenge': challenge,
'code_challenge_method': 'S256',
}
return discovery()['authorization_endpoint'] + '?' + urlencode(params)
def exchange_code(code: str, flow: dict, redirect_uri: str) -> dict:
"""Trade the code for tokens and return the checked id_token claims."""
try:
resp = requests.post(
discovery()['token_endpoint'],
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri,
'code_verifier': flow['verifier'],
},
auth=(settings.OIDC_CLIENT_ID, settings.OIDC_CLIENT_SECRET),
timeout=TIMEOUT,
)
except requests.RequestException as exc:
raise OidcError(f'Token-Abruf fehlgeschlagen: {exc}') from exc
if resp.status_code != 200:
raise OidcError(f'Token-Abruf abgelehnt ({resp.status_code})')
try:
id_token = resp.json()['id_token']
except (ValueError, KeyError) as exc:
raise OidcError('Antwort ohne id_token') from exc
return verify_id_token(id_token, flow['nonce'])
def _public_key(kid: str):
jwks_uri = discovery()['jwks_uri']
for fresh in (False, True): # a key rotation shows up as an unknown kid: fetch again once
for jwk in _get_json(jwks_uri, fresh=fresh).get('keys', []):
if jwk.get('kty') == 'RSA' and (not kid or jwk.get('kid') == kid):
n = int.from_bytes(_b64url_decode(jwk['n']), 'big')
e = int.from_bytes(_b64url_decode(jwk['e']), 'big')
return rsa.RSAPublicNumbers(e, n).public_key()
raise OidcError('Signaturschlüssel des Anbieters nicht gefunden')
def verify_id_token(token: str, nonce, now: float = None, client_id: str = None) -> dict:
"""Check signature and claims. `nonce` is the expected value, or a function that says
whether the token's nonce is good (the apps' signed ones, oidc_views.app_login)."""
try:
header_b64, payload_b64, sig_b64 = token.split('.')
header = json.loads(_b64url_decode(header_b64))
claims = json.loads(_b64url_decode(payload_b64))
signature = _b64url_decode(sig_b64)
except (ValueError, TypeError) as exc:
raise OidcError('id_token unlesbar') from exc
if header.get('alg') != 'RS256':
raise OidcError(f'nicht unterstützte Signatur {header.get("alg")!r} (erwartet RS256)')
try:
_public_key(header.get('kid', '')).verify(
signature, f'{header_b64}.{payload_b64}'.encode('ascii'),
padding.PKCS1v15(), hashes.SHA256())
except InvalidSignature as exc:
raise OidcError('Signatur des id_token ungültig') from exc
now = time.time() if now is None else now
if claims.get('iss', '').rstrip('/') != settings.OIDC_ISSUER.rstrip('/'):
raise OidcError('falscher Aussteller')
aud = claims.get('aud')
if (client_id or settings.OIDC_CLIENT_ID) not in (aud if isinstance(aud, list) else [aud]):
raise OidcError('id_token ist nicht für diora')
if not isinstance(claims.get('exp'), (int, float)) or claims['exp'] < now - 60:
raise OidcError('id_token abgelaufen')
good = nonce(claims.get('nonce') or '') if callable(nonce) else (nonce and claims.get('nonce') == nonce)
if not good:
raise OidcError('nonce passt nicht')
if not claims.get('sub'):
raise OidcError('id_token ohne sub')
return claims

245
accounts/oidc_views.py Normal file
View file

@ -0,0 +1,245 @@
"""Sign-in with Authelia and linking it to an existing account (accounts/oidc.py)."""
import json
import logging
import secrets
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.core import signing
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from . import oidc
from .models import ApiToken, OidcIdentity
log = logging.getLogger(__name__)
SESSION_KEY = 'oidc_flow'
def _redirect_uri(request):
return settings.OIDC_REDIRECT_URI or request.build_absolute_uri('/accounts/oidc/callback/')
def _safe_next(request, value):
if value and url_has_allowed_host_and_scheme(value, allowed_hosts={request.get_host()},
require_https=request.is_secure()):
return value
return '/'
def oidc_context(request):
"""What the settings page shows about the provider."""
return {
'oidc_enabled': oidc.enabled(),
'oidc_provider': settings.OIDC_PROVIDER_NAME,
'oidc_identities': list(request.user.oidc_identities.all()) if request.user.is_authenticated else [],
}
@require_http_methods(['GET'])
def oidc_start(request):
"""Off to the provider. `?link=1` (signed in) links the account instead of signing in."""
if not oidc.enabled():
messages.error(request, 'Die Anmeldung über Authelia ist auf diesem Server nicht eingerichtet.')
return redirect('login')
link = request.GET.get('link') == '1'
if link and not request.user.is_authenticated:
return redirect('login')
flow = oidc.new_flow()
flow['link'] = link
flow['next'] = _safe_next(request, request.GET.get('next', ''))
try:
url = oidc.authorize_url(flow, _redirect_uri(request))
except oidc.OidcError as exc:
log.warning('oidc start: %s', exc)
messages.error(request, 'Authelia ist gerade nicht erreichbar.')
return redirect('settings' if link else 'login')
request.session[SESSION_KEY] = flow
return redirect(url)
@require_http_methods(['GET'])
def oidc_callback(request):
flow = request.session.pop(SESSION_KEY, None)
fail_to = 'login'
if flow and flow.get('link'):
fail_to = 'settings'
if not flow or request.GET.get('state') != flow.get('state'):
messages.error(request, 'Die Anmeldung über Authelia ist abgelaufen. Bitte noch einmal.')
return redirect(fail_to)
if request.GET.get('error'):
messages.error(request, 'Authelia hat die Anmeldung abgebrochen.')
return redirect(fail_to)
code = request.GET.get('code', '')
try:
claims = oidc.exchange_code(code, flow, _redirect_uri(request))
except oidc.OidcError as exc:
log.warning('oidc callback: %s', exc)
messages.error(request, 'Die Anmeldung über Authelia hat nicht geklappt.')
return redirect(fail_to)
issuer = settings.OIDC_ISSUER.rstrip('/')
sub = claims['sub']
shown = {
'username': (claims.get('preferred_username') or '')[:150],
'email': (claims.get('email') or '')[:254],
}
identity = OidcIdentity.objects.filter(issuer=issuer, sub=sub).select_related('user').first()
if flow.get('link'):
if not request.user.is_authenticated:
return redirect('login')
if identity and identity.user_id != request.user.id:
messages.error(request, 'Dieses Authelia-Konto ist schon mit einem anderen diora-Konto verknüpft.')
return redirect('settings')
if identity is None:
OidcIdentity.objects.create(user=request.user, issuer=issuer, sub=sub, **shown)
else:
OidcIdentity.objects.filter(pk=identity.pk).update(**shown)
messages.success(request, f'Authelia-Konto „{shown["username"] or sub}“ verknüpft. '
'Du kannst dich jetzt auch darüber anmelden.')
if oidc.key_enabled(): # leave the book key with Authelia's help right away
return redirect('/accounts/oidc/key/?next=/accounts/settings/')
return redirect('settings')
if identity is None:
messages.error(request, 'Dieses Authelia-Konto ist mit keinem diora-Konto verknüpft. '
'Melde dich einmal mit Passwort an und verknüpfe es in den Einstellungen.')
return redirect('login')
if not identity.user.is_active:
messages.error(request, 'Dieses diora-Konto ist gesperrt.')
return redirect('login')
OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now(), **shown)
login(request, identity.user, backend='django.contrib.auth.backends.ModelBackend')
# A password login brings the book key along, this one cannot: the device fetches it
# (oidc_key below), else the page asks (static/js/app/33-unlock.js).
next_url = flow.get('next') or '/'
if oidc.key_enabled():
from urllib.parse import urlencode
return redirect('/accounts/oidc/key/?' + urlencode({'next': next_url}))
return redirect(next_url)
@login_required
@require_http_methods(['POST'])
def oidc_unlink(request, pk):
OidcIdentity.objects.filter(user=request.user, pk=pk).delete()
messages.success(request, 'Verknüpfung mit Authelia gelöst.')
return redirect('settings')
@login_required
@require_http_methods(['GET'])
def oidc_key(request):
"""The book key with the provider's help, without asking (docs/accounts.md).
Runs entirely on the device: it signs in once more as the public client
(OIDC_APP_CLIENT_ID, PKCE, prompt=none — the provider session from just before makes it
invisible), reads its `diora_key` claim from userinfo and with it opens the book key the
server keeps wrapped (vault "authelia-key") — or, when the device has the key, wraps and
leaves it there. The server never sees the claim; this view only hands out the page.
The same page is the redirect target (`/accounts/oidc/key/callback/`)."""
next_url = _safe_next(request, request.GET.get('next', ''))
if not oidc.key_enabled():
return redirect(next_url)
try:
disco = oidc.discovery()
except oidc.OidcError as exc:
log.warning('oidc key: %s', exc)
return redirect(next_url)
config = {
'authorize': disco['authorization_endpoint'],
'token': disco['token_endpoint'],
'userinfo': disco.get('userinfo_endpoint', ''),
'client_id': settings.OIDC_APP_CLIENT_ID,
'redirect_uri': request.build_absolute_uri('/accounts/oidc/key/callback/')
if not settings.OIDC_REDIRECT_URI
else settings.OIDC_REDIRECT_URI.replace('/oidc/callback/', '/oidc/key/callback/'),
'subs': [i.sub for i in request.user.oidc_identities.all()],
'user_id': request.user.id,
'next': next_url,
}
return render(request, 'accounts/oidc_key.html', {'config': config})
# ---------------------------------------------------------------------------
# The apps (Android, desktop): sign in with Authelia as the public client themselves
# ---------------------------------------------------------------------------
#
# 1. GET /accounts/oidc/app/ -> where Authelia is, the client id and a nonce
# signed by this server (good for APP_NONCE_AGE).
# 2. The app runs the code flow with PKCE in the system browser (redirect to its own
# scheme or loopback), trades the code itself and reads `diora_key` from userinfo.
# 3. POST /accounts/oidc/app/login/ {id_token} -> the account's API token, as the
# password login (diora-android Auth.kt) hands it out.
# The book key never passes here: the app opens the vault "authelia-key" itself.
APP_NONCE_SALT = 'diora.oidc.app'
APP_NONCE_AGE = 600
@require_http_methods(['GET'])
def app_config(request):
if not oidc.key_enabled():
return JsonResponse({'enabled': False})
try:
disco = oidc.discovery()
except oidc.OidcError as exc:
log.warning('oidc app config: %s', exc)
return JsonResponse({'enabled': False, 'error': 'provider unreachable'}, status=502)
return JsonResponse({
'enabled': True,
'provider': settings.OIDC_PROVIDER_NAME,
'issuer': settings.OIDC_ISSUER.rstrip('/'),
'authorize': disco['authorization_endpoint'],
'token': disco['token_endpoint'],
'userinfo': disco.get('userinfo_endpoint', ''),
'client_id': settings.OIDC_APP_CLIENT_ID,
'scope': 'openid profile diora_key',
'nonce': signing.dumps(secrets.token_urlsafe(16), salt=APP_NONCE_SALT),
})
def _app_nonce_ok(value):
try:
signing.loads(value, salt=APP_NONCE_SALT, max_age=APP_NONCE_AGE)
return True
except signing.BadSignature:
return False
@csrf_exempt # a native client without cookies; the signed id_token is the proof
@require_http_methods(['POST'])
def app_login(request):
if not oidc.key_enabled():
return JsonResponse({'error': 'not enabled'}, status=404)
try:
body = json.loads(request.body or b'{}')
id_token = body['id_token']
if not isinstance(id_token, str):
raise TypeError
except (ValueError, KeyError, TypeError):
return JsonResponse({'error': 'id_token required'}, status=400)
try:
claims = oidc.verify_id_token(id_token, _app_nonce_ok, client_id=settings.OIDC_APP_CLIENT_ID)
except oidc.OidcError as exc:
log.warning('oidc app login: %s', exc)
return JsonResponse({'error': 'Die Anmeldung über Authelia war ungültig.'}, status=401)
identity = OidcIdentity.objects.filter(
issuer=settings.OIDC_ISSUER.rstrip('/'), sub=claims['sub']).select_related('user').first()
if identity is None:
return JsonResponse({'error': 'Dieses Authelia-Konto ist mit keinem diora-Konto verknüpft. '
'Verknüpfe es einmal im Browser unter Einstellungen.'}, status=403)
if not identity.user.is_active:
return JsonResponse({'error': 'Dieses diora-Konto ist gesperrt.'}, status=403)
OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now())
token, _ = ApiToken.objects.get_or_create(user=identity.user)
return JsonResponse({'token': token.token, 'user_id': identity.user.id,
'username': identity.user.username, 'sub': claims['sub']})

321
accounts/test_oidc.py Normal file
View file

@ -0,0 +1,321 @@
import base64
import json
import time
from unittest.mock import patch
from urllib.parse import parse_qs, urlsplit
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from . import oidc
from .models import ApiToken, KeyShare, OidcIdentity
ISSUER = 'https://auth.example.org'
PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048)
OTHER_PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _b64url(b):
return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
def _int_b64(i):
return _b64url(i.to_bytes((i.bit_length() + 7) // 8, 'big'))
def make_token(claims, key=PRIV, alg='RS256', kid='k1'):
header = _b64url(json.dumps({'alg': alg, 'kid': kid, 'typ': 'JWT'}).encode())
payload = _b64url(json.dumps(claims).encode())
sig = key.sign(f'{header}.{payload}'.encode(), padding.PKCS1v15(), hashes.SHA256())
return f'{header}.{payload}.{_b64url(sig)}'
DISCOVERY = {
'issuer': ISSUER,
'authorization_endpoint': ISSUER + '/api/oidc/authorization',
'token_endpoint': ISSUER + '/api/oidc/token',
'jwks_uri': ISSUER + '/jwks.json',
}
pub = PRIV.public_key().public_numbers()
JWKS = {'keys': [{'kty': 'RSA', 'kid': 'k1', 'n': _int_b64(pub.n), 'e': _int_b64(pub.e)}]}
class FakeResp:
def __init__(self, data, status=200):
self._data, self.status_code = data, status
def json(self):
return self._data
def raise_for_status(self):
if self.status_code >= 400:
raise oidc.requests.HTTPError(str(self.status_code))
def fake_get(url, timeout=None):
return FakeResp(DISCOVERY if url.endswith('openid-configuration') else JWKS)
@override_settings(OIDC_ISSUER=ISSUER, OIDC_CLIENT_ID='diora', OIDC_CLIENT_SECRET='s3cret',
OIDC_REDIRECT_URI='https://diora.test/accounts/oidc/callback/')
class OidcFlowTests(TestCase):
def setUp(self):
oidc._cache.clear()
self.user = User.objects.create_user(username='crm', password='pw12345678')
self.get = patch('accounts.oidc.requests.get', side_effect=fake_get)
self.get.start()
self.addCleanup(self.get.stop)
def _start(self, query=''):
resp = self.client.get('/accounts/oidc/start/' + query)
self.assertEqual(resp.status_code, 302)
q = parse_qs(urlsplit(resp['Location']).query)
self.assertEqual(q['code_challenge_method'], ['S256'])
self.assertEqual(q['client_id'], ['diora'])
return q['state'][0], q['nonce'][0]
def _callback(self, state, claims, token_status=200):
token = make_token(claims)
with patch('accounts.oidc.requests.post',
return_value=FakeResp({'id_token': token}, token_status)) as post:
resp = self.client.get('/accounts/oidc/callback/', {'state': state, 'code': 'c0de'})
return resp, post
def _claims(self, nonce, **extra):
c = {'iss': ISSUER, 'aud': ['diora'], 'sub': 'uuid-1', 'exp': time.time() + 300,
'nonce': nonce, 'preferred_username': 'marwin', 'email': 'm@example.org'}
c.update(extra)
return c
def test_unlinked_identity_is_turned_away(self):
state, nonce = self._start()
resp, _ = self._callback(state, self._claims(nonce))
self.assertEqual(resp['Location'], '/accounts/login/')
self.assertNotIn('_auth_user_id', self.client.session)
self.assertFalse(User.objects.exclude(pk=self.user.pk).exists())
def test_link_then_login(self):
self.client.force_login(self.user)
state, nonce = self._start('?link=1')
resp, post = self._callback(state, self._claims(nonce))
self.assertEqual(resp['Location'], '/accounts/settings/')
ident = OidcIdentity.objects.get()
self.assertEqual((ident.user, ident.sub, ident.username), (self.user, 'uuid-1', 'marwin'))
# client_secret_basic + PKCE verifier
self.assertEqual(post.call_args.kwargs['auth'], ('diora', 's3cret'))
self.assertIn('code_verifier', post.call_args.kwargs['data'])
self.client.logout()
state, nonce = self._start('?next=/books/')
resp, _ = self._callback(state, self._claims(nonce))
self.assertEqual(resp['Location'], '/books/')
self.assertEqual(int(self.client.session['_auth_user_id']), self.user.pk)
ident.refresh_from_db()
self.assertIsNotNone(ident.last_login_at)
def test_next_to_other_host_is_dropped(self):
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
state, nonce = self._start('?next=https://evil.example/')
resp, _ = self._callback(state, self._claims(nonce))
self.assertEqual(resp['Location'], '/')
def test_identity_of_other_account_is_not_moved(self):
other = User.objects.create_user(username='bob', password='pw12345678')
OidcIdentity.objects.create(user=other, issuer=ISSUER, sub='uuid-1')
self.client.force_login(self.user)
state, nonce = self._start('?link=1')
self._callback(state, self._claims(nonce))
self.assertEqual(OidcIdentity.objects.get().user, other)
def test_wrong_state_nonce_audience_or_signature_fail(self):
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
cases = [
('state', {}),
('nonce', {'nonce': 'other'}),
('aud', {'aud': 'someone-else'}),
('iss', {'iss': 'https://evil.example'}),
('exp', {'exp': time.time() - 3600}),
]
for name, extra in cases:
with self.subTest(name):
state, nonce = self._start()
claims = {**self._claims(nonce), **extra}
resp, _ = self._callback('wrong' if name == 'state' else state, claims)
self.assertEqual(resp['Location'], '/accounts/login/')
self.assertNotIn('_auth_user_id', self.client.session)
state, nonce = self._start()
with patch('accounts.oidc.requests.post',
return_value=FakeResp({'id_token': make_token(self._claims(nonce), key=OTHER_PRIV)})):
self.client.get('/accounts/oidc/callback/', {'state': state, 'code': 'x'})
self.assertNotIn('_auth_user_id', self.client.session)
def test_state_is_single_use(self):
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
state, nonce = self._start()
self._callback(state, self._claims(nonce))
self.client.logout()
resp, _ = self._callback(state, self._claims(nonce))
self.assertNotIn('_auth_user_id', self.client.session)
def test_unlink(self):
ident = OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
other = User.objects.create_user(username='bob', password='pw12345678')
foreign = OidcIdentity.objects.create(user=other, issuer=ISSUER, sub='uuid-2')
self.client.force_login(self.user)
self.client.post(f'/accounts/oidc/{foreign.pk}/unlink/')
self.client.post(f'/accounts/oidc/{ident.pk}/unlink/')
self.assertEqual(list(OidcIdentity.objects.all()), [foreign])
def test_with_app_client_login_and_link_go_by_the_key_page(self):
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
with override_settings(OIDC_APP_CLIENT_ID='diora-app'):
state, nonce = self._start('?next=/books/')
resp, _ = self._callback(state, self._claims(nonce))
self.assertEqual(resp['Location'], '/accounts/oidc/key/?next=%2Fbooks%2F')
page = self.client.get(resp['Location'])
self.assertContains(page, 'oidc-key-config')
cfg = page.context['config']
self.assertEqual(cfg['client_id'], 'diora-app')
self.assertEqual(cfg['redirect_uri'], 'https://diora.test/accounts/oidc/key/callback/')
self.assertEqual(cfg['subs'], ['uuid-1'])
self.assertEqual(cfg['next'], '/books/')
self.assertEqual(cfg['token'], DISCOVERY['token_endpoint'])
self.assertEqual(self.client.get('/accounts/oidc/key/callback/?code=x&state=y').status_code, 200)
state, nonce = self._start('?link=1')
resp, _ = self._callback(state, self._claims(nonce))
self.assertEqual(resp['Location'], '/accounts/oidc/key/?next=/accounts/settings/')
def test_key_page_without_app_client_goes_straight_on(self):
self.client.force_login(self.user)
resp = self.client.get('/accounts/oidc/key/?next=/books/')
self.assertEqual(resp['Location'], '/books/')
def test_login_page_shows_button_only_when_configured(self):
self.assertContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden')
with override_settings(OIDC_CLIENT_SECRET=''):
self.assertNotContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden')
@override_settings(OIDC_ISSUER=ISSUER, OIDC_CLIENT_ID='diora', OIDC_CLIENT_SECRET='s3cret',
OIDC_APP_CLIENT_ID='diora-app')
class OidcAppLoginTests(TestCase):
def setUp(self):
oidc._cache.clear()
self.user = User.objects.create_user(username='crm', password='pw12345678')
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
p = patch('accounts.oidc.requests.get', side_effect=fake_get)
p.start()
self.addCleanup(p.stop)
def _nonce(self):
cfg = self.client.get('/accounts/oidc/app/').json()
self.assertEqual((cfg['client_id'], cfg['token']), ('diora-app', DISCOVERY['token_endpoint']))
return cfg['nonce']
def _login(self, **claims):
c = {'iss': ISSUER, 'aud': ['diora-app'], 'sub': 'uuid-1', 'exp': time.time() + 300,
'nonce': self._nonce()}
c.update(claims)
return self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token(c)}),
content_type='application/json')
def test_linked_account_gets_its_existing_token(self):
existing = ApiToken.objects.create(user=self.user)
resp = self._login()
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json(), {'token': existing.token, 'user_id': self.user.id,
'username': 'crm', 'sub': 'uuid-1'})
def test_token_is_made_when_there_is_none(self):
resp = self._login()
self.assertEqual(resp.json()['token'], ApiToken.objects.get(user=self.user).token)
def test_refused(self):
for name, claims, code in [
('unlinked', {'sub': 'other'}, 403),
('web client token', {'aud': 'diora'}, 401),
('own nonce', {'nonce': 'made-up'}, 401),
('expired', {'exp': time.time() - 3600}, 401),
]:
with self.subTest(name):
self.assertEqual(self._login(**claims).status_code, code)
self.assertEqual(self.client.post('/accounts/oidc/app/login/', '{}',
content_type='application/json').status_code, 400)
def test_old_nonce_is_refused(self):
nonce = self._nonce()
with patch('django.core.signing.time.time', return_value=time.time() + 3600):
resp = self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token(
{'iss': ISSUER, 'aud': 'diora-app', 'sub': 'uuid-1', 'exp': time.time() + 7200, 'nonce': nonce})}),
content_type='application/json')
self.assertEqual(resp.status_code, 401)
def test_off_without_app_client(self):
with override_settings(OIDC_APP_CLIENT_ID=''):
self.assertEqual(self.client.get('/accounts/oidc/app/').json(), {'enabled': False})
PUB = base64.b64encode(b'\x04' + b'\x01' * 64).decode()
class KeyShareTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='crm', password='pw12345678')
self.client.force_login(self.user)
def _post(self, url, body):
return self.client.post(url, json.dumps(body), content_type='application/json')
def test_round_trip_hands_answer_out_once(self):
token = self._post('/accounts/keyshare/', {'pub': PUB, 'device': 'Firefox auf Linux'}).json()['token']
self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'pending'})
pending = self.client.get('/accounts/keyshare/pending/').json()['requests']
self.assertEqual([(r['token'], r['pub'], r['device']) for r in pending], [(token, PUB, 'Firefox auf Linux')])
answer = {'pub': PUB, 'iv': base64.b64encode(b'i' * 12).decode(),
'ct': base64.b64encode(b'c' * 48).decode()}
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', answer).status_code, 200)
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', answer).status_code, 409)
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
got = self.client.get(f'/accounts/keyshare/{token}/').json()
self.assertEqual(got, {'state': 'answered', **answer})
self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'expired'})
def test_requests_of_devices_that_stopped_asking_are_not_offered(self):
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
KeyShare.objects.update(polled_at=KeyShare.objects.get().created_at.replace(year=2000))
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
self.client.get(f'/accounts/keyshare/{token}/') # asks again
self.assertEqual(len(self.client.get('/accounts/keyshare/pending/').json()['requests']), 1)
def test_decline(self):
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
self._post(f'/accounts/keyshare/{token}/answer/', {'decline': True})
self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'declined'})
def test_bad_input_is_refused(self):
self.assertEqual(self._post('/accounts/keyshare/', {'pub': 'nope'}).status_code, 400)
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
bad = {'pub': PUB, 'iv': base64.b64encode(b'i' * 12).decode(), 'ct': base64.b64encode(b'c' * 10).decode()}
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', bad).status_code, 400)
def test_other_accounts_see_nothing(self):
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
other = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(other)
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', {'decline': True}).status_code, 404)
self.client.logout()
self.assertEqual(self.client.get('/accounts/keyshare/pending/').status_code, 401)
def test_open_requests_are_limited_and_run_out(self):
tokens = [self._post('/accounts/keyshare/', {'pub': PUB}).json()['token'] for _ in range(7)]
self.assertEqual(KeyShare.objects.count(), 5)
self.assertEqual(self.client.get(f'/accounts/keyshare/{tokens[0]}/').json(), {'state': 'expired'})
self.assertEqual(self.client.get(f'/accounts/keyshare/{tokens[-1]}/').json(), {'state': 'pending'})
KeyShare.objects.update(created_at=KeyShare.objects.first().created_at.replace(year=2000))
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
self.assertEqual(self._post('/accounts/keyshare/', {'pub': PUB}).status_code, 200)

View file

@ -1,6 +1,6 @@
from django.contrib.auth.views import LogoutView from django.contrib.auth.views import LogoutView
from django.urls import path from django.urls import path
from . import views from . import keyshare, oidc_views, views
urlpatterns = [ urlpatterns = [
path('register/', views.register, name='register'), path('register/', views.register, name='register'),
@ -21,4 +21,15 @@ urlpatterns = [
path('webdav/<int:pk>/test/', views.webdav_test, name='webdav_test'), path('webdav/<int:pk>/test/', views.webdav_test, name='webdav_test'),
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'), path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
path('vault/<slug:name>/', views.vault, name='vault'), path('vault/<slug:name>/', views.vault, name='vault'),
path('oidc/start/', oidc_views.oidc_start, name='oidc_start'),
path('oidc/callback/', oidc_views.oidc_callback, name='oidc_callback'),
path('oidc/app/', oidc_views.app_config, name='oidc_app_config'),
path('oidc/app/login/', oidc_views.app_login, name='oidc_app_login'),
path('oidc/key/', oidc_views.oidc_key, name='oidc_key'),
path('oidc/key/callback/', oidc_views.oidc_key, name='oidc_key_callback'),
path('oidc/<int:pk>/unlink/', oidc_views.oidc_unlink, name='oidc_unlink'),
path('keyshare/', keyshare.create, name='keyshare_create'),
path('keyshare/pending/', keyshare.pending, name='keyshare_pending'),
path('keyshare/<str:token>/', keyshare.status, name='keyshare_status'),
path('keyshare/<str:token>/answer/', keyshare.answer, name='keyshare_answer'),
] ]

View file

@ -13,6 +13,7 @@ from django.views.decorators.http import require_http_methods
from radio import lastfm as lastfm_module from radio import lastfm as lastfm_module
from .models import ApiToken, UserVault, WebDAVSource from .models import ApiToken, UserVault, WebDAVSource
from .oidc_views import oidc_context
from .webdav import WebDAVError, list_directory from .webdav import WebDAVError, list_directory
User = get_user_model() User = get_user_model()
@ -56,7 +57,7 @@ def login_view(request):
else: else:
form = AuthenticationForm() form = AuthenticationForm()
return render(request, 'accounts/login.html', {'form': form}) return render(request, 'accounts/login.html', {'form': form, **oidc_context(request)})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -78,6 +79,7 @@ def settings_view(request):
'password_form': PasswordChangeForm(request.user), 'password_form': PasswordChangeForm(request.user),
'api_token': getattr(request.user, 'api_token', None), 'api_token': getattr(request.user, 'api_token', None),
'webdav_sources': request.user.webdav_sources.all(), 'webdav_sources': request.user.webdav_sources.all(),
**oidc_context(request),
} }
return render(request, 'accounts/settings.html', context) return render(request, 'accounts/settings.html', context)
@ -217,6 +219,7 @@ def change_password(request):
'password_form_open': True, 'password_form_open': True,
'api_token': getattr(request.user, 'api_token', None), 'api_token': getattr(request.user, 'api_token', None),
'webdav_sources': request.user.webdav_sources.all(), 'webdav_sources': request.user.webdav_sources.all(),
**oidc_context(request),
}) })

10
deploy/whisper/Dockerfile Normal file
View file

@ -0,0 +1,10 @@
# diora's built-in Whisper (docs/transcription.md). Built on the server next to diora-prod:
# docker compose build whisper
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir faster-whisper==1.2.1 fastapi==0.143.1 uvicorn==0.54.0 python-multipart==0.0.32
WORKDIR /app
COPY server.py .
ENV WHISPER_CACHE=/models HF_HOME=/models/hf
EXPOSE 8000
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]

85
deploy/whisper/server.py Normal file
View file

@ -0,0 +1,85 @@
"""diora's built-in Whisper: a small OpenAI-compatible transcription server.
POST /v1/audio/transcriptions (multipart: file, model, language, prompt, response_format)
returns {text} or, with response_format=verbose_json, {language, duration, text, segments}.
The `model` field is ignored; WHISPER_MODEL decides. The model is loaded on the first request
and freed again after WHISPER_IDLE seconds without work, so it holds no memory while unused.
One transcription at a time; parallel requests wait (the server has few cores to share).
See docs/transcription.md.
"""
import os
import subprocess
import threading
import time
# Windows with a GPU: where the CUDA libraries (cuBLAS, cuDNN) are, separated by ';'.
for _d in filter(None, os.environ.get('WHISPER_DLL_DIRS', '').split(';')):
os.add_dll_directory(_d)
os.environ['PATH'] = _d + os.pathsep + os.environ['PATH']
import numpy as np # noqa: E402
from fastapi import FastAPI, File, Form, HTTPException, UploadFile # noqa: E402
from faster_whisper import WhisperModel # noqa: E402
MODEL = os.environ.get('WHISPER_MODEL', 'large-v3-turbo')
THREADS = int(os.environ.get('WHISPER_THREADS', '4'))
DEVICE = os.environ.get('WHISPER_DEVICE', 'cpu') # cpu or cuda
COMPUTE = os.environ.get('WHISPER_COMPUTE', 'int8') # int8_float16 on a GPU
IDLE = int(os.environ.get('WHISPER_IDLE', '600'))
CACHE = os.environ.get('WHISPER_CACHE', '/models')
app = FastAPI()
work = threading.Lock() # one transcription at a time
state = {'model': None, 'last': 0.0}
def model():
if state['model'] is None:
state['model'] = WhisperModel(MODEL, device=DEVICE, compute_type=COMPUTE, cpu_threads=THREADS,
download_root=CACHE)
return state['model']
def unloader():
while True:
time.sleep(30)
if state['model'] is not None and time.time() - state['last'] > IDLE and work.acquire(blocking=False):
try:
state['model'] = None
finally:
work.release()
threading.Thread(target=unloader, daemon=True).start()
def decode(data: bytes) -> np.ndarray:
p = subprocess.run(['ffmpeg', '-nostdin', '-loglevel', 'error', '-i', 'pipe:0', '-ac', '1', '-ar', '16000',
'-f', 'f32le', 'pipe:1'], input=data, capture_output=True)
if p.returncode != 0:
raise HTTPException(400, 'not audio: ' + p.stderr.decode('utf-8', 'replace')[-200:])
return np.frombuffer(p.stdout, np.float32)
@app.get('/health')
def health():
return {'ok': True, 'model': MODEL, 'device': DEVICE, 'loaded': state['model'] is not None}
@app.post('/v1/audio/transcriptions')
def transcriptions(file: UploadFile = File(...), language: str = Form(''), prompt: str = Form(''),
response_format: str = Form('json')):
audio = decode(file.file.read())
t0 = time.time()
with work:
segs, info = model().transcribe(audio, language=language or None, initial_prompt=prompt or None,
beam_size=5, vad_filter=True)
segs = [{'id': i, 'start': round(s.start, 2), 'end': round(s.end, 2), 'text': s.text}
for i, s in enumerate(segs)]
state['last'] = time.time()
dur = len(audio) / 16000
print(f'{dur:.0f} s audio in {time.time() - t0:.0f} s', flush=True)
text = ''.join(s['text'] for s in segs).strip()
if response_format == 'verbose_json':
return {'task': 'transcribe', 'language': info.language, 'duration': dur, 'text': text, 'segments': segs}
return {'text': text}

View file

@ -84,6 +84,9 @@ DATABASES = {
} }
} }
# The built-in Whisper instance (OpenAI-compatible, base such as http://whisper:8000/v1);
# empty = none (docs/transcription.md).
WHISPER_URL = os.environ.get('DIORA_WHISPER_URL', '').strip().rstrip('/')
PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200')) PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200'))
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
@ -138,6 +141,17 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = '/accounts/login/' LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/' LOGIN_REDIRECT_URL = '/'
# Sign-in with Authelia (accounts/oidc.py); off while one of the three is empty.
OIDC_ISSUER = os.environ.get('DIORA_OIDC_ISSUER', '')
OIDC_CLIENT_ID = os.environ.get('DIORA_OIDC_CLIENT_ID', '')
OIDC_CLIENT_SECRET = os.environ.get('DIORA_OIDC_CLIENT_SECRET', '')
# Fixed callback address; empty = built from the request (needs the real https host).
OIDC_REDIRECT_URI = os.environ.get('DIORA_OIDC_REDIRECT_URI', '')
OIDC_PROVIDER_NAME = os.environ.get('DIORA_OIDC_PROVIDER_NAME', 'Authelia')
# Public client the devices use to fetch their `diora_key` claim from the provider themselves
# (docs/accounts.md, "Schlüssel über Authelia"); empty = off.
OIDC_APP_CLIENT_ID = os.environ.get('DIORA_OIDC_APP_CLIENT_ID', '')
LOGOUT_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/'
# Last.fm # Last.fm

91
docs/accounts.md Normal file
View file

@ -0,0 +1,91 @@
# Anmeldung und Bücher-Schlüssel
## Zwei Wege hinein
**Benutzername und Passwort** (`accounts/views.py` `login_view`): Das Login-Formular leitet im
Browser aus dem Passwort den Bücher-Schlüssel ab (PBKDF2-SHA256, Salt `diora:<username>`,
200 000 Runden) und legt ihn bis zur nächsten Seite in `localStorage['diora_pending_enc_key']`
ab. Die App-Seite übernimmt ihn als `diora_enc_key_<userId>` (`32-crypto.js`, `99-init.js`).
**Authelia (OpenID Connect)** (`accounts/oidc.py`, `accounts/oidc_views.py`): als zusätzlicher
Weg, die Anmeldung mit Passwort bleibt. Der Ablauf ist Authorization Code mit PKCE (S256) und
einem vertraulichen Client (`client_secret_basic`). Das id_token wird hier geprüft (RS256 gegen
die JWKS des Anbieters, `iss`, `aud`, `exp`, `nonce`), dafür reicht `cryptography`.
- Ein Konto wird **einmal verknüpft**: in den Einstellungen „Mit Authelia verknüpfen“, während
man normal angemeldet ist (`/accounts/oidc/start/?link=1`). Gespeichert wird `OidcIdentity`
(`issuer`, `sub`; Name und Mail nur zur Anzeige). Danach führt „Mit Authelia anmelden“ auf der
Login-Seite in dieses Konto.
- Eine nicht verknüpfte Authelia-Identität wird abgewiesen. Es entstehen **keine neuen
Konten** über Authelia, und es wird auch nicht über Name oder Mail automatisch verknüpft.
- Wer hinein darf, regelt Authelia (Policy `mitglieder` = Gruppen `admins` oder `freunde`).
- Einstellungen per Umgebung: `DIORA_OIDC_ISSUER`, `DIORA_OIDC_CLIENT_ID`,
`DIORA_OIDC_CLIENT_SECRET`, `DIORA_OIDC_REDIRECT_URI` (fest, weil hinter dem Proxy kein
https erkannt wird), optional `DIORA_OIDC_PROVIDER_NAME`. Fehlt eins der ersten drei, gibt es
keinen Knopf. Auf prod stehen sie in `~/docker-compose/diora-prod/.env` (Strato), das
Gegenstück liegt auf Hetzner in `/opt/authelia/oidc-clients.env`.
- Redirect-URI im Authelia-Client: `https://diora.creamfresh.xyz/accounts/oidc/callback/`.
## Der Schlüssel nach einer Authelia-Anmeldung
### Normalfall: über Authelia, ohne Nachfrage
Authelia hält je Nutzer ein geheimes Attribut `diora_key` (32 Byte, base64). Es geht nur an den
öffentlichen Client `diora-app` (`DIORA_OIDC_APP_CLIENT_ID`, PKCE, Scope `diora_key`) und nur
über userinfo, nicht im id_token. Nach jedem Authelia-Login (und nach dem Verknüpfen) führt
diora auf `/accounts/oidc/key/` (`templates/accounts/oidc_key.html`). Die Seite meldet sich
**im Browser** noch einmal mit `prompt=none` als `diora-app` an, unsichtbar, weil die
Authelia-Sitzung noch besteht, und holt per CORS Token und userinfo. Daraus leitet sie mit
HKDF-SHA256 (Salt = `sub`, info `diora authelia key v1`) einen AES-GCM-Schlüssel ab:
- Hat das Gerät den Bücher-Schlüssel (und passt er zu einem Buch), wird er damit verschlüsselt
und im Vault `authelia-key` abgelegt, bei jedem Authelia-Login neu. So heilt ein geänderter
`diora_key` von selbst.
- Hat es keinen, öffnet sie den Vault-Eintrag damit und legt den Schlüssel ab.
- Es zählt nur das Authelia-Konto, das mit diesem diora-Konto verknüpft ist (`sub`).
Der diora-Server sieht `diora_key` nie. **Grenze, ehrlich:** Wer Authelia (Hetzner-Root) oder
dein Authelia-Konto (Passwort, Mail-Reset) übernimmt, kommt damit an die Bücher. Wer Strato
übernimmt, kann im Browser ohnehin manipuliertes JS ausliefern. Geschützt ist gegen geleakte
Daten **einer** Seite (diora-DB/Backup ohne Authelia-Nutzerdatei oder umgekehrt). Geht
`diora_key` verloren, sind die Bücher nicht weg: Der Schlüssel kommt weiter aus dem Passwort.
Entschieden 2026-11-01 vom Nutzer (Komfort vor stärkerer Trennung).
### Notweg: Code oder Passwort
Ohne Passwort gibt es keinen Schlüssel. Deshalb erzeugt `getOrCreateEncKey()` **nie mehr
selbst einen neuen Schlüssel**: Früher machte es einen zufälligen, wenn keiner da war, und damit
waren die vorhandenen Bücher auf diesem Gerät verloren. Jetzt fragt es `dioraUnlock()`
(`static/js/app/33-unlock.js`). Es gibt zwei Wege:
1. **Von einem entsperrten Gerät übergeben** (`accounts/keyshare.py`, Modell `KeyShare`). Das
neue Gerät schickt einen ECDH-P-256-Public-Key und zeigt einen 6-stelligen Code, der aus
diesem Key berechnet wird. Entsperrte Geräte fragen alle 15 s
`/accounts/keyshare/pending/` ab und zeigen denselben Code mit „Freigeben/Ablehnen“. Die
Freigabe verschlüsselt den Bücher-Schlüssel mit AES-GCM unter dem gemeinsamen Geheimnis
(HKDF-SHA256, info `diora keyshare v1`). Der Server sieht nur Public Keys und Chiffrat und
gibt die Antwort genau einmal heraus. Anträge laufen nach 10 min ab. Angeboten werden nur
solche, deren Gerät in den letzten 20 s nachgefragt hat, und je Konto höchstens 5, die
ältesten weichen.
2. **Das diora-Passwort einmal eingeben**: dieselbe Ableitung wie beim Login.
In beiden Fällen wird der Schlüssel an einem Buch geprüft, bevor er gespeichert wird. Mit
„Später“ bleiben Bücher gesperrt, und die Bibliothek zeigt einen Knopf „Entsperren“. Die
Einstellungsseite erzeugt ebenfalls keinen Schlüssel mehr.
## Apps
Android und Desktop melden sich bisher nur mit Passwort an (eigene Login-Seiten in
`diora-android/web-native/login.html` und `diora-desktop/src/web/login.js`, die einen
API-Token holen). Mit Authelia melden sie sich selbst als öffentlicher Client `diora-app` an:
1. `GET /accounts/oidc/app/` sagt, wo Authelia ist, gibt die Client-ID und eine vom Server
signierte nonce (10 min gültig).
2. Die App führt den Code-Flow mit PKCE im Systembrowser aus. Redirect:
`xyz.creamfresh.diora:/oauth` bzw. `xyz.creamfresh.diora.reader:/oauth` (Android),
Loopback `http://127.0.0.1:<port>/oauth` (Desktop, jeder Port). Den Code tauscht sie selbst
und liest `diora_key` aus userinfo.
3. `POST /accounts/oidc/app/login/ {id_token}` prüft das Token (`aud` = `diora-app`, nonce) und
gibt den API-Token des verknüpften Kontos heraus (den vorhandenen, kein neuer).
4. Die App holt mit dem Token `/accounts/vault/authelia-key/` und öffnet den Bücher-Schlüssel
wie die Webseite (HKDF, Salt `sub`). Ist dort noch nichts, fragt sie nach dem diora-Passwort.

View file

@ -29,6 +29,8 @@ kept by the server as a blob it can't read:
the key can't be read: the settings ask for it again. Not on diora Reader (e-readers). the key can't be read: the settings ask for it again. Not on diora Reader (e-readers).
- Server-side jobs (in the background, without an app open) were left out on purpose: they - Server-side jobs (in the background, without an app open) were left out on purpose: they
would need the key on the server. The project Radar has its own server key (docs/projects.md). would need the key on the server. The project Radar has its own server key (docs/projects.md).
Podcast articles wait on the server as a wish until a device with the key is open
(docs/transcription.md).
## Building blocks (`static/js/ai.js`, an ES module) ## Building blocks (`static/js/ai.js`, an ES module)
@ -41,6 +43,8 @@ kept by the server as a blob it can't read:
(`output_config.format` json_schema), effort `low` by default, streamed, `fallbacks: "default"` (`output_config.format` json_schema), effort `low` by default, streamed, `fallbacks: "default"`
(beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long (beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long
answers become plain German messages. answers become plain German messages.
- `ask` also takes `onProgress(words)` (words written so far while streaming; OpenRouter is not streamed and reports once at the end)
and `signal` (an `AbortSignal`), both optional; `DioraAI.modelName()` is the model of the chosen provider. Used by the podcast articles.
- `DioraAI.review({title, intro, items, onApply})`: the suggestions with checkboxes — nothing - `DioraAI.review({title, intro, items, onApply})`: the suggestions with checkboxes — nothing
changes until the user ticks and presses "Übernehmen". changes until the user ticks and presses "Übernehmen".
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close - `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
@ -53,5 +57,6 @@ kept by the server as a blob it can't read:
| Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark | | Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark |
| Podcasts | ✦ in Abos (diora-android `web-native/static/native-podcast.js`, copied to `static/podcasts/`) | per subscription: title, author, folders, level, heard in 12 months, publishing interval, three newest episode titles | folders to add (≤ 2), level Stamm / Strom / Auf Abruf (only with a clear reason) | | Podcasts | ✦ in Abos (diora-android `web-native/static/native-podcast.js`, copied to `static/podcasts/`) | per subscription: title, author, folders, level, heard in 12 months, publishing interval, three newest episode titles | folders to add (≤ 2), level Stamm / Strom / Auf Abruf (only with a clear reason) |
| Nachrichten, Blogs | ✦ in the feeds' bar (`static/js/feeds.js`; the apps' section bar presses it) | per source: title, address, kind, Ressort or folders, recent titles shown | Ressort for news, folders for blogs, news ↔ blog when clearly wrong | | Nachrichten, Blogs | ✦ in the feeds' bar (`static/js/feeds.js`; the apps' section bar presses it) | per source: title, address, kind, Ressort or folders, recent titles shown | Ressort for news, folders for blogs, news ↔ blog when clearly wrong |
| Podcast-Artikel | ✦ Artikel in an episode (`static/js/podcast-article.js`, docs/transcription.md) | the episode's transcript (feed or Whisper), title, author, shownotes, chapters | an article from the speakers' standpoint with summary and sections, saved to the episode and to Blogs (no review sheet: it is new text, removable) |
Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand). Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).

View file

@ -182,3 +182,12 @@ Listening without committing: one button plays the Strom like a station.
- Web host (`static/js/podcasts-web.js`, `DioraPodcastHost`): `podcastLaterAdd(json episodes)`, - Web host (`static/js/podcasts-web.js`, `DioraPodcastHost`): `podcastLaterAdd(json episodes)`,
`podcastLaterRemove(json ids)`, `podcastListsSwitch(on)`, `podcastStreamSeen()` (keeps the `podcastLaterRemove(json ids)`, `podcastListsSwitch(on)`, `podcastStreamSeen()` (keeps the
previous time as `state.stream_seen_before` for the marks). previous time as `state.stream_seen_before` for the marks).
## Transcripts
An episode's Transkript tab shows one of two kinds. **From the feed**: `<podcast:transcript>`
(SRT, VTT, JSON, Podlove), fetched and parsed on first use and cached in `EpisodeTranscript`
(`podcasts/transcripts.py`, `GET /podcasts/episodes/<id>/transcript/`). **Made by Whisper**
(since 2026-11): only when the user asks, kept on `TranscriptionJob`; the feed's wins when both
exist. From a transcript the device can write an article that is pinned to the episode and shows
in Blogs. All of that is in docs/transcription.md.

262
docs/transcription.md Normal file
View file

@ -0,0 +1,262 @@
# Smarte Transkription: Transkript und Artikel zu einer Folge
Decided with the user on 2026-11-09. Plan and order of work:
diora-android `plans/smarte-transkription.md`.
## What it is for
Sometimes the shownotes don't tell whether an episode still has something to teach. Then the
user presses a button and, a few hours later at most, gets a readable article written from the
speakers' own standpoint. It is meant to let them judge the episode for themselves, so it keeps
arguments, reasons and evidence and does not just summarise them. The article sits on the
episode and in Blogs.
Nothing happens on its own. The server never transcribes ahead, by cron or on subscribing; the
only way to start a transcription is the `POST` below, sent when the user presses a button.
## Who does what
The **server** transcribes. It already downloads and decodes episodes for skipping silence
(`podcasts/silence.py`), and browsers and the WebView can't fetch podcast audio (CORS). It cuts
the audio at pauses and sends the pieces in parallel to a Whisper instance with an
OpenAI-compatible API (`POST /v1/audio/transcriptions`): the built-in one by default (`DIORA_WHISPER_URL`),
or any instance the user enters. YouTube episodes are
not transcribed: the server never fetches their sound (docs/youtube.md).
The **device** writes the article with the user's own key (docs/ai.md). The key never reaches the
server. It is one LLM call of a few minutes. If diora is open when the transcript is done, it
happens right away; otherwise the next time diora is opened on any of the user's devices. The
server only remembers that an article was wanted.
Transcript and article are stored in plain text on the server, like all podcast and feed data.
## Flow
1. In the episode the user presses "Transkript erstellen" (the Transkript tab) or
"✦ Artikel erstellen" / "✦ Transkribieren und Artikel erstellen". The second sends
`article: true`. With a transcript already there (from the feed or an earlier run) only the
article is written.
2. The server works through the episode (minutes to hours, no GPU on Strato). The UI shows
"Transkribiere 7/38 …" while it is open.
3. Done: a notice for `GET /feeds/notifications/`. Android's BackgroundSync and the desktop show
"Transkript fertig: <Folge>", with "Artikel wird beim Öffnen geschrieben" when one is wanted.
Tapping it opens diora at the episode.
4. A device with a key (`static/js/podcast-article.js`) picks up the open wishes, reserves one,
writes the article and saves it. It then says "Artikel fertig" (on Android as a system
notification through the bridge, `DioraNative.notify(title, text, target)` with target
`episode:<id>` or `item:<id>`; elsewhere a toast) and opens it on a tap.
5. The article is the first tab of the episode ("Artikel") and an item of the favourite blog
"Podcast-Artikel", linking back to the episode.
## API
**Settings.** `GET /podcasts/transcription/settings/` returns
`{engine: "builtin"|"custom", url, model, language, parallel, has_token, builtin_available}`.
`POST` takes the same fields and `token` (write-only; `""` removes it). The token is kept
encrypted with Fernet like `feeds.SiteLogin.secret`. `language` is `""` for automatic or an ISO
code (`de`). `parallel` is 1 to 8, default 3. `builtin_available` is whether `DIORA_WHISPER_URL`
is set. `url` is the instance's base, e.g. `https://whisper.example.org/v1`; the server appends
`/audio/transcriptions`.
An empty `model` is sent as `whisper-1`. The built-in URL is read into `settings.WHISPER_URL` from
`DIORA_WHISPER_URL`. A custom engine needs a `url`; the token is only sent to custom instances.
`POST /podcasts/transcription/test/` sends one second of silence to the configured instance and
returns `{ok, model, error}`. A custom URL goes through the same SSRF check as chapters
(`podcasts/chapters.py` `_open`): no private, loopback or link-local addresses. Only the built-in
URL from `DIORA_WHISPER_URL` is exempt.
**Job.** `POST /podcasts/episodes/<id>/transcribe/` with `{article: true|false}` starts a job, or
updates `want_article` on a running one. With a transcript already there it returns
`{state: "done"}` straight away (and keeps `want_article`). `GET` on the same address returns
`{state, chunks_done, chunks_total, error, want_article, started_at, finished_at}`, with `state`
one of `none`, `queued`, `downloading`, `transcribing`, `done`, `error`. `DELETE` cancels a
queued or running job (its row is deleted, so `GET` then says `none`; a finished one stays). The server runs one job at a time; the others wait as `queued`. A YouTube
episode returns 400 `{error: "youtube"}`, an episode over 800 MB 400 `{error: "too large"}`. The size is asked with a `HEAD`
request (`Content-Length`; if that fails the job starts anyway) and checked again while
downloading, where it ends the job with the error `too large`. `article: true` is only ever
raised, never lowered, by a repeated `POST`; a `POST` after an `error` starts a new job (at the
back of the queue). Jobs run in id order, which is why the notice below can use ids as its cursor.
A feed transcript already there gives a job that is `done` with no `data`: it only holds the wish
for an article and causes no `transcripts` notice.
**Transcript.** The result is kept on the job (`TranscriptionJob.data`) in the format of
`EpisodeTranscript.data`: `{segments: [{start, end, speaker, text}], source: {type: "whisper",
model, language}}`. `speaker` is `""`. It is kept apart from `EpisodeTranscript`, which is a cache
of the feed's transcripts and is rewritten when the feed changes them.
`GET /podcasts/episodes/<id>/transcript/` returns the feed's transcript when the feed has a
usable one, else the Whisper one, else 404 as before. `episode_detail` adds
`transcript_origin` (`"feed"`, `"whisper"` or `null`), `has_transcript` is true for both, and it
adds `article_item_id` (or `null`) and `transcription` (the job's state as above, or `null`).
**Article.** `POST /podcasts/episodes/<id>/article/` takes `{title, summary, body_html, model,
kind}` and returns `{item_id}`. The server cleans `body_html` with `feeds/fetch.py clean_html`.
It creates the user's source "Podcast-Artikel" when missing (`kind` blog, `fav` true,
`synthetic` true, `url` `diora://podcast-artikel`) and adds a `feeds.Item` there, or replaces the
episode's earlier one. Item fields: the episode's link (else its audio URL) as `url`, the feed's
author as `author`, the episode's artwork as `image_url`, now as `published` (so it is on top of
the blog stream under "heute"), `words` counted. Saving clears the wish. `DELETE` on the same
address removes the article and its item. `GET /feeds/items/<id>/` and `_item_data` carry
`episode_id` for such items.
`body_html` may contain `href="#t=<seconds>"` links; `clean_html` keeps exactly this fragment form
(`podcasts/shownotes.py` `TIME_LINK`) and no other `#` links. Validation: `title` 1 to 300
characters, `body_html` non-empty after cleaning and at most 1,000,000 characters, `summary` at most
2000, `model` at most 100, `kind` at most 20 (400 otherwise). The item has `content_full` true (no
page is ever fetched) and, as the reader's furniture filter would strip repeated "▶ ab" blocks,
`GET /feeds/items/<id>/` skips that filter for synthetic sources. The endpoint is
`podcasts/article.py`.
Synthetic sources are skipped by the feed refresh (`refresh_newsfeeds`, `refresh_now`) and by the
pruning of old blog items (`feeds/refresh.py`). They can be hidden or have their bell turned off
like any blog; they can't be refreshed, renamed to another URL or turned into news. `refresh.refresh()` itself drops them, the OPML export leaves them out, and `_source_data` carries `synthetic`.
**Open wishes.** `GET /podcasts/transcription/pending/` returns
`[{episode_id, title, podcast, duration_seconds}]`: the user's episodes whose transcript is done,
that want an article, have none and aren't reserved. `POST
/podcasts/episodes/<id>/article/claim/` reserves one for 15 minutes for the calling device
(200), or answers 409 when another device holds it, so phone and desktop don't both write it.
`DELETE` on the claim releases it (the device gave up, e.g. no key or an error the user saw).
The device names itself with `device` in the JSON body (or, if that is missing, the
`X-Diora-Device` header): a short stable name such as `android`, `desktop` or `browser`, at most 60
characters; a claim without one is 400. The same device claiming again renews its 15 minutes. A
claim on an episode with no open wish (no job done, no article wanted, or an article already
there) is 404. `DELETE` by a device that does not hold the claim is 409.
**Notice.** `GET /feeds/notifications/` additionally takes `t_after` and returns
`transcripts: [{job_id, episode_id, title, podcast, want_article}]` (jobs done after that id) and
`t_last`. Without `t_after` (a device's first time) the list is empty and only `t_last` comes,
like `after`/`last`. Devices that don't know the fields ignore them.
## Transcribing (`podcasts/transcribe.py`)
Download with the same limits as `silence.py`, decode with imageio-ffmpeg to 16 kHz mono. Cut
into pieces of about eight minutes; each cut moves to the longest pause within ±30 seconds
(`silence.py`'s detection), so no word is cut. Pieces go out as FLAC to the built-in instance
and as Opus 32 kbit/s to custom ones, to keep uploads small.
Each piece is sent with `model`, `language` (when set), `response_format=verbose_json` and a
`prompt` of podcast title, episode title and author names, so Whisper spells names right. Pieces
run in a thread pool of `parallel` workers; each is retried up to three times with a growing
pause; progress is written after each. Segment times are shifted by the piece's start; the
segments are joined and smoothed with `transcripts.merge`. A job runs in a daemon thread, like
`silence.ensure()`; one per server. A job found `downloading` or `transcribing` after a restart
is started again. This happens when the first
request (`GET`/`POST` on `/transcribe/` or `/transcription/pending/`) after a start reaches the server.
Cancelling deletes the job's row; the worker notices at the latest after five seconds, stops
sending pieces and removes its temporary files (they live in a temporary directory that is always
removed). Only the worker's main thread writes to the database. The settings used are those of the
episode's owner, read when the job starts. A refusal by the instance with a 4xx status other than
408 and 429 is not retried and ends the job with `Whisper: HTTP <code>`.
## The article (`static/js/podcast-article.js`)
Input: the transcript as plain text, one line per segment group of about 30 seconds,
`[mm:ss] Sprecher: text` (or `[mm:ss] text` without speakers), plus the episode's metadata:
podcast title, author, episode title, date, duration, the shownotes as plain text and the
chapters. Over about 120,000 tokens (more than about nine hours) the device stops with a clear
message for now. A five-hour episode is about 70,000 tokens.
`DioraAI.ask({system, prompt, schema, effort: "medium", maxTokens: 32000, onProgress, signal})`.
`onProgress(words)` reports the words written so far while streaming ("Schreibe Artikel … 2.300
Wörter"); `signal` aborts. Both are optional and new in ai.js; the existing callers don't change.
Schema:
```json
{
"type": "object",
"additionalProperties": false,
"required": ["title", "summary", "kind", "speakers", "sections"],
"properties": {
"title": {"type": "string"},
"summary": {"type": "string"},
"kind": {"type": "string", "enum": ["Interview", "Gespräch", "Monolog", "Diskussion"]},
"speakers": {"type": "array", "items": {"type": "string"}},
"sections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["heading", "start", "body_markdown"],
"properties": {
"heading": {"type": "string"},
"start": {"type": "integer"},
"body_markdown": {"type": "string"}
}
}
}
}
}
```
The HTML: the summary as a lead paragraph, then per section an `<h2>`, below it a link
"▶ ab 12:34" (`<a href="#t=754">`; `clean_html` keeps fragment links, and feeds.js plays the
item's `episode_id` from there when such a link is tapped) and the body. Markdown is limited to paragraphs, emphasis, lists and block
quotes, converted on the device; the server cleans the result anyway.
System prompt (the user's words decide the perspective; keep its substance when changing it):
```text
You turn the transcript of a podcast episode into an article that reads as if the people
speaking had written it themselves instead of recording the episode.
The reader wants to decide whether this episode still has something to teach them. They do not
want a summary; they want to understand what is argued here well enough to judge it for
themselves. Write for that reader.
Perspective. Write from the speakers' standpoint and state their positions directly, as their
own claims. Never distance yourself from them: no "im Podcast wird die These aufgestellt", "der
Sprecher meint", "in dieser Folge geht es um", "die Gäste diskutieren", "laut X". If the episode
is one person speaking, write as that person argues, in the third person or impersonal voice,
but without reporting verbs. Do not add your own opinion, criticism or fact checks, and do not
soften what is said.
Conversations. Interviews, conversations and discussions are condensed into their core theses;
the form of question and answer disappears. Where people hold different positions, keep them
apart and name who holds which ("Zuckermann hält dagegen, dass …"), but do not turn it into
minutes. Questions from a host matter only where they shape the argument. When the transcript
has no speaker names, infer them from the context (introductions, the shownotes, how people
address each other); if you can't, do not invent names.
Substance over summary. Give the arguments with their reasons, examples and evidence, not just
the conclusions. Keep figures, dates, quotations that carry an argument (short, in quotation
marks, attributed), and every work mentioned (books, articles, studies, films) with author and
title. Make clear what is new, distinctive or surprising in the episode and where it restates
what is commonly said, by how you weight and order it, without grading it.
Leave out: advertising and sponsors, appeals for donations, subscriptions or ratings, notes on
the podcast itself (schedules, technology, community), greetings, small talk and digressions that
lead nowhere.
Form. Write in the language of the episode. `title`: a title for the article, not the episode
title repeated. `summary`: three to six sentences up front that state the central thesis and how
the episode gets there. `sections`: as many as the content needs (usually four to ten), each
with a heading that says something (not "Einleitung"), `start` = the second in the episode where
its content begins (from the [mm:ss] marks), and `body_markdown` in plain paragraphs; lists only
where the speakers list things themselves. Follow the order of the episode unless a different
order makes the argument clearer. Length: as long as needed for the reader to judge the episode,
roughly a tenth of the transcript, at most about 5,000 words.
The transcript was made by speech recognition: fix obvious mishearings, especially of names,
using the metadata, and don't quote garbled passages.
```
Prompt (user turn): the metadata block, then `Transkript:` and the text.
## The built-in instance (since 2026-11-09)
Whisper does not fit on the Strato server: with about 2 GB of memory free, large-v3-turbo needed
more than 2.2 GB and even `small` broke a 1.2 GB limit, and earlyoom then killed other services.
So the built-in instance runs on the user's PC (multi11, GTX 1660 Ti) and diora-prod reaches it over
the tailnet: `DIORA_WHISPER_URL=http://100.64.0.3:8766/v1` in `~/docker-compose/diora-prod/.env`.
It is `deploy/whisper/server.py` (a small OpenAI-compatible server around faster-whisper,
large-v3-turbo, int8_float16 on CUDA; one request at a time; the model is loaded on demand and freed
after 10 idle minutes), started at logon from `F:\diora-whisper` (start.cmd via start-hidden.vbs,
shortcut in the user's Startup folder; log `F:\diora-whisper\server.log`), listening only on the
tailnet address. One hour of speech takes about five minutes.
When the PC is off, a job does not fail: the server checks the instance before downloading, and a
connection error (there or while pieces are out) puts the job back into the queue with
`error = "Whisper-Instanz nicht erreichbar, neuer Versuch um HH:MM"`, tried again every ten
minutes (`WAIT_RETRY`) for up to two days after the button was pressed (`WAIT_MAX`). Only the
built-in instance waits like this; a custom instance's connection errors fail the job as before.

View file

@ -0,0 +1,129 @@
"""A small PDF with the cases the PDF reader has to get right (e2e and by hand).
python e2e/fixtures/make_test_pdf.py e2e/fixtures/test.pdf
12 pages: A4 portrait text pages, one A5 page and one A4 landscape page (mixed
sizes), page 7 nearly empty (only a page number: the smart crop must not zoom
it to infinity), an outline (table of contents) with three entries, internal
links from page 1 to pages 5 and 9 and one external link, a coloured picture on
page 2 (it must stay as it is in the reader's colours) and page labels like a
book's: i, ii for the first two pages, then 1, 2, 3 … Plain PDF 1.4, Helvetica,
no dependencies.
"""
import sys
A4 = (595, 842)
A5 = (420, 595)
A4L = (842, 595)
WORDS = ('Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. '
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. ').split()
def esc(t):
return t.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
def text_lines(n, words_per_line=11):
out, i = [], 0
for _ in range(n):
out.append(' '.join(WORDS[(i + k) % len(WORDS)] for k in range(words_per_line)))
i += words_per_line
return out
def page_content(no, size):
w, h = size
ops = ['BT', '/F1 22 Tf', f'50 {h - 70} Td', f'(Kapitel {no} - Seite {no}) Tj', 'ET']
if no == 7:
# Nearly empty: just the number at the bottom.
return '\n'.join(['BT', '/F1 10 Tf', f'{w / 2 - 10:.0f} 30 Td', f'({no}) Tj', 'ET'])
y = h - 110
lines = text_lines(28 if h > 700 else 18, 11 if w > 500 else 8)
ops += ['BT', '/F1 11 Tf', f'50 {y} Td', '14 TL']
for ln in lines:
ops.append(f'({esc(ln)}) Tj T*')
ops.append('ET')
if no == 2:
ops += ['q', f'200 0 0 120 {w - 260} 60 cm', '/Im1 Do', 'Q']
if no == 1:
ops += ['BT', '/F1 12 Tf', f'50 {y - 30 - 14 * len(lines)} Td',
'(Link zu Seite 5) Tj', '0 -20 Td', '(Link zu Seite 9) Tj', '0 -20 Td', '(Externer Link: example.org) Tj', 'ET']
ops += ['BT', '/F1 10 Tf', f'{w / 2 - 10:.0f} 30 Td', f'({no}) Tj', 'ET']
return '\n'.join(ops)
def build(path):
sizes = [A4] * 12
sizes[3] = A5 # page 4
sizes[8] = A4L # page 9
objs = [] # index = object number - 1
def add(body):
objs.append(body)
return len(objs)
catalog = add(None)
pages_obj = add(None)
font = add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>')
# A picture of four colours (2 x 2 pixels, drawn large).
pixels = b'FF0000 00A000 0000FF FFD700>'
image = add(b'<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB '
b'/BitsPerComponent 8 /Filter /ASCIIHexDecode /Length %d >>\nstream\n' % len(pixels)
+ pixels + b'\nendstream')
page_ids = []
for i, size in enumerate(sizes):
content = page_content(i + 1, size).encode('cp1252')
cid = add(b'<< /Length %d >>\nstream\n' % len(content) + content + b'\nendstream')
pid = add(None)
page_ids.append((pid, cid, size))
# links on page 1 (placed where the text is: bottom part of the text block)
p1_h = sizes[0][1]
base_y = p1_h - 110 - 30 - 14 * 28
def link_to(page_no, y):
return add(f'<< /Type /Annot /Subtype /Link /Rect [48 {y - 4} 220 {y + 14}] /Border [0 0 0] '
f'/Dest [{page_ids[page_no - 1][0]} 0 R /XYZ 0 {sizes[page_no - 1][1]} null] >>')
annots = [link_to(5, base_y), link_to(9, base_y - 20),
add(f'<< /Type /Annot /Subtype /Link /Rect [48 {base_y - 44} 300 {base_y - 26}] /Border [0 0 0] '
f'/A << /S /URI /URI (https://example.org/) >> >>')]
for n, (pid, cid, (w, h)) in enumerate(page_ids):
extra = f' /Annots [{" ".join(f"{a} 0 R" for a in annots)}]' if n == 0 else ''
xobj = f' /XObject << /Im1 {image} 0 R >>' if n == 1 else ''
objs[pid - 1] = (f'<< /Type /Page /Parent {pages_obj} 0 R /MediaBox [0 0 {w} {h}] '
f'/Resources << /Font << /F1 {font} 0 R >>{xobj} >> /Contents {cid} 0 R{extra} >>')
objs[pages_obj - 1] = f'<< /Type /Pages /Kids [{" ".join(f"{p} 0 R" for p, _, _ in page_ids)}] /Count {len(page_ids)} >>'
# outline: three entries
outline = add(None)
entries = [('Anfang', 1), ('Querformat', 9), ('Fast leer', 7)]
ids = [add(None) for _ in entries]
for k, ((title, page), oid) in enumerate(zip(entries, ids)):
nav = ''
if k > 0:
nav += f' /Prev {ids[k - 1]} 0 R'
if k < len(ids) - 1:
nav += f' /Next {ids[k + 1]} 0 R'
objs[oid - 1] = (f'<< /Title ({esc(title)}) /Parent {outline} 0 R{nav} '
f'/Dest [{page_ids[page - 1][0]} 0 R /XYZ 0 {sizes[page - 1][1]} null] >>')
objs[outline - 1] = f'<< /Type /Outlines /First {ids[0]} 0 R /Last {ids[-1]} 0 R /Count {len(ids)} >>'
objs[catalog - 1] = (f'<< /Type /Catalog /Pages {pages_obj} 0 R /Outlines {outline} 0 R /PageMode /UseOutlines '
f'/PageLabels << /Nums [0 << /S /r >> 2 << /S /D >>] >> >>')
info = add('<< /Title (Testdokument PDF) /Author (diora Test) >>')
out = bytearray(b'%PDF-1.4\n%\xe2\xe3\xcf\xd3\n')
offsets = []
for i, body in enumerate(objs):
offsets.append(len(out))
data = body if isinstance(body, bytes) else body.encode('cp1252')
out += b'%d 0 obj\n' % (i + 1) + data + b'\nendobj\n'
xref = len(out)
out += b'xref\n0 %d\n0000000000 65535 f \n' % (len(objs) + 1)
for off in offsets:
out += b'%010d 00000 n \n' % off
out += (b'trailer\n<< /Size %d /Root %d 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n'
% (len(objs) + 1, catalog, info, xref))
open(path, 'wb').write(out)
if __name__ == '__main__':
build(sys.argv[1] if len(sys.argv) > 1 else 'test.pdf')

652
e2e/fixtures/test.pdf Normal file
View file

@ -0,0 +1,652 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<< /Type /Catalog /Pages 2 0 R /Outlines 32 0 R /PageMode /UseOutlines /PageLabels << /Nums [0 << /S /r >> 2 << /S /D >>] >> >>
endobj
2 0 obj
<< /Type /Pages /Kids [6 0 R 8 0 R 10 0 R 12 0 R 14 0 R 16 0 R 18 0 R 20 0 R 22 0 R 24 0 R 26 0 R 28 0 R] /Count 12 >>
endobj
3 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
endobj
4 0 obj
<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 28 >>
stream
FF0000 00A000 0000FF FFD700>
endstream
endobj
5 0 obj
<< /Length 2463 >>
stream
BT
/F1 22 Tf
50 772 Td
(Kapitel 1 - Seite 1) Tj
ET
BT
/F1 11 Tf
50 732 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
ET
BT
/F1 12 Tf
50 310 Td
(Link zu Seite 5) Tj
0 -20 Td
(Link zu Seite 9) Tj
0 -20 Td
(Externer Link: example.org) Tj
ET
BT
/F1 10 Tf
288 30 Td
(1) Tj
ET
endstream
endobj
6 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R /Annots [29 0 R 30 0 R 31 0 R] >>
endobj
7 0 obj
<< /Length 2379 >>
stream
BT
/F1 22 Tf
50 772 Td
(Kapitel 2 - Seite 2) Tj
ET
BT
/F1 11 Tf
50 732 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
ET
q
200 0 0 120 335 60 cm
/Im1 Do
Q
BT
/F1 10 Tf
288 30 Td
(2) Tj
ET
endstream
endobj
8 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 4 0 R >> >> /Contents 7 0 R >>
endobj
9 0 obj
<< /Length 2345 >>
stream
BT
/F1 22 Tf
50 772 Td
(Kapitel 3 - Seite 3) Tj
ET
BT
/F1 11 Tf
50 732 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
ET
BT
/F1 10 Tf
288 30 Td
(3) Tj
ET
endstream
endobj
10 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 9 0 R >>
endobj
11 0 obj
<< /Length 1196 >>
stream
BT
/F1 22 Tf
50 525 Td
(Kapitel 4 - Seite 4) Tj
ET
BT
/F1 11 Tf
50 485 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
ET
BT
/F1 10 Tf
200 30 Td
(4) Tj
ET
endstream
endobj
12 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 11 0 R >>
endobj
13 0 obj
<< /Length 2345 >>
stream
BT
/F1 22 Tf
50 772 Td
(Kapitel 5 - Seite 5) Tj
ET
BT
/F1 11 Tf
50 732 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
ET
BT
/F1 10 Tf
288 30 Td
(5) Tj
ET
endstream
endobj
14 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 13 0 R >>
endobj
15 0 obj
<< /Length 2345 >>
stream
BT
/F1 22 Tf
50 772 Td
(Kapitel 6 - Seite 6) Tj
ET
BT
/F1 11 Tf
50 732 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
ET
BT
/F1 10 Tf
288 30 Td
(6) Tj
ET
endstream
endobj
16 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 15 0 R >>
endobj
17 0 obj
<< /Length 32 >>
stream
BT
/F1 10 Tf
288 30 Td
(7) Tj
ET
endstream
endobj
18 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 17 0 R >>
endobj
19 0 obj
<< /Length 2345 >>
stream
BT
/F1 22 Tf
50 772 Td
(Kapitel 8 - Seite 8) Tj
ET
BT
/F1 11 Tf
50 732 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
ET
BT
/F1 10 Tf
288 30 Td
(8) Tj
ET
endstream
endobj
20 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 19 0 R >>
endobj
21 0 obj
<< /Length 1547 >>
stream
BT
/F1 22 Tf
50 525 Td
(Kapitel 9 - Seite 9) Tj
ET
BT
/F1 11 Tf
50 485 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
ET
BT
/F1 10 Tf
411 30 Td
(9) Tj
ET
endstream
endobj
22 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 21 0 R >>
endobj
23 0 obj
<< /Length 2348 >>
stream
BT
/F1 22 Tf
50 772 Td
(Kapitel 10 - Seite 10) Tj
ET
BT
/F1 11 Tf
50 732 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
ET
BT
/F1 10 Tf
288 30 Td
(10) Tj
ET
endstream
endobj
24 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 23 0 R >>
endobj
25 0 obj
<< /Length 2348 >>
stream
BT
/F1 22 Tf
50 772 Td
(Kapitel 11 - Seite 11) Tj
ET
BT
/F1 11 Tf
50 732 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
ET
BT
/F1 10 Tf
288 30 Td
(11) Tj
ET
endstream
endobj
26 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 25 0 R >>
endobj
27 0 obj
<< /Length 2348 >>
stream
BT
/F1 22 Tf
50 772 Td
(Kapitel 12 - Seite 12) Tj
ET
BT
/F1 11 Tf
50 732 Td
14 TL
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
ET
BT
/F1 10 Tf
288 30 Td
(12) Tj
ET
endstream
endobj
28 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 27 0 R >>
endobj
29 0 obj
<< /Type /Annot /Subtype /Link /Rect [48 306 220 324] /Border [0 0 0] /Dest [14 0 R /XYZ 0 842 null] >>
endobj
30 0 obj
<< /Type /Annot /Subtype /Link /Rect [48 286 220 304] /Border [0 0 0] /Dest [22 0 R /XYZ 0 595 null] >>
endobj
31 0 obj
<< /Type /Annot /Subtype /Link /Rect [48 266 300 284] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>
endobj
32 0 obj
<< /Type /Outlines /First 33 0 R /Last 35 0 R /Count 3 >>
endobj
33 0 obj
<< /Title (Anfang) /Parent 32 0 R /Next 34 0 R /Dest [6 0 R /XYZ 0 842 null] >>
endobj
34 0 obj
<< /Title (Querformat) /Parent 32 0 R /Prev 33 0 R /Next 35 0 R /Dest [22 0 R /XYZ 0 595 null] >>
endobj
35 0 obj
<< /Title (Fast leer) /Parent 32 0 R /Prev 34 0 R /Dest [18 0 R /XYZ 0 842 null] >>
endobj
36 0 obj
<< /Title (Testdokument PDF) /Author (diora Test) >>
endobj
xref
0 37
0000000000 65535 f
0000000015 00000 n
0000000158 00000 n
0000000292 00000 n
0000000389 00000 n
0000000584 00000 n
0000003099 00000 n
0000003256 00000 n
0000005687 00000 n
0000005839 00000 n
0000008236 00000 n
0000008363 00000 n
0000009612 00000 n
0000009740 00000 n
0000012138 00000 n
0000012266 00000 n
0000014664 00000 n
0000014792 00000 n
0000014875 00000 n
0000015003 00000 n
0000017401 00000 n
0000017529 00000 n
0000019129 00000 n
0000019257 00000 n
0000021658 00000 n
0000021786 00000 n
0000024187 00000 n
0000024315 00000 n
0000026716 00000 n
0000026844 00000 n
0000026964 00000 n
0000027084 00000 n
0000027218 00000 n
0000027292 00000 n
0000027388 00000 n
0000027502 00000 n
0000027602 00000 n
trailer
<< /Size 37 /Root 1 0 R /Info 36 0 R >>
startxref
27671
%%EOF

View file

@ -13,7 +13,7 @@ class Command(BaseCommand):
parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)') parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
def handle(self, *args, **options): def handle(self, *args, **options):
qs = Source.objects.select_related('user').order_by('last_refreshed_at') qs = Source.objects.filter(synthetic=False).select_related('user').order_by('last_refreshed_at')
if options['source_id']: if options['source_id']:
qs = qs.filter(pk=options['source_id']) qs = qs.filter(pk=options['source_id'])
else: else:

View file

@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-11-09 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feeds', '0006_folder_order'),
]
operations = [
migrations.AddField(
model_name='source',
name='synthetic',
field=models.BooleanField(default=False),
),
]

View file

@ -35,6 +35,9 @@ class Source(models.Model):
fav = models.BooleanField(default=False) fav = models.BooleanField(default=False)
hidden = models.BooleanField(default=False) hidden = models.BooleanField(default=False)
notify = models.BooleanField(default=False) notify = models.BooleanField(default=False)
# Filled by diora itself (the blog "Podcast-Artikel"), not by a feed: never
# fetched, pruned, given another URL or turned into news.
synthetic = models.BooleanField(default=False)
# Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed). # Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed).
last_refreshed_at = models.DateTimeField(null=True, blank=True) last_refreshed_at = models.DateTimeField(null=True, blank=True)

View file

@ -216,7 +216,7 @@ def expire(user, now=None):
Item.objects.filter(user=user, gone=True, published__lt=now - timedelta(days=GONE_KEEP_DAYS)).delete() Item.objects.filter(user=user, gone=True, published__lt=now - timedelta(days=GONE_KEEP_DAYS)).delete()
Story.objects.filter(user=user).exclude(items__gone=False).delete() Story.objects.filter(user=user).exclude(items__gone=False).delete()
# Blogs: the oldest read articles beyond BLOG_KEEP per blog. # Blogs: the oldest read articles beyond BLOG_KEEP per blog.
for source in Source.objects.filter(user=user, kind=Source.BLOG): for source in Source.objects.filter(user=user, kind=Source.BLOG, synthetic=False):
ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:]) ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
if ids: if ids:
Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete() Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete()
@ -225,7 +225,7 @@ def expire(user, now=None):
def refresh(sources, deadline=None): def refresh(sources, deadline=None):
"""Refresh `sources` (downloads in parallel, writes one by one). """Refresh `sources` (downloads in parallel, writes one by one).
Returns (refreshed, failed, new items).""" Returns (refreshed, failed, new items)."""
sources = list(sources) sources = [s for s in sources if not s.synthetic] # synthetic sources have no feed to fetch
now = timezone.now() now = timezone.now()
keep = {} keep = {}
refreshed = failed = 0 refreshed = failed = 0

View file

@ -539,3 +539,56 @@ class NewsTextApiTest(TestCase):
self.assertEqual(got['related'], []) self.assertEqual(got['related'], [])
single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json() single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json()
self.assertNotIn('summary', single) self.assertNotIn('summary', single)
class SyntheticSourceTest(TestCase):
"""A source diora fills itself ("Podcast-Artikel") is never fetched or pruned."""
def setUp(self):
self.u = User.objects.create_user('tester', password='pw12345678')
self.client.force_login(self.u)
self.synth = Source.objects.create(user=self.u, url='diora://podcast-artikel', title='Podcast-Artikel',
kind='blog', fav=True, synthetic=True)
self.real = Source.objects.create(user=self.u, url='https://real.example/feed', title='Real', kind='blog')
def post(self, url, data):
return self.client.post(url, json.dumps(data), content_type='application/json')
def test_refresh_skips_it(self):
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([]), 'https://real.example/feed')) as get:
refresh.refresh([self.synth, self.real])
self.assertEqual([c.args[0] for c in get.call_args_list], ['https://real.example/feed'])
def test_refresh_now_and_command_skip_it(self):
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([]), 'https://real.example/feed')) as get:
r = self.post('/feeds/refresh/', {})
from django.core.management import call_command
call_command('refresh_newsfeeds', stdout=mock.MagicMock())
self.assertEqual(r.status_code, 200)
self.assertTrue(get.call_args_list)
self.assertTrue(all(c.args[0] == 'https://real.example/feed' for c in get.call_args_list))
call_command('refresh_newsfeeds', '--source-id', str(self.synth.id), stdout=mock.MagicMock())
def test_old_items_are_not_pruned(self):
old = timezone.now() - timedelta(days=400)
for source in (self.synth, self.real):
for n in range(refresh.BLOG_KEEP + 5):
Item.objects.create(source=source, user=self.u, kind='blog', guid_hash=f'{source.pk}-{n}',
title='x', published=old - timedelta(minutes=n), read_at=old)
refresh.expire(self.u)
self.assertEqual(self.synth.items.count(), refresh.BLOG_KEEP + 5)
self.assertEqual(self.real.items.count(), refresh.BLOG_KEEP)
def test_cannot_become_news_but_can_be_hidden(self):
r = self.post(f'/feeds/sources/{self.synth.id}/', {'kind': 'news'})
self.assertEqual(r.status_code, 400)
r = self.post(f'/feeds/sources/{self.synth.id}/', {'hidden': True, 'notify': True})
self.assertEqual(r.status_code, 200)
self.synth.refresh_from_db()
self.assertEqual((self.synth.kind, self.synth.hidden, self.synth.notify), ('blog', True, True))
self.assertTrue(r.json()['source']['synthetic'])
def test_not_in_opml(self):
body = self.client.get('/feeds/export.opml').content.decode()
self.assertIn('real.example', body)
self.assertNotIn('diora://', body)

View file

@ -93,7 +93,7 @@ def _source_data(s, unread=0, new=0, interval=None):
'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url, 'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url,
'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext, 'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext,
'unread': unread, 'new': new, 'unread': unread, 'new': new,
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, 'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, 'synthetic': s.synthetic,
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare. # Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
'interval_days': interval, 'interval_days': interval,
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count, 'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
@ -108,6 +108,11 @@ def _item_data(i, content=False):
'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'progress': i.progress, 'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'progress': i.progress,
'starred': i.starred, 'story': i.story_id, 'starred': i.starred, 'story': i.story_id,
} }
if i.source.synthetic:
# The article of a podcast episode (docs/transcription.md); the lists fetch it with the item.
art = getattr(i, 'episode_article', None)
if art is not None:
d['episode_id'] = art.episode_id
if content: if content:
d['content'] = i.content d['content'] = i.content
d['content_full'] = i.content_full d['content_full'] = i.content_full
@ -356,7 +361,7 @@ def add_sources(request):
defaults=_place(request.user, kind, x.get('category'), x.get('folders'), defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
title=(x.get('title') or url)[:300])) title=(x.get('title') or url)[:300]))
added += created added += created
sources = Source.objects.filter(user=request.user, last_refreshed_at=None) sources = Source.objects.filter(user=request.user, last_refreshed_at=None, synthetic=False)
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT) refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
return JsonResponse({'added': added}) return JsonResponse({'added': added})
@ -386,6 +391,8 @@ def edit_source(request, pk):
if body.get('fulltext') in dict(Source.FULLTEXTS): if body.get('fulltext') in dict(Source.FULLTEXTS):
source.fulltext = body['fulltext'] source.fulltext = body['fulltext']
new_kind = _kind(body.get('kind')) new_kind = _kind(body.get('kind'))
if source.synthetic and new_kind == Source.NEWS:
return JsonResponse({'error': 'Diese Quelle bleibt ein Blog'}, status=400)
if new_kind and new_kind != source.kind: if new_kind and new_kind != source.kind:
# A section becomes a folder and back. # A section becomes a folder and back.
if new_kind == Source.BLOG and source.category and not source.folders: if new_kind == Source.BLOG and source.category and not source.folders:
@ -416,7 +423,7 @@ def export_opml(request):
if (err := _auth(request)): if (err := _auth(request)):
return err return err
groups = {} groups = {}
for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'): for s in Source.objects.filter(user=request.user, synthetic=False).order_by('kind', 'category', 'title'):
kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs' kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs'
place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0]) place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0])
label = kind + (f' – {place}' if place else '') label = kind + (f' – {place}' if place else '')
@ -440,7 +447,7 @@ def export_opml(request):
def refresh_now(request): def refresh_now(request):
if (err := _auth(request)): if (err := _auth(request)):
return err return err
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user), refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user, synthetic=False),
deadline=time.monotonic() + REFRESH_WAIT) deadline=time.monotonic() + REFRESH_WAIT)
return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new}) return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new})
@ -611,7 +618,7 @@ def story_overview(request, pk):
def blogs(request): def blogs(request):
if (err := _auth(request)): if (err := _auth(request)):
return err return err
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source') qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source', 'episode_article')
if request.GET.get('source', '').isdigit(): if request.GET.get('source', '').isdigit():
qs = qs.filter(source_id=int(request.GET['source'])) qs = qs.filter(source_id=int(request.GET['source']))
elif 'folder' in request.GET: elif 'folder' in request.GET:
@ -640,7 +647,7 @@ def blogs(request):
def starred(request): def starred(request):
if (err := _auth(request)): if (err := _auth(request)):
return err return err
items = Item.objects.filter(user=request.user, starred=True).select_related('source').order_by('-published') items = Item.objects.filter(user=request.user, starred=True).select_related('source', 'episode_article').order_by('-published')
return JsonResponse({'items': [_item_data(i) for i in items[:200]]}) return JsonResponse({'items': [_item_data(i) for i in items[:200]]})
@ -649,13 +656,14 @@ def starred(request):
def item(request, pk): def item(request, pk):
if (err := _auth(request)): if (err := _auth(request)):
return err return err
i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source').first() i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source', 'episode_article').first()
if i is None: if i is None:
return JsonResponse({'error': 'not found'}, status=404) return JsonResponse({'error': 'not found'}, status=404)
if refresh.needs_article(i, i.source): if refresh.needs_article(i, i.source):
refresh.load_article(i) refresh.load_article(i)
d = _item_data(i, content=True) d = _item_data(i, content=True)
d['content'] = textproc.html_without_furniture(i.content, _furniture(i.source)) if not i.source.synthetic:
d['content'] = textproc.html_without_furniture(i.content, _furniture(i.source))
return JsonResponse({'item': d}) return JsonResponse({'item': d})
@ -744,17 +752,32 @@ def star(request, pk):
def notifications(request): def notifications(request):
"""What a device should announce: posts marked when they came in, still """What a device should announce: posts marked when they came in, still
unread, of blogs that still have the bell on, after the id the device saw unread, of blogs that still have the bell on, after the id the device saw
last. Without `after` (a device's first time) nothing, only where to start.""" last. Without `after` (a device's first time) nothing, only where to start.
The same for finished transcripts (`t_after`, `transcripts`, `t_last`; jobs the
user's episodes have, docs/transcription.md)."""
if (err := _auth(request)): if (err := _auth(request)):
return err return err
qs = Item.objects.filter(user=request.user, notify=True) qs = Item.objects.filter(user=request.user, notify=True)
last = qs.order_by('-id').values_list('id', flat=True).first() or 0 last = qs.order_by('-id').values_list('id', flat=True).first() or 0
# Jobs run in id order, one at a time, so a job never finishes below an id already seen.
# (A job that is done with no data only reflects a feed transcript: no notice for it.)
from podcasts.models import TranscriptionJob
jobs = TranscriptionJob.objects.filter(
episode__feed__user=request.user, state=TranscriptionJob.DONE, data__isnull=False)
t_last = jobs.order_by('-id').values_list('id', flat=True).first() or 0
out = {'items': [], 'last': last, 'transcripts': [], 't_last': t_last}
t_after = request.GET.get('t_after', '')
if t_after.isdigit():
out['transcripts'] = [{
'job_id': j.id, 'episode_id': j.episode_id, 'title': j.episode.title,
'podcast': j.episode.feed.title, 'want_article': j.want_article,
} for j in jobs.filter(id__gt=int(t_after)).select_related('episode__feed').order_by('id')[:50]]
after = request.GET.get('after', '') after = request.GET.get('after', '')
if not after.isdigit(): if after.isdigit():
return JsonResponse({'items': [], 'last': last}) items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True) .select_related('source').order_by('-published')[:50])
.select_related('source').order_by('-published')[:50]) out['items'] = [_item_data(i) for i in items]
return JsonResponse({'items': [_item_data(i) for i in items], 'last': last}) return JsonResponse(out)
@csrf_exempt @csrf_exempt
@ -770,6 +793,8 @@ def save_login(request):
password = body.get('password') or '' password = body.get('password') or ''
if source is None or not username or not password: if source is None or not username or not password:
return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400) return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400)
if source.synthetic:
return JsonResponse({'error': 'Diese Quelle hat keine Adresse'}, status=400)
try: try:
adapter = logins.check_and_store(request.user, source.url, username, password) adapter = logins.check_and_store(request.user, source.url, username, password)
except logins.LoginFailed as e: except logins.LoginFailed as e:

119
podcasts/article.py Normal file
View file

@ -0,0 +1,119 @@
"""The article written from an episode's transcript (docs/transcription.md): its text
is a feeds.Item in the user's favourite blog "Podcast-Artikel"."""
import hashlib
import json
from django.db import transaction
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from feeds import fetch
from feeds.models import Item, Source
from .models import EpisodeArticle, PodcastEpisode, TranscriptionJob
SOURCE_URL = 'diora://podcast-artikel'
SOURCE_TITLE = 'Podcast-Artikel'
TITLE_MAX = 300
SUMMARY_MAX = 2000
BODY_MAX = 1_000_000 # characters of body_html
MODEL_MAX = 100
KIND_MAX = 20
def _auth(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return None
def _body(request):
try:
body = json.loads(request.body or b'{}')
except (ValueError, UnicodeDecodeError):
return None
return body if isinstance(body, dict) else None
def article_source(user):
"""The user's blog "Podcast-Artikel", made on first use."""
source, _ = Source.objects.get_or_create(user=user, url=SOURCE_URL, defaults={
'title': SOURCE_TITLE, 'kind': Source.BLOG, 'fav': True, 'synthetic': True})
return source
def _text(value, limit):
return value.strip() if isinstance(value, str) and len(value.strip()) <= limit else None
@csrf_exempt
@require_http_methods(['POST', 'DELETE'])
def episode_article(request, pk):
if (err := _auth(request)):
return err
ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).select_related('feed').first()
if ep is None:
return JsonResponse({'error': 'not found'}, status=404)
if request.method == 'DELETE':
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
if art is not None:
art.item.delete() # the article goes with it (cascade)
return JsonResponse({'ok': True})
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
title = _text(body.get('title'), TITLE_MAX)
if not title:
return JsonResponse({'error': 'title needed (at most %d characters)' % TITLE_MAX}, status=400)
html_in = body.get('body_html')
if not isinstance(html_in, str) or not html_in.strip():
return JsonResponse({'error': 'body_html needed'}, status=400)
if len(html_in) > BODY_MAX:
return JsonResponse({'error': 'body_html too large'}, status=400)
summary = body.get('summary') or ''
if not isinstance(summary, str) or len(summary) > SUMMARY_MAX:
return JsonResponse({'error': 'summary too long'}, status=400)
model = body.get('model') or ''
kind = body.get('kind') or ''
if not isinstance(model, str) or len(model) > MODEL_MAX or not isinstance(kind, str) or len(kind) > KIND_MAX:
return JsonResponse({'error': 'model or kind too long'}, status=400)
url = getattr(ep, 'link', '') or ep.audio_url
content = fetch.clean_html(html_in, url)
if not fetch.plain(content):
return JsonResponse({'error': 'body_html empty'}, status=400)
fields = {
'url': url[:2000],
'title': title,
'author': (ep.feed.author or '')[:300],
'published': timezone.now(),
'summary': fetch.plain(summary),
'content': content,
'content_full': True, # nothing to fetch from a page
'words': fetch.word_count(content),
'image_url': (ep.artwork_url or ep.feed.artwork_url or '')[:2000],
'read_at': None, 'progress': 0, 'progress_at': None,
}
with transaction.atomic():
source = article_source(request.user)
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
if art is not None:
item = art.item
for k, v in fields.items():
setattr(item, k, v)
item.source = source
item.save()
art.model, art.kind = model, kind
art.save(update_fields=['model', 'kind'])
else:
guid = hashlib.sha256(f'diora-article:{ep.pk}'.encode()).hexdigest()
item = Item.objects.create(source=source, user=request.user, kind=Source.BLOG,
guid_hash=guid, **fields)
EpisodeArticle.objects.create(episode=ep, item=item, model=model, kind=kind)
# Saving fulfils the wish and frees the reservation.
TranscriptionJob.objects.filter(episode=ep).update(want_article=False, claimed_by='', claimed_until=None)
return JsonResponse({'item_id': item.id})

View file

@ -128,25 +128,28 @@ FetchUnavailable = ChaptersUnavailable
FetchInvalid = ChaptersInvalid FetchInvalid = ChaptersInvalid
def _open(url, headers): def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs):
"""GET `url` with every redirect hop checked; the streamed response of """`method` (GET or POST) `url` with every redirect hop checked; the streamed response of
the final hop, which the caller has to close. the final hop, which the caller has to close. `kwargs` (data, files) go to
requests; a POST only follows 307/308, which keep the method and body.
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
ones that may go away (network trouble, server errors). ones that may go away (network trouble, server errors).
""" """
redirects = (301, 302, 303, 307, 308) if method == 'GET' else (307, 308)
for _ in range(CHAPTERS_MAX_REDIRECTS + 1): for _ in range(CHAPTERS_MAX_REDIRECTS + 1):
try: try:
assert_safe_url(url) assert_safe_url(url)
except WebDAVInputError as e: except WebDAVInputError as e:
raise ChaptersInvalid(str(e)) raise ChaptersInvalid(str(e))
try: try:
r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers) send = requests.get if method == 'GET' else requests.post
r = send(url, timeout=timeout, allow_redirects=False, stream=True, headers=headers, **kwargs)
except requests.RequestException as e: except requests.RequestException as e:
raise ChaptersUnavailable(e.__class__.__name__) raise ChaptersUnavailable(e.__class__.__name__)
try: try:
_assert_peer_is_safe(r) _assert_peer_is_safe(r)
if r.status_code in (301, 302, 303, 307, 308): if r.status_code in redirects:
location = r.headers.get('Location') location = r.headers.get('Location')
if not location: if not location:
raise ChaptersInvalid('redirect without a location') raise ChaptersInvalid('redirect without a location')
@ -155,7 +158,7 @@ def _open(url, headers):
continue continue
if r.status_code >= 500 or r.status_code == 429: if r.status_code >= 500 or r.status_code == 429:
raise ChaptersUnavailable(f'HTTP {r.status_code}') raise ChaptersUnavailable(f'HTTP {r.status_code}')
if r.status_code >= 400: if r.status_code >= 400 or r.status_code in (301, 302, 303):
raise ChaptersInvalid(f'HTTP {r.status_code}') raise ChaptersInvalid(f'HTTP {r.status_code}')
except WebDAVInputError as e: except WebDAVInputError as e:
r.close() r.close()

View file

@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-11-09 08:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0026_podcastepisode_sponsor'),
]
operations = [
migrations.AddField(
model_name='podcastepisode',
name='link',
field=models.URLField(blank=True, max_length=1000),
),
]

View file

@ -0,0 +1,59 @@
# Generated by Django 6.1 on 2026-11-09 08:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feeds', '0007_transcription'),
('podcasts', '0027_podcastepisode_link'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EpisodeArticle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model', models.CharField(blank=True, max_length=100)),
('kind', models.CharField(blank=True, max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='article', to='podcasts.podcastepisode')),
('item', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='episode_article', to='feeds.item')),
],
),
migrations.CreateModel(
name='TranscriptionJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.CharField(choices=[('none', 'none'), ('queued', 'queued'), ('downloading', 'downloading'), ('transcribing', 'transcribing'), ('done', 'done'), ('error', 'error')], default='none', max_length=15)),
('chunks_done', models.PositiveIntegerField(default=0)),
('chunks_total', models.PositiveIntegerField(default=0)),
('error', models.CharField(blank=True, max_length=500)),
('want_article', models.BooleanField(default=False)),
('claimed_by', models.CharField(blank=True, max_length=60)),
('claimed_until', models.DateTimeField(blank=True, null=True)),
('data', models.JSONField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('started_at', models.DateTimeField(blank=True, null=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_job', to='podcasts.podcastepisode')),
],
),
migrations.CreateModel(
name='TranscriptionSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('engine', models.CharField(choices=[('builtin', 'Eingebaut'), ('custom', 'Eigene Instanz')], default='builtin', max_length=10)),
('url', models.CharField(blank=True, max_length=500)),
('token_encrypted', models.TextField(blank=True)),
('model', models.CharField(blank=True, max_length=100)),
('language', models.CharField(blank=True, max_length=10)),
('parallel', models.PositiveSmallIntegerField(default=3)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_settings', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -164,6 +164,8 @@ class PodcastEpisode(models.Model):
title = models.CharField(max_length=500) title = models.CharField(max_length=500)
description = models.TextField(blank=True) description = models.TextField(blank=True)
audio_url = models.URLField(max_length=1000) audio_url = models.URLField(max_length=1000)
# The episode's page, from the feed item's <link> (for sharing); '' when it has none.
link = models.URLField(max_length=1000, blank=True)
duration_seconds = models.IntegerField(default=0) duration_seconds = models.IntegerField(default=0)
pub_date = models.DateTimeField(null=True, blank=True) pub_date = models.DateTimeField(null=True, blank=True)
episode_number = models.IntegerField(null=True, blank=True) episode_number = models.IntegerField(null=True, blank=True)
@ -217,6 +219,68 @@ class EpisodeTranscript(models.Model):
return f'Transcript of {self.episode}' return f'Transcript of {self.episode}'
class TranscriptionSettings(models.Model):
"""Where a user's episodes are transcribed (docs/transcription.md): the
built-in Whisper instance (DIORA_WHISPER_URL) or their own."""
BUILTIN = 'builtin'
CUSTOM = 'custom'
ENGINES = [(BUILTIN, 'Eingebaut'), (CUSTOM, 'Eigene Instanz')]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='transcription_settings')
engine = models.CharField(max_length=10, choices=ENGINES, default=BUILTIN)
# The instance's base, e.g. https://whisper.example.org/v1 (custom only).
url = models.CharField(max_length=500, blank=True)
# The API token, Fernet-encrypted like feeds.SiteLogin.secret; never handed out.
token_encrypted = models.TextField(blank=True)
model = models.CharField(max_length=100, blank=True)
# '' = automatic, else an ISO code ("de").
language = models.CharField(max_length=10, blank=True)
parallel = models.PositiveSmallIntegerField(default=3) # 1 to 8 pieces at once
def __str__(self):
return f'Transcription settings of {self.user}'
class TranscriptionJob(models.Model):
"""One episode's transcription run, its Whisper result and the wish for an
article. Kept apart from EpisodeTranscript, which caches the feed's
transcripts and is rewritten when the feed changes them."""
NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR = (
'none', 'queued', 'downloading', 'transcribing', 'done', 'error')
STATES = [(s, s) for s in (NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR)]
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='transcription_job')
state = models.CharField(max_length=15, choices=STATES, default=NONE)
chunks_done = models.PositiveIntegerField(default=0)
chunks_total = models.PositiveIntegerField(default=0)
error = models.CharField(max_length=500, blank=True)
want_article = models.BooleanField(default=False)
# The device writing the article (a short name) and until when it holds the wish.
claimed_by = models.CharField(max_length=60, blank=True)
claimed_until = models.DateTimeField(null=True, blank=True)
# {"segments": [{start, end, speaker, text}], "source": {type: "whisper", model, language}}
data = models.JSONField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
finished_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f'Transcription of {self.episode} ({self.state})'
class EpisodeArticle(models.Model):
"""The article written from an episode's transcript; its text is the
feeds.Item (in the blog "Podcast-Artikel")."""
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='article')
item = models.OneToOneField('feeds.Item', on_delete=models.CASCADE, related_name='episode_article')
model = models.CharField(max_length=100, blank=True)
kind = models.CharField(max_length=20, blank=True) # Interview, Gespräch, Monolog, Diskussion
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'Article on {self.episode}'
# An episode counts as played this close to its end (AntennaPod's default), # An episode counts as played this close to its end (AntennaPod's default),
# the same rule as the apps' "als gehört zählen". # the same rule as the apps' "als gehört zählen".
PLAYED_BEFORE_END_SECONDS = 30 PLAYED_BEFORE_END_SECONDS = 30

View file

@ -37,6 +37,9 @@ ALLOWED_ATTRS = {
URL_ATTRS = {'href', 'src'} URL_ATTRS = {'href', 'src'}
LINK_SCHEMES = {'http', 'https', 'mailto'} LINK_SCHEMES = {'http', 'https', 'mailto'}
IMAGE_SCHEMES = {'http', 'https'} IMAGE_SCHEMES = {'http', 'https'}
# A link to a second in the episode ("#t=754"), as in the article of a transcript
# (docs/transcription.md): the only fragment link that is kept.
TIME_LINK = re.compile(r'^#t=\d{1,6}$')
_CONTROL_AND_SPACE = re.compile(r'[\x00-\x20\x7f]+') _CONTROL_AND_SPACE = re.compile(r'[\x00-\x20\x7f]+')
_SCHEME = re.compile(r'^([a-z][a-z0-9+.\-]*):') _SCHEME = re.compile(r'^([a-z][a-z0-9+.\-]*):')
@ -81,7 +84,9 @@ class _Sanitizer(HTMLParser):
name = name.lower() name = name.lower()
if name not in allowed or value is None: if name not in allowed or value is None:
continue continue
if name in URL_ATTRS: if tag == 'a' and name == 'href' and TIME_LINK.match(value.strip()):
value = value.strip()
elif name in URL_ATTRS:
value = _safe_url(value, IMAGE_SCHEMES if tag == 'img' else LINK_SCHEMES) value = _safe_url(value, IMAGE_SCHEMES if tag == 'img' else LINK_SCHEMES)
if value is None: if value is None:
continue continue

146
podcasts/test_article.py Normal file
View file

@ -0,0 +1,146 @@
import json
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone
from feeds.models import Item, Source
from .models import EpisodeArticle, PodcastEpisode, PodcastFeed, TranscriptionJob
BODY = ('<p>Zusammenfassung <script>alert(1)</script></p><h2>Teil eins</h2>'
'<p><a href="#t=754" onclick="evil()">▶ ab 12:34</a> und <a href="https://example.org/x">Quelle</a></p>')
class ArticleTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://p.example/rss', title='Salon',
author='Anna Autorin', artwork_url='https://p.example/feed.jpg')
self.ep = PodcastEpisode.objects.create(feed=self.feed, guid='e1', title='Folge 1',
audio_url='https://p.example/e1.mp3', artwork_url='https://p.example/e1.jpg')
def url(self, ep=None):
return f'/podcasts/episodes/{(ep or self.ep).id}/article/'
def post(self, data=None, ep=None):
data = {'title': 'Ein Artikel', 'summary': 'Kurz.', 'body_html': BODY, 'model': 'claude', 'kind': 'Interview'} \
if data is None else data
return self.client.post(self.url(ep), json.dumps(data), content_type='application/json')
def test_requires_login_and_ownership(self):
self.client.logout()
self.assertEqual(self.post().status_code, 401)
self.assertEqual(self.client.delete(self.url()).status_code, 401)
bob = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(bob)
self.assertEqual(self.post().status_code, 404)
self.assertEqual(self.client.delete(self.url()).status_code, 404)
self.assertFalse(Item.objects.exists())
def test_create(self):
r = self.post()
self.assertEqual(r.status_code, 200)
item = Item.objects.get(pk=r.json()['item_id'])
self.assertEqual(item.user, self.user)
self.assertEqual(item.kind, Source.BLOG)
self.assertEqual(item.title, 'Ein Artikel')
self.assertEqual(item.summary, 'Kurz.')
self.assertEqual(item.author, 'Anna Autorin')
self.assertEqual(item.image_url, 'https://p.example/e1.jpg')
self.assertEqual(item.url, 'https://p.example/e1.mp3')
self.assertTrue(item.content_full)
self.assertGreater(item.words, 3)
self.assertLess((timezone.now() - item.published).total_seconds(), 60)
art = EpisodeArticle.objects.get(episode=self.ep)
self.assertEqual((art.item, art.model, art.kind), (item, 'claude', 'Interview'))
self.assertEqual(item.episode_article, art)
def test_image_falls_back_to_feed_artwork(self):
self.ep.artwork_url = ''
self.ep.save()
item = Item.objects.get(pk=self.post().json()['item_id'])
self.assertEqual(item.image_url, 'https://p.example/feed.jpg')
def test_sanitizer_keeps_time_links_only(self):
item = Item.objects.get(pk=self.post().json()['item_id'])
self.assertNotIn('<script', item.content)
self.assertNotIn('alert(1)', item.content)
self.assertNotIn('onclick', item.content)
self.assertIn('href="#t=754"', item.content)
self.assertIn('href="https://example.org/x"', item.content)
evil = self.post({'title': 'x', 'body_html': '<a href="#t=1&quot; onfocus=&quot;x">a</a><a href="#top">b</a>'})
content = Item.objects.get(pk=evil.json()['item_id']).content
self.assertNotIn('onfocus', content)
self.assertNotIn('#top', content)
def test_source_made_once(self):
other = PodcastEpisode.objects.create(feed=self.feed, guid='e2', title='Folge 2', audio_url='https://p.example/e2.mp3')
self.post()
self.post(ep=other)
src = Source.objects.get(user=self.user, url='diora://podcast-artikel')
self.assertEqual((src.title, src.kind, src.fav, src.synthetic), ('Podcast-Artikel', Source.BLOG, True, True))
self.assertEqual(Source.objects.count(), 1)
self.assertEqual(src.items.count(), 2)
def test_replace_keeps_one_item(self):
first = self.post().json()['item_id']
Item.objects.filter(pk=first).update(read_at=timezone.now(), progress=1)
second = self.post({'title': 'Neu', 'body_html': '<p>Ganz anderer Text</p>', 'model': 'm2'}).json()['item_id']
self.assertEqual(first, second)
self.assertEqual(Item.objects.count(), 1)
item = Item.objects.get()
self.assertEqual((item.title, item.read_at, item.progress), ('Neu', None, 0))
self.assertIn('Ganz anderer Text', item.content)
self.assertEqual(EpisodeArticle.objects.get().model, 'm2')
def test_delete(self):
self.post()
self.assertEqual(self.client.delete(self.url()).status_code, 200)
self.assertFalse(Item.objects.exists())
self.assertFalse(EpisodeArticle.objects.exists())
self.assertEqual(Source.objects.count(), 1)
self.assertEqual(self.client.delete(self.url()).status_code, 200) # again: nothing to do
def test_validation(self):
for data in ({}, {'title': '', 'body_html': BODY}, {'title': 'x' * 301, 'body_html': BODY},
{'title': 'x'}, {'title': 'x', 'body_html': '<script>1</script>'},
{'title': 'x', 'body_html': 'a' * 1_000_001},
{'title': 'x', 'body_html': BODY, 'model': 'm' * 101}):
self.assertEqual(self.post(data).status_code, 400, data and list(data))
self.assertEqual(self.client.post(self.url(), 'nope', content_type='application/json').status_code, 400)
self.assertFalse(Item.objects.exists())
self.assertEqual(self.client.get(self.url()).status_code, 405)
def test_saving_clears_the_wish(self):
TranscriptionJob.objects.create(episode=self.ep, state='done', want_article=True, claimed_by='phone',
claimed_until=timezone.now() + timezone.timedelta(minutes=10))
self.post()
job = TranscriptionJob.objects.get()
self.assertEqual((job.want_article, job.claimed_by, job.claimed_until), (False, '', None))
def test_in_favourite_blogs_with_episode_id(self):
item_id = self.post().json()['item_id']
items = self.client.get('/feeds/blogs/?fav=1').json()['items']
self.assertEqual([i['id'] for i in items], [item_id])
self.assertEqual(items[0]['episode_id'], self.ep.id)
detail = self.client.get(f'/feeds/items/{item_id}/').json()['item']
self.assertEqual(detail['episode_id'], self.ep.id)
self.assertIn('href="#t=754"', detail['content'])
def test_ordinary_items_have_no_episode_id(self):
src = Source.objects.create(user=self.user, url='https://b.example/feed', title='B', kind=Source.BLOG, fav=True)
Item.objects.create(source=src, user=self.user, kind=Source.BLOG, guid_hash='g', title='T',
published=timezone.now(), content='<p>x</p>', content_full=True)
self.post()
items = self.client.get('/feeds/blogs/?fav=1').json()['items']
self.assertEqual(len(items), 2)
self.assertEqual(sorted('episode_id' in i for i in items), [False, True])
def test_blog_list_needs_no_query_per_article(self):
for n in range(5):
self.post(ep=PodcastEpisode.objects.create(feed=self.feed, guid=f'x{n}', title=str(n),
audio_url='https://p.example/x.mp3'))
with self.assertNumQueries(3):
self.client.get('/feeds/blogs/?fav=1')

View file

@ -89,7 +89,7 @@ class PlayerSyncTests(TestCase):
self.assertEqual(d['seq'], 2) # the report, and device a coming online self.assertEqual(d['seq'], 2) # the report, and device a coming online
self.assertEqual((d['kind'], d['playing'], d['position_ms'], d['speed']), ('podcast', True, 12345, 1.5)) self.assertEqual((d['kind'], d['playing'], d['position_ms'], d['speed']), ('podcast', True, 12345, 1.5))
self.assertEqual((d['device_id'], d['device_name']), ('a', 'A')) self.assertEqual((d['device_id'], d['device_name']), ('a', 'A'))
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url', self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds', 'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
'in_queue'}) 'in_queue'})
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True)) self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))

45
podcasts/test_share.py Normal file
View file

@ -0,0 +1,45 @@
import feedparser
from django.contrib.auth.models import User
from django.test import TestCase
from .models import PodcastEpisode, PodcastFeed
from .views import FeedFetch, _apply_feed, _entry_link
def _feed(link):
item_link = f'<link>{link}</link>' if link is not None else ''
return feedparser.parse(
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>99 ZU EINS</title>'
'<item><title>Folge 724</title><guid>g724</guid>' + item_link +
'<enclosure url="https://cdn.example/724.mp3" type="audio/mpeg" length="1"/></item>'
'</channel></rss>'
)
class EpisodeLinkTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/rss', title='99 ZU EINS')
def fetch(self, link):
_apply_feed(self.feed, FeedFetch(200, _feed(link)))
return PodcastEpisode.objects.get(feed=self.feed)
def test_entry_link_takes_only_web_addresses(self):
self.assertEqual(_entry_link({'link': ' https://x.fireside.fm/724 '}), 'https://x.fireside.fm/724')
self.assertEqual(_entry_link({'link': 'javascript:alert(1)'}), '')
self.assertEqual(_entry_link({}), '')
def test_a_new_episode_gets_the_items_link(self):
self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
def test_a_refresh_fills_the_link_of_an_episode_that_has_none(self):
self.assertEqual(self.fetch(None).link, '')
self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
def test_episode_detail_returns_the_link(self):
ep = self.fetch('https://99zueins.fireside.fm/724')
r = self.client.get(f'/podcasts/episodes/{ep.id}/')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['link'], 'https://99zueins.fireside.fm/724')

821
podcasts/test_transcribe.py Normal file
View file

@ -0,0 +1,821 @@
"""The transcription pipeline (podcasts/transcribe.py) against a fake Whisper instance, the
endpoints around it and the notice in /feeds/notifications/."""
import email
import io
import json
import math
import os
import struct
import subprocess
import tempfile
import threading
import time
import wave
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from django.contrib.auth.models import User
from django.db import OperationalError
from django.test import TestCase, TransactionTestCase, override_settings
from django.utils import timezone
from feeds.models import Item, Source
from . import transcribe, transcription, youtube
from .models import (
EpisodeArticle, EpisodeTranscript, PodcastEpisode, PodcastFeed, TranscriptionJob, TranscriptionSettings,
)
RATE = 16000
TOTAL = 120
# Where the long pauses are (start, length): the pieces should be cut in these. The short
# ones (0.3 s every 4 s) are only there to tempt.
LONG_PAUSES = [(36.5, 1.6), (83.0, 1.4)]
SEGMENTS = [{'start': 0.0, 'end': 2.0, 'text': ' Hallo '}, {'start': 2.0, 'end': 5.5, 'text': 'Welt.'}]
def make_audio():
"""~2 minutes of tones with pauses, as mp3 bytes (and the wav as a check of the length)."""
samples = []
t = 0.0
pauses = [(s, s + n) for s, n in LONG_PAUSES]
for i in range(RATE * TOTAL):
t = i / RATE
silent = any(a <= t < b for a, b in pauses) or (t % 4.0) > 3.7
value = 0 if silent else int(9000 * math.sin(2 * math.pi * (180 + 40 * ((i // 8000) % 5)) * t))
samples.append(value)
buf = io.BytesIO()
with wave.open(buf, 'wb') as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(RATE)
w.writeframes(struct.pack('<%dh' % len(samples), *samples))
import imageio_ffmpeg
with tempfile.TemporaryDirectory() as tmp:
src, dst = os.path.join(tmp, 'a.wav'), os.path.join(tmp, 'a.mp3')
open(src, 'wb').write(buf.getvalue())
subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), '-loglevel', 'error', '-i', src, '-b:a', '64k', dst], check=True)
return open(dst, 'rb').read()
_AUDIO = []
def audio():
if not _AUDIO:
_AUDIO.append(make_audio())
return _AUDIO[0]
def decoded_seconds(data, suffix):
"""How long the uploaded piece is, by decoding it."""
import imageio_ffmpeg
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, 'p' + suffix)
open(path, 'wb').write(data)
out = subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), '-loglevel', 'error', '-i', path, '-f', 's16le',
'-ac', '1', '-ar', str(RATE), '-'], stdout=subprocess.PIPE, check=True).stdout
return len(out) / 2 / RATE
class Fake:
"""A local HTTP server: GET /ep.mp3 is the test audio, POST /v1/audio/transcriptions a Whisper."""
def __init__(self, fail_first=0, fail_status=500, delay=0, on_call=None):
self.fail_first, self.fail_status, self.delay, self.on_call = fail_first, fail_status, delay, on_call
self.calls = [] # {'fields', 'filename', 'body', 'auth'}
self.lock = threading.Lock()
fake = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
if self.path == '/missing.mp3':
self.send_response(404)
self.end_headers()
return
body = audio_holder.data or audio()
self.send_response(200)
self.send_header('Content-Type', 'audio/mpeg')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
raw = self.rfile.read(int(self.headers['Content-Length']))
msg = email.message_from_bytes(
b'Content-Type: ' + self.headers['Content-Type'].encode() + b'\r\n\r\n' + raw)
fields, filename, body = {}, '', b''
for part in msg.get_payload():
name = part.get_param('name', header='content-disposition')
if part.get_filename():
filename, body = part.get_filename(), part.get_payload(decode=True)
else:
fields[name] = part.get_payload(decode=True).decode()
with fake.lock:
fake.calls.append({'fields': fields, 'filename': filename, 'body': body,
'auth': self.headers.get('Authorization')})
n = len(fake.calls)
if fake.on_call:
fake.on_call(n)
if fake.delay:
time.sleep(fake.delay)
if n <= fake.fail_first or fake.fail_first < 0:
self.send_response(fake.fail_status)
self.send_header('Content-Length', '0')
self.end_headers()
return
out = json.dumps({'language': 'german', 'segments': SEGMENTS}).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(out)))
self.end_headers()
self.wfile.write(out)
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
self.server.daemon_threads = True
self.port = self.server.server_address[1]
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def whisper(self):
return f'http://127.0.0.1:{self.port}/v1'
def url(self, path='/ep.mp3'):
return f'http://127.0.0.1:{self.port}{path}'
def stop(self):
self.server.shutdown()
self.server.server_close()
def leftovers():
base = tempfile.gettempdir()
return {d for d in os.listdir(base) if d.startswith('diora-transcribe-')}
FAST = dict(CHUNK_SECONDS=40, CUT_WINDOW=8, MIN_TAIL=10, RETRY_PAUSES=(0, 0, 0))
def wait_for(cond, timeout=30):
end = time.time() + timeout
while time.time() < end:
if cond():
return True
time.sleep(0.05)
return False
class PipelineBase(TransactionTestCase):
def setUp(self):
for k, v in FAST.items():
patcher = patch.object(transcribe, k, v)
patcher.start()
self.addCleanup(patcher.stop)
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='Der Podcast',
author='Anna Autorin')
self.fake = Fake()
self.addCleanup(self.fake.stop)
self.before = leftovers()
def job(self, audio_path='/ep.mp3', **kw):
n = PodcastEpisode.objects.count()
ep = PodcastEpisode.objects.create(feed=self.feed, guid=f'g{n}', title=f'Folge {n}',
audio_url=self.fake.url(audio_path), duration_seconds=TOTAL)
return TranscriptionJob.objects.create(episode=ep, state=TranscriptionJob.QUEUED, **kw)
def reload(self, job):
for _ in range(50): # the in-memory test database may be locked for a moment by the worker
try:
return TranscriptionJob.objects.get(pk=job.pk)
except OperationalError:
time.sleep(0.05)
return TranscriptionJob.objects.get(pk=job.pk)
def assertClean(self):
self.assertEqual(leftovers(), self.before)
class PlanTests(PipelineBase):
def test_cuts_land_in_the_long_pauses(self):
with tempfile.TemporaryDirectory() as tmp:
src, pcm = os.path.join(tmp, 'a.mp3'), os.path.join(tmp, 'a.pcm')
open(src, 'wb').write(audio())
transcribe._decode(src, pcm)
total = os.path.getsize(pcm) / 2 / RATE
pieces = transcribe.plan(total, transcribe.pauses_of(pcm))
self.assertAlmostEqual(total, TOTAL, delta=0.5)
self.assertEqual(len(pieces), 3)
cuts = [p[1] for p in pieces[:-1]]
for cut, (start, length) in zip(cuts, LONG_PAUSES):
self.assertTrue(start <= cut <= start + length, f'{cut} not in the pause {start}+{length}')
self.assertEqual(pieces[0][0], 0)
self.assertEqual(pieces[-1][1], total)
self.assertEqual([a[1] for a in pieces[:-1]], [b[0] for b in pieces[1:]])
def test_plan_without_pauses_cuts_at_the_target(self):
self.assertEqual(transcribe.plan(100, []), [(0.0, 40), (40, 80), (80, 100)])
self.assertEqual(transcribe.plan(30, []), [(0.0, 30)])
# a short tail is joined to the last piece
self.assertEqual(transcribe.plan(85, []), [(0.0, 40), (40, 85)])
@override_settings(WHISPER_URL='')
class RunTests(PipelineBase):
def configure(self, **kw):
conf = TranscriptionSettings(user=self.user, **kw)
conf.save()
return conf
def test_builtin_run_end_to_end(self):
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(language='de', parallel=2)
job = self.job()
started = time.time()
transcribe.run(job.pk)
took = time.time() - started
print(f'\n[pipeline run of the {TOTAL} s test audio took {took:.1f} s]')
job = self.reload(job)
self.assertEqual((job.state, job.error), ('done', ''))
self.assertEqual((job.chunks_done, job.chunks_total), (3, 3))
self.assertIsNotNone(job.finished_at)
self.assertEqual(len(self.fake.calls), 3)
for c in self.fake.calls:
self.assertEqual(c['fields']['model'], 'whisper-1')
self.assertEqual(c['fields']['language'], 'de')
self.assertEqual(c['fields']['response_format'], 'verbose_json')
self.assertEqual(c['fields']['prompt'], 'Der Podcast. Folge 0. Anna Autorin')
self.assertTrue(c['filename'].endswith('.flac'))
self.assertIsNone(c['auth']) # the token only goes to custom instances
# the uploaded pieces are the planned ones: the same total, cut at the pauses
lengths = sorted(decoded_seconds(c['body'], '.flac') for c in self.fake.calls)
self.assertAlmostEqual(sum(lengths), TOTAL, delta=0.5)
data = job.data
self.assertEqual(data['source'], {'type': 'whisper', 'model': 'whisper-1', 'language': 'de'})
segs = data['segments']
starts = [s['start'] for s in segs]
self.assertEqual(starts, sorted(starts))
self.assertTrue(all(s['speaker'] == '' for s in segs))
# every piece answered "Hallo Welt." at 0 to 5.5 s: its start is where the piece starts,
# which lies on a cut in a long pause, and the first at zero
self.assertEqual(starts[0], 0)
cuts = starts[1:]
self.assertEqual(len(cuts), 2)
for cut, (p, n) in zip(cuts, LONG_PAUSES):
self.assertTrue(p <= cut <= p + n, f'{cut} not in {p}+{n}')
self.assertEqual(segs[0]['text'], 'Hallo Welt.')
self.assertAlmostEqual(segs[0]['end'], 5.5)
self.assertClean()
def test_language_empty_takes_what_whisper_detects_and_custom_gets_opus_and_token(self):
conf = self.configure(engine='custom', url=self.fake.whisper, model='large-v3', language='')
conf.token_encrypted = transcription.logins.encrypt('tok')
conf.save()
job = self.job()
with patch.object(transcribe.chapters, 'assert_safe_url'), \
patch.object(transcribe.chapters, '_assert_peer_is_safe'):
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual((job.state, job.error), ('done', ''))
self.assertEqual(job.data['source'], {'type': 'whisper', 'model': 'large-v3', 'language': 'german'})
for c in self.fake.calls:
self.assertTrue(c['filename'].endswith('.ogg'))
self.assertNotIn('language', c['fields'])
self.assertEqual(c['auth'], 'Bearer tok')
self.assertLess(len(c['body']), decoded_seconds(c['body'], '.ogg') * 6500) # 32 kbit/s: about 4 kB a second, ogg and VBR on top
self.assertAlmostEqual(sum(decoded_seconds(c['body'], '.ogg') for c in self.fake.calls), TOTAL, delta=0.5)
self.assertClean()
def test_custom_instance_on_a_private_address_is_refused(self):
self.configure(engine='custom', url=self.fake.whisper)
job = self.job()
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual(job.state, 'error')
self.assertIn('Whisper', job.error)
self.assertEqual(self.fake.calls, [])
def test_unreachable_builtin_waits_and_tries_again(self):
import socket
sock = socket.socket()
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
sock.close() # nothing listens there now
with override_settings(WHISPER_URL=f'http://127.0.0.1:{port}/v1'):
self.configure()
job = self.job()
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual(job.state, 'queued')
self.assertIn('nicht erreichbar', job.error)
self.assertGreater(transcribe._not_before[job.pk], time.time())
self.assertEqual(self.fake.calls, []) # nothing downloaded while the instance is away
self.assertEqual(transcribe._next_job(), transcribe.WAIT)
transcribe._not_before[job.pk] = 0 # due again: the instance is back
with override_settings(WHISPER_URL=self.fake.whisper):
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual((job.state, job.error), ('done', ''))
transcribe._not_before.clear()
def test_waiting_gives_up_after_two_days(self):
from datetime import timedelta
from django.utils import timezone
with override_settings(WHISPER_URL='http://127.0.0.1:9/v1'):
self.configure()
job = self.job()
TranscriptionJob.objects.filter(pk=job.pk).update(created_at=timezone.now() - timedelta(days=3))
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual(job.state, 'error')
self.assertIn('zwei Tage', job.error)
@override_settings(WHISPER_URL='')
def test_retries_then_succeeds(self):
self.fake.fail_first = 2
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(parallel=1)
job = self.job()
transcribe.run(job.pk)
self.assertEqual(self.reload(job).state, 'done')
self.assertEqual(len(self.fake.calls), 3 + 2)
def test_gives_up_after_three_retries(self):
self.fake.fail_first = -1
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(parallel=1)
job = self.job()
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual(job.state, 'error')
self.assertEqual(job.error, 'Whisper: HTTP 500')
self.assertEqual(len(self.fake.calls), 4) # the first try and three more
self.assertIsNone(job.data)
self.assertClean()
def test_a_refusal_is_not_retried(self):
self.fake.fail_first, self.fake.fail_status = -1, 401
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(parallel=1)
job = self.job()
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual((job.state, job.error), ('error', 'Whisper: HTTP 401'))
self.assertEqual(len(self.fake.calls), 1)
def test_download_failure(self):
with override_settings(WHISPER_URL=self.fake.whisper):
job = self.job('/missing.mp3')
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual((job.state, job.error), ('error', 'Download: HTTP 404'))
self.assertClean()
def test_not_audio(self):
with override_settings(WHISPER_URL=self.fake.whisper), patch.object(audio_holder, 'data', b'<html>no</html>'):
job = self.job()
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual(job.state, 'error')
self.assertTrue(job.error.startswith('ffmpeg:'), job.error)
self.assertClean()
def test_no_builtin_instance(self):
job = self.job()
transcribe.run(job.pk)
self.assertEqual(self.reload(job).error, 'Keine eingebaute Instanz eingerichtet')
def test_cancel_while_running(self):
job = self.job()
def cancel(n):
if n == 1:
TranscriptionJob.objects.filter(pk=job.pk).delete()
from django.db import connection
connection.close()
self.fake.on_call, self.fake.delay = cancel, 0.2
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(parallel=1)
transcribe.run(job.pk)
self.assertFalse(TranscriptionJob.objects.filter(pk=job.pk).exists())
self.assertLessEqual(len(self.fake.calls), 1) # no further piece was sent
self.assertClean()
def test_cancelled_while_waiting_is_not_started(self):
job = self.job()
job.delete()
transcribe.run(job.pk)
self.assertEqual(self.fake.calls, [])
class _Holder:
"""Lets a test swap the audio the fake server sends."""
data = None
audio_holder = _Holder()
class QueueTests(PipelineBase):
"""The runner and its queue. The work itself (_transcribe) is replaced by a stand-in the test
holds back, so only the runner thread writes to the database while the test looks at it."""
def setUp(self):
super().setUp()
self.gate = threading.Event()
self.started = []
patcher = patch.object(transcribe, '_resumed', True)
patcher.start()
self.addCleanup(patcher.stop)
work = patch.object(transcribe, '_transcribe', side_effect=self.work)
work.start()
self.addCleanup(work.stop)
self.addCleanup(self.gate.set)
self.addCleanup(lambda: wait_for(lambda: transcribe._runner is None))
def work(self, job_id):
self.started.append(job_id)
self.gate.wait(30)
return {'segments': [{'start': 0, 'end': 1, 'speaker': '', 'text': 'x'}], 'source': {'type': 'whisper'}}
def test_one_job_at_a_time_oldest_first(self):
first, second, third = self.job(), self.job(), self.job()
transcribe.kick()
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
transcribe.kick() # a second runner doesn't start
time.sleep(0.3)
self.assertEqual(self.started, [first.pk])
self.assertEqual([self.reload(j).state for j in (first, second, third)], ['downloading', 'queued', 'queued'])
self.gate.set()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(self.started, [first.pk, second.pk, third.pk])
jobs = [self.reload(j) for j in (first, second, third)]
self.assertEqual([j.state for j in jobs], ['done'] * 3)
self.assertEqual(sorted(j.finished_at for j in jobs), [j.finished_at for j in jobs])
def test_a_job_queued_while_the_runner_works_is_taken_too(self):
first = self.job()
transcribe.kick()
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
second = self.job()
transcribe.kick()
self.gate.set()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(self.started, [first.pk, second.pk])
def test_a_cancelled_waiting_job_is_skipped(self):
first, second, third = self.job(), self.job(), self.job()
transcribe.kick()
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
second.delete()
self.gate.set()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(self.started, [first.pk, third.pk])
def test_a_crashing_job_does_not_stop_the_queue(self):
first, second = self.job(), self.job()
self.gate.set()
calls = []
def work(job_id):
calls.append(job_id)
if job_id == first.pk:
raise RuntimeError('boom')
return self.work(job_id)
with patch.object(transcribe, '_transcribe', side_effect=work):
transcribe.kick()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(calls, [first.pk, second.pk])
self.assertEqual((self.reload(first).state, self.reload(first).error), ('error', 'RuntimeError: boom'))
self.assertEqual(self.reload(second).state, 'done')
def test_stuck_jobs_start_again_after_a_restart(self):
self.gate.set()
job = self.job()
TranscriptionJob.objects.filter(pk=job.pk).update(state='transcribing', chunks_done=2, chunks_total=3)
with patch.object(transcribe, '_resumed', False):
transcribe.kick()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(self.started, [job.pk])
self.assertEqual(self.reload(job).state, 'done')
def test_nothing_queued_starts_no_runner(self):
transcribe.kick()
self.assertIsNone(transcribe._runner)
# --------------------------------------------------------------------------- endpoints
class EndpointBase(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='Der Podcast')
self.ep = self.episode('e1')
kick = patch.object(transcribe, 'kick')
self.kick = kick.start()
self.addCleanup(kick.stop)
def episode(self, guid, **kw):
kw.setdefault('audio_url', f'https://example.org/{guid}.mp3')
return PodcastEpisode.objects.create(feed=self.feed, guid=guid, title=f'Folge {guid}', duration_seconds=600, **kw)
def post(self, url, body=None, **extra):
return self.client.post(url, json.dumps(body or {}), content_type='application/json', **extra)
def done_job(self, ep, **kw):
kw.setdefault('data', {'segments': [{'start': 0, 'end': 4, 'speaker': '', 'text': 'Hallo.'}],
'source': {'type': 'whisper', 'model': 'whisper-1', 'language': 'de'}})
return TranscriptionJob.objects.create(episode=ep, state='done', finished_at=timezone.now(), **kw)
class TranscribeEndpointTests(EndpointBase):
def url(self, ep=None):
return f'/podcasts/episodes/{(ep or self.ep).id}/transcribe/'
def test_requires_login_and_ownership(self):
other = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(other)
self.assertEqual(self.client.get(self.url()).status_code, 404)
self.assertEqual(self.post(self.url()).status_code, 404)
self.client.logout()
self.assertEqual(self.client.get(self.url()).status_code, 401)
def test_none_before_a_job(self):
self.assertEqual(self.client.get(self.url()).json(), {
'state': 'none', 'chunks_done': 0, 'chunks_total': 0, 'error': '', 'want_article': False,
'started_at': None, 'finished_at': None})
@patch('podcasts.transcription._too_large', return_value=False)
def test_post_queues_a_job_and_starts_the_runner(self, _):
r = self.post(self.url())
self.assertEqual(r.status_code, 200)
self.assertEqual((r.json()['state'], r.json()['want_article']), ('queued', False))
self.assertEqual(TranscriptionJob.objects.get(episode=self.ep).state, 'queued')
self.kick.assert_called()
@patch('podcasts.transcription._too_large', return_value=False)
def test_post_again_only_raises_the_article_wish(self, _):
self.post(self.url())
first = TranscriptionJob.objects.get(episode=self.ep).pk
r = self.post(self.url(), {'article': True})
self.assertTrue(r.json()['want_article'])
r = self.post(self.url(), {'article': False}) # never lowers it
self.assertTrue(r.json()['want_article'])
self.assertEqual(TranscriptionJob.objects.filter(episode=self.ep).count(), 1)
self.assertEqual(TranscriptionJob.objects.get(episode=self.ep).pk, first)
@patch('podcasts.transcription._too_large', return_value=False)
def test_post_with_article_true_sets_the_wish(self, _):
self.assertTrue(self.post(self.url(), {'article': True}).json()['want_article'])
def test_youtube_is_refused(self):
ep = self.episode('yt', audio_url=youtube.WATCH + 'abc123')
r = self.post(self.url(ep))
self.assertEqual((r.status_code, r.json()), (400, {'error': 'youtube'}))
self.assertFalse(TranscriptionJob.objects.exists())
@patch('podcasts.transcription._too_large', return_value=True)
def test_too_large_is_refused(self, _):
r = self.post(self.url())
self.assertEqual((r.status_code, r.json()), (400, {'error': 'too large'}))
self.assertFalse(TranscriptionJob.objects.exists())
def test_the_size_check_reads_the_content_length(self):
from . import silence
resp = type('R', (), {'headers': {'Content-Length': str(silence.MAX_BYTES + 1)}})()
with patch('podcasts.transcription.requests.head', return_value=resp):
self.assertTrue(transcription._too_large('https://example.org/x.mp3'))
resp.headers = {'Content-Length': '1000'}
with patch('podcasts.transcription.requests.head', return_value=resp):
self.assertFalse(transcription._too_large('https://example.org/x.mp3'))
with patch('podcasts.transcription.requests.head', side_effect=transcription.requests.ConnectionError):
self.assertFalse(transcription._too_large('https://example.org/x.mp3'))
def test_a_whisper_transcript_is_done_already(self):
self.done_job(self.ep)
r = self.post(self.url(), {'article': True})
self.assertEqual((r.json()['state'], r.json()['want_article']), ('done', True))
self.kick.assert_not_called()
self.assertTrue(TranscriptionJob.objects.get(episode=self.ep).want_article)
def test_a_feed_transcript_is_done_already(self):
self.ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
self.ep.save()
r = self.post(self.url(), {'article': True})
self.assertEqual((r.json()['state'], r.json()['want_article']), ('done', True))
job = TranscriptionJob.objects.get(episode=self.ep)
self.assertIsNone(job.data)
self.kick.assert_not_called()
@patch('podcasts.transcription._too_large', return_value=False)
def test_after_an_error_post_starts_again_at_the_back(self, _):
old = TranscriptionJob.objects.create(episode=self.ep, state='error', error='Whisper: HTTP 500')
r = self.post(self.url(), {'article': True})
self.assertEqual((r.json()['state'], r.json()['error']), ('queued', ''))
new = TranscriptionJob.objects.get(episode=self.ep)
self.assertGreater(new.pk, old.pk)
@patch('podcasts.transcription._too_large', return_value=False)
def test_delete_cancels_queued_and_running_but_not_a_finished_one(self, _):
self.post(self.url())
self.assertEqual(self.client.delete(self.url()).json()['state'], 'none')
self.assertFalse(TranscriptionJob.objects.exists())
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', chunks_total=5, chunks_done=2)
self.assertEqual(self.client.delete(self.url()).json()['state'], 'none')
self.assertFalse(TranscriptionJob.objects.exists())
self.done_job(self.ep)
self.assertEqual(self.client.delete(self.url()).json()['state'], 'done')
self.assertTrue(TranscriptionJob.objects.exists())
def test_get_shows_progress(self):
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', chunks_total=38, chunks_done=7,
started_at=timezone.now())
d = self.client.get(self.url()).json()
self.assertEqual((d['state'], d['chunks_done'], d['chunks_total']), ('transcribing', 7, 38))
self.assertIsNotNone(d['started_at'])
self.kick.assert_called() # a restart's first request starts the queue again
class TranscriptTests(EndpointBase):
def transcript(self, ep=None):
return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/transcript/')
def detail(self, ep=None):
return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/').json()
def test_404_as_before_without_any(self):
self.assertEqual((self.transcript().status_code, self.transcript().json()), (404, {'error': 'no transcript'}))
d = self.detail()
self.assertEqual((d['has_transcript'], d['transcript_origin'], d['transcription'], d['article_item_id']),
(False, None, None, None))
def test_falls_back_to_the_whisper_transcript(self):
job = self.done_job(self.ep)
r = self.transcript()
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), job.data)
d = self.detail()
self.assertEqual((d['has_transcript'], d['transcript_origin']), (True, 'whisper'))
self.assertEqual(d['transcription']['state'], 'done')
def test_a_running_job_has_no_transcript_yet(self):
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', data=None)
self.assertEqual(self.transcript().status_code, 404)
d = self.detail()
self.assertEqual((d['has_transcript'], d['transcript_origin'], d['transcription']['state']),
(False, None, 'transcribing'))
def feed_transcript(self, error=''):
ep = self.ep
ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
ep.save()
EpisodeTranscript.objects.create(
episode=ep, sources=['https://example.org/t.vtt'], error=error,
data=None if error else {'segments': [{'start': 0, 'end': 1, 'speaker': 'A', 'text': 'Aus dem Feed'}],
'source': {'url': 'https://example.org/t.vtt'}})
def test_the_feeds_transcript_wins(self):
self.done_job(self.ep)
self.feed_transcript()
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Aus dem Feed')
self.assertEqual(self.detail()['transcript_origin'], 'feed')
def test_an_unusable_feed_transcript_gives_way(self):
self.done_job(self.ep)
self.feed_transcript(error='invalid')
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Hallo.')
self.assertEqual(self.detail()['transcript_origin'], 'whisper')
def test_a_feed_transcript_that_cannot_be_loaded_now_gives_way_too(self):
self.done_job(self.ep)
self.ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
self.ep.save()
from . import chapters
with patch('podcasts.views.transcripts_mod.load', side_effect=chapters.FetchUnavailable('x')):
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Hallo.')
def test_article_item_id(self):
source = Source.objects.create(user=self.user, url='diora://podcast-artikel', title='Podcast-Artikel',
kind='blog')
item = Item.objects.create(source=source, user=self.user, kind='blog', guid_hash='x', title='A',
published=timezone.now())
EpisodeArticle.objects.create(episode=self.ep, item=item)
self.assertEqual(self.detail()['article_item_id'], item.id)
class WishTests(EndpointBase):
PENDING = '/podcasts/transcription/pending/'
def claim(self, ep=None, method='post', **kw):
return getattr(self.client, method)(
f'/podcasts/episodes/{(ep or self.ep).id}/article/claim/', json.dumps(kw.pop('body', {})),
content_type='application/json', **kw)
def test_pending_lists_open_wishes_only(self):
wished = self.done_job(self.ep, want_article=True)
e2, e3, e4, e5 = (self.episode(g) for g in ('e2', 'e3', 'e4', 'e5'))
self.done_job(e2) # no wish
TranscriptionJob.objects.create(episode=e3, state='transcribing', want_article=True) # not done
self.done_job(e4, want_article=True, claimed_by='tablet', claimed_until=timezone.now() + timedelta(minutes=5))
job5 = self.done_job(e5, want_article=True)
source = Source.objects.create(user=self.user, url='diora://podcast-artikel', title='P', kind='blog')
item = Item.objects.create(source=source, user=self.user, kind='blog', guid_hash='x', title='A',
published=timezone.now())
EpisodeArticle.objects.create(episode=e5, item=item) # written already
r = self.client.get(self.PENDING).json()
self.assertEqual(r, [{'episode_id': self.ep.id, 'title': 'Folge e1', 'podcast': 'Der Podcast',
'duration_seconds': 600}])
self.assertTrue(wished.want_article and job5.want_article)
def test_pending_is_per_user_and_needs_login(self):
self.done_job(self.ep, want_article=True)
other = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(other)
self.assertEqual(self.client.get(self.PENDING).json(), [])
self.client.logout()
self.assertEqual(self.client.get(self.PENDING).status_code, 401)
def test_an_expired_claim_is_open_again(self):
self.done_job(self.ep, want_article=True, claimed_by='tablet',
claimed_until=timezone.now() - timedelta(seconds=1))
self.assertEqual(len(self.client.get(self.PENDING).json()), 1)
def test_claim_blocks_the_other_device_until_it_expires(self):
job = self.done_job(self.ep, want_article=True)
r = self.claim(body={'device': 'phone'})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['claimed_by'], 'phone')
job.refresh_from_db()
left = job.claimed_until - timezone.now()
self.assertTrue(timedelta(minutes=14) < left <= timedelta(minutes=15))
self.assertEqual(self.client.get(self.PENDING).json(), [])
r = self.claim(body={'device': 'desktop'})
self.assertEqual(r.status_code, 409)
self.assertEqual(r.json()['error'], 'claimed')
self.assertEqual(self.claim(body={'device': 'phone'}).status_code, 200) # the holder renews
TranscriptionJob.objects.filter(pk=job.pk).update(claimed_until=timezone.now() - timedelta(seconds=1))
self.assertEqual(self.claim(body={'device': 'desktop'}).status_code, 200)
job.refresh_from_db()
self.assertEqual(job.claimed_by, 'desktop')
def test_the_device_may_come_in_a_header(self):
self.done_job(self.ep, want_article=True)
self.assertEqual(self.claim(HTTP_X_DIORA_DEVICE='phone').status_code, 200)
self.assertEqual(self.claim(HTTP_X_DIORA_DEVICE='desktop').status_code, 409)
def test_a_device_name_is_required(self):
self.done_job(self.ep, want_article=True)
self.assertEqual(self.claim().status_code, 400)
def test_release(self):
self.done_job(self.ep, want_article=True)
self.claim(body={'device': 'phone'})
self.assertEqual(self.claim(method='delete', body={'device': 'desktop'}).status_code, 409)
self.assertEqual(self.claim(method='delete', body={'device': 'phone'}).status_code, 200)
self.assertEqual(len(self.client.get(self.PENDING).json()), 1)
def test_no_wish_no_claim(self):
self.done_job(self.ep) # done, but no article wanted
self.assertEqual(self.claim(body={'device': 'phone'}).status_code, 404)
self.assertEqual(self.claim(self.episode('e9'), body={'device': 'phone'}).status_code, 404)
class NotificationTests(EndpointBase):
def get(self, **q):
return self.client.get('/feeds/notifications/', q).json()
def test_first_time_only_the_cursor(self):
j = self.done_job(self.ep, want_article=True)
d = self.get()
self.assertEqual((d['transcripts'], d['t_last'], d['items']), ([], j.id, []))
self.assertEqual(d['last'], 0)
def test_jobs_done_after_the_cursor(self):
e2, e3 = self.episode('e2'), self.episode('e3')
a = self.done_job(self.ep)
b = self.done_job(e2, want_article=True)
TranscriptionJob.objects.create(episode=e3, state='transcribing') # not done: no notice
d = self.get(t_after=a.id)
self.assertEqual(d['transcripts'], [{'job_id': b.id, 'episode_id': e2.id, 'title': 'Folge e2',
'podcast': 'Der Podcast', 'want_article': True}])
self.assertEqual(d['t_last'], b.id)
self.assertEqual(self.get(t_after=b.id)['transcripts'], [])
self.assertEqual([t['job_id'] for t in self.get(t_after=0)['transcripts']], [a.id, b.id])
def test_only_the_users_own_and_not_feed_only_jobs(self):
other = User.objects.create_user(username='bob', password='pw12345678')
feed = PodcastFeed.objects.create(user=other, rss_url='https://example.org/o', title='O')
ep = PodcastEpisode.objects.create(feed=feed, guid='o', title='O', audio_url='https://example.org/o.mp3')
self.done_job(ep)
self.done_job(self.episode('e2'), data=None) # only the wish for an article from a feed transcript
d = self.get(t_after=0)
self.assertEqual((d['transcripts'], d['t_last']), ([], 0))
def test_items_still_work_beside_it(self):
d = self.get(after=0)
self.assertEqual((d['items'], d['last'], d['transcripts'], d['t_last']), ([], 0, [], 0))

View file

@ -0,0 +1,153 @@
import io
import json
import wave
from unittest.mock import MagicMock, patch
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from feeds import logins
from . import chapters, transcription
from .models import TranscriptionSettings
SETTINGS = '/podcasts/transcription/settings/'
TEST = '/podcasts/transcription/test/'
def fake_response(status=200):
r = MagicMock()
r.status_code = status
r.headers = {}
r.raw = None # nothing to introspect for the peer check
return r
class SettingsTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
def post(self, data, url=SETTINGS):
return self.client.post(url, json.dumps(data), content_type='application/json')
def test_requires_login(self):
self.client.logout()
self.assertEqual(self.client.get(SETTINGS).status_code, 401)
self.assertEqual(self.post({}, TEST).status_code, 401)
def test_defaults(self):
self.assertEqual(self.client.get(SETTINGS).json(), {
'engine': 'builtin', 'url': '', 'model': '', 'language': '', 'parallel': 3,
'has_token': False, 'builtin_available': False})
self.assertFalse(TranscriptionSettings.objects.exists())
@override_settings(WHISPER_URL='http://whisper:8000/v1')
def test_builtin_available_follows_the_environment_setting(self):
self.assertTrue(self.client.get(SETTINGS).json()['builtin_available'])
def test_roundtrip(self):
r = self.post({'engine': 'custom', 'url': 'https://whisper.example.org/v1/', 'model': 'large-v3',
'language': 'de', 'parallel': 5, 'token': 'sk-secret'})
self.assertEqual(r.status_code, 200)
want = {'engine': 'custom', 'url': 'https://whisper.example.org/v1', 'model': 'large-v3',
'language': 'de', 'parallel': 5, 'has_token': True, 'builtin_available': False}
self.assertEqual(r.json(), want)
self.assertEqual(self.client.get(SETTINGS).json(), want)
def test_token_is_write_only_and_stored_encrypted(self):
self.post({'engine': 'custom', 'url': 'https://w.example.org/v1', 'token': 'sk-secret'})
conf = TranscriptionSettings.objects.get(user=self.user)
self.assertTrue(conf.token_encrypted)
self.assertNotIn('sk-secret', conf.token_encrypted)
self.assertEqual(transcription.token_of(conf), 'sk-secret')
for body in (self.client.get(SETTINGS).content, self.post({'model': 'm'}).content):
self.assertNotIn(b'sk-secret', body)
self.assertNotIn(conf.token_encrypted.encode(), body)
# Not sending a token keeps it; an empty one removes it.
conf.refresh_from_db()
self.assertTrue(conf.token_encrypted)
self.assertFalse(self.post({'token': ''}).json()['has_token'])
conf.refresh_from_db()
self.assertEqual(conf.token_encrypted, '')
def test_validation(self):
bad = [
{'engine': 'cloud'}, {'parallel': 0}, {'parallel': 9}, {'parallel': '3'}, {'parallel': True},
{'url': 'ftp://x.org/v1'}, {'url': 'javascript:alert(1)'}, {'url': 'whisper.local/v1'},
{'language': 'Deutsch'}, {'model': 5}, {'token': 5},
{'engine': 'custom'}, # a custom instance needs a url
]
for body in bad:
self.assertEqual(self.post(body).status_code, 400, body)
self.assertEqual(self.client.post(SETTINGS, 'nope', content_type='application/json').status_code, 400)
self.assertFalse(TranscriptionSettings.objects.exists())
for body in ({'parallel': 1}, {'parallel': 8}, {'language': ''}, {'language': 'pt-BR'}):
self.assertEqual(self.post(body).status_code, 200, body)
def test_settings_are_per_user(self):
self.post({'parallel': 7})
other = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(other)
self.assertEqual(self.client.get(SETTINGS).json()['parallel'], 3)
class TestEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
def configure(self, **kw):
TranscriptionSettings.objects.update_or_create(user=self.user, defaults=kw)
def test_custom_sends_a_second_of_silence(self):
self.configure(engine='custom', url='https://whisper.example.org/v1', model='large-v3', language='de',
token_encrypted=logins.encrypt('sk-secret'))
calls = []
def post(url, **kw):
calls.append((url, kw))
return fake_response()
with patch('podcasts.chapters.assert_safe_url'), patch('podcasts.chapters.requests.post', post):
r = self.client.post(TEST)
self.assertEqual(r.json(), {'ok': True, 'model': 'large-v3', 'error': ''})
url, kw = calls[0]
self.assertEqual(url, 'https://whisper.example.org/v1/audio/transcriptions')
self.assertEqual(kw['headers']['Authorization'], 'Bearer sk-secret')
self.assertEqual(kw['data']['model'], 'large-v3')
self.assertEqual(kw['data']['language'], 'de')
name, wav, mime = kw['files']['file']
self.assertEqual(mime, 'audio/wav')
with wave.open(io.BytesIO(wav)) as w:
self.assertEqual((w.getframerate(), w.getnchannels(), w.getnframes()), (16000, 1, 16000))
def test_custom_private_address_is_refused(self):
self.configure(engine='custom', url='http://127.0.0.1:9000/v1')
with patch('podcasts.chapters.requests.post') as post:
r = self.client.post(TEST)
self.assertFalse(r.json()['ok'])
self.assertTrue(r.json()['error'])
post.assert_not_called()
def test_custom_http_error_is_reported(self):
self.configure(engine='custom', url='https://whisper.example.org/v1')
with patch('podcasts.chapters.assert_safe_url'), \
patch('podcasts.chapters.requests.post', return_value=fake_response(401)):
r = self.client.post(TEST)
self.assertEqual((r.json()['ok'], r.json()['error']), (False, 'HTTP 401'))
@override_settings(WHISPER_URL='http://whisper:8000/v1')
def test_builtin_is_exempt_from_the_address_check(self):
self.configure(engine='builtin')
with patch('podcasts.transcription.requests.post', return_value=fake_response()) as post, \
patch('podcasts.chapters.assert_safe_url', side_effect=chapters.WebDAVInputError('private')):
r = self.client.post(TEST)
self.assertTrue(r.json()['ok'])
self.assertEqual(post.call_args.args[0], 'http://whisper:8000/v1/audio/transcriptions')
self.assertNotIn('Authorization', post.call_args.kwargs['headers'])
def test_builtin_without_instance(self):
r = self.client.post(TEST)
self.assertFalse(r.json()['ok'])
self.assertTrue(r.json()['error'])

View file

@ -837,7 +837,7 @@ class EpisodeListTests(TestCase):
a2 = dict(eps[self.a2.id]) a2 = dict(eps[self.a2.id])
self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark) self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark)
self.assertEqual(a2, { self.assertEqual(a2, {
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'duration_seconds': 0, 'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'link': '', 'duration_seconds': 0,
'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '', 'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '',
'feed__id': self.fa.id, 'feed__title': 'Alpha', 'feed__artwork_url': 'https://example.org/a.jpg', 'feed__id': self.fa.id, 'feed__title': 'Alpha', 'feed__artwork_url': 'https://example.org/a.jpg',
'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True, 'in_later': False, 'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True, 'in_later': False,

431
podcasts/transcribe.py Normal file
View file

@ -0,0 +1,431 @@
"""Transcribe an episode with a Whisper instance (docs/transcription.md, "Transcribing").
The server downloads the episode (as silence.py does, with the same size limit), decodes it
once to 16 kHz mono, cuts it into pieces of about eight minutes where there is a pause (so no
word is cut), sends the pieces in parallel to the instance's OpenAI-compatible
`/audio/transcriptions` and joins the answers into one transcript, which is kept on the job
(TranscriptionJob.data).
A job runs in a daemon thread, one at a time per server; the others wait as `queued` and are
taken oldest first (by id). A job found `downloading` or `transcribing` when the process has
just started (a restart killed it) is queued again. Cancelling deletes the job's row: the
worker notices when it next writes progress and stops.
"""
import datetime
import logging
import os
import subprocess
import tempfile
import threading
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
import requests
from django.conf import settings
from django.db import close_old_connections
from django.utils import timezone
from . import chapters, silence, transcripts
from .models import TranscriptionJob, TranscriptionSettings
from .transcription import DEFAULT_MODEL, for_user, token_of
log = logging.getLogger(__name__)
CHUNK_SECONDS = 8 * 60 # about this long a piece
CUT_WINDOW = 30 # a cut moves to the longest pause within this many seconds
MIN_TAIL = 60 # a last piece shorter than this is joined to the one before
RATE = 16000 # Hz, mono, 16-bit
WINDOW = silence.WINDOW
RETRIES = 3
RETRY_PAUSES = (3, 10, 30) # seconds before each retry
UPLOAD_TIMEOUT = (10, 3600) # a CPU instance may need many minutes for a piece
DECODE_TIMEOUT = 3600
OPUS_BITRATE = '32k'
MAX_PROMPT = 500 # characters
ALIVE_CHECK = 5 # seconds between looks whether the job was cancelled
WAIT_RETRY = 600 # seconds between tries while the built-in instance can't be reached
WAIT_MAX = 48 * 3600 # after this long since the button was pressed a waiting job gives up
WAIT_IDLE = 30 # seconds the runner sleeps while every queued job is waiting
_lock = threading.Lock()
_runner = None # the thread working through the queue, or None
_resumed = False # whether jobs left over from before a restart were queued again
_not_before = {} # job id -> time.time() before which a waiting job isn't tried again
WAIT = 'wait' # _next_job: jobs are queued, but none is due yet
class Cancelled(Exception):
"""The job's row is gone: the user cancelled it."""
class Failed(Exception):
"""The job can't succeed; the message is shown to the user."""
class Unreachable(Exception):
"""The built-in instance can't be reached (e.g. the machine it runs on is off): the job waits
and tries again later instead of failing."""
class Permanent(Exception):
"""A request the instance refused for good (a client error): not worth a retry."""
# ---------------------------------------------------------------------------
# Queue
# ---------------------------------------------------------------------------
def kick():
"""Make sure queued jobs are being worked on. Called by the endpoints, so after a restart
the first request brings back what was running."""
global _runner, _resumed
with _lock:
if not _resumed:
_resumed = True
TranscriptionJob.objects.filter(
state__in=[TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING],
).update(state=TranscriptionJob.QUEUED, chunks_done=0, chunks_total=0)
if _runner is not None:
return
if not TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED).exists():
return
_runner = threading.Thread(target=_loop, name='transcribe', daemon=True)
_runner.start()
def _next_job():
global _runner
with _lock:
queued = list(TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED)
.order_by('id').values_list('id', flat=True))
if not queued:
_runner = None # decided under the lock, so a job queued right now starts a new runner
return None
now = time.time()
due = next((i for i in queued if _not_before.get(i, 0) <= now), None)
return due if due is not None else WAIT
def _loop():
global _runner
try:
while True:
close_old_connections()
job_id = _next_job()
if job_id is None:
return
if job_id == WAIT:
time.sleep(WAIT_IDLE)
continue
try:
run(job_id)
except Exception: # run() reports its own failures; this keeps the queue going
log.exception('transcription job %s crashed', job_id)
except BaseException:
with _lock:
_runner = None
raise
finally:
close_old_connections()
def run(job_id):
"""Work one job through to `done` or `error`, in this thread."""
started = TranscriptionJob.objects.filter(pk=job_id, state=TranscriptionJob.QUEUED).update(
state=TranscriptionJob.DOWNLOADING, started_at=timezone.now(), finished_at=None,
error='', chunks_done=0, chunks_total=0)
if not started:
return # cancelled while it waited
try:
data = _transcribe(job_id)
_write(job_id, state=TranscriptionJob.DONE, data=data, error='', finished_at=timezone.now())
except Cancelled:
log.info('transcription job %s cancelled', job_id)
except Unreachable as e:
_wait(job_id, str(e))
except Failed as e:
_fail(job_id, str(e))
except Exception as e:
log.exception('transcription job %s failed', job_id)
_fail(job_id, f'{type(e).__name__}: {e}')
def _wait(job_id, reason):
"""Back into the queue to try again in WAIT_RETRY seconds, or give up when it waited too long."""
job = TranscriptionJob.objects.filter(pk=job_id).only('created_at').first()
if job is None:
return
if (timezone.now() - job.created_at).total_seconds() > WAIT_MAX:
_not_before.pop(job_id, None)
_fail(job_id, 'Whisper-Instanz zwei Tage lang nicht erreichbar')
return
_not_before[job_id] = time.time() + WAIT_RETRY
again = timezone.localtime(timezone.now() + datetime.timedelta(seconds=WAIT_RETRY)).strftime('%H:%M')
log.info('transcription job %s waits for the instance (%s)', job_id, reason)
TranscriptionJob.objects.filter(pk=job_id).update(
state=TranscriptionJob.QUEUED, chunks_done=0, chunks_total=0, started_at=None,
error=f'Whisper-Instanz nicht erreichbar, neuer Versuch um {again}')
def _probe(url):
"""Unreachable unless something answers at the built-in instance's address."""
try:
requests.get(url.rsplit('/audio/transcriptions', 1)[0], timeout=(5, 10)).close()
except (requests.ConnectionError, requests.Timeout) as e:
raise Unreachable(e.__class__.__name__)
def _fail(job_id, message):
TranscriptionJob.objects.filter(pk=job_id).update(
state=TranscriptionJob.ERROR, error=(message or 'failed')[:500], finished_at=timezone.now())
def _write(job_id, **fields):
"""Update the job; Cancelled when its row is gone."""
if not TranscriptionJob.objects.filter(pk=job_id).update(**fields):
raise Cancelled()
def _alive(job_id):
if not TranscriptionJob.objects.filter(pk=job_id).exists():
raise Cancelled()
# ---------------------------------------------------------------------------
# The work
# ---------------------------------------------------------------------------
def _target(conf):
"""(url, headers, builtin) of the instance the settings point at."""
builtin = conf.engine == TranscriptionSettings.BUILTIN
base = settings.WHISPER_URL if builtin else conf.url
if not base:
raise Failed('Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen')
headers = {'User-Agent': 'diora transcription'}
if not builtin and conf.token_encrypted:
headers['Authorization'] = 'Bearer ' + token_of(conf)
return base.rstrip('/') + '/audio/transcriptions', headers, builtin
def _prompt(episode):
parts = [episode.feed.title, episode.title, episode.feed.author]
return '. '.join(p.strip() for p in parts if p and p.strip())[:MAX_PROMPT]
def _transcribe(job_id):
job = TranscriptionJob.objects.select_related('episode__feed').get(pk=job_id)
episode = job.episode
conf = for_user(episode.feed.user)
url, headers, builtin = _target(conf)
if builtin:
_probe(url) # before downloading: is the instance there at all?
options = {'model': conf.model or DEFAULT_MODEL, 'response_format': 'verbose_json'}
if conf.language:
options['language'] = conf.language
prompt = _prompt(episode)
if prompt:
options['prompt'] = prompt
with tempfile.TemporaryDirectory(prefix='diora-transcribe-') as tmp:
source = os.path.join(tmp, 'episode.audio')
_download(episode.audio_url, source, job_id)
pcm = os.path.join(tmp, 'episode.pcm')
_decode(source, pcm)
os.remove(source)
total = os.path.getsize(pcm) // 2 / RATE
if total <= 0:
raise Failed('Kein Ton in der Datei')
pieces = plan(total, pauses_of(pcm))
_write(job_id, state=TranscriptionJob.TRANSCRIBING, chunks_total=len(pieces), chunks_done=0)
results = {}
stop = threading.Event() # set when the job ends early: no piece starts after that
suffix, mime = ('.flac', 'audio/flac') if builtin else ('.ogg', 'audio/ogg')
def work(index):
# Only the HTTP side runs here; the database is touched by this thread alone.
if stop.is_set():
return index, None
start, end = pieces[index]
path = _encode(pcm, start, end, tmp, index, opus=not builtin)
try:
with open(path, 'rb') as f:
body = f.read()
finally:
os.remove(path)
return index, _send(url, headers, builtin, f'piece-{index}{suffix}', mime, body, options, stop)
pool = ThreadPoolExecutor(max_workers=max(1, min(conf.parallel, 8)))
try:
pending = {pool.submit(work, i) for i in range(len(pieces))}
while pending:
finished, pending = wait(pending, timeout=ALIVE_CHECK, return_when=FIRST_COMPLETED)
_alive(job_id) # also while a long piece is out
for f in finished:
index, answer = f.result() # the first failure ends the job
results[index] = answer
if finished:
_write(job_id, chunks_done=len(results))
finally:
stop.set()
pool.shutdown(wait=True, cancel_futures=True)
return build(pieces, [results[i] for i in range(len(pieces))], options['model'], conf.language)
def _download(url, path, job_id):
size = 0
with open(path, 'wb') as out, requests.get(
url, stream=True, timeout=(10, 60), headers={'User-Agent': 'diora transcription'}) as r:
if r.status_code >= 400:
raise Failed(f'Download: HTTP {r.status_code}')
if int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES:
raise Failed('too large')
for n, chunk in enumerate(r.iter_content(1 << 20)):
size += len(chunk)
if size > silence.MAX_BYTES:
raise Failed('too large')
out.write(chunk)
if n % 8 == 0:
_alive(job_id)
if size == 0:
raise Failed('Download: leere Datei')
def _run_ffmpeg(args, input=None, timeout=DECODE_TIMEOUT):
import imageio_ffmpeg
proc = subprocess.run(
[imageio_ffmpeg.get_ffmpeg_exe(), '-nostdin', '-hide_banner', '-loglevel', 'error', *args],
input=input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', 'replace').strip().splitlines()
raise Failed('ffmpeg: ' + (err[-1] if err else f'exit {proc.returncode}')[:200])
def _decode(source, pcm):
"""The audio at `source` as raw 16 kHz mono 16-bit samples in the file `pcm`."""
_run_ffmpeg(['-vn', '-sn', '-dn', '-i', source, '-ac', '1', '-ar', str(RATE), '-f', 's16le', '-y', pcm])
def pauses_of(pcm):
"""The pauses of the decoded audio as [(middle, length)] in seconds, found with
silence.py's rules (what it cuts out of a pause is centred on the pause)."""
size = int(RATE * WINDOW) * 2
levels = []
with open(pcm, 'rb') as f:
while True:
data = f.read(size * 100000)
if not data:
break
for i in range(0, len(data) - size + 1, size):
levels.append(silence._level_db(data[i:i + size]))
return [((a + b) / 2, b - a) for a, b in silence.cuts(levels)]
def plan(total, pauses):
"""[(start, end)] in seconds covering 0..total: pieces of about CHUNK_SECONDS, each cut at
the longest pause within CUT_WINDOW of where it would fall (right there when there is none)."""
cuts = []
last = 0.0
k = 1
while k * CHUNK_SECONDS < total - MIN_TAIL:
target = k * CHUNK_SECONDS
k += 1
near = [p for p in pauses if abs(p[0] - target) <= CUT_WINDOW and p[0] > last + 1]
at = max(near, key=lambda p: p[1])[0] if near else target
if at <= last or at >= total:
continue
cuts.append(at)
last = at
edges = [0.0, *cuts, total]
return [(edges[i], edges[i + 1]) for i in range(len(edges) - 1)]
def _encode(pcm, start, end, tmp, index, opus):
"""The piece [start, end) of the decoded audio as FLAC or Opus (ogg), in a file."""
with open(pcm, 'rb') as f:
f.seek(int(round(start * RATE)) * 2)
data = f.read(int(round((end - start) * RATE)) * 2)
out = os.path.join(tmp, f'piece-{index}.' + ('ogg' if opus else 'flac'))
codec = ['-c:a', 'libopus', '-b:a', OPUS_BITRATE, '-f', 'ogg'] if opus else ['-c:a', 'flac', '-f', 'flac']
_run_ffmpeg(['-f', 's16le', '-ar', str(RATE), '-ac', '1', '-i', 'pipe:0', *codec, '-y', out], input=data)
return out
def _post(url, headers, builtin, name, mime, body, options):
"""One request; the parsed answer. Permanent for a refusal that won't change, RuntimeError
for trouble that may pass."""
files = {'file': (name, body, mime)}
try:
if builtin:
# The operator's own instance (DIORA_WHISPER_URL) may sit on a private network.
try:
r = requests.post(url, headers=headers, files=files, data=options, timeout=UPLOAD_TIMEOUT)
except requests.ConnectionError as e:
raise Unreachable(e.__class__.__name__)
if r.status_code >= 400:
if r.status_code < 500 and r.status_code not in (408, 429):
raise Permanent(f'HTTP {r.status_code}')
raise RuntimeError(f'HTTP {r.status_code}')
else:
try:
r = chapters._open(url, headers, method='POST', timeout=UPLOAD_TIMEOUT, files=files, data=options)
except chapters.ChaptersInvalid as e:
raise Permanent(str(e))
except chapters.ChaptersUnavailable as e:
raise RuntimeError(str(e))
try:
return r.json()
finally:
r.close()
except requests.RequestException as e:
raise RuntimeError(e.__class__.__name__)
except ValueError:
raise RuntimeError('Antwort ist kein JSON')
def _send(url, headers, builtin, name, mime, body, options, stop):
"""A piece to the instance, retried up to RETRIES times with a growing pause."""
for attempt in range(RETRIES + 1):
if stop.is_set():
raise Cancelled()
try:
return _post(url, headers, builtin, name, mime, body, options)
except Permanent as e:
raise Failed(f'Whisper: {e}')
except Unreachable:
if attempt >= RETRIES:
raise
time.sleep(RETRY_PAUSES[min(attempt, len(RETRY_PAUSES) - 1)])
except RuntimeError as e:
if attempt >= RETRIES:
raise Failed(f'Whisper: {e}')
log.info('whisper piece %s failed (%s), retrying', name, e)
time.sleep(RETRY_PAUSES[min(attempt, len(RETRY_PAUSES) - 1)])
def build(pieces, answers, model, language):
"""{segments, source} from the pieces' places and the instance's verbose_json answers."""
fragments = []
detected = ''
for (start, end), answer in zip(pieces, answers):
if not isinstance(answer, dict):
continue
detected = detected or (answer.get('language') or '')
segments = answer.get('segments')
if not isinstance(segments, list): # a plain {"text": …} answer: one segment for the piece
text = (answer.get('text') or '').strip()
segments = [{'start': 0, 'end': end - start, 'text': text}] if text else []
for s in segments:
if not isinstance(s, dict):
continue
text = ' '.join(str(s.get('text') or '').split())
try:
a, b = float(s.get('start', 0)), float(s.get('end', 0))
except (TypeError, ValueError):
continue
if text:
fragments.append((start + a, start + b, '', text))
merged = transcripts.merge(fragments)
if not merged:
raise Failed('Kein Text erkannt')
return {'segments': merged, 'source': {'type': 'whisper', 'model': model, 'language': language or detected}}

297
podcasts/transcription.py Normal file
View file

@ -0,0 +1,297 @@
"""Transcription settings and the test of the configured Whisper instance
(docs/transcription.md). The job itself is podcasts/transcribe.py."""
import io
import json
import re
import wave
from urllib.parse import urlsplit
import requests
from django.conf import settings
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from feeds import logins
from . import chapters
from .models import EpisodeArticle, PodcastEpisode, TranscriptionJob, TranscriptionSettings
DEFAULT_MODEL = 'whisper-1'
TEST_TIMEOUT = 60 # a cold CPU instance may load its model first
LANGUAGE_RE = re.compile(r'^[a-z]{2,3}(-[A-Za-z]{2,4})?$')
def _auth(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return None
def _body(request):
try:
body = json.loads(request.body or b'{}')
except (ValueError, UnicodeDecodeError):
return None
return body if isinstance(body, dict) else None
def for_user(user):
"""The user's settings (unsaved defaults when there are none)."""
return TranscriptionSettings.objects.filter(user=user).first() or TranscriptionSettings(user=user)
def token_of(conf):
"""The plain API token ('' when there is none or it can't be read)."""
return (logins.decrypt(conf.token_encrypted) or '') if conf.token_encrypted else ''
def _data(conf):
return {
'engine': conf.engine, 'url': conf.url, 'model': conf.model, 'language': conf.language,
'parallel': conf.parallel, 'has_token': bool(conf.token_encrypted),
'builtin_available': bool(settings.WHISPER_URL),
}
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def transcription_settings(request):
if (err := _auth(request)):
return err
conf = for_user(request.user)
if request.method == 'GET':
return JsonResponse(_data(conf))
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
if 'engine' in body:
if body['engine'] not in dict(TranscriptionSettings.ENGINES):
return JsonResponse({'error': 'engine must be builtin or custom'}, status=400)
conf.engine = body['engine']
if 'url' in body:
url = body['url']
if not isinstance(url, str):
return JsonResponse({'error': 'url must be text'}, status=400)
url = url.strip().rstrip('/')
if url and (urlsplit(url).scheme not in ('http', 'https') or not urlsplit(url).hostname):
return JsonResponse({'error': 'url must start with http:// or https://'}, status=400)
if len(url) > 500:
return JsonResponse({'error': 'url too long'}, status=400)
conf.url = url
if 'model' in body:
if not isinstance(body['model'], str) or len(body['model'].strip()) > 100:
return JsonResponse({'error': 'invalid model'}, status=400)
conf.model = body['model'].strip()
if 'language' in body:
lang = body['language']
if not isinstance(lang, str) or (lang.strip() and not LANGUAGE_RE.match(lang.strip())):
return JsonResponse({'error': 'language must be empty or an ISO code'}, status=400)
conf.language = lang.strip()
if 'parallel' in body:
n = body['parallel']
if isinstance(n, bool) or not isinstance(n, int) or not 1 <= n <= 8:
return JsonResponse({'error': 'parallel must be 1 to 8'}, status=400)
conf.parallel = n
if 'token' in body:
if not isinstance(body['token'], str):
return JsonResponse({'error': 'token must be text'}, status=400)
conf.token_encrypted = logins.encrypt(body['token']) if body['token'] else ''
if conf.engine == TranscriptionSettings.CUSTOM and not conf.url:
return JsonResponse({'error': 'a custom instance needs a url'}, status=400)
conf.save()
return JsonResponse(_data(conf))
def silent_wav(seconds=1, rate=16000):
"""`seconds` of 16-bit mono silence as a WAV file, made in memory."""
buf = io.BytesIO()
with wave.open(buf, 'wb') as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(b'\0\0' * rate * seconds)
return buf.getvalue()
@csrf_exempt
@require_http_methods(['POST'])
def transcription_test(request):
"""One second of silence to the configured instance: {ok, model, error}."""
if (err := _auth(request)):
return err
conf = for_user(request.user)
builtin = conf.engine == TranscriptionSettings.BUILTIN
base = settings.WHISPER_URL if builtin else conf.url
model = conf.model or DEFAULT_MODEL
if not base:
error = 'Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen'
return JsonResponse({'ok': False, 'model': model, 'error': error})
url = base.rstrip('/') + '/audio/transcriptions'
headers = {'User-Agent': 'diora transcription test'}
if not builtin and conf.token_encrypted:
headers['Authorization'] = 'Bearer ' + token_of(conf)
files = {'file': ('silence.wav', silent_wav(), 'audio/wav')}
data = {'model': model, 'response_format': 'json'}
if conf.language:
data['language'] = conf.language
try:
if builtin:
# The operator's own instance (DIORA_WHISPER_URL) may sit on a private network.
r = requests.post(url, headers=headers, files=files, data=data, timeout=TEST_TIMEOUT)
if r.status_code >= 400:
raise chapters.ChaptersInvalid(f'HTTP {r.status_code}')
else:
r = chapters._open(url, headers, method='POST', timeout=TEST_TIMEOUT, files=files, data=data)
r.close()
except (chapters.ChaptersInvalid, chapters.ChaptersUnavailable) as e:
return JsonResponse({'ok': False, 'model': model, 'error': str(e)})
except requests.RequestException as e:
return JsonResponse({'ok': False, 'model': model, 'error': e.__class__.__name__})
return JsonResponse({'ok': True, 'model': model, 'error': ''})
# ---------------------------------------------------------------------------
# Jobs
# ---------------------------------------------------------------------------
CLAIM_MINUTES = 15
ACTIVE = (TranscriptionJob.QUEUED, TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING)
def job_state(job):
"""The job as the API shows it; state `none` when there is no job."""
if job is None:
return {'state': TranscriptionJob.NONE, 'chunks_done': 0, 'chunks_total': 0, 'error': '',
'want_article': False, 'started_at': None, 'finished_at': None}
return {
'state': job.state, 'chunks_done': job.chunks_done, 'chunks_total': job.chunks_total,
'error': job.error, 'want_article': job.want_article,
'started_at': job.started_at.isoformat() if job.started_at else None,
'finished_at': job.finished_at.isoformat() if job.finished_at else None,
}
def job_of(episode):
return TranscriptionJob.objects.filter(episode=episode).first()
def _too_large(url):
"""Whether the server says the file at `url` is over the size limit (a failed look doesn't count)."""
from . import silence
try:
r = requests.head(url, timeout=5, allow_redirects=True, headers={'User-Agent': 'diora transcription'})
return int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES
except (requests.RequestException, ValueError):
return False
@csrf_exempt
@require_http_methods(['GET', 'POST', 'DELETE'])
def episode_transcribe(request, pk):
"""POST starts the episode's transcription (or just raises `want_article` on one that exists),
GET tells how far it is, DELETE cancels a queued or running one."""
from . import transcribe, views
from .youtube import is_video
if (err := _auth(request)):
return err
ep = PodcastEpisode.objects.select_related('feed').filter(pk=pk, feed__user=request.user).first()
if ep is None:
return JsonResponse({'error': 'not found'}, status=404)
job = job_of(ep)
if request.method == 'GET':
transcribe.kick() # after a restart the first request brings the work back
return JsonResponse(job_state(job))
if request.method == 'DELETE':
if job is not None and job.state != TranscriptionJob.DONE:
job.delete() # a running worker notices its row is gone and stops
job = None
return JsonResponse(job_state(job))
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
article = body.get('article') is True
if is_video(ep.audio_url):
return JsonResponse({'error': 'youtube'}, status=400)
has_feed = views._has_transcript(ep)
if job is not None and (job.state in ACTIVE or (job.state == TranscriptionJob.DONE and (job.data or has_feed))):
if article and not job.want_article:
job.want_article = True
job.save(update_fields=['want_article'])
return JsonResponse(job_state(job))
if has_feed: # the feed's own transcript is there: only the article is wanted
if job is None:
job = TranscriptionJob(episode=ep)
job.state, job.want_article, job.finished_at = TranscriptionJob.DONE, article or job.want_article, timezone.now()
job.save()
return JsonResponse(job_state(job))
if not ep.audio_url:
return JsonResponse({'error': 'no audio'}, status=400)
if _too_large(ep.audio_url):
return JsonResponse({'error': 'too large'}, status=400)
if job is not None:
job.delete() # a failed or hollow earlier run: the new one starts at the back of the queue
job = TranscriptionJob.objects.create(episode=ep, state=TranscriptionJob.QUEUED, want_article=article)
transcribe.kick()
return JsonResponse(job_state(job))
def _wishes(user):
"""The user's jobs whose transcript is there and whose article is wanted but not written."""
return (TranscriptionJob.objects
.filter(episode__feed__user=user, state=TranscriptionJob.DONE, want_article=True)
.exclude(episode__in=EpisodeArticle.objects.values('episode')))
@csrf_exempt
@require_http_methods(['GET'])
def transcription_pending(request):
"""The open wishes for an article that no device holds right now."""
from . import transcribe
if (err := _auth(request)):
return err
transcribe.kick()
jobs = (_wishes(request.user).exclude(claimed_until__gt=timezone.now())
.select_related('episode__feed').order_by('id'))
return JsonResponse([{
'episode_id': j.episode_id, 'title': j.episode.title, 'podcast': j.episode.feed.title,
'duration_seconds': j.episode.duration_seconds,
} for j in jobs], safe=False)
@csrf_exempt
@require_http_methods(['POST', 'DELETE'])
def article_claim(request, pk):
"""Reserve the wish for an article for the calling device for CLAIM_MINUTES (POST), or give
it up (DELETE). The device names itself with `device` in the JSON body or the
X-Diora-Device header. 409 when another device holds it."""
from datetime import timedelta
if (err := _auth(request)):
return err
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
device = body.get('device') or request.META.get('HTTP_X_DIORA_DEVICE') or ''
if not isinstance(device, str) or not device.strip():
return JsonResponse({'error': 'device missing'}, status=400)
device = device.strip()[:60]
job = _wishes(request.user).filter(episode_id=pk).first()
if job is None:
return JsonResponse({'error': 'no open wish'}, status=404)
now = timezone.now()
if job.claimed_until and job.claimed_until > now and job.claimed_by != device:
return JsonResponse({'error': 'claimed', 'claimed_until': job.claimed_until.isoformat()}, status=409)
if request.method == 'DELETE':
job.claimed_by, job.claimed_until = '', None
else:
job.claimed_by, job.claimed_until = device, now + timedelta(minutes=CLAIM_MINUTES)
job.save(update_fields=['claimed_by', 'claimed_until'])
return JsonResponse({'claimed_by': job.claimed_by,
'claimed_until': job.claimed_until.isoformat() if job.claimed_until else None})

View file

@ -1,5 +1,5 @@
from django.urls import path from django.urls import path
from . import audiobooks, discover, folders, radio, views from . import article, audiobooks, discover, folders, radio, transcription, views
urlpatterns = [ urlpatterns = [
path('search/', views.podcast_search, name='podcast_search'), path('search/', views.podcast_search, name='podcast_search'),
@ -28,9 +28,15 @@ urlpatterns = [
path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'), path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'), path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'), path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
path('episodes/<int:pk>/article/', article.episode_article, name='podcast_episode_article'),
path('episodes/<int:pk>/silences/', views.episode_silences, name='podcast_episode_silences'), path('episodes/<int:pk>/silences/', views.episode_silences, name='podcast_episode_silences'),
path('episodes/<int:pk>/sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'), path('episodes/<int:pk>/sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'),
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'), path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
path('transcription/settings/', transcription.transcription_settings, name='podcast_transcription_settings'),
path('transcription/test/', transcription.transcription_test, name='podcast_transcription_test'),
path('transcription/pending/', transcription.transcription_pending, name='podcast_transcription_pending'),
path('episodes/<int:pk>/transcribe/', transcription.episode_transcribe, name='podcast_episode_transcribe'),
path('episodes/<int:pk>/article/claim/', transcription.article_claim, name='podcast_article_claim'),
path('stats/', views.podcast_stats, name='podcast_stats'), path('stats/', views.podcast_stats, name='podcast_stats'),
path('queue/', views.queue_get, name='podcast_queue_get'), path('queue/', views.queue_get, name='podcast_queue_get'),
path('queue/add/', views.queue_add, name='podcast_queue_add'), path('queue/add/', views.queue_add, name='podcast_queue_add'),

View file

@ -29,11 +29,12 @@ from . import chapters as chapters_mod
from . import silence as silence_mod from . import silence as silence_mod
from . import sponsor as sponsor_mod from . import sponsor as sponsor_mod
from . import feedext from . import feedext
from . import transcription as transcription_mod
from . import transcripts as transcripts_mod from . import transcripts as transcripts_mod
from . import youtube as youtube_mod from . import youtube as youtube_mod
from . import folders as folders_mod from . import folders as folders_mod
from .models import ( from .models import (
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater, EpisodeArticle, EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played, PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
) )
from .shownotes import extract_timestamps, sanitize_html from .shownotes import extract_timestamps, sanitize_html
@ -231,7 +232,7 @@ def _later_data(user):
.order_by(F('episode__pub_date').desc(nulls_last=True), '-id') .order_by(F('episode__pub_date').desc(nulls_last=True), '-id')
.values( .values(
'id', 'added_at', 'auto', 'id', 'added_at', 'auto',
'episode__id', 'episode__title', 'episode__audio_url', 'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
'episode__duration_seconds', 'episode__artwork_url', 'episode__duration_seconds', 'episode__artwork_url',
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url', 'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
'episode__pub_date', 'episode__pub_date',
@ -383,7 +384,7 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts,
return bool(changed) return bool(changed)
EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at', EXTRA_FIELDS = ['link', 'description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
'title', 'pub_date', 'episode_number'] 'title', 'pub_date', 'episode_number']
@ -642,6 +643,12 @@ def _move_to_permanent_url(feed_obj, new_url):
feed_obj.rss_url = new_url feed_obj.rss_url = new_url
def _entry_link(entry):
"""The item's <link>, the episode's own page; '' unless it is an http(s) address."""
link = (entry.get('link') or '').strip()
return link[:1000] if re.match(r'^https?://', link, re.I) else ''
def _entry_media_url(entry): def _entry_media_url(entry):
"""The episode's file: its audio enclosure, else a video one (the players """The episode's file: its audio enclosure, else a video one (the players
play only its sound). '' when there is neither.""" play only its sound). '' when there is neither."""
@ -713,6 +720,7 @@ def _apply_feed(feed_obj, fetched, full=False):
audio_url = _entry_media_url(entry) audio_url = _entry_media_url(entry)
if not audio_url: if not audio_url:
continue continue
link = _entry_link(entry)
guid = entry.get('id') or entry.get('guid') or audio_url guid = entry.get('id') or entry.get('guid') or audio_url
guid = str(guid)[:1000] guid = str(guid)[:1000]
@ -737,7 +745,7 @@ def _apply_feed(feed_obj, fetched, full=False):
known = existing.get(guid) known = existing.get(guid)
if known is not None: if known is not None:
renamed = {'title': title, 'episode_number': ep_number} renamed = {'title': title, 'episode_number': ep_number, 'link': link}
if pub_date is not None: if pub_date is not None:
renamed['pub_date'] = pub_date renamed['pub_date'] = pub_date
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed): if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
@ -762,6 +770,7 @@ def _apply_feed(feed_obj, fetched, full=False):
'title': title, 'title': title,
'description': description, 'description': description,
'audio_url': audio_url[:1000], 'audio_url': audio_url[:1000],
'link': link,
'duration_seconds': duration_seconds, 'duration_seconds': duration_seconds,
'pub_date': pub_date, 'pub_date': pub_date,
'episode_number': ep_number, 'episode_number': ep_number,
@ -1324,7 +1333,7 @@ def feed_episodes(request, pk):
episodes = list( episodes = list(
feed.episodes.values( feed.episodes.values(
'id', 'title', 'description', 'audio_url', 'duration_seconds', 'id', 'title', 'description', 'audio_url', 'link', 'duration_seconds',
'pub_date', 'artwork_url', 'episode_number', 'season_number', 'pub_date', 'artwork_url', 'episode_number', 'season_number',
) )
) )
@ -1424,6 +1433,9 @@ def episode_detail(request, pk):
chapters = _load_chapters(ep) chapters = _load_chapters(ep)
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first() progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
job = transcription_mod.job_of(ep)
origin = _transcript_origin(ep, job)
article = EpisodeArticle.objects.filter(episode=ep).first()
return JsonResponse({ return JsonResponse({
'id': ep.id, 'id': ep.id,
'title': ep.title, 'title': ep.title,
@ -1431,9 +1443,13 @@ def episode_detail(request, pk):
'timestamps': extract_timestamps(ep.description, ep.duration_seconds), 'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
'chapters': chapters, 'chapters': chapters,
'transcripts': ep.transcripts, 'transcripts': ep.transcripts,
'has_transcript': _has_transcript(ep), 'has_transcript': origin is not None,
'transcript_origin': origin,
'article_item_id': article.item_id if article else None,
'transcription': transcription_mod.job_state(job) if job else None,
'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url}, 'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
'audio_url': ep.audio_url, 'audio_url': ep.audio_url,
'link': ep.link,
'artwork_url': ep.artwork_url, 'artwork_url': ep.artwork_url,
'duration_seconds': ep.duration_seconds, 'duration_seconds': ep.duration_seconds,
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None, 'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
@ -1461,6 +1477,13 @@ def _has_transcript(ep):
return not (cached and cached[0] == sources and cached[1]) return not (cached and cached[0] == sources and cached[1])
def _transcript_origin(ep, job=None):
""""feed" when the feed has a usable transcript, else "whisper" when we made one, else None."""
if _has_transcript(ep):
return 'feed'
return 'whisper' if job is not None and job.state == 'done' and job.data else None
@csrf_exempt @csrf_exempt
@require_http_methods(['GET']) @require_http_methods(['GET'])
def episode_silences(request, pk): def episode_silences(request, pk):
@ -1503,20 +1526,28 @@ def episode_sponsor(request, pk):
def episode_transcript(request, pk): def episode_transcript(request, pk):
"""The episode's transcript as {"segments": [{start, end, speaker, text}], """The episode's transcript as {"segments": [{start, end, speaker, text}],
"source": {url, type, language}}, fetched and parsed on first use, then "source": {url, type, language}}, fetched and parsed on first use, then
kept. 404 {"error": "no transcript" | "unsupported" | "invalid"} when kept. When the feed has none we can use, the one Whisper made (a finished
there's none to show; 502 {"error": "unavailable"} when it couldn't be TranscriptionJob, docs/transcription.md). 404 {"error": "no transcript" |
loaded right now.""" "unsupported" | "invalid"} when there's none to show; 502
{"error": "unavailable"} when it couldn't be loaded right now."""
if not request.user.is_authenticated: if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401) return JsonResponse({'error': 'authentication required'}, status=401)
try: try:
ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user) ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
except PodcastEpisode.DoesNotExist: except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404) return JsonResponse({'error': 'not found'}, status=404)
def fallback(error, status=404):
job = transcription_mod.job_of(ep)
if job is not None and job.state == 'done' and job.data:
return JsonResponse(job.data)
return JsonResponse({'error': error}, status=status)
if not ep.transcripts: if not ep.transcripts:
return JsonResponse({'error': 'no transcript'}, status=404) return fallback('no transcript')
sources = _transcript_sources(ep) sources = _transcript_sources(ep)
if not sources: if not sources:
return JsonResponse({'error': 'unsupported'}, status=404) return fallback('unsupported')
cached = EpisodeTranscript.objects.filter(episode=ep).first() cached = EpisodeTranscript.objects.filter(episode=ep).first()
if cached is not None and cached.sources == sources: if cached is not None and cached.sources == sources:
@ -1527,7 +1558,7 @@ def episode_transcript(request, pk):
error = '' error = ''
except chapters_mod.FetchUnavailable as e: except chapters_mod.FetchUnavailable as e:
log.info('transcript of episode %s not available: %s', ep.id, e) log.info('transcript of episode %s not available: %s', ep.id, e)
return JsonResponse({'error': 'unavailable'}, status=502) return fallback('unavailable', 502)
except transcripts_mod.Unsupported as e: except transcripts_mod.Unsupported as e:
log.info('transcript of episode %s unsupported: %s', ep.id, e) log.info('transcript of episode %s unsupported: %s', ep.id, e)
data, error, final = None, 'unsupported', True data, error, final = None, 'unsupported', True
@ -1539,7 +1570,7 @@ def episode_transcript(request, pk):
episode=ep, defaults={'sources': sources, 'data': data, 'error': error}, episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
) )
if error: if error:
return JsonResponse({'error': error}, status=404) return fallback(error)
return JsonResponse(data) return JsonResponse(data)
@ -1645,7 +1676,7 @@ EPISODE_IDS_MAX = 1000
EPISODE_LIST_LIMIT = 100 EPISODE_LIST_LIMIT = 100
EPISODE_LIST_MAX = 500 EPISODE_LIST_MAX = 500
EPISODE_LIST_FIELDS = ( EPISODE_LIST_FIELDS = (
'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url', 'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url', 'feed__id', 'feed__title', 'feed__artwork_url',
'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at', 'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at',
) )
@ -2010,7 +2041,7 @@ def _queue_data(user):
.select_related('episode__feed') .select_related('episode__feed')
.values( .values(
'id', 'position', 'id', 'position',
'episode__id', 'episode__title', 'episode__audio_url', 'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
'episode__duration_seconds', 'episode__artwork_url', 'episode__duration_seconds', 'episode__artwork_url',
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url', 'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
'episode__pub_date', 'episode__pub_date',
@ -2506,7 +2537,7 @@ def inbox(request):
def _inbox_fields(with_description=True): def _inbox_fields(with_description=True):
fields = ['id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url', fields = ['id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url'] 'feed__id', 'feed__title', 'feed__artwork_url']
if with_description: if with_description:
fields.insert(2, 'description') fields.insert(2, 'description')

View file

@ -734,6 +734,19 @@ html[data-theme="light"] .form-errors { background: #fdecee; border-color: #e7a3
font-size: 0.75rem; font-size: 0.75rem;
} }
.auth-alt { margin-top: 1rem; }
.auth-or {
display: flex; align-items: center; gap: 0.6rem;
margin: 0 0 1rem; font-size: 0.8rem; color: var(--text-muted);
}
.auth-or::before, .auth-or::after { content: ''; flex: 1; border-top: 1px solid var(--border, #333); }
.auth-alt .btn-full { display: block; text-align: center; text-decoration: none; }
/* Unlocking the books on a device without the key (js/app/33-unlock.js) */
.unlock-code { font: 600 1.6rem/1.2 ui-monospace, monospace; letter-spacing: 0.15em; text-align: center; margin: 4px 0 14px; }
.unlock-or { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 8px; }
.unlock-status { font-size: 0.85rem; color: var(--text-muted); min-height: 1.2em; margin: 0 0 10px; }
.auth-switch { .auth-switch {
margin-top: 1.25rem; margin-top: 1.25rem;
font-size: 0.85rem; font-size: 0.85rem;
@ -1792,14 +1805,25 @@ body.dnd-mode .timer-display {
.pdf-page-inner { position:relative; display:inline-block; line-height:0; } .pdf-page-inner { position:relative; display:inline-block; line-height:0; }
/* PDF text layer — sits flush over the canvas inside pdf-page-inner */ /* PDF text layer — sits flush over the canvas inside pdf-page-inner */
.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; } .pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; line-height:1; -webkit-text-size-adjust:none; text-size-adjust:none; z-index:1; }
.pdf-text-layer span { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; } .pdf-text-layer span, .pdf-text-layer br { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; transform-origin:0 0; }
.pdf-text-layer span::selection { background:rgba(0,120,255,0.3); } .pdf-text-layer span::selection, .pdf-text-layer mark::selection { background:rgba(0,120,255,0.3); }
.pdf-text-layer span.reader-search-match { background:rgba(241,196,15,.5); } .pdf-text-layer br::selection { background:transparent; }
.pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); } .pdf-text-layer mark.reader-search-match { color:transparent; background:rgba(241,196,15,.5); padding:0; border-radius:2px; }
.pdf-text-layer mark.reader-search-match.active { background:rgba(230,57,70,.6); }
/* PDF links — over the text, so a tap on one follows it */
.pdf-link-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:2; }
.pdf-link { position:absolute; display:block; pointer-events:auto; border-radius:2px; }
.pdf-link:hover, .pdf-link:focus-visible { background:rgba(0,120,255,0.15); outline:none; }
.pdf-back-btn { position:absolute; left:50%; bottom:calc(var(--bar-h, 0px) + 24px); transform:translateX(-50%); z-index:20; box-shadow:0 2px 8px rgba(0,0,0,.25); }
/* PDF invert */ /* PDF invert */
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); } /* Dark pages: inverted, and the hues turned back, so photos and coloured figures aren't negatives. */
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1) hue-rotate(180deg); }
/* Leserfarben (58-pdf.js _pdfColorFilter): the page recoloured, its pictures over it as printed */
.pdf-page.pdf-recolored { filter:url(#pdf-reader-colors); }
.pdf-image-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }
.pdf-image-layer canvas { position:absolute; display:block; }
/* ---- Immersive reader mode ---- */ /* ---- Immersive reader mode ---- */
.navbar { transition: transform 0.35s ease; } .navbar { transition: transform 0.35s ease; }
@ -1824,8 +1848,17 @@ html.nui-tts-on body.reader-immersive .now-playing-bar { transform: none; }
html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); } html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); }
/* PDF paginated */ /* PDF paginated */
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; } /* Page by page the page fills the whole reading area, without its padding: 66-pdf-paginated.js
measures the area and places the page by its text box itself. */
.reader-content.pdf-paginated { overflow:hidden !important; display:block; padding:0 !important; }
/* The pages are as wide as they're drawn, not the text column's line length (--reader-max-width);
zoomed wider than the screen, scrolling reaches their left edge too (not centred past it). */
.reader-content > #pdf-viewport { max-width:none; }
.reader-content:not(.pdf-paginated) > #pdf-viewport { width:max-content; min-width:100%; }
.reader-content.pdf-paginated > #pdf-viewport { width:100%; height:100%; max-width:none; margin:0; }
.pdf-paginated .pdf-page-wrapper { margin:0; } .pdf-paginated .pdf-page-wrapper { margin:0; }
.pdf-paginated .pdf-page-wrapper { position:relative; }
.pdf-paginated .pdf-page-inner { position:absolute; left:0; top:0; }
/* PDF loading overlay */ /* PDF loading overlay */
.pdf-loading-overlay { .pdf-loading-overlay {
@ -1866,6 +1899,57 @@ html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); }
.footnote-popover.reader-theme-sepia { background:#ede0c4; color:#3b2a1a; border-color:#c8b89a; } .footnote-popover.reader-theme-sepia { background:#ede0c4; color:#3b2a1a; border-color:#c8b89a; }
.footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; } .footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; }
.footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; } .footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; }
.footnote-popover > :first-child { margin-top:0; }
.footnote-popover > :last-child { margin-bottom:0; }
/* ---- Reading in pages (63-pages.js) ----
Only the current chapter is laid out; the others stay in the DOM (block
indices, search, notes, anchors). No free scrolling: the pager moves. */
#reader-content.nui-window > [data-epub-src]:not(.nui-ch-current) { display:none; }
/* Room below the chapter, so its last page can show just the remaining text
(then the page's colour) instead of scrolling back over lines already read. */
#reader-content.nui-window::after { content:""; display:block; height:100vh; }
#reader-content.nui-window { overflow-y:hidden; -ms-overflow-style:none; scrollbar-width:none; }
#reader-content.nui-window::-webkit-scrollbar { display:none; }
/* Masks: the line cut at the page's bottom and whatever sits in the top gap. */
.nui-page-mask { position:fixed; z-index:250; pointer-events:none; display:none; }
/* Footnotes at the foot of the page, like a printed book. */
.nui-page-notes { position:fixed; z-index:255; overflow:hidden; padding:0 0 4px; box-sizing:border-box;
line-height:1.4; font-weight:400; cursor:pointer; }
.nui-pn-rule { width:30%; border-top:1px solid currentColor; margin:2px 0 6px; }
.nui-pn-item { margin:0 0 4px; padding-left:1.6em; text-indent:-1.6em; }
.nui-pn-item p, .nui-pn-item div, .nui-pn-item aside, .nui-pn-item li { display:inline; margin:0; padding:0; text-indent:0; }
.nui-pn-item p + p::before { content:" "; }
.nui-pn-label { display:inline-block; min-width:1.6em; text-indent:0; font-weight:700; }
.nui-pn-item a { color:inherit; text-decoration:none; }
.nui-pn-item img { display:none; }
/* Cut off: say so, a tap shows the whole note. */
.nui-page-notes.nui-pn-cut::after { content:"… mehr"; position:absolute; right:0; bottom:2px; padding-left:8px;
background:inherit; font-weight:700; }
/* Two pages side by side (a spread, 63-pages.js): the right one is a copy of
the chapter, scrolled to the page after the left one. */
#reader-spread-page { display:none; }
#reader-overlay.reader-spread #reader-content,
#reader-overlay.reader-spread #reader-spread-page { flex:1 1 0; min-width:0; }
#reader-overlay.reader-spread #reader-spread-page { display:block; overflow:hidden; box-shadow:inset 1px 0 0 var(--border);
user-select:none; -webkit-user-select:none; cursor:default; }
#reader-spread-page::after { content:""; display:block; height:100vh; }
/* An article in the reader (65-article.js): what only books have stays out. */
#reader-overlay.reader-article-mode .reader-progress-wrap,
#reader-overlay.reader-article-mode #reader-bookmark-btn,
#reader-overlay.reader-article-mode #reader-bm-list-btn,
#reader-overlay.reader-article-mode #reader-toc-btn,
#reader-overlay.reader-article-mode #reader-margin-btn,
#reader-overlay.reader-article-mode #reader-reset-pos-btn,
#reader-overlay.reader-article-mode .marker-mode-btn,
#reader-overlay.reader-article-mode #reader-margin,
#reader-overlay.reader-article-mode .nui-toc { display:none !important; }
.reader-article-actions { display:inline-flex; gap:10px; align-items:center; }
/* The browser's line under the page (the apps have their own footer). */
#reader-page-footer { display:flex; justify-content:space-between; gap:12px; flex-shrink:0; padding:4px 16px 6px;
font-size:12px; color:var(--muted,#888); font-variant-numeric:tabular-nums; }
.reader-theme-sepia #reader-page-footer { background:#f5e6c8; color:#7a6650; }
.reader-theme-bright #reader-page-footer { background:#fff; color:#777; }
/* Page numbers of the printed edition (epub:type="pagebreak") */ /* Page numbers of the printed edition (epub:type="pagebreak") */
.reader-content .epub-pagenum { display:inline-block; font-size:0.62em; line-height:1.3; font-weight:400; font-style:normal; letter-spacing:0; text-transform:none; vertical-align:0.35em; padding:0 0.35em; margin-right:0.4em; border:1px solid currentColor; border-radius:3px; opacity:0.45; user-select:none; } .reader-content .epub-pagenum { display:inline-block; font-size:0.62em; line-height:1.3; font-weight:400; font-style:normal; letter-spacing:0; text-transform:none; vertical-align:0.35em; padding:0 0.35em; margin-right:0.4em; border:1px solid currentColor; border-radius:3px; opacity:0.45; user-select:none; }

View file

@ -7,7 +7,7 @@
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles, // server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
// authors, folder names …). Nothing is changed without the user ticking it in review(). // authors, folder names …). Nothing is changed without the user ticking it in review().
// //
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema}), review(…)} // window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema, effort, maxTokens, onProgress, signal}), review(…)}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs'; import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
@ -116,12 +116,17 @@ function parseAnswer(text) {
} }
} }
// Words in a text so far (for onProgress).
const countWords = t => { const m = String(t || '').match(/\S+/g); return m ? m.length : 0; };
// One request with a JSON answer of `schema` (structured outputs). // One request with a JSON answer of `schema` (structured outputs).
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) { // Optional: onProgress(words) while the answer is written (throttled; OpenRouter is not streamed,
// it reports once at the end), signal (an AbortSignal) to give up.
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000, onProgress, signal}) {
const v = await loadVault(); const v = await loadVault();
const key = keyOf(v); const key = keyOf(v);
if (!key) throw fail('Kein API-Key hinterlegt.'); if (!key) throw fail('Kein API-Key hinterlegt.');
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens}); if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal});
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model. // Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
const stream = client(key).beta.messages.stream({ const stream = client(key).beta.messages.stream({
model: PROVIDERS.anthropic.model, model: PROVIDERS.anthropic.model,
@ -131,8 +136,18 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000})
system, system,
messages: [{role: 'user', content: prompt}], messages: [{role: 'user', content: prompt}],
output_config: {effort, format: {type: 'json_schema', schema}}, output_config: {effort, format: {type: 'json_schema', schema}},
}); }, signal ? {signal} : undefined);
if (onProgress) {
let last = 0;
stream.on('text', (delta, snapshot) => {
const now = Date.now();
if (now - last < 400) return;
last = now;
try { onProgress(countWords(snapshot)); } catch (e) { /* the caller's display must not break the call */ }
});
}
const msg = await stream.finalMessage(); const msg = await stream.finalMessage();
if (onProgress) { try { onProgress(countWords(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''))); } catch (e) { /* see above */ } }
if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.'); if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.');
if (msg.stop_reason === 'max_tokens') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.'); if (msg.stop_reason === 'max_tokens') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join('')); return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''));
@ -149,8 +164,8 @@ async function openRouterFetch(key, path, init) {
return r.json(); return r.json();
} }
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) { async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}) {
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', body: JSON.stringify({ const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', signal, body: JSON.stringify({
model: PROVIDERS.openrouter.model, model: PROVIDERS.openrouter.model,
max_tokens: maxTokens, max_tokens: maxTokens,
reasoning: {effort}, reasoning: {effort},
@ -161,7 +176,9 @@ async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
if (!choice) throw fail('Die Antwort ließ sich nicht lesen.'); if (!choice) throw fail('Die Antwort ließ sich nicht lesen.');
if (choice.message && choice.message.refusal) throw fail('Claude hat diese Anfrage abgelehnt.'); if (choice.message && choice.message.refusal) throw fail('Claude hat diese Anfrage abgelehnt.');
if (choice.finish_reason === 'length') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.'); if (choice.finish_reason === 'length') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
return parseAnswer((choice.message && choice.message.content) || ''); const text = (choice.message && choice.message.content) || '';
if (onProgress) { try { onProgress(countWords(text)); } catch (e) { /* the caller's display must not break the call */ } }
return parseAnswer(text);
} }
// Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.) // Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.)
@ -361,6 +378,8 @@ window.__dioraBack = function () {
return back ? back.apply(this, arguments) : false; return back ? back.apply(this, arguments) : false;
}; };
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), settings, withKey, ask, review, progress, showError, const modelName = () => PROVIDERS[providerOf(vault)].model;
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), modelName, settings, withKey, ask, review, progress, showError,
close, toast, explain}; close, toast, explain};
window.dispatchEvent(new Event('diora-ai')); window.dispatchEvent(new Event('diora-ai'));

View file

@ -37,9 +37,12 @@ document.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); } if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); }
if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); } if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); }
} }
if (readerSettings.pdfPaginated && currentPdfDoc) { // PDF page by page: arrows, Space, PageUp/PageDown turn (scrolling has its own keys below).
if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } if (readerSettings.pdfPaginated && currentPdfDoc && !readerSearchOpen
if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } && !/^(INPUT|TEXTAREA|SELECT)$/.test((document.activeElement && document.activeElement.tagName) || '')) {
const turn = {ArrowRight: 1, ArrowDown: 1, PageDown: 1, ' ': e.shiftKey ? -1 : 1,
ArrowLeft: -1, ArrowUp: -1, PageUp: -1}[e.key];
if (turn) { e.preventDefault(); pdfGoToPage(pdfCurrentPage + turn); return; }
} }
// Vim-style scroll — ignore when typing in an input // Vim-style scroll — ignore when typing in an input

View file

@ -41,10 +41,19 @@ async function getOrCreateEncKey() {
return _encKey; return _encKey;
} catch (e) { /* fall through, generate new */ } } catch (e) { /* fall through, generate new */ }
} }
// No key found — generate one and store it // Just signed in with the password: the login form left the derived key (99-init.js
_encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); // moves it too, but this may run first).
const raw = await crypto.subtle.exportKey('raw', _encKey); const pending = window.USER_ID ? localStorage.getItem('diora_pending_enc_key') : null;
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw))); if (pending) {
localStorage.setItem(storageKey, pending);
localStorage.removeItem('diora_pending_enc_key');
return getOrCreateEncKey();
}
// No key on this device (signed in through Authelia, or the storage was cleared): never
// make a new one — that would lock the existing books away. Ask for the real one
// (33-unlock.js); throws DioraLocked when the user puts it off.
const raw = await dioraUnlock();
_encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
return _encKey; return _encKey;
} }

254
static/js/app/33-unlock.js Normal file
View file

@ -0,0 +1,254 @@
'use strict';
// ---------------------------------------------------------------------------
// Unlocking the books on a device without the key (accounts/keyshare.py)
//
// A password login derives the book key on the device (login.html). A device signed in
// another way (Authelia, accounts/oidc.py) has none: getOrCreateEncKey() then asks here
// instead of making a new key. Two ways in:
// - an unlocked device of the account hands it over: this device posts an ECDH P-256
// public key, the other one shows the same 6-digit code (made from that key), the user
// approves there and the book key comes back encrypted for this device only;
// - or the diora password, the same derivation as at login.
// The server only ever sees public keys and ciphertext.
// ---------------------------------------------------------------------------
const KEYSHARE_INFO = new TextEncoder().encode('diora keyshare v1');
let _unlockPromise = null;
let _unlockDismissed = false;
function dioraLockedError() {
const e = new Error('Bücher sind auf diesem Gerät noch nicht entsperrt');
e.name = 'DioraLocked';
return e;
}
function _csrf() {
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
}
async function _ksFetch(url, body) {
const opts = body === undefined ? {credentials: 'same-origin'} : {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': _csrf()},
body: JSON.stringify(body),
};
const res = await fetch(url, opts);
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
// 6-digit code from the asking device's public key, shown on both devices.
async function keyshareCode(pubB64) {
const h = new Uint8Array(await crypto.subtle.digest('SHA-256', base64ToBytes(pubB64)));
const n = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0;
const s = String(n % 1000000).padStart(6, '0');
return s.slice(0, 3) + ' ' + s.slice(3);
}
async function _sharedAesKey(privateKey, otherPubB64) {
const otherPub = await crypto.subtle.importKey('raw', base64ToBytes(otherPubB64),
{name: 'ECDH', namedCurve: 'P-256'}, false, []);
const bits = await crypto.subtle.deriveBits({name: 'ECDH', public: otherPub}, privateKey, 256);
const hk = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
return crypto.subtle.deriveKey({name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: KEYSHARE_INFO},
hk, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
}
function _deviceName() {
const ua = navigator.userAgent || '';
const app = /diora-desktop|Electron/i.test(ua) ? 'diora Desktop'
: /; wv\)|diora/i.test(ua) && /Android/i.test(ua) ? 'diora App'
: /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge'
: /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
const os = /Android/i.test(ua) ? 'Android' : /iPhone|iPad/i.test(ua) ? 'iOS'
: /Windows/i.test(ua) ? 'Windows' : /Mac OS/i.test(ua) ? 'macOS' : /Linux/i.test(ua) ? 'Linux' : '';
return os ? `${app} auf ${os}` : app;
}
// Is this raw key the account's book key? Tried on one book's metadata; without books
// there is nothing to compare with, so it is taken.
async function _keyFits(raw) {
let books = [];
try { books = await _ksFetch('/books/'); } catch (e) { return true; }
const b = Array.isArray(books) ? books.find(x => x && x.meta_ct && x.meta_iv) : null;
if (!b) return true;
try {
const key = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['decrypt']);
await decryptBytes(key, b.meta_iv, b.meta_ct);
return true;
} catch (e) { return false; }
}
function _storeRawKey(raw) {
localStorage.setItem(`diora_enc_key_${window.USER_ID || 0}`, bytesToBase64(new Uint8Array(raw)));
}
function _el(tag, attrs, text) {
const el = document.createElement(tag);
for (const k in (attrs || {})) el.setAttribute(k, attrs[k]);
if (text) el.textContent = text;
return el;
}
function _openDialog(build) {
const overlay = _el('div', {class: 'modal-overlay'});
const dialog = _el('div', {class: 'modal-dialog', role: 'dialog', 'aria-modal': 'true'});
document.body.append(overlay, dialog);
const close = () => { overlay.remove(); dialog.remove(); };
build(dialog, close);
return close;
}
// Ask for the key. Resolves with the raw key (already stored), rejects with DioraLocked.
function dioraUnlock(opts) {
opts = opts || {};
if (_unlockPromise) return _unlockPromise;
if (_unlockDismissed && !opts.force) return Promise.reject(dioraLockedError());
_unlockPromise = new Promise((resolve, reject) => {
let stopped = false;
let pair = null, token = null;
const finish = (raw) => {
stopped = true; close(); _unlockPromise = null; _unlockDismissed = false;
_storeRawKey(raw);
resolve(raw);
document.dispatchEvent(new CustomEvent('diora:unlocked'));
};
const close = _openDialog((d, closeFn) => {
d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Bücher entsperren'));
d.append(_el('p', {class: 'modal-message'},
'Deine Bücher sind mit deinem diora-Passwort verschlüsselt. Öffne diora auf einem Gerät, '
+ 'auf dem sie schon offen sind, und bestätige dort diesen Code:'));
const code = _el('div', {class: 'unlock-code'}, '··· ···');
const status = _el('p', {class: 'unlock-status'}, 'Frage an deine anderen Geräte …');
d.append(code, status);
d.append(_el('p', {class: 'unlock-or'}, 'Oder gib hier dein diora-Passwort ein:'));
const pw = _el('input', {type: 'password', class: 'modal-input', autocomplete: 'current-password',
placeholder: 'diora-Passwort'});
d.append(pw);
const actions = _el('div', {class: 'modal-actions'});
const later = _el('button', {class: 'btn', type: 'button'}, 'Später');
const ok = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Entsperren');
actions.append(later, ok);
d.append(actions);
const tryPassword = async () => {
if (!pw.value) { pw.focus(); return; }
ok.disabled = true;
status.textContent = 'Schlüssel wird abgeleitet …';
try {
const enc = new TextEncoder();
const username = document.querySelector('meta[name="username"]')?.content || '';
const mat = await crypto.subtle.importKey('raw', enc.encode(pw.value), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'},
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key));
if (!(await _keyFits(raw))) { status.textContent = 'Das Passwort passt nicht zu deinen Büchern.'; ok.disabled = false; return; }
finish(raw);
} catch (e) {
status.textContent = 'Fehler: ' + e.message; ok.disabled = false;
}
};
ok.addEventListener('click', tryPassword);
pw.addEventListener('keydown', (e) => { if (e.key === 'Enter') tryPassword(); });
later.addEventListener('click', () => {
stopped = true; closeFn(); _unlockPromise = null; _unlockDismissed = true;
reject(dioraLockedError());
});
// Ask the other devices.
(async () => {
try {
pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']);
const pub = bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)));
token = (await _ksFetch('/accounts/keyshare/', {pub, device: _deviceName()})).token;
code.textContent = await keyshareCode(pub);
status.textContent = 'Warte auf Freigabe …';
while (!stopped) {
await new Promise(r => setTimeout(r, 3000));
if (stopped) break;
let st;
try { st = await _ksFetch(`/accounts/keyshare/${token}/`); } catch (e) { continue; }
if (st.state === 'pending') continue;
if (st.state === 'declined') { status.textContent = 'Auf dem anderen Gerät abgelehnt.'; break; }
if (st.state === 'expired') { status.textContent = 'Die Anfrage ist abgelaufen. Lade die Seite neu, um es noch einmal zu versuchen.'; break; }
const aes = await _sharedAesKey(pair.privateKey, st.pub);
const raw = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: base64ToBytes(st.iv)}, aes, base64ToBytes(st.ct)));
if (raw.length !== 32 || !(await _keyFits(raw))) { status.textContent = 'Der übergebene Schlüssel passt nicht.'; break; }
finish(raw);
break;
}
} catch (e) {
if (!stopped) status.textContent = 'Übergabe nicht möglich (' + e.message + '). Das Passwort geht immer.';
}
})();
});
});
return _unlockPromise;
}
// --- The other side: an unlocked device approves new ones -------------------------------
const _seenShares = new Set();
let _approving = false;
async function _approveShare(req, rawKey) {
const codeText = await keyshareCode(req.pub);
return new Promise((resolve) => {
_openDialog((d, close) => {
d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Neues Gerät entsperren?'));
d.append(_el('p', {class: 'modal-message'},
`„${req.device || 'Ein Gerät'}“ möchte deine Bücher öffnen. Gib nur frei, wenn dort derselbe Code steht:`));
d.append(_el('div', {class: 'unlock-code'}, codeText));
const status = _el('p', {class: 'unlock-status'});
d.append(status);
const actions = _el('div', {class: 'modal-actions'});
const no = _el('button', {class: 'btn', type: 'button'}, 'Ablehnen');
const yes = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Freigeben');
actions.append(no, yes);
d.append(actions);
no.addEventListener('click', async () => {
try { await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, {decline: true}); } catch (e) {}
close(); resolve(false);
});
yes.addEventListener('click', async () => {
yes.disabled = no.disabled = true;
try {
const pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']);
const aes = await _sharedAesKey(pair.privateKey, req.pub);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aes, rawKey);
await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, {
pub: bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))),
iv: bytesToBase64(iv), ct: bytesToBase64(new Uint8Array(ct)),
});
close(); resolve(true);
} catch (e) {
status.textContent = 'Freigabe fehlgeschlagen: ' + e.message;
yes.disabled = no.disabled = false;
}
});
});
});
}
async function checkKeyShareRequests() {
if (_approving || !window.USER_ID || document.hidden) return;
const stored = localStorage.getItem(`diora_enc_key_${window.USER_ID}`);
if (!stored) return;
let data;
try { data = await _ksFetch('/accounts/keyshare/pending/'); } catch (e) { return; }
const req = (data.requests || []).find(r => !_seenShares.has(r.token));
if (!req) return;
_seenShares.add(req.token);
_approving = true;
try { await _approveShare(req, base64ToBytes(stored)); } finally { _approving = false; }
}
(function () {
if (!window.USER_ID || !window.crypto || !crypto.subtle) return;
setTimeout(checkKeyShareRequests, 2000);
setInterval(checkKeyShareRequests, 15000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) checkKeyShareRequests(); });
})();

View file

@ -106,10 +106,12 @@ async function parseEpub(arrayBuffer) {
// spine text by CHARS_PER_BOOK_PAGE (as the reader estimates it). The library's length sorts. // spine text by CHARS_PER_BOOK_PAGE (as the reader estimates it). The library's length sorts.
async function bookPagesOfFile(buf, type) { async function bookPagesOfFile(buf, type) {
if (type === 'pdf') { if (type === 'pdf') {
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(buf)}).promise; const pdf = await _pdfLoad(new Uint8Array(buf)).promise;
const n = pdf.numPages; try {
pdf.destroy(); return pdf.numPages;
return n; } finally {
try { pdf.destroy(); } catch (e) {}
}
} }
const zip = await JSZip.loadAsync(buf); const zip = await JSZip.loadAsync(buf);
const containerXml = await zip.file('META-INF/container.xml').async('text'); const containerXml = await zip.file('META-INF/container.xml').async('text');
@ -228,14 +230,41 @@ function _injectImageBlobs(html, chapterDir, imageMap) {
// Calibre/zeno.org conversions) use neither and just wrap a plain link to // Calibre/zeno.org conversions) use neither and just wrap a plain link to
// another chapter file's fragment in a <sup> — in either direction // another chapter file's fragment in a <sup> — in either direction
// (<sup><a>…</a></sup> or <a><sup>…</sup></a>). Recognize all of these // (<sup><a>…</a></sup> or <a><sup>…</sup></a>). Recognize all of these
// rather than only the single hardcoded class name. // rather than only the single hardcoded class name. Also role="doc-noteref",
// more note classes, and a bare note number as the link's text ("3", "[12]", "*").
function _looksLikeFootnoteLink(el, href) { function _looksLikeFootnoteLink(el, href) {
if (!href.includes('#')) return false; if (!href.includes('#')) return false;
const cls = (el.getAttribute('class') || '').toLowerCase(); const cls = (el.getAttribute('class') || '').toLowerCase();
if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true; if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true;
if (/\b(fnref|noteref|footnote-?ref|endnote-?ref)\b/.test(cls)) return true;
const epubType = (el.getAttribute('epub:type') || '').toLowerCase(); const epubType = (el.getAttribute('epub:type') || '').toLowerCase();
if (epubType.includes('noteref')) return true; if (epubType.includes('noteref')) return true;
return !!(el.closest('sup') || el.querySelector('sup')); if ((el.getAttribute('role') || '').toLowerCase() === 'doc-noteref') return true;
if (el.closest('sup') || el.querySelector('sup')) return true;
return /^\s*[\[(]?\s*(\d{1,4}|[ivxlc]{1,6}|[a-z]|[*†‡§¶]+)\s*[\])]?\s*$/i.test(el.textContent || '');
}
// The number at the start of a note that links back to the text ("3 Endnote…")
// is a way back, not a reference to another note.
const _NOTE_BOX_SELECTOR = '.footnote, .endnote, aside, [role="doc-footnote"], [role="doc-endnote"], ' +
'[epub\\:type~="footnote"], [epub\\:type~="endnote"], [epub\\:type~="rearnote"]';
function _isNoteBacklink(el) {
const role = (el.getAttribute('role') || '').toLowerCase();
const type = (el.getAttribute('epub:type') || '').toLowerCase();
if (role === 'doc-backlink' || /backlink/.test(type)) return true;
// Inside a note's box. Not the link itself: a reference can carry the class
// "footnote" too (<a class="footnote" href="#ftn.fn1">, Suhrkamp/DocBook).
if (el.parentElement && el.parentElement.closest(_NOTE_BOX_SELECTOR)) return true;
const block = el.closest('p, li, div, dd');
if (!block) return false;
// A note: the block is a link target itself, or holds an empty anchor that is
// (zeno.org / e-artnow: <p><a id="F258"></a><a href="…#N258">1</a> Note…</p>).
const anchored = block.id || el.id || Array.prototype.some.call(block.querySelectorAll('[id]'),
x => x !== el && !(x.textContent || '').trim());
if (!anchored) return false;
const text = (block.textContent || '').trim();
const firstLink = Array.prototype.find.call(block.querySelectorAll('a'), a => (a.textContent || '').trim());
return firstLink === el && text.startsWith((el.textContent || '').trim());
} }
// Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML // Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML
@ -272,15 +301,18 @@ function sanitizeEpubHtml(html) {
if (href.startsWith('http://') || href.startsWith('https://')) { if (href.startsWith('http://') || href.startsWith('https://')) {
el.setAttribute('target', '_blank'); el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer'); el.setAttribute('rel', 'noopener noreferrer');
} else if (_looksLikeFootnoteLink(el, href)) { } else if (!(href.includes('#') && _isNoteBacklink(el)) && _looksLikeFootnoteLink(el, href)) {
// The whole book is one concatenated DOM, so a fragment id resolves // data-footnote-ref (the id) marks the link as a reference; the href
// via getElementById regardless of which chapter file it originally // as written (data-footnote-href) finds the note in the chapter file it
// pointed into — strip any "../Text/other.html" path prefix and keep // points into (78-footnotes.js) — ids like "fn1" repeat across files.
// just the id after the last '#'.
el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1); el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1);
el.dataset.footnoteHref = href;
el.removeAttribute('href'); el.removeAttribute('href');
el.style.cursor = 'pointer'; el.style.cursor = 'pointer';
} else { } else {
// A link within the book (a cross-reference, a note's way back): kept
// for the apps, which follow it (diora-android native-ui.js).
if (href && !/^[a-z]+:/i.test(href)) el.dataset.internalHref = href;
el.removeAttribute('href'); el.removeAttribute('href');
el.style.cursor = 'default'; el.style.cursor = 'default';
} }

View file

@ -41,9 +41,9 @@ let _parseCodeSig = null;
function _parsePreparationSig() { function _parsePreparationSig() {
if (_parseCodeSig) return _parseCodeSig; if (_parseCodeSig) return _parseCodeSig;
// window.__dioraPrepareSig: what an app layer adds to the preparation (the // window.__dioraPrepareSig: what an app layer adds to the preparation — a
// footnote detection in diora-android's native-ui.js) — a change there too. // change there too.
const src = [parseEpub, sanitizeEpubHtml, _injectImageBlobs, _resolveImageBlob, _parseEpubToc] const src = [parseEpub, sanitizeEpubHtml, _looksLikeFootnoteLink, _isNoteBacklink, _injectImageBlobs, _resolveImageBlob, _parseEpubToc]
.map(f => String(f)).join('\n') + (window.__dioraPrepareSig || ''); .map(f => String(f)).join('\n') + (window.__dioraPrepareSig || '');
let h = 5381; let h = 5381;
for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0; for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0;

View file

@ -3,7 +3,7 @@
// Reader settings // Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif', noBold: false, fontWeight: null, fontFamily: 'serif', noBold: false, fontWeight: null,
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }; pdfZoom: 100, pdfInverted: false, pdfPageColors: false, pdfPaginated: false, pdfSpread: false };
let readerSettingsPanelOpen = false; let readerSettingsPanelOpen = false;
let currentPdfDoc = null; let currentPdfDoc = null;
let currentPdfBuffer = null; let currentPdfBuffer = null;
@ -45,9 +45,6 @@ function _readerScrollTick() {
_readerScrollLastTs = now; _readerScrollLastTs = now;
$('reader-content')?.scrollBy({top: Math.round(_readerScrollDir * READER_SCROLL_SPEED * dt / 1000)}); $('reader-content')?.scrollBy({top: Math.round(_readerScrollDir * READER_SCROLL_SPEED * dt / 1000)});
} }
let _pinchStartDist = 0;
let _pinchStartZoom = 100;
let _isPinching = false;
if (typeof pdfjsLib !== 'undefined') { if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';

View file

@ -41,6 +41,13 @@ async function loadBookList() {
try { try {
key = await getOrCreateEncKey(); key = await getOrCreateEncKey();
} catch (e) { } catch (e) {
if (e.name === 'DioraLocked') {
listEl.innerHTML = '<p class="muted">Deine Bücher sind auf diesem Gerät noch gesperrt. <button class="btn" type="button" data-unlock-books>Entsperren</button></p>';
listEl.querySelector('[data-unlock-books]')?.addEventListener('click', () => {
dioraUnlock({force: true}).then(() => loadBookList(), () => {});
});
return;
}
listEl.innerHTML = `<p class="muted">Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.</p>`; listEl.innerHTML = `<p class="muted">Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.</p>`;
return; return;
} }
@ -126,10 +133,14 @@ function repairBook(bookId) {
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = ''; let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = '';
try { try {
if (isPdf) { if (isPdf) {
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise;
const meta = await pdfDoc.getMetadata(); try {
title = meta.info?.Title?.trim() || title; const meta = await pdfDoc.getMetadata();
author = meta.info?.Author?.trim() || ''; title = meta.info?.Title?.trim() || title;
author = meta.info?.Author?.trim() || '';
} finally {
try { pdfDoc.destroy(); } catch (e) {}
}
} else { } else {
const zip = await JSZip.loadAsync(buf.slice(0)); const zip = await JSZip.loadAsync(buf.slice(0));
const containerXml = await zip.file('META-INF/container.xml').async('text'); const containerXml = await zip.file('META-INF/container.xml').async('text');
@ -778,10 +789,14 @@ async function _importEbookBuffer(buf, filename, statusEl, opts) {
if (isPdf) { if (isPdf) {
try { try {
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise;
const meta = await pdfDoc.getMetadata(); try {
title = meta.info?.Title?.trim() || title; const meta = await pdfDoc.getMetadata();
author = meta.info?.Author?.trim() || ''; title = meta.info?.Title?.trim() || title;
author = meta.info?.Author?.trim() || '';
} finally {
try { pdfDoc.destroy(); } catch (e) {}
}
} catch (e) { /* use filename as title */ } } catch (e) { /* use filename as title */ }
} else { } else {
try { try {

View file

@ -933,7 +933,7 @@ function _libAddMenu() {
(function () { (function () {
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = [ const rules = [
'html.lib-v2 #tab-books .book-drop-zone, html.lib-v2 #tab-books .book-list-filter,', '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; }', '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-head { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }',
@ -1026,6 +1026,15 @@ function _libAddMenu() {
'.cloud-chosen:empty { display: none; }', '.cloud-chosen:empty { display: none; }',
'.cloud-entry .cloud-check { width: 18px; height: 18px; margin: 0 8px 0 2px; flex: none; }', '.cloud-entry .cloud-check { width: 18px; height: 18px; margin: 0 8px 0 2px; flex: none; }',
'body.eink #book-list .lib-cover { box-shadow: none; border: 1px solid #000; }', 'body.eink #book-list .lib-cover { box-shadow: none; border: 1px solid #000; }',
].join('\n'); ];
// The audiobooks' shelf (audiobooks.js) is built of the same parts: the same rules for it,
// its covers square like an audiobook's.
const shelf = rules.filter(r => r.indexOf('#book-list') >= 0).map(r => r.replace(/#book-list/g, '#audiobook-shelf'));
style.textContent = rules.concat(shelf, [
'#audiobook-shelf .lib-cover { aspect-ratio: 1; }',
'#audiobook-shelf .lib-hero-cover { height: auto; aspect-ratio: 1; width: 120px; align-self: flex-start; }',
'@media (min-width: 721px) { #audiobook-shelf .lib-hero-cover { width: 160px; height: auto; } }',
'#audiobook-shelf button.lib-play { border: none; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }',
]).join('\n');
document.head.appendChild(style); document.head.appendChild(style);
})(); })();

View file

@ -279,7 +279,7 @@ const _issueNorm = s => String(s || '').toLowerCase().replace(/ß/g, 'ss').norma
async function _issueIndexPdf(bytes) { async function _issueIndexPdf(bytes) {
if (typeof pdfjsLib === 'undefined') return null; if (typeof pdfjsLib === 'undefined') return null;
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise; const pdf = await _pdfLoad(new Uint8Array(bytes)).promise;
try { try {
return await _issuePdfArticles(pdf); return await _issuePdfArticles(pdf);
} finally { } finally {

View file

@ -1,49 +1,569 @@
'use strict'; 'use strict';
// ---------------------------------------------------------------------------
// The PDF reader: one document, shown either scrolling (every page has its
// place, only those near the view are drawn) or page by page, cropped to the
// page's text (66-pdf-paginated.js). Whichever, a page is drawn only through
// _pdfDraw: one drawing at a time per canvas, sharp at the device's pixel
// ratio but never larger than PDF_MAX_PIXELS.
// ---------------------------------------------------------------------------
// pdf.js's own viewer caps a canvas at this many pixels: beyond it phones run out of memory.
const PDF_MAX_PIXELS = 16777216;
// One pdf.js worker for every document (the reader, covers, uploads): starting one takes a
// moment — on an e-reader a long one.
let _pdfWorker = null;
function _pdfSharedWorker() {
if (!_pdfWorker && typeof pdfjsLib !== 'undefined' && pdfjsLib.PDFWorker) {
try { _pdfWorker = new pdfjsLib.PDFWorker(); } catch (e) { _pdfWorker = null; }
}
return _pdfWorker;
}
/** pdf.js's loading task for [bytes] (a Uint8Array), on the shared worker. */
function _pdfLoad(bytes) {
const worker = _pdfSharedWorker();
return pdfjsLib.getDocument(worker ? {data: bytes, worker} : {data: bytes});
}
/**
* The page (from 1) a destination leads to — a named one or an explicit array, whose page is
* a reference or, in some files, a plain index. 0 when it leads nowhere.
*/
async function _pdfDestPage(pdf, dest) {
try {
if (typeof dest === 'string') dest = await pdf.getDestination(dest);
if (!dest || dest[0] == null) return 0;
return (typeof dest[0] === 'number' ? dest[0] : await pdf.getPageIndex(dest[0])) + 1;
} catch (e) {
return 0;
}
}
// The table of contents: all entries resolved at once (one after the other cost a round trip
// to the worker each).
async function _parsePdfOutline(pdf, items, depth) { async function _parsePdfOutline(pdf, items, depth) {
depth = depth || 0; depth = depth || 0;
const result = []; const parts = await Promise.all(items.map(async item => {
for (const item of items) {
let href = ''; let href = '';
if (item.dest) { if (item.dest) {
try { const n = await _pdfDestPage(pdf, item.dest);
const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest; if (n) href = `#pdf-page-${n}`;
if (dest) {
const pageIndex = await pdf.getPageIndex(dest[0]);
href = `#pdf-page-${pageIndex + 1}`;
}
} catch (e) {}
} }
result.push({label: item.title || '(untitled)', href, depth}); const kids = item.items && item.items.length ? await _parsePdfOutline(pdf, item.items, depth + 1) : [];
if (item.items && item.items.length) { return [{label: item.title || '(untitled)', href, depth}].concat(kids);
result.push(...await _parsePdfOutline(pdf, item.items, depth + 1)); }));
} return [].concat.apply([], parts);
}
return result;
} }
// The document being loaded (pdf.js loading task), so opening can be cancelled. // The document being loaded (pdf.js loading task), so opening can be cancelled.
let _pdfLoadingTask = null; let _pdfLoadingTask = null;
// The pages' drawing of the last renderPdf: an observer and its queue (stopped on the next one). // The open document's pages and how they are drawn (renderPdf; stopped on the next one).
let _pdfView = null;
// Backwards name: the reader stops "the lazy drawing" when a book closes.
let _pdfLazy = null; let _pdfLazy = null;
function _pdfStopLazy() { // The open document's text, page by page (pdf.js text content): for the text layer, the
if (_pdfLazy) { // search and the crop to the text box. Kept for the document, not for one layout of it.
_pdfLazy.stopped = true; let _pdfTexts = null;
if (_pdfLazy.observer) _pdfLazy.observer.disconnect();
_pdfLazy = null; function _pdfPageText(pdf, num) {
if (!_pdfTexts || _pdfTexts.pdf !== pdf) _pdfTexts = {pdf, pages: new Map()};
let p = _pdfTexts.pages.get(num);
if (!p) {
p = pdf.getPage(num).then(page => page.getTextContent()).catch(() => ({items: [], styles: {}}));
_pdfTexts.pages.set(num, p);
} }
return p;
}
/**
* A page's text as one string, lower case, for searching — with where each item (each span of
* the text layer, in order) starts in it. Lines ending inside it get a space.
*/
function _pdfPageString(tc) {
if (tc._diora) return tc._diora;
let str = '';
const starts = [];
for (const item of tc.items) {
starts.push(str.length);
str += item.str || '';
if (item.hasEOL) str += ' ';
}
tc._diora = {str: str.toLowerCase(), starts};
return tc._diora;
}
// Title, table of contents and page labels: asked for once per document, alongside the
// pages (opening doesn't wait for them; a layout anew reuses them).
let _pdfExtras = null;
function _pdfDocExtras(pdf) {
if (_pdfExtras && _pdfExtras.pdf === pdf) return _pdfExtras;
const meta = pdf.getMetadata().then(m => ({
title: (m && m.info && m.info.Title || '').trim(),
author: (m && m.info && m.info.Author || '').trim(),
}), () => ({title: '', author: ''}));
const toc = pdf.getOutline().then(o => (o && o.length ? _parsePdfOutline(pdf, o) : []), () => []);
const labels = pdf.getPageLabels().then(l => {
// Labels that only say 1, 2, 3 … are no labels.
if (!l || !l.length || l.every((s, i) => !s || s === String(i + 1))) return null;
return l.map((s, i) => s || String(i + 1));
}, () => null);
_pdfExtras = {pdf, meta, toc, labels};
return _pdfExtras;
}
// The page labels of the open document (front matter "iv", appendix "A-3" …), null without.
let _pdfLabels = null;
/** Page [n] (from 1) as the book numbers it. */
function pdfPageLabel(n) {
return (_pdfLabels && _pdfLabels[n - 1]) || String(n);
}
/** The page meant by [value] typed in: a label of the book first, else a page number. 0: none. */
function _pdfPageFromInput(value) {
const v = String(value || '').trim();
if (!v) return 0;
if (_pdfLabels) {
let i = _pdfLabels.indexOf(v);
if (i < 0) i = _pdfLabels.findIndex(l => l.toLowerCase() === v.toLowerCase());
if (i >= 0) return i + 1;
}
return parseInt(v, 10) || 0;
}
/**
* The pages in the reader's colours (Leserfarben): {background, foreground} as #rrggbb, the
* reader's (Dunkel, Sepia …). null: as printed (also when the reader is black on white).
*/
function _pdfPageColors() {
if (!readerSettings.pdfPageColors || readerSettings.pdfInverted) return null;
const contentEl = $('reader-content');
if (!contentEl) return null;
let bg = null;
for (let el = contentEl; el && !bg; el = el.parentElement) bg = _pdfCssHex(getComputedStyle(el).backgroundColor);
const fg = _pdfCssHex(getComputedStyle(contentEl).color);
// On white the page is as printed anyway (Hell).
if (!bg || !fg || bg === '#ffffff') return null;
return {background: bg, foreground: fg};
}
/** "rgb(…)" / "rgba(…)" as #rrggbb; null for transparent. */
function _pdfCssHex(css) {
const m = /rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?/.exec(css || '');
if (!m || (m[4] !== undefined && parseFloat(m[4]) === 0)) return null;
return '#' + [m[1], m[2], m[3]].map(x => (+x).toString(16).padStart(2, '0')).join('');
}
// Whether the pages are drawn for Leserfarben (with their pictures laid over them, below):
// switching that draws them anew; another theme only changes the filter's colours.
function _pdfColorsKey() {
return _pdfPageColors() ? 'reader' : '';
}
/**
* Leserfarben: the page is drawn as printed and recoloured by an SVG filter on its canvas —
* its brightness from the reader's text colour (black) to its background (white). (pdf.js's
* own pageColors recolours the pictures too, and lets nothing be left out.) Kept up to date
* with the colours; true when it applies.
*/
function _pdfColorFilter() {
const c = _pdfPageColors();
if (!c) return false;
const ns = 'http://www.w3.org/2000/svg';
let svg = document.getElementById('pdf-reader-colors-svg');
if (!svg) {
svg = document.createElementNS(ns, 'svg');
svg.id = 'pdf-reader-colors-svg';
svg.setAttribute('width', '0');
svg.setAttribute('height', '0');
svg.setAttribute('aria-hidden', 'true');
svg.style.position = 'absolute';
svg.innerHTML = '<filter id="pdf-reader-colors" color-interpolation-filters="sRGB">' +
'<feColorMatrix type="matrix" values="0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 ' +
'0.2126 0.7152 0.0722 0 0 0 0 0 1 0"/>' +
'<feComponentTransfer><feFuncR type="table"/><feFuncG type="table"/><feFuncB type="table"/>' +
'</feComponentTransfer></filter>';
document.body.appendChild(svg);
}
const ch = (hex, i) => (parseInt(hex.substr(1 + i * 2, 2), 16) / 255).toFixed(3);
['R', 'G', 'B'].forEach((name, i) => {
svg.querySelector('feFunc' + name).setAttribute('tableValues', `${ch(c.foreground, i)} ${ch(c.background, i)}`);
});
return true;
}
/**
* Where page [page]'s pictures are, in CSS pixels of [viewport]: from its drawing operations
* (the unit square of each image under the transform at that point). Image masks are left
* out (they're drawn in a colour, like text).
*/
async function _pdfImageRects(page, viewport) {
const OPS = pdfjsLib.OPS;
let list;
try {
list = await page.getOperatorList({annotationMode: pdfjsLib.AnnotationMode.ENABLE_FORMS});
} catch (e) {
return [];
}
const mul = (m, n) => [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];
let ctm = viewport.transform.slice();
const stack = [];
const rects = [];
for (let i = 0; i < list.fnArray.length; i++) {
const fn = list.fnArray[i], args = list.argsArray[i];
if (fn === OPS.save) stack.push(ctm);
else if (fn === OPS.restore) ctm = stack.pop() || ctm;
else if (fn === OPS.transform) ctm = mul(ctm, args);
else if (fn === OPS.paintFormXObjectBegin) {
stack.push(ctm);
if (Array.isArray(args[0]) && args[0].length === 6) ctm = mul(ctm, args[0]);
} else if (fn === OPS.paintFormXObjectEnd) ctm = stack.pop() || ctm;
else if (fn === OPS.paintImageXObject || fn === OPS.paintInlineImageXObject) {
const xs = [ctm[4], ctm[0] + ctm[4], ctm[2] + ctm[4], ctm[0] + ctm[2] + ctm[4]];
const ys = [ctm[5], ctm[1] + ctm[5], ctm[3] + ctm[5], ctm[1] + ctm[3] + ctm[5]];
const x = Math.max(0, Math.min.apply(null, xs)), y = Math.max(0, Math.min.apply(null, ys));
const w = Math.min(viewport.width, Math.max.apply(null, xs)) - x;
const h = Math.min(viewport.height, Math.max.apply(null, ys)) - y;
if (w > 2 && h > 2) rects.push({x, y, w, h});
}
}
return rects;
}
// A picture covering this much of the page is the page itself (a scan): recoloured with it.
const PDF_SCAN_SHARE = 0.5;
/** Leserfarben: page [e]'s pictures laid over it as printed (copied from its canvas). */
async function _pdfImageLayer(e, page, viewport, ratio, token) {
const rects = await _pdfImageRects(page, viewport);
if (e.token !== token) return;
const area = viewport.width * viewport.height;
const shown = rects.filter(r => r.w * r.h < area * PDF_SCAN_SHARE);
if (!shown.length) return;
const layer = document.createElement('div');
layer.className = 'pdf-image-layer';
for (const r of shown) {
const c = document.createElement('canvas');
const sx = Math.floor(r.x * ratio), sy = Math.floor(r.y * ratio);
const sw = Math.min(e.canvas.width - sx, Math.ceil(r.w * ratio)), sh = Math.min(e.canvas.height - sy, Math.ceil(r.h * ratio));
if (sw <= 0 || sh <= 0) continue;
c.width = sw;
c.height = sh;
c.getContext('2d').drawImage(e.canvas, sx, sy, sw, sh, 0, 0, sw, sh);
c.style.left = sx / ratio + 'px';
c.style.top = sy / ratio + 'px';
c.style.width = sw / ratio + 'px';
c.style.height = sh / ratio + 'px';
layer.appendChild(c);
}
e.inner.insertBefore(layer, e.canvas.nextSibling);
}
// Internal links followed, the pages they were followed from (the back button).
let _pdfBackStack = [];
/** The document is gone (another one, or the reader closed): what belonged to it goes too. */
function _pdfForgetDoc() {
_pdfTexts = null;
_pdfExtras = null;
_pdfLabels = null;
_pdfBackStack = [];
_pdfShowBack();
if (typeof _pdfSearchStop === 'function') _pdfSearchStop();
}
async function _pdfFollowDest(dest) {
const pdf = currentPdfDoc;
if (!pdf) return;
const n = await _pdfDestPage(pdf, dest);
if (!n || pdf !== currentPdfDoc) return;
const contentEl = $('reader-content');
const from = readerSettings.pdfPaginated || !contentEl ? pdfCurrentPage : _pdfTopPage(contentEl);
if (n === from) return;
_pdfBackStack.push(from);
pdfGoToPage(n);
_pdfShowBack();
}
/** The button back to where the last link was followed from (gone when there's none). */
function _pdfShowBack() {
let btn = document.getElementById('pdf-back-btn');
const from = _pdfBackStack[_pdfBackStack.length - 1];
if (!from) {
if (btn) btn.remove();
return;
}
if (!btn) {
const overlay = $('reader-overlay');
if (!overlay) return;
btn = document.createElement('button');
btn.id = 'pdf-back-btn';
btn.type = 'button';
btn.className = 'btn btn-sm pdf-back-btn';
btn.addEventListener('click', ev => {
ev.stopPropagation();
const page = _pdfBackStack.pop();
if (page) pdfGoToPage(page);
_pdfShowBack();
});
overlay.appendChild(btn);
}
btn.textContent = `← Zurück zu S. ${pdfPageLabel(from)}`;
}
/** A page's text layer and links taken off (another scale, or the page let go). */
function _pdfDropLayers(e) {
if (e.textTask) { try { e.textTask.cancel(); } catch (err) {} }
e.textTask = null;
e.textDivs = null;
e.textInfo = null;
e.marked = null;
e.inner.querySelectorAll('.pdf-text-layer, .pdf-link-layer, .pdf-image-layer').forEach(el => el.remove());
}
/**
* Over a drawn page: its text (transparent, to select and copy, and where the search marks
* go) and its links. After the pixels, so a turn isn't held up by them.
*/
async function _pdfLayers(e, page, viewport, token) {
const pdf = currentPdfDoc;
if (!pdf) return;
let annots = [];
try { annots = await page.getAnnotations({intent: 'display'}); } catch (err) {}
if (e.token !== token) return;
_pdfLinks(e, viewport, annots);
if (typeof pdfjsLib.renderTextLayer !== 'function') return;
const text = await _pdfPageText(pdf, e.num);
if (e.token !== token || !text.items.length) return;
const layer = document.createElement('div');
layer.className = 'pdf-text-layer';
layer.style.setProperty('--scale-factor', viewport.scale);
e.inner.appendChild(layer);
const divs = [];
const task = pdfjsLib.renderTextLayer({textContentSource: text, container: layer, viewport, textDivs: divs});
e.textTask = task;
try {
await task.promise;
} catch (err) {
return;
}
if (e.token !== token || e.textTask !== task) return;
e.textTask = null;
e.textDivs = divs;
e.textInfo = {items: text.items, string: _pdfPageString(text)};
if (typeof _pdfSearchMarkPage === 'function') _pdfSearchMarkPage(e);
}
// Links: ones into the document go there (and back, _pdfShowBack); web addresses open outside.
function _pdfLinks(e, viewport, annots) {
const links = annots.filter(a => a.subtype === 'Link' && (a.url || a.dest));
if (!links.length) return;
const layer = document.createElement('div');
layer.className = 'pdf-link-layer';
for (const a of links) {
const r = viewport.convertToViewportRectangle(a.rect);
const link = document.createElement('a');
link.className = 'pdf-link';
link.style.left = Math.min(r[0], r[2]) + 'px';
link.style.top = Math.min(r[1], r[3]) + 'px';
link.style.width = Math.abs(r[2] - r[0]) + 'px';
link.style.height = Math.abs(r[3] - r[1]) + 'px';
if (a.url) {
link.href = a.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.title = a.url;
} else {
const dest = a.dest;
link.href = '#';
link.addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation();
_pdfFollowDest(dest);
});
}
layer.appendChild(link);
}
e.inner.appendChild(layer);
}
function _pdfStopLazy() {
const v = _pdfView;
if (!v) return;
v.stopped = true;
if (v.observer) v.observer.disconnect();
if (v.resize) v.resize.disconnect();
clearTimeout(v.resizeTimer);
v.entries.forEach(e => { e.token++; if (e.task) { try { e.task.cancel(); } catch (err) {} } });
_pdfView = _pdfLazy = null;
}
/**
* Draw page [page] of entry [e] at [cssScale] (CSS pixels per PDF point). Cancels a drawing
* of the same canvas still under way (pdf.js refuses two at once) and waits for it to let go.
* Returns false when a newer drawing (or letting go) came first.
*/
function _pdfDraw(e, page, cssScale) {
// The same drawing asked for again while it's under way (the page shown, then laid out
// again as the bars go): that one, not a new one cancelling it.
if (e.drawing && e.drawing.scale === cssScale) return e.drawing.promise;
const drawing = {scale: cssScale, promise: null};
drawing.promise = _pdfDrawNow(e, page, cssScale).then(ok => {
if (e.drawing === drawing) e.drawing = null;
return ok;
}, err => {
if (e.drawing === drawing) e.drawing = null;
throw err;
});
e.drawing = drawing;
return drawing.promise;
}
async function _pdfDrawNow(e, page, cssScale) {
const token = ++e.token;
_pdfDropLayers(e);
const prev = e.task;
if (prev) {
try { prev.cancel(); } catch (err) {}
try { await prev.promise; } catch (err) {}
if (e.token !== token) return false;
}
const viewport = page.getViewport({scale: cssScale});
let ratio = window.devicePixelRatio || 1;
const area = viewport.width * viewport.height;
if (area * ratio * ratio > PDF_MAX_PIXELS) ratio = Math.max(0.5, Math.sqrt(PDF_MAX_PIXELS / area));
// Drawn on a canvas of its own and put in place when it's done: the page shown so far stays
// until then (after a zoom larger or smaller and blurred for a moment, never blank).
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page';
canvas.width = Math.round(viewport.width * ratio);
canvas.height = Math.round(viewport.height * ratio);
canvas.style.width = viewport.width + 'px';
canvas.style.height = viewport.height + 'px';
const task = page.render({
canvasContext: canvas.getContext('2d'),
viewport,
transform: ratio !== 1 ? [ratio, 0, 0, ratio, 0, 0] : null,
});
e.task = task;
const discard = () => { canvas.width = 0; canvas.height = 0; };
try {
await task.promise;
} catch (err) {
if (e.task === task) e.task = null;
discard();
if (err && err.name !== 'RenderingCancelledException') console.warn('PDF page', e.num, err);
return false;
}
if (e.task === task) e.task = null;
if (e.token !== token) { discard(); return false; }
const old = e.canvas;
if (old.parentNode) old.parentNode.replaceChild(canvas, old);
old.width = 0;
old.height = 0;
e.canvas = canvas;
e.drawn = cssScale;
e.stale = false;
const recolor = _pdfColorFilter();
e.canvas.classList.toggle('pdf-recolored', recolor);
if (recolor) _pdfImageLayer(e, page, viewport, ratio, token).catch(err => console.warn('PDF page pictures', e.num, err));
_pdfLayers(e, page, viewport, token).catch(err => console.warn('PDF page layers', e.num, err));
return true;
}
/** Let a page's pixels go (its place stays) and pdf.js forget what it parsed of it. */
function _pdfLetGo(e, pdf) {
e.token++;
e.drawing = null;
if (e.task) { try { e.task.cancel(); } catch (err) {} }
_pdfDropLayers(e);
e.drawn = 0;
e.canvas.width = 0;
e.canvas.height = 0;
if (pdf) pdf.getPage(e.num).then(p => { if (!e.task) p.cleanup(); }, () => {});
}
/**
* What of which page is at (x, y) on the screen (client pixels): {e, x, y, rx, ry}, rx/ry the
* place on the page as a share of its width and height.
*/
function _pdfPointAt(x, y) {
const v = _pdfView;
if (!v || !v.entries.length) return null;
let lo = 0, hi = v.entries.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (v.entries[mid].wrapper.getBoundingClientRect().bottom > y) hi = mid; else lo = mid + 1;
}
const e = v.entries[lo];
const r = e.canvas.getBoundingClientRect();
return {e, x, y, rx: r.width ? (x - r.left) / r.width : 0, ry: r.height ? (y - r.top) / r.height : 0};
}
/**
* Scrolling, zoomed by [f]: every page's place grows or shrinks at once (the ones drawn are
* shown larger or smaller until drawn anew, those near the view first), and [anchor]'s point
* (_pdfPointAt) stays where it is on the screen. false when there's no scrolling view.
*/
function _pdfRezoomScroll(f, anchor) {
const v = _pdfView;
if (!v || v.stopped || readerSettings.pdfPaginated || !v.work) return false;
for (const e of v.entries) {
e.canvas.style.width = (parseFloat(e.canvas.style.width) || 0) * f + 'px';
e.canvas.style.height = (parseFloat(e.canvas.style.height) || 0) * f + 'px';
if (e.task) {
e.token++;
e.drawing = null;
try { e.task.cancel(); } catch (err) {}
}
if (e.drawn || e.task) {
_pdfDropLayers(e);
e.stale = true;
}
if (e.near) v.queue.add(e);
}
if (anchor) {
const contentEl = v.contentEl;
const r = anchor.e.canvas.getBoundingClientRect();
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
contentEl.scrollLeft += r.left + anchor.rx * r.width - anchor.x;
contentEl.scrollTop += r.top + anchor.ry * r.height - anchor.y;
}
v.work();
return true;
}
/** The page shown at the top of the scrolling view (binary search, the pages are in order). */
function _pdfTopPage(contentEl) {
const v = _pdfView;
if (!v || !v.entries.length) return pdfCurrentPage || 1;
const top = contentEl.getBoundingClientRect().top + 20;
let lo = 0, hi = v.entries.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (v.entries[mid].wrapper.getBoundingClientRect().bottom > top) hi = mid; else lo = mid + 1;
}
return v.entries[lo].num;
} }
// Every page gets its place at once (sized like the first page until it is drawn), but only the // Every page gets its place at once (sized like the first page until it is drawn), but only the
// pages near the view are drawn; ones far away are let go again. A book of 1000 scanned pages // pages near the view are drawn; ones far away are let go again. A book of 1000 scanned pages
// opens as fast as one of ten, and doesn't hold 1000 canvases. // opens as fast as one of ten, and doesn't hold 1000 canvases. Page by page (pdfPaginated),
// 66-pdf-paginated.js shows and draws the pages itself.
async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
const myGen = ++_pdfRenderGen; const myGen = ++_pdfRenderGen;
_pdfStopLazy(); _pdfStopLazy();
let pdf = currentPdfDoc; let pdf = currentPdfDoc;
if (!pdf) { if (!pdf) {
_pdfLoadingTask = pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}); _pdfForgetDoc();
_pdfLoadingTask = _pdfLoad(new Uint8Array(arrayBuffer.slice(0)));
try { try {
pdf = await _pdfLoadingTask.promise; pdf = await _pdfLoadingTask.promise;
} finally { } finally {
@ -52,20 +572,15 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
} }
if (_pdfRenderGen !== myGen) return null; if (_pdfRenderGen !== myGen) return null;
currentPdfDoc = pdf; currentPdfDoc = pdf;
// [pivotPage] may be given as what of the document it is (the place saved, a fraction).
if (typeof pivotPage === 'function') pivotPage = pivotPage(pdf.numPages);
let pdfTitle = '', pdfAuthor = ''; const extras = _pdfDocExtras(pdf);
try { extras.labels.then(labels => {
const meta = await pdf.getMetadata(); if (pdf !== currentPdfDoc) return;
pdfTitle = meta.info?.Title?.trim() || ''; _pdfLabels = labels;
pdfAuthor = meta.info?.Author?.trim() || ''; if (labels) _pdfSetCurrentPage(pdfCurrentPage);
} catch (e) {} });
let toc = [];
try {
const outline = await pdf.getOutline();
if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline);
} catch (e) {}
if (_pdfRenderGen !== myGen) return null;
contentEl.innerHTML = ''; contentEl.innerHTML = '';
@ -77,7 +592,6 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
? contentEl.clientWidth - 32 ? contentEl.clientWidth - 32
: Math.min(contentEl.clientWidth - 32, 900); : Math.min(contentEl.clientWidth - 32, 900);
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth; const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
const dpr = window.devicePixelRatio || 1;
const scaleFor = vp => (scaleOverride != null ? scaleOverride const scaleFor = vp => (scaleOverride != null ? scaleOverride
: Math.max(0.5, pageWidth / vp.width) * (readerSettings.pdfZoom / 100)); : Math.max(0.5, pageWidth / vp.width) * (readerSettings.pdfZoom / 100));
@ -102,7 +616,7 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
canvas.style.height = firstVp.height + 'px'; canvas.style.height = firstVp.height + 'px';
inner.appendChild(canvas); inner.appendChild(canvas);
wrapper.appendChild(inner); wrapper.appendChild(inner);
if (!readerSettings.pdfSpread) { if (!readerSettings.pdfSpread || readerSettings.pdfPaginated) {
pdfVp.appendChild(wrapper); pdfVp.appendChild(wrapper);
} else if (pageNum === 1) { } else if (pageNum === 1) {
wrapper.classList.add('pdf-spread-cover'); wrapper.classList.add('pdf-spread-cover');
@ -117,78 +631,93 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
if (spreadContainer) spreadContainer.appendChild(wrapper); if (spreadContainer) spreadContainer.appendChild(wrapper);
else pdfVp.appendChild(wrapper); else pdfVp.appendChild(wrapper);
} }
entries.push({num: pageNum, wrapper, canvas, drawn: false, near: false, task: null}); entries.push({num: pageNum, wrapper, inner, canvas, drawn: 0, near: false, task: null, token: 0});
} }
pdfTotalPages = pdf.numPages; pdfTotalPages = pdf.numPages;
const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1)); const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1));
const pivotEl = document.getElementById(`pdf-page-${pivot}`);
if (pivotEl && pivot > 1) pivotEl.scrollIntoView({block: 'start'});
const lazy = {stopped: false, observer: null, queue: new Set(), working: false}; const view = {pdf, contentEl, entries, gen: myGen, stopped: false, observer: null, resize: null,
_pdfLazy = lazy; resizeTimer: 0, queue: new Set(), working: false, scaleFor, showToken: 0,
const gone = () => lazy.stopped || _pdfRenderGen !== myGen; colorsKey: _pdfColorsKey()};
_pdfView = _pdfLazy = view;
const gone = () => view.stopped || _pdfRenderGen !== myGen;
async function draw(e) { async function draw(e) {
const page = await pdf.getPage(e.num); const page = await pdf.getPage(e.num);
if (gone() || !e.near) return; if (gone() || !e.near || readerSettings.pdfPaginated) return;
const viewport = page.getViewport({scale: scaleFor(page.getViewport({scale: 1}))}); await _pdfDraw(e, page, scaleFor(page.getViewport({scale: 1})));
e.canvas.style.width = viewport.width + 'px';
e.canvas.style.height = viewport.height + 'px';
e.canvas.width = Math.round(viewport.width * dpr);
e.canvas.height = Math.round(viewport.height * dpr);
const ctx = e.canvas.getContext('2d');
ctx.scale(dpr, dpr);
e.task = page.render({canvasContext: ctx, viewport});
try {
await e.task.promise;
e.drawn = true;
} catch (err) { /* cancelled: let go below */ }
e.task = null;
}
function letGo(e) {
if (e.task) { try { e.task.cancel(); } catch (err) {} }
if (!e.drawn) return;
e.drawn = false;
e.canvas.width = 0; // frees the pixels; the CSS size keeps the page's place
e.canvas.height = 0;
} }
// One page at a time, the nearest to the current one first. // One page at a time, the nearest to the current one first.
async function work() { async function work() {
if (lazy.working) return; if (view.working) return;
lazy.working = true; view.working = true;
try { try {
while (!gone() && lazy.queue.size) { while (!gone() && view.queue.size && !readerSettings.pdfPaginated) {
const cur = typeof _currentScrollPage === 'function' && !readerSettings.pdfPaginated const cur = _pdfTopPage(contentEl);
? _currentScrollPage(contentEl) : (pdfCurrentPage || pivot); const next = Array.from(view.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0];
const next = Array.from(lazy.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0]; view.queue.delete(next);
lazy.queue.delete(next); if (next.near && (!next.drawn || next.stale)) await draw(next);
if (next.near && !next.drawn) await draw(next);
} }
} finally { } finally {
lazy.working = false; view.working = false;
} }
} }
view.work = work;
// Turning the device, a narrower window, the bars coming or going: laid out again at the same page.
let lastW = contentEl.clientWidth, lastH = contentEl.clientHeight;
if (typeof ResizeObserver === 'function') {
view.resize = new ResizeObserver(() => {
const w = contentEl.clientWidth, h = contentEl.clientHeight;
if (!w || !h || (w === lastW && h === lastH)) return;
const widthChanged = Math.abs(w - lastW) > 2;
lastW = w;
lastH = h;
clearTimeout(view.resizeTimer);
view.resizeTimer = setTimeout(() => {
if (gone()) return;
if (readerSettings.pdfPaginated) _pdfShowPage(pdfCurrentPage);
else if (widthChanged) reRenderPdf(_pdfTopPage(contentEl));
}, readerSettings.pdfPaginated ? 150 : 300);
});
view.resize.observe(contentEl);
}
if (readerSettings.pdfPaginated) {
// Page by page: the pivot page cropped to its text, at once (66-pdf-paginated.js).
pdfCurrentPage = pivot;
await _pdfShowPage(pivot);
if (gone()) return null;
return _pdfResult(pdf, extras);
}
// Opening at the saved page is no "scrolled far" (44-books.js).
if (pivot > 1) {
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
entries[pivot - 1].wrapper.scrollIntoView({block: 'start'});
}
pdfCurrentPage = pivot;
const byEl = new Map(entries.map(e => [e.wrapper, e])); const byEl = new Map(entries.map(e => [e.wrapper, e]));
if (typeof IntersectionObserver === 'function') { if (typeof IntersectionObserver === 'function') {
lazy.observer = new IntersectionObserver(items => { view.observer = new IntersectionObserver(items => {
if (readerSettings.pdfPaginated) return;
for (const it of items) { for (const it of items) {
const e = byEl.get(it.target); const e = byEl.get(it.target);
if (!e) continue; if (!e) continue;
e.near = it.isIntersecting; e.near = it.isIntersecting;
if (e.near) { if (!e.drawn) lazy.queue.add(e); } if (e.near) { if (!e.drawn) view.queue.add(e); }
else { lazy.queue.delete(e); letGo(e); } else { view.queue.delete(e); if (e.drawn || e.task) _pdfLetGo(e, pdf); }
} }
work(); work();
}, {root: contentEl, rootMargin: '150% 0px'}); }, {root: contentEl, rootMargin: '150% 0px'});
entries.forEach(e => lazy.observer.observe(e.wrapper)); entries.forEach(e => view.observer.observe(e.wrapper));
} else { } else {
// Very old WebViews: every page, from the pivot on (as before). // Very old WebViews: every page, from the pivot on (as before).
entries.forEach(e => { e.near = true; lazy.queue.add(e); }); entries.forEach(e => { e.near = true; view.queue.add(e); });
work(); work();
} }
@ -197,5 +726,12 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
pe.near = true; pe.near = true;
if (!pe.drawn) await draw(pe); if (!pe.drawn) await draw(pe);
if (gone()) return null; if (gone()) return null;
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; return _pdfResult(pdf, extras);
}
// What openBook needs of the document: the title (asked for while the page was drawn) and
// the table of contents to come (a long one resolves its pages after the book is shown).
async function _pdfResult(pdf, extras) {
const meta = await extras.meta;
return {title: meta.title, author: meta.author, toc: [], tocLater: extras.toc, numPages: pdf.numPages};
} }

View file

@ -29,6 +29,65 @@ function _immHandleTap(e) {
} }
} }
// A double click on the text (with a mouse): full screen, the bars out of the
// way; again (or Esc): back as it was. Fingers keep their double tap; beside
// the text a click turns the page (63-pages.js), so only on the text itself.
let _readerFsHidBars = false;
// What openBook hangs on the content and the page number for this book, taken off again in
// closeReader — else every book opened adds its own, and a PDF's handlers run under the next
// EPUB. (Not addEventListener's signal: the tolino's Chrome 61 ignores it.)
let _readerListeners = [];
function _readerListen(el, type, fn, opts) {
if (!el) return;
el.addEventListener(type, fn, opts);
_readerListeners.push([el, type, fn, opts]);
}
function _readerUnlistenAll() {
for (const [el, type, fn, opts] of _readerListeners) el.removeEventListener(type, fn, opts);
_readerListeners = [];
}
function _readerDblClick(e) {
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
if (!e.target.closest('[data-epub-src], .pdf-page-wrapper')) return;
if (e.target.closest('button, a, input, select, textarea, label, [data-footnote-ref], mark.epub-highlight')) return;
// The double click also picked a word: that's not a marking.
const sel = window.getSelection();
if (sel) sel.removeAllRanges();
if (typeof dismissHighlightPopover === 'function') dismissHighlightPopover();
if (document.fullscreenElement) {
if (document.exitFullscreen) document.exitFullscreen().catch(() => {});
return;
}
const el = document.documentElement;
if (!el.requestFullscreen) return;
el.requestFullscreen().then(() => {
if (_immBarsVisible) {
_immBarsVisible = false;
document.body.classList.add('reader-immersive');
_readerFsHidBars = true;
}
}).catch(() => {});
}
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement || !_readerFsHidBars) return;
_readerFsHidBars = false;
const overlay = $('reader-overlay');
if (overlay && overlay.style.display !== 'none' && !_immBarsVisible) {
_immBarsVisible = true;
document.body.classList.remove('reader-immersive');
}
});
document.addEventListener('DOMContentLoaded', () => {
const row = $('reader-body-row');
if (row) row.addEventListener('dblclick', _readerDblClick);
});
function enterReaderImmersiveMode() { function enterReaderImmersiveMode() {
_immBarsVisible = true; _immBarsVisible = true;
document.body.classList.remove('reader-immersive'); document.body.classList.remove('reader-immersive');
@ -64,8 +123,10 @@ function cancelBookOpening() {
if (overlay) overlay.style.display = 'none'; if (overlay) overlay.style.display = 'none';
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (contentEl) contentEl.innerHTML = ''; if (contentEl) contentEl.innerHTML = '';
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
currentPdfDoc = null; currentPdfDoc = null;
currentPdfBuffer = null; currentPdfBuffer = null;
_readerUnlistenAll();
_readPos = null; _readPos = null;
return true; return true;
} }
@ -86,6 +147,36 @@ async function openBook(bookId) {
} }
} }
async /**
* Book [bookId]'s entry with its place: the server's (offline: the one kept here), or an own
* newer one the server doesn't have yet. null when there's none.
*/
async function _bookProgress(bookId) {
let bookData = null;
try {
const progressRes = await fetch('/books/', {cache: 'no-store'});
_noteServerNowHeader(progressRes);
const allBooks = await progressRes.json();
bookData = allBooks.find(b => b.id === bookId) || null;
} catch {
// offline — use cached meta
return _bookProgressCached(bookId);
}
// The newer of the server's place and an own one it doesn't have yet.
return bookData ? _withPendingProgress(bookData) : null;
}
/** The same from what's kept on this device only (no waiting for the network). */
async function _bookProgressCached(bookId) {
try {
const cached = await _loadCachedBookMeta();
const bookData = cached.find(b => b.id === bookId) || null;
return bookData ? _withPendingProgress(bookData) : null;
} catch {
return null;
}
}
async function _openBookOnce(bookId) { async function _openBookOnce(bookId) {
// Nothing is saved for this book until its place is restored. // Nothing is saved for this book until its place is restored.
_readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false}; _readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false};
@ -107,6 +198,9 @@ async function _openBookOnce(bookId) {
loadingEl.querySelector('button').addEventListener('click', () => cancelBookOpening()); loadingEl.querySelector('button').addEventListener('click', () => cancelBookOpening());
const showLoading = setTimeout(() => { if (!tok.cancelled) overlay.appendChild(loadingEl); }, 400); const showLoading = setTimeout(() => { if (!tok.cancelled) overlay.appendChild(loadingEl); }, 400);
const left = () => tok.cancelled; const left = () => tok.cancelled;
// The place this book was read at: asked for at once, alongside the book's file (a PDF
// opens at that page instead of drawing page 1 first).
const progressP = _bookProgress(bookId).catch(() => null);
try { try {
loadReaderSettings(bookId); loadReaderSettings(bookId);
@ -151,14 +245,28 @@ async function _openBookOnce(bookId) {
const isPdfBook = cachedMeta.type === 'pdf'; const isPdfBook = cachedMeta.type === 'pdf';
if (isPdfBook) { if (isPdfBook) {
currentPdfDoc = null; // reset so renderPdf creates fresh doc // A document still open (another book) goes; renderPdf loads this one.
const result = await renderPdf(plain, contentEl); if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
currentPdfDoc = null;
// The colours of the reader first: the pages may be drawn in them (Leserfarben).
applyReaderSettings(true);
// The page it opens at: the place saved, if it's known by now (else the one kept here).
const saved = await Promise.race([progressP, new Promise(r => setTimeout(() => r(undefined), 400))])
|| await _bookProgressCached(bookId);
const startFraction = saved ? (saved.scroll_fraction || 0) : 0;
const result = await renderPdf(plain, contentEl, undefined,
n => (startFraction > 0 && n > 1 ? Math.max(1, Math.min(n, Math.round(startFraction * (n - 1)) + 1)) : 1));
if (left() || !result) return; if (left() || !result) return;
title = result.title || title; title = result.title || title;
author = result.author || author; author = result.author || author;
toc = result.toc; toc = result.toc;
numPages = result.numPages; numPages = result.numPages;
currentPdfBuffer = plain; // The table of contents follows when its pages are resolved.
result.tocLater.then(entries => {
if (currentBookId === bookId && currentPdfDoc && entries.length) currentBookToc = entries;
});
// pdf.js holds the document: the decrypted bytes needn't stay as well.
currentPdfBuffer = null;
} else { } else {
currentPdfBuffer = null; currentPdfBuffer = null;
// Prepared before (same book file, same preparation code): only the images to unpack. // Prepared before (same book file, same preparation code): only the images to unpack.
@ -219,18 +327,14 @@ async function _openBookOnce(bookId) {
// Apply reader settings (theme, font size, etc.) // Apply reader settings (theme, font size, etc.)
applyReaderSettings(isPdfBook); applyReaderSettings(isPdfBook);
// If this book already has notes/highlights, surface them right away // The notes margin stays closed when a book opens (also with notes in it): it's one
// instead of making the reader hide them behind a manual toggle. // tap away (✎), and opens by itself when a note is written.
// Not on phones: there the margin covers the text; it stays one tap away (✎).
if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) {
toggleAnnotationsMargin();
}
// Touch: swipe (paginated) + pinch-to-zoom // Touch: swipe (paginated) + pinch-to-zoom
contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true}); _readerListen(contentEl, 'touchstart', _pdfTouchStart, {passive: true});
contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false}); _readerListen(contentEl, 'touchmove', _pdfTouchMove, {passive: false});
contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true}); _readerListen(contentEl, 'touchend', _pdfTouchEnd, {passive: true});
contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true}); _readerListen(contentEl, 'touchcancel', _pdfTouchEnd, {passive: true});
// Set up progress input // Set up progress input
const progressInput = $('reader-progress-input'); const progressInput = $('reader-progress-input');
@ -247,16 +351,13 @@ async function _openBookOnce(bookId) {
if (progressSuffix) progressSuffix.textContent = '%'; if (progressSuffix) progressSuffix.textContent = '%';
} }
progressInput.addEventListener('change', function () { _readerListen(progressInput, 'change', function () {
_suppressScrollJumpDetect(); _suppressScrollJumpDetect();
if (isPdf) { if (isPdf) {
const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1)); // The book's page number ("iv", "12") or the file's.
this.value = page; const page = Math.min(numPages, Math.max(1, _pdfPageFromInput(this.value) || pdfCurrentPage || 1));
const target = contentEl.querySelector(`#pdf-page-${page}`); this.value = pdfPageLabel(page);
if (target) { pdfGoToPage(page);
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
contentEl.scrollBy({top: top - 8, behavior: 'smooth'});
}
} else { } else {
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value))); const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
this.value = _formatBookPercent(pct / 100); this.value = _formatBookPercent(pct / 100);
@ -264,7 +365,7 @@ async function _openBookOnce(bookId) {
} }
}); });
progressInput.addEventListener('click', function () { this.select(); }); _readerListen(progressInput, 'click', function () { this.select(); });
} }
// Restore scroll position — must happen BEFORE auto-save timer is started // Restore scroll position — must happen BEFORE auto-save timer is started
@ -272,19 +373,7 @@ async function _openBookOnce(bookId) {
let openedProgress = null; let openedProgress = null;
try { try {
let fraction = 0, anchor = ''; let fraction = 0, anchor = '';
let bookData = null; const bookData = await progressP;
try {
const progressRes = await fetch('/books/', {cache: 'no-store'});
_noteServerNowHeader(progressRes);
const allBooks = await progressRes.json();
bookData = allBooks.find(b => b.id === bookId) || null;
} catch {
// offline — use cached meta
const cached = await _loadCachedBookMeta();
bookData = cached.find(b => b.id === bookId) || null;
}
// The newer of the server's place and an own one it doesn't have yet.
if (bookData) bookData = _withPendingProgress(bookData);
fraction = bookData ? (bookData.scroll_fraction || 0) : 0; fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
anchor = bookData ? (bookData.position_anchor || '') : ''; anchor = bookData ? (bookData.position_anchor || '') : '';
openedProgress = bookData; openedProgress = bookData;
@ -298,6 +387,7 @@ async function _openBookOnce(bookId) {
await new Promise(r => requestAnimationFrame(r)); await new Promise(r => requestAnimationFrame(r));
if (fraction > 0) if (fraction > 0)
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
_pdfSetCurrentPage(_pdfTopPage(contentEl));
} else { } else {
// EPUB — wait for images so scrollHeight is final // EPUB — wait for images so scrollHeight is final
const imgs = Array.from(contentEl.querySelectorAll('img')); const imgs = Array.from(contentEl.querySelectorAll('img'));
@ -330,29 +420,24 @@ async function _openBookOnce(bookId) {
// first — that could otherwise be a later, unrelated layout correction. // first — that could otherwise be a later, unrelated layout correction.
_scrollRestTop = contentEl.scrollTop; _scrollRestTop = contentEl.scrollTop;
// Update progress input on scroll // PDF scrolling: the page shown at the top is the current one (header, bookmarks, the
contentEl.addEventListener('scroll', () => { // app's footer). EPUB: shown from the position anchor, see the anchor tracking below.
if (!progressInput) return; if (isPdf) {
if (isPdf) { let pageRaf = 0;
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); _readerListen(contentEl, 'scroll', () => {
const cTop = contentEl.getBoundingClientRect().top; if (pageRaf || readerSettings.pdfPaginated) return;
let currentPage = 1; pageRaf = requestAnimationFrame(() => {
for (const w of wrappers) { pageRaf = 0;
if (w.getBoundingClientRect().bottom > cTop + 20) { if (currentPdfDoc) _pdfSetCurrentPage(_pdfTopPage(contentEl));
currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1; });
break; }, {passive: true});
} }
}
progressInput.value = currentPage;
}
// EPUB: shown from the position anchor, see the anchor tracking below.
});
// Auto-save progress every 10s and on scroll (debounced 2s) // Auto-save progress every 10s and on scroll (debounced 2s)
// Started AFTER restore so an early visibilitychange can't overwrite with position 0 // Started AFTER restore so an early visibilitychange can't overwrite with position 0
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000); readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
let _scrollDebounce = null; let _scrollDebounce = null;
contentEl.addEventListener('scroll', () => { _readerListen(contentEl, 'scroll', () => {
clearTimeout(_scrollDebounce); clearTimeout(_scrollDebounce);
_scrollDebounce = setTimeout(saveReaderProgress, 2000); _scrollDebounce = setTimeout(saveReaderProgress, 2000);
}, {passive: true}); }, {passive: true});
@ -362,7 +447,7 @@ async function _openBookOnce(bookId) {
// scrolling) would otherwise restore to a stale, pre-scroll anchor. // scrolling) would otherwise restore to a stale, pre-scroll anchor.
if (!isPdf) { if (!isPdf) {
let _anchorTrackDebounce = null; let _anchorTrackDebounce = null;
contentEl.addEventListener('scroll', () => { _readerListen(contentEl, 'scroll', () => {
clearTimeout(_anchorTrackDebounce); clearTimeout(_anchorTrackDebounce);
_anchorTrackDebounce = setTimeout(() => { _anchorTrackDebounce = setTimeout(() => {
_currentPositionAnchor = getPositionAnchor(contentEl); _currentPositionAnchor = getPositionAnchor(contentEl);
@ -391,7 +476,7 @@ async function _openBookOnce(bookId) {
// fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above. // fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above.
_scrollRestTop = null; _scrollRestTop = null;
let _scrollJumpRaf = null; let _scrollJumpRaf = null;
contentEl.addEventListener('scroll', () => { _readerListen(contentEl, 'scroll', () => {
if (_scrollJumpRaf) return; if (_scrollJumpRaf) return;
_scrollJumpRaf = requestAnimationFrame(() => { _scrollJumpRaf = requestAnimationFrame(() => {
_scrollJumpRaf = null; _scrollJumpRaf = null;
@ -549,6 +634,8 @@ function closeReader() {
// Still opening: just leave it (nothing to save yet). // Still opening: just leave it (nothing to save yet).
if (cancelBookOpening()) return; if (cancelBookOpening()) return;
if (typeof _issueReaderClosed === 'function') _issueReaderClosed(); if (typeof _issueReaderClosed === 'function') _issueReaderClosed();
// An article (65-article.js): where it was read to, before the reader empties.
if (typeof _articleReaderClosed === 'function') _articleReaderClosed();
exitReaderImmersiveMode(); exitReaderImmersiveMode();
stopReadAloud(); stopReadAloud();
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
@ -598,12 +685,12 @@ function closeReader() {
// Remove touch handlers and free image blob URLs // Remove touch handlers and free image blob URLs
const contentEl = $('reader-content'); const contentEl = $('reader-content');
_readerUnlistenAll();
if (contentEl) { if (contentEl) {
contentEl.removeEventListener('touchstart', _pdfTouchStart); contentEl.removeEventListener('click', _pdfPaginatedClick);
contentEl.removeEventListener('touchmove', _pdfTouchMove); contentEl.removeEventListener('wheel', _pdfPaginatedWheel);
contentEl.removeEventListener('touchend', _pdfTouchEnd); contentEl.classList.remove('pinch-active', 'pdf-paginated');
contentEl.removeEventListener('touchcancel', _pdfTouchEnd); contentEl.style.overflow = '';
contentEl.classList.remove('pinch-active');
contentEl.innerHTML = ''; contentEl.innerHTML = '';
} }
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
@ -616,6 +703,7 @@ function closeReader() {
if (typeof _pdfStopLazy === 'function') _pdfStopLazy(); if (typeof _pdfStopLazy === 'function') _pdfStopLazy();
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} } if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
currentPdfDoc = null; currentPdfDoc = null;
if (typeof _pdfForgetDoc === 'function') _pdfForgetDoc();
currentPdfBuffer = null; currentPdfBuffer = null;
currentBookmarks = []; currentBookmarks = [];
bookmarksDirty = false; bookmarksDirty = false;
@ -667,13 +755,9 @@ function jumpToTocEntry(href) {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
// PDF page jump // PDF page jump (scrolling or page by page)
if (href.startsWith('#pdf-page-')) { if (href.startsWith('#pdf-page-')) {
const target = contentEl.querySelector(href); pdfGoToPage(parseInt(href.slice('#pdf-page-'.length), 10) || 1);
if (target) {
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
}
return; return;
} }

1030
static/js/app/63-pages.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -15,10 +15,14 @@ function _readerTypeDefaults() {
? _READER_TYPE_DEFAULTS_WIDE : _READER_TYPE_DEFAULTS_NARROW; ? _READER_TYPE_DEFAULTS_WIDE : _READER_TYPE_DEFAULTS_NARROW;
} }
// Where the settings shown are kept: the book's id, or 'article' (all articles, 65-article.js).
let _readerSettingsKey = null;
function loadReaderSettings(bookId) { function loadReaderSettings(bookId) {
_readerSettingsKey = bookId;
// Reset to defaults, then apply per-book overrides // Reset to defaults, then apply per-book overrides
Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false, Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false,
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }, pdfZoom: 100, pdfInverted: false, pdfPageColors: false, pdfPaginated: false, pdfSpread: false },
_readerTypeDefaults()); _readerTypeDefaults());
try { try {
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}'); const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}');
@ -40,8 +44,9 @@ function loadReaderSettings(bookId) {
} }
function saveReaderSettings() { function saveReaderSettings() {
if (currentBookId) { const key = currentBookId || _readerSettingsKey;
localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 3 })); if (key) {
localStorage.setItem(`diora_reader_settings_${key}`, JSON.stringify({ ...readerSettings, v: 3 }));
} }
} }
@ -89,6 +94,12 @@ function applyReaderSettings(isPdf) {
// PDF invert // PDF invert
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted'); if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted');
else overlay.classList.remove('pdf-inverted'); else overlay.classList.remove('pdf-inverted');
// Leserfarben: another theme changes the filter's colours; switching it on or off draws
// the pages anew (with or without their pictures laid over them).
if (isPdf && currentPdfDoc) {
_pdfColorFilter();
if (_pdfView && _pdfView.colorsKey !== _pdfColorsKey()) reRenderPdf();
}
// Annotations (marker mode + margin panel) are EPUB-only for now — PDFs have no text layer yet // Annotations (marker mode + margin panel) are EPUB-only for now — PDFs have no text layer yet
const marginBtn = $('reader-margin-btn'); const marginBtn = $('reader-margin-btn');
@ -113,6 +124,21 @@ function _readerWeight() {
return Math.round(w / step) * step; return Math.round(w / step) * step;
} }
// "Scrollen | Seiten" and, in pages, whether the notes stand at the page's foot
// and whether two pages lie side by side.
// The apps' e-reader mode always reads in pages: no choice there.
function _readerModeButtons() {
if (!window.DioraPages || DioraPages.isForced()) return '';
const pages = DioraPages.isOn();
return `
<button class="btn btn-sm ${pages ? '' : 'active'}" data-rs-mode="scroll">Scrollen</button>
<button class="btn btn-sm ${pages ? 'active' : ''}" data-rs-mode="pages">Seiten</button>
<button class="btn btn-sm ${DioraPages.pageNotes() ? 'active' : ''}" id="rs-page-notes" style="${pages ? '' : 'display:none'}"
title="Die Fußnoten stehen unten auf der Seite, auf der auf sie verwiesen wird">Fußnoten unten</button>
<button class="btn btn-sm ${DioraPages.spread() ? 'active' : ''}" id="rs-page-spread" style="${pages ? '' : 'display:none'}"
title="Zwei Seiten nebeneinander wie ein aufgeschlagenes Buch — ab einer Breite von 900 Pixeln">Doppelseite</button>`;
}
function toggleSettingsPanel() { function toggleSettingsPanel() {
const overlay = $('reader-overlay'); const overlay = $('reader-overlay');
const contentEl = $('reader-content'); const contentEl = $('reader-content');
@ -148,12 +174,20 @@ function toggleSettingsPanel() {
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dunkel</button> <button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dunkel</button>
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button> <button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Hell</button> <button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Hell</button>
${_readerModeButtons()}
`; `;
} else { } else {
panel.innerHTML = ` panel.innerHTML = `
<label>Zoom <button class="btn btn-sm" id="rs-zoom-minus">−</button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label> <label>Zoom <button class="btn btn-sm" id="rs-zoom-minus">−</button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invertieren</button> <button class="btn btn-sm ${readerSettings.pdfPaginated ? '' : 'active'}" data-rs-pdfmode="scroll">Scrollen</button>
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Doppelseite</button> <button class="btn btn-sm ${readerSettings.pdfPaginated ? 'active' : ''}" data-rs-pdfmode="pages">Seiten</button>
<button class="btn btn-sm ${!readerSettings.pdfInverted && !readerSettings.pdfPageColors ? 'active' : ''}" data-rs-pdfcolors="original"
title="Die Seiten wie gedruckt">Originalfarben</button>
<button class="btn btn-sm ${!readerSettings.pdfInverted && readerSettings.pdfPageColors ? 'active' : ''}" data-rs-pdfcolors="reader"
title="Text und Hintergrund in den Farben des Lesers (Dunkel, Sepia), Bilder bleiben, wie sie sind">Leserfarben</button>
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" data-rs-pdfcolors="invert"
title="Die ganze Seite umgekehrt, auch Bilder (Farben bleiben ungefähr)">Invertieren</button>
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread"${readerSettings.pdfPaginated ? ' style="display:none"' : ''}>Doppelseite</button>
`; `;
} }
@ -267,20 +301,77 @@ function toggleSettingsPanel() {
saveReaderSettings(); saveReaderSettings();
}); });
}); });
// Scrolling or pages (63-pages.js): per device, for every book.
const syncMode = () => {
const pages = DioraPages.isOn();
panel.querySelectorAll('[data-rs-mode]').forEach(b => b.classList.toggle('active', (b.dataset.rsMode === 'pages') === pages));
const notes = panel.querySelector('#rs-page-notes');
if (notes) {
notes.style.display = pages ? '' : 'none';
notes.classList.toggle('active', DioraPages.pageNotes());
}
const spread = panel.querySelector('#rs-page-spread');
if (spread) {
spread.style.display = pages ? '' : 'none';
spread.classList.toggle('active', DioraPages.spread());
}
};
panel.querySelectorAll('[data-rs-mode]').forEach(btn => {
btn.addEventListener('click', () => { DioraPages.setOn(btn.dataset.rsMode === 'pages'); syncMode(); });
});
panel.querySelector('#rs-page-notes')?.addEventListener('click', () => { DioraPages.togglePageNotes(); syncMode(); });
panel.querySelector('#rs-page-spread')?.addEventListener('click', () => { DioraPages.setSpread(!DioraPages.spread()); syncMode(); });
} else { } else {
const zoomRange = panel.querySelector('#rs-zoom'); const zoomRange = panel.querySelector('#rs-zoom');
zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10))); // While the slider moves only the number follows; the pages are drawn anew once it rests.
let zoomTimer = 0;
zoomRange.addEventListener('input', () => {
const z = parseInt(zoomRange.value, 10);
const val = panel.querySelector('#rs-zoom-val');
if (val) val.textContent = z + '%';
clearTimeout(zoomTimer);
zoomTimer = setTimeout(() => applyPdfZoom(z), 250);
});
panel.querySelector('#rs-zoom-minus').addEventListener('click', () => panel.querySelector('#rs-zoom-minus').addEventListener('click', () =>
applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10))); applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10)));
panel.querySelector('#rs-zoom-plus').addEventListener('click', () => panel.querySelector('#rs-zoom-plus').addEventListener('click', () =>
applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10))); applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10)));
panel.querySelector('#rs-invert').addEventListener('click', function () { // The pages' colours: as printed, in the reader's colours, or inverted.
readerSettings.pdfInverted = !readerSettings.pdfInverted; panel.querySelectorAll('[data-rs-pdfcolors]').forEach(btn => {
this.classList.toggle('active', readerSettings.pdfInverted); btn.addEventListener('click', () => {
applyReaderSettings(true); const which = btn.dataset.rsPdfcolors;
saveReaderSettings(); readerSettings.pdfInverted = which === 'invert';
readerSettings.pdfPageColors = which === 'reader';
panel.querySelectorAll('[data-rs-pdfcolors]').forEach(b => b.classList.toggle('active', b === btn));
applyReaderSettings(true);
saveReaderSettings();
});
});
// Scrolling or page by page (phones and e-readers start with pages).
panel.querySelectorAll('[data-rs-pdfmode]').forEach(btn => {
btn.addEventListener('click', () => {
const pages = btn.dataset.rsPdfmode === 'pages';
if (pages === !!readerSettings.pdfPaginated || !currentPdfDoc) return;
const contentEl = $('reader-content');
const page = pages ? _pdfTopPage(contentEl) : pdfCurrentPage;
readerSettings.pdfPaginated = pages;
saveReaderSettings();
panel.querySelectorAll('[data-rs-pdfmode]').forEach(b => b.classList.toggle('active', b === btn));
const spread = panel.querySelector('#rs-spread');
if (spread) spread.style.display = pages ? 'none' : '';
if (pages) {
pdfCurrentPage = page;
// Spreads are a scrolling thing: one page at a time here.
if (readerSettings.pdfSpread) reRenderPdf(page).then(() => enterPdfPaginatedMode());
else enterPdfPaginatedMode();
} else {
exitPdfPaginatedMode(page);
}
});
}); });
panel.querySelector('#rs-spread').addEventListener('click', function () { panel.querySelector('#rs-spread').addEventListener('click', function () {
@ -293,37 +384,42 @@ function toggleSettingsPanel() {
} }
} }
async function applyPdfZoom(newZoom) { /**
* The PDF at another zoom (%). Scrolling, [anchor] (_pdfPointAt: where the fingers were) stays
* in place, else the middle of the screen; page by page, the page is fitted anew (_pdfFitPage).
*/
async function applyPdfZoom(newZoom, anchor) {
const old = readerSettings.pdfZoom;
readerSettings.pdfZoom = newZoom; readerSettings.pdfZoom = newZoom;
const zoomRange = document.getElementById('rs-zoom'); const zoomRange = document.getElementById('rs-zoom');
const zoomVal = document.getElementById('rs-zoom-val'); const zoomVal = document.getElementById('rs-zoom-val');
if (zoomRange) zoomRange.value = newZoom; if (zoomRange) zoomRange.value = newZoom;
if (zoomVal) zoomVal.textContent = newZoom + '%'; if (zoomVal) zoomVal.textContent = newZoom + '%';
saveReaderSettings(); saveReaderSettings();
if (!currentPdfDoc) return;
if (readerSettings.pdfPaginated) { if (readerSettings.pdfPaginated) {
pdfSmartZoomPage(pdfCurrentPage); await _pdfShowPage(pdfCurrentPage);
} else { return;
await reRenderPdf();
} }
if (newZoom === old) return;
if (!anchor) {
const r = $('reader-content').getBoundingClientRect();
anchor = _pdfPointAt(r.left + r.width / 2, r.top + r.height / 2);
}
if (!_pdfRezoomScroll(newZoom / old, anchor)) await reRenderPdf();
} }
// The page at the top of the view (scrolling) — 58-pdf.js keeps the pages in order.
function _currentScrollPage(contentEl) { function _currentScrollPage(contentEl) {
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); return _pdfTopPage(contentEl);
const viewTop = contentEl.scrollTop;
for (const w of wrappers) {
if (w.offsetTop + w.offsetHeight > viewTop) {
const n = parseInt(w.id.replace('pdf-page-', ''), 10);
if (n) return n;
}
}
return pdfCurrentPage || 1;
} }
async function reRenderPdf() { /** Lay the open PDF out again (zoom, spreads, another width), at [pivot] or the page shown now. */
if (!currentPdfBuffer) return; async function reRenderPdf(pivot) {
if (!currentPdfDoc) return;
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl); if (!pivot) pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _pdfTopPage(contentEl);
await renderPdf(currentPdfBuffer, contentEl, undefined, pivot); await renderPdf(null, contentEl, undefined, pivot);
if (readerSettings.pdfPaginated) enterPdfPaginatedMode(); if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
} }

174
static/js/app/65-article.js Normal file
View file

@ -0,0 +1,174 @@
'use strict';
// ---------------------------------------------------------------------------
// Articles in the book reader (DioraArticle) — blog posts and news (feeds.js)
// read like books: the same overlay, typography and colours (⚙, kept for all
// articles together), pages or scrolling (63-pages.js) with the notes at the
// page's foot, search, read-aloud, full screen. Bookmarks, markings and the
// notes margin stay with books (an article has nowhere to keep them).
//
// The article is one "chapter" in #reader-content; currentBookId stays null,
// so nothing of a book's position or annotations is saved. Where it was read
// to goes back to its owner (onProgress), as do clicks on its own buttons.
// ---------------------------------------------------------------------------
(function () {
let session = null; // {title, html, startFraction, onProgress, onClick, onClose, actions}
let progressTimer = 0;
const overlay = () => document.getElementById('reader-overlay');
const content = () => document.getElementById('reader-content');
// Same-page links: the notes of a blog post ("¹" → "#fn1") become references
// like in books; other jumps inside the post must not change the app's
// address (#books, #feeds …): kept as internal links (the apps follow them).
function markLinks(root) {
root.querySelectorAll('a[href^="#"]').forEach(a => {
const href = a.getAttribute('href');
a.removeAttribute('href');
if (href.length < 2) return;
// "#t=754": a second in a podcast's article, never a note.
if (!/^#t=\d+$/.test(href) && !_isNoteBacklink(a) && _looksLikeFootnoteLink(a, href)) {
a.dataset.footnoteRef = href.slice(1);
a.dataset.footnoteHref = href;
a.style.cursor = 'pointer';
} else {
a.dataset.internalHref = href;
}
});
// Everything else opens outside.
root.querySelectorAll('a[href]').forEach(a => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
}
// How far it is read: down to the bottom of what is on screen (0-1).
function fraction() {
const c = content();
const ch = c && c.querySelector(':scope > [data-epub-src]');
if (!ch || !ch.offsetHeight) return 0;
return Math.max(0, Math.min(1, (c.scrollTop + c.clientHeight - ch.offsetTop) / ch.offsetHeight));
}
function report() {
if (session && session.onProgress) session.onProgress(fraction());
}
function onScroll() {
clearTimeout(progressTimer);
progressTimer = setTimeout(report, 1200);
}
function onClick(e) {
if (session && session.onClick) session.onClick(e);
}
// The article's own buttons (star, project, original): in the browser's
// header; the apps put them into their reader menu (native-ui.js asks actions()).
function renderActions() {
const bar = document.querySelector('#reader-overlay .reader-header-actions');
if (!bar) return;
let box = bar.querySelector('.reader-article-actions');
if (!box) {
box = document.createElement('span');
box.className = 'reader-article-actions';
bar.insertBefore(box, bar.firstChild);
}
box.textContent = '';
actions().forEach(a => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn-icon' + (a.active ? ' active' : '');
b.title = a.label;
b.textContent = a.icon;
b.addEventListener('click', e => { e.stopPropagation(); a.action(); });
box.appendChild(b);
});
}
function actions() {
return session && session.actions ? session.actions().filter(Boolean) : [];
}
async function open(o) {
const ov = overlay();
const c = content();
if (!ov || !c) return;
if (session) {
report();
stopReadAloud(); // the next article: the voice was reading the last one
}
else if (ov.style.display !== 'none') closeReader(); // a book was open
session = o;
loadReaderSettings('article');
_charIndex = null;
_readPos = null;
currentBookId = null;
ov.classList.add('reader-article-mode');
$('reader-title').textContent = o.title || '';
c.textContent = '';
const ch = document.createElement('div');
ch.setAttribute('data-epub-src', 'article');
ch.id = 'epub-chapter-0';
ch.className = 'fd-artview';
ch.innerHTML = o.html;
markLinks(ch);
c.appendChild(ch);
ov.style.display = '';
applyReaderSettings(false);
enterReaderImmersiveMode();
renderActions();
c.scrollTop = 0;
if (window.DioraPages) DioraPages.opened();
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
const f = o.startFraction || 0;
if (f > 0.02 && f < 0.9) {
// Back where reading stopped (on any device).
await new Promise(r => requestAnimationFrame(r));
if (session !== o) return;
_suppressScrollJumpDetect();
c.scrollTop = Math.max(0, f * ch.offsetHeight + ch.offsetTop - c.clientHeight);
} else {
setTimeout(() => { if (session === o) report(); }, 1500); // short enough to fit: read at once
}
}
// Replaces what is shown (the overview of a story arrived) without moving.
function update(html) {
const ch = content() && content().querySelector(':scope > [data-epub-src="article"]');
if (!ch || !session) return;
session.html = html;
ch.innerHTML = html;
markLinks(ch);
}
// closeReader (62-reader.js) calls this before it empties the reader.
window._articleReaderClosed = function () {
if (!session) return;
clearTimeout(progressTimer);
report();
const s = session;
session = null;
overlay().classList.remove('reader-article-mode');
const box = document.querySelector('#reader-overlay .reader-article-actions');
if (box) box.remove();
if (s.onClose) setTimeout(s.onClose, 0);
};
window.DioraArticle = {
open,
update,
isOpen: () => !!session,
close: () => { if (session) closeReader(); },
actions,
refreshActions: renderActions,
fraction,
};
document.addEventListener('DOMContentLoaded', () => {
const c = content();
if (!c) return;
c.addEventListener('scroll', () => { if (session) onScroll(); }, {passive: true});
c.addEventListener('click', e => { if (session) onClick(e); });
});
})();

View file

@ -1,175 +1,386 @@
'use strict'; 'use strict';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PDF Paginated Mode // PDF page by page (phones and e-readers by default; "Seiten" in the PDF panel):
// one page at a time, cropped to its text so the letters are as large as the
// screen allows, the next one drawn ahead for the turn. A tap on the left or
// right turns (the middle shows the bars), so do a swipe, the arrow keys,
// Space, PageUp/PageDown and the mouse wheel. Every page goes through
// _pdfDraw (58-pdf.js).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// A page nearly empty (only its number) would be zoomed to fill the screen with one digit:
// at most this many times the size the whole page would have.
const PDF_CROP_MAX = 2.5;
const PDF_CROP_PAD = 12;
// Zoom (readerSettings.pdfZoom, %): the PDF panel's slider and pinching.
const PDF_ZOOM_MIN = 50;
const PDF_ZOOM_MAX = 200;
let _pdfTurnSaveTimer = 0;
let _pdfWheelSum = 0;
let _pdfWheelAt = 0;
let _touchStartY = 0;
// A touch gesture under way: {kind: 'pinch' | 'pan' | 'after'} (_pdfTouchStart).
let _pdfGesture = null;
let _pdfPanAt = 0; // when a page was last moved by a finger (that touch is no tap)
function enterPdfPaginatedMode() { function enterPdfPaginatedMode() {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
contentEl.classList.add('pdf-paginated'); contentEl.classList.add('pdf-paginated');
contentEl.style.overflow = 'hidden'; contentEl.style.overflow = 'hidden';
const pdfVp = document.getElementById('pdf-viewport'); const pdfVp = document.getElementById('pdf-viewport');
if (pdfVp) pdfVp.style.zoom = 1; if (pdfVp) { pdfVp.style.transform = ''; pdfVp.style.transformOrigin = ''; }
// From scrolling: the drawing near the view stops, the pages are this mode's now.
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); const v = _pdfView;
wrappers.forEach((w, i) => { if (v && v.observer) { v.observer.disconnect(); v.observer = null; v.queue.clear(); }
w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none';
});
pdfSmartZoomPage(pdfCurrentPage);
// Tap left/right to navigate
contentEl.addEventListener('click', _pdfPaginatedClick); contentEl.addEventListener('click', _pdfPaginatedClick);
contentEl.addEventListener('wheel', _pdfPaginatedWheel, {passive: false});
_pdfShowPage(pdfCurrentPage);
} }
function exitPdfPaginatedMode() { /** Back to scrolling, at [page] (the one shown now). */
function exitPdfPaginatedMode(page) {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
contentEl.classList.remove('pdf-paginated'); contentEl.classList.remove('pdf-paginated');
contentEl.style.overflow = ''; contentEl.style.overflow = '';
contentEl.removeEventListener('click', _pdfPaginatedClick); contentEl.removeEventListener('click', _pdfPaginatedClick);
reRenderPdf(); contentEl.removeEventListener('wheel', _pdfPaginatedWheel);
reRenderPdf(page || pdfCurrentPage);
} }
function _pdfPaginatedClick(e) { function _pdfPaginatedClick(e) {
const w = e.currentTarget.clientWidth; if (e.target.closest('a, button, input')) return;
if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1); // The page was just moved with a finger: no tap.
else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1); if (Date.now() - _pdfPanAt < 400) return;
// Text just selected (the text layer): that tap wasn't a turn.
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
const r = e.currentTarget.getBoundingClientRect();
const x = e.clientX - r.left;
const dir = x < r.width * 0.4 ? -1 : x > r.width * 0.6 ? 1 : 0;
if (!dir) return; // the middle: the bars come or go (62-reader.js)
// A turn is a turn only: not also the bars.
e.stopPropagation();
pdfGoToPage(pdfCurrentPage + dir);
} }
function _pdfPaginatedWheel(e) {
if (e.ctrlKey) return; // the browser's zoom
e.preventDefault();
// A page taller than the screen (zoomed in) is moved first; at its end the wheel turns.
const pe = _pdfView && _pdfView.entries[pdfCurrentPage - 1];
if (pe && pe.place && pe.place.minY < pe.place.maxY) {
const y = Math.max(pe.place.minY, Math.min(pe.place.maxY, pe.pos.y - e.deltaY));
if (Math.abs(y - pe.pos.y) > 0.5) {
_pdfPlace(pe, pe.pos.x, y);
_pdfKeepFocus(pe);
_pdfWheelSum = 0;
_pdfWheelAt = Date.now();
return;
}
}
const now = Date.now();
if (now - _pdfWheelAt > 400) _pdfWheelSum = 0;
_pdfWheelAt = now;
_pdfWheelSum += e.deltaY;
if (Math.abs(_pdfWheelSum) < 60) return;
const dir = _pdfWheelSum > 0 ? 1 : -1;
_pdfWheelSum = 0;
_pdfWheelAt = now + 250; // one turn per flick of the wheel
pdfGoToPage(pdfCurrentPage + dir);
}
// Touch (62-reader.js listens for every book; only a PDF's are handled here):
// - two fingers zoom around the point between them: a preview while they move (a CSS
// transform, the layout stays as it is), then drawn anew at the new size with that point
// where it was (scrolling: _pdfRezoomScroll; page by page: _pdfFitPage and v.focus);
// - page by page, a page larger than the screen moves with one finger, and pushed on past
// its edge sideways it turns; otherwise a swipe turns.
function _pdfTouchStart(e) { function _pdfTouchStart(e) {
const v = _pdfView;
if (!currentPdfDoc || !v) return;
const contentEl = $('reader-content');
if (e.touches.length === 2) { if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX; const a = e.touches[0], b = e.touches[1];
const dy = e.touches[0].clientY - e.touches[1].clientY; const x = (a.clientX + b.clientX) / 2, y = (a.clientY + b.clientY) / 2;
_pinchStartDist = Math.hypot(dx, dy); const r = contentEl.getBoundingClientRect();
_pinchStartZoom = readerSettings.pdfZoom; const g = {kind: 'pinch', dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1,
_isPinching = true; zoom: readerSettings.pdfZoom, f: 1, cx: x - r.left, cy: y - r.top};
const ce = $('reader-content'); if (readerSettings.pdfPaginated) {
if (ce) ce.classList.add('pinch-active'); g.entry = v.entries[pdfCurrentPage - 1];
} else { if (!g.entry || !g.entry.pos) return;
_touchStartX = e.touches[0].clientX; g.pos = {x: g.entry.pos.x, y: g.entry.pos.y};
_isPinching = false; } else {
const vp = document.getElementById('pdf-viewport');
if (!vp) return;
const vr = vp.getBoundingClientRect();
g.vp = vp;
g.ox = x - vr.left;
g.oy = y - vr.top;
g.anchor = _pdfPointAt(x, y);
}
_pdfGesture = g;
contentEl.classList.add('pinch-active');
return;
}
if (e.touches.length !== 1) return;
const t = e.touches[0];
_touchStartX = t.clientX;
_touchStartY = t.clientY;
_pdfGesture = null;
const pe = readerSettings.pdfPaginated && v.entries[pdfCurrentPage - 1];
if (pe && pe.place && (pe.place.minX < pe.place.maxX || pe.place.minY < pe.place.maxY)) {
_pdfGesture = {kind: 'pan', entry: pe, x: t.clientX, y: t.clientY, ox: pe.pos.x, oy: pe.pos.y,
dx: 0, dy: 0, moved: false};
} }
} }
function _pdfTouchMove(e) { function _pdfTouchMove(e) {
if (e.touches.length !== 2 || !_isPinching) return; const g = _pdfGesture;
e.preventDefault(); if (!g) return;
const dx = e.touches[0].clientX - e.touches[1].clientX; if (g.kind === 'pinch') {
const dy = e.touches[0].clientY - e.touches[1].clientY; if (e.touches.length !== 2) return;
const dist = Math.hypot(dx, dy); e.preventDefault();
if (_pinchStartDist === 0) return; const a = e.touches[0], b = e.touches[1];
const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist))); const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
const vp = document.getElementById('pdf-viewport'); const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, g.zoom * dist / g.dist));
if (vp) vp.style.zoom = liveZoom / 100; g.f = zoom / g.zoom;
if (g.entry) {
// The page grows around the point between the fingers (its transform's origin is 0 0).
const x = g.cx - g.f * (g.cx - g.pos.x), y = g.cy - g.f * (g.cy - g.pos.y);
g.entry.inner.style.transform = `translate(${x}px, ${y}px) scale(${g.f})`;
} else {
g.vp.style.transformOrigin = `${g.ox}px ${g.oy}px`;
g.vp.style.transform = `scale(${g.f})`;
}
return;
}
if (g.kind === 'pan' && e.touches.length === 1) {
const t = e.touches[0];
g.dx = t.clientX - g.x;
g.dy = t.clientY - g.y;
if (!g.moved && Math.hypot(g.dx, g.dy) < 8) return;
g.moved = true;
e.preventDefault();
_pdfPlace(g.entry, g.ox + g.dx, g.oy + g.dy);
}
} }
function _pdfTouchEnd(e) { function _pdfTouchEnd(e) {
if (_isPinching) { const g = _pdfGesture;
_isPinching = false; if (g && g.kind === 'pinch') {
const ce = $('reader-content'); // The other finger may stay down a moment: it's no swipe.
if (ce) ce.classList.remove('pinch-active'); _pdfGesture = {kind: 'after'};
const vp = document.getElementById('pdf-viewport'); const contentEl = $('reader-content');
const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom; if (contentEl) contentEl.classList.remove('pinch-active');
const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10)); const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, Math.round(g.zoom * g.f / 10) * 10));
applyPdfZoom(snapped); if (g.entry) {
const pe = g.entry, s = pe.drawn, p = pe.place;
// The page's point between the fingers stays there (_pdfFitPage); the preview stays
// until the page is drawn at its new size.
if (s && p) {
_pdfView.focus = {x: (g.cx - g.pos.x) / s, y: (g.cy - g.pos.y) / s, sx: g.cx / p.W, sy: g.cy / p.H};
_pdfView.focusPage = pe.num;
}
if (zoom === readerSettings.pdfZoom) _pdfPlace(pe, g.pos.x, g.pos.y);
} else {
g.vp.style.transform = '';
g.vp.style.transformOrigin = '';
}
if (zoom !== readerSettings.pdfZoom) applyPdfZoom(zoom, g.anchor);
return; return;
} }
if (!readerSettings.pdfPaginated) return; if (g && g.kind === 'after') {
const delta = e.changedTouches[0].clientX - _touchStartX; if (!e.touches || !e.touches.length) _pdfGesture = null;
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1); return;
else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1); }
if (g && g.kind === 'pan') {
_pdfGesture = null;
if (!g.moved) return; // a tap: the click turns or shows the bars
_pdfPanAt = Date.now();
_pdfKeepFocus(g.entry);
// Already at its edge and pushed on sideways: the next or previous page (a move that only
// reaches the edge doesn't turn as well).
const p = g.entry.place;
const atEdge = g.dx < 0 ? g.ox <= p.minX + 1 : g.ox >= p.maxX - 1;
if (atEdge && Math.abs(g.dx) > 50 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5) pdfGoToPage(pdfCurrentPage + (g.dx > 0 ? -1 : 1));
return;
}
if (!currentPdfDoc || !readerSettings.pdfPaginated || !e.changedTouches.length) return;
const dx = e.changedTouches[0].clientX - _touchStartX;
const dy = e.changedTouches[0].clientY - _touchStartY;
// Sideways, clearly: a slanted scroll is not a turn.
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
// Dragging a selection's handles (the text layer) isn't a swipe.
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
pdfGoToPage(pdfCurrentPage + (dx > 0 ? -1 : 1));
} }
/** The page number the reader shows (header input) follows the page. */
function _pdfSetCurrentPage(n) {
pdfCurrentPage = n;
const input = $('reader-progress-input');
if (input && currentPdfDoc && document.activeElement !== input) input.value = pdfPageLabel(n);
// With the book's own page numbers: which page of the file it is beside them.
const suffix = $('reader-progress-suffix');
if (suffix && currentPdfDoc && pdfTotalPages) {
suffix.textContent = _pdfLabels ? `(${n} / ${pdfTotalPages})` : `/ ${pdfTotalPages}`;
if (input) input.setAttribute('inputmode', _pdfLabels ? 'text' : 'decimal');
}
}
/**
* Go to page [n], scrolling or page by page: the table of contents, the page
* number in the header, bookmarks, keys and the app's "Zu Seite springen".
*/
function pdfGoToPage(n) { function pdfGoToPage(n) {
if (!currentPdfDoc) return; if (!currentPdfDoc) return;
n = Math.max(1, Math.min(pdfTotalPages, n)); n = Math.max(1, Math.min(pdfTotalPages, n | 0));
if (n === pdfCurrentPage) return;
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
if (!readerSettings.pdfPaginated) {
const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); const target = contentEl.querySelector(`#pdf-page-${n}`);
if (oldWrapper) oldWrapper.style.display = 'none'; if (target) {
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
pdfCurrentPage = n; contentEl.scrollTop += target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top - 8;
const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); }
if (newWrapper) newWrapper.style.display = ''; _pdfSetCurrentPage(n);
return;
pdfSmartZoomPage(pdfCurrentPage); }
if (n === pdfCurrentPage && _pdfView && _pdfView.shown === n) return;
const progressInput = $('reader-progress-input'); _pdfShowPage(n);
if (progressInput) progressInput.value = pdfCurrentPage; _pdfTurned();
} }
async function pdfSmartZoomPage(pageNum) { // A turn is reading: the place is saved soon (not only by the 10 s timer), and e-readers
if (!currentPdfDoc) return; // get their refresh (native-eink.js listens).
const contentEl = $('reader-content'); function _pdfTurned() {
if (!contentEl) return; if (typeof _noteReaderInteraction === 'function') _noteReaderInteraction();
const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`); clearTimeout(_pdfTurnSaveTimer);
if (!wrapper) return; _pdfTurnSaveTimer = setTimeout(() => { if (currentPdfDoc) saveReaderProgress(); }, 1500);
const canvas = wrapper.querySelector('canvas'); try { window.dispatchEvent(new CustomEvent('diora-pdf-turn', {detail: {page: pdfCurrentPage}})); } catch (e) {}
if (!canvas) return;
const page = await currentPdfDoc.getPage(pageNum);
const naturalVp = page.getViewport({scale: 1});
const pageW = naturalVp.width;
const pageH = naturalVp.height;
let bbox = _pdfPageTextBoxCache[pageNum];
if (!bbox) {
bbox = await _computePdfTextBox(page, pageW, pageH);
_pdfPageTextBoxCache[pageNum] = bbox;
}
const containerW = contentEl.clientWidth;
const containerH = contentEl.clientHeight;
const contentW = bbox.x2 - bbox.x1;
const contentH = bbox.y2 - bbox.y1;
const pad = 12;
const scale = Math.min(
(containerW - pad * 2) / contentW,
(containerH - pad * 2) / contentH
) * (readerSettings.pdfZoom / 100);
// Re-render canvas at new scale if significantly different
const currentScale = canvas.width / naturalVp.width;
if (Math.abs(scale - currentScale) / currentScale > 0.05) {
const vp = page.getViewport({scale});
canvas.width = vp.width;
canvas.height = vp.height;
await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise;
}
// Position canvas to center the text bounding box
const renderedScale = canvas.width / naturalVp.width;
const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2;
// PDF y-axis is bottom-up; canvas is top-down
const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2;
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
wrapper.style.overflow = 'hidden';
wrapper.style.width = containerW + 'px';
wrapper.style.height = containerH + 'px';
} }
async function _computePdfTextBox(page, pageW, pageH) { /** Show page [n] alone, cropped; draw the next one ahead and let those far away go. */
async function _pdfShowPage(n) {
const v = _pdfView;
if (!v || !v.entries.length) return;
n = Math.max(1, Math.min(v.entries.length, n));
_pdfSetCurrentPage(n);
v.shown = n;
const token = ++v.showToken;
v.entries.forEach(e => { e.wrapper.style.display = e.num === n ? '' : 'none'; });
try {
await _pdfFitPage(v, v.entries[n - 1]);
if (token !== v.showToken || v.stopped) return;
if (n < v.entries.length) await _pdfFitPage(v, v.entries[n], true);
if (token !== v.showToken || v.stopped) return;
if (n > 1 && !v.entries[n - 2].drawn) await _pdfFitPage(v, v.entries[n - 2], true);
} catch (err) {
console.warn('PDF page', n, err);
}
if (token !== v.showToken || v.stopped) return;
v.entries.forEach(e => { if (Math.abs(e.num - n) > 2 && (e.drawn || e.task)) _pdfLetGo(e, v.pdf); });
}
/**
* Page [e] sized to the screen by its text (its ink, for scans): drawn when the size differs,
* then placed so the text is centred. [ahead]: for the next turn, not shown yet.
*/
async function _pdfFitPage(v, e, ahead) {
const contentEl = v.contentEl;
const page = await v.pdf.getPage(e.num);
if (v.stopped) return;
const natural = page.getViewport({scale: 1});
let box = _pdfPageTextBoxCache[e.num];
if (!box) {
box = await _computePdfTextBox(page, natural);
_pdfPageTextBoxCache[e.num] = box;
}
if (v.stopped) return;
const W = contentEl.clientWidth;
const H = contentEl.clientHeight;
const bw = Math.max(1, box.x2 - box.x1);
const bh = Math.max(1, box.y2 - box.y1);
const fitBox = Math.min((W - PDF_CROP_PAD * 2) / bw, (H - PDF_CROP_PAD * 2) / bh);
const fitPage = Math.min((W - PDF_CROP_PAD * 2) / natural.width, (H - PDF_CROP_PAD * 2) / natural.height);
const scale = Math.max(0.1, Math.min(fitBox, fitPage * PDF_CROP_MAX)) * (readerSettings.pdfZoom / 100);
if (!e.drawn || Math.abs(scale - e.drawn) / e.drawn > 0.05) {
if (!await _pdfDraw(e, page, scale)) return;
}
// The text box centred where it fits (at the scale it was drawn with); larger than the
// screen (zoomed in), it can be moved up to its edges and starts at its top left — or
// where it was looked at (v.focus: a pinch, a move, the screen turned).
const s = e.drawn;
const range = (size, lo, hi) => {
if (hi - lo + PDF_CROP_PAD * 2 <= size + 1) {
const c = (size - (hi - lo)) / 2 - lo;
return [c, c];
}
return [size - PDF_CROP_PAD - hi, PDF_CROP_PAD - lo];
};
const [minX, maxX] = range(W, s * box.x1, s * box.x2);
const [minY, maxY] = range(H, s * box.y1, s * box.y2);
e.place = {minX, maxX, minY, maxY, W, H};
const f = v.focusPage === e.num ? v.focus : null;
e.inner.style.transformOrigin = '0 0';
_pdfPlace(e, f ? W * f.sx - s * f.x : maxX, f ? H * f.sy - s * f.y : maxY);
e.wrapper.style.overflow = 'hidden';
e.wrapper.style.width = W + 'px';
e.wrapper.style.height = H + 'px';
if (!ahead && v.shown === e.num) e.wrapper.style.display = '';
}
/** Page [e] moved to (x, y) (its offset in the reading area), kept within its edges (e.place). */
function _pdfPlace(e, x, y) {
const p = e.place;
if (p) {
x = Math.max(p.minX, Math.min(p.maxX, x));
y = Math.max(p.minY, Math.min(p.maxY, y));
}
e.pos = {x, y};
e.inner.style.transform = `translate(${x}px, ${y}px)`;
}
/** Page [e] moved: that part of it stays in view when it's laid out again (another zoom, turned). */
function _pdfKeepFocus(e) {
const v = _pdfView, p = e.place;
if (!v || !p || !e.drawn) return;
v.focus = {x: (p.W / 2 - e.pos.x) / e.drawn, y: (p.H / 2 - e.pos.y) / e.drawn, sx: 0.5, sy: 0.5};
v.focusPage = e.num;
}
/**
* Where the page's content is, in the page's own top-down coordinates at scale 1 (as
* getViewport gives them, so turned pages and odd media boxes come out right): its text,
* or — a scan without text — what isn't white on a small drawing of it.
*/
async function _computePdfTextBox(page, natural) {
const pageW = natural.width, pageH = natural.height;
// Tier 1: text-based // Tier 1: text-based
try { try {
const tc = await page.getTextContent(); const tc = currentPdfDoc ? await _pdfPageText(currentPdfDoc, page.pageNumber) : await page.getTextContent();
if (tc.items && tc.items.length) { if (tc.items && tc.items.length) {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const item of tc.items) { for (const item of tc.items) {
if (!item.transform) continue; if (!item.transform || !item.str || !item.str.trim()) continue;
const tx = item.transform[4], ty = item.transform[5]; const tx = item.transform[4], ty = item.transform[5];
const iw = item.width || 0, ih = item.height || 0; const iw = item.width || 0, ih = item.height || Math.abs(item.transform[3]) || 0;
if (tx < x1) x1 = tx; for (const [px, py] of [[tx, ty], [tx + iw, ty + ih]]) {
if (ty < y1) y1 = ty; const [vx, vy] = natural.convertToViewportPoint(px, py);
if (tx + iw > x2) x2 = tx + iw; if (vx < x1) x1 = vx;
if (ty + ih > y2) y2 = ty + ih; if (vx > x2) x2 = vx;
if (vy < y1) y1 = vy;
if (vy > y2) y2 = vy;
}
} }
const area = (x2 - x1) * (y2 - y1); const area = (x2 - x1) * (y2 - y1);
if (isFinite(x1) && area > pageW * pageH * 0.25) { if (isFinite(x1) && area > pageW * pageH * 0.25) {
return {x1, y1, x2, y2}; return {x1: Math.max(0, x1), y1: Math.max(0, y1), x2: Math.min(pageW, x2), y2: Math.min(pageH, y2)};
} }
} }
} catch (e) {} } catch (e) {}
@ -179,17 +390,18 @@ async function _computePdfTextBox(page, pageW, pageH) {
const lowScale = 0.3; const lowScale = 0.3;
const vp = page.getViewport({scale: lowScale}); const vp = page.getViewport({scale: lowScale});
const offCanvas = document.createElement('canvas'); const offCanvas = document.createElement('canvas');
offCanvas.width = vp.width; offCanvas.width = Math.ceil(vp.width);
offCanvas.height = vp.height; offCanvas.height = Math.ceil(vp.height);
const ctx = offCanvas.getContext('2d'); const ctx = offCanvas.getContext('2d');
await page.render({canvasContext: ctx, viewport: vp}).promise; await page.render({canvasContext: ctx, viewport: vp}).promise;
const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height); const {data, width, height} = ctx.getImageData(0, 0, offCanvas.width, offCanvas.height);
offCanvas.width = offCanvas.height = 0;
let rMin = height, rMax = 0, cMin = width, cMax = 0; let rMin = height, rMax = 0, cMin = width, cMax = 0;
for (let r = 0; r < height; r++) { for (let r = 0; r < height; r++) {
for (let c = 0; c < width; c++) { for (let c = 0; c < width; c++) {
const idx = (r * width + c) * 4; const idx = (r * width + c) * 4;
if (data[idx] + data[idx+1] + data[idx+2] < 720) { if (data[idx] + data[idx + 1] + data[idx + 2] < 720) {
if (r < rMin) rMin = r; if (r < rMin) rMin = r;
if (r > rMax) rMax = r; if (r > rMax) rMax = r;
if (c < cMin) cMin = c; if (c < cMin) cMin = c;
@ -198,12 +410,7 @@ async function _computePdfTextBox(page, pageW, pageH) {
} }
} }
if (rMin < rMax && cMin < cMax) { if (rMin < rMax && cMin < cMax) {
return { return {x1: cMin / lowScale, y1: rMin / lowScale, x2: cMax / lowScale, y2: rMax / lowScale};
x1: cMin / lowScale,
y1: (height - rMax) / lowScale,
x2: cMax / lowScale,
y2: (height - rMin) / lowScale,
};
} }
} catch (e) {} } catch (e) {}

View file

@ -59,8 +59,8 @@ function addBookmark() {
let label, anchor, scrollFraction; let label, anchor, scrollFraction;
if (currentPdfDoc) { if (currentPdfDoc) {
const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1; const page = pdfCurrentPage || 1;
label = `Seite ${page}`; label = `Seite ${pdfPageLabel(page)}`;
anchor = `pdf-page-${page}`; anchor = `pdf-page-${page}`;
scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1); scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1);
} else { } else {
@ -143,15 +143,7 @@ function jumpToBookmark(id) {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
if (bm.anchor.startsWith('pdf-page-')) { if (bm.anchor.startsWith('pdf-page-')) {
if (readerSettings.pdfPaginated) { pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1);
pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1);
} else {
const target = contentEl.querySelector('#' + bm.anchor);
if (target) {
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
}
}
} else { } else {
const target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) const target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
.find(el => el.getAttribute('data-epub-src') === bm.anchor); .find(el => el.getAttribute('data-epub-src') === bm.anchor);

View file

@ -94,15 +94,9 @@ async function doReaderSearch(query) {
} catch (e) {} } catch (e) {}
} }
} else { } else {
// PDF: collect text layer spans // PDF: the whole document, page by page (not only the pages drawn now).
const spans = contentEl.querySelectorAll('.pdf-text-layer > span'); _pdfSearchAll(query);
const lq = query.toLowerCase(); return;
for (const span of spans) {
if (span.textContent.toLowerCase().includes(lq)) {
span.classList.add('reader-search-match');
searchMatches.push(span);
}
}
} }
if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0'; if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0';
@ -131,10 +125,7 @@ function clearReaderSearchHighlights() {
}); });
} }
} else { } else {
// PDF: remove highlight class from spans _pdfSearchStop();
document.querySelectorAll('.reader-search-match').forEach(el => {
el.classList.remove('reader-search-match', 'active');
});
} }
searchMatches = []; searchMatches = [];
searchMatchIndex = -1; searchMatchIndex = -1;
@ -150,6 +141,7 @@ function clearReaderSearch() {
function scrollToSearchMatch(idx) { function scrollToSearchMatch(idx) {
if (!searchMatches.length) return; if (!searchMatches.length) return;
if (currentPdfDoc) { _pdfShowMatch(idx); return; }
_suppressScrollJumpDetect(); _suppressScrollJumpDetect();
searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx)); searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx));
searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'}); searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
@ -157,6 +149,134 @@ function scrollToSearchMatch(idx) {
if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`; if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`;
} }
// ---------------------------------------------------------------------------
// PDF search: through the text of every page (58-pdf.js keeps it), in order; the matches
// marked in the text layer of the pages drawn, and of those drawn later.
// ---------------------------------------------------------------------------
let _pdfSearchState = null; // {query, matches: [{page, start, end}], done}
let _pdfSearchGen = 0;
function _pdfSearchCount() {
const countEl = document.getElementById('rs-search-count');
const st = _pdfSearchState;
if (!countEl || !st) return;
const more = st.done ? '' : ' …';
countEl.textContent = st.matches.length
? `${searchMatchIndex >= 0 ? searchMatchIndex + 1 : '–'} / ${st.matches.length}${more}`
: (st.done ? '0' : '…');
}
async function _pdfSearchAll(query) {
const pdf = currentPdfDoc;
const gen = ++_pdfSearchGen;
const lq = query.toLowerCase();
const st = {query, matches: [], done: false};
_pdfSearchState = st;
searchMatches = st.matches;
searchMatchIndex = -1;
_pdfSearchCount();
// The first match from the page being read on is shown as soon as it's found.
const from = pdfCurrentPage || 1;
for (let n = 1; n <= pdf.numPages; n++) {
const tc = await _pdfPageText(pdf, n);
if (gen !== _pdfSearchGen || pdf !== currentPdfDoc) return;
const {str} = _pdfPageString(tc);
const before = st.matches.length;
let i = 0;
while (lq && (i = str.indexOf(lq, i)) !== -1) {
st.matches.push({page: n, start: i, end: i + lq.length});
i += lq.length;
}
if (st.matches.length > before) {
const e = _pdfView && _pdfView.entries[n - 1];
if (e) _pdfSearchMarkPage(e);
if (searchMatchIndex < 0 && n >= from) { searchMatchIndex = before; _pdfShowMatch(before); }
}
if (n % 20 === 0) _pdfSearchCount();
}
st.done = true;
if (searchMatchIndex < 0 && st.matches.length) { searchMatchIndex = 0; _pdfShowMatch(0); }
_pdfSearchCount();
}
/** Marks of search [st] in page [e]'s text layer (none when no search is on). */
function _pdfSearchMarkPage(e) {
if (!e.textDivs || !e.textInfo) return;
if (e.marked) {
for (const i of e.marked) if (e.textDivs[i]) e.textDivs[i].textContent = e.textInfo.items[i].str;
e.marked = null;
}
const st = _pdfSearchState;
if (!st) return;
const {starts} = e.textInfo.string;
const items = e.textInfo.items;
// Per span, the pieces of it that match (one match can run over several spans).
const pieces = new Map();
st.matches.forEach((m, idx) => {
if (m.page !== e.num) return;
for (let i = 0; i < items.length; i++) {
const s = starts[i], len = (items[i].str || '').length;
if (s >= m.end) break;
if (s + len <= m.start || !len) continue;
if (!pieces.has(i)) pieces.set(i, []);
pieces.get(i).push([Math.max(m.start, s) - s, Math.min(m.end, s + len) - s, idx]);
}
});
e.marked = [];
pieces.forEach((list, i) => {
const div = e.textDivs[i];
if (!div) return;
const str = items[i].str;
div.textContent = '';
let at = 0;
for (const [a, b, idx] of list) {
if (a > at) div.appendChild(document.createTextNode(str.slice(at, a)));
const mark = document.createElement('mark');
mark.className = 'reader-search-match' + (idx === searchMatchIndex ? ' active' : '');
mark.dataset.m = idx;
mark.textContent = str.slice(a, b);
div.appendChild(mark);
at = b;
}
if (at < str.length) div.appendChild(document.createTextNode(str.slice(at)));
e.marked.push(i);
});
}
/** Go to match [idx]: its page, and (scrolling) the match itself into view. */
async function _pdfShowMatch(idx) {
const st = _pdfSearchState;
const m = st && st.matches[idx];
if (!m) return;
const gen = _pdfSearchGen;
_pdfSearchCount();
document.querySelectorAll('.pdf-text-layer mark.reader-search-match').forEach(mk => {
mk.classList.toggle('active', +mk.dataset.m === idx);
});
const contentEl = $('reader-content');
const shown = readerSettings.pdfPaginated ? pdfCurrentPage : (contentEl ? _pdfTopPage(contentEl) : 0);
if (m.page !== shown || readerSettings.pdfPaginated) pdfGoToPage(m.page);
if (readerSettings.pdfPaginated || !contentEl) return;
// Scrolling: the page is drawn (and its text laid over it) once it's near the view.
for (let t = 0; t < 50; t++) {
if (gen !== _pdfSearchGen || searchMatchIndex !== idx) return;
const mk = contentEl.querySelector(`#pdf-page-${m.page} mark[data-m="${idx}"]`);
if (mk) {
_suppressScrollJumpDetect();
mk.scrollIntoView({block: 'center'});
return;
}
await new Promise(r => setTimeout(r, 100));
}
}
function _pdfSearchStop() {
_pdfSearchGen++;
_pdfSearchState = null;
if (_pdfView) _pdfView.entries.forEach(e => { if (e.marked) _pdfSearchMarkPage(e); });
}
function readerSearchNext() { function readerSearchNext() {
if (!searchMatches.length) return; if (!searchMatches.length) return;
searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length; searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length;

View file

@ -219,7 +219,9 @@ function handleReaderSelection(e) {
return; return;
} }
if (!markerModeActive) return; // Markings belong to books (an article has nowhere to keep them, 65-article.js), and to
// EPUBs only: a PDF's text layer is there to select and copy.
if (!markerModeActive || !currentBookId || currentPdfDoc) return;
// Don't hijack taps on real interactive content (footnote refs, external // Don't hijack taps on real interactive content (footnote refs, external
// links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless // links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless
// <a id="..."> used only as an internal jump target (sanitizeEpubHtml // <a id="..."> used only as an internal jump target (sanitizeEpubHtml

View file

@ -1,41 +1,131 @@
'use strict'; 'use strict';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Footnote popover // Footnotes: finding a reference's note, and the popover that shows it
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The whole book is one concatenated DOM, and ids like "fn1" repeat per
// chapter file: a note is looked up in the chapter its link points into (the
// link's original href, kept in data-footnote-href by sanitizeEpubHtml), and
// only exactly the note is shown — not the parent around an id that sits on
// the note's own <p>/<aside>/<li>, which can be a whole chapter. The same
// lookup serves the popover here, the notes at the foot of a page
// (63-pages.js) and the apps' note sheet (diora-android native-ui.js).
const _NOTE_INLINE = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL|CODE)$/;
function noteHrefOf(link) {
return link.dataset.footnoteHref || ('#' + (link.dataset.footnoteRef || ''));
}
function _noteChapterFor(fromEl, href) {
const chapter = fromEl.closest('[data-epub-src]');
const file = href.split('#')[0];
if (!file || !chapter) return chapter;
const src = chapter.dataset.epubSrc || '';
const dir = src.includes('/') ? src.slice(0, src.lastIndexOf('/') + 1) : '';
const target = resolveEpubPath(dir, file);
return document.querySelector(`#reader-content > [data-epub-src="${CSS.escape(target)}"]`) || chapter;
}
// The element `href` (as written in the book, relative to fromEl's chapter) points at.
function resolveNoteTarget(fromEl, href) {
const id = decodeURIComponent(href.slice(href.indexOf('#') + 1));
const scope = _noteChapterFor(fromEl, href);
const content = document.getElementById('reader-content');
let el = id && scope ? scope.querySelector(`[id="${CSS.escape(id)}"]`) : null;
if (!el && id && content) el = content.querySelector(`[id="${CSS.escape(id)}"]`);
if (!el && !id) el = scope; // link to a whole chapter file
return el;
}
// Converters (zeno.org / e-artnow, some Calibre output) park an empty
// <a id="F537"></a> right *before* the note's paragraph instead of inside it —
// or at the start of it (then the paragraph is the note, not what follows).
function _noteAfterEmptyAnchor(target) {
if ((target.textContent || '').trim() || target.querySelector('img')) return null;
const own = target.closest('p, li, dd, dt, td, blockquote');
const ownText = own && !own.hasAttribute('data-epub-src') ? (own.textContent || '').trim() : '';
if (ownText && !/^[\[(]?\d{1,4}[\])]?\.?$/.test(ownText)) return own;
if (ownText) {
// Only the number: the note (a poem, a long quote) runs on in the next
// paragraphs, up to the next note's anchor.
const box = document.createElement('div');
box.appendChild(own.cloneNode(true));
let len = 0;
for (let next = own.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
if (next.matches('h1, h2, h3, h4, h5, h6') || next.querySelector('[id]')) break;
if (!(next.textContent || '').trim()) continue;
box.appendChild(next.cloneNode(true));
len += next.textContent.length;
}
return box;
}
for (let el = target; el && !el.hasAttribute('data-epub-src'); el = el.parentElement) {
let next = el.nextElementSibling;
while (next && !(next.textContent || '').trim()) next = next.nextElementSibling;
if (next) return next.matches('h1, h2, h3, h4, h5, h6') ? null : next;
}
return null;
}
function _noteBox(target) {
const following = _noteAfterEmptyAnchor(target);
if (following) return following;
let box = target.closest(_NOTE_BOX_SELECTOR); // 40-epub.js
if (box && (box.hasAttribute('data-epub-src') || box.id === 'reader-content')) box = null;
if (!box) box = _NOTE_INLINE.test(target.tagName) ? (target.closest('p, li, dd, div, td, blockquote') || target) : target;
// Never a whole chapter or section: fall back to the paragraph around the target.
if (box.hasAttribute('data-epub-src') || (box.textContent || '').length > 6000) {
box = target.closest('p, li, dd') || target;
}
return box;
}
// A copy of the note at `target`, ready to show outside the book.
function noteContentFor(target, label) {
const clone = _noteBox(target).cloneNode(true);
clone.querySelectorAll('[role="doc-backlink"], [epub\\:type~="backlink"]').forEach(e => e.remove());
// A page of the printed edition that starts inside the note.
clone.querySelectorAll('.epub-pagenum, [role="doc-pagebreak"], [epub\\:type~="pagebreak"]').forEach(e => e.remove());
// Links back to the text ("↩", or the note's own number at its start).
clone.querySelectorAll('a').forEach(a => {
const t = (a.textContent || '').trim();
if (/^[↩↑⤴^]+$/.test(t)) a.remove();
});
// The note's own number at its very start ("3 Endnote…", "[4] …"): whatever
// shows the note labels it already.
const first = Array.prototype.find.call(clone.querySelectorAll('a, sup, span, b, strong'),
e => (e.textContent || '').trim());
const firstText = first ? (first.textContent || '').trim() : '';
const bare = t => t.replace(/[\[\]().\s]/g, '');
if (first && firstText && (clone.textContent || '').trim().startsWith(firstText) &&
(bare(firstText) === bare(label || '') || /^[\[(]?(\d{1,4}|[*†‡§]+)[\])]?\.?$/.test(firstText))) {
first.remove();
// …and the space that followed it.
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null, false);
const t = walker.nextNode();
if (t) t.nodeValue = t.nodeValue.replace(/^\s+/, '');
}
// No duplicate ids in the page (getElementById must keep finding the book's).
clone.removeAttribute('id');
clone.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
clone.querySelectorAll('mark.tts-current, mark.reader-search-match').forEach(m => m.replaceWith(...m.childNodes));
return clone;
}
// The note of a reference link, or null.
function noteForLink(link) {
const target = resolveNoteTarget(link, noteHrefOf(link));
return target ? noteContentFor(target, (link.textContent || '').trim()) : null;
}
let currentFootnotePopover = null; let currentFootnotePopover = null;
let _footnotePopoverTimer = null; let _footnotePopoverTimer = null;
function showFootnotePopover(link) { function showFootnotePopover(link) {
dismissFootnotePopover(); dismissFootnotePopover();
const targetEl = document.getElementById(link.dataset.footnoteRef); const note = noteForLink(link);
if (!targetEl) return; if (!note) return;
// The note's paragraph, also when the id sits on an inline element in it
// (<p><span><a id="F400" …>143</a></span> Note…</p>).
const inline = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL)$/.test(targetEl.tagName);
const container = targetEl.closest('.footnote') ||
(inline && targetEl.closest('p, li, dd, blockquote')) || targetEl.parentElement;
if (!container) return;
const clone = container.cloneNode(true);
clone.querySelector('sup')?.remove();
clone.querySelectorAll('.epub-pagenum').forEach(e => e.remove());
// The note's own number at its start, a link back to the text ("1 Note…").
const first = Array.from(clone.querySelectorAll('a')).find(a => a.textContent.trim());
const num = first ? first.textContent.trim() : '';
if (/^[\[(]?\d{1,4}[\])]?\.?$/.test(num) && clone.textContent.trim().startsWith(num)) first.remove();
// Only the number: the note (a poem, a long quote) runs on in the next
// paragraphs, up to the next note's anchor.
if (!clone.textContent.trim()) {
let len = 0;
for (let next = container.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
if (/^H[1-6]$/.test(next.tagName) || next.querySelector('[id]')) break;
if (!next.textContent.trim()) continue;
clone.appendChild(next.cloneNode(true));
len += next.textContent.length;
}
}
const popover = document.createElement('div'); const popover = document.createElement('div');
popover.className = 'footnote-popover'; popover.className = 'footnote-popover';
@ -43,7 +133,7 @@ function showFootnotePopover(link) {
['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => { ['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => {
if (_overlay?.classList.contains(cls)) popover.classList.add(cls); if (_overlay?.classList.contains(cls)) popover.classList.add(cls);
}); });
popover.innerHTML = clone.innerHTML; popover.appendChild(note);
const closeBtn = document.createElement('button'); const closeBtn = document.createElement('button');
closeBtn.className = 'footnote-popover-close'; closeBtn.className = 'footnote-popover-close';
@ -66,7 +156,7 @@ function showFootnotePopover(link) {
const pr = popover.getBoundingClientRect(); const pr = popover.getBoundingClientRect();
if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px'; if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px';
if (pr.bottom > window.innerHeight - 8) popover.style.top = (rect.top - pr.height - 8) + 'px'; if (pr.bottom > window.innerHeight - 8) popover.style.top = Math.max(8, rect.top - pr.height - 8) + 'px';
} }
function dismissFootnotePopover() { function dismissFootnotePopover() {

View file

@ -31,6 +31,10 @@
folder: null, // the folder chip chosen (null: all, '': without a folder) folder: null, // the folder chip chosen (null: all, '': without a folder)
folderOrder: [], // the podcasts' folder order (/podcasts/folders/) folderOrder: [], // the podcasts' folder order (/podcasts/folders/)
series: null, // the series shown (its key), inside the folder series: null, // the series shown (its key), inside the folder
q: '', // the search in the shelf's head
searching: false, // the search field open (narrow screens show a ⌕ first)
sort: cached('sort') || 'recent',
status: 'all', // the state chip: all, started, unheard, heard
}; };
const shelf = document.createElement('div'); const shelf = document.createElement('div');
@ -175,45 +179,6 @@
return st.detail; return st.detail;
} }
// ---- the shelf in "Bücher" ------------------------------------------------------------
function renderShelf() {
const h = host();
const books = st.books || [];
const state = h ? podcastState() : null;
const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : [];
if (!h) { shelf.innerHTML = ''; return; }
if (!books.length && !pods.length) {
shelf.innerHTML = '<p class="ab-empty">Hörbücher und Hörspiele sind hier Podcasts, die wie ein Buch gehört ' +
'werden: Kapitel in Reihenfolge, Lesezeichen. Erst einen Podcast abonnieren (Podcasts → Suche), dann hier markieren.</p>';
return;
}
let html = '';
const go = books.filter(b => b.next && b.last_played_at)[0];
if (go) {
html += '<div class="ab-continue" data-ab-open="' + go.feed_id + '">' +
cover(go.artwork_url, go.title, 'ab-cover ab-cover-s') +
'<div class="ab-card-text"><div class="ab-label">Weiterhören</div>' +
'<div class="ab-title">' + esc(go.title) + '</div>' +
'<div class="ab-meta">' + esc(progressText(go)) + '</div>' +
'<div class="ab-bar"><span style="width:' + pct(go) + '%"></span></div></div>' +
'<button type="button" class="ab-play" data-ab-go="' + go.feed_id + '" aria-label="Weiterhören">▶</button></div>';
}
html += '<div class="ab-head"><h3 class="ab-h">Hörbücher</h3>' +
(pods.length ? '<button type="button" class="ab-link" data-ab="pick">+ Hörbuch</button>' : '') + '</div>';
if (books.length) {
html += gridHtml(books);
} else {
html += '<p class="ab-empty">Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' +
'wie ein Buch hören: in Kapitelreihenfolge, mit Lesezeichen. „+ Hörbuch“ wählt einen aus.</p>';
}
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; }
}
// ---- organising: folders (set per book) and series (found in the titles) ---------------- // ---- organising: folders (set per book) and series (found in the titles) ----------------
// "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12": // "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12":
@ -270,28 +235,131 @@
x.sort.localeCompare(y.sort, 'de')); x.sort.localeCompare(y.sort, 'de'));
} }
// ---- the shelf, built like the books' library (53-library.js: its lib-* parts, and its
// styles, which hold for #audiobook-shelf too): a head with search, sort and +, chips for
// the state and the folders, "Weiterhören" on top, the books as cards. ---------------------
const SORTS = [['recent', 'Zuletzt'], ['title', 'Titel'], ['author', 'Autor'],
['long', 'Längste zuerst'], ['short', 'Kürzeste zuerst']];
const heard = b => !b.next;
const started = b => !!b.next && (b.heard_seconds > 0 || b.done > 0 || !!b.last_played_at);
const unheard = b => !!b.next && !started(b);
const STATES = {started, unheard, heard};
function renderShelf() {
const h = host();
const books = st.books || [];
const state = h ? podcastState() : null;
const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : [];
if (!h) { shelf.innerHTML = ''; return; }
const narrow = window.matchMedia && matchMedia('(max-width: 720px)').matches;
let html = '<div class="lib-head"><h2 class="lib-h">Hörbücher</h2>' +
(!narrow || st.searching || st.q
? '<input class="lib-q" type="search" placeholder="Titel, Autor …" value="' + esc(st.q) + '">'
: '<button type="button" class="lib-icon" data-ab="search" aria-label="Suchen">⌕</button>') +
'<select class="lib-sort" aria-label="Sortieren">' + SORTS.map(s =>
'<option value="' + s[0] + '"' + (st.sort === s[0] ? ' selected' : '') + '>' + s[1] + '</option>').join('') + '</select>' +
(pods.length ? '<button type="button" class="lib-icon lib-add" data-ab="pick" aria-label="Hörbuch hinzufügen" ' +
'title="Einen abonnierten Podcast als Hörbuch führen">+</button>' : '') +
'</div><div class="lib-chips"></div>';
if (st.picking) html += pickHtml(pods);
if (!books.length) {
html += '<p class="lib-empty">' + (pods.length
? 'Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier wie ein Buch hören: ' +
'in Kapitelreihenfolge, mit Lesezeichen. Mit + einen auswählen.'
: 'Hörbücher und Hörspiele sind hier Podcasts, die wie ein Buch gehört werden: Kapitel in Reihenfolge, ' +
'Lesezeichen. Erst einen Podcast abonnieren (Podcasts → Suche), dann hier mit + markieren.') + '</p>';
} else {
html += listHtml(books);
}
const q = shelf.querySelector('.lib-q');
const focused = q && document.activeElement === q;
shelf.innerHTML = html;
drawChips(books);
if (focused || (st.searching && !st.q)) {
const n = shelf.querySelector('.lib-q');
if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); }
}
const input = shelf.querySelector('.ab-pick-q');
if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; }
}
function shownBooks(books) {
const q = (st.q || '').trim().toLowerCase();
return books.filter(b =>
(st.folder == null ? true : st.folder === '' ? !foldersOf(b).length : foldersOf(b).indexOf(st.folder) >= 0) &&
(!STATES[st.status] || STATES[st.status](b)) &&
(!q || (b.title + ' ' + (b.author || '')).toLowerCase().indexOf(q) >= 0));
}
// The list's order (entries() comes "Zuletzt": the last heard first).
function sorted(list) {
const books = e => (e.kind === 'series' ? e.books : [e.b]);
const length = e => books(e).reduce((n, b) => n + (b.total_seconds || 0), 0);
const author = e => books(e)[0].author || '';
const by = {
title: (x, y) => x.sort.localeCompare(y.sort, 'de'),
author: (x, y) => author(x).localeCompare(author(y), 'de') || x.sort.localeCompare(y.sort, 'de'),
long: (x, y) => length(y) - length(x),
short: (x, y) => length(x) - length(y),
}[st.sort];
return by ? list.slice().sort(by) : list;
}
function listHtml(books) {
const here = shownBooks(books);
// Series as stacks, unless searching or filtering by state (as with the books).
const stack = !st.q && !STATES[st.status];
const list = sorted(stack ? entries(here) : here.map(b => ({kind: 'book', b, sort: b.title})));
if (st.series != null) {
const g = list.filter(e => e.kind === 'series' && e.key === st.series)[0];
if (g) {
return '<div class="lib-place"><button type="button" class="lib-btn" data-ab="up">‹ Hörbücher</button>' +
'<strong class="lib-place-name">' + esc(g.name) + '</strong></div>' +
'<div class="lib-grid">' + g.books.map(b => bookCard(b, b._volume)).join('') + '</div>';
}
st.series = null;
}
let html = '';
const go = !st.q && st.folder == null && !STATES[st.status] && books.filter(b => b.next && b.last_played_at)[0];
if (go) html += continueHtml(go);
if (!list.length) return html + '<p class="lib-empty">Nichts gefunden.</p>';
return html + '<div class="lib-grid">' + list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
}
// "Weiterhören": the book last listened to, large — like "Weiterlesen".
function continueHtml(b) {
return '<section class="lib-recent" aria-label="Weiterhören"><div class="lib-continue" data-ab-open="' + b.feed_id + '">' +
cover(b.artwork_url, b.title, 'lib-hero-cover', b.author) +
'<span class="lib-text"><span class="lib-kicker">Weiterhören</span>' +
'<span class="lib-title lib-hero-title">' + esc(b.title) + '</span>' +
(b.author ? '<span class="lib-meta">' + esc(b.author) + '</span>' : '') +
'<span class="lib-hero-progress"><span class="lib-bar"><span style="width:' + pct(b) + '%"></span></span>' +
'<span class="lib-meta">' + esc(progressText(b)) + '</span></span>' +
'<button type="button" class="lib-play" data-ab-go="' + b.feed_id + '">▶ Weiterhören</button></span></div></section>';
}
function bookCard(b, label) { function bookCard(b, label) {
return '<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' + return '<div class="lib-card" data-ab-open="' + b.feed_id + '">' +
cover(b.artwork_url, b.title, 'ab-cover') + '<span class="lib-coverbox">' + cover(b.artwork_url, b.title, 'lib-cover', b.author) +
'<span class="ab-card-text"><span class="ab-title">' + esc(label || b.title) + '</span>' + (heard(b) ? '<span class="lib-badge">✓</span>' : '') + '</span>' +
(b.author && !label ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') + '<span class="lib-bar"><span style="width:' + pct(b) + '%"></span></span>' +
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' + '<span class="lib-title">' + esc(label || b.title) + '</span>' +
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>'; '<span class="lib-meta">' + esc(label ? progressText(b) : (b.author || progressText(b))) + '</span></div>';
} }
function seriesCard(g) { function seriesCard(g) {
const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0); const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0);
const heard = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0); const heardSecs = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
const going = g.books.filter(b => b.next && b.last_played_at)[0]; const going = g.books.filter(b => b.next && b.last_played_at)[0];
const done = g.books.filter(b => !b.next).length; const done = g.books.filter(heard).length;
const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume
: done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : ''); : done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : '');
return '<button type="button" class="ab-card ab-stack" data-ab-series="' + esc(g.key) + '">' + return '<div class="lib-card lib-stack" data-ab-series="' + esc(g.key) + '">' +
'<span class="ab-stack-covers">' + cover(g.books[0].artwork_url, g.name, 'ab-cover') + '</span>' + '<span class="lib-coverbox lib-stackbox">' + cover(g.books[0].artwork_url, g.name, 'lib-cover', g.books[0].author) + '</span>' +
'<span class="ab-card-text"><span class="ab-title">' + esc(g.name) + '</span>' + '<span class="lib-bar"><span style="width:' + (total ? Math.round(100 * heardSecs / total) : 0) + '%"></span></span>' +
(g.books[0].author ? '<span class="ab-meta">' + esc(g.books[0].author) + '</span>' : '') + '<span class="lib-title">' + esc(g.name) + '</span>' +
'<span class="ab-meta">' + esc(meta) + '</span>' + '<span class="lib-meta">' + esc(meta) + '</span></div>';
'<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] : [])); const foldersOf = b => (b.folders && b.folders.length ? b.folders : (b.folder ? [b.folder] : []));
@ -311,32 +379,24 @@
return {folders: names.map(n => ({name: n, count: counts.get(n)})), none: names.length ? none : 0}; return {folders: names.map(n => ({name: n, count: counts.get(n)})), none: names.length ? none : 0};
} }
function gridHtml(books) {
const here = books.filter(b => st.folder == null ? true
: st.folder === '' ? !foldersOf(b).length : foldersOf(b).indexOf(st.folder) >= 0);
const list = entries(here);
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">‹ 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;
}
return '<div class="ab-chips"></div><div class="ab-grid">' +
list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
}
function drawChips(books) { function drawChips(books) {
const el = shelf.querySelector('.ab-chips'); const el = shelf.querySelector('.lib-chips');
if (!el || !window.DioraFolders) return; if (!el || !window.DioraFolders) return;
if (!books.length) { el.innerHTML = ''; return; }
const c = folderChips(books); const c = folderChips(books);
if (!c.folders.length) { el.innerHTML = ''; return; }
window.DioraFolders.chips(el, { window.DioraFolders.chips(el, {
lead: [
{id: 'started', label: 'Angefangen', count: books.filter(started).length, on: st.status === 'started'},
{id: 'unheard', label: 'Ungehört', count: books.filter(unheard).length, on: st.status === 'unheard'},
{id: 'heard', label: 'Gehört', count: books.filter(heard).length, on: st.status === 'heard'},
],
folders: c.folders, active: st.folder, none: c.none, folders: c.folders, active: st.folder, none: c.none,
onPick(name) { st.folder = name; renderShelf(); }, onPick(p) {
onManage: manageFolders, if (p && p.lead) st.status = st.status === p.lead ? 'all' : p.lead;
else st.folder = p;
renderShelf();
},
onManage: c.folders.length ? manageFolders : null,
}); });
} }
@ -442,7 +502,7 @@
const d = st.detail && st.detail.book.feed_id === st.open ? st.detail : null; const d = st.detail && st.detail.book.feed_id === st.open ? st.detail : null;
const fromList = (st.books || []).filter(b => b.feed_id === st.open)[0]; const fromList = (st.books || []).filter(b => b.feed_id === st.open)[0];
const b = d ? d.book : fromList; const b = d ? d.book : fromList;
if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; } if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Hörbücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; }
const now = nowInBook(); const now = nowInBook();
const marks = d ? d.bookmarks : []; const marks = d ? d.bookmarks : [];
const dlMap = d ? downloads() : null; const dlMap = d ? downloads() : null;
@ -452,7 +512,7 @@
return ch ? 'Kapitel ' + (ch.index + 1) : ''; return ch ? 'Kapitel ' + (ch.index + 1) : '';
}; };
view.innerHTML = view.innerHTML =
'<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button>' + '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Hörbücher</button>' +
'<button type="button" class="ab-icon" data-ab="menu" aria-label="Optionen">⋮</button></div>' + '<button type="button" class="ab-icon" data-ab="menu" aria-label="Optionen">⋮</button></div>' +
'<div class="ab-menu" hidden>' + '<div class="ab-menu" hidden>' +
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' + '<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
@ -763,8 +823,9 @@
} }
shelf.addEventListener('click', e => { shelf.addEventListener('click', e => {
const t = e.target.closest('button, .ab-continue'); const t = e.target.closest('button, .lib-continue, .lib-card');
if (!t) return; if (!t) return;
if (t.dataset.ab === 'search') { st.searching = true; renderShelf(); return; }
if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); 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.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; }
if (t.dataset.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; } if (t.dataset.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; }
@ -792,7 +853,18 @@
if (t.dataset.ab === 'pick') { st.picking = !st.picking; st.pickFocus = st.picking; renderShelf(); return; } if (t.dataset.ab === 'pick') { st.picking = !st.picking; st.pickFocus = st.picking; renderShelf(); return; }
if (t.dataset.ab === 'pick-close') { st.picking = false; renderShelf(); } if (t.dataset.ab === 'pick-close') { st.picking = false; renderShelf(); }
}); });
shelf.addEventListener('change', e => {
if (!e.target.classList.contains('lib-sort')) return;
st.sort = e.target.value;
cached('sort', st.sort);
renderShelf();
});
shelf.addEventListener('input', e => { shelf.addEventListener('input', e => {
if (e.target.classList.contains('lib-q')) {
st.q = e.target.value;
renderShelf();
return;
}
if (!e.target.classList.contains('ab-pick-q')) return; if (!e.target.classList.contains('ab-pick-q')) return;
st.pickQuery = e.target.value; st.pickQuery = e.target.value;
const box = shelf.querySelector('.ab-pick-list'); const box = shelf.querySelector('.ab-pick-list');

View file

@ -122,16 +122,20 @@
async function pdfCover(bytes) { async function pdfCover(bytes) {
if (typeof pdfjsLib === 'undefined') return null; if (typeof pdfjsLib === 'undefined') return null;
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise; const pdf = await (typeof _pdfLoad === 'function' ? _pdfLoad(new Uint8Array(bytes))
const page = await pdf.getPage(1); : pdfjsLib.getDocument({data: new Uint8Array(bytes)})).promise;
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'); const canvas = document.createElement('canvas');
canvas.width = Math.round(viewport.width); try {
canvas.height = Math.round(viewport.height); const page = await pdf.getPage(1);
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise; const v = page.getViewport({scale: 1});
pdf.destroy(); const scale = Math.min(MAX_W / v.width, MAX_H / v.height);
const viewport = page.getViewport({scale});
canvas.width = Math.round(viewport.width);
canvas.height = Math.round(viewport.height);
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
} finally {
try { pdf.destroy(); } catch (e) {}
}
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY)); return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY));
} }

View file

@ -603,59 +603,35 @@
// ---- article view // ---- article view
let reader = null; // the overlay element // ---- reading: an article opens in the book reader (65-article.js, DioraArticle) —
// the same typography, colours, pages, search, read-aloud and full screen as books.
let readerList = []; // what "next" walks through (blog items) let readerList = []; // what "next" walks through (blog items)
let readerStory = null; let readerStory = null;
let readerItem = null; let readerItem = null;
let reading = false; // an article of ours is in the reader
function fontSize() {
let v = 18;
try { v = parseInt(localStorage.getItem('diora_feeds_font') || '18', 10) || 18; } catch (e) {}
return Math.max(14, Math.min(26, v));
}
function ensureReader() {
if (reader) return reader;
reader = document.createElement('div');
reader.id = 'fd-article';
reader.hidden = true;
reader.addEventListener('click', onReaderClick);
reader.addEventListener('scroll', () => {
clearTimeout(progressTimer);
progressTimer = setTimeout(saveProgress, 1200);
}, {passive: true});
document.body.appendChild(reader);
return reader;
}
function readerOpen() { function readerOpen() {
return !!(reader && !reader.hidden); return reading && !!(window.DioraArticle && DioraArticle.isOpen());
} }
function closeReader() { function closeReader() {
if (!reader) return; if (window.DioraArticle) DioraArticle.close();
saveProgress(); }
reader.hidden = true;
reader.innerHTML = ''; function articleClosed() {
document.documentElement.classList.remove('fd-reading'); reading = false;
readerItem = readerStory = null; readerItem = readerStory = null;
render(); render();
} }
// How far the open article is read (0-1); saved now and then and when leaving it. // How far the open article is read (0-1, from the reader); saved now and then and when leaving it.
let progressSent = 0; let progressSent = 0;
let progressTimer = null;
function readProgress() { function saveProgress(f) {
if (!reader || reader.hidden) return 0;
const room = reader.scrollHeight - reader.clientHeight;
return room <= 40 ? 1 : Math.min(1, reader.scrollTop / room);
}
function saveProgress() {
const i = readerItem; const i = readerItem;
if (!i) return; if (!i) return;
const p = Math.round(readProgress() * 100) / 100; const p = Math.round(f * 100) / 100;
if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return; if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return;
progressSent = p; progressSent = p;
i.progress = p; i.progress = p;
@ -667,51 +643,68 @@
api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {}); api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {});
} }
async function openItem(id, story) { // Opens a link outside, like the article's own links (the app hands them to the browser).
ensureReader(); function openOutside(url) {
if (readerItem) saveProgress(); const a = document.createElement('a');
readerStory = story || null; a.href = url;
reader.hidden = false; a.target = '_blank';
document.documentElement.classList.add('fd-reading'); a.rel = 'noopener';
reader.innerHTML = readerShell('<p class="fd-note">Lädt …</p>'); document.body.appendChild(a);
reader.scrollTop = 0; a.click();
try { a.remove();
const data = await api('/feeds/items/' + id + '/');
readerItem = data.item;
reader.innerHTML = readerShell(articleHtml(readerItem));
reader.scrollTop = 0;
if (readerStory) loadOverview(readerStory);
progressSent = readerItem.progress || 0;
if (readerItem.kind === 'news') {
// News is read once opened; a blog post once read to 90 % (the server keeps where).
if (!readerItem.read) {
readerItem.read = true;
markRead([id], true);
}
} else {
if (progressSent > 0.02 && progressSent < 0.9) {
// Back where reading stopped (on any device).
requestAnimationFrame(() => { reader.scrollTop = progressSent * (reader.scrollHeight - reader.clientHeight); });
} else {
setTimeout(saveProgress, 1500); // short enough to fit: read at once
}
}
} catch (e) {
reader.innerHTML = readerShell('<p class="fd-error">' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '</p>');
}
} }
function readerShell(inner) { // The article's own buttons, in the reader's header (the apps: in its menu).
const i = readerItem; function articleActions(i) {
return '<div class="fd-art-bar">' + return [
'<button type="button" class="fd-icon" data-r="close" aria-label="Zurück" title="Zurück">‹</button>' + {icon: i.starred ? '★' : '☆', label: i.starred ? 'Gemerkt' : 'Merken', active: !!i.starred, action: () => toggleStar(i)},
'<span class="fd-art-src">' + esc(i ? i.source_title : '') + '</span>' +
'<button type="button" class="fd-icon" data-r="font" aria-label="Schriftgröße" title="Schriftgröße">Aa</button>' +
(i ? '<button type="button" class="fd-icon' + (i.starred ? ' on' : '') + '" data-r="star" aria-label="Merken" title="Merken">' + (i.starred ? '★' : '☆') + '</button>' : '') +
// Projekte (projects.js): this article as a source of a project. // Projekte (projects.js): this article as a source of a project.
(i && window.DioraProjects ? '<button type="button" class="fd-icon" data-r="project" aria-label="Mit Projekt verknüpfen" title="Mit Projekt verknüpfen">◇</button>' : '') + window.DioraProjects && {icon: '◇', label: 'Mit Projekt verknüpfen', action: () => window.DioraProjects.link('article', i.id, {
(i && i.url ? '<a class="fd-icon" href="' + esc(i.url) + '" target="_blank" rel="noopener" aria-label="Im Browser öffnen" title="Im Browser öffnen">↗</a>' : '') + title: i.title, source: i.source_title, author: i.author, date: i.published, url: i.url, news: i.kind === 'news'})},
'</div><div class="fd-art" style="font-size:' + fontSize() + 'px">' + inner + '</div>'; i.url && {icon: '↗', label: 'Im Browser öffnen', action: () => openOutside(i.url)},
];
}
function toggleStar(i) {
i.starred = !i.starred;
api('/feeds/items/' + i.id + '/star/', {starred: i.starred}).catch(() => {});
st.items.forEach(x => { if (x.id === i.id) x.starred = i.starred; });
if (window.DioraArticle) DioraArticle.refreshActions();
}
async function openItem(id, story) {
if (!window.DioraArticle) return;
readerStory = story || null;
let item = null;
let error = '';
try {
item = (await api('/feeds/items/' + id + '/')).item;
} catch (e) {
error = e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message;
}
readerItem = item;
reading = true;
if (!item) {
DioraArticle.open({title: 'Artikel', html: '<p class="fd-error">' + esc(error) + '</p>',
onClick: onReaderClick, onClose: articleClosed});
return;
}
progressSent = item.progress || 0;
await DioraArticle.open({
title: item.source_title || item.title,
html: articleHtml(item),
// News is read once opened; a blog post once read to 90 % (the server keeps where).
startFraction: item.kind === 'news' ? 0 : progressSent,
onProgress: f => { if (readerItem === item) saveProgress(f); },
onClick: onReaderClick,
onClose: articleClosed,
actions: () => articleActions(item),
});
if (readerStory) loadOverview(readerStory);
if (item.kind === 'news' && !item.read) {
item.read = true;
markRead([id], true);
}
} }
function articleHtml(i) { function articleHtml(i) {
@ -727,6 +720,10 @@
if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>'; if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>';
const content = i.content || ''; const content = i.content || '';
if (i.image_url && content.indexOf(i.image_url) < 0) h += '<img class="fd-art-img" alt="" src="' + esc(i.image_url) + '">'; if (i.image_url && content.indexOf(i.image_url) < 0) h += '<img class="fd-art-img" alt="" src="' + esc(i.image_url) + '">';
if (i.episode_id) {
// The article of a podcast episode (docs/transcription.md): to the episode itself.
h += '<p class="fd-art-listen"><button type="button" class="btn" data-r="episode" data-id="' + i.episode_id + '">▶ Folge anhören</button></p>';
}
h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>'; h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>';
if (i.paywalled) { if (i.paywalled) {
h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) + h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
@ -787,7 +784,7 @@
} catch (e) { } catch (e) {
return; // offline and never fetched: the article alone return; // offline and never fetched: the article alone
} }
const box = reader && !reader.hidden && readerStory === story && reader.querySelector('.fd-ov'); const box = readerOpen() && readerStory === story && document.querySelector('#reader-content .fd-ov');
if (box) box.innerHTML = overviewHtml(story); if (box) box.innerHTML = overviewHtml(story);
} }
@ -807,37 +804,76 @@
}); });
} }
// ---- the episode of a podcast article (docs/transcription.md)
function podcastHost() {
return window.DioraPodcastHost || window.DioraNative || null;
}
function parseJson(text, fallback) {
try { return JSON.parse(text); } catch (e) { return fallback; }
}
// Play the episode from `seconds` (null: open its page, where ▶ starts it). The browser and the
// diora desktop have the host of podcasts-web.js; the apps' bridge has the same calls
// (podcastPlayEpisodeAt is the one that starts at a place; without it the episode starts at its own place).
async function playEpisode(episodeId, seconds) {
const host = podcastHost();
if (!host || !host.playerState) { alert('Der Podcast-Bereich ist hier nicht verfügbar.'); return; }
const now = parseJson(host.playerState(), {});
const ms = seconds == null ? null : Math.max(0, Math.round(seconds * 1000));
let opened = false;
if (now.kind === 'podcast' && now.episodeId === episodeId) {
if (ms != null && host.podcastSeek) host.podcastSeek(ms);
if (ms != null && !now.playing && host.podcastToggle) host.podcastToggle();
opened = ms != null;
} else {
let d;
try {
d = await api('/podcasts/episodes/' + episodeId + '/');
} catch (e) {
alert(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : 'Die Folge ist nicht mehr da.');
return;
}
const row = {id: d.id, title: d.title, audio_url: d.audio_url, duration_seconds: d.duration_seconds,
artwork_url: d.artwork_url, pub_date: d.pub_date, position_seconds: d.position_seconds,
feed__id: d.feed.id, feed__title: d.feed.title, feed__artwork_url: d.feed.artwork_url};
closeReader();
if (ms != null && host.podcastPlayEpisodeAt) {
host.podcastPlayEpisodeAt(JSON.stringify(row), now.episodeId || 0, ms);
opened = true;
} else if (ms == null && window.__dioraPodcastOpenEpisode && window.__dioraPodcastOpenEpisode(row)) {
return;
} else if (host.podcastPlayEpisode) {
host.podcastPlayEpisode(JSON.stringify(row), now.episodeId || 0);
opened = true;
}
}
closeReader();
if (opened && window.__dioraOpenPlayer) window.__dioraOpenPlayer('podcast');
}
// Clicks in the article (65-article.js hands them over): its other reports, the next article.
function onReaderClick(e) { function onReaderClick(e) {
const b = e.target.closest('[data-r]'); // A time mark in a podcast article ("▶ ab 12:34", href="#t=754"; the reader keeps it as data-internal-href).
if (!b) { const t = e.target.closest('a[data-internal-href^="#t="], a[href^="#t="]');
// Links in the article open outside (the app hands them to the browser). if (t && readerItem && readerItem.episode_id) {
const a = e.target.closest('.fd-art-body a[href]'); const sec = parseInt((t.dataset.internalHref || t.getAttribute('href')).slice(3), 10);
if (a) { a.target = '_blank'; a.rel = 'noopener'; } if (sec >= 0) {
e.preventDefault();
playEpisode(readerItem.episode_id, sec);
}
return; return;
} }
const b = e.target.closest('[data-r]');
if (!b) return;
const r = b.dataset.r; const r = b.dataset.r;
if (r === 'close') closeReader(); if (r === 'episode') playEpisode(parseInt(b.dataset.id, 10), null);
else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory); else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
else if (r === 'story') { else if (r === 'story') {
const other = readerStory && readerStory.overview && (readerStory.overview.related || []).find(x => x.id === parseInt(b.dataset.id, 10)); const other = readerStory && readerStory.overview && (readerStory.overview.related || []).find(x => x.id === parseInt(b.dataset.id, 10));
if (other) openItem((other.lead || other.items[0]).id, other); if (other) openItem((other.lead || other.items[0]).id, other);
} }
else if (r === 'font') {
let v = fontSize() + 2;
if (v > 26) v = 14;
try { localStorage.setItem('diora_feeds_font', String(v)); } catch (err) {}
const art = reader.querySelector('.fd-art');
if (art) art.style.fontSize = v + 'px';
} else if (r === 'project' && readerItem && window.DioraProjects) {
window.DioraProjects.link('article', readerItem.id, {title: readerItem.title, source: readerItem.source_title,
author: readerItem.author, date: readerItem.published, url: readerItem.url, news: readerItem.kind === 'news'});
} else if (r === 'star' && readerItem) {
readerItem.starred = !readerItem.starred;
api('/feeds/items/' + readerItem.id + '/star/', {starred: readerItem.starred}).catch(() => {});
b.textContent = readerItem.starred ? '★' : '☆';
b.classList.toggle('on', readerItem.starred);
st.items.forEach(i => { if (i.id === readerItem.id) i.starred = readerItem.starred; });
}
} }
async function openStory(id) { async function openStory(id) {
@ -1354,9 +1390,6 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
} }
return false; return false;
}; };
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && readerOpen()) closeReader();
});
// diora Reader has no tab bar of its own: a small one. With the navigation (nav.js) it // diora Reader has no tab bar of its own: a small one. With the navigation (nav.js) it
// holds the sections of Lesen itself — a row of them to swipe is no good on e-ink. // holds the sections of Lesen itself — a row of them to swipe is no good on e-ink.
@ -1436,9 +1469,9 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
'.fd-sources { display: flex; gap: 6px; margin: 4px -18px 10px; padding: 0 18px; align-items: center; overflow-x: auto; white-space: nowrap; scrollbar-width: none; }', '.fd-sources { display: flex; gap: 6px; margin: 4px -18px 10px; padding: 0 18px; align-items: center; overflow-x: auto; white-space: nowrap; scrollbar-width: none; }',
'.fd-sources::-webkit-scrollbar { display: none; }', '.fd-sources::-webkit-scrollbar { display: none; }',
'.fd-sources .fd-chip { flex: none; }', '.fd-sources .fd-chip { flex: none; }',
'#fd-article .fd-art-more { font-family: var(--font); margin: 30px 0 4px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }', '.fd-artview .fd-art-more { font-family: var(--font); margin: 30px 0 4px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
'#fd-article .fd-other { display: flex; flex-direction: column; gap: 3px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 0.85em; text-align: left; cursor: pointer; }', '.fd-artview .fd-other { display: flex; flex-direction: column; gap: 3px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 0.85em; text-align: left; cursor: pointer; }',
'#fd-article .fd-other-title { font-weight: 600; line-height: 1.35; }', '.fd-artview .fd-other-title { font-weight: 600; line-height: 1.35; }',
'.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }', '.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }', '.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }',
'.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }', '.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }',
@ -1491,45 +1524,39 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
'#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }', '#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }',
'#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }', '#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }',
'#tab-feeds .fd-file { display: inline-block; }', '#tab-feeds .fd-file { display: inline-block; }',
// The article: a page over everything, like the book reader. // The article, in the book reader (65-article.js).
'#fd-article { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; background: var(--bg, #000); color: var(--fg, #fff); overflow-y: auto; -webkit-overflow-scrolling: touch; }', '.fd-artview .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
'#fd-article[hidden] { display: none; }', '.fd-artview .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
'#fd-article .fd-art-bar { position: sticky; top: 0; display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: var(--bg, #000); border-bottom: 1px solid var(--border, #2a2a2a); padding-top: calc(4px + env(safe-area-inset-top, 0px)); }', '.fd-artview .fd-art-img, .fd-artview .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }',
'#fd-article .fd-art-src { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--nui-muted, #9a9a9a); }', '.fd-artview .fd-art-listen { margin: 0 0 12px; }',
'#fd-article .fd-art { max-width: 68ch; margin: 0 auto; padding: 12px 18px 48px; font-family: "Source Serif 4", Georgia, serif; font-weight: 380; line-height: 1.65; }', '.fd-artview .fd-art-body a { color: var(--accent, #e63946); }',
'#fd-article .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }', '.fd-artview .fd-art-body figure { margin: 12px 0; }',
'#fd-article .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }', '.fd-artview .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
'#fd-article .fd-art-img, #fd-article .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }', '.fd-artview .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
'#fd-article .fd-art-body a { color: var(--accent, #e63946); }', '.fd-artview .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
'#fd-article .fd-art-body figure { margin: 12px 0; }', '.fd-artview .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
'#fd-article .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }', '.fd-artview .fd-art-body td, .fd-artview .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
'#fd-article .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }', '.fd-artview .fd-sources { font-family: var(--font); }',
'#fd-article .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
'#fd-article .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
'#fd-article .fd-art-body td, #fd-article .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
'#fd-article .fd-sources { font-family: var(--font); }',
'#tab-feeds .fd-topics .fd-note { margin-right: 2px; }', '#tab-feeds .fd-topics .fd-note { margin-right: 2px; }',
'#tab-feeds .fd-topics small { opacity: 0.6; }', '#tab-feeds .fd-topics small { opacity: 0.6; }',
'#fd-article .fd-ov { font-size: 0.9em; }', '.fd-artview .fd-ov { font-size: 0.9em; }',
'#fd-article .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }', '.fd-artview .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
'#fd-article .fd-ov-box summary { font-family: var(--font); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); cursor: pointer; }', '.fd-artview .fd-ov-box summary { font-family: var(--font); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); cursor: pointer; }',
'#fd-article .fd-ov p { margin: 8px 0; line-height: 1.45; }', '.fd-artview .fd-ov p { margin: 8px 0; line-height: 1.45; }',
'#fd-article .fd-ov h4 { font-family: var(--font); font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); margin: 14px 0 4px; }', '.fd-artview .fd-ov h4 { font-family: var(--font); font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); margin: 14px 0 4px; }',
'#fd-article .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }', '.fd-artview .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
'#fd-article .fd-ov-words { font-family: var(--font); font-size: 13px; }', '.fd-artview .fd-ov-words { font-family: var(--font); font-size: 13px; }',
'#fd-article .fd-ov-rel { margin: 0 0 18px; }', '.fd-artview .fd-ov-rel { margin: 0 0 18px; }',
'#fd-article .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }', '.fd-artview .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }',
'html.fd-reading #nui-dock { display: none; }',
'@media (hover: hover) { #tab-feeds .fd-card:hover, #tab-feeds .fd-row:hover, #tab-feeds .fd-post:hover, #tab-feeds .fd-src:hover { background: var(--hover, #111); } }', '@media (hover: hover) { #tab-feeds .fd-card:hover, #tab-feeds .fd-row:hover, #tab-feeds .fd-post:hover, #tab-feeds .fd-src:hover { background: var(--hover, #111); } }',
// Bright pages and e-ink: black on white, no grey boxes, no thumbnails to flicker. // Bright pages and e-ink: black on white, no grey boxes, no thumbnails to flicker.
'body.bright-bg #tab-feeds .fd-brief, body.eink #tab-feeds .fd-brief { background: #fff; }', 'body.bright-bg #tab-feeds .fd-brief, body.eink #tab-feeds .fd-brief { background: #fff; }',
'body.eink #tab-feeds .fd-thumb { display: none; }', 'body.eink #tab-feeds .fd-thumb { display: none; }',
'body.eink #tab-feeds .fd-seg button.on, body.eink #tab-feeds .fd-badge { background: #000; color: #fff; }', 'body.eink #tab-feeds .fd-seg button.on, body.eink #tab-feeds .fd-badge { background: #000; color: #fff; }',
'body.eink .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }', 'body.eink .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }',
'body.eink #fd-article, body.eink #fd-article .fd-art-bar { background: #fff; color: #000; }', 'body.eink .fd-artview .fd-ov-box { border-color: #000; }',
'body.eink #fd-article .fd-ov-box { border-color: #000; }', 'body.eink .fd-artview .fd-ov-src, body.eink .fd-artview .fd-ov h4, body.eink .fd-artview .fd-ov-box summary { color: #333; }',
'body.eink #fd-article .fd-ov-src, body.eink #fd-article .fd-ov h4, body.eink #fd-article .fd-ov-box summary { color: #333; }', 'body.eink .fd-artview .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }',
'body.eink #fd-article .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }',
].join('\n'); ].join('\n');
document.head.appendChild(style); document.head.appendChild(style);
})(); })();

View file

@ -22,22 +22,45 @@
// lead: chips before the folders (the library's Angefangen/Ungelesen …): [{id, label, count, on}], // 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. // picked with onPick({lead: id}). active: null = all, '' = without a folder, else a name.
// The folders fold away (a "Ordner" chip), per area (the list's element id), remembered on
// this device; folded, only the folder chosen stays in sight. Folded until opened.
function areaKey(el) {
const owner = el.id ? el : el.parentElement && el.parentElement.closest('[id]');
return 'diora_folders_folded_' + (owner ? owner.id : 'x');
}
function folded(el) {
try { return localStorage.getItem(areaKey(el)) !== '0'; } catch (e) { return true; }
}
function chips(el, o) { function chips(el, o) {
const lead = o.lead || []; const lead = o.lead || [];
const leadOn = lead.some(c => c.on); const leadOn = lead.some(c => c.on);
const folders = o.folders || [];
const fold = folders.length > 0 && folded(el);
let h = '<div class="dfo-row" role="toolbar">'; 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"'); 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) + '"'); }); 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>'; if (lead.length && folders.length) h += '<span class="dfo-sep" aria-hidden="true"></span>';
(o.folders || []).forEach(f => { if (folders.length) {
h += '<button type="button" class="dfo-chip dfo-fold" data-dfo-fold="1" aria-expanded="' + !fold + '" title="' +
(fold ? 'Ordner zeigen' : 'Ordner einklappen') + '">Ordner ' + (fold ? '▸ <span class="dfo-count">' + folders.length + '</span>' : '▾') + '</button>';
}
folders.forEach(f => {
if (fold && o.active !== f.name) return;
h += chip('', f.name, f.count, o.active === f.name, 'data-dfo-folder="' + esc(f.name) + '"'); 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.none && (!fold || o.active === '')) 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>'; if (o.onManage && !fold) 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.innerHTML = h + '</div>';
el.onclick = e => { el.onclick = e => {
const b = e.target.closest('button'); const b = e.target.closest('button');
if (!b) return; if (!b) return;
if (b.dataset.dfoFold) {
try { localStorage.setItem(areaKey(el), fold ? '0' : '1'); } catch (err) {}
chips(el, o);
return;
}
if (b.dataset.dfoManage) { o.onManage(); return; } if (b.dataset.dfoManage) { o.onManage(); return; }
if (b.dataset.dfoAll) { o.onPick(null); return; } if (b.dataset.dfoAll) { o.onPick(null); return; }
if (b.dataset.dfoLead != null) { o.onPick({lead: b.dataset.dfoLead}); return; } if (b.dataset.dfoLead != null) { o.onPick({lead: b.dataset.dfoLead}); return; }
@ -188,6 +211,7 @@
'.dfo-count { opacity: .7; font-size: 12px; }', '.dfo-count { opacity: .7; font-size: 12px; }',
'.dfo-none { font-style: italic; }', '.dfo-none { font-style: italic; }',
'.dfo-manage { min-width: 34px; padding: 0 8px; }', '.dfo-manage { min-width: 34px; padding: 0 8px; }',
'.dfo-fold { border-style: dashed; }',
'.dfo-sep { flex: none; width: 1px; height: 20px; background: var(--border-strong, #444); margin: 0 2px; }', '.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. // 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; }', '.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; }',

View file

@ -0,0 +1,380 @@
// ---------------------------------------------------------------------------
// Podcast articles on the device (docs/transcription.md): the transcript of an episode goes to
// Claude with the user's own key (DioraAI, docs/ai.md) and comes back as an article that is saved
// to the episode and to the blog "Podcast-Artikel". Also the picker-up of open wishes: the server
// only remembers that an article was wanted; a device with a key writes it.
//
// An ES module like ai.js. window.DioraPodcastArticle = {write, pickUp, watch, ...}; the helpers
// are exported too (tools/tests/podcast-article.test.mjs runs them under Node).
// ---------------------------------------------------------------------------
export const MAX_TOKENS_IN = 120000; // about nine hours of speech; more stops with a message
export const GROUP_SECONDS = 30;
const SHOWNOTES_MAX = 6000; // characters of the shownotes handed to Claude
const POLL_MS = 5 * 60 * 1000;
const ai = () => globalThis.DioraAI;
export const SYSTEM = `You turn the transcript of a podcast episode into an article that reads as if the people
speaking had written it themselves instead of recording the episode.
The reader wants to decide whether this episode still has something to teach them. They do not
want a summary; they want to understand what is argued here well enough to judge it for
themselves. Write for that reader.
Perspective. Write from the speakers' standpoint and state their positions directly, as their
own claims. Never distance yourself from them: no "im Podcast wird die These aufgestellt", "der
Sprecher meint", "in dieser Folge geht es um", "die Gäste diskutieren", "laut X". If the episode
is one person speaking, write as that person argues, in the third person or impersonal voice,
but without reporting verbs. Do not add your own opinion, criticism or fact checks, and do not
soften what is said.
Conversations. Interviews, conversations and discussions are condensed into their core theses;
the form of question and answer disappears. Where people hold different positions, keep them
apart and name who holds which ("Zuckermann hält dagegen, dass …"), but do not turn it into
minutes. Questions from a host matter only where they shape the argument. When the transcript
has no speaker names, infer them from the context (introductions, the shownotes, how people
address each other); if you can't, do not invent names.
Substance over summary. Give the arguments with their reasons, examples and evidence, not just
the conclusions. Keep figures, dates, quotations that carry an argument (short, in quotation
marks, attributed), and every work mentioned (books, articles, studies, films) with author and
title. Make clear what is new, distinctive or surprising in the episode and where it restates
what is commonly said, by how you weight and order it, without grading it.
Leave out: advertising and sponsors, appeals for donations, subscriptions or ratings, notes on
the podcast itself (schedules, technology, community), greetings, small talk and digressions that
lead nowhere.
Form. Write in the language of the episode. \`title\`: a title for the article, not the episode
title repeated. \`summary\`: three to six sentences up front that state the central thesis and how
the episode gets there. \`sections\`: as many as the content needs (usually four to ten), each
with a heading that says something (not "Einleitung"), \`start\` = the second in the episode where
its content begins (from the [mm:ss] marks), and \`body_markdown\` in plain paragraphs; lists only
where the speakers list things themselves. Follow the order of the episode unless a different
order makes the argument clearer. Length: as long as needed for the reader to judge the episode,
roughly a tenth of the transcript, at most about 5,000 words.
The transcript was made by speech recognition: fix obvious mishearings, especially of names,
using the metadata, and don't quote garbled passages.`;
export const SCHEMA = {
"type": "object",
"additionalProperties": false,
"required": [
"title",
"summary",
"kind",
"speakers",
"sections"
],
"properties": {
"title": {
"type": "string"
},
"summary": {
"type": "string"
},
"kind": {
"type": "string",
"enum": [
"Interview",
"Gespräch",
"Monolog",
"Diskussion"
]
},
"speakers": {
"type": "array",
"items": {
"type": "string"
}
},
"sections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"heading",
"start",
"body_markdown"
],
"properties": {
"heading": {
"type": "string"
},
"start": {
"type": "integer"
},
"body_markdown": {
"type": "string"
}
}
}
}
}
};
// ---- the input text -------------------------------------------------------------------------
export const pad = n => String(n).padStart(2, '0');
// mm:ss (minutes keep counting past 59: "125:03"), the form of the [mm:ss] marks Claude reads.
export function mmss(sec) {
const s = Math.max(0, Math.floor(Number(sec) || 0));
return pad(Math.floor(s / 60)) + ':' + pad(s % 60);
}
// h:mm:ss from an hour on, else mm:ss: the label of the "▶ ab …" links.
export function clock(sec) {
const s = Math.max(0, Math.floor(Number(sec) || 0));
return s >= 3600 ? Math.floor(s / 3600) + ':' + pad(Math.floor(s / 60) % 60) + ':' + pad(s % 60) : mmss(s);
}
// One line per group of about GROUP_SECONDS: "[mm:ss] Sprecher: text", or "[mm:ss] text" without
// speakers. A new speaker always starts a new line.
export function transcriptToText(segments) {
const lines = [];
let cur = null;
for (const seg of segments || []) {
const text = String((seg && seg.text) || '').replace(/\s+/g, ' ').trim();
if (!text) continue;
const start = Number(seg.start) || 0;
const speaker = String(seg.speaker || '').trim();
if (!cur || speaker !== cur.speaker || start - cur.start >= GROUP_SECONDS) {
cur = {start, speaker, parts: []};
lines.push(cur);
}
cur.parts.push(text);
}
return lines.map(l => '[' + mmss(l.start) + '] ' + (l.speaker ? l.speaker + ': ' : '') + l.parts.join(' ')).join('\n');
}
export const estimateTokens = text => Math.ceil(String(text || '').length / 4);
// HTML (the shownotes) as plain text.
export function htmlToText(html) {
return String(html || '')
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
.replace(/<\s*br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h[1-6]|blockquote|tr)\s*>/gi, '\n')
.replace(/<li[^>]*>/gi, '- ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'").replace(/&amp;/g, '&')
.replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
export function metaBlock(ep) {
const rows = [];
const feed = ep.feed || {};
if (feed.title) rows.push('Podcast: ' + feed.title);
if (ep.author || feed.author) rows.push('Autor: ' + (ep.author || feed.author));
rows.push('Folge: ' + (ep.title || ''));
if (ep.pub_date) rows.push('Datum: ' + String(ep.pub_date).slice(0, 10));
if (ep.duration_seconds) rows.push('Dauer: ' + clock(ep.duration_seconds));
let notes = htmlToText(ep.description);
if (notes.length > SHOWNOTES_MAX) notes = notes.slice(0, SHOWNOTES_MAX) + ' …';
if (notes) rows.push('Shownotes:\n' + notes);
const chapters = (ep.chapters || []).filter(c => c && c.title);
if (chapters.length) rows.push('Kapitel:\n' + chapters.map(c => '[' + mmss(c.start) + '] ' + c.title).join('\n'));
return rows.join('\n');
}
function userError(message) {
const e = new Error(message);
e.diora = true;
return e;
}
// The prompt (user turn), or an Error with a German message when it is too long.
export function buildPrompt(ep, transcript) {
const text = transcriptToText(transcript && transcript.segments);
if (!text) throw userError('Das Transkript ist leer.');
const prompt = metaBlock(ep) + '\n\nTranskript:\n' + text;
const tokens = estimateTokens(prompt + SYSTEM);
if (tokens > MAX_TOKENS_IN) {
throw userError('Dieses Transkript ist für einen Artikel zu lang (etwa ' + Math.round(tokens / 1000) +
'.000 Tokens, mehr als ' + MAX_TOKENS_IN / 1000 + '.000 gehen nicht, das sind rund neun Stunden).');
}
return prompt;
}
// ---- markdown to HTML (paragraphs, emphasis, lists, block quotes; everything else is escaped) ---
export const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// Escaped first, then the emphasis marks: model output can't bring its own tags.
function inline(s) {
return esc(s)
.replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*\w])\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')
.replace(/(^|[^_\w])_(?=[^\s_])(.+?)(?<=[^\s_])_(?![_\w])/g, '$1<em>$2</em>');
}
export function markdownToHtml(md) {
const blocks = String(md || '').replace(/\r\n?/g, '\n').split(/\n\s*\n/);
const out = [];
for (const block of blocks) {
const lines = block.split('\n').map(l => l.replace(/\s+$/, '')).filter(l => l.trim());
if (!lines.length) continue;
if (lines.every(l => /^\s*>/.test(l))) {
out.push('<blockquote><p>' + inline(lines.map(l => l.replace(/^\s*>\s?/, '')).join(' ')) + '</p></blockquote>');
} else if (lines.every(l => /^\s*([-*+]|\d+[.)])\s+/.test(l))) {
const tag = /^\s*\d/.test(lines[0]) ? 'ol' : 'ul';
out.push('<' + tag + '>' + lines.map(l => '<li>' + inline(l.replace(/^\s*([-*+]|\d+[.)])\s+/, '')) + '</li>').join('') + '</' + tag + '>');
} else {
out.push('<p>' + inline(lines.map(l => l.trim()).join(' ')) + '</p>');
}
}
return out.join('\n');
}
// The article: the summary as a lead paragraph, per section a heading, the "▶ ab 12:34" link
// (feeds.js plays the item's episode from `#t=`) and the body.
export function articleHtml(answer) {
const parts = [];
if (answer.summary) parts.push(markdownToHtml(answer.summary));
for (const s of answer.sections || []) {
const t = Math.max(0, Math.floor(Number(s.start) || 0));
parts.push('<h2>' + esc(s.heading) + '</h2>');
parts.push('<p><a href="#t=' + t + '">▶ ab ' + clock(t) + '</a></p>');
parts.push(markdownToHtml(s.body_markdown));
}
return parts.filter(Boolean).join('\n');
}
// ---- talking to diora ---------------------------------------------------------------------------
function csrf() {
try {
if (typeof getCsrfToken === 'function') return getCsrfToken();
} catch (e) { /* fall through */ }
const m = typeof document !== 'undefined' && /(?:^|; )csrftoken=([^;]+)/.exec(document.cookie || '');
return m ? decodeURIComponent(m[1]) : '';
}
const send = (url, method, body) => fetch(url, {
method, headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: body === undefined ? undefined : JSON.stringify(body),
});
// This device's name for reserving a wish (/article/claim/), kept per browser or app.
let memDevice = '';
export function deviceId() {
try {
let id = localStorage.getItem('diora-device-id');
if (!id) { id = 'd-' + Math.random().toString(36).slice(2, 12); localStorage.setItem('diora-device-id', id); }
return id;
} catch (e) {
return memDevice || (memDevice = 'd-' + Math.random().toString(36).slice(2, 12));
}
}
export const claimWish = id => send('/podcasts/episodes/' + id + '/article/claim/', 'POST', {device: deviceId()});
export const release = id => send('/podcasts/episodes/' + id + '/article/claim/', 'DELETE', {device: deviceId()});
async function getJson(url) {
const r = await fetch(url, {cache: 'no-store'});
return {status: r.status, ok: r.ok, data: r.ok ? await r.json() : null};
}
// Write the article of an episode and save it. Resolves {item_id, title}.
// onState({phase: 'loading'|'writing'|'saving', text, words}); signal aborts.
export async function write(episodeId, {onState, signal} = {}) {
const say = (phase, text, words) => { if (onState) { try { onState({phase, text, words}); } catch (e) { /* display only */ } } };
const AI = ai();
if (!AI || !AI.available()) throw userError('Die KI-Funktionen sind auf diesem Gerät nicht verfügbar.');
say('loading', 'Lade Transkript …');
const base = '/podcasts/episodes/' + encodeURIComponent(episodeId) + '/';
const [detail, tr] = await Promise.all([getJson(base), getJson(base + 'transcript/')]);
if (!detail.ok) throw userError('Die Folge ließ sich nicht laden (' + detail.status + ').');
if (!tr.ok) throw userError(tr.status === 404 ? 'Zu dieser Folge gibt es noch kein Transkript.' : 'Das Transkript ließ sich nicht laden (' + tr.status + ').');
const prompt = buildPrompt(detail.data, tr.data);
const answer = await new Promise((resolve, reject) => {
// The key first (the settings open when there is none, as for every KI feature).
AI.withKey(() => AI.ask({
system: SYSTEM, prompt, schema: SCHEMA, effort: 'medium', maxTokens: 32000, signal,
onProgress: words => say('writing', 'Schreibe Artikel … ' + words.toLocaleString('de-DE') + ' Wörter', words),
}).then(resolve, reject));
});
if (!answer || !Array.isArray(answer.sections) || !answer.sections.length) throw userError('Die Antwort enthielt keinen Artikel.');
say('saving', 'Speichere Artikel …');
const title = answer.title || detail.data.title;
const r = await send(base + 'article/', 'POST', {
title,
summary: answer.summary || '',
body_html: articleHtml(answer),
model: AI.modelName ? AI.modelName() : 'claude-opus-5-5',
kind: answer.kind || '',
});
if (!r.ok) throw userError('Der Artikel ließ sich nicht speichern (' + r.status + ').');
const saved = await r.json();
return {item_id: saved.item_id, title};
}
// "Artikel fertig": a system notification on Android (the bridge), else a toast in the page.
export function notifyDone(title, episodeId) {
const N = globalThis.DioraNative;
try {
if (N && typeof N.notify === 'function') { N.notify('Artikel fertig', title, 'episode:' + episodeId); return; }
} catch (e) { /* fall back to the toast */ }
const AI = ai();
if (AI && AI.toast) AI.toast('Artikel fertig: ' + title);
}
let running = false;
const gaveUp = new Set(); // wishes that failed in this visit: not tried again until diora is reopened
// The open wishes (docs/transcription.md): reserve one, write it, save it. Nothing without a key.
export async function pickUp({onState} = {}) {
if (running) return {busy: true};
const AI = ai();
if (!AI || !AI.available()) return {done: 0};
running = true;
const result = {done: 0, failed: 0, skipped: 0};
try {
if (!(await AI.hasKey())) return result;
let pending;
try {
const r = await getJson('/podcasts/transcription/pending/');
if (!r.ok || !Array.isArray(r.data)) return result;
pending = r.data;
} catch (e) { return result; }
for (const wish of pending) {
if (gaveUp.has(wish.episode_id)) { result.skipped++; continue; }
let claim;
try { claim = await claimWish(wish.episode_id); } catch (e) { return result; }
if (!claim.ok) { result.skipped++; continue; } // 409: another device has it
try {
const saved = await write(wish.episode_id, {onState});
result.done++;
notifyDone(saved.title || wish.title, wish.episode_id);
} catch (e) {
result.failed++;
gaveUp.add(wish.episode_id);
try { await release(wish.episode_id); } catch (e2) { /* it runs out in 15 minutes */ }
}
}
} finally {
running = false;
}
return result;
}
// While diora is open and a job of its own runs, ask every five minutes (watch(true) when one starts,
// watch(false) when none does).
let timer = null;
export function watch(on) {
if (on && !timer) timer = setInterval(() => { pickUp(); }, POLL_MS);
else if (!on && timer) { clearInterval(timer); timer = null; }
}
export const _reset = () => { running = false; gaveUp.clear(); };
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.DioraPodcastArticle = {write, pickUp, watch, notifyDone, claim: claimWish, release, deviceId, markdownToHtml, transcriptToText};
window.dispatchEvent(new Event('diora-podcast-article'));
// At the start of diora.
setTimeout(() => { pickUp(); }, 4000);
}

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v155'; const CACHE = 'diora-v171';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',
@ -16,6 +16,7 @@ const SHELL = [
'/static/js/app/29-m3u.js', '/static/js/app/29-m3u.js',
'/static/js/app/30-contrast.js', '/static/js/app/30-contrast.js',
'/static/js/app/32-crypto.js', '/static/js/app/32-crypto.js',
'/static/js/app/33-unlock.js',
'/static/js/app/34-wallpaper.js', '/static/js/app/34-wallpaper.js',
'/static/js/app/40-epub.js', '/static/js/app/40-epub.js',
'/static/js/app/44-books.js', '/static/js/app/44-books.js',
@ -31,6 +32,8 @@ const SHELL = [
'/static/js/app/58-pdf.js', '/static/js/app/58-pdf.js',
'/static/js/app/60-tts.js', '/static/js/app/60-tts.js',
'/static/js/app/62-reader.js', '/static/js/app/62-reader.js',
'/static/js/app/63-pages.js',
'/static/js/app/65-article.js',
'/static/js/app/64-reader-settings.js', '/static/js/app/64-reader-settings.js',
'/static/js/app/66-pdf-paginated.js', '/static/js/app/66-pdf-paginated.js',
'/static/js/app/68-bookmarks.js', '/static/js/app/68-bookmarks.js',

View file

@ -1,4 +1,4 @@
// Copied from diora-android e287f48 2026-10-25: web-native/static/native-podcast.js // Copied from diora-android ba1e933 2026-11-09: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js. // and copy again. The browser host is static/js/podcasts-web.js.
@ -273,6 +273,47 @@
t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600); t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
} }
// ---- sharing an episode: its page from the feed (`link`), else the file / the video's watch URL ----
function shareUrl(e) {
const r = e.raw || {};
const d = e.id > 0 ? storedDetails(e.id) : null;
return r.link || r.episode__link || (d && d.link) || r.audio_url || r.episode__audio_url || (d && d.audio_url) || '';
}
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).then(() => true, () => copyTextOld(text));
}
return Promise.resolve(copyTextOld(text));
}
function copyTextOld(text) {
const t = el('textarea');
t.value = text;
t.setAttribute('readonly', '');
t.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(t);
t.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) {}
t.remove();
return ok;
}
// Android: the system's share sheet. Elsewhere (browser, desktop app): title and link onto the clipboard.
async function shareEpisode(e) {
let url = shareUrl(e);
if (!url && e.id > 0 && navigator.onLine) { // the phone's own list data may not know the link yet
const d = await loadDetails(e.id);
url = (d && (d.link || d.audio_url)) || '';
}
if (!url) { toast('Diese Folge hat keine Adresse zum Teilen'); return; }
const headline = e.feed ? `${e.title} – ${e.feed}` : e.title;
if (N && N.shareText) { N.shareText(headline, url); return; }
toast(await copyText(headline + '\n' + url) ?'Link kopiert' : 'Kopieren ging nicht');
}
// GET from our server; a status other than 2xx throws (as a network error does). // GET from our server; a status other than 2xx throws (as a network error does).
async function getJson(url) { async function getJson(url) {
const r = await fetch(url); const r = await fetch(url);
@ -581,6 +622,7 @@
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)}, {icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
!playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)}, !playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e), favoriteItem(e),
shareItem(e),
!playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')}, !playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
{icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')}, {icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')},
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')}, {icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
@ -595,6 +637,10 @@
]); ]);
} }
function shareItem(e) {
return {icon: '⇪', label: 'Teilen', hint: N && N.shareText ? null : 'Link in die Zwischenablage', action: () => shareEpisode(e)};
}
function episodeMenu(e, opts) { function episodeMenu(e, opts) {
if (v2()) { listsMenu(e, opts); return; } if (v2()) { listsMenu(e, opts); return; }
const queued = inQueue(e.id); const queued = inQueue(e.id);
@ -602,6 +648,7 @@
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)}, {icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)}, {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e), favoriteItem(e),
shareItem(e),
!queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')}, !queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')},
!queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')}, !queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')},
!queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')}, !queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
@ -625,6 +672,7 @@
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)}, {icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)},
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)}, {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e), favoriteItem(e),
shareItem(e),
N.podcastLaterAdd && {icon: kept ? '✕' : '+', svg: kept ? 'listx' : null, label: kept ? 'Aus der Merkliste nehmen' : 'Auf die Merkliste', N.podcastLaterAdd && {icon: kept ? '✕' : '+', svg: kept ? 'listx' : null, label: kept ? 'Aus der Merkliste nehmen' : 'Auf die Merkliste',
hint: kept ? null : 'für später; wird geladen', action: () => toggleLater(e)}, hint: kept ? null : 'für später; wird geladen', action: () => toggleLater(e)},
!queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')}, !queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')},
@ -691,6 +739,7 @@
...dlSettingsItems(), ...dlSettingsItems(),
]), ]),
backgroundSyncItem(), backgroundSyncItem(),
{icon: '✎', label: 'Transkription', hint: 'Whisper-Instanz für Transkripte und Artikel, Modell, Sprache', action: transcriptionSettings},
{icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings}, {icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings},
startPageItem(), startPageItem(),
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [ group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
@ -2833,6 +2882,15 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
return true; return true;
}; };
// A transcript/article notice was tapped (TranscriptNotices.kt): the episode by id.
window.__dioraOpenEpisodeById = async function (id) {
const d = await loadDetails(id);
if (!d) return false;
return window.__dioraPodcastOpenEpisode(Object.assign({}, d, {
feed__id: d.feed && d.feed.id, feed__title: d.feed && d.feed.title, feed__artwork_url: d.feed && d.feed.artwork_url,
}));
};
window.__dioraPodcastOpenFeed = function (feedId) { window.__dioraPodcastOpenFeed = function (feedId) {
const f = feeds().find(x => x.id === Number(feedId)); const f = feeds().find(x => x.id === Number(feedId));
if (!f) return false; if (!f) return false;
@ -4328,6 +4386,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.')); box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.'));
return; return;
} }
const tr = trOf(id);
trFill(tr, d, !!fresh);
if (live && sheet) { if (live && sheet) {
const fav = sheet.querySelector('.nui-pod-fav'); const fav = sheet.querySelector('.nui-pod-fav');
if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); } if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); }
@ -4338,10 +4398,23 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
if (chapBox) chapterBox(chapBox, chapters, id); if (chapBox) chapterBox(chapBox, chapters, id);
const tabs = el('div', 'nui-pod-subtabs'); const tabs = el('div', 'nui-pod-subtabs');
const body = el('div', 'nui-pod-notesbody'); const body = el('div', 'nui-pod-notesbody');
// Jump to a second of the episode (taps on the article's "▶ ab 12:34" links).
const seek = s => {
if (live) N.podcastSeek(s * 1000);
else if (jump) jump(s);
else toast('Zum Anhören die Folge öffnen');
};
let current = '';
let sig = '';
const sigOf = () => tabList().map(x => x[0]).join() + '|' +
(current === 'transcript' ? (tr.has ? 'has' : trSig(id)) : current === 'article' ? trSig(id) + ':' + tr.article : '');
const show = which => { const show = which => {
current = which;
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which)); tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which));
body.textContent = ''; body.textContent = '';
if (which === 'chapters') { if (which === 'article') {
articlePanel(body, id, seek);
} else if (which === 'chapters') {
const ol = el('ol', 'nui-pod-chapters'); const ol = el('ol', 'nui-pod-chapters');
chapters.forEach((c, i) => { chapters.forEach((c, i) => {
const li = el('li', 'nui-pod-chapter'); const li = el('li', 'nui-pod-chapter');
@ -4372,6 +4445,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
} }
body.append(notes); body.append(notes);
} }
sig = sigOf();
}; };
const tab = (which, label) => { const tab = (which, label) => {
const b = el('button', 'nui-pod-subtab', label); const b = el('button', 'nui-pod-subtab', label);
@ -4380,11 +4454,33 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
b.addEventListener('click', () => show(which)); b.addEventListener('click', () => show(which));
tabs.append(b); tabs.append(b);
}; };
if (chapters.length && !chapBox) tab('chapters', `Kapitel (${chapters.length})`); // The Artikel tab comes first (when there is one); Transkript for every episode with sound
tab('notes', 'Shownotes'); // (not YouTube: the server never fetches its sound), with a button to make one when it is missing.
if (d.has_transcript) tab('transcript', 'Transkript'); const canTranscribe = !!d.audio_url && !isYouTube(d.audio_url);
function tabList() {
const list = [];
if (tr.article) list.push(['article', 'Artikel']);
if (chapters.length && !chapBox) list.push(['chapters', `Kapitel (${chapters.length})`]);
list.push(['notes', 'Shownotes']);
if (tr.has || canTranscribe) list.push(['transcript', 'Transkript']);
return list;
}
const draw = want => {
const list = tabList();
tabs.textContent = '';
list.forEach(([w, l]) => tab(w, l));
show(list.some(x => x[0] === want) ? want : list[0][0]);
};
// Called when the state of the episode's transcript / article changes.
box.trUpdate = select => {
if (select) draw(select);
else if (sigOf() !== sig) draw(current);
};
trBoxes.add(box);
box.append(tabs, body); box.append(tabs, body);
show(chapters.length && !chapBox ? 'chapters' : 'notes'); draw('');
if (epScreen && epScreen.e.id === id) episodeScreenParts();
trSchedule();
} }
// The chapters in the full player: which one plays, previous / next (like AntennaPod's // The chapters in the full player: which one plays, previous / next (like AntennaPod's
@ -4455,6 +4551,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while
async function showTranscript(body, id, live) { async function showTranscript(body, id, live) {
// None yet: the button to make one (or its progress).
if (!trOf(id).has) { transcriptPanel(body, id); return; }
body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …')); body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …'));
let t = transcripts.get(id); let t = transcripts.get(id);
if (!t) { if (!t) {
@ -4468,6 +4566,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
} }
} }
body.textContent = ''; body.textContent = '';
if (trOf(id).origin === 'whisper') body.append(el('div', 'nui-pod-meta nui-pod-tr-origin', 'automatisch transkribiert'));
const list = el('div', 'nui-pod-transcript'); const list = el('div', 'nui-pod-transcript');
for (const seg of t.segments || []) { for (const seg of t.segments || []) {
const p = el('p', 'nui-pod-seg'); const p = el('p', 'nui-pod-seg');
@ -4495,6 +4594,493 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
} }
} }
// ---- smart transcription (diora-web docs/transcription.md) ------------------------------
// The server transcribes (a job per episode, polled every 5 s while it is on screen); the article
// is written on this device with the user's own key (DioraPodcastArticle). One state per episode
// feeds the Transkript tab, the Artikel tab and the "✦ Artikel" button.
const trStates = new Map(); // episode id -> {job, writing, has, origin, article}
const trBoxes = new Set(); // the details boxes on screen (showDetails)
const trArticles = new Map(); // feed item id -> its item (the article's text)
let trTimer = null;
async function trFetch(method, url, body) {
const headers = {'Content-Type': 'application/json'};
if (typeof getCsrfToken === 'function') headers['X-CSRFToken'] = getCsrfToken();
const r = await fetch(url, {method, headers, cache: 'no-store', body: body === undefined ? undefined : JSON.stringify(body)});
let data = null;
try { data = await r.json(); } catch (e) {}
return {ok: r.ok, status: r.status, data};
}
function trFill(t, d, fresh) {
if (fresh || !t.has) t.has = !!d.has_transcript;
if (fresh || !t.origin) t.origin = d.transcript_origin || null;
if (fresh || t.article == null) t.article = d.article_item_id || null;
if ((fresh || !t.job) && 'transcription' in d) t.job = d.transcription || null;
}
function trOf(id) {
let t = trStates.get(id);
if (!t) {
t = {job: null, writing: null, has: false, origin: null, article: null, polling: false};
trStates.set(id, t);
const d = storedDetails(id);
if (d) trFill(t, d, true);
}
return t;
}
const trActive = j => !!j && (j.state === 'queued' || j.state === 'downloading' || j.state === 'transcribing');
// What is going on with the episode right now (a transcription on the server, or the article
// being written here), as the line the buttons show; null when nothing.
function trStatus(id) {
const t = trOf(id);
if (t.writing) return {text: t.writing.text, kind: 'writing'};
const j = t.job;
if (!trActive(j)) return null;
const text = j.state === 'queued' ? (j.error ? 'Wartet: ' + j.error : 'Wartet …')
: j.state === 'downloading' ? 'Lade Folge …'
: j.chunks_total ? `Transkribiere ${j.chunks_done || 0}/${j.chunks_total} …` : 'Transkribiere …';
return {text, kind: 'job'};
}
// Changes only when what a view shows changes.
function trSig(id) {
const t = trOf(id);
const st = trStatus(id);
return (st ? st.text : '') + '|' + (t.job && t.job.state === 'error' ? t.job.error || 'x' : '');
}
const trVisible = id => (epScreen && epScreen.e.id === id && !!notesSheet && notesSheet.isConnected) ||
[...trBoxes].some(b => b.isConnected && b.dataset.episode === String(id));
function trChanged(id, select) {
for (const b of [...trBoxes]) {
if (!b.isConnected) { trBoxes.delete(b); continue; }
if (b.dataset.episode === String(id) && b.trUpdate) b.trUpdate(select);
}
if (epScreen && epScreen.e.id === id) episodeScreenParts();
trSchedule();
}
// Poll the jobs on screen every five seconds; nothing runs when none is shown.
function trSchedule() {
const ids = [...trStates].filter(([id, t]) => trActive(t.job) && trVisible(id)).map(([id]) => id);
if (ids.length && !trTimer) {
trTimer = setInterval(trTick, 5000);
trTick();
} else if (!ids.length && trTimer) {
clearInterval(trTimer);
trTimer = null;
}
}
async function trTick() {
if (document.hidden) return;
for (const [id, t] of [...trStates]) {
if (!trActive(t.job) || !trVisible(id) || t.polling) continue;
t.polling = true;
try {
const r = await trFetch('GET', `/podcasts/episodes/${id}/transcribe/`);
if (r.ok && r.data) await trApply(id, r.data);
} catch (e) {} finally { t.polling = false; }
}
trSchedule();
}
async function trApply(id, job) {
const t = trOf(id);
if (!job || job.state === 'none') { t.job = null; trChanged(id); return; }
t.job = job;
if (job.state !== 'done') { trChanged(id); return; }
const want = !!job.want_article;
t.job = null;
t.has = true;
transcripts.delete(id);
await trFresh(id);
trChanged(id);
// diora is open: the article is written right away (when this device has a key; else it waits).
if (want && !t.article && !t.writing) writeArticle(id, {claim: true});
}
async function trFresh(id) {
const d = await loadDetails(id);
if (d) trFill(trOf(id), d, true);
return d;
}
async function startTranscription(id, article) {
const t = trOf(id);
if (!navigator.onLine) { toast('Das braucht eine Verbindung'); return; }
let r;
try { r = await trFetch('POST', `/podcasts/episodes/${id}/transcribe/`, {article: !!article}); } catch (e) { r = {ok: false, status: 0}; }
if (!r.ok) {
const err = r.data && r.data.error;
const msg = err === 'youtube' ? 'YouTube-Folgen lassen sich nicht transkribieren.'
: err === 'too large' ? 'Die Folge ist zu groß zum Transkribieren (über 800 MB).'
: r.status ? `Das Transkribieren ließ sich nicht starten (${r.status}).` : 'Keine Verbindung.';
t.job = {state: 'error', error: msg, want_article: !!article};
trChanged(id);
return;
}
const job = r.data || {};
if (job.state === 'done') {
t.job = null;
t.has = true;
await trFresh(id);
trChanged(id);
if (article) writeArticle(id);
return;
}
t.job = Object.assign({state: 'queued', want_article: !!article}, job);
if (article && window.DioraPodcastArticle) DioraPodcastArticle.watch(true);
trChanged(id);
}
async function cancelTranscription(id) {
const t = trOf(id);
try { await trFetch('DELETE', `/podcasts/episodes/${id}/transcribe/`); } catch (e) {}
t.job = null;
trChanged(id);
}
// The key first: without one the settings open (write() would wait for ever behind the sheet).
async function trKeyReady() {
const AI = window.DioraAI;
if (!AI || !AI.available()) { toast('Die KI-Funktionen gibt es hier nicht'); return false; }
let has = false;
try { has = await AI.hasKey(); } catch (e) {}
if (!has) {
toast('Für den Artikel braucht diora deinen KI-Schlüssel');
AI.settings();
}
return has;
}
async function articleAction(id) {
const st = trStatus(id);
if (st) { toast(st.text); return; }
if (!(await trKeyReady())) return;
if (trOf(id).has) writeArticle(id);
else startTranscription(id, true);
}
// Write the article here and save it. `claim`: a wish of the server (reserve it first, so that the
// phone and the desktop don't both write it) and no questions when there is no key.
async function writeArticle(id, opts) {
const claim = !!(opts && opts.claim);
const t = trOf(id);
if (t.writing) return;
const AI = window.DioraAI;
const PA = window.DioraPodcastArticle;
if (!AI || !AI.available() || !PA) { if (!claim) toast('Artikel schreiben geht hier nicht'); return; }
if (claim) {
let has = false;
try { has = await AI.hasKey(); } catch (e) {}
if (!has) return;
}
const ctrl = new AbortController();
t.writing = {text: 'Lade Transkript …', ctrl};
trChanged(id);
let claimed = false;
try {
if (claim) {
const c = await PA.claim(id);
if (!c.ok) { t.writing = null; trChanged(id); return; } // another device has it
claimed = true;
}
let last = 0;
const saved = await PA.write(id, {
signal: ctrl.signal,
onState: s => {
if (!t.writing) return;
t.writing.text = s.text;
const now = Date.now();
if (s.phase !== 'writing' || now - last > 1000) { last = now; trChanged(id); }
},
});
if (t.article) trArticles.delete(t.article);
t.article = saved.item_id;
t.writing = null;
await trFresh(id);
if (claim && PA.notifyDone) PA.notifyDone(saved.title, id); else toast('Artikel fertig');
trChanged(id, 'article');
} catch (e) {
t.writing = null;
if (claimed) { try { await PA.release(id); } catch (e2) {} }
trChanged(id);
if (ctrl.signal.aborted) toast('Abgebrochen');
else if (AI.showError) AI.showError('Artikel schreiben', e);
else toast((e && e.message) || 'Der Artikel ließ sich nicht schreiben');
}
}
async function removeArticle(id) {
const t = trOf(id);
const ok = typeof customConfirm === 'function'
? await customConfirm('Den Artikel zu dieser Folge entfernen? Er verschwindet auch aus „Podcast-Artikel“ in den Blogs.', {danger: true}) : true;
if (!ok) return;
let r;
try { r = await trFetch('DELETE', `/podcasts/episodes/${id}/article/`); } catch (e) { r = {ok: false}; }
if (!r.ok) { toast('Entfernen ging nicht'); return; }
if (t.article) trArticles.delete(t.article);
t.article = null;
await trFresh(id);
trChanged(id);
toast('Artikel entfernt');
}
// The Transkript tab of an episode without a transcript: start, progress, error.
function transcriptPanel(body, id) {
const t = trOf(id);
body.textContent = '';
const st = trStatus(id);
const wrap = el('div', 'nui-pod-tr');
if (st) {
wrap.append(el('div', 'nui-pod-tr-status', st.text));
if (st.kind === 'job') {
wrap.append(el('div', 'nui-pod-meta', t.job.want_article
? 'Das dauert je nach Länge Minuten bis Stunden. Danach schreibt diora den Artikel, wenn diora offen ist.'
: 'Das dauert je nach Länge Minuten bis Stunden. Du bekommst eine Benachrichtigung, wenn es fertig ist.'));
wrap.append(trButton('Abbrechen', () => cancelTranscription(id)));
} else {
wrap.append(trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
}
} else if (t.job && t.job.state === 'error') {
wrap.append(el('div', 'nui-pod-tr-error', t.job.error || 'Das Transkribieren ist fehlgeschlagen.'));
wrap.append(trButton('Erneut versuchen', () => startTranscription(id, !!t.job.want_article), true));
} else {
wrap.append(el('div', 'nui-pod-meta', 'Diese Folge hat kein Transkript. Der Server kann es erstellen; das dauert je nach Länge ' +
'Minuten bis Stunden, am Ende kommt eine Benachrichtigung.'));
wrap.append(trButton('Transkript erstellen', () => startTranscription(id, false), true));
}
body.append(wrap);
}
function trButton(label, run, primary) {
const b = el('button', 'btn nui-pod-trbtn' + (primary ? ' btn-primary' : ''), label);
b.type = 'button';
b.addEventListener('click', run);
return b;
}
// The Artikel tab: the article from Blogs (a feed item), its three actions, taps on "▶ ab 12:34" seek.
async function articlePanel(body, id, seek) {
const t = trOf(id);
const itemId = t.article;
body.textContent = '';
const bar = el('div', 'nui-pod-trbar');
const st = trStatus(id);
if (st && st.kind === 'writing') {
bar.append(el('span', 'nui-pod-tr-status', st.text), trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
} else {
if (window.__dioraFeedsOpenItem) {
bar.append(trButton('In Blogs öffnen', () => {
closeNotes();
closePlayer();
window.__dioraFeedsOpenItem(itemId);
}));
}
if (window.DioraAI && DioraAI.available() && !st) bar.append(trButton('Neu erstellen', () => rewriteArticle(id)));
bar.append(trButton('Entfernen', () => removeArticle(id)));
}
const text = el('div', 'nui-pod-notes nui-pod-article');
body.append(bar, text);
let item = trArticles.get(itemId);
if (!item) {
text.append(el('div', 'nui-pod-meta', 'Artikel wird geladen …'));
try {
item = (await getJson(`/feeds/items/${itemId}/`)).item;
if (item) trArticles.set(itemId, item);
} catch (e) {}
if (!text.isConnected || t.article !== itemId) return;
text.textContent = '';
}
if (!item) {
text.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Der Artikel ließ sich nicht laden.' : 'Der Artikel braucht eine Verbindung.'));
return;
}
// The server cleaned the HTML (feeds/fetch.py clean_html), as for every feed item.
if (item.content) text.innerHTML = item.content;
else text.append(el('p', null, item.summary || ''));
text.querySelectorAll('a[href]:not([href^="#t="])').forEach(a => a.setAttribute('target', '_blank'));
text.addEventListener('click', ev => {
const a = ev.target.closest('a[href^="#t="]');
if (!a) return;
ev.preventDefault();
const s = parseInt(a.getAttribute('href').slice(3), 10);
if (s >= 0) seek(s);
});
}
// "Neu erstellen" replaces the article: ask first.
async function rewriteArticle(id) {
const ok = typeof customConfirm === 'function'
? await customConfirm('Den Artikel neu schreiben? Der jetzige wird ersetzt.') : true;
if (ok) articleAction(id);
}
// The "✦ Artikel" button of the episode screen, or null where it isn't offered.
function trArticleButton(e) {
const id = e.id;
if (!(id > 0)) return null;
const t = trOf(id);
const d = storedDetails(id) || {};
const url = (e.raw && (e.raw.audio_url || e.raw.episode__audio_url)) || d.audio_url || '';
if (e.yt || !url || isYouTube(url)) return null;
const st = trStatus(id);
if (st) return {label: st.text, run: () => toast(st.text), cls: 'on'};
if (t.article) return {label: 'Artikel', run: () => { if (epScreen && epScreen.box && epScreen.box.trUpdate) epScreen.box.trUpdate('article'); }, cls: 'on'};
if (!(window.DioraAI && DioraAI.available())) return null;
return {label: t.has ? 'Artikel erstellen' : 'Transkribieren und Artikel erstellen', run: () => articleAction(id)};
}
// ---- settings of the transcription instance (GET/POST /podcasts/transcription/settings/) ----
const TR_LANGUAGES = [['', 'Automatisch'], ['de', 'Deutsch'], ['en', 'Englisch'], ['fr', 'Französisch'], ['es', 'Spanisch'],
['it', 'Italienisch'], ['nl', 'Niederländisch'], ['pt', 'Portugiesisch'], ['pl', 'Polnisch'], ['ru', 'Russisch'],
['tr', 'Türkisch'], ['uk', 'Ukrainisch'], ['ja', 'Japanisch'], ['zh', 'Chinesisch']];
let trSheet = null;
function closeTrSettings() {
if (!trSheet) return false;
trSheet.remove();
trSheet = null;
return true;
}
async function transcriptionSettings() {
closeTrSettings();
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-trset', 'Schließen', closeTrSettings);
trSheet = made;
const body = el('div', 'nui-pod-trform');
panel.append(top, el('div', 'nui-pod-player-title', 'Transkription'), body);
document.body.append(made);
body.append(el('div', 'nui-pod-meta', 'Einstellungen werden geladen …'));
let s;
try {
const r = await trFetch('GET', '/podcasts/transcription/settings/');
if (!r.ok) throw new Error(r.status);
s = r.data;
} catch (e) {
body.textContent = '';
body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Die Einstellungen ließen sich nicht laden.' : 'Die Einstellungen brauchen eine Verbindung.'));
return;
}
if (trSheet !== made) return;
drawTranscriptionSettings(body, s, made);
}
function drawTranscriptionSettings(body, s, made) {
body.textContent = '';
const field = (label, input) => {
const l = el('label', 'nui-pod-trfield');
l.append(el('span', null, label), input);
return l;
};
const text = (type, value, placeholder) => {
const i = el('input', 'search-input');
i.type = type;
i.value = value == null ? '' : value;
if (placeholder) i.placeholder = placeholder;
i.setAttribute('autocomplete', 'off');
i.setAttribute('autocapitalize', 'off');
i.spellcheck = false;
return i;
};
body.append(el('div', 'nui-pod-meta', 'Der Server schneidet die Folge in Stücke und schickt sie an eine Whisper-Instanz ' +
'(OpenAI-kompatibel). Das passiert nur, wenn du es in einer Folge auslöst.'));
let engine = s.engine === 'builtin' && s.builtin_available ? 'builtin' : 'custom';
const engines = el('div', 'nui-pod-subtabs');
const options = [s.builtin_available && ['builtin', 'Eingebaute Instanz'], ['custom', 'Eigene Instanz']].filter(Boolean);
const url = text('url', s.url, 'https://whisper.example.org/v1');
const token = text('password', '', s.has_token ? 'gespeichert (leer lassen = behalten)' : 'optional');
token.setAttribute('autocomplete', 'new-password');
const urlField = field('Adresse', url);
const tokenField = field('Token', token);
const sync = () => {
engines.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.engine === engine));
urlField.hidden = tokenField.hidden = engine !== 'custom';
};
for (const [value, label] of options) {
const b = el('button', 'nui-pod-subtab', label);
b.type = 'button';
b.dataset.engine = value;
b.addEventListener('click', () => { engine = value; sync(); });
engines.append(b);
}
body.append(engines, urlField, tokenField);
const model = text('text', s.model, 'z. B. large-v3-turbo');
const lang = el('select', 'search-input');
const langs = TR_LANGUAGES.some(l => l[0] === (s.language || '')) ? TR_LANGUAGES : TR_LANGUAGES.concat([[s.language, s.language]]);
for (const [v, l] of langs) {
const o = el('option', null, l);
o.value = v;
lang.append(o);
}
lang.value = s.language || '';
const par = text('number', s.parallel || 3);
par.min = '1';
par.max = '8';
par.inputMode = 'numeric';
body.append(field('Modell', model), field('Sprache', lang), field('Parallel (1 bis 8 Stücke gleichzeitig)', par));
const status = el('div', 'nui-pod-tr-status');
status.setAttribute('aria-live', 'polite');
const say = (msg, bad) => { status.textContent = msg; status.classList.toggle('nui-pod-tr-error', !!bad); };
// Save; the token is sent only when typed (empty keeps it) or removed on purpose.
const save = async removeToken => {
if (engine === 'custom' && !url.value.trim()) { say('Bitte eine Adresse eintragen.', true); return null; }
const post = {
engine, url: url.value.trim(), model: model.value.trim(), language: lang.value,
parallel: Math.max(1, Math.min(8, parseInt(par.value, 10) || 3)),
};
if (removeToken) post.token = '';
else if (token.value) post.token = token.value;
const r = await trFetch('POST', '/podcasts/transcription/settings/', post);
if (!r.ok) { say((r.data && r.data.error) || `Speichern ging nicht (${r.status}).`, true); return null; }
let next = r.data && 'engine' in r.data ? r.data : null;
if (!next) {
const g = await trFetch('GET', '/podcasts/transcription/settings/');
next = g.ok ? g.data : Object.assign({}, s, post, {has_token: !!(s.has_token && !removeToken) || !!post.token});
}
return next;
};
const run = fn => async () => {
try { await fn(); } catch (e) { say(navigator.onLine ? 'Das ging nicht.' : 'Keine Verbindung.', true); }
};
const buttons = el('div', 'nui-pod-trbar');
buttons.append(
trButton('Speichern', run(async () => {
const next = await save(false);
if (next && trSheet === made) { toast('Gespeichert'); drawTranscriptionSettings(body, next, made); }
}), true),
trButton('Verbindung testen', run(async () => {
say('Speichere und teste …');
const next = await save(false);
if (!next) return;
const r = await trFetch('POST', '/podcasts/transcription/test/', {});
if (trSheet !== made) return;
drawTranscriptionSettings(body, next, made);
const out = body.querySelector('.nui-pod-tr-status');
const ok = r.ok && r.data && r.data.ok;
out.textContent = ok ? `Verbindung steht${r.data.model ? ' (Modell ' + r.data.model + ')' : ''}.`
: 'Verbindung fehlgeschlagen: ' + ((r.data && r.data.error) || `Fehler ${r.status}`);
out.classList.toggle('nui-pod-tr-error', !ok);
})));
if (s.has_token) {
buttons.append(trButton('Token entfernen', run(async () => {
const next = await save(true);
if (next && trSheet === made) { toast('Token entfernt'); drawTranscriptionSettings(body, next, made); }
})));
}
body.append(buttons, status);
if (s.has_token) body.append(el('div', 'nui-pod-meta', 'Token: gespeichert'));
sync();
}
// ---- the episode screen: a tap on a row opens it ---------------------------------------- // ---- the episode screen: a tap on a row opens it ----------------------------------------
// Cover, title, podcast (→ its page), date, length, where it stands; one big button to play; // Cover, title, podcast (→ its page), date, length, where it stands; one big button to play;
// the actions with the swipes' names and icons; chapters (a tap plays from there) and notes. // the actions with the swipes' names and icons; chapters (a tap plays from there) and notes.
@ -4552,7 +5138,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
}).catch(() => {}); }).catch(() => {});
} }
document.body.append(notesSheet); document.body.append(notesSheet);
epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')}; epScreen = {e, ctx, actions, playBtn, box, facts: info.querySelector('.nui-pod-epfacts')};
episodeScreenParts(); episodeScreenParts();
showDetails(box, e.id, false, seconds => playAt(e, seconds)); showDetails(box, e.id, false, seconds => playAt(e, seconds));
} }
@ -4613,6 +5199,9 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
if (N.podcastDiscard) { if (N.podcastDiscard) {
act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger'); act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger');
} }
act(null, '⇪', 'Teilen', () => shareEpisode(e));
const art = trArticleButton(e);
if (art) act(null, '✦', art.label, art.run, art.cls);
const fav = e.raw.favorite; const fav = e.raw.favorite;
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }), act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
fav ? 'on' : ''); fav ? 'on' : '');
@ -5828,6 +6417,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
window.__dioraBack = function () { window.__dioraBack = function () {
if (closeStats()) return true; if (closeStats()) return true;
if (closeDownloads()) return true; if (closeDownloads()) return true;
if (closeTrSettings()) return true;
if (closeNotes()) return true; if (closeNotes()) return true;
if (closePlayer()) return true; if (closePlayer()) return true;
const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts'; const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 7f37d4e 2026-10-25: the podcast parts of web-native/static/native.css /* Copied from diora-android 6a0a362 2026-11-09: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ The web's own additions are in podcasts-web.css. */
@ -500,7 +500,9 @@ body.eink .nui-pod-chip.on strong,
body.eink .nui-pod-sleep.on { color: #000 !important; } body.eink .nui-pod-sleep.on { color: #000 !important; }
/* podcast chapters and shownotes */ /* podcast chapters and shownotes */
.nui-pod-notesbox { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; min-height: 40px; } /* flex: none – in the sheet's scrolling column the box would otherwise shrink to its 40 px and
the notes spill over what comes after (the similar episodes). */
.nui-pod-notesbox { flex: none; margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; min-height: 40px; }
.nui-pod-subtabs { display: flex; gap: 4px; margin-bottom: 6px; } .nui-pod-subtabs { display: flex; gap: 4px; margin-bottom: 6px; }
.nui-pod-subtab { .nui-pod-subtab {
min-height: 36px; min-height: 36px;
@ -548,6 +550,21 @@ body.eink .nui-pod-sleep.on { color: #000 !important; }
.nui-pod-epact-icon { font-size: 20px; line-height: 22px; } .nui-pod-epact-icon { font-size: 20px; line-height: 22px; }
.nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); } .nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); }
.nui-pod-epact-danger { color: var(--red, #e5484d); } .nui-pod-epact-danger { color: var(--red, #e5484d); }
/* Transcript and article of an episode (the Transkript / Artikel tabs, the transcription settings). */
.nui-pod-tr { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 6px 2px; }
.nui-pod-tr-status { font-size: 14.5px; font-weight: 600; }
.nui-pod-tr-error { font-size: 14px; color: var(--red, #e5484d); }
.nui-pod-tr-origin { margin-bottom: 6px; font-style: italic; }
.nui-pod-trbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; }
.nui-pod-trbtn { min-height: 40px; }
.nui-pod-article h2 { font-size: 17px; margin: 1.2em 0 0.2em; }
.nui-pod-article p { margin: 0.5em 0; }
.nui-pod-article blockquote { margin: 0.6em 0; padding-left: 12px; border-left: 3px solid var(--border); }
.nui-pod-trform { display: flex; flex-direction: column; gap: 10px; }
.nui-pod-trfield { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--nui-muted); }
.nui-pod-trfield input, .nui-pod-trfield select { width: 100%; box-sizing: border-box; font-size: 15px; color: inherit; }
.nui-pod-trfield[hidden] { display: none; }
body.eink .nui-pod-tr-error { color: #000; }
body.eink .nui-pod-epact { border-color: #000; color: #000; } body.eink .nui-pod-epact { border-color: #000; color: #000; }
body.eink .nui-pod-epact-danger { color: #000; } body.eink .nui-pod-epact-danger { color: #000; }
body.eink .nui-pod-chapter.current, body.eink .nui-pod-chapter.current,

View file

@ -28,6 +28,12 @@
{% endif %} {% endif %}
<button type="submit" class="btn btn-primary btn-full">Anmelden</button> <button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form> </form>
{% if oidc_enabled %}
<div class="auth-alt">
<div class="auth-or"><span>oder</span></div>
<a class="btn btn-full" href="{% url 'oidc_start' %}{% if request.GET.next %}?next={{ request.GET.next|urlencode }}{% endif %}">Mit {{ oidc_provider }} anmelden</a>
</div>
{% endif %}
<p class="auth-switch"> <p class="auth-switch">
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a> Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
</p> </p>

View file

@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden — diora</title>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
font: 15px/1.5 system-ui, sans-serif; background: #111; color: #ccc; }
@media (prefers-color-scheme: light) { body { background: #fafafa; color: #444; } }
</style>
</head>
<body>
<p id="status">Bücher werden entsperrt …</p>
{{ config|json_script:"oidc-key-config" }}
<script>
// The book key with Authelia's help (accounts/oidc_views.py oidc_key, docs/accounts.md).
// Every way out ends on `next`; whatever fails here, the app asks for the key itself
// (static/js/app/33-unlock.js).
(async function () {
const C = JSON.parse(document.getElementById('oidc-key-config').textContent);
const SS = 'diora_oidc_key_flow';
const KEY_NAME = 'diora_enc_key_' + C.user_id;
const status = document.getElementById('status');
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
const unb64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
const b64url = (buf) => b64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const hexToBytes = (h) => Uint8Array.from(h.match(/../g) || [], x => parseInt(x, 16));
const csrf = () => (document.cookie.split('; ').find(r => r.startsWith('csrftoken=')) || '').split('=')[1] || '';
const done = (to) => location.replace(to || C.next || '/');
async function wrapKey(secret, sub) {
const hk = await crypto.subtle.importKey('raw', unb64(secret), 'HKDF', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode(sub), info: new TextEncoder().encode('diora authelia key v1')},
hk, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
}
// Is this the key of the books? Tried on one book's metadata (none: nothing to check).
async function fits(raw) {
try {
const books = await (await fetch('/books/', {credentials: 'same-origin'})).json();
const b = Array.isArray(books) ? books.find(x => x && x.meta_ct && x.meta_iv) : null;
if (!b) return true;
const k = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['decrypt']);
await crypto.subtle.decrypt({name: 'AES-GCM', iv: hexToBytes(b.meta_iv)}, k, unb64(b.meta_ct));
return true;
} catch (e) { return false; }
}
const params = new URLSearchParams(location.search);
const isCallback = location.pathname.endsWith('/callback/');
if (!isCallback) {
// Also a device that has its key goes once round: it leaves the key (again) wrapped with
// the current claim, so a changed claim at Authelia heals with the next login.
const flow = {
state: b64url(crypto.getRandomValues(new Uint8Array(24))),
verifier: b64url(crypto.getRandomValues(new Uint8Array(48))),
next: C.next,
};
sessionStorage.setItem(SS, JSON.stringify(flow));
const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(flow.verifier)));
const q = new URLSearchParams({
response_type: 'code', client_id: C.client_id, redirect_uri: C.redirect_uri,
scope: 'openid profile diora_key', state: flow.state, prompt: 'none',
code_challenge: challenge, code_challenge_method: 'S256',
});
location.replace(C.authorize + '?' + q);
return;
}
let flow = null;
try { flow = JSON.parse(sessionStorage.getItem(SS) || 'null'); } catch (e) {}
sessionStorage.removeItem(SS);
const next = flow && flow.next;
try {
if (!flow || params.get('state') !== flow.state || !params.get('code')) return done(next);
const tok = await (await fetch(C.token, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
grant_type: 'authorization_code', code: params.get('code'), redirect_uri: C.redirect_uri,
client_id: C.client_id, code_verifier: flow.verifier,
}),
})).json();
if (!tok.access_token) return done(next);
const info = await (await fetch(C.userinfo, {headers: {Authorization: 'Bearer ' + tok.access_token}})).json();
// Only the Authelia account linked to this diora account counts.
if (!info.sub || !C.subs.includes(info.sub) || !info.diora_key) return done(next);
const wrap = await wrapKey(info.diora_key, info.sub);
let have = null;
try { have = localStorage.getItem(KEY_NAME); } catch (e) {}
if (have) {
// This device has the key: leave it with the server, wrapped — only the right one (an
// old browser may hold a stray key of its own).
if (!(await fits(unb64(have)))) return done(next);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrap, unb64(have));
await fetch('/accounts/vault/authelia-key/', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: JSON.stringify({ct: b64(ct), iv: b64(iv)}),
});
return done(next);
}
const v = await (await fetch('/accounts/vault/authelia-key/', {credentials: 'same-origin'})).json();
if (!v.ct) return done(next);
const raw = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: unb64(v.iv)}, wrap, unb64(v.ct)));
if (raw.length === 32 && await fits(raw)) localStorage.setItem(KEY_NAME, b64(raw));
} catch (e) {
status.textContent = 'Entsperren über Authelia ging nicht – du kannst es gleich mit Code oder Passwort tun.';
await new Promise(r => setTimeout(r, 1500));
}
done(next);
})();
</script>
</body>
</html>

View file

@ -94,6 +94,25 @@
</form> </form>
</details> </details>
{% if oidc_enabled or oidc_identities %}
<div class="oidc-links" style="margin-top:1rem;">
<h3 style="font-size:1rem; margin:0 0 0.4rem;">Anmelden über {{ oidc_provider }}</h3>
{% for ident in oidc_identities %}
<form method="post" action="{% url 'oidc_unlink' ident.pk %}" class="inline-form" style="display:flex; gap:0.6rem; align-items:center; margin-bottom:0.4rem;">
{% csrf_token %}
<span>Verknüpft mit <strong>{{ ident.username|default:ident.sub }}</strong>{% if ident.email %} ({{ ident.email }}){% endif %}</span>
<button type="submit" class="btn">Lösen</button>
</form>
{% empty %}
<p class="muted" style="margin:0 0 0.4rem;">Verknüpfe dein Konto, dann kannst du dich auch über {{ oidc_provider }} anmelden. Die Anmeldung mit Passwort bleibt.</p>
{% endfor %}
{% if oidc_enabled and not oidc_identities %}
<a class="btn" href="{% url 'oidc_start' %}?link=1">Mit {{ oidc_provider }} verknüpfen</a>
{% endif %}
<p class="muted" style="font-size:0.85rem; margin-top:0.4rem;">Deine Bücher bleiben mit deinem diora-Passwort verschlüsselt. Ein Gerät, das du über {{ oidc_provider }} anmeldest, bekommt den Schlüssel von einem schon entsperrten Gerät, oder du gibst dort einmal dein Passwort ein.</p>
</div>
{% endif %}
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;"> <form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn">Abmelden</button> <button type="submit" class="btn">Abmelden</button>
@ -268,10 +287,8 @@ async function _getOrCreateEncKey() {
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']); return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
} catch (e) {} } catch (e) {}
} }
const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); // Never a new key here: it would lock the existing books away (static/js/app/33-unlock.js).
const raw = await crypto.subtle.exportKey('raw', key); throw new Error('Bücher sind auf diesem Gerät noch nicht entsperrt – öffne zuerst die Bibliothek');
localStorage.setItem(storageKey, _bytesToBase64(raw));
return key;
} }
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) { document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {

View file

@ -371,6 +371,7 @@
<script src="/static/js/app/29-m3u.js"></script> <script src="/static/js/app/29-m3u.js"></script>
<script src="/static/js/app/30-contrast.js"></script> <script src="/static/js/app/30-contrast.js"></script>
<script src="/static/js/app/32-crypto.js"></script> <script src="/static/js/app/32-crypto.js"></script>
<script src="/static/js/app/33-unlock.js"></script>
<script src="/static/js/app/34-wallpaper.js"></script> <script src="/static/js/app/34-wallpaper.js"></script>
<script src="/static/js/app/40-epub.js"></script> <script src="/static/js/app/40-epub.js"></script>
<script src="/static/js/app/44-books.js"></script> <script src="/static/js/app/44-books.js"></script>
@ -386,7 +387,9 @@
<script src="/static/js/app/58-pdf.js"></script> <script src="/static/js/app/58-pdf.js"></script>
<script src="/static/js/app/60-tts.js"></script> <script src="/static/js/app/60-tts.js"></script>
<script src="/static/js/app/62-reader.js"></script> <script src="/static/js/app/62-reader.js"></script>
<script src="/static/js/app/63-pages.js"></script>
<script src="/static/js/app/64-reader-settings.js"></script> <script src="/static/js/app/64-reader-settings.js"></script>
<script src="/static/js/app/65-article.js"></script>
<script src="/static/js/app/66-pdf-paginated.js"></script> <script src="/static/js/app/66-pdf-paginated.js"></script>
<script src="/static/js/app/68-bookmarks.js"></script> <script src="/static/js/app/68-bookmarks.js"></script>
<script src="/static/js/app/70-search.js"></script> <script src="/static/js/app/70-search.js"></script>
@ -397,7 +400,8 @@
<script src="/static/js/app/80-radio-sidebar.js"></script> <script src="/static/js/app/80-radio-sidebar.js"></script>
<script src="/static/js/app/99-init.js"></script> <script src="/static/js/app/99-init.js"></script>
{% if user.is_authenticated %}{# The KI features (docs/ai.md): the user's own key, calls from the device. #} {% if user.is_authenticated %}{# The KI features (docs/ai.md): the user's own key, calls from the device. #}
<script type="module" src="/static/js/ai.js"></script>{% endif %} <script type="module" src="/static/js/ai.js"></script>
<script type="module" src="/static/js/podcast-article.js"></script>{% endif %}
<script src="/static/js/podcasts-web.js"></script> <script src="/static/js/podcasts-web.js"></script>
<script src="/static/js/home.js"></script> <script src="/static/js/home.js"></script>
<script src="/static/js/feeds.js"></script> <script src="/static/js/feeds.js"></script>

View file

@ -0,0 +1,172 @@
// Tests of static/js/podcast-article.js under Node: node --test tools/tests/
// No network, no LLM: fetch and DioraAI are stubbed.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as pa from '../../static/js/podcast-article.js';
test('transcriptToText groups 30 seconds and splits on speaker change', () => {
const segs = [
{start: 0, end: 5, speaker: '', text: 'Hallo zusammen.'},
{start: 20, end: 29, speaker: '', text: 'Heute geht es um X.'},
{start: 31, end: 40, speaker: '', text: 'Zweite Gruppe.'},
{start: 3725, end: 3730, speaker: '', text: 'Spät.'},
{start: 3726, end: 3730, speaker: '', text: ' '},
];
assert.equal(pa.transcriptToText(segs),
'[00:00] Hallo zusammen. Heute geht es um X.\n[00:31] Zweite Gruppe.\n[62:05] Spät.');
const sp = [
{start: 0, speaker: 'Anna', text: 'Eins.'},
{start: 4, speaker: 'Anna', text: 'Zwei.'},
{start: 8, speaker: 'Bernd', text: 'Drei.'},
];
assert.equal(pa.transcriptToText(sp), '[00:00] Anna: Eins. Zwei.\n[00:08] Bernd: Drei.');
assert.equal(pa.transcriptToText([]), '');
});
test('clock and mmss', () => {
assert.equal(pa.mmss(754), '12:34');
assert.equal(pa.clock(754), '12:34');
assert.equal(pa.clock(3725), '1:02:05');
});
test('token estimate and the limit message', () => {
assert.equal(pa.estimateTokens('a'.repeat(400)), 100);
const big = {segments: Array.from({length: 20000}, (_, i) => ({start: i * 31, speaker: '', text: 'x'.repeat(30)}))};
assert.throws(() => pa.buildPrompt({title: 'T'}, big), e => e.diora && /zu lang/.test(e.message));
const ok = pa.buildPrompt({title: 'T', feed: {title: 'P'}, description: '<p>Notes &amp; mehr</p>', duration_seconds: 3725,
chapters: [{start: 60, title: 'Kap 1'}]}, {segments: [{start: 0, text: 'Hallo'}]});
assert.match(ok, /Podcast: P\nFolge: T\nDauer: 1:02:05\nShownotes:\nNotes & mehr\nKapitel:\n\[01:00\] Kap 1\n\nTranskript:\n\[00:00\] Hallo$/);
assert.throws(() => pa.buildPrompt({title: 'T'}, {segments: []}), /leer/);
});
test('system prompt is the one of the contract', async () => {
const fs = await import('node:fs');
const doc = fs.readFileSync(new URL('../../docs/transcription.md', import.meta.url), 'utf8').split('\r\n').join('\n');
assert.ok(doc.includes(pa.SYSTEM));
assert.deepEqual(pa.SCHEMA.required, ['title', 'summary', 'kind', 'speakers', 'sections']);
});
test('markdownToHtml: paragraphs, emphasis, lists, quotes, escaping', () => {
assert.equal(pa.markdownToHtml('Eins *kursiv* und **fett**.\nWeiter.\n\nZwei.'),
'<p>Eins <em>kursiv</em> und <strong>fett</strong>. Weiter.</p>\n<p>Zwei.</p>');
assert.equal(pa.markdownToHtml('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
assert.equal(pa.markdownToHtml('1. a\n2. b'), '<ol><li>a</li><li>b</li></ol>');
assert.equal(pa.markdownToHtml('> „Zitat“\n> mehr'), '<blockquote><p>„Zitat“ mehr</p></blockquote>');
const evil = pa.markdownToHtml('<script>alert(1)</script> **<img src=x onerror=y>** [l](javascript:x) "q"');
assert.ok(!/<script|<img/.test(evil));
assert.ok(evil.includes('&lt;script&gt;') && evil.includes('<strong>&lt;img'));
assert.equal(pa.markdownToHtml('2 * 3 * 4'), '<p>2 * 3 * 4</p>');
});
test('articleHtml: summary, heading, time link, body', () => {
const h = pa.articleHtml({summary: 'Kurz & gut.', sections: [
{heading: 'Erste <b>Idee</b>', start: 754.9, body_markdown: 'Text.'}]});
assert.equal(h, '<p>Kurz &amp; gut.</p>\n<h2>Erste &lt;b&gt;Idee&lt;/b&gt;</h2>\n<p><a href="#t=754">▶ ab 12:34</a></p>\n<p>Text.</p>');
});
// ---- stubs ---------------------------------------------------------------------------------
function stubWorld({pending, claims = {}, hasKey = true, askImpl, saveStatus = 200}) {
const calls = [];
const notices = [];
globalThis.DioraNative = undefined;
globalThis.DioraAI = {
available: () => true,
hasKey: async () => hasKey,
withKey: fn => fn(),
modelName: () => 'test-model',
toast: t => notices.push(t),
ask: askImpl || (async opts => {
opts.onProgress && opts.onProgress(1200);
return {title: 'Titel', summary: 'Sum.', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]};
}),
};
globalThis.fetch = async (url, init = {}) => {
const method = init.method || 'GET';
calls.push(method + ' ' + url + (init.body ? ' ' + init.body : ''));
const json = (status, data) => ({status, ok: status < 400, json: async () => data});
if (url === '/podcasts/transcription/pending/') return json(200, pending);
let m = /^\/podcasts\/episodes\/(\d+)\/article\/claim\/$/.exec(url);
if (m) return method === 'POST' ? json(claims[m[1]] || 200, {}) : json(200, {});
m = /^\/podcasts\/episodes\/(\d+)\/transcript\/$/.exec(url);
if (m) return m[1] === '3' ? json(404, {error: 'no transcript'}) : json(200, {segments: [{start: 0, speaker: '', text: 'Hallo'}]});
m = /^\/podcasts\/episodes\/(\d+)\/article\/$/.exec(url);
if (m) return json(saveStatus, {item_id: 900 + Number(m[1])});
m = /^\/podcasts\/episodes\/(\d+)\/$/.exec(url);
if (m) return json(200, {id: Number(m[1]), title: 'Folge ' + m[1], description: '', chapters: [], feed: {title: 'P'}});
return json(404, {});
};
return {calls, notices};
}
test('write: loads, asks with the contract options, saves the HTML', async () => {
const w = stubWorld({pending: []});
let opts;
globalThis.DioraAI.ask = async o => { opts = o; o.onProgress(5); return {title: 'T', summary: 'S', kind: 'Monolog', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]}; };
const states = [];
const r = await pa.write(7, {onState: s => states.push(s.phase)});
assert.deepEqual(r, {item_id: 907, title: 'T'});
assert.equal(opts.effort, 'medium');
assert.equal(opts.maxTokens, 32000);
assert.equal(opts.system, pa.SYSTEM);
assert.match(opts.prompt, /Transkript:\n\[00:00\] Hallo$/);
assert.deepEqual(states, ['loading', 'writing', 'saving']);
const post = w.calls.find(c => c.startsWith('POST /podcasts/episodes/7/article/ '));
const body = JSON.parse(post.slice(post.indexOf('{')));
assert.equal(body.model, 'test-model');
assert.equal(body.kind, 'Monolog');
assert.match(body.body_html, /<a href="#t=61">▶ ab 01:01<\/a>/);
});
test('write: no transcript gives a German message', async () => {
stubWorld({pending: []});
await assert.rejects(pa.write(3), /noch kein Transkript/);
});
test('pickUp: claim, 409 skipped, failure releases the claim, notifier per success', async () => {
pa._reset();
const w = stubWorld({
pending: [{episode_id: 1, title: 'Eins'}, {episode_id: 2, title: 'Zwei'}, {episode_id: 4, title: 'Vier'}],
claims: {2: 409},
askImpl: async o => {
if (o.prompt.includes('Folge 4')) throw new Error('kaputt');
return {title: 'A', summary: 'S', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 0, body_markdown: 'B'}]};
},
});
const r = await pa.pickUp();
assert.deepEqual(r, {done: 1, failed: 1, skipped: 1});
assert.ok(w.calls.some(c => /^POST \/podcasts\/episodes\/2\/article\/claim\/ \{"device":"d-/.test(c)));
assert.ok(!w.calls.some(c => c.startsWith('GET /podcasts/episodes/2/'))); // 409: not written
assert.ok(w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/4/article/claim/'))); // failed: released
assert.ok(!w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/1/article/claim/')));
assert.deepEqual(w.notices, ['Artikel fertig: A']);
// The failed one is not tried again in this visit.
const w2 = stubWorld({pending: [{episode_id: 4, title: 'Vier'}]});
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 1});
assert.ok(!w2.calls.some(c => c.includes('claim')));
});
test('pickUp: android bridge notifies instead of the toast', async () => {
pa._reset();
const w = stubWorld({pending: [{episode_id: 1, title: 'Eins'}]});
const got = [];
globalThis.DioraNative = {notify: (...a) => got.push(a)};
await pa.pickUp();
assert.deepEqual(got, [['Artikel fertig', 'Titel', 'episode:1']]);
assert.deepEqual(w.notices, []);
globalThis.DioraNative = undefined;
});
test('pickUp: no key does nothing, not even a request; no second run at the same time', async () => {
pa._reset();
const w = stubWorld({pending: [{episode_id: 1, title: 'x'}], hasKey: false});
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 0});
assert.deepEqual(w.calls, []);
const w2 = stubWorld({pending: [{episode_id: 1, title: 'x'}]});
const a = pa.pickUp();
const b = await pa.pickUp();
assert.deepEqual(b, {busy: true});
assert.equal((await a).done, 1);
assert.equal(w2.calls.filter(c => c.startsWith('POST') && c.includes('/claim/ {"device":')).length, 1);
});

126
tui/README.md Normal file
View file

@ -0,0 +1,126 @@
# diora-tui
EPUB-Reader im Terminal — die erste Stufe einer TUI-Version von diora.
Zeigt ein Buch als **eine durchgehende Ansicht** über alle Kapitel hinweg (wie diora's
Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe
`"blockIndex:innerFraction"`-Positionsangabe geführt wie der Web-Reader (`books/models.py`,
`EBookProgress`) — `blockIndex` zählt dabei exakt wie `static/js/app.js`s
`EPUB_BLOCK_SELECTOR` (`p, h1-h6, li, blockquote, dt, dd, figcaption` + textbasierte
`div`s ohne Element-Kinder), fortlaufend über das ganze Buch. Dadurch ist die Position
zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in
beide Richtungen synchronisieren (siehe unten).
Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben
("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via
`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene
Position zurücksetzt.
## Setup
```bash
cd tui
pip install -e .
```
## Nutzung
```bash
diora-tui --library ~/Books # Standard: ~/Books
```
Tastenkürzel:
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen
- `n` — nächstes Kapitel, `p` — vorheriges Kapitel
- `f` — Fußnote in der Nähe der aktuellen Position anzeigen (Peek-Overlay, `Escape`/`f`/`q`
zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s
`_looksLikeFootnoteLink` (Link in/um `<sup>`, Klassenname mit note/footnote/fn, oder
`epub:type="noteref"`)
- `Enter` — markiertes Buch aus der Bibliothek öffnen
- `r` (in der Bibliothek) — gelesene Bücher ein-/ausblenden (siehe unten)
- `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek)
Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
volle Terminalbreite bei breiten Fenstern).
Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit,
sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht.
Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand
des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher
standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json`
gespiegelt) — `r` zeigt sie wieder an, für diese Sitzung.
## Bücher + Fortschritt vom Server holen (`diora-tui sync`)
Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe
unten), synct `diora-tui` **automatisch** — einmal leise im Hintergrund beim Start (neue
Bücher + Fortschritt werden nachgeladen, die Bibliotheksliste aktualisiert sich von
selbst) und einmal beim Beenden über `q` (kurzer Moment Verzögerung, bevor die App
tatsächlich schließt). Der explizite Befehl ist für's Ersteinrichten und für
Nicht-interaktive Nutzung (Cron o.ä.):
```bash
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage
diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern
```
Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books/<id>/data/` herunter,
entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als
normale `.epub`-Dateien in `--library` ab (Dateiname `<id> - <Titel>.epub`) — von da an
funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden
beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot
wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch).
Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am `<id> - `-Dateinamens-
Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server
(`POST /books/<id>/progress/`, `force: false` — überschreibt also nie eine weiter
gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein
lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff.
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter
`~/.config/diora-tui/config.json`, `chmod 600`):
- **Server-URL** — z.B. `https://diora.creamfresh.xyz`.
- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token".
- **Verschlüsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig
verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege:
1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's
"Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000
Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`).
Funktioniert nur, wenn der Account diesen Weg im Browser mindestens einmal benutzt
hat — sonst wurde der Key ursprünglich zufällig im Browser erzeugt, und diese
Ableitung trifft ihn nicht. `sync` meldet einen falschen Key als
Entschlüsselungsfehler (harmlos, kein Datenverlust), nie als falsches Ergebnis.
2. **Base64-Key direkt einfügen** — für den Fall, dass Weg 1 nicht passt. Der
Export-Button dafür ist im Browser-UI aktuell nicht verdrahtet (`exportEncKey()` in
`app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, in
der Browser-Devtools-Konsole auf der diora-Seite (nicht `/accounts/settings/` — die
lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins
Clipboard, von dort ins `sync`-Prompt einfügen. Schlägt das mit einem
`NotAllowedError`/`InvalidAccessError` fehl, stattdessen direkt aus `localStorage`
lesen: `localStorage.getItem('diora_enc_key_' + window.USER_ID)`.
Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der
Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei.
## Performance bei großen Büchern
Sehr große Bücher (mehrstellige Tausend Absätze — z.B. Sammelbände) können beim
*ersten* Öffnen mehrere Sekunden bis niedrige zweistellige Sekunden brauchen (Parsing +
Zeilenumbruch-Berechnung für die durchgehende Ansicht). Ein zweites Öffnen desselben
Buchs bei gleicher Terminalbreite ist dank Cache (`~/.cache/diora-tui/layout_cache/`)
deutlich schneller. Das Rendering selbst skaliert nicht mit der Buchgröße — nur die
tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch
auf einmal.
## Grenzen der aktuellen Version
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen,
wird gemeldet), da der Reader sie ohnehin nicht darstellen kann.
- Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt.
- `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung
(zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung
zählt primär `blockIndex`, der exakt mit dem Web-Reader übereinstimmt; `innerFraction`
ist nur ein Tiebreaker innerhalb desselben Blocks.

View file

@ -0,0 +1 @@
"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients."""

View file

@ -0,0 +1,4 @@
from diora_tui.app import main
if __name__ == "__main__":
main()

48
tui/diora_tui/api.py Normal file
View file

@ -0,0 +1,48 @@
"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for
local clients"). Status: that API is provisional — expect endpoint/field
changes as the server-side implementation settles.
"""
from __future__ import annotations
import requests
class ApiError(Exception):
pass
def _headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def fetch_sync_snapshot(server_url: str, token: str) -> dict:
resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30)
if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
resp.raise_for_status()
return resp.json()
def fetch_book_data(server_url: str, token: str, book_id: int) -> dict:
resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=120)
if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
if resp.status_code == 404:
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
resp.raise_for_status()
return resp.json()
def post_progress(
server_url: str, token: str, book_id: int, *, scroll_fraction: float, position_anchor: str, force: bool = False
) -> None:
body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force}
resp = requests.post(
f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30
)
if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
if resp.status_code == 404:
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
resp.raise_for_status()

231
tui/diora_tui/app.py Normal file
View file

@ -0,0 +1,231 @@
"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress
against a diora server (see project README)."""
from __future__ import annotations
import argparse
import asyncio
import getpass
from pathlib import Path
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView
from . import config as config_mod
from . import crypto, epub, library_meta, progress, remote
from .reader_screen import ReaderScreen
from .statusbar import StatusBar
DEFAULT_LIBRARY = Path.home() / "Books"
class LibraryScreen(Screen):
BINDINGS = [
Binding("enter", "open_selected", "Öffnen"),
Binding("r", "toggle_read_filter", "Gelesene ein-/ausblenden"),
Binding("q", "quit", "Beenden"),
]
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
self.paths: list[Path] = []
self.visible_paths: list[Path] = []
self.show_read = False
def compose(self) -> ComposeResult:
yield Header()
yield ListView(id="library-list")
yield StatusBar()
yield Footer()
def on_mount(self) -> None:
self.sub_title = str(self.library_dir)
self._refresh_list()
cfg = config_mod.load()
if cfg is not None:
self._auto_sync(cfg)
@work(thread=True)
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
try:
result = remote.sync_library(self.library_dir, cfg)
except remote.SyncError:
return # best-effort — e.g. offline; the local library still works
self.app.call_from_thread(self._on_auto_sync_done, result)
def _on_auto_sync_done(self, result: remote.SyncResult) -> None:
if result.downloaded or result.progress_pulled:
self._refresh_list()
if result.downloaded:
self.notify(f"{len(result.downloaded)} neue(s) Buch/Bücher synchronisiert.", timeout=3)
def _refresh_list(self) -> None:
self.paths = epub.scan_library(self.library_dir)
entries = []
for path in self.paths:
book_id = epub._book_id(path)
read = library_meta.is_read(book_id)
if read and not self.show_read:
continue
saved = progress.load(book_id)
last_opened = saved.updated_at if saved else 0.0
entries.append((last_opened, path, read))
entries.sort(key=lambda e: e[0], reverse=True)
self.visible_paths = [path for _, path, _ in entries]
list_view = self.query_one("#library-list", ListView)
list_view.clear()
if not self.paths:
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
return
if not entries:
list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen")))
return
for _, path, read in entries:
label = f"✓ {path.stem}" if read else path.stem
list_view.append(ListItem(Label(label)))
list_view.index = 0
list_view.focus()
def action_toggle_read_filter(self) -> None:
self.show_read = not self.show_read
self._refresh_list()
state = "eingeblendet" if self.show_read else "ausgeblendet"
self.notify(f"Gelesene Bücher {state}.", timeout=2)
def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView)
if not self.visible_paths or list_view.index is None:
return
self.app.push_screen(ReaderScreen(self.visible_paths[list_view.index]))
def on_list_view_selected(self, event: ListView.Selected) -> None:
self.action_open_selected()
class DioraTuiApp(App):
CSS_PATH = "app.tcss"
TITLE = "diora-tui"
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
def on_mount(self) -> None:
self.push_screen(LibraryScreen(self.library_dir))
async def action_quit(self) -> None:
cfg = config_mod.load()
if cfg is not None:
try:
await asyncio.to_thread(remote.sync_library, self.library_dir, cfg)
except remote.SyncError:
pass # best-effort — don't block quitting on a sync failure
self.exit()
def _prompt(label: str, *, secret: bool = False) -> str:
value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip()
if not value:
raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.")
return value
def _ask_yes_no(question: str) -> bool:
return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja")
def _prompt_enc_key() -> str:
print()
print("Verschlüsselungs-Key — zwei Wege:")
print(" [1] Aus Benutzername + Passwort ableiten (wie diora's 'Unlock'-Formular im Browser)")
print(" [2] Base64-Key direkt einfügen (z.B. per Browser-Konsole exportiert)")
choice = input("Wahl [1/2, Standard 1]: ").strip() or "1"
if choice == "2":
return _prompt("Verschlüsselungs-Key (Base64)", secret=True)
print(
"Hinweis: das liefert nur dann den richtigen Key, wenn dieser Account je über "
"diora's Passwort-Ableitung ('Unlock with password' im Browser) entsperrt wurde. "
"War der Key dort nur automatisch zufällig erzeugt, kommt hier ein anderer "
"(falscher) Key raus — 'sync' meldet das dann als Entschlüsselungsfehler, ohne "
"etwas kaputtzumachen; in dem Fall stattdessen [2] mit dem exportierten Key nutzen."
)
username = _prompt("diora-Benutzername")
password = _prompt("diora-Passwort", secret=True)
return crypto.derive_key_b64(username, password)
def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig:
cfg = None if server_override else config_mod.load()
if cfg is not None:
return cfg
print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.")
server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/")
token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True)
enc_key = _prompt_enc_key()
cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=enc_key)
if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"):
config_mod.save(cfg)
print("Gespeichert.")
return cfg
def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None:
cfg = _resolve_remote_config(server_override, save=save)
print(f"Verbinde zu {cfg.server_url} …")
try:
result = remote.sync_library(library_dir, cfg)
except remote.SyncError as e:
raise SystemExit(f"Fehler: {e}")
print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.")
for name in result.downloaded:
print(f" + {name}")
if result.unchanged:
print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.")
if result.skipped_pdf:
titles = ", ".join(result.skipped_pdf)
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}")
if result.failed:
titles = ", ".join(result.failed)
print(f"{len(result.failed)} Buch/Bücher konnten nicht entschlüsselt werden (falscher Key?): {titles}")
if result.progress_pulled:
print(f"Lesefortschritt für {result.progress_pulled} Buch/Bücher vom Server übernommen.")
def main() -> None:
parser = argparse.ArgumentParser(
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
)
parser.add_argument(
"--library",
type=Path,
default=DEFAULT_LIBRARY,
help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})",
)
subparsers = parser.add_subparsers(dest="command")
sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln")
sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf")
sync_parser.add_argument(
"--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen"
)
args = parser.parse_args()
library_dir = args.library.expanduser()
if args.command == "sync":
run_sync(library_dir, args.server, save=args.save)
return
DioraTuiApp(library_dir).run()
if __name__ == "__main__":
main()

49
tui/diora_tui/app.tcss Normal file
View file

@ -0,0 +1,49 @@
#library-list {
height: 1fr;
}
StatusBar {
dock: bottom;
height: 1;
padding: 0 1;
background: $panel;
color: $text-muted;
text-align: right;
}
#reader-scroll {
height: 1fr;
padding: 1 2;
align-horizontal: center;
}
#reader-loading {
height: 1fr;
}
FootnoteScreen {
align: center middle;
}
#footnote-body {
width: 80%;
max-width: 100;
height: auto;
max-height: 80%;
background: $panel;
border: round $primary;
padding: 1 2;
}
.footnote-note {
margin-bottom: 1;
}
#footnote-hint {
dock: bottom;
width: 80%;
max-width: 100;
background: $panel;
color: $text-muted;
padding: 0 2;
}

214
tui/diora_tui/blocks.py Normal file
View file

@ -0,0 +1,214 @@
"""Whole-book flat block extraction, matching static/js/app.js's EPUB_BLOCK_SELECTOR
and getPositionAnchor()/restoreFromAnchor() exactly in *structure* (blockIndex is
purely DOM order, no rendering needed) so position anchors are comparable across
the web reader and this TUI. See CLAUDE.md's `tui/` section for the full picture.
innerFraction on the web is defined via getBoundingClientRect() pixel geometry,
which has no TUI equivalent — a terminal has no font metrics/reflow the way a
browser does. We approximate it with row-based geometry from Rich's own text
wrapping (see reader.py), which is close enough because the server's "furthest
wins" comparison (_progress_is_further) only falls back to comparing fractions
when two anchors share the exact same block index — block index is the primary,
exactly-reproducible signal.
Footnote references are detected with the same heuristic as app.js's
_looksLikeFootnoteLink: wrapped in/wrapping a <sup>, a class name containing
note/footnote/fn, or an epub:type="noteref" attribute.
"""
from __future__ import annotations
import re
import warnings
from dataclasses import dataclass, field
from pathlib import Path
import ebooklib
from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning
from ebooklib import epub
# EPUB content documents are XHTML; treating them as HTML (matching app.js's
# `new DOMParser().parseFromString(html, 'text/html')`, static/js/app.js:2638)
# is intentional, not a mistake — silence bs4's XML-vs-HTML nudge for it.
# lxml's HTML parser is ~3x faster than bs4's built-in html.parser, which
# matters here: some real-world EPUBs run to tens of thousands of blocks.
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
_PARSER = "lxml"
# Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li,
# blockquote, dt, dd, figcaption, div:not(:has(*))'
_BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt", "dd", "figcaption"}
# Matches app.js's sanitizeEpubHtml() strip list (script/style are also stripped
# via regex before DOMParser even runs there; decomposing here is equivalent).
_STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"]
# Matches app.js's _looksLikeFootnoteLink's class-name check.
_FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b")
# Anchor format the server accepts (books/views.py:save_progress); anything else
# is silently discarded back to ''.
_ANCHOR_RE = re.compile(r"\d{1,7}:\d(\.\d{1,6})?")
@dataclass
class FootnoteRef:
marker: str # visible text of the reference link, e.g. "1"
offset: int # character offset into the block's text where the marker sits
target_id: str # fragment id to resolve against block ids
@dataclass
class Block:
text: str
tag: str
chapter_index: int
id: str | None = None
footnotes: list[FootnoteRef] = field(default_factory=list)
# Every id found anywhere in this block's subtree, not just on the block
# element itself — footnote *targets* are frequently an <a id="..."> or
# similar nested a level or two inside the actual containing paragraph
# (see showFootnotePopover's `.closest('.footnote') || .parentElement`
# walk-up in app.js), so a target id resolves to "the block containing
# it" rather than requiring the id to sit on the block tag itself.
ids: list[str] = field(default_factory=list)
@dataclass
class FlatBook:
id: str
title: str
author: str
path: Path
blocks: list[Block]
chapter_titles: list[str]
chapter_start_block: list[int] # blocks[chapter_start_block[i]] is chapter i's first block
footnote_targets: dict[str, int] # fragment id -> block index of its content
def _is_leaf_div(tag: Tag) -> bool:
return tag.name == "div" and tag.find(True) is None
def _looks_like_footnote_link(el: Tag, href: str) -> bool:
if "#" not in href:
return False
cls = " ".join(el.get("class") or []).lower()
if _FOOTNOTE_CLASS_RE.search(cls):
return True
epub_type = (el.get("epub:type") or "").lower()
if "noteref" in epub_type:
return True
return el.find_parent("sup") is not None or el.find("sup") is not None
def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
for child in el.children:
if isinstance(child, NavigableString):
out.append(str(child))
elif isinstance(child, Tag):
if child.name == "a":
href = child.get("href") or ""
if _looks_like_footnote_link(child, href):
marker = child.get_text(" ", strip=True)
offset = len("".join(out))
target_id = href.split("#", 1)[1] if "#" in href else ""
footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id))
out.append(marker)
continue
_walk_text(child, footnotes, out)
def _extract_blocks(html: bytes, chapter_index: int) -> list[Block]:
soup = BeautifulSoup(html, _PARSER)
for name in _STRIP_TAGS:
for el in soup.find_all(name):
el.decompose()
blocks: list[Block] = []
for el in soup.find_all(True):
if el.name in _BLOCK_TAGS or _is_leaf_div(el):
footnotes: list[FootnoteRef] = []
parts: list[str] = []
_walk_text(el, footnotes, parts)
text = re.sub(r"\s+", " ", "".join(parts)).strip()
ids = [tag_id for tag_id in (el.get("id"), *(d.get("id") for d in el.find_all(True))) if tag_id]
blocks.append(
Block(
text=text,
tag=el.name,
chapter_index=chapter_index,
id=el.get("id"),
footnotes=footnotes,
ids=ids,
)
)
return blocks
def load_flat_book(path: Path) -> FlatBook:
from .epub import _book_id # reuse the same path-hash id scheme
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
title_meta = raw.get_metadata("DC", "title")
title = title_meta[0][0] if title_meta else path.stem
author_meta = raw.get_metadata("DC", "creator")
author = author_meta[0][0] if author_meta else "Unbekannt"
blocks: list[Block] = []
chapter_titles: list[str] = []
chapter_start_block: list[int] = []
# app.js's parseEpub() includes every spine itemref unconditionally — no
# `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote
# targets are commonly parked in a linear="no" document. Skipping it here
# would both break footnote-target resolution and shift blockIndex
# numbering out of sync with the web reader for every block after it.
for idref, _linear in raw.spine:
item = raw.get_item_with_id(idref)
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
continue
chapter_index = len(chapter_titles)
chapter_blocks = _extract_blocks(item.get_content(), chapter_index)
chapter_start_block.append(len(blocks))
first_text = next((b.text for b in chapter_blocks if b.text), None)
chapter_titles.append((first_text or item.get_name())[:60])
blocks.extend(chapter_blocks)
footnote_targets: dict[str, int] = {}
for idx, b in enumerate(blocks):
for tag_id in b.ids:
footnote_targets.setdefault(tag_id, idx)
return FlatBook(
id=_book_id(path),
title=title,
author=author,
path=path,
blocks=blocks,
chapter_titles=chapter_titles,
chapter_start_block=chapter_start_block,
footnote_targets=footnote_targets,
)
def format_anchor(block_index: int, inner_fraction: float) -> str:
inner_fraction = max(0.0, min(1.0, inner_fraction))
return f"{block_index}:{inner_fraction:.6f}"
def parse_anchor(anchor: str) -> tuple[int, float] | None:
if not anchor or not _ANCHOR_RE.fullmatch(anchor):
return None
block_str, _, frac_str = anchor.partition(":")
return int(block_str), float(frac_str)
def anchor_is_further(new_anchor: str, old_anchor: str) -> bool:
"""Mirrors _progress_is_further / _cmpProgress (books/views.py, app.js)."""
new_parts = parse_anchor(new_anchor)
old_parts = parse_anchor(old_anchor)
if new_parts is None or old_parts is None:
return bool(new_anchor) and not old_anchor
nb, ni = new_parts
ob, oi = old_parts
return ni >= oi if nb == ob else nb >= ob

View file

@ -0,0 +1,42 @@
"""Line-API scroll view for the continuous reader — renders only the rows
actually visible on screen (via Widget.render_line), reading from the flat
Strip list diora_tui.layout.build_layout() precomputed once. This is what
keeps very large books responsive: nothing here scales with book size at
paint time, only with viewport height.
"""
from __future__ import annotations
from textual.geometry import Size
from textual.scroll_view import ScrollView
from textual.strip import Strip
from .layout import BookLayout
class ContinuousBookView(ScrollView):
# We always wrap text to fit exactly, so a horizontal scrollbar should
# never be needed — and "scroll" (not "auto") for the vertical one keeps
# its gutter reserved from the very first (still-empty) layout pass, so
# the width we wrap text at later never has to guess whether a scrollbar
# will appear and steal columns out from under already-wrapped lines.
DEFAULT_CSS = """
ContinuousBookView {
overflow-x: hidden;
overflow-y: scroll;
}
"""
def __init__(self, book_layout: BookLayout) -> None:
super().__init__()
self.book_layout = book_layout
self.virtual_size = Size(book_layout.width, book_layout.total_rows)
def render_line(self, y: int) -> Strip:
_scroll_x, scroll_y = self.scroll_offset
row = scroll_y + y
strips = self.book_layout.row_strips
width = self.scrollable_content_region.width
if row < 0 or row >= len(strips):
return Strip.blank(width, self.rich_style)
return strips[row].crop_extend(0, width, self.rich_style)

52
tui/diora_tui/cache.py Normal file
View file

@ -0,0 +1,52 @@
"""On-disk cache for the (FlatBook, BookLayout) pair a book open computes —
extraction + row-layout for very large books (tens of thousands of blocks)
can take several seconds each; reopening the same book at the same terminal
width should be near-instant instead of paying that cost again every time.
Not a correctness-critical cache: any miss (new book, different width, edited
file) just falls back to recomputing from scratch, so a stale/corrupt cache
entry is handled by overwriting it, never by crashing the reader.
"""
from __future__ import annotations
import hashlib
import pickle
from pathlib import Path
from platformdirs import user_cache_dir
from .blocks import FlatBook
from .layout import BookLayout
_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache"
def _cache_key(path: Path, width: int) -> str:
stat = path.stat()
raw = f"{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def load(path: Path, width: int) -> tuple[FlatBook, BookLayout] | None:
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
if not cache_file.exists():
return None
try:
with cache_file.open("rb") as f:
book, book_layout = pickle.load(f)
if not isinstance(book, FlatBook) or not isinstance(book_layout, BookLayout):
return None
return book, book_layout
except Exception:
return None
def save(path: Path, width: int, book: FlatBook, book_layout: BookLayout) -> None:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
try:
with cache_file.open("wb") as f:
pickle.dump((book, book_layout), f, protocol=pickle.HIGHEST_PROTOCOL)
except Exception:
pass # best-effort — a failed cache write shouldn't break reading

42
tui/diora_tui/config.py Normal file
View file

@ -0,0 +1,42 @@
"""Local storage for diora server credentials used by `diora-tui sync`.
Mirrors the trust model of the web client's key storage (static/js/app.js,
getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept
in plaintext on disk, scoped to this machine/user (0600), with no additional
at-rest encryption — same exposure as the browser's localStorage already has.
"""
from __future__ import annotations
import json
import stat
from dataclasses import asdict, dataclass
from pathlib import Path
from platformdirs import user_config_dir
_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora"))
_CONFIG_FILE = _CONFIG_DIR / "config.json"
@dataclass
class RemoteConfig:
server_url: str
api_token: str
enc_key_b64: str
def load() -> RemoteConfig | None:
if not _CONFIG_FILE.exists():
return None
try:
data = json.loads(_CONFIG_FILE.read_text())
return RemoteConfig(**data)
except (json.JSONDecodeError, OSError, TypeError):
return None
def save(cfg: RemoteConfig) -> None:
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2))
_CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)

51
tui/diora_tui/crypto.py Normal file
View file

@ -0,0 +1,51 @@
"""AES-256-GCM helpers matching static/js/app.js's encryptBytes/decryptBytes
(Web Crypto AES-GCM, 12-byte IV hex-encoded, ciphertext base64, key handled
as raw bytes) — diora's books are end-to-end encrypted client-side, so the
server (and this module) only ever sees ciphertext plus the key the user
supplies out-of-band.
"""
from __future__ import annotations
import base64
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
_PBKDF2_ITERATIONS = 200_000
_KEY_LENGTH_BYTES = 32
class DecryptError(Exception):
"""Ciphertext could not be decrypted with the given key (wrong key, or corrupt data)."""
def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes:
try:
key = base64.b64decode(key_b64)
iv = bytes.fromhex(iv_hex)
ct = base64.b64decode(ciphertext_b64)
return AESGCM(key).decrypt(iv, ct, None)
except (InvalidTag, ValueError) as e:
raise DecryptError(str(e)) from e
def derive_key_b64(username: str, password: str) -> str:
"""Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does:
PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only
yields the key that actually decrypts a user's books if that account's
key was ever set up via diora's "unlock with password" flow — a browser
that only ever auto-generated a random key (the default) has a key this
can't reproduce.
"""
salt = f"diora:{username}".encode("utf-8")
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=_KEY_LENGTH_BYTES,
salt=salt,
iterations=_PBKDF2_ITERATIONS,
)
raw = kdf.derive(password.encode("utf-8"))
return base64.b64encode(raw).decode()

19
tui/diora_tui/epub.py Normal file
View file

@ -0,0 +1,19 @@
"""Library scanning and the stable per-book id shared across progress.py,
blocks.py, and remote.py."""
from __future__ import annotations
import hashlib
from pathlib import Path
def _book_id(path: Path) -> str:
# Identifies a book by its resolved path for now. Once the sync API exists,
# this should switch to whatever stable id diora assigns server-side.
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
def scan_library(library_dir: Path) -> list[Path]:
if not library_dir.exists():
return []
return sorted(library_dir.rglob("*.epub"))

90
tui/diora_tui/layout.py Normal file
View file

@ -0,0 +1,90 @@
"""Row-based layout for the continuous reader view — the TUI's terminal-grid
analogue of the browser's pixel-based getBoundingClientRect() geometry (see
blocks.py's module docstring for why blockIndex is exact but innerFraction is
only an approximation here).
Each block is wrapped independently at a known width, once, into a flat list
of pre-rendered Strip objects (one per terminal row) that book_view.py's
Line-API widget indexes directly in render_line() — this is what makes very
large books (tens of thousands of blocks) open in seconds rather than
minutes: Textual only ever renders the rows actually on screen, instead of
laying out the whole book up front the way a single giant Static would.
"""
from __future__ import annotations
from dataclasses import dataclass
from rich.console import Console
from rich.text import Text
from textual.strip import Strip
_SEPARATOR_ROWS = 1 # one blank row between blocks, matching the old "\n\n".join style
@dataclass
class BookLayout:
starts: list[int] # row where block i begins
heights: list[int] # rendered row count for block i
row_strips: list[Strip] # one Strip per absolute row, len == total_rows
total_rows: int
width: int
def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout:
width = max(1, width)
starts: list[int] = []
heights: list[int] = []
row_strips: list[Strip] = []
blank = Strip.blank(width)
row = 0
for text in block_texts:
starts.append(row)
wrapped_lines = list(Text(text).wrap(console, width)) if text else []
if not wrapped_lines:
wrapped_lines = [Text("")]
heights.append(len(wrapped_lines))
for line in wrapped_lines:
strip = Strip(line.render(console), None).adjust_cell_length(width)
row_strips.append(strip)
row_strips.append(blank)
row += len(wrapped_lines) + _SEPARATOR_ROWS
return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width)
def get_position_anchor(layout: BookLayout, scroll_y: int) -> tuple[int, float]:
"""Mirrors app.js's getPositionAnchor(): the last block whose top row is at
or above scroll_y, or the first block if none has scrolled that far yet."""
n = len(layout.heights)
if n == 0:
return 0, 0.0
best_index = 0
found = False
for i in range(n):
if layout.heights[i] < 1:
continue
if layout.starts[i] > scroll_y:
break
best_index = i
found = True
if not found:
best_index = next((i for i in range(n) if layout.heights[i] >= 1), 0)
top = layout.starts[best_index]
height = max(1, layout.heights[best_index])
inner_fraction = max(0.0, min(1.0, (scroll_y - top) / height))
return best_index, inner_fraction
def scroll_y_for_anchor(layout: BookLayout, block_index: int, inner_fraction: float) -> int:
"""Mirrors app.js's restoreFromAnchor()."""
n = len(layout.heights)
if n == 0:
return 0
idx = max(0, min(block_index, n - 1))
top = layout.starts[idx]
height = layout.heights[idx]
return top + round(inner_fraction * height)

View file

@ -0,0 +1,43 @@
"""Local per-book metadata pulled from the server — currently just "read"
status (EBook.is_read from the /api/sync/ snapshot). Kept separate from
progress.py (reading position): different concern, different cadence — this
is only ever set by `diora-tui sync`, never by the reader itself.
"""
from __future__ import annotations
import json
from pathlib import Path
from platformdirs import user_data_dir
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
_META_FILE = _DATA_DIR / "library.json"
def _load_all() -> dict[str, dict]:
if not _META_FILE.exists():
return {}
try:
return json.loads(_META_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
def _save_all(data: dict[str, dict]) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
_META_FILE.write_text(json.dumps(data, indent=2))
def is_read(book_id: str) -> bool:
entry = _load_all().get(book_id)
return bool(entry and entry.get("is_read"))
def set_many(read_status: dict[str, bool]) -> None:
if not read_status:
return
data = _load_all()
for book_id, read in read_status.items():
data.setdefault(book_id, {})["is_read"] = read
_save_all(data)

68
tui/diora_tui/progress.py Normal file
View file

@ -0,0 +1,68 @@
"""Local reading-progress storage.
Positions are stored as the same "blockIndex:innerFraction" anchor string the
server uses (books/models.py, EBookProgress.save_progress) — see
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js
exactly, and anchor_is_further() for the identical "furthest wins" comparison
used server-side (_progress_is_further in books/views.py). A saved position
only ever advances (unless forced), so an older/offline run can't regress a
further-along read position — matching that same rule locally.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from pathlib import Path
from platformdirs import user_data_dir
from .blocks import anchor_is_further
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
_PROGRESS_FILE = _DATA_DIR / "progress.json"
@dataclass
class Position:
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000"
updated_at: float = 0.0
def _load_all() -> dict[str, dict]:
if not _PROGRESS_FILE.exists():
return {}
try:
return json.loads(_PROGRESS_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
def _save_all(data: dict[str, dict]) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
_PROGRESS_FILE.write_text(json.dumps(data, indent=2))
def load(book_id: str) -> Position | None:
entry = _load_all().get(book_id)
if not isinstance(entry, dict) or not entry.get("anchor"):
return None
return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0))
def save(book_id: str, position: Position, *, force: bool = False) -> bool:
"""Returns True if the position was actually written (i.e. it was further
along, or forced) — callers that push to the server only need to do so
when this returns True."""
data = _load_all()
existing = data.get(book_id)
if existing is not None and not force:
old_anchor = existing.get("anchor", "")
if not anchor_is_further(position.anchor, old_anchor):
return False
if not position.updated_at:
position.updated_at = time.time()
data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at}
_save_all(data)
return True

View file

@ -0,0 +1,286 @@
"""Continuous whole-book reader screen — mirrors the web reader's single
scrollable view (see blocks.py's module docstring) instead of the old
per-chapter pagination, so reading position is expressed in the same
"blockIndex:innerFraction" anchor space as the server and the web client.
Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the
whole book into one Static: for large books (tens of thousands of blocks) a
single giant renderable made Textual's layout/paint pass take upwards of a
minute, whereas the Line API only ever renders the rows on screen.
"""
from __future__ import annotations
import bisect
import re
from pathlib import Path
from rich.console import Console
from textual import work
from textual.binding import Binding
from textual.containers import Container, VerticalScroll
from textual.geometry import Size
from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
from . import api, blocks, cache, config as config_mod, layout, progress
from .book_view import ContinuousBookView
from .statusbar import StatusBar
MAX_LINE_WIDTH = 120
AUTOSAVE_INTERVAL = 5.0
# ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
# so this stays constant — measuring the container's width and subtracting
# this fixed amount avoids a measure-after-constrain race against book_view's
# own (possibly already-constrained-from-a-previous-layout) width.
SCROLLBAR_GUTTER = 2
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
# Downloaded-via-sync files are named "<server-id> - <title>.epub" (see
# remote.py) — reused here to find the server book id for progress push.
_SERVER_ID_RE = re.compile(r"^(\d{4,7}) - ")
def _server_book_id(path: Path) -> int | None:
m = _SERVER_ID_RE.match(path.name)
return int(m.group(1)) if m else None
class FootnoteScreen(ModalScreen[None]):
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
def __init__(self, notes: list[str]) -> None:
super().__init__()
self._notes = notes
def compose(self):
with VerticalScroll(id="footnote-body"):
for note in self._notes:
yield Static(note, classes="footnote-note")
yield Label("Escape/f zum Schließen", id="footnote-hint")
def action_dismiss_self(self) -> None:
self.dismiss(None)
class ReaderScreen(Screen):
BINDINGS = [
Binding("j,down", "scroll_down_line", "Runter", show=False),
Binding("k,up", "scroll_up_line", "Hoch", show=False),
Binding("n", "next_chapter", "Nächstes Kapitel"),
Binding("p", "prev_chapter", "Vorheriges Kapitel"),
Binding("f", "peek_footnote", "Fußnote"),
Binding("q,escape", "back", "Zurück zur Bibliothek"),
]
def __init__(self, path: Path) -> None:
super().__init__()
self.path = path
self.book: blocks.FlatBook | None = None
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
self._server_id = _server_book_id(path)
self._remote_cfg = config_mod.load() if self._server_id is not None else None
self._last_pushed_anchor = ""
def compose(self):
yield Header()
yield LoadingIndicator(id="reader-loading")
with Container(id="reader-scroll") as scroll_container:
self._scroll_container = scroll_container
# Stays visible (empty) from the start rather than toggling display
# on once loaded — a widget that's just been switched from hidden
# to visible hasn't been through a layout pass yet, so its `.size`
# is still (0, 0) and an immediate scroll_to() right after has
# nothing to clamp against and silently resets to 0.
self._book_view = ContinuousBookView(_EMPTY_LAYOUT)
yield self._book_view
yield StatusBar()
yield Footer()
def on_mount(self) -> None:
# Deferred rather than read synchronously here: right after mount the
# container hasn't been through a layout pass yet, so its size isn't
# reliable — same lesson as the scroll-restore race below.
self.call_after_refresh(self._start_loading)
def _start_loading(self) -> None:
if self._scroll_container.size.width == 0:
# Not sized yet after all — keep deferring instead of guessing a
# fixed number of refresh cycles (mirrors _restore_position below).
self.call_after_refresh(self._start_loading)
return
self._load_book(self._content_width())
@work(thread=True)
def _load_book(self, width: int) -> None:
cached = cache.load(self.path, width)
if cached is not None:
book, book_layout = cached
else:
book = blocks.load_flat_book(self.path)
console = Console(width=width)
book_layout = layout.build_layout([b.text for b in book.blocks], width, console)
cache.save(self.path, width, book, book_layout)
self.app.call_from_thread(self._on_book_loaded, book, book_layout, width)
def _on_book_loaded(self, book: blocks.FlatBook, book_layout: layout.BookLayout, width: int) -> None:
self.book = book
self.sub_title = book.title
self._apply_layout(book_layout, width)
self.query_one("#reader-loading", LoadingIndicator).display = False
self.call_after_refresh(self._restore_position)
self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
def _content_width(self) -> int:
# Measuring the *container* rather than book_view's own size avoids a
# measure-after-constrain race: once a layout has been applied,
# book_view's width is pinned to a previous value via styles.width
# (below), so re-measuring book_view itself on a later resize would
# just read that stale pinned width back instead of the new
# available space. The container's width is unaffected by that.
available = self._scroll_container.size.width - SCROLLBAR_GUTTER
return max(20, min(MAX_LINE_WIDTH, available))
def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
self.book_layout = book_layout
# Pin book_view's outer width to content-width-plus-scrollbar so its
# *inner* content region (what render_line actually draws into) ends
# up exactly `width` — matching what block texts were wrapped at.
self._book_view.styles.width = width + SCROLLBAR_GUTTER
self._book_view.book_layout = book_layout
self._book_view.virtual_size = Size(width, book_layout.total_rows)
self._book_view.refresh()
def _build_layout(self) -> None:
assert self.book is not None
width = self._content_width()
console = Console(width=width)
block_texts = [b.text for b in self.book.blocks]
new_layout = layout.build_layout(block_texts, width, console)
cache.save(self.path, width, self.book, new_layout)
self._apply_layout(new_layout, width)
def on_resize(self) -> None:
if self.book is None:
return
old_y = self._book_view.scroll_y
anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y)
self._build_layout()
new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac)
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
def _restore_position(self, attempt: int = 0) -> None:
if self.book is None:
return
saved = progress.load(self.book.id)
if saved is None:
return
parsed = blocks.parse_anchor(saved.anchor)
if parsed is None:
return
block_index, inner_fraction = parsed
y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction)
if y <= 0:
return
self._book_view.scroll_to(y=y, animate=False, immediate=True)
# A widget that's only just become part of the layout doesn't always
# honor an immediate scroll on the first attempt (its own size/scroll
# bounds can still be mid-update) — verify it actually landed and
# retry a bounded number of times rather than guessing a fixed delay.
if attempt < 20 and abs(self._book_view.scroll_y - y) > 1:
self.call_after_refresh(lambda: self._restore_position(attempt + 1))
def _current_anchor_str(self) -> str:
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return blocks.format_anchor(block_index, inner_fraction)
def _autosave(self) -> None:
self._save_progress()
def _save_progress(self) -> None:
if self.book is None or not self.book_layout.heights:
return
anchor = self._current_anchor_str()
advanced = progress.save(self.book.id, progress.Position(anchor=anchor))
if advanced and self._remote_cfg is not None and self._server_id is not None:
self._push_remote_progress(anchor)
@work(thread=True, exclusive=True, group="progress-push")
def _push_remote_progress(self, anchor: str) -> None:
if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None:
return
try:
api.post_progress(
self._remote_cfg.server_url,
self._remote_cfg.api_token,
self._server_id,
scroll_fraction=0.0,
position_anchor=anchor,
force=False,
)
self._last_pushed_anchor = anchor
except Exception:
pass # best-effort — local progress is already saved regardless
def on_unmount(self) -> None:
self._save_progress()
def action_scroll_down_line(self) -> None:
self._book_view.scroll_relative(y=1, animate=False)
def action_scroll_up_line(self) -> None:
self._book_view.scroll_relative(y=-1, animate=False)
def _current_chapter_index(self) -> int:
if self.book is None:
return 0
block_index, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return bisect.bisect_right(self.book.chapter_start_block, block_index) - 1
def _jump_to_block(self, block_index: int) -> None:
y = layout.scroll_y_for_anchor(self.book_layout, block_index, 0.0)
self._book_view.scroll_to(y=y, animate=False, immediate=True)
def action_next_chapter(self) -> None:
if self.book is None:
return
chapter = self._current_chapter_index()
if chapter + 1 < len(self.book.chapter_start_block):
self._jump_to_block(self.book.chapter_start_block[chapter + 1])
self._save_progress()
def action_prev_chapter(self) -> None:
if self.book is None:
return
chapter = self._current_chapter_index()
if chapter > 0:
self._jump_to_block(self.book.chapter_start_block[chapter - 1])
self._save_progress()
def action_peek_footnote(self) -> None:
if self.book is None or not self.book_layout.heights:
return
current_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
forward = [(i, b) for i, b in enumerate(self.book.blocks) if i >= current_block and b.footnotes]
backward = [(i, b) for i, b in enumerate(self.book.blocks) if i < current_block and b.footnotes]
candidate = forward[0] if forward else (backward[-1] if backward else None)
if candidate is None:
self.notify("Keine Fußnote in diesem Buch gefunden.", timeout=3)
return
_, block = candidate
notes: list[str] = []
for ref in block.footnotes:
target_idx = self.book.footnote_targets.get(ref.target_id)
if target_idx is None:
continue
notes.append(self.book.blocks[target_idx].text)
if not notes:
self.notify("Fußnote konnte nicht aufgelöst werden.", timeout=3)
return
self.app.push_screen(FootnoteScreen(notes))
def action_back(self) -> None:
self.app.pop_screen()

110
tui/diora_tui/remote.py Normal file
View file

@ -0,0 +1,110 @@
"""Fetch + decrypt books from a diora server into the local TUI library, and
pull reading progress for them into the local progress store.
Progress is pull-only here (server -> local); the reader pushes local ->
server itself while a book is open (see reader_screen.py). Both directions
use the same "blockIndex:innerFraction" anchor format as the web reader
(diora_tui/blocks.py) and the same furthest-wins merge rule
(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can
only ever advance local progress, never regress it, unless forced.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
import requests
from . import api, crypto, library_meta
from .config import RemoteConfig
from .epub import _book_id
from .progress import Position, save as save_progress
class SyncError(Exception):
pass
@dataclass
class SyncResult:
downloaded: list[str] = field(default_factory=list)
unchanged: int = 0
skipped_pdf: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list)
progress_pulled: int = 0
def _sanitize_filename(name: str) -> str:
name = re.sub(r"[^\w\s.-]", "_", name).strip()
return (name or "buch")[:80]
def _parse_meta(raw: bytes) -> dict:
return json.loads(raw.decode("utf-8"))
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
try:
snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token)
except api.ApiError as e:
raise SyncError(str(e)) from e
except requests.RequestException as e:
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
library_dir.mkdir(parents=True, exist_ok=True)
result = SyncResult()
local_path_by_server_id: dict[int, Path] = {}
for book in snapshot.get("books", []):
book_id = book["id"]
try:
meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"]))
except crypto.DecryptError:
result.failed.append(f"#{book_id}")
continue
if meta.get("type") == "pdf":
result.skipped_pdf.append(meta.get("title") or f"#{book_id}")
continue
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None)
if existing is not None:
result.unchanged += 1
local_path_by_server_id[book_id] = existing
continue
try:
data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id)
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
except (api.ApiError, crypto.DecryptError, requests.RequestException):
result.failed.append(meta.get("title") or f"#{book_id}")
continue
title = meta.get("title") or f"book-{book_id}"
dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub"
dest.write_bytes(raw)
result.downloaded.append(dest.name)
local_path_by_server_id[book_id] = dest
for entry in snapshot.get("book_progress", []):
anchor = entry.get("position_anchor") or ""
if not anchor:
continue # PDF-only scroll_fraction progress — this TUI is EPUB-only
dest = local_path_by_server_id.get(entry.get("book_id"))
if dest is None:
continue
local_id = _book_id(dest)
if save_progress(local_id, Position(anchor=anchor)):
result.progress_pulled += 1
read_status = {
_book_id(path): bool(book.get("is_read"))
for book in snapshot.get("books", [])
if (path := local_path_by_server_id.get(book["id"])) is not None
}
library_meta.set_many(read_status)
return result

View file

@ -0,0 +1,44 @@
"""Bottom status bar: battery level + current time, refreshed every second.
Battery is read via psutil (cross-platform); on a desktop machine with no
battery, psutil.sensors_battery() returns None and the bar just shows time.
"""
from __future__ import annotations
from datetime import datetime
from textual.widgets import Static
try:
import psutil
except ImportError:
psutil = None # battery display degrades gracefully to time-only
class StatusBar(Static):
def on_mount(self) -> None:
self._refresh()
self.set_interval(1.0, self._refresh)
def _refresh(self) -> None:
self.update(self._render_text())
def _render_text(self) -> str:
parts = []
battery = self._battery_text()
if battery:
parts.append(battery)
parts.append(datetime.now().strftime("%H:%M:%S"))
return " ".join(parts)
def _battery_text(self) -> str | None:
if psutil is None:
return None
try:
battery = psutil.sensors_battery()
except Exception:
return None
if battery is None:
return None
state = "lädt" if battery.power_plugged else "Akku"
return f"{state} {round(battery.percent)}%"

25
tui/pyproject.toml Normal file
View file

@ -0,0 +1,25 @@
[project]
name = "diora-tui"
version = "0.1.0"
description = "Terminal-EPUB-Reader für diora mit Server-Sync für Bücher und Lesefortschritt."
requires-python = ">=3.9"
dependencies = [
"textual>=0.60",
"ebooklib>=0.18",
"beautifulsoup4>=4.12",
"lxml>=5.0",
"platformdirs>=4.0",
"requests>=2.31",
"cryptography>=42.0",
"psutil>=5.9",
]
[project.scripts]
diora-tui = "diora_tui.app:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["diora_tui"]