Compare commits

..

7 commits

Author SHA1 Message Date
6047ae44c4 TUI: TODO (Pull Request anlegen, Forgejo-Zugang, selbst ausprobieren)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:55:56 +01:00
e246a32212 TUI: Markierungen, Notizen und Lesezeichen (anzeigen, anlegen, löschen, abgleichen)
Dieselben verschlüsselten Listen wie der Web-Reader (annotations.py). Die Anker des Webs
(Block-Index plus Zeichenposition im Rohtext) werden auf den angezeigten Text umgerechnet
(marks.py, blocks.collapse_map), alte XPath-Anker über das Zitat gefunden. Im Reader: v markiert
(Cursor je Wort, Anfang/Ende, Farbe oder Notiz), n legt eine Notiz an, b ein Lesezeichen,
a öffnet die Liste (springen, Notiz ändern, löschen).

Der Server ersetzt die Liste als Ganzes; die TUI legt ihre Änderungen vor dem Schreiben
dreiwege je Eintrag-ID auf die aktuelle Liste des Servers, offline bleiben sie vorgemerkt.
Geprüft: die echten resolveAnchorRange des Webs lösen 81 TUI-Anker exakt auf
(tools/parity/web_anchors.cjs), 34 von 34 echten Web-Markierungen werden angezeigt, der
Server-Smoke schreibt und mischt zwei Geräte. 75 Tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:42:32 +01:00
695037fb9c TUI: Bibliothek wie im Web (Weiterlesen-Karte, Status- und Ordner-Chips, Suche, Sortierung, Länge)
library.py hält die Regeln der Web-Bibliothek (53-library.js) als reine Funktionen:
Status Angefangen/Ungelesen/Gelesen, Ordner (mehrere je Buch, Reihenfolge aus den
verschlüsselten Bibliothekseinstellungen, "Ohne Ordner"), Suche ohne Akzente, sechs
Sortierungen mit natürlicher Zahlenordnung, Weiterlesen-Karte aus den zuletzt gelesenen,
Bücher/Zeitschriften. library_screen.py zeigt sie an (Tasten 1-4, o, /, s, m, c; Chips und
Karte klickbar). Metadaten (Ordner, Seitenzahl, Zeitschrift) und die Ordnerreihenfolge
kommen beim Abgleich; fehlt die Länge, rechnet die TUI sie für geladene Bücher aus dem Text
(1800 Zeichen je Seite). crypto.encrypt für Stufe 3. 54 Tests, Smoke prüft Ordner/Länge/Prefs
gegen den lokalen Server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:18:49 +01:00
51b7316398 TUI: Lesestand-Vertrag (zuletzt gelesen gewinnt), Login wie diora_client, Bücher beim Öffnen laden
progress.py setzt docs/progress-sync.md um (read_at in Serverzeit mit Uhrversatz, sent_at beim
Senden, force, Warteschlange je Buch, Neuanwendung nach dem Abgleich; dieselben Regeln wie
Ops.kt/ops.js). Der Reader speichert nur, was der Leser bewegt hat; wiederherstellen,
Umbruch und Sprünge schreiben nichts. Neuere Stellen anderer Geräte werden angeboten
("Auf X weitergelesen – springen?"), nie von allein angewendet; "w" springt zur weitesten
Stelle. Fortschritt als Textanteil in Zeichen statt 0.0.

Abgleich über GET /books/ (ohne Markierungs-Blobs), Bücher erst beim Öffnen geladen
(☁ in der Liste). `diora-tui login` meldet wie die Apps an, liest den vorhandenen Token,
leitet den Schlüssel aus dem Passwort ab und speichert beides geschützt (DPAPI).
DIORA_TUI_HOME legt alle Dateien in einen Ordner (platformdirs folgt unter Windows den
Umgebungsvariablen nicht). 35 Tests, tools/smoke_server.py gegen einen lokalen Server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:08:59 +01:00
edc9c6d4c3 TUI: EPUB wie der Browser lesen (HTML5, Rohdaten) und Zeichenpositionen je Block
Blocktext, Block-Index und Zeichenoffsets stimmen nun bei sechs echten Büchern exakt mit
parseEpub des Web-Readers überein. Grundlage für einen echten Fortschrittsanteil
(fraction_for_anchor) statt 0.0. Parser html5lib statt lxml, Rohinhalt statt
ebooklib-get_content (formatiert um), UTF-8 wie JSZip.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:59:03 +01:00
7095e176b7 TUI: Blocktext wie der Web-Reader, Tests und Anker-Parität
HTML-Kommentare zählten als Text (1435 Blöcke in einem Testbuch), Leerraum wurde mit
Pythons statt JavaScripts \s zusammengezogen, Fußnotenmarker bekamen Leerzeichen.
Neu: tests/ (Blockliste, Anker, App-Start mit Textual-Pilot) und tools/parity/ – vergleicht
Anzahl und Tags der Blöcke mit parseEpub aus dem Web-Reader in headless Chromium
(6 Bücher, bis 25.798 Blöcke: gleich).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:40:20 +01:00
58f59476d8 Merge master into wt-tui 2026-10-26 12:33:14 +01:00
114 changed files with 5016 additions and 9875 deletions

1
.gitattributes vendored
View file

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

View file

@ -1,134 +0,0 @@
"""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

@ -1,50 +0,0 @@
# 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,40 +142,3 @@ 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)

View file

@ -1,165 +0,0 @@
"""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

View file

@ -1,245 +0,0 @@
"""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']})

View file

@ -1,321 +0,0 @@
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 keyshare, oidc_views, views from . import views
urlpatterns = [ urlpatterns = [
path('register/', views.register, name='register'), path('register/', views.register, name='register'),
@ -21,15 +21,4 @@ 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,7 +13,6 @@ 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()
@ -57,7 +56,7 @@ def login_view(request):
else: else:
form = AuthenticationForm() form = AuthenticationForm()
return render(request, 'accounts/login.html', {'form': form, **oidc_context(request)}) return render(request, 'accounts/login.html', {'form': form})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -79,7 +78,6 @@ 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)
@ -219,7 +217,6 @@ 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),
}) })

View file

@ -1,10 +0,0 @@
# 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"]

View file

@ -1,85 +0,0 @@
"""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,9 +84,6 @@ 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 = [
@ -141,17 +138,6 @@ 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

View file

@ -1,91 +0,0 @@
# 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,8 +29,6 @@ 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)
@ -43,8 +41,6 @@ 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
@ -57,6 +53,5 @@ 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,12 +182,3 @@ 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.

View file

@ -1,262 +0,0 @@
# 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

@ -1,129 +0,0 @@
"""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')

View file

@ -1,652 +0,0 @@
%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.filter(synthetic=False).select_related('user').order_by('last_refreshed_at') qs = Source.objects.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

@ -1,18 +0,0 @@
# 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,9 +35,6 @@ 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, synthetic=False): for source in Source.objects.filter(user=user, kind=Source.BLOG):
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 = [s for s in sources if not s.synthetic] # synthetic sources have no feed to fetch sources = list(sources)
now = timezone.now() now = timezone.now()
keep = {} keep = {}
refreshed = failed = 0 refreshed = failed = 0

View file

@ -539,56 +539,3 @@ 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, 'synthetic': s.synthetic, 'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify,
# 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,11 +108,6 @@ 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
@ -361,7 +356,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, synthetic=False) sources = Source.objects.filter(user=request.user, last_refreshed_at=None)
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT) refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
return JsonResponse({'added': added}) return JsonResponse({'added': added})
@ -391,8 +386,6 @@ 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:
@ -423,7 +416,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, synthetic=False).order_by('kind', 'category', 'title'): for s in Source.objects.filter(user=request.user).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 '')
@ -447,7 +440,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, synthetic=False), refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user),
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})
@ -618,7 +611,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', 'episode_article') qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source')
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:
@ -647,7 +640,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', 'episode_article').order_by('-published') items = Item.objects.filter(user=request.user, starred=True).select_related('source').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]]})
@ -656,14 +649,13 @@ 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', 'episode_article').first() i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source').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)
if not i.source.synthetic: d['content'] = textproc.html_without_furniture(i.content, _furniture(i.source))
d['content'] = textproc.html_without_furniture(i.content, _furniture(i.source))
return JsonResponse({'item': d}) return JsonResponse({'item': d})
@ -752,32 +744,17 @@ 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 after.isdigit(): if not after.isdigit():
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True) return JsonResponse({'items': [], 'last': last})
.select_related('source').order_by('-published')[:50]) items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
out['items'] = [_item_data(i) for i in items] .select_related('source').order_by('-published')[:50])
return JsonResponse(out) return JsonResponse({'items': [_item_data(i) for i in items], 'last': last})
@csrf_exempt @csrf_exempt
@ -793,8 +770,6 @@ 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:

View file

@ -1,119 +0,0 @@
"""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,28 +128,25 @@ FetchUnavailable = ChaptersUnavailable
FetchInvalid = ChaptersInvalid FetchInvalid = ChaptersInvalid
def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs): def _open(url, headers):
"""`method` (GET or POST) `url` with every redirect hop checked; the streamed response of """GET `url` with every redirect hop checked; the streamed response of
the final hop, which the caller has to close. `kwargs` (data, files) go to the final hop, which the caller has to close.
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:
send = requests.get if method == 'GET' else requests.post r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers)
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 redirects: if r.status_code in (301, 302, 303, 307, 308):
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')
@ -158,7 +155,7 @@ def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs):
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 or r.status_code in (301, 302, 303): if r.status_code >= 400:
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

@ -1,18 +0,0 @@
# 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

@ -1,59 +0,0 @@
# 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,8 +164,6 @@ 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)
@ -219,68 +217,6 @@ 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,9 +37,6 @@ 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+.\-]*):')
@ -84,9 +81,7 @@ 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 tag == 'a' and name == 'href' and TIME_LINK.match(value.strip()): if name in URL_ATTRS:
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

View file

@ -1,146 +0,0 @@
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', 'link', 'duration_seconds', 'pub_date', 'artwork_url', self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', '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))

View file

@ -1,45 +0,0 @@
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')

View file

@ -1,821 +0,0 @@
"""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

@ -1,153 +0,0 @@
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', 'link': '', 'duration_seconds': 0, 'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', '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,

View file

@ -1,431 +0,0 @@
"""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}}

View file

@ -1,297 +0,0 @@
"""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 article, audiobooks, discover, folders, radio, transcription, views from . import audiobooks, discover, folders, radio, views
urlpatterns = [ urlpatterns = [
path('search/', views.podcast_search, name='podcast_search'), path('search/', views.podcast_search, name='podcast_search'),
@ -28,15 +28,9 @@ 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,12 +29,11 @@ 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 (
EpisodeArticle, EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater, 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
@ -232,7 +231,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__link', 'episode__id', 'episode__title', 'episode__audio_url',
'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',
@ -384,7 +383,7 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts,
return bool(changed) return bool(changed)
EXTRA_FIELDS = ['link', 'description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at', EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
'title', 'pub_date', 'episode_number'] 'title', 'pub_date', 'episode_number']
@ -643,12 +642,6 @@ 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."""
@ -720,7 +713,6 @@ 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]
@ -745,7 +737,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, 'link': link} renamed = {'title': title, 'episode_number': ep_number}
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):
@ -770,7 +762,6 @@ 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,
@ -1333,7 +1324,7 @@ def feed_episodes(request, pk):
episodes = list( episodes = list(
feed.episodes.values( feed.episodes.values(
'id', 'title', 'description', 'audio_url', 'link', 'duration_seconds', 'id', 'title', 'description', 'audio_url', 'duration_seconds',
'pub_date', 'artwork_url', 'episode_number', 'season_number', 'pub_date', 'artwork_url', 'episode_number', 'season_number',
) )
) )
@ -1433,9 +1424,6 @@ 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,
@ -1443,13 +1431,9 @@ 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': origin is not None, 'has_transcript': _has_transcript(ep),
'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,
@ -1477,13 +1461,6 @@ 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):
@ -1526,28 +1503,20 @@ 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. When the feed has none we can use, the one Whisper made (a finished kept. 404 {"error": "no transcript" | "unsupported" | "invalid"} when
TranscriptionJob, docs/transcription.md). 404 {"error": "no transcript" | there's none to show; 502 {"error": "unavailable"} when it couldn't be
"unsupported" | "invalid"} when there's none to show; 502 loaded right now."""
{"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 fallback('no transcript') return JsonResponse({'error': 'no transcript'}, status=404)
sources = _transcript_sources(ep) sources = _transcript_sources(ep)
if not sources: if not sources:
return fallback('unsupported') return JsonResponse({'error': 'unsupported'}, status=404)
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:
@ -1558,7 +1527,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 fallback('unavailable', 502) return JsonResponse({'error': 'unavailable'}, status=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
@ -1570,7 +1539,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 fallback(error) return JsonResponse({'error': error}, status=404)
return JsonResponse(data) return JsonResponse(data)
@ -1676,7 +1645,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', 'link', 'duration_seconds', 'pub_date', 'artwork_url', 'id', 'title', 'audio_url', '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',
) )
@ -2041,7 +2010,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__link', 'episode__id', 'episode__title', 'episode__audio_url',
'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',
@ -2537,7 +2506,7 @@ def inbox(request):
def _inbox_fields(with_description=True): def _inbox_fields(with_description=True):
fields = ['id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url', fields = ['id', 'title', 'audio_url', '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,19 +734,6 @@ 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;
@ -1805,25 +1792,14 @@ 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; line-height:1; -webkit-text-size-adjust:none; text-size-adjust:none; z-index:1; } .pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }
.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 { 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::selection, .pdf-text-layer mark::selection { background:rgba(0,120,255,0.3); } .pdf-text-layer span::selection { background:rgba(0,120,255,0.3); }
.pdf-text-layer br::selection { background:transparent; } .pdf-text-layer span.reader-search-match { background:rgba(241,196,15,.5); }
.pdf-text-layer mark.reader-search-match { color:transparent; background:rgba(241,196,15,.5); padding:0; border-radius:2px; } .pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); }
.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 */
/* 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); }
#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; }
@ -1848,17 +1824,8 @@ 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 */
/* Page by page the page fills the whole reading area, without its padding: 66-pdf-paginated.js .reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
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 {
@ -1899,57 +1866,6 @@ 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, effort, maxTokens, onProgress, signal}), review(…)} // window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema}), review(…)}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs'; import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
@ -116,17 +116,12 @@ 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).
// Optional: onProgress(words) while the answer is written (throttled; OpenRouter is not streamed, async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
// 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, onProgress, signal}); if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens});
// 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,
@ -136,18 +131,8 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000, o
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(''));
@ -164,8 +149,8 @@ async function openRouterFetch(key, path, init) {
return r.json(); return r.json();
} }
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}) { async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', signal, body: JSON.stringify({ const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', body: JSON.stringify({
model: PROVIDERS.openrouter.model, model: PROVIDERS.openrouter.model,
max_tokens: maxTokens, max_tokens: maxTokens,
reasoning: {effort}, reasoning: {effort},
@ -176,9 +161,7 @@ async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, on
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.');
const text = (choice.message && choice.message.content) || ''; return parseAnswer((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.)
@ -378,8 +361,6 @@ window.__dioraBack = function () {
return back ? back.apply(this, arguments) : false; return back ? back.apply(this, arguments) : false;
}; };
const modelName = () => PROVIDERS[providerOf(vault)].model; window.DioraAI = {available, hasKey: async () => !!(await loadKey()), settings, withKey, ask, review, progress, showError,
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,12 +37,9 @@ 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(); }
} }
// PDF page by page: arrows, Space, PageUp/PageDown turn (scrolling has its own keys below). if (readerSettings.pdfPaginated && currentPdfDoc) {
if (readerSettings.pdfPaginated && currentPdfDoc && !readerSearchOpen if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); }
&& !/^(INPUT|TEXTAREA|SELECT)$/.test((document.activeElement && document.activeElement.tagName) || '')) { if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); }
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,19 +41,10 @@ async function getOrCreateEncKey() {
return _encKey; return _encKey;
} catch (e) { /* fall through, generate new */ } } catch (e) { /* fall through, generate new */ }
} }
// Just signed in with the password: the login form left the derived key (99-init.js // No key found — generate one and store it
// moves it too, but this may run first). _encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
const pending = window.USER_ID ? localStorage.getItem('diora_pending_enc_key') : null; const raw = await crypto.subtle.exportKey('raw', _encKey);
if (pending) { localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
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;
} }

View file

@ -1,254 +0,0 @@
'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,12 +106,10 @@ 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 _pdfLoad(new Uint8Array(buf)).promise; const pdf = await pdfjsLib.getDocument({data: new Uint8Array(buf)}).promise;
try { const n = pdf.numPages;
return pdf.numPages; pdf.destroy();
} finally { return n;
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');
@ -230,41 +228,14 @@ 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. Also role="doc-noteref", // rather than only the single hardcoded class name.
// 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;
if ((el.getAttribute('role') || '').toLowerCase() === 'doc-noteref') return true; return !!(el.closest('sup') || el.querySelector('sup'));
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
@ -301,18 +272,15 @@ 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 (!(href.includes('#') && _isNoteBacklink(el)) && _looksLikeFootnoteLink(el, href)) { } else if (_looksLikeFootnoteLink(el, href)) {
// data-footnote-ref (the id) marks the link as a reference; the href // The whole book is one concatenated DOM, so a fragment id resolves
// as written (data-footnote-href) finds the note in the chapter file it // via getElementById regardless of which chapter file it originally
// points into (78-footnotes.js) — ids like "fn1" repeat across files. // pointed into — strip any "../Text/other.html" path prefix and keep
// 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 — a // window.__dioraPrepareSig: what an app layer adds to the preparation (the
// change there too. // footnote detection in diora-android's native-ui.js) — a change there too.
const src = [parseEpub, sanitizeEpubHtml, _looksLikeFootnoteLink, _isNoteBacklink, _injectImageBlobs, _resolveImageBlob, _parseEpubToc] const src = [parseEpub, sanitizeEpubHtml, _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, pdfPageColors: false, pdfPaginated: false, pdfSpread: false }; pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false };
let readerSettingsPanelOpen = false; let readerSettingsPanelOpen = false;
let currentPdfDoc = null; let currentPdfDoc = null;
let currentPdfBuffer = null; let currentPdfBuffer = null;
@ -45,6 +45,9 @@ 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,13 +41,6 @@ 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;
} }
@ -133,14 +126,10 @@ 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 _pdfLoad(new Uint8Array(buf.slice(0))).promise; const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
try { const meta = await pdfDoc.getMetadata();
const meta = await pdfDoc.getMetadata(); title = meta.info?.Title?.trim() || title;
title = meta.info?.Title?.trim() || title; author = meta.info?.Author?.trim() || '';
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');
@ -789,14 +778,10 @@ async function _importEbookBuffer(buf, filename, statusEl, opts) {
if (isPdf) { if (isPdf) {
try { try {
const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise; const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
try { const meta = await pdfDoc.getMetadata();
const meta = await pdfDoc.getMetadata(); title = meta.info?.Title?.trim() || title;
title = meta.info?.Title?.trim() || title; author = meta.info?.Author?.trim() || '';
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');
const rules = [ style.textContent = [
'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,15 +1026,6 @@ 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 _pdfLoad(new Uint8Array(bytes)).promise; const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise;
try { try {
return await _issuePdfArticles(pdf); return await _issuePdfArticles(pdf);
} finally { } finally {

View file

@ -1,569 +1,49 @@
'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 parts = await Promise.all(items.map(async item => { const result = [];
for (const item of items) {
let href = ''; let href = '';
if (item.dest) { if (item.dest) {
const n = await _pdfDestPage(pdf, item.dest); try {
if (n) href = `#pdf-page-${n}`; const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest;
if (dest) {
const pageIndex = await pdf.getPageIndex(dest[0]);
href = `#pdf-page-${pageIndex + 1}`;
}
} catch (e) {}
} }
const kids = item.items && item.items.length ? await _parsePdfOutline(pdf, item.items, depth + 1) : []; result.push({label: item.title || '(untitled)', href, depth});
return [{label: item.title || '(untitled)', href, depth}].concat(kids); if (item.items && item.items.length) {
})); 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 open document's pages and how they are drawn (renderPdf; stopped on the next one). // The pages' drawing of the last renderPdf: an observer and its queue (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;
// The open document's text, page by page (pdf.js text content): for the text layer, the
// search and the crop to the text box. Kept for the document, not for one layout of it.
let _pdfTexts = 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() { function _pdfStopLazy() {
const v = _pdfView; if (_pdfLazy) {
if (!v) return; _pdfLazy.stopped = true;
v.stopped = true; if (_pdfLazy.observer) _pdfLazy.observer.disconnect();
if (v.observer) v.observer.disconnect(); _pdfLazy = null;
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. Page by page (pdfPaginated), // opens as fast as one of ten, and doesn't hold 1000 canvases.
// 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) {
_pdfForgetDoc(); _pdfLoadingTask = pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))});
_pdfLoadingTask = _pdfLoad(new Uint8Array(arrayBuffer.slice(0)));
try { try {
pdf = await _pdfLoadingTask.promise; pdf = await _pdfLoadingTask.promise;
} finally { } finally {
@ -572,15 +52,20 @@ 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);
const extras = _pdfDocExtras(pdf); let pdfTitle = '', pdfAuthor = '';
extras.labels.then(labels => { try {
if (pdf !== currentPdfDoc) return; const meta = await pdf.getMetadata();
_pdfLabels = labels; pdfTitle = meta.info?.Title?.trim() || '';
if (labels) _pdfSetCurrentPage(pdfCurrentPage); pdfAuthor = meta.info?.Author?.trim() || '';
}); } 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 = '';
@ -592,6 +77,7 @@ 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));
@ -616,7 +102,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 || readerSettings.pdfPaginated) { if (!readerSettings.pdfSpread) {
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');
@ -631,93 +117,78 @@ 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, inner, canvas, drawn: 0, near: false, task: null, token: 0}); entries.push({num: pageNum, wrapper, canvas, drawn: false, near: false, task: null});
} }
pdfTotalPages = pdf.numPages; pdfTotalPages = pdf.numPages;
const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1));
const view = {pdf, contentEl, entries, gen: myGen, stopped: false, observer: null, resize: null, const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1));
resizeTimer: 0, queue: new Set(), working: false, scaleFor, showToken: 0, const pivotEl = document.getElementById(`pdf-page-${pivot}`);
colorsKey: _pdfColorsKey()}; if (pivotEl && pivot > 1) pivotEl.scrollIntoView({block: 'start'});
_pdfView = _pdfLazy = view;
const gone = () => view.stopped || _pdfRenderGen !== myGen; const lazy = {stopped: false, observer: null, queue: new Set(), working: false};
_pdfLazy = lazy;
const gone = () => lazy.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 || readerSettings.pdfPaginated) return; if (gone() || !e.near) return;
await _pdfDraw(e, page, scaleFor(page.getViewport({scale: 1}))); const viewport = page.getViewport({scale: 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 (view.working) return; if (lazy.working) return;
view.working = true; lazy.working = true;
try { try {
while (!gone() && view.queue.size && !readerSettings.pdfPaginated) { while (!gone() && lazy.queue.size) {
const cur = _pdfTopPage(contentEl); const cur = typeof _currentScrollPage === 'function' && !readerSettings.pdfPaginated
const next = Array.from(view.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0]; ? _currentScrollPage(contentEl) : (pdfCurrentPage || pivot);
view.queue.delete(next); const next = Array.from(lazy.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0];
if (next.near && (!next.drawn || next.stale)) await draw(next); lazy.queue.delete(next);
if (next.near && !next.drawn) await draw(next);
} }
} finally { } finally {
view.working = false; lazy.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') {
view.observer = new IntersectionObserver(items => { lazy.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) view.queue.add(e); } if (e.near) { if (!e.drawn) lazy.queue.add(e); }
else { view.queue.delete(e); if (e.drawn || e.task) _pdfLetGo(e, pdf); } else { lazy.queue.delete(e); letGo(e); }
} }
work(); work();
}, {root: contentEl, rootMargin: '150% 0px'}); }, {root: contentEl, rootMargin: '150% 0px'});
entries.forEach(e => view.observer.observe(e.wrapper)); entries.forEach(e => lazy.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; view.queue.add(e); }); entries.forEach(e => { e.near = true; lazy.queue.add(e); });
work(); work();
} }
@ -726,12 +197,5 @@ 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 _pdfResult(pdf, extras); return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
}
// 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,65 +29,6 @@ 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');
@ -123,10 +64,8 @@ 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;
} }
@ -147,36 +86,6 @@ 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};
@ -198,9 +107,6 @@ 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);
@ -245,28 +151,14 @@ async function _openBookOnce(bookId) {
const isPdfBook = cachedMeta.type === 'pdf'; const isPdfBook = cachedMeta.type === 'pdf';
if (isPdfBook) { if (isPdfBook) {
// A document still open (another book) goes; renderPdf loads this one. currentPdfDoc = null; // reset so renderPdf creates fresh doc
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} } const result = await renderPdf(plain, contentEl);
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;
// The table of contents follows when its pages are resolved. currentPdfBuffer = plain;
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.
@ -327,14 +219,18 @@ async function _openBookOnce(bookId) {
// Apply reader settings (theme, font size, etc.) // Apply reader settings (theme, font size, etc.)
applyReaderSettings(isPdfBook); applyReaderSettings(isPdfBook);
// The notes margin stays closed when a book opens (also with notes in it): it's one // If this book already has notes/highlights, surface them right away
// tap away (✎), and opens by itself when a note is written. // instead of making the reader hide them behind a manual toggle.
// 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
_readerListen(contentEl, 'touchstart', _pdfTouchStart, {passive: true}); contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true});
_readerListen(contentEl, 'touchmove', _pdfTouchMove, {passive: false}); contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false});
_readerListen(contentEl, 'touchend', _pdfTouchEnd, {passive: true}); contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true});
_readerListen(contentEl, 'touchcancel', _pdfTouchEnd, {passive: true}); contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true});
// Set up progress input // Set up progress input
const progressInput = $('reader-progress-input'); const progressInput = $('reader-progress-input');
@ -351,13 +247,16 @@ async function _openBookOnce(bookId) {
if (progressSuffix) progressSuffix.textContent = '%'; if (progressSuffix) progressSuffix.textContent = '%';
} }
_readerListen(progressInput, 'change', function () { progressInput.addEventListener('change', function () {
_suppressScrollJumpDetect(); _suppressScrollJumpDetect();
if (isPdf) { if (isPdf) {
// The book's page number ("iv", "12") or the file's. const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1));
const page = Math.min(numPages, Math.max(1, _pdfPageFromInput(this.value) || pdfCurrentPage || 1)); this.value = page;
this.value = pdfPageLabel(page); const target = contentEl.querySelector(`#pdf-page-${page}`);
pdfGoToPage(page); if (target) {
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);
@ -365,7 +264,7 @@ async function _openBookOnce(bookId) {
} }
}); });
_readerListen(progressInput, 'click', function () { this.select(); }); progressInput.addEventListener('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
@ -373,7 +272,19 @@ async function _openBookOnce(bookId) {
let openedProgress = null; let openedProgress = null;
try { try {
let fraction = 0, anchor = ''; let fraction = 0, anchor = '';
const bookData = await progressP; 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
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;
@ -387,7 +298,6 @@ 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'));
@ -420,24 +330,29 @@ 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;
// PDF scrolling: the page shown at the top is the current one (header, bookmarks, the // Update progress input on scroll
// app's footer). EPUB: shown from the position anchor, see the anchor tracking below. contentEl.addEventListener('scroll', () => {
if (isPdf) { if (!progressInput) return;
let pageRaf = 0; if (isPdf) {
_readerListen(contentEl, 'scroll', () => { const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
if (pageRaf || readerSettings.pdfPaginated) return; const cTop = contentEl.getBoundingClientRect().top;
pageRaf = requestAnimationFrame(() => { let currentPage = 1;
pageRaf = 0; for (const w of wrappers) {
if (currentPdfDoc) _pdfSetCurrentPage(_pdfTopPage(contentEl)); if (w.getBoundingClientRect().bottom > cTop + 20) {
}); currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1;
}, {passive: true}); break;
} }
}
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;
_readerListen(contentEl, 'scroll', () => { contentEl.addEventListener('scroll', () => {
clearTimeout(_scrollDebounce); clearTimeout(_scrollDebounce);
_scrollDebounce = setTimeout(saveReaderProgress, 2000); _scrollDebounce = setTimeout(saveReaderProgress, 2000);
}, {passive: true}); }, {passive: true});
@ -447,7 +362,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;
_readerListen(contentEl, 'scroll', () => { contentEl.addEventListener('scroll', () => {
clearTimeout(_anchorTrackDebounce); clearTimeout(_anchorTrackDebounce);
_anchorTrackDebounce = setTimeout(() => { _anchorTrackDebounce = setTimeout(() => {
_currentPositionAnchor = getPositionAnchor(contentEl); _currentPositionAnchor = getPositionAnchor(contentEl);
@ -476,7 +391,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;
_readerListen(contentEl, 'scroll', () => { contentEl.addEventListener('scroll', () => {
if (_scrollJumpRaf) return; if (_scrollJumpRaf) return;
_scrollJumpRaf = requestAnimationFrame(() => { _scrollJumpRaf = requestAnimationFrame(() => {
_scrollJumpRaf = null; _scrollJumpRaf = null;
@ -634,8 +549,6 @@ 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
@ -685,12 +598,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('click', _pdfPaginatedClick); contentEl.removeEventListener('touchstart', _pdfTouchStart);
contentEl.removeEventListener('wheel', _pdfPaginatedWheel); contentEl.removeEventListener('touchmove', _pdfTouchMove);
contentEl.classList.remove('pinch-active', 'pdf-paginated'); contentEl.removeEventListener('touchend', _pdfTouchEnd);
contentEl.style.overflow = ''; contentEl.removeEventListener('touchcancel', _pdfTouchEnd);
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);
@ -703,7 +616,6 @@ 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;
@ -755,9 +667,13 @@ function jumpToTocEntry(href) {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
// PDF page jump (scrolling or page by page) // PDF page jump
if (href.startsWith('#pdf-page-')) { if (href.startsWith('#pdf-page-')) {
pdfGoToPage(parseInt(href.slice('#pdf-page-'.length), 10) || 1); const target = contentEl.querySelector(href);
if (target) {
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
}
return; return;
} }

File diff suppressed because it is too large Load diff

View file

@ -15,14 +15,10 @@ 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, pdfPageColors: false, pdfPaginated: false, pdfSpread: false }, pdfZoom: 100, pdfInverted: 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}`) || '{}');
@ -44,9 +40,8 @@ function loadReaderSettings(bookId) {
} }
function saveReaderSettings() { function saveReaderSettings() {
const key = currentBookId || _readerSettingsKey; if (currentBookId) {
if (key) { localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 3 }));
localStorage.setItem(`diora_reader_settings_${key}`, JSON.stringify({ ...readerSettings, v: 3 }));
} }
} }
@ -94,12 +89,6 @@ 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');
@ -124,21 +113,6 @@ 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');
@ -174,20 +148,12 @@ 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.pdfPaginated ? '' : 'active'}" data-rs-pdfmode="scroll">Scrollen</button> <button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invertieren</button>
<button class="btn btn-sm ${readerSettings.pdfPaginated ? 'active' : ''}" data-rs-pdfmode="pages">Seiten</button> <button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Doppelseite</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>
`; `;
} }
@ -301,77 +267,20 @@ 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');
// While the slider moves only the number follows; the pages are drawn anew once it rests. zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10)));
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)));
// The pages' colours: as printed, in the reader's colours, or inverted. panel.querySelector('#rs-invert').addEventListener('click', function () {
panel.querySelectorAll('[data-rs-pdfcolors]').forEach(btn => { readerSettings.pdfInverted = !readerSettings.pdfInverted;
btn.addEventListener('click', () => { this.classList.toggle('active', readerSettings.pdfInverted);
const which = btn.dataset.rsPdfcolors; applyReaderSettings(true);
readerSettings.pdfInverted = which === 'invert'; saveReaderSettings();
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 () {
@ -384,42 +293,37 @@ 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) {
await _pdfShowPage(pdfCurrentPage); pdfSmartZoomPage(pdfCurrentPage);
return; } else {
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) {
return _pdfTopPage(contentEl); const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
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;
} }
/** Lay the open PDF out again (zoom, spreads, another width), at [pivot] or the page shown now. */ async function reRenderPdf() {
async function reRenderPdf(pivot) { if (!currentPdfBuffer) return;
if (!currentPdfDoc) return;
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
if (!pivot) pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _pdfTopPage(contentEl); const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl);
await renderPdf(null, contentEl, undefined, pivot); await renderPdf(currentPdfBuffer, contentEl, undefined, pivot);
if (readerSettings.pdfPaginated) enterPdfPaginatedMode(); if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
} }

View file

@ -1,174 +0,0 @@
'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,386 +1,175 @@
'use strict'; 'use strict';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PDF page by page (phones and e-readers by default; "Seiten" in the PDF panel): // PDF Paginated Mode
// 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.transform = ''; pdfVp.style.transformOrigin = ''; } if (pdfVp) pdfVp.style.zoom = 1;
// From scrolling: the drawing near the view stops, the pages are this mode's now.
const v = _pdfView; const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
if (v && v.observer) { v.observer.disconnect(); v.observer = null; v.queue.clear(); } wrappers.forEach((w, i) => {
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);
} }
/** Back to scrolling, at [page] (the one shown now). */ function exitPdfPaginatedMode() {
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);
contentEl.removeEventListener('wheel', _pdfPaginatedWheel); reRenderPdf();
reRenderPdf(page || pdfCurrentPage);
} }
function _pdfPaginatedClick(e) { function _pdfPaginatedClick(e) {
if (e.target.closest('a, button, input')) return; const w = e.currentTarget.clientWidth;
// The page was just moved with a finger: no tap. if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1);
if (Date.now() - _pdfPanAt < 400) return; else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1);
// 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 a = e.touches[0], b = e.touches[1]; const dx = e.touches[0].clientX - e.touches[1].clientX;
const x = (a.clientX + b.clientX) / 2, y = (a.clientY + b.clientY) / 2; const dy = e.touches[0].clientY - e.touches[1].clientY;
const r = contentEl.getBoundingClientRect(); _pinchStartDist = Math.hypot(dx, dy);
const g = {kind: 'pinch', dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1, _pinchStartZoom = readerSettings.pdfZoom;
zoom: readerSettings.pdfZoom, f: 1, cx: x - r.left, cy: y - r.top}; _isPinching = true;
if (readerSettings.pdfPaginated) { const ce = $('reader-content');
g.entry = v.entries[pdfCurrentPage - 1]; if (ce) ce.classList.add('pinch-active');
if (!g.entry || !g.entry.pos) return; } else {
g.pos = {x: g.entry.pos.x, y: g.entry.pos.y}; _touchStartX = e.touches[0].clientX;
} else { _isPinching = false;
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) {
const g = _pdfGesture; if (e.touches.length !== 2 || !_isPinching) return;
if (!g) return; e.preventDefault();
if (g.kind === 'pinch') { const dx = e.touches[0].clientX - e.touches[1].clientX;
if (e.touches.length !== 2) return; const dy = e.touches[0].clientY - e.touches[1].clientY;
e.preventDefault(); const dist = Math.hypot(dx, dy);
const a = e.touches[0], b = e.touches[1]; if (_pinchStartDist === 0) return;
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist)));
const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, g.zoom * dist / g.dist)); const vp = document.getElementById('pdf-viewport');
g.f = zoom / g.zoom; if (vp) vp.style.zoom = liveZoom / 100;
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) {
const g = _pdfGesture; if (_isPinching) {
if (g && g.kind === 'pinch') { _isPinching = false;
// The other finger may stay down a moment: it's no swipe. const ce = $('reader-content');
_pdfGesture = {kind: 'after'}; if (ce) ce.classList.remove('pinch-active');
const contentEl = $('reader-content'); const vp = document.getElementById('pdf-viewport');
if (contentEl) contentEl.classList.remove('pinch-active'); const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom;
const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, Math.round(g.zoom * g.f / 10) * 10)); const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10));
if (g.entry) { applyPdfZoom(snapped);
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 (g && g.kind === 'after') { if (!readerSettings.pdfPaginated) return;
if (!e.touches || !e.touches.length) _pdfGesture = null; const delta = e.changedTouches[0].clientX - _touchStartX;
return; if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
} 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 | 0)); n = Math.max(1, Math.min(pdfTotalPages, n));
if (n === pdfCurrentPage) return;
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
if (!readerSettings.pdfPaginated) {
const target = contentEl.querySelector(`#pdf-page-${n}`); const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
if (target) { if (oldWrapper) oldWrapper.style.display = 'none';
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
contentEl.scrollTop += target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top - 8; pdfCurrentPage = n;
} const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
_pdfSetCurrentPage(n); if (newWrapper) newWrapper.style.display = '';
return;
pdfSmartZoomPage(pdfCurrentPage);
const progressInput = $('reader-progress-input');
if (progressInput) progressInput.value = pdfCurrentPage;
}
async function pdfSmartZoomPage(pageNum) {
if (!currentPdfDoc) return;
const contentEl = $('reader-content');
if (!contentEl) return;
const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`);
if (!wrapper) return;
const canvas = wrapper.querySelector('canvas');
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;
} }
if (n === pdfCurrentPage && _pdfView && _pdfView.shown === n) return;
_pdfShowPage(n);
_pdfTurned();
}
// A turn is reading: the place is saved soon (not only by the 10 s timer), and e-readers const containerW = contentEl.clientWidth;
// get their refresh (native-eink.js listens). const containerH = contentEl.clientHeight;
function _pdfTurned() { const contentW = bbox.x2 - bbox.x1;
if (typeof _noteReaderInteraction === 'function') _noteReaderInteraction(); const contentH = bbox.y2 - bbox.y1;
clearTimeout(_pdfTurnSaveTimer); const pad = 12;
_pdfTurnSaveTimer = setTimeout(() => { if (currentPdfDoc) saveReaderProgress(); }, 1500); const scale = Math.min(
try { window.dispatchEvent(new CustomEvent('diora-pdf-turn', {detail: {page: pdfCurrentPage}})); } catch (e) {} (containerW - pad * 2) / contentW,
} (containerH - pad * 2) / contentH
) * (readerSettings.pdfZoom / 100);
/** Show page [n] alone, cropped; draw the next one ahead and let those far away go. */ // Re-render canvas at new scale if significantly different
async function _pdfShowPage(n) { const currentScale = canvas.width / naturalVp.width;
const v = _pdfView; if (Math.abs(scale - currentScale) / currentScale > 0.05) {
if (!v || !v.entries.length) return; const vp = page.getViewport({scale});
n = Math.max(1, Math.min(v.entries.length, n)); canvas.width = vp.width;
_pdfSetCurrentPage(n); canvas.height = vp.height;
v.shown = n; await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise;
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); }); // 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) {
* 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 = currentPdfDoc ? await _pdfPageText(currentPdfDoc, page.pageNumber) : await page.getTextContent(); const tc = 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 || !item.str || !item.str.trim()) continue; if (!item.transform) 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 || Math.abs(item.transform[3]) || 0; const iw = item.width || 0, ih = item.height || 0;
for (const [px, py] of [[tx, ty], [tx + iw, ty + ih]]) { if (tx < x1) x1 = tx;
const [vx, vy] = natural.convertToViewportPoint(px, py); if (ty < y1) y1 = ty;
if (vx < x1) x1 = vx; if (tx + iw > x2) x2 = tx + iw;
if (vx > x2) x2 = vx; if (ty + ih > y2) y2 = ty + ih;
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: Math.max(0, x1), y1: Math.max(0, y1), x2: Math.min(pageW, x2), y2: Math.min(pageH, y2)}; return {x1, y1, x2, y2};
} }
} }
} catch (e) {} } catch (e) {}
@ -390,18 +179,17 @@ async function _computePdfTextBox(page, natural) {
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 = Math.ceil(vp.width); offCanvas.width = vp.width;
offCanvas.height = Math.ceil(vp.height); offCanvas.height = 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, offCanvas.width, offCanvas.height); const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.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;
@ -410,7 +198,12 @@ async function _computePdfTextBox(page, natural) {
} }
} }
if (rMin < rMax && cMin < cMax) { if (rMin < rMax && cMin < cMax) {
return {x1: cMin / lowScale, y1: rMin / lowScale, x2: cMax / lowScale, y2: rMax / lowScale}; return {
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 || 1; const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1;
label = `Seite ${pdfPageLabel(page)}`; label = `Seite ${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,7 +143,15 @@ 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-')) {
pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1); if (readerSettings.pdfPaginated) {
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,9 +94,15 @@ async function doReaderSearch(query) {
} catch (e) {} } catch (e) {}
} }
} else { } else {
// PDF: the whole document, page by page (not only the pages drawn now). // PDF: collect text layer spans
_pdfSearchAll(query); const spans = contentEl.querySelectorAll('.pdf-text-layer > span');
return; const lq = query.toLowerCase();
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';
@ -125,7 +131,10 @@ function clearReaderSearchHighlights() {
}); });
} }
} else { } else {
_pdfSearchStop(); // PDF: remove highlight class from spans
document.querySelectorAll('.reader-search-match').forEach(el => {
el.classList.remove('reader-search-match', 'active');
});
} }
searchMatches = []; searchMatches = [];
searchMatchIndex = -1; searchMatchIndex = -1;
@ -141,7 +150,6 @@ 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'});
@ -149,134 +157,6 @@ 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,9 +219,7 @@ function handleReaderSelection(e) {
return; return;
} }
// Markings belong to books (an article has nowhere to keep them, 65-article.js), and to if (!markerModeActive) return;
// 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,131 +1,41 @@
'use strict'; 'use strict';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Footnotes: finding a reference's note, and the popover that shows it // Footnote popover
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 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 note = noteForLink(link); const targetEl = document.getElementById(link.dataset.footnoteRef);
if (!note) return; if (!targetEl) 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';
@ -133,7 +43,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.appendChild(note); popover.innerHTML = clone.innerHTML;
const closeBtn = document.createElement('button'); const closeBtn = document.createElement('button');
closeBtn.className = 'footnote-popover-close'; closeBtn.className = 'footnote-popover-close';
@ -156,7 +66,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 = Math.max(8, rect.top - pr.height - 8) + 'px'; if (pr.bottom > window.innerHeight - 8) popover.style.top = (rect.top - pr.height - 8) + 'px';
} }
function dismissFootnotePopover() { function dismissFootnotePopover() {

View file

@ -31,10 +31,6 @@
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');
@ -179,6 +175,45 @@
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":
@ -235,131 +270,28 @@
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 '<div class="lib-card" data-ab-open="' + b.feed_id + '">' + return '<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
'<span class="lib-coverbox">' + cover(b.artwork_url, b.title, 'lib-cover', b.author) + cover(b.artwork_url, b.title, 'ab-cover') +
(heard(b) ? '<span class="lib-badge">✓</span>' : '') + '</span>' + '<span class="ab-card-text"><span class="ab-title">' + esc(label || b.title) + '</span>' +
'<span class="lib-bar"><span style="width:' + pct(b) + '%"></span></span>' + (b.author && !label ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
'<span class="lib-title">' + esc(label || b.title) + '</span>' + '<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
'<span class="lib-meta">' + esc(label ? progressText(b) : (b.author || progressText(b))) + '</span></div>'; '<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>';
} }
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 heardSecs = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0); const heard = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
const going = g.books.filter(b => b.next && b.last_played_at)[0]; const going = g.books.filter(b => b.next && b.last_played_at)[0];
const done = g.books.filter(heard).length; const done = g.books.filter(b => !b.next).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 '<div class="lib-card lib-stack" data-ab-series="' + esc(g.key) + '">' + return '<button type="button" class="ab-card ab-stack" data-ab-series="' + esc(g.key) + '">' +
'<span class="lib-coverbox lib-stackbox">' + cover(g.books[0].artwork_url, g.name, 'lib-cover', g.books[0].author) + '</span>' + '<span class="ab-stack-covers">' + cover(g.books[0].artwork_url, g.name, 'ab-cover') + '</span>' +
'<span class="lib-bar"><span style="width:' + (total ? Math.round(100 * heardSecs / total) : 0) + '%"></span></span>' + '<span class="ab-card-text"><span class="ab-title">' + esc(g.name) + '</span>' +
'<span class="lib-title">' + esc(g.name) + '</span>' + (g.books[0].author ? '<span class="ab-meta">' + esc(g.books[0].author) + '</span>' : '') +
'<span class="lib-meta">' + esc(meta) + '</span></div>'; '<span class="ab-meta">' + esc(meta) + '</span>' +
'<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] : []));
@ -379,24 +311,32 @@
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('.lib-chips'); const el = shelf.querySelector('.ab-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(p) { onPick(name) { st.folder = name; renderShelf(); },
if (p && p.lead) st.status = st.status === p.lead ? 'all' : p.lead; onManage: manageFolders,
else st.folder = p;
renderShelf();
},
onManage: c.folders.length ? manageFolders : null,
}); });
} }
@ -502,7 +442,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">‹ Hörbü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">‹ Bü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;
@ -512,7 +452,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">‹ Hörbücher</button>' + '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bü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>' +
@ -823,9 +763,8 @@
} }
shelf.addEventListener('click', e => { shelf.addEventListener('click', e => {
const t = e.target.closest('button, .lib-continue, .lib-card'); const t = e.target.closest('button, .ab-continue');
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; }
@ -853,18 +792,7 @@
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,20 +122,16 @@
async function pdfCover(bytes) { async function pdfCover(bytes) {
if (typeof pdfjsLib === 'undefined') return null; if (typeof pdfjsLib === 'undefined') return null;
const pdf = await (typeof _pdfLoad === 'function' ? _pdfLoad(new Uint8Array(bytes)) const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise;
: pdfjsLib.getDocument({data: new Uint8Array(bytes)})).promise; const page = await pdf.getPage(1);
const v = page.getViewport({scale: 1});
const scale = Math.min(MAX_W / v.width, MAX_H / v.height);
const viewport = page.getViewport({scale});
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
try { canvas.width = Math.round(viewport.width);
const page = await pdf.getPage(1); canvas.height = Math.round(viewport.height);
const v = page.getViewport({scale: 1}); await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
const scale = Math.min(MAX_W / v.width, MAX_H / v.height); pdf.destroy();
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,35 +603,59 @@
// ---- article view // ---- article view
// ---- reading: an article opens in the book reader (65-article.js, DioraArticle) — let reader = null; // the overlay element
// 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 reading && !!(window.DioraArticle && DioraArticle.isOpen()); return !!(reader && !reader.hidden);
} }
function closeReader() { function closeReader() {
if (window.DioraArticle) DioraArticle.close(); if (!reader) return;
} saveProgress();
reader.hidden = true;
function articleClosed() { reader.innerHTML = '';
reading = false; document.documentElement.classList.remove('fd-reading');
readerItem = readerStory = null; readerItem = readerStory = null;
render(); render();
} }
// How far the open article is read (0-1, from the reader); saved now and then and when leaving it. // How far the open article is read (0-1); saved now and then and when leaving it.
let progressSent = 0; let progressSent = 0;
let progressTimer = null;
function saveProgress(f) { function readProgress() {
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(f * 100) / 100; const p = Math.round(readProgress() * 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;
@ -643,70 +667,53 @@
api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {}); api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {});
} }
// Opens a link outside, like the article's own links (the app hands them to the browser).
function openOutside(url) {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
// The article's own buttons, in the reader's header (the apps: in its menu).
function articleActions(i) {
return [
{icon: i.starred ? '★' : '☆', label: i.starred ? 'Gemerkt' : 'Merken', active: !!i.starred, action: () => toggleStar(i)},
// Projekte (projects.js): this article as a source of a project.
window.DioraProjects && {icon: '◇', label: 'Mit Projekt verknüpfen', action: () => window.DioraProjects.link('article', i.id, {
title: i.title, source: i.source_title, author: i.author, date: i.published, url: i.url, news: i.kind === 'news'})},
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) { async function openItem(id, story) {
if (!window.DioraArticle) return; ensureReader();
if (readerItem) saveProgress();
readerStory = story || null; readerStory = story || null;
let item = null; reader.hidden = false;
let error = ''; document.documentElement.classList.add('fd-reading');
reader.innerHTML = readerShell('<p class="fd-note">Lädt …</p>');
reader.scrollTop = 0;
try { try {
item = (await api('/feeds/items/' + id + '/')).item; 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) { } catch (e) {
error = e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message; reader.innerHTML = readerShell('<p class="fd-error">' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '</p>');
}
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 readerShell(inner) {
const i = readerItem;
return '<div class="fd-art-bar">' +
'<button type="button" class="fd-icon" data-r="close" aria-label="Zurück" title="Zurück">‹</button>' +
'<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.
(i && window.DioraProjects ? '<button type="button" class="fd-icon" data-r="project" aria-label="Mit Projekt verknüpfen" title="Mit Projekt verknüpfen">◇</button>' : '') +
(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>' : '') +
'</div><div class="fd-art" style="font-size:' + fontSize() + 'px">' + inner + '</div>';
}
function articleHtml(i) { function articleHtml(i) {
let h = ''; let h = '';
const story = readerStory; const story = readerStory;
@ -720,10 +727,6 @@
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) +
@ -784,7 +787,7 @@
} catch (e) { } catch (e) {
return; // offline and never fetched: the article alone return; // offline and never fetched: the article alone
} }
const box = readerOpen() && readerStory === story && document.querySelector('#reader-content .fd-ov'); const box = reader && !reader.hidden && readerStory === story && reader.querySelector('.fd-ov');
if (box) box.innerHTML = overviewHtml(story); if (box) box.innerHTML = overviewHtml(story);
} }
@ -804,76 +807,37 @@
}); });
} }
// ---- 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) {
// A time mark in a podcast article ("▶ ab 12:34", href="#t=754"; the reader keeps it as data-internal-href). const b = e.target.closest('[data-r]');
const t = e.target.closest('a[data-internal-href^="#t="], a[href^="#t="]'); if (!b) {
if (t && readerItem && readerItem.episode_id) { // Links in the article open outside (the app hands them to the browser).
const sec = parseInt((t.dataset.internalHref || t.getAttribute('href')).slice(3), 10); const a = e.target.closest('.fd-art-body a[href]');
if (sec >= 0) { if (a) { a.target = '_blank'; a.rel = 'noopener'; }
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 === 'episode') playEpisode(parseInt(b.dataset.id, 10), null); if (r === 'close') closeReader();
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) {
@ -1390,6 +1354,9 @@ 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.
@ -1469,9 +1436,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-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-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-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 { 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-title { font-weight: 600; line-height: 1.35; }', '#fd-article .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; }',
@ -1524,39 +1491,45 @@ 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, in the book reader (65-article.js). // The article: a page over everything, like the book reader.
'.fd-artview .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }', '#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-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }', '#fd-article[hidden] { display: none; }',
'.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-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-listen { margin: 0 0 12px; }', '#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-body a { color: var(--accent, #e63946); }', '#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 figure { margin: 12px 0; }', '#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 figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }', '#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 blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }', '#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 pre { white-space: pre-wrap; font-size: 0.85em; }', '#fd-article .fd-art-body a { color: var(--accent, #e63946); }',
'.fd-artview .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }', '#fd-article .fd-art-body figure { margin: 12px 0; }',
'.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 figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
'.fd-artview .fd-sources { font-family: var(--font); }', '#fd-article .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
'#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-artview .fd-ov { font-size: 0.9em; }', '#fd-article .fd-ov { font-size: 0.9em; }',
'.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 { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
'.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-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 p { margin: 8px 0; line-height: 1.45; }', '#fd-article .fd-ov p { margin: 8px 0; line-height: 1.45; }',
'.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 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-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }', '#fd-article .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
'.fd-artview .fd-ov-words { font-family: var(--font); font-size: 13px; }', '#fd-article .fd-ov-words { font-family: var(--font); font-size: 13px; }',
'.fd-artview .fd-ov-rel { margin: 0 0 18px; }', '#fd-article .fd-ov-rel { margin: 0 0 18px; }',
'.fd-artview .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }', '#fd-article .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-artview .fd-ov-box { border-color: #000; }', 'body.eink #fd-article, body.eink #fd-article .fd-art-bar { background: #fff; 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-box { border-color: #000; }',
'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-ov-src, body.eink #fd-article .fd-ov h4, body.eink #fd-article .fd-ov-box summary { color: #333; }',
'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,45 +22,22 @@
// 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 && folders.length) h += '<span class="dfo-sep" aria-hidden="true"></span>'; if (lead.length && (o.folders || []).length) h += '<span class="dfo-sep" aria-hidden="true"></span>';
if (folders.length) { (o.folders || []).forEach(f => {
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 && (!fold || o.active === '')) h += chip('dfo-none', 'Ohne Ordner', o.none, o.active === '', 'data-dfo-folder=""'); if (o.none) h += chip('dfo-none', 'Ohne Ordner', o.none, o.active === '', 'data-dfo-folder=""');
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>'; if (o.onManage) h += '<button type="button" class="dfo-chip dfo-manage" data-dfo-manage="1" title="Ordner verwalten" aria-label="Ordner verwalten">⋯</button>';
el.innerHTML = h + '</div>'; el.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; }
@ -211,7 +188,6 @@
'.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

@ -1,380 +0,0 @@
// ---------------------------------------------------------------------------
// 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-v171'; const CACHE = 'diora-v155';
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,7 +16,6 @@ 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',
@ -32,8 +31,6 @@ 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 ba1e933 2026-11-09: web-native/static/native-podcast.js // Copied from diora-android e287f48 2026-10-25: 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,47 +273,6 @@
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);
@ -622,7 +581,6 @@
{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')},
@ -637,10 +595,6 @@
]); ]);
} }
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);
@ -648,7 +602,6 @@
{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')},
@ -672,7 +625,6 @@
{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')},
@ -739,7 +691,6 @@
...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', () => [
@ -2882,15 +2833,6 @@ 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;
@ -4386,8 +4328,6 @@ 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); }
@ -4398,23 +4338,10 @@ 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 === 'article') { if (which === 'chapters') {
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');
@ -4445,7 +4372,6 @@ 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);
@ -4454,33 +4380,11 @@ 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);
}; };
// The Artikel tab comes first (when there is one); Transkript for every episode with sound if (chapters.length && !chapBox) tab('chapters', `Kapitel (${chapters.length})`);
// (not YouTube: the server never fetches its sound), with a button to make one when it is missing. tab('notes', 'Shownotes');
const canTranscribe = !!d.audio_url && !isYouTube(d.audio_url); if (d.has_transcript) tab('transcript', 'Transkript');
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);
draw(''); show(chapters.length && !chapBox ? 'chapters' : 'notes');
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
@ -4551,8 +4455,6 @@ 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) {
@ -4566,7 +4468,6 @@ 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');
@ -4594,493 +4495,6 @@ 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.
@ -5138,7 +4552,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, box, facts: info.querySelector('.nui-pod-epfacts')}; epScreen = {e, ctx, actions, playBtn, 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));
} }
@ -5199,9 +4613,6 @@ 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' : '');
@ -6417,7 +5828,6 @@ 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 6a0a362 2026-11-09: the podcast parts of web-native/static/native.css /* Copied from diora-android 7f37d4e 2026-10-25: 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,9 +500,7 @@ 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 */
/* flex: none – in the sheet's scrolling column the box would otherwise shrink to its 40 px and .nui-pod-notesbox { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; min-height: 40px; }
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;
@ -550,21 +548,6 @@ 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,12 +28,6 @@
{% 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

@ -1,120 +0,0 @@
<!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,25 +94,6 @@
</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>
@ -287,8 +268,10 @@ 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) {}
} }
// Never a new key here: it would lock the existing books away (static/js/app/33-unlock.js). const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
throw new Error('Bücher sind auf diesem Gerät noch nicht entsperrt – öffne zuerst die Bibliothek'); const raw = await crypto.subtle.exportKey('raw', key);
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,7 +371,6 @@
<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>
@ -387,9 +386,7 @@
<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>
@ -400,8 +397,7 @@
<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> <script type="module" src="/static/js/ai.js"></script>{% endif %}
<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

@ -1,172 +0,0 @@
// 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);
});

View file

@ -11,10 +11,9 @@ Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe
zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in
beide Richtungen synchronisieren (siehe unten). beide Richtungen synchronisieren (siehe unten).
Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben Die Lesestelle folgt dem Vertrag aus `docs/progress-sync.md`: **die zuletzt gelesene Stelle
("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via gewinnt** (auf welchem Gerät auch immer), die weiteste bleibt als Hinweis erhalten. Die
`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene Regeln stehen in `progress.py` und sind dieselben wie auf dem Server und in den Apps.
Position zurücksetzt.
## Setup ## Setup
@ -33,12 +32,17 @@ Tastenkürzel:
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen - `↑`/`k`, `↓`/`j` — zeilenweise scrollen
- `n` — nächstes Kapitel, `p` — vorheriges Kapitel - `n` — nächstes Kapitel, `p` — vorheriges Kapitel
- `w` — zur weitesten Stelle des Buchs springen (auf Wunsch; beim Öffnen weist ein Hinweis darauf hin)
- `v` — Text markieren (siehe unten), `b` — Lesezeichen setzen, `a` — Liste der Lesezeichen,
Markierungen und Notizen
- `f` — Fußnote in der Nähe der aktuellen Position anzeigen (Peek-Overlay, `Escape`/`f`/`q` - `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 zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s
`_looksLikeFootnoteLink` (Link in/um `<sup>`, Klassenname mit note/footnote/fn, oder `_looksLikeFootnoteLink` (Link in/um `<sup>`, Klassenname mit note/footnote/fn, oder
`epub:type="noteref"`) `epub:type="noteref"`)
- `Enter` — markiertes Buch aus der Bibliothek öffnen - Bibliothek: `Enter` öffnet das markierte Buch, `c` das Buch der „Weiterlesen“-Karte, `1`–`4`
- `r` (in der Bibliothek) — gelesene Bücher ein-/ausblenden (siehe unten) Status (Alle · Angefangen · Ungelesen · Gelesen), `o` Ordner, `/` Suche, `s` Sortierung,
`m` Bücher ⇄ Zeitschriften, `Escape` setzt Suche und Filter zurück. Chips und Karte lassen
sich auch anklicken.
- `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek) - `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 Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
@ -47,63 +51,102 @@ volle Terminalbreite bei breiten Fenstern).
Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit, Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit,
sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht. sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht.
Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand ## Die Bibliothek
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`) Wie die Bibliothek im Web (`docs/library.md`), nur im Terminal:
Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe - **Weiterlesen-Karte** oben (Titel, Autor, Balken, „Seite 132 von 186“, wann und auf welchem
unten), synct `diora-tui` **automatisch** — einmal leise im Hintergrund beim Start (neue Gerät zuletzt gelesen) und darunter die Bücher, die davor gelesen wurden. Das Buch der Karte
Bücher + Fortschritt werden nachgeladen, die Bibliotheksliste aktualisiert sich von steht nicht noch einmal in der Liste. Bei Suche und Filtern entfällt die Karte.
selbst) und einmal beim Beenden über `q` (kurzer Moment Verzögerung, bevor die App - **Chips:** Alle · Angefangen · Ungelesen · Gelesen (mit Anzahl), dann die Ordner in der
tatsächlich schließt). Der explizite Befehl ist für's Ersteinrichten und für Reihenfolge, die im Web eingestellt ist (verschlüsselte Bibliothekseinstellungen), und
Nicht-interaktive Nutzung (Cron o.ä.): „Ohne Ordner“. Ein Buch kann in mehreren Ordnern stehen.
- **Suche** in Titel und Autor, ohne Beachtung von Groß-/Kleinschreibung und Akzenten.
- **Sortierung:** Zuletzt · Titel · Autor · Neu hinzugefügt · Kürzeste/Längste zuerst (Zahlen
nach Wert: „Band 2“ vor „Band 10“). Sie bleibt gespeichert.
- **Länge:** die Seitenzahl aus den Metadaten (`pages`); fehlt sie, rechnet die TUI sie für
schon geladene Bücher einmal aus dem Text aus (1800 Zeichen je Seite, wie der Web-Reader), wenn
nach Länge sortiert wird („Länge wird ermittelt …“). Nicht geladene Bücher ohne Länge stehen am
Ende. Zurück auf den Server schreibt die TUI die Länge nicht; das macht der Web-Reader.
- **Bücher ⇄ Zeitschriften** (`m`): Zeitschriften sind, was in den Metadaten so markiert ist
oder im Ordner „Zeitschriften“/„Magazine“ liegt. Die Heft-Ansicht mit Artikeln gibt es in der
TUI nicht; ein Heft ist ein Buch.
- ☁ vor dem Titel: auf dem Server, noch nicht geladen. ✓: gelesen.
Nicht in der TUI: Serien-Stapel, Ausgaben (mehrere Fassungen eines Werks stehen einzeln),
Cover, Ordner anlegen/zuweisen, Eingang aus der Cloud.
## Markierungen, Notizen, Lesezeichen
Dieselben Daten wie im Web-Reader (verschlüsselt mit dem Buchschlüssel, je Buch und Art eine Liste
`/books/<id>/highlights/` und `/bookmarks/`; Format in `annotations.py`). Was im Web, in den Apps
oder hier gemacht wird, erscheint überall.
- **Anzeige:** Markierungen stehen im Text mit der Farbe (Gelb, Grün, Blau, Rot); eine freie Notiz
ist unterstrichen. Die Anker des Web-Readers (Block-Nummer plus Zeichenposition im Rohtext des
Absatzes) werden auf den angezeigten Text umgerechnet; alte Anker ohne Blocknummer findet die TUI
wie das Web über das Zitat. `a` öffnet die Liste (Enter springt dorthin, `e` ändert die Notiz,
`d` löscht, Escape schließt).
- **Markieren (`v`):** Der Cursor steht auf einem Wort; `h`/`l` wechseln das Wort, `j`/`k` den
Absatz. Enter setzt den Anfang, danach `h`/`l`/`j`/`k` zum Ende und Enter: Farbe wählen, oder
„Gelb mit Notiz“. `n` legt statt dessen eine Notiz auf das Wort bzw. die Auswahl. Escape bricht
ab. Stellen, die sich nicht genau zuordnen lassen (ein Fußnotenmarker mit Leerraum im Absatz),
lehnt die TUI ab, statt eine falsche Position zu schreiben.
- **Lesezeichen (`b`):** wie im Web das aktuelle Kapitel (Name der Kapiteldatei, neueste zuerst).
- **Abgleich:** Der Server ersetzt die Liste als Ganzes und führt nichts zusammen; die Apps
schreiben deshalb ihre ganze Liste. Die TUI merkt sich, was sie zuletzt vom Server gesehen hat, und
legt vor dem Schreiben ihre Änderungen (neu, gelöscht, geändert, je Eintrag-ID) auf die aktuelle
Liste des Servers: was ein anderes Gerät inzwischen markiert hat, geht nicht verloren. Offline
bleiben Änderungen vorgemerkt und gehen beim nächsten Abgleich, beim Verlassen des Buchs oder
beim Beenden hinaus. Beim Öffnen eines Buchs werden seine Listen geholt.
- **Prüfung gegen das Web:** `tools/parity/web_anchors.cjs` lässt den echten `resolveAnchorRange`
des Web-Readers die in der TUI erzeugten Anker auflösen (an zwei echten Büchern: alle 81 Anker
an der gemeinten Stelle); umgekehrt lassen sich 34 von 34 echten Web-Markierungen anzeigen (eine alte
XPath-Notiz über ihr Zitat). Nicht in der TUI: PDF-Markierungen.
## Anmelden und synchronisieren
```bash ```bash
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage diora-tui login # einmal; fragt Benutzername und Passwort
diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern diora-tui # die Oberfläche, synct beim Start und beim Beenden
diora-tui sync # nur abgleichen, ohne Oberfläche
diora-tui logout # gespeicherte Zugangsdaten löschen
``` ```
Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books/<id>/data/` herunter, `login` meldet sich wie die Apps über das Web-Formular an, liest den vorhandenen API-Token
entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als von der Einstellungsseite (es wird nie ein neuer erzeugt: das würde die Apps abmelden) und
normale `.epub`-Dateien in `--library` ab (Dateiname `<id> - <Titel>.epub`) — von da an leitet den Buchschlüssel aus dem Passwort ab (PBKDF2, wie „Mit Passwort entsperren“ im Web).
funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden Das Passwort wird nicht gespeichert. Passt der abgeleitete Schlüssel nicht (Passwort seit dem
beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot Hochladen geändert, Schlüssel zufällig erzeugt), fragt `login` nach dem Schlüssel
wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch). (diora: Einstellungen → Schlüssel kopieren; `--paste-key` fragt gleich danach).
Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am `<id> - `-Dateinamens- Token und Schlüssel liegen geschützt in `config.json`: unter Windows per DPAPI (nur dieser
Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server Windows-Benutzer auf diesem Rechner kann sie lesen), sonst als Base64 in einer 0600-Datei.
(`POST /books/<id>/progress/`, `force: false` — überschreibt also nie eine weiter Eine ältere Konfiguration mit Klartextfeldern wird beim ersten Lesen geschützt neu geschrieben.
gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein Alle Dateien liegen unter `platformdirs`' Verzeichnissen; `DIORA_TUI_HOME=<Ordner>` legt sie
lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff. stattdessen gemeinsam in einen Ordner (zweites Konto, Tests).
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter **Was synchronisiert wird:** die Bücherliste (`GET /books/`, ohne die Blobs der Markierungen)
`~/.config/diora-tui/config.json`, `chmod 600`): samt Lesestand, Gelesen-Status und der Uhr des Servers. Die Bücher selbst werden erst beim
Öffnen geladen und entschlüsselt (`<id> - <Titel>.epub` im Bibliotheksordner). PDFs werden
übersprungen.
- **Server-URL** — z.B. `https://diora.creamfresh.xyz`. **Lesestand** (`progress.py`, `reader_screen.py`):
- **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 - Gespeichert wird nur, was der Leser selbst bewegt hat (Tasten, Mausrad), mit dem Zeitpunkt
Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei. des Ankommens (`read_at`). Wiederherstellen, Umbruch bei Größenänderung und Sprünge auf
Angebote ändern nichts. Ein offenes, ruhendes Buch kann deshalb nie eine neuere Stelle von
woanders überschreiben.
- Zeiten sind Serverzeit: die TUI lernt aus jeder Antwort den Versatz ihrer Uhr, und der
Server korrigiert mit `sent_at` nach. Ein falsch gehender Rechner ordnet trotzdem richtig.
- Jede Stelle wird lokal angewendet und vorgemerkt (`pending`, je Buch die später gelesene),
bis der Server sie hat, auch offline; nach einem Abgleich wird sie nach derselben Regel auf
den Stand des Servers gelegt.
- Alle zwei Minuten fragt ein offenes Buch den Server. Liegt dort eine neuere Stelle von einem
anderen Gerät, fragt die TUI „Auf *X* weitergelesen — bei n %. Springen?“ (`y`/`n`), und
bewegt sich nie von allein.
- Der Fortschritt in % ist wie im Web der Anteil am Buchtext in Zeichen
(`blocks.fraction_for_anchor`), nicht mehr 0.
## Performance bei großen Büchern ## Performance bei großen Büchern
@ -115,10 +158,32 @@ deutlich schneller. Das Rendering selbst skaliert nicht mit der Buchgröße —
tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch
auf einmal. auf einmal.
## Tests und Anker-Parität mit dem Web-Reader
```bash
pip install -e . pytest
pytest tests # Blockliste, Anker, App-Start (Textual-Pilot)
```
Die Position ist ein Block-Index, den alle Clients gleich zählen müssen. Ob die TUI das tut,
prüft `tools/parity/` gegen den echten Web-Code (`parseEpub` aus `static/js/app/40-epub.js`
in headless Chromium):
```bash
NODE_PATH=<diora-web>/node_modules PLAYWRIGHT_BROWSERS_PATH=... node tools/parity/web_blocks.cjs <diora-web> web.json buch1.epub buch2.epub
python tools/parity/compare_blocks.py web.json
```
Stand 2026-10-26 (6 Bücher, bis 25.798 Blöcke): Anzahl, Tags, Text, Zeichenposition und
-länge aller Blöcke sowie die Gesamtlänge sind identisch. Das setzt voraus, dass die TUI den
EPUB-Inhalt wie der Browser liest: UTF-8-dekodiert, mit dem HTML5-Parser (`html5lib`, nicht
libxml2) und aus den Rohdaten (`item.content`; `get_content()` von ebooklib formatiert um und
fügt Leerraum ein).
## Grenzen der aktuellen Version ## Grenzen der aktuellen Version
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen, - Nur EPUB, kein PDF (der Abgleich überspringt PDFs und meldet sie); keine PDF-Markierungen.
wird gemeldet), da der Reader sie ohnehin nicht darstellen kann. - Notizen sind einzeilig; mehrzeilige Notizen aus dem Web werden angezeigt und lassen sich ersetzen.
- Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt. - Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt.
- `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung - `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung
(zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung (zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung

36
tui/TODO.md Normal file
View file

@ -0,0 +1,36 @@
# TODO diora-tui
## Offen
- [ ] **Pull Request `wt-tui` → `master` anlegen** (nur anlegen, nicht mergen). Forgejo verlangt dafür eine
Anmeldung; Claude hat keinen Token. Vergleich ist vorbereitet:
https://fg.creamfresh.xyz/mrwnslz/diora-web/compare/master...wt-tui
(Stand 2026-10-26: 14 Commits, mit dem damaligen `origin/master` konfliktfrei mergebar.)
**Titel:** `diora-tui: auf den aktuellen Stand (Lesestand, Bibliothek, Markierungen)`
**Beschreibung:**
```
Bringt die TUI (tui/) auf den Stand von Web und Apps. Nur tui/ ist betroffen, der Server bleibt unverändert.
- Liest EPUBs wie der Browser (html5lib, Rohdaten, UTF-8): Block-Index, Text und Zeichenpositionen stimmen bei 6 echten Büchern mit parseEpub des Web-Readers überein (tools/parity/).
- Lesestand "zuletzt gelesen gewinnt" nach docs/progress-sync.md (read_at/sent_at/device, Warteschlange, Angebote "Auf X weitergelesen" und "Weiteste Stelle"); Fortschritt als Textanteil statt 0.0.
- `diora-tui login` wie diora_client (Token auslesen, Schlüssel aus dem Passwort, geschützt gespeichert: DPAPI unter Windows), Bücher erst beim Öffnen geladen, Abgleich über GET /books/.
- Bibliothek wie im Web: Weiterlesen-Karte, Status- und Ordner-Chips, Suche, 6 Sortierungen inkl. Länge, Bücher/Zeitschriften.
- Markierungen, Notizen, Lesezeichen anzeigen, anlegen, löschen; die TUI legt ihre Änderungen je ID auf die aktuelle Server-Liste, bevor sie schreibt.
Geprüft: 75 Tests (pytest tests), tools/smoke_server.py gegen einen lokalen Server, tools/parity/web_anchors.cjs (der echte resolveAnchorRange des Webs findet 81 TUI-Anker exakt; 34 von 34 echten Web-Markierungen werden angezeigt).
Nicht enthalten: Podcasts/Radio, PDF-Markierungen, Serien-Stapel, Ausgaben, Cover.
```
- [ ] **Forgejo-Zugang für Claude** (optional): API-Token als Umgebungsvariable oder `tea` einrichten, dann
legt Claude künftige PRs selbst an.
- [ ] **TUI selbst ausprobieren** mit dem echten Konto: `pip install -e tui`, `diora-tui login`,
`diora-tui`. Bisher nur in automatisierten Terminal-Läufen und gegen einen lokalen Testserver
gesehen, nicht von Hand bedient.
## Bewusst nicht in der TUI
Podcasts und Radio, PDF-Markierungen, Serien-Stapel, Ausgaben (mehrere Fassungen eines Werks),
Cover, Ordner anlegen/zuweisen, Eingang aus der Cloud, Heft-Ansicht für Zeitschriften.

View file

@ -0,0 +1,179 @@
"""The reader's note editor and the list of a book's bookmarks, highlights and notes."""
from __future__ import annotations
from dataclasses import dataclass
from rich.text import Text
from textual.binding import Binding
from textual.containers import Container
from textual.screen import ModalScreen
from textual.widgets import Input, Label, ListItem, ListView, Static
from . import annotations, blocks
class NoteScreen(ModalScreen[str]):
"""Type a note; Enter keeps it, Escape leaves it as it was (dismisses with None)."""
BINDINGS = [Binding("escape", "cancel", "Abbrechen")]
def __init__(self, title: str, text: str = "") -> None:
super().__init__()
self._title = title
self._text = text
def compose(self):
with Container(id="note-body"):
yield Label(self._title, id="note-title")
yield Input(value=self._text, placeholder="Notiz …", id="note-input")
yield Label("Enter: speichern Escape: abbrechen", id="note-hint")
def on_mount(self) -> None:
self.query_one(Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
self.dismiss(event.value.strip())
def action_cancel(self) -> None:
self.dismiss(None)
@dataclass
class Entry:
kind: str # 'bookmarks' | 'highlights'
item: dict
block: int # where it belongs, for the jump
percent: int
def entries(book: blocks.FlatBook, ann: annotations.BookAnnotations, mark_index=None) -> list[Entry]:
out: list[Entry] = []
for bm in ann.bookmarks:
block = bookmark_block(book, bm)
out.append(Entry("bookmarks", bm, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100)))
for h in sorted(ann.highlights, key=annotations.sort_key):
a = h.get("anchor") or {}
block = a.get("startBlockIndex") if isinstance(a.get("startBlockIndex"), int) else None
if block is None and mark_index is not None: # an old anchor: where its quote is
found = mark_index._find_quote(a)
block = found[0] if found else None
block = min(max(block or 0, 0), len(book.blocks) - 1)
out.append(Entry("highlights", h, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100)))
return out
def bookmark_block(book: blocks.FlatBook, bm: dict) -> int:
"""The block a bookmark points at: its chapter's first block; else its fraction of the text."""
src = bm.get("anchor") or ""
if src in book.chapter_srcs:
return book.chapter_start_block[book.chapter_srcs.index(src)]
target = float(bm.get("scrollFraction") or 0) * book.total_chars
return next((i for i, b in enumerate(book.blocks) if b.char_start + b.char_len >= target), 0)
class AnnotationsScreen(ModalScreen[int]):
"""Lesezeichen, then Markierungen and Notizen in the order of the book. Enter jumps (dismisses
with the block), d deletes, e edits a note; Escape closes."""
BINDINGS = [
Binding("escape,q,a", "close", "Schließen"),
Binding("d", "delete", "Löschen"),
Binding("e", "edit", "Notiz"),
]
def __init__(self, book: blocks.FlatBook, store: annotations.AnnotationStore, key: str, on_change, mark_index=None) -> None:
super().__init__()
self.book = book
self.store = store
self.key = key
self.mark_index = mark_index
self.on_change = on_change # called after a delete or an edit: marks and the queue follow
self.rows: list[Entry] = []
def compose(self):
with Container(id="ann-body"):
yield Label("Lesezeichen, Markierungen und Notizen", id="ann-title")
yield ListView(id="ann-list")
yield Static("", id="ann-detail")
yield Label("Enter: hinspringen d: löschen e: Notiz bearbeiten Escape: schließen", id="ann-hint")
def on_mount(self) -> None:
self._fill()
def _fill(self, keep: int = 0) -> None:
self.rows = entries(self.book, self.store.get(self.key), self.mark_index)
lv = self.query_one(ListView)
lv.clear()
if not self.rows:
lv.append(ListItem(Label("Noch nichts. b: Lesezeichen, v: Text markieren, im Lesen.")))
for r in self.rows:
lv.append(ListItem(Label(Text(self._label(r)))))
if self.rows:
lv.index = min(keep, len(self.rows) - 1)
lv.focus()
self._detail()
@staticmethod
def _label(r: Entry) -> str:
if r.kind == "bookmarks":
return f"★ {r.item.get('label') or 'Lesezeichen'} {r.percent} %"
h = r.item
quote = " ".join(((h.get("anchor") or {}).get("quote") or "").split())
quote = quote if len(quote) <= 60 else quote[:59] + "…"
if h.get("type") == "note":
return f"✎ {h.get('note') or ''} {r.percent} %"
mark = {"yellow": "▌", "green": "▌", "blue": "▌", "red": "▌"}.get(h.get("color") or "yellow", "▌")
note = f" ✎ {h['note']}" if h.get("note") else ""
return f"{mark} „{quote}“{note} {r.percent} %"
def _detail(self) -> None:
lv = self.query_one(ListView)
detail = self.query_one("#ann-detail", Static)
if not self.rows or lv.index is None or lv.index >= len(self.rows):
detail.update("")
return
h = self.rows[lv.index].item
text = ""
if self.rows[lv.index].kind == "highlights":
text = " ".join(((h.get("anchor") or {}).get("quote") or "").split())
if h.get("note"):
text += "\n✎ " + h["note"]
detail.update(Text(text))
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
self._detail()
def on_list_view_selected(self, event: ListView.Selected) -> None:
lv = self.query_one(ListView)
if self.rows and lv.index is not None and lv.index < len(self.rows):
self.dismiss(self.rows[lv.index].block)
def _current(self) -> Entry | None:
lv = self.query_one(ListView)
return self.rows[lv.index] if self.rows and lv.index is not None and lv.index < len(self.rows) else None
def action_close(self) -> None:
self.dismiss(None)
def action_delete(self) -> None:
r = self._current()
if r is None:
return
idx = self.query_one(ListView).index or 0
self.store.delete(self.key, r.kind, r.item["id"])
self.on_change()
self._fill(idx)
def action_edit(self) -> None:
r = self._current()
if r is None or r.kind != "highlights":
return
idx = self.query_one(ListView).index or 0
def edited(text: str | None) -> None:
if text is not None and self.store.set_note(self.key, r.item["id"], text):
self.on_change()
self._fill(idx)
self.app.push_screen(NoteScreen("Notiz", r.item.get("note") or ""), edited)

View file

@ -0,0 +1,258 @@
"""Highlights, notes and bookmarks of a book, as the web reader keeps them.
Format (static/js/app/72-highlights.js, 68-bookmarks.js): per book and kind one JSON list,
encrypted with the book key (AES-GCM, {ct, iv}), replaced as a whole on the server:
highlight {id, type: 'highlight'|'note', anchor, color, note, createdAt}
anchor {type: 'epub', chapterSrc, startBlockIndex, startChar, endBlockIndex, endChar, quote}
bookmark {id, label, anchor (the chapter's file), scrollFraction, createdAt} newest first
The server does no merging, so the apps write their whole list. The TUI keeps what it last
saw (`base`), and before it writes it merges its list with the server's current one by id
(three-way: what it added, deleted or changed since `base`, on top of what the server holds), so
a highlight another device made meanwhile is not wiped out. Its edits are queued (`dirty`) while
offline.
"""
from __future__ import annotations
import functools
import json
import os
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from . import crypto, paths
COLORS = {"yellow": "#f1c40f", "green": "#2ecc71", "blue": "#3498db", "red": "#e63946"}
COLOR_NAMES = {"yellow": "Gelb", "green": "Grün", "blue": "Blau", "red": "Rot"}
KINDS = ("highlights", "bookmarks")
MAX_BYTES = {"highlights": 700 * 1024, "bookmarks": 100 * 1024} # the server's size guards (raw bytes)
QUOTE_MAX = 200
_DIR = paths.data_dir() / "annotations"
def _now_iso() -> str:
"""Like the browser's toISOString(): 2026-10-26T12:00:00.123Z."""
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
# ---- the items
def new_highlight(anchor: dict, color: str = "yellow", note: str = "") -> dict:
return {"id": str(uuid.uuid4()), "type": "highlight", "anchor": anchor, "color": color, "note": note, "createdAt": _now_iso()}
def new_note(anchor: dict, text: str) -> dict:
"""A freeform note: no mark in the text, its anchor only says where it belongs."""
return {"id": str(uuid.uuid4()), "type": "note", "anchor": anchor, "color": None, "note": text, "createdAt": _now_iso()}
def new_bookmark(label: str, anchor: str, scroll_fraction: float) -> dict:
return {"id": str(uuid.uuid4()), "label": label, "anchor": anchor, "scrollFraction": scroll_fraction, "createdAt": _now_iso()}
def make_anchor(chapter_src: str, start_block: int, start_char: int, end_block: int, end_char: int, quote: str) -> dict:
return {"type": "epub", "chapterSrc": chapter_src, "startBlockIndex": start_block, "startChar": start_char,
"endBlockIndex": end_block, "endChar": end_char, "quote": quote[:QUOTE_MAX]}
def sort_key(h: dict) -> tuple:
a = h.get("anchor") or {}
return (a.get("startBlockIndex", 10**9), a.get("startChar", 0))
# ---- merge
def merge(base: list[dict] | None, ours: list[dict], theirs: list[dict], *, prepend_added: bool = False) -> list[dict]:
"""The server's list (`theirs`) with our changes since `base` laid on it, by id: what we
deleted is gone, what we added is in, what we changed is ours (also against their change).
`base` None: we never saw the server's list, so nothing counts as deleted."""
base_by = {x["id"]: x for x in (base or []) if isinstance(x, dict) and "id" in x}
ours_by = {x["id"]: x for x in ours if isinstance(x, dict) and "id" in x}
deleted = set(base_by) - set(ours_by) if base is not None else set()
added = [x for x in ours if x["id"] not in base_by]
changed = {i for i, x in ours_by.items() if i in base_by and x != base_by[i]}
out, seen = [], set()
for x in theirs:
i = x.get("id")
if i in deleted:
continue
out.append(ours_by[i] if i in changed else x)
seen.add(i)
new = [x for x in added if x["id"] not in seen]
# a change of ours to something they deleted stays: losing an edit is worse than a revived item
revived = [ours_by[i] for i in changed if i not in seen and ours_by[i] not in new]
return (new + revived + out) if prepend_added else (out + revived + new)
# ---- the blobs
def decode(key_b64: str, blob: dict) -> list[dict]:
"""The server's {ct, iv} (ct may be null: none yet) as a list."""
if not blob or not blob.get("ct"):
return []
data = json.loads(crypto.decrypt(key_b64, blob["iv"], blob["ct"]).decode("utf-8"))
return [x for x in data if isinstance(x, dict) and x.get("id")] if isinstance(data, list) else []
def encode(key_b64: str, items: list[dict]) -> dict:
iv, ct = crypto.encrypt(key_b64, json.dumps(items, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
return {"ct": ct, "iv": iv}
# ---- local state
@dataclass
class BookAnnotations:
highlights: list[dict] = field(default_factory=list)
bookmarks: list[dict] = field(default_factory=list)
base: dict[str, list[dict] | None] = field(default_factory=lambda: {"highlights": None, "bookmarks": None})
dirty: dict[str, bool] = field(default_factory=lambda: {"highlights": False, "bookmarks": False})
def items(self, kind: str) -> list[dict]:
return self.highlights if kind == "highlights" else self.bookmarks
def _locked(fn):
@functools.wraps(fn)
def wrapper(self, *a, **kw):
with self._lock:
return fn(self, *a, **kw)
return wrapper
class AnnotationStore:
"""One JSON file per book key; shared by the reader (edits) and the sync workers."""
def __init__(self, directory: Path | None = None) -> None:
self.dir = directory or _DIR
self._lock = threading.RLock()
self._books: dict[str, BookAnnotations] = {}
def _file(self, key: str) -> Path:
return self.dir / f"{key}.json"
def _load(self, key: str) -> BookAnnotations:
if key in self._books:
return self._books[key]
ann = BookAnnotations()
try:
raw = json.loads(self._file(key).read_text(encoding="utf-8"))
ann.highlights = list(raw.get("highlights") or [])
ann.bookmarks = list(raw.get("bookmarks") or [])
ann.base = {k: raw.get("base", {}).get(k) for k in KINDS}
ann.dirty = {k: bool(raw.get("dirty", {}).get(k)) for k in KINDS}
except (OSError, ValueError, AttributeError):
pass
self._books[key] = ann
return ann
def _save(self, key: str) -> None:
ann = self._books[key]
self.dir.mkdir(parents=True, exist_ok=True)
tmp = self._file(key).with_suffix(".tmp")
tmp.write_text(json.dumps({"highlights": ann.highlights, "bookmarks": ann.bookmarks, "base": ann.base,
"dirty": ann.dirty}, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, self._file(key))
@_locked
def get(self, key: str) -> BookAnnotations:
"""A copy (the lists are copied, not the items: don't change those)."""
a = self._load(key)
return BookAnnotations(list(a.highlights), list(a.bookmarks),
{k: (list(v) if v is not None else None) for k, v in a.base.items()}, dict(a.dirty))
@_locked
def dirty_keys(self) -> list[str]:
keys = set(self._books)
if self.dir.exists():
keys |= {p.stem for p in self.dir.glob("*.json")}
return sorted(k for k in keys if any(self._load(k).dirty.values()))
# ---- edits (they make the kind dirty: queued for the server)
@_locked
def add(self, key: str, kind: str, item: dict) -> None:
ann = self._load(key)
if kind == "bookmarks":
ann.bookmarks.insert(0, item) # newest first, like the web
else:
ann.highlights.append(item)
ann.dirty[kind] = True
self._save(key)
@_locked
def delete(self, key: str, kind: str, item_id: str) -> bool:
ann = self._load(key)
items = ann.items(kind)
kept = [x for x in items if x.get("id") != item_id]
if len(kept) == len(items):
return False
items[:] = kept
ann.dirty[kind] = True
self._save(key)
return True
@_locked
def set_note(self, key: str, item_id: str, text: str) -> bool:
ann = self._load(key)
for i, h in enumerate(ann.highlights):
if h.get("id") == item_id:
if h.get("note") == text:
return False
ann.highlights[i] = {**h, "note": text}
ann.dirty["highlights"] = True
self._save(key)
return True
return False
# ---- the server
@_locked
def take_remote(self, key: str, kind: str, theirs: list[dict]) -> None:
"""The server's current list: ours becomes it, with our queued edits laid on top."""
ann = self._load(key)
if ann.dirty[kind]:
merged = merge(ann.base[kind], ann.items(kind), theirs, prepend_added=kind == "bookmarks")
ann.items(kind)[:] = merged
else:
ann.items(kind)[:] = theirs
ann.base[kind] = list(theirs)
self._save(key)
@_locked
def for_push(self, key: str, kind: str, theirs: list[dict]) -> tuple[list[dict], list[dict]]:
"""(what to write over the server's current list `theirs`, our list as it is now)."""
ann = self._load(key)
ours = list(ann.items(kind))
return merge(ann.base[kind], ours, theirs, prepend_added=kind == "bookmarks"), ours
@_locked
def pushed(self, key: str, kind: str, written: list[dict], snapshot: list[dict]) -> None:
"""The server holds `written`, made from our list `snapshot`. Ours becomes `written`;
what was edited while it was on its way stays queued on top."""
ann = self._load(key)
current = ann.items(kind)
ann.base[kind] = list(written)
if current == snapshot:
current[:] = written
ann.dirty[kind] = False
else:
current[:] = merge(snapshot, current, written, prepend_added=kind == "bookmarks")
ann.dirty[kind] = True
self._save(key)
_default: AnnotationStore | None = None
def default() -> AnnotationStore:
global _default
if _default is None:
_default = AnnotationStore()
return _default

View file

@ -1,48 +1,84 @@
"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for """HTTP client for the diora server, authenticated by the account's API token
local clients"). Status: that API is provisional — expect endpoint/field (Authorization: Bearer; the server sets request.user for every endpoint).
changes as the server-side implementation settles.
Only what the reader needs: the book list with the reading state (`GET /books/` – the same
rows the apps pull, without the notes' blobs that `/api/sync/` carries), a book's encrypted
file, and a position write (`POST /books/<id>/progress/`, docs/progress-sync.md).
""" """
from __future__ import annotations from __future__ import annotations
import time
import requests import requests
USER_AGENT = "diora-tui"
class ApiError(Exception): class ApiError(Exception):
pass pass
def _headers(token: str) -> dict[str, str]: def _headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT}
def fetch_sync_snapshot(server_url: str, token: str) -> dict: def _check(resp: requests.Response, what: str) -> None:
resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30)
if resp.status_code == 401: if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") raise ApiError("Anmeldung abgelehnt — Token ungültig oder neu erzeugt? `diora-tui login` ausführen.")
if resp.status_code == 404:
raise ApiError(f"{what} nicht gefunden (gelöscht oder anderes Konto?)")
resp.raise_for_status() resp.raise_for_status()
def fetch_books(server_url: str, token: str) -> tuple[list[dict], int | None, int]:
"""(books, the server's clock in ms from X-Server-Now or None, this machine's clock in ms when
the answer came). Each book: id, meta_ct, meta_iv, uploaded_at, is_read, has_cover and the
reading state (scroll_fraction, position_anchor, read_at, device, furthest_*)."""
resp = requests.get(f"{server_url}/books/", headers=_headers(token), timeout=60)
device_now = int(time.time() * 1000)
_check(resp, "Bücherliste")
try:
server_now = int(resp.headers.get("X-Server-Now", ""))
except ValueError:
server_now = None
return resp.json(), server_now, device_now
def fetch_prefs(server_url: str, token: str) -> dict:
"""The library's encrypted settings: {prefs_ct, prefs_iv}, both '' while there are none."""
resp = requests.get(f"{server_url}/books/prefs/", headers=_headers(token), timeout=30)
_check(resp, "Bibliothekseinstellungen")
return resp.json() return resp.json()
def fetch_book_data(server_url: str, token: str, book_id: int) -> dict: 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) resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=300)
if resp.status_code == 401: _check(resp, f"Buch {book_id}")
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() return resp.json()
def post_progress( def fetch_annotations(server_url: str, token: str, book_id: int, kind: str) -> dict:
server_url: str, token: str, book_id: int, *, scroll_fraction: float, position_anchor: str, force: bool = False """A book's encrypted highlights or bookmarks: {ct, iv} (both null while there are none)."""
) -> None: resp = requests.get(f"{server_url}/books/{book_id}/{kind}/", headers=_headers(token), timeout=60)
body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force} _check(resp, f"Buch {book_id}")
resp = requests.post( return resp.json()
f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30
)
if resp.status_code == 401: def post_annotations(server_url: str, token: str, book_id: int, kind: str, blob: dict) -> None:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") """Replace a book's highlights or bookmarks as a whole (the server doesn't merge)."""
if resp.status_code == 404: resp = requests.post(f"{server_url}/books/{book_id}/{kind}/", headers=_headers(token), json=blob, timeout=60)
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)") _check(resp, f"Buch {book_id}")
resp.raise_for_status()
def post_progress(server_url: str, token: str, book_id: int, *, anchor: str, fraction: float, read_at: int,
device: str, force: bool = False) -> dict:
"""Send a queued position. `sent_at` is the moment it leaves: the server corrects this
machine's clock error with it. The answer is the state after the write."""
body = {"scroll_fraction": fraction, "position_anchor": anchor, "read_at": read_at,
"sent_at": int(time.time() * 1000), "device": device}
if force:
body["force"] = True
resp = requests.post(f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30)
_check(resp, f"Buch {book_id}")
return resp.json()

View file

@ -1,113 +1,21 @@
"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress """diora-tui: terminal EPUB reader for diora, syncing books + reading position
against a diora server (see project README).""" against a diora server (see project README)."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import getpass
from pathlib import Path from pathlib import Path
from textual import work from textual.app import App
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 config as config_mod
from . import crypto, epub, library_meta, progress, remote from . import login as login_mod, remote
from .reader_screen import ReaderScreen from .library_screen import LibraryScreen
from .statusbar import StatusBar
DEFAULT_LIBRARY = Path.home() / "Books" 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): class DioraTuiApp(App):
CSS_PATH = "app.tcss" CSS_PATH = "app.tcss"
TITLE = "diora-tui" TITLE = "diora-tui"
@ -123,87 +31,45 @@ class DioraTuiApp(App):
cfg = config_mod.load() cfg = config_mod.load()
if cfg is not None: if cfg is not None:
try: try:
# Sends what is queued, then pulls: nothing read stays only here.
await asyncio.to_thread(remote.sync_library, self.library_dir, cfg) await asyncio.to_thread(remote.sync_library, self.library_dir, cfg)
except remote.SyncError: except remote.SyncError:
pass # best-effort — don't block quitting on a sync failure pass # best-effort — don't block quitting on a sync failure
self.exit() self.exit()
def _prompt(label: str, *, secret: bool = False) -> str: def run_login(server: str | None, *, paste_key: bool) -> None:
value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip() try:
if not value: cfg = login_mod.login(server, paste_key=paste_key)
raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.") except login_mod.LoginError as e:
return value raise SystemExit(f"Fehler: {e}")
config_mod.save(cfg)
print("Gespeichert (Token und Schlüssel geschützt). Weiter mit diora-tui oder diora-tui sync.")
def _ask_yes_no(question: str) -> bool: def run_sync(library_dir: Path) -> None:
return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja") cfg = config_mod.load()
if cfg is None:
raise SystemExit("Nicht angemeldet: erst diora-tui login")
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} …") print(f"Verbinde zu {cfg.server_url} …")
try: try:
result = remote.sync_library(library_dir, cfg) result = remote.sync_library(library_dir, cfg)
except remote.SyncError as e: except remote.SyncError as e:
raise SystemExit(f"Fehler: {e}") raise SystemExit(f"Fehler: {e}")
print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.") print(f"{result.books} EPUB(s) auf dem Server (geladen wird beim Öffnen)." )
for name in result.downloaded: if result.pushed:
print(f" + {name}") print(f"Lesestand von {result.pushed} Buch/Bücher an den Server geschickt.")
if result.unchanged: for title in result.new_titles:
print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.") print(f" + {title}")
if result.skipped_pdf: if result.skipped_pdf:
titles = ", ".join(result.skipped_pdf) print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest nur EPUB): {', '.join(result.skipped_pdf)}")
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}")
if result.failed: if result.failed:
titles = ", ".join(result.failed) print(f"{len(result.failed)} Buch/Bücher nicht entschlüsselbar (falscher Schlüssel?): {', '.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: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="diora-tui — EPUB-Reader im Terminal, synct mit diora")
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
)
parser.add_argument( parser.add_argument(
"--library", "--library",
type=Path, type=Path,
@ -211,17 +77,23 @@ def main() -> None:
help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})", help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})",
) )
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln") login_parser = subparsers.add_parser("login", help="Einmal anmelden (Token + Schlüssel, geschützt gespeichert)")
sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf") login_parser.add_argument("--server", help=f"Server-URL (Standard: {login_mod.DEFAULT_SERVER})")
sync_parser.add_argument( login_parser.add_argument("--paste-key", action="store_true", help="Buchschlüssel einfügen statt aus dem Passwort ableiten")
"--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen" subparsers.add_parser("logout", help="Gespeicherte Zugangsdaten löschen")
) subparsers.add_parser("sync", help="Bücherliste und Lesestand abgleichen (ohne die Oberfläche)")
args = parser.parse_args() args = parser.parse_args()
library_dir = args.library.expanduser() library_dir = args.library.expanduser()
if args.command == "login":
run_login(args.server, paste_key=args.paste_key)
return
if args.command == "logout":
print("Zugangsdaten gelöscht." if config_mod.forget() else "Keine Zugangsdaten gespeichert.")
return
if args.command == "sync": if args.command == "sync":
run_sync(library_dir, args.server, save=args.save) run_sync(library_dir)
return return
DioraTuiApp(library_dir).run() DioraTuiApp(library_dir).run()

View file

@ -2,6 +2,46 @@
height: 1fr; height: 1fr;
} }
#lib-chips {
height: auto;
padding: 0 1;
}
#lib-search {
margin: 0 1;
}
#lib-card {
height: auto;
margin: 1 1 0 1;
padding: 0 2;
border: round $primary;
}
#lib-note {
height: auto;
padding: 0 2;
color: $text-muted;
}
ChoiceScreen {
align: center middle;
}
#choice-title {
width: 50;
background: $panel;
padding: 0 2;
text-style: bold;
}
#choice-list {
width: 50;
height: auto;
max-height: 70%;
border: round $primary;
}
StatusBar { StatusBar {
dock: bottom; dock: bottom;
height: 1; height: 1;
@ -47,3 +87,75 @@ FootnoteScreen {
color: $text-muted; color: $text-muted;
padding: 0 2; padding: 0 2;
} }
JumpOfferScreen {
align: center middle;
}
#offer-body {
width: 60;
height: auto;
background: $panel;
border: round $primary;
padding: 1 2;
}
#offer-hint {
margin-top: 1;
color: $text-muted;
}
NoteScreen {
align: center middle;
}
#note-body {
width: 70;
height: auto;
background: $panel;
border: round $primary;
padding: 1 2;
}
#note-title {
text-style: bold;
margin-bottom: 1;
}
#note-hint {
color: $text-muted;
margin-top: 1;
}
AnnotationsScreen {
align: center middle;
}
#ann-body {
width: 90%;
max-width: 110;
height: 80%;
background: $panel;
border: round $primary;
padding: 1 2;
}
#ann-title {
text-style: bold;
}
#ann-list {
height: 1fr;
}
#ann-detail {
height: auto;
max-height: 6;
color: $text-muted;
margin-top: 1;
}
#ann-hint {
color: $text-muted;
margin-top: 1;
}

View file

@ -25,15 +25,17 @@ from pathlib import Path
import ebooklib import ebooklib
from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning
from bs4.element import PreformattedString
from ebooklib import epub from ebooklib import epub
# EPUB content documents are XHTML; treating them as HTML (matching app.js's # EPUB content documents are XHTML; treating them as HTML (matching app.js's
# `new DOMParser().parseFromString(html, 'text/html')`, static/js/app.js:2638) # `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. # 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 # html5lib builds the tree with the HTML5 algorithm, as the browser's DOMParser the web reader
# matters here: some real-world EPUBs run to tens of thousands of blocks. # uses does: same nesting for broken markup (`<h1/>` opens a block) and the same text nodes
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) # (whitespace included), which the block indexes and the character offsets depend on. lxml's
_PARSER = "lxml" # HTML parser (libxml2) trims indentation and closes elements differently.
_PARSER = "html5lib"
# Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, # Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li,
# blockquote, dt, dd, figcaption, div:not(:has(*))' # blockquote, dt, dd, figcaption, div:not(:has(*))'
@ -43,6 +45,9 @@ _BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt"
_STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"] _STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"]
# Matches app.js's _looksLikeFootnoteLink's class-name check. # Matches app.js's _looksLikeFootnoteLink's class-name check.
_FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b") _FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b")
# JavaScript's `\s`, not Python's (which also matches \x1c-\x1f and \x85 but not ): a
# block's text is cut and measured the way the web reader does.
_JS_SPACE_RE = re.compile("[ \t\n\v\f\r   - 

   ]+")
# Anchor format the server accepts (books/views.py:save_progress); anything else # Anchor format the server accepts (books/views.py:save_progress); anything else
# is silently discarded back to ''. # is silently discarded back to ''.
@ -70,6 +75,12 @@ class Block:
# walk-up in app.js), so a target id resolves to "the block containing # 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. # it" rather than requiring the id to sit on the block tag itself.
ids: list[str] = field(default_factory=list) ids: list[str] = field(default_factory=list)
# Where the block sits in the book's text, counted in characters the way the web reader
# counts them (44-books.js _bookCharIndex): char_start = offset of the block's first text
# node among all text nodes of the book, char_len = its textContent length (nested blocks
# included). Progress as a share of the text (position -> fraction) is computed from these.
char_start: int = 0
char_len: int = 0
@dataclass @dataclass
@ -82,6 +93,10 @@ class FlatBook:
chapter_titles: list[str] chapter_titles: list[str]
chapter_start_block: list[int] # blocks[chapter_start_block[i]] is chapter i's first block 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 footnote_targets: dict[str, int] # fragment id -> block index of its content
total_chars: int = 1 # all text nodes of the book (never 0: it divides)
# chapter i's file as the web reader names it (data-epub-src: the OPF's folder + the manifest
# href): what highlights and bookmarks store as chapterSrc / anchor
chapter_srcs: list[str] = field(default_factory=list)
def _is_leaf_div(tag: Tag) -> bool: def _is_leaf_div(tag: Tag) -> bool:
@ -102,13 +117,15 @@ def _looks_like_footnote_link(el: Tag, href: str) -> bool:
def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None: def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
for child in el.children: for child in el.children:
if isinstance(child, PreformattedString):
continue # comments, CDATA, declarations: not text (textContent skips them)
if isinstance(child, NavigableString): if isinstance(child, NavigableString):
out.append(str(child)) out.append(str(child))
elif isinstance(child, Tag): elif isinstance(child, Tag):
if child.name == "a": if child.name == "a":
href = child.get("href") or "" href = child.get("href") or ""
if _looks_like_footnote_link(child, href): if _looks_like_footnote_link(child, href):
marker = child.get_text(" ", strip=True) marker = _JS_SPACE_RE.sub(" ", child.get_text("")).strip(" ")
offset = len("".join(out)) offset = len("".join(out))
target_id = href.split("#", 1)[1] if "#" in href else "" target_id = href.split("#", 1)[1] if "#" in href else ""
footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id)) footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id))
@ -117,31 +134,62 @@ def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
_walk_text(child, footnotes, out) _walk_text(child, footnotes, out)
def _extract_blocks(html: bytes, chapter_index: int) -> list[Block]: def _text_nodes(el: Tag):
soup = BeautifulSoup(html, _PARSER) for node in el.descendants:
if isinstance(node, NavigableString) and not isinstance(node, PreformattedString):
yield node
def _extract_blocks(html: bytes, chapter_index: int) -> tuple[list[Block], int]:
"""The chapter's blocks (char_start relative to the chapter) and its text length in chars."""
# Decoded as UTF-8 first, as the web reader reads the file (JSZip 'text'); handing html5lib
# bytes would have it guess an encoding.
soup = BeautifulSoup(html.decode("utf-8", "replace"), _PARSER)
for name in _STRIP_TAGS: for name in _STRIP_TAGS:
for el in soup.find_all(name): for el in soup.find_all(name):
el.decompose() el.decompose()
root = soup.body or soup
blocks: list[Block] = [] blocks: list[Block] = []
for el in soup.find_all(True): offset = 0
if el.name in _BLOCK_TAGS or _is_leaf_div(el): for node in root.descendants:
footnotes: list[FootnoteRef] = [] if isinstance(node, Tag):
parts: list[str] = [] if node.name in _BLOCK_TAGS or _is_leaf_div(node):
_walk_text(el, footnotes, parts) blocks.append(_make_block(node, chapter_index, offset))
text = re.sub(r"\s+", " ", "".join(parts)).strip() elif isinstance(node, NavigableString) and not isinstance(node, PreformattedString):
ids = [tag_id for tag_id in (el.get("id"), *(d.get("id") for d in el.find_all(True))) if tag_id] offset += len(node)
blocks.append( return blocks, offset
Block(
text=text,
tag=el.name, def _make_block(el: Tag, chapter_index: int, char_start: int) -> Block:
chapter_index=chapter_index, footnotes: list[FootnoteRef] = []
id=el.get("id"), parts: list[str] = []
footnotes=footnotes, _walk_text(el, footnotes, parts)
ids=ids, text = _JS_SPACE_RE.sub(" ", "".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]
) return Block(
return blocks text=text,
tag=el.name,
chapter_index=chapter_index,
id=el.get("id"),
footnotes=footnotes,
ids=ids,
char_start=char_start,
char_len=sum(len(n) for n in _text_nodes(el)),
)
def _opf_dir(path: Path) -> str:
"""The folder of the book's OPF inside the zip, with a trailing slash ('' at the root)."""
import re
import zipfile
try:
with zipfile.ZipFile(path) as z:
m = re.search(r'full-path="([^"]+)"', z.read("META-INF/container.xml").decode("utf-8", "replace"))
except (OSError, KeyError, zipfile.BadZipFile):
return ""
return m.group(1).rsplit("/", 1)[0] + "/" if m and "/" in m.group(1) else ""
def load_flat_book(path: Path) -> FlatBook: def load_flat_book(path: Path) -> FlatBook:
@ -157,6 +205,9 @@ def load_flat_book(path: Path) -> FlatBook:
blocks: list[Block] = [] blocks: list[Block] = []
chapter_titles: list[str] = [] chapter_titles: list[str] = []
chapter_start_block: list[int] = [] chapter_start_block: list[int] = []
chapter_srcs: list[str] = []
opf_dir = _opf_dir(path)
total_chars = 0
# app.js's parseEpub() includes every spine itemref unconditionally — no # app.js's parseEpub() includes every spine itemref unconditionally — no
# `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote # `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote
@ -168,16 +219,22 @@ def load_flat_book(path: Path) -> FlatBook:
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT: if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
continue continue
chapter_index = len(chapter_titles) chapter_index = len(chapter_titles)
chapter_blocks = _extract_blocks(item.get_content(), chapter_index) chapter_blocks, chapter_chars = _extract_blocks(item.content, chapter_index) # raw bytes: EpubHtml.get_content() pretty-prints, adding whitespace
for b in chapter_blocks:
b.char_start += total_chars
total_chars += chapter_chars
chapter_start_block.append(len(blocks)) chapter_start_block.append(len(blocks))
first_text = next((b.text for b in chapter_blocks if b.text), None) first_text = next((b.text for b in chapter_blocks if b.text), None)
chapter_titles.append((first_text or item.get_name())[:60]) chapter_titles.append((first_text or item.get_name())[:60])
chapter_srcs.append(opf_dir + item.file_name)
blocks.extend(chapter_blocks) blocks.extend(chapter_blocks)
footnote_targets: dict[str, int] = {} footnote_targets: dict[str, int] = {}
for idx, b in enumerate(blocks): for idx, b in enumerate(blocks):
for tag_id in b.ids: for tag_id in b.ids:
footnote_targets.setdefault(tag_id, idx) footnote_targets.setdefault(tag_id, idx)
if not b.char_len: # no text of its own: where the previous block ends (0 for the first), as the web reader
b.char_start = blocks[idx - 1].char_start + blocks[idx - 1].char_len if idx else 0
return FlatBook( return FlatBook(
id=_book_id(path), id=_book_id(path),
@ -188,9 +245,20 @@ def load_flat_book(path: Path) -> FlatBook:
chapter_titles=chapter_titles, chapter_titles=chapter_titles,
chapter_start_block=chapter_start_block, chapter_start_block=chapter_start_block,
footnote_targets=footnote_targets, footnote_targets=footnote_targets,
total_chars=total_chars or 1,
chapter_srcs=chapter_srcs,
) )
def fraction_for_anchor(book: FlatBook, block_index: int, inner_fraction: float) -> float:
"""The share of the book's text before the anchored place (0..1), as the web reader's
_fractionFromAnchor: what other clients show as progress."""
if not 0 <= block_index < len(book.blocks):
return 0.0
b = book.blocks[block_index]
return max(0.0, min(1.0, (b.char_start + inner_fraction * b.char_len) / book.total_chars))
def format_anchor(block_index: int, inner_fraction: float) -> str: def format_anchor(block_index: int, inner_fraction: float) -> str:
inner_fraction = max(0.0, min(1.0, inner_fraction)) inner_fraction = max(0.0, min(1.0, inner_fraction))
return f"{block_index}:{inner_fraction:.6f}" return f"{block_index}:{inner_fraction:.6f}"
@ -212,3 +280,75 @@ def anchor_is_further(new_anchor: str, old_anchor: str) -> bool:
nb, ni = new_parts nb, ni = new_parts
ob, oi = old_parts ob, oi = old_parts
return ni >= oi if nb == ob else nb >= ob return ni >= oi if nb == ob else nb >= ob
def book_pages(path: Path) -> int:
"""The book's length in printed pages from its text alone (bookPagesOfFile, 40-epub.js): every
spine document's body text by 1800 characters a page. Cheaper than load_flat_book: no blocks."""
from .library import pages_of_chars
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
chars = 0
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
soup = BeautifulSoup(item.content.decode("utf-8", "replace"), _PARSER)
for name in ("script", "style"):
for el in soup.find_all(name):
el.decompose()
chars += sum(len(n) for n in _text_nodes(soup.body or soup))
return pages_of_chars(chars)
# ---- raw text of blocks (for highlights: the web stores character offsets into a block's raw text)
def raw_block_texts(path: Path, chapter_index: int) -> list[str]:
"""The raw text (all text nodes, whitespace as in the file) of every block of one spine
document, in block order: what the web's _charOffsetInBlock counts in. `chapter_index` is the
index load_flat_book gave the document."""
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
seen = -1
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
seen += 1
if seen != chapter_index:
continue
soup = BeautifulSoup(item.content.decode("utf-8", "replace"), _PARSER)
for name in _STRIP_TAGS:
for el in soup.find_all(name):
el.decompose()
root = soup.body or soup
return ["".join(str(n) for n in _text_nodes(el)) for el in root.descendants
if isinstance(el, Tag) and (el.name in _BLOCK_TAGS or _is_leaf_div(el))]
return []
def collapse_map(raw: str) -> tuple[list[int], list[int], int]:
"""How a block's raw text becomes its displayed text (whitespace runs to one space, the ends
cut). Returns (norm_of_raw, raw_of_norm, length):
norm_of_raw[i] = offset in the displayed text of raw offset i (0..len(raw));
raw_of_norm[k] = raw offset of the character that is displayed character k."""
norm_of_raw: list[int] = []
raw_of_norm: list[int] = []
n = 0
started = False
prev_space = False
for i, ch in enumerate(raw):
norm_of_raw.append(n)
if _JS_SPACE_RE.fullmatch(ch):
if started and not prev_space:
raw_of_norm.append(i)
n += 1
prev_space = True
else:
started, prev_space = True, False
raw_of_norm.append(i)
n += 1
norm_of_raw.append(n)
length = n - 1 if prev_space and started else (n if started else 0)
if prev_space and started:
raw_of_norm.pop()
return [min(x, length) for x in norm_of_raw], raw_of_norm[:length], length

View file

@ -7,6 +7,9 @@ paint time, only with viewport height.
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
from typing import Callable
from textual.geometry import Size from textual.geometry import Size
from textual.scroll_view import ScrollView from textual.scroll_view import ScrollView
from textual.strip import Strip from textual.strip import Strip
@ -27,10 +30,28 @@ class ContinuousBookView(ScrollView):
} }
""" """
def __init__(self, book_layout: BookLayout) -> None: def __init__(self, book_layout: BookLayout, on_read_scroll: Callable[[], None] | None = None) -> None:
super().__init__() super().__init__()
self.book_layout = book_layout self.book_layout = book_layout
self.virtual_size = Size(book_layout.width, book_layout.total_rows) self.virtual_size = Size(book_layout.width, book_layout.total_rows)
# Called when the reader moves the view (keys, wheel, scrollbar), not when the program does
# (restoring a position, re-wrapping after a resize, jumping on an offer): only the first
# is "arriving at a place by reading" for the reading position (docs/progress-sync.md).
self.on_read_scroll = on_read_scroll
self._program_scrolls = 0
@contextmanager
def program_scroll(self):
self._program_scrolls += 1
try:
yield
finally:
self._program_scrolls -= 1
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
super().watch_scroll_y(old_value, new_value)
if old_value != new_value and not self._program_scrolls and self.on_read_scroll is not None:
self.on_read_scroll()
def render_line(self, y: int) -> Strip: def render_line(self, y: int) -> Strip:
_scroll_x, scroll_y = self.scroll_offset _scroll_x, scroll_y = self.scroll_offset

View file

@ -14,17 +14,17 @@ import hashlib
import pickle import pickle
from pathlib import Path from pathlib import Path
from platformdirs import user_cache_dir from . import paths
from .blocks import FlatBook from .blocks import FlatBook
from .layout import BookLayout from .layout import BookLayout
_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache" _CACHE_VERSION = 3 # 2: blocks carry char_start/char_len; 3: chapter_srcs
_CACHE_DIR = paths.cache_dir() / "layout_cache"
def _cache_key(path: Path, width: int) -> str: def _cache_key(path: Path, width: int) -> str:
stat = path.stat() stat = path.stat()
raw = f"{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}" raw = f"v{_CACHE_VERSION}|{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}"
return hashlib.sha256(raw.encode()).hexdigest()[:32] return hashlib.sha256(raw.encode()).hexdigest()[:32]

View file

@ -1,21 +1,20 @@
"""Local storage for diora server credentials used by `diora-tui sync`. """The account the TUI syncs with: server, API token and the book key.
Mirrors the trust model of the web client's key storage (static/js/app.js, Token and key are stored protected (secretstore.py), the server and user name in the clear.
getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept A config from before 2026-10 (token and key as plain JSON fields) is still read and is
in plaintext on disk, scoped to this machine/user (0600), with no additional rewritten protected at once.
at-rest encryption — same exposure as the browser's localStorage already has.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import stat import stat
from dataclasses import asdict, dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from platformdirs import user_config_dir from . import paths, secretstore
_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora")) _CONFIG_DIR = paths.config_dir()
_CONFIG_FILE = _CONFIG_DIR / "config.json" _CONFIG_FILE = _CONFIG_DIR / "config.json"
@ -24,19 +23,42 @@ class RemoteConfig:
server_url: str server_url: str
api_token: str api_token: str
enc_key_b64: str enc_key_b64: str
username: str = ""
def load() -> RemoteConfig | None: def load() -> RemoteConfig | None:
if not _CONFIG_FILE.exists():
return None
try: try:
data = json.loads(_CONFIG_FILE.read_text()) data = json.loads(_CONFIG_FILE.read_text())
return RemoteConfig(**data) except (OSError, ValueError):
except (json.JSONDecodeError, OSError, TypeError):
return None return None
if not isinstance(data, dict):
return None
try:
if "secret" in data:
secret = json.loads(secretstore.unprotect(data["secret"]))
return RemoteConfig(data["server_url"], secret["token"], secret["key"], data.get("username", ""))
cfg = RemoteConfig(data["server_url"], data["api_token"], data["enc_key_b64"], data.get("username", ""))
except (KeyError, ValueError, OSError):
return None
save(cfg) # a legacy plaintext config: protect it now
return cfg
def save(cfg: RemoteConfig) -> None: def save(cfg: RemoteConfig) -> None:
_CONFIG_DIR.mkdir(parents=True, exist_ok=True) _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2)) secret = json.dumps({"token": cfg.api_token, "key": cfg.enc_key_b64}).encode()
_CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) _CONFIG_FILE.write_text(json.dumps({
"server_url": cfg.server_url, "username": cfg.username, "secret": secretstore.protect(secret),
}, indent=2))
try:
_CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
def forget() -> bool:
try:
_CONFIG_FILE.unlink()
return True
except OSError:
return False

View file

@ -8,6 +8,7 @@ supplies out-of-band.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import os
from cryptography.exceptions import InvalidTag from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
@ -32,6 +33,13 @@ def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes:
raise DecryptError(str(e)) from e raise DecryptError(str(e)) from e
def encrypt(key_b64: str, plaintext: bytes) -> tuple[str, str]:
"""(iv hex, ciphertext base64): what the web client's encryptBytes sends as iv/ct."""
iv = os.urandom(12)
ct = AESGCM(base64.b64decode(key_b64)).encrypt(iv, plaintext, None)
return iv.hex(), base64.b64encode(ct).decode()
def derive_key_b64(username: str, password: str) -> str: def derive_key_b64(username: str, password: str) -> str:
"""Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does: """Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does:
PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only

View file

@ -1,15 +1,31 @@
"""Library scanning and the stable per-book id shared across progress.py, """Library scanning and the stable per-book key shared across progress.py, blocks.py and
blocks.py, and remote.py.""" remote.py."""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import re
from pathlib import Path from pathlib import Path
# Books downloaded from the server are named "<server id, 4+ digits> - <title>.epub".
_SERVER_FILE_RE = re.compile(r"^(\d{4,7}) - ")
def server_id_of(path: Path) -> int | None:
m = _SERVER_FILE_RE.match(path.name)
return int(m.group(1)) if m else None
def server_key(server_id: int) -> str:
return f"s{server_id}"
def _book_id(path: Path) -> str: def _book_id(path: Path) -> str:
# Identifies a book by its resolved path for now. Once the sync API exists, """A book's key for reading state: the server's id for a downloaded book (the same whether
# this should switch to whatever stable id diora assigns server-side. or not, and under whatever name, the file is there), else a hash of its path."""
sid = server_id_of(path)
if sid is not None:
return server_key(sid)
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16] return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]

View file

@ -31,6 +31,28 @@ class BookLayout:
width: int width: int
def render_block(text: str, width: int, console: Console, spans=()) -> list[Strip]:
"""One block wrapped to `width`, a Strip per row. `spans`: (start, end, style) over the text
(highlights, a selection); styling never changes how the text wraps."""
t = Text(text)
for start, end, style in spans:
t.stylize(style, start, end)
lines = list(t.wrap(console, width)) if text else []
if not lines:
lines = [Text("")]
return [Strip(line.render(console), None).adjust_cell_length(width) for line in lines]
def restyle_block(book_layout: "BookLayout", index: int, text: str, console: Console, spans=()) -> bool:
"""Draw block `index` again with `spans`, in place (its rows stay where they are)."""
strips = render_block(text, book_layout.width, console, spans)
if len(strips) != book_layout.heights[index]:
return False
start = book_layout.starts[index]
book_layout.row_strips[start:start + len(strips)] = strips
return True
def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout: def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout:
width = max(1, width) width = max(1, width)
starts: list[int] = [] starts: list[int] = []
@ -40,15 +62,11 @@ def build_layout(block_texts: list[str], width: int, console: Console) -> BookLa
row = 0 row = 0
for text in block_texts: for text in block_texts:
starts.append(row) starts.append(row)
wrapped_lines = list(Text(text).wrap(console, width)) if text else [] strips = render_block(text, width, console)
if not wrapped_lines: heights.append(len(strips))
wrapped_lines = [Text("")] row_strips.extend(strips)
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_strips.append(blank)
row += len(wrapped_lines) + _SEPARATOR_ROWS row += len(strips) + _SEPARATOR_ROWS
return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width) return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width)

210
tui/diora_tui/library.py Normal file
View file

@ -0,0 +1,210 @@
"""The library list: what the web library shows (docs/library.md, static/js/app/53-library.js)
without drawing it — rows, the status and folder chips, search, sort, the "Weiterlesen" card.
Pure functions on plain rows, so the screen stays thin and the rules can be tested. Where the
web has a rule, this follows it (names in the comments are the web's).
"""
from __future__ import annotations
import re
import time
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
SORTS = [
("recent", "Zuletzt"),
("title", "Titel"),
("author", "Autor"),
("added", "Neu hinzugefügt"),
("short", "Kürzeste zuerst"),
("long", "Längste zuerst"),
]
STATUSES = [("all", "Alle"), ("started", "Angefangen"), ("unread", "Ungelesen"), ("read", "Gelesen")]
_MAGAZINE_FOLDER = re.compile(r"^(zeitschriften|magazine|magazines)$", re.I) # _MAGAZINE_FOLDER
CHARS_PER_BOOK_PAGE = 1800 # the reader's own estimate (44-books.js)
RECENT_POOL = 7 # _recentlyReadBooks: the 7 read last
RECENT_ROW = 6 # _libRecentBooks: the others shown beside the large one
@dataclass
class Row:
key: str
title: str
author: str = ""
path: Path | None = None # the file, if it is here
server_id: int | None = None
is_read: bool = False
uploaded_at: str = ""
folders: list[str] = field(default_factory=list)
pages: int = 0 # from the encrypted meta, else worked out here
magazine: bool | None = None # meta.magazine; None: decided by the folders
read_at: int = 0 # ms, server clock; 0: never opened
fraction: float = 0.0
device: str = ""
def is_magazine(r: Row) -> bool:
return r.magazine is True or (r.magazine is not False and any(_MAGAZINE_FOLDER.match(f.strip()) for f in r.folders))
def folders_of_meta(meta: dict) -> list[str]:
"""_foldersOfMeta: `folders`, else the older single `folder`."""
f = meta.get("folders")
if isinstance(f, list):
return [x for x in f if isinstance(x, str) and x.strip()]
return [meta["folder"]] if meta.get("folder") else []
def pages_of_chars(chars: int) -> int:
"""An EPUB's length in printed pages from its text (bookPagesOfFile): 1800 characters a page."""
return max(1, int(chars / CHARS_PER_BOOK_PAGE + 0.5)) if chars else 0
def build_rows(files: list[Path], remote: dict[str, dict], states: dict, local_pages: dict[str, int], key_of) -> list[Row]:
"""One row per book: each file in the folder, and each server book not downloaded yet.
`states` maps a key to its reading state (progress.BookState), `key_of(path)` is the book key."""
rows: dict[str, Row] = {}
def fill(row: Row, info: dict) -> Row:
row.title = info.get("title") or row.title
row.author = info.get("author") or ""
row.server_id = info.get("server_id")
row.is_read = bool(info.get("is_read"))
row.uploaded_at = info.get("uploaded_at") or ""
row.folders = list(info.get("folders") or [])
row.pages = int(info.get("pages") or 0)
row.magazine = info.get("magazine")
return row
for path in files:
key = key_of(path)
rows[key] = fill(Row(key, path.stem, path=path), remote.get(key) or {})
for key, info in remote.items():
if key not in rows:
rows[key] = fill(Row(key, "?"), info)
for key, r in rows.items():
st = states.get(key)
if st is not None:
r.read_at = st.read_at or 0
r.fraction = st.fraction
r.device = st.device
if not r.pages:
r.pages = int(local_pages.get(key) or 0)
return list(rows.values())
# ---- the filters
def started(r: Row) -> bool:
return not r.is_read and r.fraction > 0
def unread(r: Row) -> bool:
return not r.is_read and not r.fraction > 0
def norm(s: str) -> str:
"""_libNorm: without accents, lower case."""
return "".join(c for c in unicodedata.normalize("NFD", s or "") if not unicodedata.combining(c)).lower()
def collate(s: str) -> list:
"""German-ish order with numbers by value (localeCompare('de', {numeric: true})): accents
don't count at first, the plain text breaks ties."""
parts = re.split(r"(\d+)", norm(s))
return [(0, int(p)) if p.isdigit() else (1, p) for p in parts if p != ""] + [(2, s or "")]
def section(rows: list[Row], magazines: bool) -> list[Row]:
"""_bookSectionFilter: Bücher or Zeitschriften."""
return [r for r in rows if is_magazine(r) == magazines]
def folder_list(rows: list[Row], order: list[str] | None) -> list[tuple[str, int]]:
"""_libFolderList: every folder with its book count, in the user's order, the rest by name."""
counts: dict[str, int] = {}
for r in rows:
for f in r.folders:
counts[f] = counts.get(f, 0) + 1
order = order or []
return sorted(((n, c) for n, c in counts.items()),
key=lambda nc: (order.index(nc[0]) if nc[0] in order else 10**6, collate(nc[0])))
def status_counts(rows: list[Row]) -> dict[str, int]:
return {"all": len(rows), "started": sum(map(started, rows)), "unread": sum(map(unread, rows)),
"read": sum(r.is_read for r in rows)}
def shown(rows: list[Row], *, q: str = "", status: str = "all", folder: str | None = None, sort: str = "recent") -> list[Row]:
"""_libShown. `folder`: None all, '' without a folder, else a folder's name."""
needle = norm(q).strip()
def keep(r: Row) -> bool:
if status == "started" and not started(r):
return False
if status == "unread" and not unread(r):
return False
if status == "read" and not r.is_read:
return False
if folder is not None and (bool(r.folders) if folder == "" else folder not in r.folders):
return False
return not needle or needle in norm(r.title + " " + r.author)
by_title = lambda r: collate(r.title) # noqa: E731
out = [r for r in rows if keep(r)]
if sort == "title":
out.sort(key=by_title)
elif sort == "author":
out.sort(key=lambda r: (collate(r.author or "~"), by_title(r)))
elif sort == "added":
out.sort(key=lambda r: r.uploaded_at, reverse=True)
elif sort == "short": # a book whose length isn't known comes last
out.sort(key=lambda r: (r.pages or float("inf"), by_title(r)))
elif sort == "long":
out.sort(key=lambda r: (-(r.pages or 0), by_title(r)))
else: # recent: read last first, then newest upload
out.sort(key=lambda r: (r.read_at, r.uploaded_at), reverse=True)
return out
def continue_rows(rows: list[Row]) -> tuple[Row | None, list[Row]]:
"""The "Weiterlesen" card: of the books read last, the first not finished, and the others
of those read lately beside it (_libContinueBook, _libRecentBooks)."""
pool = sorted((r for r in rows if r.read_at), key=lambda r: r.read_at, reverse=True)[:RECENT_POOL]
going = next((r for r in pool if not r.is_read), None)
if going is None:
return None, []
return going, [r for r in pool if r is not going][:RECENT_ROW]
def ago(ms: int, now_ms: int | None = None) -> str:
"""_libAgo: "vor 5 Minuten", "gestern", "am 4. Okt."."""
if not ms:
return ""
now = now_ms if now_ms is not None else int(time.time() * 1000)
minutes = round((now - ms) / 60000)
if minutes < 2:
return "gerade eben"
if minutes < 60:
return f"vor {minutes} Minuten"
day = lambda t: time.strftime("%Y-%m-%d", time.localtime(t / 1000)) # noqa: E731
hours = round(minutes / 60)
if day(ms) == day(now):
return "vor einer Stunde" if hours == 1 else f"vor {hours} Stunden"
if day(ms) == day(now - 86400000):
return "gestern"
days = round((now - ms) / 86400000)
if days < 7:
return f"vor {days} Tagen"
months = ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."]
t = time.localtime(ms / 1000)
return f"am {t.tm_mday}. {months[t.tm_mon - 1]}"
def percent(r: Row) -> int:
return 100 if r.is_read else int(r.fraction * 100 + 0.5) # Math.round

View file

@ -1,43 +1,75 @@
"""Local per-book metadata pulled from the server — currently just "read" """What the TUI knows about the server's books beyond the files: title, author, read status
status (EBook.is_read from the /api/sync/ snapshot). Kept separate from and which books exist at all (so a book can be listed before it is downloaded). Set only by
progress.py (reading position): different concern, different cadence — this `sync`; the reader's position is progress.py.
is only ever set by `diora-tui sync`, never by the reader itself.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path from pathlib import Path
from platformdirs import user_data_dir from . import paths
_DATA_DIR = Path(user_data_dir("diora-tui", "diora")) _DATA_DIR = paths.data_dir()
_META_FILE = _DATA_DIR / "library.json" _META_FILE = _DATA_DIR / "library.json"
def _load_all() -> dict[str, dict]: def _load_all() -> dict[str, dict]:
if not _META_FILE.exists():
return {}
try: try:
return json.loads(_META_FILE.read_text()) data = json.loads(_META_FILE.read_text())
except (json.JSONDecodeError, OSError): except (OSError, ValueError):
return {} return {}
return data if isinstance(data, dict) else {}
def _save_all(data: dict[str, dict]) -> None: def _save_all(data: dict[str, dict]) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True) _DATA_DIR.mkdir(parents=True, exist_ok=True)
_META_FILE.write_text(json.dumps(data, indent=2)) tmp = _META_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=1))
os.replace(tmp, _META_FILE)
def is_read(book_id: str) -> bool: def is_read(book_key: str) -> bool:
entry = _load_all().get(book_id) entry = _load_all().get(book_key)
return bool(entry and entry.get("is_read")) return bool(entry and entry.get("is_read"))
def set_many(read_status: dict[str, bool]) -> None: def get(book_key: str) -> dict | None:
if not read_status: return _load_all().get(book_key)
return
def get_extra(name: str, default=None):
"""A setting kept beside the books (stored under '_<name>'): the folder order, the sort."""
entry = _load_all().get("_" + name)
return entry["value"] if isinstance(entry, dict) and "value" in entry else default
def set_extra(name: str, value) -> None:
data = _load_all() data = _load_all()
for book_id, read in read_status.items(): data["_" + name] = {"value": value}
data.setdefault(book_id, {})["is_read"] = read _save_all(data)
def set_local_pages(book_key: str, pages: int) -> None:
"""A book's length worked out from the file here (the server's meta may not have it)."""
data = _load_all()
entry = data.setdefault("_pages", {"value": {}})
entry.setdefault("value", {})[book_key] = int(pages)
_save_all(data)
def local_pages() -> dict[str, int]:
return dict(get_extra("pages", {}) or {})
def remote_entries() -> dict[str, dict]:
"""Every server book: key -> {server_id, title, author, is_read, uploaded_at}."""
return {k: v for k, v in _load_all().items() if isinstance(v, dict) and v.get("server_id") is not None}
def set_remote(entries: dict[str, dict]) -> None:
"""Replace the server books with `entries` (a book deleted on the server drops out)."""
data = {k: v for k, v in _load_all().items() if not (isinstance(v, dict) and v.get("server_id") is not None)}
data.update(entries)
_save_all(data) _save_all(data)

View file

@ -0,0 +1,388 @@
"""The library screen: the web library (docs/library.md) in a terminal — "Weiterlesen" card,
status and folder chips, search, sort, Bücher/Zeitschriften. The rules are in library.py.
Keys: 1–4 status (Alle/Angefangen/Ungelesen/Gelesen), o folder, / search, s sort, m Bücher or
Zeitschriften, c continue the book read last, Enter open. The chips and the card also answer
the mouse.
"""
from __future__ import annotations
from pathlib import Path
from rich.text import Text
from textual import work
from textual.binding import Binding
from textual.screen import ModalScreen, Screen
from textual.markup import escape
from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, OptionList, Static
from . import blocks, config as config_mod, epub, library, library_meta, progress, remote
from .reader_screen import ReaderScreen
from .statusbar import StatusBar
PAGES_NOTE_EVERY = 4 # seconds between redraws while lengths are worked out
class ChoiceScreen(ModalScreen[int]):
"""Pick one of a few options; dismisses with its index, or None on Escape."""
BINDINGS = [Binding("escape,q", "cancel", "Abbrechen")]
def __init__(self, title: str, options: list[str], current: int = 0) -> None:
super().__init__()
self._title = title
self._options = options
self._current = current
def compose(self):
yield Label(self._title, id="choice-title")
yield OptionList(*[Text(o) for o in self._options], id="choice-list")
def on_mount(self) -> None:
ol = self.query_one(OptionList)
ol.highlighted = max(0, min(self._current, len(self._options) - 1))
ol.focus()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
self.dismiss(event.option_index)
def action_cancel(self) -> None:
self.dismiss(None)
class LibraryScreen(Screen):
BINDINGS = [
Binding("enter", "open_selected", "Öffnen"),
Binding("c", "continue_reading", "Weiterlesen"),
Binding("/", "search", "Suchen"),
Binding("s", "pick_sort", "Sortierung"),
Binding("o", "pick_folder", "Ordner"),
Binding("m", "toggle_section", "Bücher/Zeitschriften"),
Binding("1", "set_status('all')", "Alle", show=False),
Binding("2", "set_status('started')", "Angefangen", show=False),
Binding("3", "set_status('unread')", "Ungelesen", show=False),
Binding("4", "set_status('read')", "Gelesen", show=False),
Binding("escape", "reset", "Zurücksetzen", show=False),
Binding("q", "quit", "Beenden"),
]
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
self.rows: list[library.Row] = [] # every book
self.section_rows: list[library.Row] = [] # of the section shown (Bücher or Zeitschriften)
self.shown: list[library.Row] = [] # the list on screen
self.going: library.Row | None = None # the card's book
self.folder_names: list[str] = []
self.q = ""
self.status = "all"
self.folder: str | None = None # None: all, '': without folder, else a name
self.sort = library_meta.get_extra("sort", "recent")
if self.sort not in dict(library.SORTS):
self.sort = "recent"
self.magazines = False
self._lengths_running = False
self._lengths_tried: set[str] = set()
self._lengths_note = ""
# ---- layout
def compose(self):
yield Header()
yield Static("", id="lib-chips")
yield Input(placeholder="Titel, Autor …", id="lib-search")
yield Static("", id="lib-card")
yield Static("", id="lib-note")
yield ListView(id="library-list")
yield StatusBar()
yield Footer()
def on_mount(self) -> None:
self.query_one("#lib-search").display = False
self.sub_title = str(self.library_dir)
self._refresh_list()
cfg = config_mod.load()
if cfg is not None:
self._auto_sync(cfg)
def on_screen_resume(self) -> None:
self._refresh_list() # a book was read: the order and the percentages changed
# ---- sync
@work(thread=True)
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
try:
result = remote.sync_library(self.library_dir, cfg)
except remote.SyncError as e:
self.app.call_from_thread(self.notify, str(e), severity="warning", timeout=5)
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:
self._refresh_list()
if result.new_titles:
self.notify(f"{len(result.new_titles)} neue(s) Buch/Bücher auf dem Server.", timeout=3)
# ---- the list
def _gather(self) -> list[library.Row]:
store = progress.default()
remote_books = library_meta.remote_entries()
files = epub.scan_library(self.library_dir)
keys = {epub._book_id(p) for p in files} | set(remote_books)
states = {k: st for k in keys if (st := store.get(k)) is not None}
return library.build_rows(files, remote_books, states, library_meta.local_pages(), epub._book_id)
def _refresh_list(self) -> None:
self.rows = self._gather()
self.section_rows = library.section(self.rows, self.magazines)
order = library_meta.get_extra("folder_order", [])
self.folder_names = [n for n, _ in library.folder_list(self.section_rows, order)]
if self.folder not in (None, "") and self.folder not in self.folder_names:
self.folder = None
self._draw()
self._fill_lengths()
def _draw(self) -> None:
self._draw_chips()
list_view = self.query_one("#library-list", ListView)
selected = self.shown[list_view.index].key if self.shown and list_view.index is not None and list_view.index < len(self.shown) else None
shown = library.shown(self.section_rows, q=self.q, status=self.status, folder=self.folder, sort=self.sort)
# The card: only on the plain list, and its book is not repeated below (once, on top).
plain = self.status == "all" and self.folder is None and not self.q
self.going, recent = library.continue_rows(self.section_rows) if plain else (None, [])
self._draw_card(recent)
if self.going is not None and self.going in shown:
shown.remove(self.going)
self.shown = shown
list_view.clear()
if not self.rows:
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
elif not self.section_rows:
list_view.append(ListItem(Label("Noch keine Zeitschriften — m wechselt zu den Büchern." if self.magazines
else "Alle Bücher stehen bei den Zeitschriften — m wechselt dorthin.")))
elif not self.shown and self.going is None:
list_view.append(ListItem(Label("Nichts gefunden.")))
by_length = self.sort in ("short", "long")
for r in self.shown:
list_view.append(ListItem(Label(Text(self._label(r, by_length)))))
if self.shown:
idx = next((i for i, r in enumerate(self.shown) if r.key == selected), 0)
list_view.index = idx
if not self.query_one("#lib-search", Input).display: # not while a search is open
list_view.focus()
self._draw_note()
@staticmethod
def _label(r: library.Row, by_length: bool) -> str:
mark = "✓ " if r.is_read else ""
cloud = "☁ " if r.path is None else ""
author = f" — {r.author}" if r.author else ""
pct = f" {library.percent(r)} %" if (r.fraction > 0 and not r.is_read) else ""
length = f" · {r.pages} S." if by_length and r.pages else ""
return f"{mark}{cloud}{r.title}{author}{pct}{length}"
def _draw_chips(self) -> None:
counts = library.status_counts(self.section_rows)
parts = []
for i, (sid, label) in enumerate(library.STATUSES):
text = label if sid == "all" else f"{label} {counts[sid]}"
parts.append(self._chip(f"{i + 1} {text}", f"screen.set_status('{sid}')", self.status == sid))
folders = library.folder_list(self.section_rows, library_meta.get_extra("folder_order", []))
if folders:
parts.append("[dim]│[/]")
parts.append(self._chip("Alle Ordner", "screen.set_folder('all')", self.folder is None))
for i, (name, count) in enumerate(folders):
parts.append(self._chip(f"{name} {count}", f"screen.set_folder('{i}')", self.folder == name))
none = sum(1 for r in self.section_rows if not r.folders)
if none:
parts.append(self._chip(f"Ohne Ordner {none}", "screen.set_folder('none')", self.folder == ""))
section = "Zeitschriften" if self.magazines else "Bücher"
sort = dict(library.SORTS)[self.sort]
head = f"[b]{section}[/] [dim]Sortierung:[/] {escape(sort)} "
self.query_one("#lib-chips", Static).update(head + " ".join(parts))
@staticmethod
def _chip(label: str, action: str, on: bool) -> str:
label = escape(label)
return f"[@click={action}]" + (f"[b reverse] {label} [/]" if on else f" [underline]{label}[/] ") + "[/]"
def _draw_card(self, recent: list[library.Row]) -> None:
card = self.query_one("#lib-card", Static)
b = self.going
if b is None:
card.display = False
return
card.display = True
ago = library.ago(b.read_at)
pct = library.percent(b)
bar = "█" * (pct // 5) + "░" * (20 - pct // 5)
pages = f" · Seite {max(1, int(b.pages * b.fraction + 0.5))} von {b.pages}" if b.pages else ""
where = f" · zuletzt auf {escape(b.device)}" if b.device and b.device != progress.DEVICE_NAME else ""
lines = [
f"[@click=screen.continue_reading][dim]Weiterlesen{' · ' + ago if ago else ''}{where}[/]",
f"[b]{escape(b.title)}[/]",
escape(b.author) if b.author else "",
f"{bar} {pct} %{pages} [dim](c)[/][/]",
]
if recent:
lines.append("[dim]Zuletzt gelesen:[/] " + " · ".join(
f"{escape(r.title)}{' ✓' if r.is_read else ''}" for r in recent))
card.update("\n".join(l for l in lines if l))
def _draw_note(self) -> None:
note = self.query_one("#lib-note", Static)
text = self._lengths_note
if not text and self.sort in ("short", "long"):
unknown = sum(1 for r in self.section_rows if not r.pages)
if unknown:
text = f"{unknown} Bücher ohne bekannte Länge (noch nicht geladen) stehen am Ende."
note.update(escape(text))
note.display = bool(text)
# ---- lengths (the web works them out once a length sort is chosen: the file is needed)
def _fill_lengths(self) -> None:
if self._lengths_running or self.sort not in ("short", "long"):
return
todo = [r for r in self.rows if not r.pages and r.path is not None and r.key not in self._lengths_tried]
if not todo:
return
self._lengths_running = True
self._work_lengths([(r.key, r.path) for r in todo])
@work(thread=True, exclusive=True, group="lengths")
def _work_lengths(self, todo: list[tuple[str, Path]]) -> None:
import time
shown_at = time.monotonic()
try:
for done, (key, path) in enumerate(todo, 1):
self._lengths_tried.add(key)
try:
pages = blocks.book_pages(path)
except Exception:
pages = 0 # stays at the end
if pages:
library_meta.set_local_pages(key, pages)
self._lengths_note = f"Länge wird ermittelt … {done} von {len(todo)} Büchern"
if time.monotonic() - shown_at > PAGES_NOTE_EVERY:
shown_at = time.monotonic()
self.app.call_from_thread(self._refresh_list_keep_note)
finally:
self._lengths_note = ""
self._lengths_running = False
self.app.call_from_thread(self._refresh_list)
def _refresh_list_keep_note(self) -> None:
self.rows = self._gather()
self.section_rows = library.section(self.rows, self.magazines)
self._draw()
# ---- actions
def action_set_status(self, status: str) -> None:
self.status = "all" if self.status == status else status
self._draw()
def action_set_folder(self, which: str) -> None:
"""`which`: 'all', 'none' (without a folder) or the index of a folder chip."""
if which == "all":
self.folder = None
elif which == "none":
self.folder = ""
elif which.isdigit() and int(which) < len(self.folder_names):
name = self.folder_names[int(which)]
self.folder = None if self.folder == name else name
self._draw()
def action_pick_folder(self) -> None:
values: list[str | None] = [None] + self.folder_names + ([""] if any(not r.folders for r in self.section_rows) else [])
if len(values) == 1:
self.notify("Keine Ordner angelegt.", timeout=2)
return
names = ["Alle Ordner" if v is None else "Ohne Ordner" if v == "" else v for v in values]
def picked(i: int | None) -> None:
if i is not None:
self.folder = values[i]
self._draw()
self.app.push_screen(ChoiceScreen("Ordner", names, values.index(self.folder)), picked)
def action_pick_sort(self) -> None:
ids = [s for s, _ in library.SORTS]
def picked(i: int | None) -> None:
if i is None:
return
self.sort = ids[i]
library_meta.set_extra("sort", self.sort)
self._draw()
self._fill_lengths()
self.app.push_screen(ChoiceScreen("Sortieren nach", [n for _, n in library.SORTS], ids.index(self.sort)), picked)
def action_toggle_section(self) -> None:
self.magazines = not self.magazines
self.folder = None
self._refresh_list()
def action_search(self) -> None:
box = self.query_one("#lib-search", Input)
box.display = True
box.focus()
def on_input_changed(self, event: Input.Changed) -> None:
self.q = event.value
self._draw()
def on_input_submitted(self, event: Input.Submitted) -> None:
self.query_one("#library-list", ListView).focus()
def action_reset(self) -> None:
box = self.query_one("#lib-search", Input)
if box.display or self.q or self.status != "all" or self.folder is not None:
box.value = ""
box.display = False
self.q, self.status, self.folder = "", "all", None
self._draw()
def action_continue_reading(self) -> None:
if self.going is not None:
self._open(self.going)
else:
self.notify("Nichts zum Weiterlesen — ein Buch öffnen.", timeout=3)
def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView)
if not self.shown or list_view.index is None or list_view.index >= len(self.shown):
return
self._open(self.shown[list_view.index])
def _open(self, row: library.Row) -> None:
if row.path is not None:
self.app.push_screen(ReaderScreen(row.path))
return
cfg = config_mod.load()
if cfg is None or row.server_id is None:
self.notify("Nicht angemeldet — `diora-tui login`.", severity="error")
return
self.notify(f"Lade „{row.title}“ …", timeout=4)
self._download_and_open(cfg, row)
@work(thread=True, exclusive=True, group="download")
def _download_and_open(self, cfg: config_mod.RemoteConfig, row: library.Row) -> None:
try:
path = remote.download_book(self.library_dir, cfg, row.server_id, row.title)
except remote.SyncError as e:
self.app.call_from_thread(self.notify, str(e), severity="error", timeout=6)
return
self.app.call_from_thread(self.app.push_screen, ReaderScreen(path))
def on_list_view_selected(self, event: ListView.Selected) -> None:
self.action_open_selected()

109
tui/diora_tui/login.py Normal file
View file

@ -0,0 +1,109 @@
"""`diora-tui login`: sign in once like the apps do and keep what a sync needs.
The password goes through the web login form (CSRF + session) only to read the account's
existing API token off the settings page, and to derive the book key (PBKDF2, as the web
client's "unlock with password"). The password is not stored; the session is logged out again.
A new token is never created here: that would sign out the apps using the current one.
"""
from __future__ import annotations
import base64
import getpass
import html
import re
import requests
from . import api, crypto
from .config import RemoteConfig
DEFAULT_SERVER = "https://diora.creamfresh.xyz"
_CSRF = re.compile(r'name="csrfmiddlewaretoken" value="([^"]+)"')
_TOKEN = re.compile(r'<code style="user-select:all[^"]*">\s*([A-Za-z0-9_-]+)\s*</code>')
_USERNAME = re.compile(r'<meta name="username" content="([^"]*)">')
class LoginError(Exception):
pass
def web_login(server: str, username: str, password: str) -> tuple[str, str]:
"""(API token, canonical user name) via the web login."""
s = requests.Session()
s.headers["User-Agent"] = api.USER_AGENT
def get(path):
return s.get(server + path, headers={"Referer": server + path}, allow_redirects=False, timeout=30)
def post(path, form):
return s.post(server + path, data=form, headers={"Referer": server + path, "Origin": server},
allow_redirects=False, timeout=30)
try:
page = get("/accounts/login/")
m = _CSRF.search(page.text)
if page.status_code != 200 or not m:
raise LoginError(f"Login-Seite nicht lesbar ({page.status_code}).")
r = post("/accounts/login/", {"csrfmiddlewaretoken": m.group(1), "username": username, "password": password})
if r.status_code == 200:
raise LoginError("Benutzername oder Passwort falsch.")
if r.status_code != 302:
raise LoginError(f"Anmeldung fehlgeschlagen ({r.status_code}).")
try:
t = _TOKEN.search(get("/accounts/settings/").text)
if not t:
raise LoginError("Kein API-Token gefunden. Melde dich einmal in einer diora-App an, dann noch mal.")
name = _USERNAME.search(get("/").text)
return t.group(1), html.unescape(name.group(1)) if name else username
finally:
c = _CSRF.search(get("/").text)
if c:
post("/accounts/logout/", {"csrfmiddlewaretoken": c.group(1)})
except requests.RequestException as e:
raise LoginError(f"Verbindung zu {server} fehlgeschlagen: {e}") from e
def _books_open(server: str, token: str, key_b64: str) -> tuple[int, int]:
"""(books, books the key opens)."""
books, _, _ = api.fetch_books(server, token)
ok = 0
for b in books:
try:
crypto.decrypt(key_b64, b["meta_iv"], b["meta_ct"])
ok += 1
except crypto.DecryptError:
pass
return len(books), ok
def login(server: str | None = None, *, paste_key: bool = False) -> RemoteConfig:
server = (server or DEFAULT_SERVER).rstrip("/")
username = input("diora-Benutzername: ").strip()
password = getpass.getpass("Passwort (wird nicht gespeichert, nicht angezeigt): ")
if not username or not password:
raise LoginError("Abgebrochen.")
token, username = web_login(server, username, password)
key = "" if paste_key else crypto.derive_key_b64(username, password)
del password
if key:
total, ok = _books_open(server, token, key)
if total and not ok:
print("Der Schlüssel aus dem Passwort öffnet die Bücher nicht (Passwort seit dem Hochladen geändert?).")
key = ""
if not key:
key = getpass.getpass("Schlüssel einfügen (diora: Einstellungen → Schlüssel kopieren, Base64; leer = abbrechen): ").strip()
if not key:
raise LoginError("Abgebrochen, nichts gespeichert.")
try:
if len(base64.b64decode(key)) != 32:
raise ValueError
except ValueError:
raise LoginError("Das ist kein gültiger Schlüssel (32 Byte, Base64).") from None
total, ok = _books_open(server, token, key)
if total and not ok:
raise LoginError("Auch dieser Schlüssel passt nicht. Nichts gespeichert.")
print(f"Angemeldet als {username} ({total} Bücher).")
return RemoteConfig(server_url=server, api_token=token, enc_key_b64=key, username=username)

164
tui/diora_tui/marks.py Normal file
View file

@ -0,0 +1,164 @@
"""Highlights in the terminal text: from the web's anchors to spans of the displayed text, and
back from a selection to an anchor.
An anchor addresses characters of a block's *raw* text (every text node, whitespace as in the
file); the TUI shows the block with whitespace collapsed (blocks.collapse_map relates the two).
The raw text is read from the file for the blocks involved only, a chapter at a time.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from rich.style import Style
from . import annotations, blocks
SELECTION_STYLE = Style(reverse=True)
NOTE_STYLE = Style(underline=True, dim=False)
_WORD_RE = re.compile(r"\S+")
def highlight_style(color: str | None) -> Style:
return Style(color="black", bgcolor=annotations.COLORS.get(color or "yellow", annotations.COLORS["yellow"]))
# ---- words (the cursor moves by word)
def word_starts(text: str) -> list[int]:
return [m.start() for m in _WORD_RE.finditer(text)]
def word_at(text: str, pos: int) -> tuple[int, int] | None:
"""(start, end) of the word at or after `pos`, or None if there is none."""
for m in _WORD_RE.finditer(text):
if m.end() > pos:
return m.start(), m.end()
return None
def next_word(text: str, pos: int) -> int | None:
return next((s for s in word_starts(text) if s > pos), None)
def prev_word(text: str, pos: int) -> int | None:
return next((s for s in reversed(word_starts(text)) if s < pos), None)
@dataclass
class Maps:
raw: str
norm_of_raw: list[int]
raw_of_norm: list[int]
length: int
class MarkIndex:
"""Relates a book's displayed blocks to the raw text anchors count in."""
def __init__(self, book: blocks.FlatBook) -> None:
self.book = book
self._raws: dict[int, list[str]] = {} # chapter -> raw text of its blocks
self._maps: dict[int, Maps | None] = {}
def maps(self, block_index: int) -> Maps | None:
"""None when the displayed text isn't the plain collapse of the raw text (a footnote
marker with spaces inside): such a block can't be addressed exactly."""
if block_index in self._maps:
return self._maps[block_index]
b = self.book.blocks[block_index]
chapter = b.chapter_index
if chapter not in self._raws:
self._raws[chapter] = blocks.raw_block_texts(self.book.path, chapter)
first = self.book.chapter_start_block[chapter]
raws = self._raws[chapter]
result = None
if 0 <= block_index - first < len(raws):
raw = raws[block_index - first]
norm_of_raw, raw_of_norm, length = blocks.collapse_map(raw)
if length == len(b.text) and "".join(raw[i] if not blocks._JS_SPACE_RE.fullmatch(raw[i]) else " " for i in raw_of_norm) == b.text:
result = Maps(raw, norm_of_raw, raw_of_norm, length)
self._maps[block_index] = result
return result
# ---- annotation -> spans
def spans(self, items: list[dict]) -> dict[int, list[tuple[int, int, Style]]]:
"""Per block the (start, end, style) spans of the highlights and notes."""
out: dict[int, list[tuple[int, int, Style]]] = {}
n = len(self.book.blocks)
for h in items:
a = h.get("anchor") or {}
if "startBlockIndex" not in a: # an old anchor (XPath): the web falls back to the quote, so do we
found = self._find_quote(a)
if found is not None:
bi, s0, e0 = found
out.setdefault(bi, []).append((s0, e0, NOTE_STYLE if h.get("type") == "note" else highlight_style(h.get("color"))))
continue
try:
sb = int(a["startBlockIndex"])
eb = int(a.get("endBlockIndex", sb))
sc, ec = int(a.get("startChar", 0)), int(a.get("endChar", 0))
except (KeyError, TypeError, ValueError):
continue
if not (0 <= sb < n and sb <= eb < n):
continue
style = NOTE_STYLE if h.get("type") == "note" else highlight_style(h.get("color"))
for bi, s, e in self._ranges(sb, sc, eb, ec, a.get("quote") or ""):
if e > s:
out.setdefault(bi, []).append((s, e, style))
return out
def _find_quote(self, anchor: dict) -> tuple[int, int, int] | None:
"""(block, start, end) of the first place in the anchor's chapter that holds its quote."""
needle = " ".join((anchor.get("quote") or "").split())
src = anchor.get("chapterSrc")
if not needle or src not in self.book.chapter_srcs:
return None
chapter = self.book.chapter_srcs.index(src)
first = self.book.chapter_start_block[chapter]
last = self.book.chapter_start_block[chapter + 1] if chapter + 1 < len(self.book.chapter_start_block) else len(self.book.blocks)
for bi in range(first, last):
i = self.book.blocks[bi].text.find(needle)
if i >= 0:
return bi, i, i + len(needle)
return None
def _ranges(self, sb: int, sc: int, eb: int, ec: int, quote: str):
text = self.book.blocks[sb].text
m = self.maps(sb)
if m is None or (eb != sb and self.maps(eb) is None):
# not addressable by offsets: find the quote
i = text.find(" ".join(quote.split())) if quote else -1
if i >= 0:
yield sb, i, i + len(" ".join(quote.split()))
return
s = m.norm_of_raw[min(sc, len(m.raw))]
if sb == eb:
yield sb, s, m.norm_of_raw[min(ec, len(m.raw))]
return
yield sb, s, m.length
for bi in range(sb + 1, eb):
yield bi, 0, len(self.book.blocks[bi].text)
me = self.maps(eb)
yield eb, 0, me.norm_of_raw[min(ec, len(me.raw))]
# ---- selection -> anchor
def anchor(self, start: tuple[int, int], end: tuple[int, int]) -> dict | None:
"""The web anchor for the displayed range start..end ((block, char) each, end exclusive),
or None when a block can't be addressed exactly."""
(sb, sc), (eb, ec) = start, end
ms, me = self.maps(sb), self.maps(eb)
if ms is None or me is None:
return None
s_raw = ms.raw_of_norm[sc] if sc < ms.length else len(ms.raw)
e_raw = me.raw_of_norm[ec - 1] + 1 if ec > 0 else 0
if sb == eb:
quote = ms.raw[s_raw:e_raw]
else:
quote = ms.raw[s_raw:] + me.raw[:e_raw]
chapter_src = self.book.chapter_srcs[self.book.blocks[sb].chapter_index] if self.book.chapter_srcs else ""
return annotations.make_anchor(chapter_src, sb, s_raw, eb, e_raw, quote)

30
tui/diora_tui/paths.py Normal file
View file

@ -0,0 +1,30 @@
"""Where the TUI keeps its files. `DIORA_TUI_HOME` moves all of it into one folder (tests, the
smoke script and anyone who wants a second account side by side use it); platformdirs' own
answer on Windows doesn't follow the LOCALAPPDATA/APPDATA environment variables."""
from __future__ import annotations
import os
from pathlib import Path
from platformdirs import user_cache_dir, user_config_dir, user_data_dir
def _home() -> Path | None:
h = os.environ.get("DIORA_TUI_HOME")
return Path(h) if h else None
def data_dir() -> Path:
h = _home()
return h / "data" if h else Path(user_data_dir("diora-tui", "diora"))
def config_dir() -> Path:
h = _home()
return h / "config" if h else Path(user_config_dir("diora-tui", "diora"))
def cache_dir() -> Path:
h = _home()
return h / "cache" if h else Path(user_cache_dir("diora-tui", "diora"))

View file

@ -1,68 +1,231 @@
"""Local reading-progress storage. """Reading position per book: the place last read wins.
Positions are stored as the same "blockIndex:innerFraction" anchor string the This is the contract in diora-web docs/progress-sync.md, the same rules the server
server uses (books/models.py, EBookProgress.save_progress) — see (books/views.py save_progress) and the other clients apply (diora-android Ops.kt,
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js diora-desktop src/main/ops.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 * A position carries `read_at`, the moment the reader arrived there. Local times are the
only ever advances (unless forced), so an older/offline run can't regress a server's clock: `offset` (server - this machine, ms, learnt from the server's clock on every
further-along read position — matching that same rule locally. answer) turns this machine's `read_at` into it. Saving an unchanged position again carries the
old time, so an idle reader can never beat a newer position from elsewhere.
* A write is stored when its time is >= the stored one (`force`: always, time = now). The
furthest place ever reported only grows.
* A write is also queued (`pending`, one per book: the later `read_at` wins, `force` sticks)
until the server has it. After a pull the pending write is applied again on top of the
server's state with the same rule.
State lives in progress.json next to the book list; the file also holds the clock offset.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import functools
import os
import threading
import time import time
from dataclasses import dataclass from dataclasses import asdict, dataclass, replace
from pathlib import Path from pathlib import Path
from platformdirs import user_data_dir from . import paths
from .blocks import parse_anchor
from .blocks import anchor_is_further DEVICE_NAME = "diora TUI"
_DATA_DIR = Path(user_data_dir("diora-tui", "diora")) _DATA_DIR = paths.data_dir()
_PROGRESS_FILE = _DATA_DIR / "progress.json" _PROGRESS_FILE = _DATA_DIR / "progress.json"
_VERSION = 2
def now_ms() -> int:
return int(time.time() * 1000)
def plausible_ms(v) -> int | None:
"""A time (ms since epoch) from the server or a body, or None (as Ops.ms)."""
if isinstance(v, bool) or not isinstance(v, (int, float)) or v != v or v in (float("inf"), float("-inf")):
return None
t = int(v)
return t if 10**12 <= t < 10**14 else None
def cmp_progress(a_anchor: str, a_frac: float, b_anchor: str, b_frac: float) -> float:
"""> 0 if A is further into the book than B, < 0 if behind (books/views.py _cmp_progress)."""
a, b = parse_anchor(a_anchor or ""), parse_anchor(b_anchor or "")
if a is not None and b is not None:
return (a[0] - b[0]) if a[0] != b[0] else a[1] - b[1]
return a_frac - b_frac
@dataclass @dataclass
class Position: class BookState:
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000" anchor: str = ""
updated_at: float = 0.0 fraction: float = 0.0
read_at: int | None = None # server clock, ms
device: str = ""
furthest_anchor: str = ""
furthest_fraction: float = 0.0
# The write the server hasn't got yet: {"anchor", "fraction", "read_at" (this machine's
# clock), "force"}.
pending: dict | None = None
# The server's read_at for the last write of ours that it stored: tells our own position
# coming back from a pull from another device's.
echo_read_at: int | None = None
def _load_all() -> dict[str, dict]: def _locked(fn):
if not _PROGRESS_FILE.exists(): @functools.wraps(fn)
return {} def wrapper(self, *a, **kw):
try: with self._lock:
return json.loads(_PROGRESS_FILE.read_text()) return fn(self, *a, **kw)
except (json.JSONDecodeError, OSError): return wrapper
return {}
def _save_all(data: dict[str, dict]) -> None: class ProgressStore:
_DATA_DIR.mkdir(parents=True, exist_ok=True) """Shared by the UI thread (the reader writes) and the sync workers (they pull and push)."""
_PROGRESS_FILE.write_text(json.dumps(data, indent=2))
def __init__(self, path: Path | None = None) -> None:
self.path = path or _PROGRESS_FILE
self._lock = threading.RLock()
self.offset = 0
self.books: dict[str, BookState] = {}
self._load()
# ---- persistence
def _load(self) -> None:
try:
raw = json.loads(self.path.read_text())
except (OSError, ValueError):
return
if not isinstance(raw, dict):
return
if raw.get("version") == _VERSION:
self.offset = int(raw.get("offset") or 0)
for key, entry in (raw.get("books") or {}).items():
try:
self.books[key] = BookState(**entry)
except TypeError:
continue
return
# version 1: {book_id: {anchor, updated_at (s)}}, furthest-wins, nothing queued
for key, entry in raw.items():
if isinstance(entry, dict) and entry.get("anchor"):
self.books[key] = BookState(
anchor=entry["anchor"], read_at=int(entry.get("updated_at", 0) * 1000) or None,
furthest_anchor=entry["anchor"],
)
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
data = {"version": _VERSION, "offset": self.offset,
"books": {k: asdict(v) for k, v in self.books.items()}}
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=1))
os.replace(tmp, self.path)
# ---- clock
@_locked
def set_server_now(self, server_now: int | None, device_now: int | None = None) -> None:
t = plausible_ms(server_now)
if t is not None:
self.offset = t - (device_now if device_now is not None else now_ms())
# ---- reads
@_locked
def get(self, key: str) -> BookState | None:
"""A copy: the sync workers change the live state."""
st = self.books.get(key)
return replace(st, pending=dict(st.pending) if st.pending else None) if st else None
@_locked
def pending_keys(self) -> list[str]:
return [k for k, v in self.books.items() if v.pending]
# ---- writes
@_locked
def write(self, key: str, anchor: str, fraction: float, read_at: int, *, force: bool = False,
queue: bool = True, device: str = DEVICE_NAME, now: int | None = None) -> bool:
"""Apply a position the reader reached (`read_at`: this machine's clock) locally and
queue it for the server (`queue` False: the book isn't one of the server's). Returns
whether it is the continue point now."""
st = self.books.setdefault(key, BookState())
fraction = max(0.0, min(1.0, fraction))
took = self._apply(st, anchor, fraction, read_at, force, device, now)
if queue:
new = {"anchor": anchor, "fraction": fraction, "read_at": read_at, "force": force}
st.pending = _merge_pending(st.pending, new)
self.save()
return took
def _apply(self, st: BookState, anchor: str, fraction: float, read_at: int | None, force: bool,
device: str, now: int | None) -> bool:
now_server = (now if now is not None else now_ms()) + self.offset
if force:
when, take = now_server, True
else:
when = min((read_at or 0) + self.offset, now_server)
take = st.read_at is None or when >= st.read_at
if take:
st.anchor, st.fraction, st.read_at, st.device = anchor, fraction, when, device[:40]
if cmp_progress(anchor, fraction, st.furthest_anchor, st.furthest_fraction) > 0:
st.furthest_anchor, st.furthest_fraction = anchor, fraction
return take
@_locked
def apply_server(self, key: str, entry: dict, *, keep_pending: bool = True) -> None:
"""Take the server's state for the book (an entry of /books/ or a progress answer), then
apply the pending write on top by the same rule."""
st = self.books.setdefault(key, BookState())
st.anchor = entry.get("position_anchor") or ""
st.fraction = float(entry.get("scroll_fraction") or 0.0)
st.read_at = plausible_ms(entry.get("read_at"))
st.device = entry.get("device") or ""
st.furthest_anchor = entry.get("furthest_anchor") or ""
st.furthest_fraction = float(entry.get("furthest_fraction") or 0.0)
if st.pending and keep_pending:
p = st.pending
self._apply(st, p["anchor"], p["fraction"], p["read_at"], bool(p.get("force")), DEVICE_NAME, None)
self.save()
@_locked
def sent(self, key: str, body: dict, answer: dict | None) -> None:
"""The server took `body` (the pending write as it was sent): forget it unless a newer one
queued meanwhile, and take the server's state from the answer."""
st = self.books.get(key)
if st is None:
return
if st.pending and st.pending.get("read_at") == body.get("read_at") and bool(st.pending.get("force")) == bool(body.get("force")):
st.pending = None
if answer:
self.set_server_now(answer.get("server_now"))
if answer.get("stored") is True:
st.echo_read_at = plausible_ms(answer.get("read_at"))
self.apply_server(key, answer)
else:
self.save()
def load(book_id: str) -> Position | None: def _merge_pending(old: dict | None, new: dict) -> dict:
entry = _load_all().get(book_id) """Two queued writes for the book: the place read later is the one to send; a forced place
if not isinstance(entry, dict) or not entry.get("anchor"): stays forced (Ops.merge)."""
return None if old is None:
return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0)) return new
keep = new if new["read_at"] >= old["read_at"] else old
keep = dict(keep)
if old.get("force") or new.get("force"):
keep["force"] = True
return keep
def save(book_id: str, position: Position, *, force: bool = False) -> bool: _default: ProgressStore | None = None
"""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.""" def default() -> ProgressStore:
data = _load_all() global _default
existing = data.get(book_id) if _default is None:
if existing is not None and not force: _default = ProgressStore()
old_anchor = existing.get("anchor", "") return _default
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

@ -7,12 +7,19 @@ 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 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 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. minute, whereas the Line API only ever renders the rows on screen.
Reading position (docs/progress-sync.md, rules in progress.py): the place last read wins.
The reader saves a position only when the *reader* moved it (keys, wheel), stamped with the
moment of arriving; restoring, re-wrapping on resize and jumping on an offer change nothing.
While a book is open the server is asked now and then; a newer position from another device
is offered ("Auf X weitergelesen — springen?"), never applied by itself.
""" """
from __future__ import annotations from __future__ import annotations
import bisect import bisect
import re import re
import threading
from pathlib import Path from pathlib import Path
from rich.console import Console from rich.console import Console
@ -23,29 +30,28 @@ from textual.geometry import Size
from textual.screen import ModalScreen, Screen from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
from . import api, blocks, cache, config as config_mod, layout, progress from . import annotations, blocks, cache, config as config_mod, epub, layout, library, library_meta, marks, progress, remote
from .annotation_screens import AnnotationsScreen, NoteScreen
from .book_view import ContinuousBookView from .book_view import ContinuousBookView
from .statusbar import StatusBar from .statusbar import StatusBar
MAX_LINE_WIDTH = 120 MAX_LINE_WIDTH = 120
AUTOSAVE_INTERVAL = 5.0 AUTOSAVE_INTERVAL = 5.0
# How often the server is asked for a newer position while the book is open (the apps pull
# at most every 2 minutes).
REMOTE_CHECK_INTERVAL = 120.0
# "Weiteste Stelle" is offered when the furthest place is this much of the book ahead.
FURTHEST_OFFER_SHARE = 0.01
# ContinuousBookView forces its vertical scrollbar always-on (see book_view.py) # ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
# so this stays constant — measuring the container's width and subtracting # so this stays constant — measuring the container's width and subtracting
# this fixed amount avoids a measure-after-constrain race against book_view's # this fixed amount avoids a measure-after-constrain race against book_view's
# own (possibly already-constrained-from-a-previous-layout) width. # own (possibly already-constrained-from-a-previous-layout) width.
SCROLLBAR_GUTTER = 2 SCROLLBAR_GUTTER = 2
# A selection may span this many paragraphs (each is drawn again at every cursor move).
MAX_MARK_BLOCKS = 200
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1) _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]): class FootnoteScreen(ModalScreen[None]):
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")] BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
@ -64,6 +70,27 @@ class FootnoteScreen(ModalScreen[None]):
self.dismiss(None) self.dismiss(None)
class JumpOfferScreen(ModalScreen[bool]):
"""A question the reader answers with a key; nothing moves unless the answer is yes."""
BINDINGS = [
Binding("y,enter", "answer(True)", "Springen"),
Binding("n,escape,q", "answer(False)", "Hier bleiben"),
]
def __init__(self, text: str) -> None:
super().__init__()
self._text = text
def compose(self):
with Container(id="offer-body"):
yield Static(self._text)
yield Label("y/Enter: springen n/Escape: hier bleiben", id="offer-hint")
def action_answer(self, yes: bool) -> None:
self.dismiss(yes)
class ReaderScreen(Screen): class ReaderScreen(Screen):
BINDINGS = [ BINDINGS = [
Binding("j,down", "scroll_down_line", "Runter", show=False), Binding("j,down", "scroll_down_line", "Runter", show=False),
@ -71,6 +98,13 @@ class ReaderScreen(Screen):
Binding("n", "next_chapter", "Nächstes Kapitel"), Binding("n", "next_chapter", "Nächstes Kapitel"),
Binding("p", "prev_chapter", "Vorheriges Kapitel"), Binding("p", "prev_chapter", "Vorheriges Kapitel"),
Binding("f", "peek_footnote", "Fußnote"), Binding("f", "peek_footnote", "Fußnote"),
Binding("w", "goto_furthest", "Weiteste Stelle"),
Binding("v", "select", "Markieren"),
Binding("b", "bookmark", "Lesezeichen"),
Binding("a", "annotations", "Markierungen"),
Binding("h,left", "word_back", "Wort zurück", show=False),
Binding("l,right", "word_forward", "Wort vor", show=False),
Binding("enter,space", "confirm", "Weiter", show=False),
Binding("q,escape", "back", "Zurück zur Bibliothek"), Binding("q,escape", "back", "Zurück zur Bibliothek"),
] ]
@ -79,9 +113,24 @@ class ReaderScreen(Screen):
self.path = path self.path = path
self.book: blocks.FlatBook | None = None self.book: blocks.FlatBook | None = None
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
self._server_id = _server_book_id(path) self._key = epub._book_id(path)
self._remote_cfg = config_mod.load() if self._server_id is not None else None self._server_id = epub.server_id_of(path)
self._last_pushed_anchor = "" self._cfg = config_mod.load() if self._server_id is not None else None
self._store = progress.default()
# The position the view is on, as last saved or restored, and when the reader last
# moved it (this machine's clock). A change of the anchor is a reading position only
# when there is such a move.
self._last_anchor: str | None = None
self._last_move_ms: int | None = None
# read_at (server clock) of the state this view is based on: a newer one from the
# server is what gets offered.
self._held_read_at: int | None = None
self._offer_open = False
self._marks: marks.MarkIndex | None = None
self._ann = annotations.default()
self._styled: set[int] = set() # blocks drawn with marks at the moment
self._sel: dict | None = None # the selection being made: phase, cursor, start
self._ann_timer = None
def compose(self): def compose(self):
yield Header() yield Header()
@ -93,7 +142,7 @@ class ReaderScreen(Screen):
# to visible hasn't been through a layout pass yet, so its `.size` # 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 # is still (0, 0) and an immediate scroll_to() right after has
# nothing to clamp against and silently resets to 0. # nothing to clamp against and silently resets to 0.
self._book_view = ContinuousBookView(_EMPTY_LAYOUT) self._book_view = ContinuousBookView(_EMPTY_LAYOUT, on_read_scroll=self._on_read_scroll)
yield self._book_view yield self._book_view
yield StatusBar() yield StatusBar()
yield Footer() yield Footer()
@ -129,8 +178,17 @@ class ReaderScreen(Screen):
self.sub_title = book.title self.sub_title = book.title
self._apply_layout(book_layout, width) self._apply_layout(book_layout, width)
self.query_one("#reader-loading", LoadingIndicator).display = False self.query_one("#reader-loading", LoadingIndicator).display = False
library_meta.set_local_pages(self._key, library.pages_of_chars(book.total_chars))
st = self._store.get(self._key)
self._held_read_at = st.read_at if st else None
self._marks = marks.MarkIndex(book)
self._apply_marks()
if self._cfg is not None:
self._pull_annotations()
self.call_after_refresh(self._restore_position) self.call_after_refresh(self._restore_position)
self.set_interval(AUTOSAVE_INTERVAL, self._autosave) self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
if self._cfg is not None:
self.set_interval(REMOTE_CHECK_INTERVAL, self._check_remote)
def _content_width(self) -> int: def _content_width(self) -> int:
# Measuring the *container* rather than book_view's own size avoids a # Measuring the *container* rather than book_view's own size avoids a
@ -160,6 +218,8 @@ class ReaderScreen(Screen):
new_layout = layout.build_layout(block_texts, width, console) new_layout = layout.build_layout(block_texts, width, console)
cache.save(self.path, width, self.book, new_layout) cache.save(self.path, width, self.book, new_layout)
self._apply_layout(new_layout, width) self._apply_layout(new_layout, width)
self._styled = set() # a fresh layout is drawn without marks
self._apply_marks()
def on_resize(self) -> None: def on_resize(self) -> None:
if self.book is None: if self.book is None:
@ -168,68 +228,187 @@ class ReaderScreen(Screen):
anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y) anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y)
self._build_layout() self._build_layout()
new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac) 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) with self._book_view.program_scroll():
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
def _scroll_to_anchor(self, anchor: str) -> bool:
parsed = blocks.parse_anchor(anchor)
if parsed is None:
return False
y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=y, animate=False, immediate=True)
return y > 0
def _restore_position(self, attempt: int = 0) -> None: def _restore_position(self, attempt: int = 0) -> None:
if self.book is None: if self.book is None:
return return
saved = progress.load(self.book.id) st = self._store.get(self._key)
if saved is None: if st is None or not st.anchor or blocks.parse_anchor(st.anchor) is None:
self._settle()
return return
parsed = blocks.parse_anchor(saved.anchor) parsed = blocks.parse_anchor(st.anchor)
if parsed is None: y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
return
block_index, inner_fraction = parsed
y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction)
if y <= 0: if y <= 0:
self._settle()
return return
self._book_view.scroll_to(y=y, animate=False, immediate=True) with self._book_view.program_scroll():
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 # 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 # honor an immediate scroll on the first attempt (its own size/scroll
# bounds can still be mid-update) — verify it actually landed and # bounds can still be mid-update) — verify it actually landed and
# retry a bounded number of times rather than guessing a fixed delay. # retry a bounded number of times rather than guessing a fixed delay.
if attempt < 20 and abs(self._book_view.scroll_y - y) > 1: if attempt < 20 and abs(self._book_view.scroll_y - y) > 1:
self.call_after_refresh(lambda: self._restore_position(attempt + 1)) self.call_after_refresh(lambda: self._restore_position(attempt + 1))
return
self._settle()
self._offer_furthest(st)
def _settle(self) -> None:
"""The view is where the saved position put it: from here a changed anchor with a move
by the reader is a reading position."""
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
def _current_anchor_str(self) -> str: def _current_anchor_str(self) -> str:
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return blocks.format_anchor(block_index, inner_fraction) return blocks.format_anchor(block_index, inner_fraction)
# ---- saving
def _on_read_scroll(self) -> None:
self._last_move_ms = progress.now_ms()
def _autosave(self) -> None: def _autosave(self) -> None:
self._save_progress() self._save_progress()
def _save_progress(self) -> None: def _save_progress(self) -> None:
if self.book is None or not self.book_layout.heights: if self.book is None or not self.book_layout.heights or self._last_anchor is None:
return return
anchor = self._current_anchor_str() anchor = self._current_anchor_str()
advanced = progress.save(self.book.id, progress.Position(anchor=anchor)) if anchor == self._last_anchor:
if advanced and self._remote_cfg is not None and self._server_id is not None: self._last_move_ms = None # moved and came back: nothing new
self._push_remote_progress(anchor) return
moved_at = self._last_move_ms
self._last_anchor = anchor
self._last_move_ms = None
if moved_at is None:
return # the layout moved it, not the reader
block_index, inner = blocks.parse_anchor(anchor)
fraction = blocks.fraction_for_anchor(self.book, block_index, inner)
self._store.write(self._key, anchor, fraction, moved_at, queue=self._cfg is not None)
st = self._store.get(self._key)
if st is not None:
self._held_read_at = st.read_at
if self._cfg is not None:
self._push()
@work(thread=True, exclusive=True, group="progress-push") @work(thread=True, exclusive=True, group="progress-push")
def _push_remote_progress(self, anchor: str) -> None: def _push(self) -> None:
if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None:
return
try: try:
api.post_progress( remote.flush_pending(self._cfg)
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: except Exception:
pass # best-effort — local progress is already saved regardless return # best-effort — the position is saved locally and stays queued
self.app.call_from_thread(self._after_push)
def _after_push(self) -> None:
# What the server stored of ours is no news; a newer position of another device in its
# answer (our write was refused) is.
st = self._store.get(self._key)
if st is not None and st.echo_read_at is not None and st.echo_read_at >= (self._held_read_at or 0):
self._held_read_at = st.echo_read_at
self._offer_newer()
def on_unmount(self) -> None: def on_unmount(self) -> None:
self._save_progress() self._save_progress()
if self._cfg is not None and (self._store.pending_keys() or self._ann.dirty_keys()):
# The screen's workers die with it; the queue still has to go out.
threading.Thread(target=self._flush_quietly, args=(self._cfg,), daemon=False).start()
@staticmethod
def _flush_quietly(cfg) -> None:
for send in (remote.flush_pending, remote.push_annotations):
try:
send(cfg)
except Exception:
pass
# ---- the server's newer positions
@work(thread=True, exclusive=True, group="progress-check")
def _check_remote(self) -> None:
try:
remote.sync_library(self.path.parent, self._cfg)
except Exception:
return # offline etc.: ask again later
self.app.call_from_thread(self._offer_newer)
def _offer_newer(self) -> None:
if self.book is None or self._offer_open or self._last_anchor is None:
return
st = self._store.get(self._key)
if st is None or not st.anchor or st.read_at is None:
return
if self._held_read_at is not None and st.read_at <= self._held_read_at:
return
parsed = blocks.parse_anchor(st.anchor)
if parsed is None:
return
shown_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
self._held_read_at = st.read_at # asked once per newer state
if parsed[0] == shown_block:
return
pct = round(blocks.fraction_for_anchor(self.book, *parsed) * 100)
who = st.device or "einem anderen Gerät"
self._ask(f"Auf {who} weitergelesen — bei {pct} %.\nSpringen?", st.anchor)
def _offer_furthest(self, st: progress.BookState) -> None:
if self.book is None or not st.furthest_anchor:
return
here, there = blocks.parse_anchor(st.anchor), blocks.parse_anchor(st.furthest_anchor)
if there is None:
return
share = blocks.fraction_for_anchor(self.book, *there) - (blocks.fraction_for_anchor(self.book, *here) if here else 0.0)
if share >= FURTHEST_OFFER_SHARE:
pct = round(blocks.fraction_for_anchor(self.book, *there) * 100)
self.notify(f"Weiteste Stelle: {pct} % — mit w springen.", timeout=6)
def _ask(self, text: str, anchor: str) -> None:
self._offer_open = True
def answered(yes: bool | None) -> None:
self._offer_open = False
if yes:
self._scroll_to_anchor(anchor)
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
self.app.push_screen(JumpOfferScreen(text), answered)
def action_goto_furthest(self) -> None:
st = self._store.get(self._key)
if self.book is None or st is None or not st.furthest_anchor:
self.notify("Keine weiteste Stelle bekannt.", timeout=3)
return
if blocks.parse_anchor(st.furthest_anchor) is None:
return
self._scroll_to_anchor(st.furthest_anchor)
# A jump, not reading: nothing is saved until the reader carries on from there.
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
# ---- navigation
def action_scroll_down_line(self) -> None: def action_scroll_down_line(self) -> None:
if self._sel is not None:
self._move_block(1)
return
self._book_view.scroll_relative(y=1, animate=False) self._book_view.scroll_relative(y=1, animate=False)
def action_scroll_up_line(self) -> None: def action_scroll_up_line(self) -> None:
if self._sel is not None:
self._move_block(-1)
return
self._book_view.scroll_relative(y=-1, animate=False) self._book_view.scroll_relative(y=-1, animate=False)
def _current_chapter_index(self) -> int: def _current_chapter_index(self) -> int:
@ -243,6 +422,9 @@ class ReaderScreen(Screen):
self._book_view.scroll_to(y=y, animate=False, immediate=True) self._book_view.scroll_to(y=y, animate=False, immediate=True)
def action_next_chapter(self) -> None: def action_next_chapter(self) -> None:
if self._sel is not None:
self._selection_note() # n: a note, while marking
return
if self.book is None: if self.book is None:
return return
chapter = self._current_chapter_index() chapter = self._current_chapter_index()
@ -283,4 +465,239 @@ class ReaderScreen(Screen):
self.app.push_screen(FootnoteScreen(notes)) self.app.push_screen(FootnoteScreen(notes))
def action_back(self) -> None: def action_back(self) -> None:
if self._sel is not None:
self._end_selection()
return
self.app.pop_screen() self.app.pop_screen()
# ---- marks on screen
def _marks_by_block(self) -> dict[int, list]:
spans: dict[int, list] = {}
if self._marks is not None:
for bi, lst in self._marks.spans(self._ann.get(self._key).highlights).items():
spans.setdefault(bi, []).extend(lst)
for bi, lst in self._selection_spans().items():
spans.setdefault(bi, []).extend(lst)
return spans
def _apply_marks(self) -> None:
"""Draw the highlights (and the selection) in place; blocks that lost theirs are drawn plain."""
if self.book is None or not self.book_layout.heights:
return
spans = self._marks_by_block()
console = Console(width=self.book_layout.width)
for bi in sorted(self._styled | set(spans)):
if 0 <= bi < len(self.book.blocks):
layout.restyle_block(self.book_layout, bi, self.book.blocks[bi].text, console, spans.get(bi, ()))
self._styled = set(spans)
self._book_view.refresh()
@work(thread=True, exclusive=True, group="ann-pull")
def _pull_annotations(self) -> None:
if remote.pull_annotations(self._cfg, self._server_id, self._key):
self.app.call_from_thread(self._apply_marks)
def _annotations_changed(self) -> None:
"""A highlight, note or bookmark was made, changed or deleted: draw it, and send it soon."""
self._apply_marks()
if self._cfg is None:
return
if self._ann_timer is not None:
self._ann_timer.stop()
self._ann_timer = self.set_timer(2.0, self._push_annotations) # like the web's debounce
@work(thread=True, exclusive=True, group="ann-push")
def _push_annotations(self) -> None:
try:
remote.push_annotations(self._cfg)
except Exception:
pass # stays queued
# ---- bookmarks and the list
def action_bookmark(self) -> None:
if self.book is None or not self.book_layout.heights or self._sel is not None:
return
block, inner = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
chapter = max(0, self._current_chapter_index())
src = self.book.chapter_srcs[chapter] if chapter < len(self.book.chapter_srcs) else ""
label = re.sub(r"\.x?html?$", "", src.rsplit("/", 1)[-1], flags=re.I) or "Lesezeichen" # as the web names it
bm = annotations.new_bookmark(label, src, blocks.fraction_for_anchor(self.book, block, inner))
self._ann.add(self._key, "bookmarks", bm)
self.notify(f"★ Lesezeichen: {label}", timeout=2.5)
self._annotations_changed()
def action_annotations(self) -> None:
if self.book is None or self._sel is not None:
return
def picked(block: int | None) -> None:
if block is not None:
self._jump_quiet(block)
self.app.push_screen(AnnotationsScreen(self.book, self._ann, self._key, self._annotations_changed, self._marks), picked)
def _jump_quiet(self, block: int) -> None:
"""Go to a block as a jump, not as reading: nothing is saved until the reader moves on."""
y = layout.scroll_y_for_anchor(self.book_layout, block, 0.0)
with self._book_view.program_scroll():
self._book_view.scroll_to(y=y, animate=False, immediate=True)
self._last_anchor = self._current_anchor_str()
self._last_move_ms = None
# ---- selecting text (v): a cursor by word, Enter sets the start, Enter again the end
def _first_block_in_view(self) -> int:
y = self._book_view.scroll_y
i = bisect.bisect_left(self.book_layout.starts, y)
return min(i, len(self.book.blocks) - 1)
def _word_block(self, block: int, step: int) -> int | None:
"""The nearest block from `block` (inclusive) in direction `step` that has a word."""
while 0 <= block < len(self.book.blocks):
if marks.word_starts(self.book.blocks[block].text):
return block
block += step
return None
def action_select(self) -> None:
if self.book is None or not self.book_layout.heights:
return
if self._sel is not None:
self.action_confirm()
return
block = self._word_block(self._first_block_in_view(), 1)
if block is None:
return
self._sel = {"phase": "start", "cursor": (block, marks.word_starts(self.book.blocks[block].text)[0]), "start": None}
self.notify("Markieren: h/l Wort, j/k Absatz, Enter = Anfang, n = Notiz, Esc = Abbrechen", timeout=6)
self._selection_moved()
def _end_selection(self) -> None:
self._sel = None
self._apply_marks()
def _selection_moved(self) -> None:
self._apply_marks()
block, char = self._sel["cursor"]
text = self.book.blocks[block].text
row = self.book_layout.starts[block] + int(char / max(1, len(text)) * self.book_layout.heights[block])
top, height = self._book_view.scroll_y, self._book_view.size.height
if not (top <= row < top + height - 1):
with self._book_view.program_scroll():
self._book_view.scroll_to(y=max(0, row - height // 3), animate=False, immediate=True)
self._last_anchor = self._current_anchor_str()
def action_word_forward(self) -> None:
self._move_word(1)
def action_word_back(self) -> None:
self._move_word(-1)
def _move_word(self, step: int) -> None:
if self._sel is None:
return
block, char = self._sel["cursor"]
text = self.book.blocks[block].text
target = marks.next_word(text, char) if step > 0 else marks.prev_word(text, char)
if target is not None:
self._sel["cursor"] = (block, target)
else:
nb = self._word_block(block + step, step)
if nb is None:
return
ws = marks.word_starts(self.book.blocks[nb].text)
self._sel["cursor"] = (nb, ws[0] if step > 0 else ws[-1])
self._selection_moved()
def _move_block(self, step: int) -> None:
nb = self._word_block(self._sel["cursor"][0] + step, step)
if nb is not None:
self._sel["cursor"] = (nb, marks.word_starts(self.book.blocks[nb].text)[0])
self._selection_moved()
def _cursor_word(self) -> tuple[int, int, int]:
block, char = self._sel["cursor"]
w = marks.word_at(self.book.blocks[block].text, char) or (char, char)
return block, w[0], w[1]
def _selection_range(self) -> tuple[tuple[int, int], tuple[int, int]]:
"""(start, end) of what is selected, end exclusive, whichever way the cursor went."""
sb, sc = self._sel["start"]
sw = marks.word_at(self.book.blocks[sb].text, sc) or (sc, sc)
cb, cs, ce = self._cursor_word()
if (cb, cs) >= (sb, sw[0]):
return (sb, sw[0]), (cb, ce)
return (cb, cs), (sb, sw[1])
def _selection_spans(self) -> dict[int, list]:
if self._sel is None or self.book is None:
return {}
if self._sel["phase"] == "start":
block, s, e = self._cursor_word()
return {block: [(s, e, marks.SELECTION_STYLE)]}
(sb, sc), (eb, ec) = self._selection_range()
out: dict[int, list] = {}
for bi in range(sb, eb + 1):
text_len = len(self.book.blocks[bi].text)
out[bi] = [(sc if bi == sb else 0, ec if bi == eb else text_len, marks.SELECTION_STYLE)]
return out
def action_confirm(self) -> None:
if self._sel is None:
return
if self._sel["phase"] == "start":
block, s, _e = self._cursor_word()
self._sel["start"] = (block, s)
self._sel["phase"] = "end"
self.notify("Ende wählen (h/l/j/k), Enter = fertig.", timeout=4)
self._selection_moved()
return
(sb, sc), (eb, ec) = self._selection_range()
if eb - sb > MAX_MARK_BLOCKS:
self.notify(f"Zu lang zum Markieren (mehr als {MAX_MARK_BLOCKS} Absätze).", severity="warning")
return
anchor = self._marks.anchor((sb, sc), (eb, ec))
if anchor is None:
self.notify("Diese Stelle lässt sich nicht genau zuordnen (Fußnotenmarker mit Leerraum).", severity="warning")
return
names = [annotations.COLOR_NAMES[c] for c in annotations.COLORS] + ["Gelb mit Notiz …"]
colors = list(annotations.COLORS)
def picked(i: int | None) -> None:
if i is None:
return # the selection stays: pick again or Esc
if i < len(colors):
self._add_highlight(anchor, colors[i], "")
else:
self.app.push_screen(NoteScreen("Notiz zur Markierung"), lambda text: self._add_highlight(anchor, "yellow", text) if text is not None else None)
from .library_screen import ChoiceScreen
self.app.push_screen(ChoiceScreen("Markieren als", names, 0), picked)
def _add_highlight(self, anchor: dict, color: str, note: str) -> None:
self._ann.add(self._key, "highlights", annotations.new_highlight(anchor, color, note))
self._sel = None
self._annotations_changed()
def _selection_note(self) -> None:
"""n while marking: a freeform note at the cursor's word (phase 1) or on the selection."""
if self._sel["phase"] == "start":
block, s, e = self._cursor_word()
start, end = (block, s), (block, e)
else:
start, end = self._selection_range()
anchor = self._marks.anchor(start, end)
if anchor is None:
self.notify("Diese Stelle lässt sich nicht genau zuordnen.", severity="warning")
return
def noted(text: str | None) -> None:
if text:
self._ann.add(self._key, "highlights", annotations.new_note(anchor, text))
self._sel = None
self._annotations_changed()
self.app.push_screen(NoteScreen("Notiz"), noted)

View file

@ -1,27 +1,24 @@
"""Fetch + decrypt books from a diora server into the local TUI library, and """Talk to the diora server: pull the book list and reading state, push queued positions,
pull reading progress for them into the local progress store. download a book when it is opened.
Progress is pull-only here (server -> local); the reader pushes local -> Reading state follows docs/progress-sync.md (progress.py holds the rules). Books are listed
server itself while a book is open (see reader_screen.py). Both directions from the server's list and fetched only when opened (the file is encrypted: decrypted here
use the same "blockIndex:innerFraction" anchor format as the web reader with the account's key and stored as a plain .epub in the library folder).
(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 from __future__ import annotations
import json import json
import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import requests import requests
from . import api, crypto, library_meta from . import annotations, api, crypto, library, library_meta, progress
from .config import RemoteConfig from .config import RemoteConfig
from .epub import _book_id from .epub import server_key
from .progress import Position, save as save_progress
class SyncError(Exception): class SyncError(Exception):
@ -30,11 +27,11 @@ class SyncError(Exception):
@dataclass @dataclass
class SyncResult: class SyncResult:
downloaded: list[str] = field(default_factory=list) books: int = 0
unchanged: int = 0
skipped_pdf: list[str] = field(default_factory=list) skipped_pdf: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list) failed: list[str] = field(default_factory=list)
progress_pulled: int = 0 pushed: int = 0
new_titles: list[str] = field(default_factory=list)
def _sanitize_filename(name: str) -> str: def _sanitize_filename(name: str) -> str:
@ -42,69 +39,155 @@ def _sanitize_filename(name: str) -> str:
return (name or "buch")[:80] return (name or "buch")[:80]
def _parse_meta(raw: bytes) -> dict: def local_path(library_dir: Path, server_id: int) -> Path | None:
return json.loads(raw.decode("utf-8")) return next(library_dir.glob(f"{server_id:04d} - *.epub"), None)
def flush_pending(cfg: RemoteConfig) -> int:
"""Send every queued position. Stops quietly at the first network trouble (the rest stays
queued); a book the server no longer has drops its queue."""
store = progress.default()
pushed = 0
for key in store.pending_keys():
st = store.get(key)
if not (key.startswith("s") and key[1:].isdigit() and st and st.pending):
continue
body = dict(st.pending)
try:
answer = api.post_progress(
cfg.server_url, cfg.api_token, int(key[1:]), anchor=body["anchor"], fraction=body["fraction"],
read_at=body["read_at"], device=progress.DEVICE_NAME, force=bool(body.get("force")),
)
except api.ApiError as e:
if "nicht gefunden" in str(e):
store.sent(key, body, None)
continue
raise SyncError(str(e)) from e
except requests.RequestException:
break
store.sent(key, body, answer)
pushed += 1
return pushed
def pull_annotations(cfg: RemoteConfig, server_id: int, key: str) -> bool:
"""The server's highlights and bookmarks of the book, laid under our queued edits. False when
offline or the key doesn't open them (what is local stays)."""
store = annotations.default()
try:
for kind in annotations.KINDS:
theirs = annotations.decode(cfg.enc_key_b64, api.fetch_annotations(cfg.server_url, cfg.api_token, server_id, kind))
store.take_remote(key, kind, theirs)
except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException):
return False
return True
def push_annotations(cfg: RemoteConfig) -> int:
"""Write every queued list: merged with the server's current one first (annotations.py).
Stops quietly at network trouble; the rest stays queued."""
store = annotations.default()
written = 0
for key in store.dirty_keys():
if not (key.startswith("s") and key[1:].isdigit()):
continue
book_id = int(key[1:])
for kind in annotations.KINDS:
if not store.get(key).dirty[kind]:
continue
try:
theirs = annotations.decode(cfg.enc_key_b64, api.fetch_annotations(cfg.server_url, cfg.api_token, book_id, kind))
merged, snapshot = store.for_push(key, kind, theirs)
blob = annotations.encode(cfg.enc_key_b64, merged)
if len(blob["ct"]) * 3 // 4 > annotations.MAX_BYTES[kind]:
raise SyncError("Zu viele Markierungen für dieses Buch (Grenze des Servers erreicht).")
api.post_annotations(cfg.server_url, cfg.api_token, book_id, kind, blob)
except api.ApiError as e:
if "nicht gefunden" in str(e):
continue # the book is gone
raise SyncError(str(e)) from e
except (crypto.DecryptError, ValueError) as e:
raise SyncError("Die Markierungen auf dem Server lassen sich nicht entschlüsseln.") from e
except requests.RequestException:
return written
store.pushed(key, kind, merged, snapshot)
written += 1
return written
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult: def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
"""Push what is queued, then pull the book list and every book's reading state."""
result = SyncResult()
try: try:
snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token) result.pushed = flush_pending(cfg)
try:
push_annotations(cfg)
except SyncError:
pass # the notes stay queued; the book list is what this call is for
books, server_now, device_now = api.fetch_books(cfg.server_url, cfg.api_token)
except api.ApiError as e: except api.ApiError as e:
raise SyncError(str(e)) from e raise SyncError(str(e)) from e
except requests.RequestException as e: except requests.RequestException as e:
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
library_dir.mkdir(parents=True, exist_ok=True) store = progress.default()
result = SyncResult() store.set_server_now(server_now, device_now)
local_path_by_server_id: dict[int, Path] = {} known = library_meta.remote_entries()
entries: dict[str, dict] = {}
for book in snapshot.get("books", []): for b in books:
book_id = book["id"]
try: try:
meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"])) meta = json.loads(crypto.decrypt(cfg.enc_key_b64, b["meta_iv"], b["meta_ct"]).decode("utf-8"))
except crypto.DecryptError: except (crypto.DecryptError, ValueError):
result.failed.append(f"#{book_id}") result.failed.append(f"#{b['id']}")
continue continue
title = meta.get("title") or f"Buch {b['id']}"
if meta.get("type") == "pdf": if meta.get("type") == "pdf":
result.skipped_pdf.append(meta.get("title") or f"#{book_id}") result.skipped_pdf.append(title)
continue continue
key = server_key(b["id"])
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None) entries[key] = {"server_id": b["id"], "title": title, "author": meta.get("author") or "",
if existing is not None: "is_read": bool(b.get("is_read")), "uploaded_at": b.get("uploaded_at") or "",
result.unchanged += 1 "folders": library.folders_of_meta(meta), "pages": int(meta.get("pages") or 0),
local_path_by_server_id[book_id] = existing "magazine": meta.get("magazine") if isinstance(meta.get("magazine"), bool) else None}
continue if key not in known:
result.new_titles.append(title)
try: store.apply_server(key, b)
data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id) library_meta.set_remote(entries)
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"]) result.books = len(entries)
except (api.ApiError, crypto.DecryptError, requests.RequestException): _pull_folder_order(cfg)
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 return result
def _pull_folder_order(cfg: RemoteConfig) -> None:
"""The folders' order: encrypted library prefs {folder_order: [...]}. Best effort."""
try:
prefs = api.fetch_prefs(cfg.server_url, cfg.api_token)
if not prefs.get("prefs_ct"):
return
plain = json.loads(crypto.decrypt(cfg.enc_key_b64, prefs["prefs_iv"], prefs["prefs_ct"]).decode("utf-8"))
order = plain.get("folder_order")
if isinstance(order, list):
library_meta.set_extra("folder_order", [f for f in order if isinstance(f, str)])
except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException):
pass
def download_book(library_dir: Path, cfg: RemoteConfig, server_id: int, title: str) -> Path:
existing = local_path(library_dir, server_id)
if existing is not None:
return existing
try:
data = api.fetch_book_data(cfg.server_url, cfg.api_token, server_id)
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
except api.ApiError as e:
raise SyncError(str(e)) from e
except crypto.DecryptError as e:
raise SyncError("Das Buch lässt sich mit dem gespeicherten Schlüssel nicht entschlüsseln.") 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)
dest = library_dir / f"{server_id:04d} - {_sanitize_filename(title)}.epub"
tmp = dest.with_suffix(".part")
tmp.write_bytes(raw)
os.replace(tmp, dest)
return dest

View file

@ -0,0 +1,55 @@
"""Protect the account's token and book key at rest.
Windows: DPAPI (CryptProtectData), bound to this Windows user on this machine, as
tools/diora_client.py in diora-android does. Elsewhere there is no such store in the standard
library: the blob is plain base64 in a 0600 file (the same exposure the web client has with
its key in localStorage), marked as such so a later version can move it into a keyring.
"""
from __future__ import annotations
import base64
import sys
_ENTROPY = b"diora-tui"
def _dpapi(fn_name: str, data: bytes) -> bytes:
import ctypes
import ctypes.wintypes as wt
class Blob(ctypes.Structure):
_fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
def blob(b: bytes):
buf = ctypes.create_string_buffer(b, len(b))
return Blob(len(b), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))), buf
inp, _k1 = blob(data)
ent, _k2 = blob(_ENTROPY)
out = Blob()
fn = getattr(ctypes.windll.crypt32, fn_name)
if not fn(ctypes.byref(inp), None, ctypes.byref(ent), None, None, 1, ctypes.byref(out)): # 1: no UI
raise ctypes.WinError()
try:
return ctypes.string_at(out.pbData, out.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out.pbData)
def protect(data: bytes) -> str:
if sys.platform == "win32":
return "dpapi:" + base64.b64encode(_dpapi("CryptProtectData", data)).decode()
return "plain:" + base64.b64encode(data).decode()
def unprotect(blob: str) -> bytes:
kind, _, b64 = blob.partition(":")
raw = base64.b64decode(b64)
if kind == "dpapi":
if sys.platform != "win32":
raise ValueError("Zugangsdaten sind mit Windows-DPAPI geschützt und nur dort lesbar")
return _dpapi("CryptUnprotectData", raw)
if kind == "plain":
return raw
raise ValueError("unbekanntes Format der gespeicherten Zugangsdaten")

View file

@ -8,6 +8,7 @@ dependencies = [
"ebooklib>=0.18", "ebooklib>=0.18",
"beautifulsoup4>=4.12", "beautifulsoup4>=4.12",
"lxml>=5.0", "lxml>=5.0",
"html5lib>=1.1",
"platformdirs>=4.0", "platformdirs>=4.0",
"requests>=2.31", "requests>=2.31",
"cryptography>=42.0", "cryptography>=42.0",

55
tui/tests/conftest.py Normal file
View file

@ -0,0 +1,55 @@
"""Tests run against a throwaway home: progress, config and caches are module-level
paths from platformdirs, read at import time, so the environment is set before anything
from diora_tui is imported."""
import os
import tempfile
import zipfile
from pathlib import Path
_HOME = tempfile.mkdtemp(prefix="diora-tui-test-")
for var in ("LOCALAPPDATA", "APPDATA", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"):
os.environ[var] = _HOME
os.environ["HOME"] = _HOME
os.environ["DIORA_TUI_HOME"] = _HOME # platformdirs ignores the variables above on Windows
import pytest # noqa: E402
CONTAINER = """<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>"""
def make_epub(path: Path, chapters: list[str], title: str = "Testbuch") -> Path:
"""A minimal EPUB whose spine is `chapters` (each one the full XHTML of a document)."""
items = "".join(f'<item id="c{i}" href="c{i}.xhtml" media-type="application/xhtml+xml"/>' for i in range(len(chapters)))
spine = "".join(f'<itemref idref="c{i}"/>' for i in range(len(chapters)))
opf = f"""<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>{title}</dc:title><dc:creator>Autor</dc:creator>
<dc:identifier id="id">test-{title}</dc:identifier><dc:language>de</dc:language></metadata>
<manifest>{items}</manifest><spine>{spine}</spine></package>"""
with zipfile.ZipFile(path, "w") as z:
z.writestr("mimetype", "application/epub+zip")
z.writestr("META-INF/container.xml", CONTAINER)
z.writestr("OEBPS/content.opf", opf)
for i, body in enumerate(chapters):
z.writestr(f"OEBPS/c{i}.xhtml", f'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>x</title></head><body>{body}</body></html>')
return path
@pytest.fixture
def epub(tmp_path):
def build(chapters, title="Testbuch"):
return make_epub(tmp_path / f"{title}.epub", chapters, title)
return build
@pytest.fixture(autouse=True)
def clean_library_meta():
"""library.json is one file in the shared test home: every test starts without it."""
from diora_tui import library_meta
library_meta._META_FILE.unlink(missing_ok=True)
yield
library_meta._META_FILE.unlink(missing_ok=True)

Some files were not shown because too many files have changed in this diff Show more