Compare commits
43 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a9b618858 | |||
| 55239f71a0 | |||
| e8bb70feb0 | |||
| 21cf91a875 | |||
| d4a47064b2 | |||
| cedc14f714 | |||
| ae5341d441 | |||
| 9659140f30 | |||
| d059ed82c8 | |||
| efdc0ffc11 | |||
| bd09d81366 | |||
| a937ed8b3e | |||
| 69442f4a4d | |||
| 810cf7741c | |||
| dbd4fb6e3a | |||
| f242605a97 | |||
| 1e36d8600b | |||
| 0d712a7f21 | |||
| 066d0052d2 | |||
| 429b875faf | |||
| 65a01569f9 | |||
| 6a3cd23095 | |||
| ec704a086f | |||
| a963841bc4 | |||
| c2a9873b0d | |||
| 04548171bf | |||
| 67f983dd1c | |||
| 077e6d4543 | |||
| 3eca4d8404 | |||
| d702a27d16 | |||
| e8b525a3de | |||
| 6bb774adb9 | |||
| 308eed150a | |||
|
|
14a45f4cb4 | ||
|
|
593b72f17f | ||
|
|
e9683fbdbd | ||
|
|
20d04361bc | ||
|
|
b1a04d2a65 | ||
|
|
db3f520632 | ||
|
|
50f711831c | ||
|
|
a4b10ae265 | ||
|
|
3fcb74631c | ||
|
|
f63bd1f879 |
93 changed files with 10954 additions and 699 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
*.pdf binary
|
||||||
134
accounts/keyshare.py
Normal file
134
accounts/keyshare.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""Passing the book key from an unlocked device to a new one (models.KeyShare).
|
||||||
|
|
||||||
|
The book key never reaches the server in the clear: the new device posts an ECDH P-256
|
||||||
|
public key, an unlocked device of the same account answers with its own public key and
|
||||||
|
the book key AES-GCM-encrypted under the shared secret. Both devices show a code made
|
||||||
|
from the request's public key (static/js/app/33-unlock.js), so the user approves the
|
||||||
|
right device. Requests run out after a few minutes; an answer is handed out once.
|
||||||
|
|
||||||
|
POST /accounts/keyshare/ {pub, device} -> {token}
|
||||||
|
GET /accounts/keyshare/<token>/ -> {state, pub, iv, ct}
|
||||||
|
GET /accounts/keyshare/pending/ -> {requests: [...]}
|
||||||
|
POST /accounts/keyshare/<token>/answer/ {pub, iv, ct} | {decline: true}
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from .models import KeyShare
|
||||||
|
|
||||||
|
LIFETIME = timedelta(minutes=10)
|
||||||
|
# Only requests whose device asked lately are offered: a closed tab drops out quickly.
|
||||||
|
ALIVE = timedelta(seconds=20)
|
||||||
|
MAX_OPEN = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _body(request):
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body or b'{}')
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
return body if isinstance(body, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(value, low, high):
|
||||||
|
"""A base64 string whose decoded length lies in [low, high], else None."""
|
||||||
|
if not isinstance(value, str) or len(value) > 4 * high:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(value, validate=True)
|
||||||
|
except (binascii.Error, ValueError):
|
||||||
|
return None
|
||||||
|
return value if low <= len(raw) <= high else None
|
||||||
|
|
||||||
|
|
||||||
|
def _purge(user):
|
||||||
|
KeyShare.objects.filter(user=user, created_at__lt=timezone.now() - LIFETIME).delete()
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def create(request):
|
||||||
|
if (r := _auth(request)):
|
||||||
|
return r
|
||||||
|
body = _body(request)
|
||||||
|
pub = _b64((body or {}).get('pub'), 65, 65) # uncompressed P-256 point
|
||||||
|
if pub is None:
|
||||||
|
return JsonResponse({'error': 'pub required'}, status=400)
|
||||||
|
_purge(request.user)
|
||||||
|
# A device that reloads asks anew: the oldest requests make room instead of blocking it.
|
||||||
|
stale = KeyShare.objects.filter(user=request.user).order_by('-created_at').values_list('pk', flat=True)[MAX_OPEN - 1:]
|
||||||
|
KeyShare.objects.filter(pk__in=list(stale)).delete()
|
||||||
|
device = str(body.get('device') or '')[:100]
|
||||||
|
share = KeyShare.objects.create(user=request.user, request_pub=pub, device=device,
|
||||||
|
polled_at=timezone.now())
|
||||||
|
return JsonResponse({'token': share.token})
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def status(request, token):
|
||||||
|
if (r := _auth(request)):
|
||||||
|
return r
|
||||||
|
_purge(request.user)
|
||||||
|
share = KeyShare.objects.filter(user=request.user, token=token).first()
|
||||||
|
if share is None:
|
||||||
|
return JsonResponse({'state': 'expired'})
|
||||||
|
if share.declined:
|
||||||
|
share.delete()
|
||||||
|
return JsonResponse({'state': 'declined'})
|
||||||
|
if not share.answer_ct:
|
||||||
|
share.polled_at = timezone.now()
|
||||||
|
share.save(update_fields=['polled_at'])
|
||||||
|
return JsonResponse({'state': 'pending'})
|
||||||
|
data = {'state': 'answered', 'pub': share.answer_pub, 'iv': share.answer_iv, 'ct': share.answer_ct}
|
||||||
|
share.delete() # handed out once
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def pending(request):
|
||||||
|
if (r := _auth(request)):
|
||||||
|
return r
|
||||||
|
_purge(request.user)
|
||||||
|
open_ = KeyShare.objects.filter(user=request.user, declined=False, answer_ct='',
|
||||||
|
polled_at__gte=timezone.now() - ALIVE).order_by('-created_at')
|
||||||
|
return JsonResponse({'requests': [
|
||||||
|
{'token': s.token, 'pub': s.request_pub, 'device': s.device,
|
||||||
|
'created_at': s.created_at.isoformat()} for s in open_]})
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def answer(request, token):
|
||||||
|
if (r := _auth(request)):
|
||||||
|
return r
|
||||||
|
_purge(request.user)
|
||||||
|
share = KeyShare.objects.filter(user=request.user, token=token).first()
|
||||||
|
if share is None:
|
||||||
|
return JsonResponse({'error': 'expired'}, status=404)
|
||||||
|
if share.answer_ct or share.declined:
|
||||||
|
return JsonResponse({'error': 'already answered'}, status=409)
|
||||||
|
body = _body(request) or {}
|
||||||
|
if body.get('decline'):
|
||||||
|
share.declined = True
|
||||||
|
share.answered_at = timezone.now()
|
||||||
|
share.save(update_fields=['declined', 'answered_at'])
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
pub = _b64(body.get('pub'), 65, 65)
|
||||||
|
iv = _b64(body.get('iv'), 12, 12)
|
||||||
|
ct = _b64(body.get('ct'), 32 + 16, 32 + 16) # 256-bit key + GCM tag
|
||||||
|
if not (pub and iv and ct):
|
||||||
|
return JsonResponse({'error': 'pub, iv and ct required'}, status=400)
|
||||||
|
share.answer_pub, share.answer_iv, share.answer_ct = pub, iv, ct
|
||||||
|
share.answered_at = timezone.now()
|
||||||
|
share.save(update_fields=['answer_pub', 'answer_iv', 'answer_ct', 'answered_at'])
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
50
accounts/migrations/0008_oidc_keyshare.py
Normal file
50
accounts/migrations/0008_oidc_keyshare.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Generated by Django 6.1 on 2026-11-01 13:35
|
||||||
|
|
||||||
|
import accounts.models
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0007_uservault'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='KeyShare',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('token', models.CharField(default=accounts.models._generate_token, max_length=64, unique=True)),
|
||||||
|
('device', models.CharField(blank=True, max_length=100)),
|
||||||
|
('request_pub', models.TextField()),
|
||||||
|
('answer_pub', models.TextField(blank=True)),
|
||||||
|
('answer_iv', models.CharField(blank=True, max_length=64)),
|
||||||
|
('answer_ct', models.TextField(blank=True)),
|
||||||
|
('declined', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('polled_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('answered_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='key_shares', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OidcIdentity',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('issuer', models.CharField(max_length=200)),
|
||||||
|
('sub', models.CharField(max_length=255)),
|
||||||
|
('username', models.CharField(blank=True, max_length=150)),
|
||||||
|
('email', models.CharField(blank=True, max_length=254)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('last_login_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oidc_identities', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('issuer', 'sub')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -142,3 +142,40 @@ class ApiToken(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"ApiToken({self.user.username})"
|
return f"ApiToken({self.user.username})"
|
||||||
|
|
||||||
|
|
||||||
|
class OidcIdentity(models.Model):
|
||||||
|
"""An account of the sign-in provider (Authelia) linked to a diora account
|
||||||
|
(accounts/oidc.py). `sub` is the provider's stable id; name and mail are only shown."""
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='oidc_identities')
|
||||||
|
issuer = models.CharField(max_length=200)
|
||||||
|
sub = models.CharField(max_length=255)
|
||||||
|
username = models.CharField(max_length=150, blank=True)
|
||||||
|
email = models.CharField(max_length=254, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
last_login_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('issuer', 'sub')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"OidcIdentity({self.username or self.sub} -> {self.user_id})"
|
||||||
|
|
||||||
|
|
||||||
|
class KeyShare(models.Model):
|
||||||
|
"""A device without the book key asks an unlocked device of the same account for it
|
||||||
|
(accounts/keyshare.py). The server only passes on public keys and ciphertext: the new
|
||||||
|
device sends an ECDH public key, the unlocked one answers with its own public key and
|
||||||
|
the book key encrypted for the shared secret. Both show a code made from the request's
|
||||||
|
public key, so the user can see the answer goes to the right device."""
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='key_shares')
|
||||||
|
token = models.CharField(max_length=64, unique=True, default=_generate_token)
|
||||||
|
device = models.CharField(max_length=100, blank=True)
|
||||||
|
request_pub = models.TextField() # base64 raw P-256 point of the asking device
|
||||||
|
answer_pub = models.TextField(blank=True)
|
||||||
|
answer_iv = models.CharField(max_length=64, blank=True)
|
||||||
|
answer_ct = models.TextField(blank=True)
|
||||||
|
declined = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
polled_at = models.DateTimeField(null=True, blank=True) # the asking device still waits
|
||||||
|
answered_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
|
||||||
165
accounts/oidc.py
Normal file
165
accounts/oidc.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""Sign-in with an OpenID Connect provider (Authelia, docs/accounts.md).
|
||||||
|
|
||||||
|
An extra way in next to username + password: an account is linked once in the settings
|
||||||
|
while signed in normally, after that the provider's login leads into it. Nobody gets a new
|
||||||
|
account from the provider — an unlinked identity is turned away.
|
||||||
|
|
||||||
|
Authorization code flow with PKCE (S256) and a confidential client (client_secret_basic).
|
||||||
|
The id_token is checked here (RS256 against the provider's JWKS, iss, aud, exp, nonce)
|
||||||
|
with `cryptography`, so no OIDC library is needed.
|
||||||
|
|
||||||
|
The book key is not touched: it is derived from the diora password on the device. A device
|
||||||
|
that signs in this way gets it from an unlocked device or by typing the password once
|
||||||
|
(accounts/keyshare.py, static/js/app/33-unlock.js).
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
TIMEOUT = 10
|
||||||
|
_CACHE_SECONDS = 3600
|
||||||
|
_cache = {} # url -> (fetched_at, json)
|
||||||
|
|
||||||
|
|
||||||
|
class OidcError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID and settings.OIDC_CLIENT_SECRET)
|
||||||
|
|
||||||
|
|
||||||
|
def key_enabled() -> bool:
|
||||||
|
return enabled() and bool(settings.OIDC_APP_CLIENT_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url(b: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(b).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url: str, fresh: bool = False) -> dict:
|
||||||
|
hit = _cache.get(url)
|
||||||
|
if hit and not fresh and time.time() - hit[0] < _CACHE_SECONDS:
|
||||||
|
return hit[1]
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except (requests.RequestException, ValueError) as exc:
|
||||||
|
raise OidcError(f'{url} nicht erreichbar: {exc}') from exc
|
||||||
|
_cache[url] = (time.time(), data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def discovery() -> dict:
|
||||||
|
issuer = settings.OIDC_ISSUER.rstrip('/')
|
||||||
|
return _get_json(issuer + '/.well-known/openid-configuration')
|
||||||
|
|
||||||
|
|
||||||
|
def new_flow() -> dict:
|
||||||
|
"""Random state, nonce and PKCE verifier for one sign-in (kept in the session)."""
|
||||||
|
return {
|
||||||
|
'state': secrets.token_urlsafe(24),
|
||||||
|
'nonce': secrets.token_urlsafe(24),
|
||||||
|
'verifier': secrets.token_urlsafe(48),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_url(flow: dict, redirect_uri: str) -> str:
|
||||||
|
challenge = _b64url(hashlib.sha256(flow['verifier'].encode('ascii')).digest())
|
||||||
|
params = {
|
||||||
|
'response_type': 'code',
|
||||||
|
'client_id': settings.OIDC_CLIENT_ID,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
'scope': 'openid profile email groups',
|
||||||
|
'state': flow['state'],
|
||||||
|
'nonce': flow['nonce'],
|
||||||
|
'code_challenge': challenge,
|
||||||
|
'code_challenge_method': 'S256',
|
||||||
|
}
|
||||||
|
return discovery()['authorization_endpoint'] + '?' + urlencode(params)
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code(code: str, flow: dict, redirect_uri: str) -> dict:
|
||||||
|
"""Trade the code for tokens and return the checked id_token claims."""
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
discovery()['token_endpoint'],
|
||||||
|
data={
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': code,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
'code_verifier': flow['verifier'],
|
||||||
|
},
|
||||||
|
auth=(settings.OIDC_CLIENT_ID, settings.OIDC_CLIENT_SECRET),
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise OidcError(f'Token-Abruf fehlgeschlagen: {exc}') from exc
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise OidcError(f'Token-Abruf abgelehnt ({resp.status_code})')
|
||||||
|
try:
|
||||||
|
id_token = resp.json()['id_token']
|
||||||
|
except (ValueError, KeyError) as exc:
|
||||||
|
raise OidcError('Antwort ohne id_token') from exc
|
||||||
|
return verify_id_token(id_token, flow['nonce'])
|
||||||
|
|
||||||
|
|
||||||
|
def _public_key(kid: str):
|
||||||
|
jwks_uri = discovery()['jwks_uri']
|
||||||
|
for fresh in (False, True): # a key rotation shows up as an unknown kid: fetch again once
|
||||||
|
for jwk in _get_json(jwks_uri, fresh=fresh).get('keys', []):
|
||||||
|
if jwk.get('kty') == 'RSA' and (not kid or jwk.get('kid') == kid):
|
||||||
|
n = int.from_bytes(_b64url_decode(jwk['n']), 'big')
|
||||||
|
e = int.from_bytes(_b64url_decode(jwk['e']), 'big')
|
||||||
|
return rsa.RSAPublicNumbers(e, n).public_key()
|
||||||
|
raise OidcError('Signaturschlüssel des Anbieters nicht gefunden')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_id_token(token: str, nonce, now: float = None, client_id: str = None) -> dict:
|
||||||
|
"""Check signature and claims. `nonce` is the expected value, or a function that says
|
||||||
|
whether the token's nonce is good (the apps' signed ones, oidc_views.app_login)."""
|
||||||
|
try:
|
||||||
|
header_b64, payload_b64, sig_b64 = token.split('.')
|
||||||
|
header = json.loads(_b64url_decode(header_b64))
|
||||||
|
claims = json.loads(_b64url_decode(payload_b64))
|
||||||
|
signature = _b64url_decode(sig_b64)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise OidcError('id_token unlesbar') from exc
|
||||||
|
if header.get('alg') != 'RS256':
|
||||||
|
raise OidcError(f'nicht unterstützte Signatur {header.get("alg")!r} (erwartet RS256)')
|
||||||
|
try:
|
||||||
|
_public_key(header.get('kid', '')).verify(
|
||||||
|
signature, f'{header_b64}.{payload_b64}'.encode('ascii'),
|
||||||
|
padding.PKCS1v15(), hashes.SHA256())
|
||||||
|
except InvalidSignature as exc:
|
||||||
|
raise OidcError('Signatur des id_token ungültig') from exc
|
||||||
|
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
if claims.get('iss', '').rstrip('/') != settings.OIDC_ISSUER.rstrip('/'):
|
||||||
|
raise OidcError('falscher Aussteller')
|
||||||
|
aud = claims.get('aud')
|
||||||
|
if (client_id or settings.OIDC_CLIENT_ID) not in (aud if isinstance(aud, list) else [aud]):
|
||||||
|
raise OidcError('id_token ist nicht für diora')
|
||||||
|
if not isinstance(claims.get('exp'), (int, float)) or claims['exp'] < now - 60:
|
||||||
|
raise OidcError('id_token abgelaufen')
|
||||||
|
good = nonce(claims.get('nonce') or '') if callable(nonce) else (nonce and claims.get('nonce') == nonce)
|
||||||
|
if not good:
|
||||||
|
raise OidcError('nonce passt nicht')
|
||||||
|
if not claims.get('sub'):
|
||||||
|
raise OidcError('id_token ohne sub')
|
||||||
|
return claims
|
||||||
245
accounts/oidc_views.py
Normal file
245
accounts/oidc_views.py
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
"""Sign-in with Authelia and linking it to an existing account (accounts/oidc.py)."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import login
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core import signing
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from . import oidc
|
||||||
|
from .models import ApiToken, OidcIdentity
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SESSION_KEY = 'oidc_flow'
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_uri(request):
|
||||||
|
return settings.OIDC_REDIRECT_URI or request.build_absolute_uri('/accounts/oidc/callback/')
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_next(request, value):
|
||||||
|
if value and url_has_allowed_host_and_scheme(value, allowed_hosts={request.get_host()},
|
||||||
|
require_https=request.is_secure()):
|
||||||
|
return value
|
||||||
|
return '/'
|
||||||
|
|
||||||
|
|
||||||
|
def oidc_context(request):
|
||||||
|
"""What the settings page shows about the provider."""
|
||||||
|
return {
|
||||||
|
'oidc_enabled': oidc.enabled(),
|
||||||
|
'oidc_provider': settings.OIDC_PROVIDER_NAME,
|
||||||
|
'oidc_identities': list(request.user.oidc_identities.all()) if request.user.is_authenticated else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def oidc_start(request):
|
||||||
|
"""Off to the provider. `?link=1` (signed in) links the account instead of signing in."""
|
||||||
|
if not oidc.enabled():
|
||||||
|
messages.error(request, 'Die Anmeldung über Authelia ist auf diesem Server nicht eingerichtet.')
|
||||||
|
return redirect('login')
|
||||||
|
link = request.GET.get('link') == '1'
|
||||||
|
if link and not request.user.is_authenticated:
|
||||||
|
return redirect('login')
|
||||||
|
flow = oidc.new_flow()
|
||||||
|
flow['link'] = link
|
||||||
|
flow['next'] = _safe_next(request, request.GET.get('next', ''))
|
||||||
|
try:
|
||||||
|
url = oidc.authorize_url(flow, _redirect_uri(request))
|
||||||
|
except oidc.OidcError as exc:
|
||||||
|
log.warning('oidc start: %s', exc)
|
||||||
|
messages.error(request, 'Authelia ist gerade nicht erreichbar.')
|
||||||
|
return redirect('settings' if link else 'login')
|
||||||
|
request.session[SESSION_KEY] = flow
|
||||||
|
return redirect(url)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def oidc_callback(request):
|
||||||
|
flow = request.session.pop(SESSION_KEY, None)
|
||||||
|
fail_to = 'login'
|
||||||
|
if flow and flow.get('link'):
|
||||||
|
fail_to = 'settings'
|
||||||
|
if not flow or request.GET.get('state') != flow.get('state'):
|
||||||
|
messages.error(request, 'Die Anmeldung über Authelia ist abgelaufen. Bitte noch einmal.')
|
||||||
|
return redirect(fail_to)
|
||||||
|
if request.GET.get('error'):
|
||||||
|
messages.error(request, 'Authelia hat die Anmeldung abgebrochen.')
|
||||||
|
return redirect(fail_to)
|
||||||
|
code = request.GET.get('code', '')
|
||||||
|
try:
|
||||||
|
claims = oidc.exchange_code(code, flow, _redirect_uri(request))
|
||||||
|
except oidc.OidcError as exc:
|
||||||
|
log.warning('oidc callback: %s', exc)
|
||||||
|
messages.error(request, 'Die Anmeldung über Authelia hat nicht geklappt.')
|
||||||
|
return redirect(fail_to)
|
||||||
|
|
||||||
|
issuer = settings.OIDC_ISSUER.rstrip('/')
|
||||||
|
sub = claims['sub']
|
||||||
|
shown = {
|
||||||
|
'username': (claims.get('preferred_username') or '')[:150],
|
||||||
|
'email': (claims.get('email') or '')[:254],
|
||||||
|
}
|
||||||
|
identity = OidcIdentity.objects.filter(issuer=issuer, sub=sub).select_related('user').first()
|
||||||
|
|
||||||
|
if flow.get('link'):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return redirect('login')
|
||||||
|
if identity and identity.user_id != request.user.id:
|
||||||
|
messages.error(request, 'Dieses Authelia-Konto ist schon mit einem anderen diora-Konto verknüpft.')
|
||||||
|
return redirect('settings')
|
||||||
|
if identity is None:
|
||||||
|
OidcIdentity.objects.create(user=request.user, issuer=issuer, sub=sub, **shown)
|
||||||
|
else:
|
||||||
|
OidcIdentity.objects.filter(pk=identity.pk).update(**shown)
|
||||||
|
messages.success(request, f'Authelia-Konto „{shown["username"] or sub}“ verknüpft. '
|
||||||
|
'Du kannst dich jetzt auch darüber anmelden.')
|
||||||
|
if oidc.key_enabled(): # leave the book key with Authelia's help right away
|
||||||
|
return redirect('/accounts/oidc/key/?next=/accounts/settings/')
|
||||||
|
return redirect('settings')
|
||||||
|
|
||||||
|
if identity is None:
|
||||||
|
messages.error(request, 'Dieses Authelia-Konto ist mit keinem diora-Konto verknüpft. '
|
||||||
|
'Melde dich einmal mit Passwort an und verknüpfe es in den Einstellungen.')
|
||||||
|
return redirect('login')
|
||||||
|
if not identity.user.is_active:
|
||||||
|
messages.error(request, 'Dieses diora-Konto ist gesperrt.')
|
||||||
|
return redirect('login')
|
||||||
|
OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now(), **shown)
|
||||||
|
login(request, identity.user, backend='django.contrib.auth.backends.ModelBackend')
|
||||||
|
# A password login brings the book key along, this one cannot: the device fetches it
|
||||||
|
# (oidc_key below), else the page asks (static/js/app/33-unlock.js).
|
||||||
|
next_url = flow.get('next') or '/'
|
||||||
|
if oidc.key_enabled():
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
return redirect('/accounts/oidc/key/?' + urlencode({'next': next_url}))
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def oidc_unlink(request, pk):
|
||||||
|
OidcIdentity.objects.filter(user=request.user, pk=pk).delete()
|
||||||
|
messages.success(request, 'Verknüpfung mit Authelia gelöst.')
|
||||||
|
return redirect('settings')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def oidc_key(request):
|
||||||
|
"""The book key with the provider's help, without asking (docs/accounts.md).
|
||||||
|
|
||||||
|
Runs entirely on the device: it signs in once more as the public client
|
||||||
|
(OIDC_APP_CLIENT_ID, PKCE, prompt=none — the provider session from just before makes it
|
||||||
|
invisible), reads its `diora_key` claim from userinfo and with it opens the book key the
|
||||||
|
server keeps wrapped (vault "authelia-key") — or, when the device has the key, wraps and
|
||||||
|
leaves it there. The server never sees the claim; this view only hands out the page.
|
||||||
|
The same page is the redirect target (`/accounts/oidc/key/callback/`)."""
|
||||||
|
next_url = _safe_next(request, request.GET.get('next', ''))
|
||||||
|
if not oidc.key_enabled():
|
||||||
|
return redirect(next_url)
|
||||||
|
try:
|
||||||
|
disco = oidc.discovery()
|
||||||
|
except oidc.OidcError as exc:
|
||||||
|
log.warning('oidc key: %s', exc)
|
||||||
|
return redirect(next_url)
|
||||||
|
config = {
|
||||||
|
'authorize': disco['authorization_endpoint'],
|
||||||
|
'token': disco['token_endpoint'],
|
||||||
|
'userinfo': disco.get('userinfo_endpoint', ''),
|
||||||
|
'client_id': settings.OIDC_APP_CLIENT_ID,
|
||||||
|
'redirect_uri': request.build_absolute_uri('/accounts/oidc/key/callback/')
|
||||||
|
if not settings.OIDC_REDIRECT_URI
|
||||||
|
else settings.OIDC_REDIRECT_URI.replace('/oidc/callback/', '/oidc/key/callback/'),
|
||||||
|
'subs': [i.sub for i in request.user.oidc_identities.all()],
|
||||||
|
'user_id': request.user.id,
|
||||||
|
'next': next_url,
|
||||||
|
}
|
||||||
|
return render(request, 'accounts/oidc_key.html', {'config': config})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The apps (Android, desktop): sign in with Authelia as the public client themselves
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# 1. GET /accounts/oidc/app/ -> where Authelia is, the client id and a nonce
|
||||||
|
# signed by this server (good for APP_NONCE_AGE).
|
||||||
|
# 2. The app runs the code flow with PKCE in the system browser (redirect to its own
|
||||||
|
# scheme or loopback), trades the code itself and reads `diora_key` from userinfo.
|
||||||
|
# 3. POST /accounts/oidc/app/login/ {id_token} -> the account's API token, as the
|
||||||
|
# password login (diora-android Auth.kt) hands it out.
|
||||||
|
# The book key never passes here: the app opens the vault "authelia-key" itself.
|
||||||
|
|
||||||
|
APP_NONCE_SALT = 'diora.oidc.app'
|
||||||
|
APP_NONCE_AGE = 600
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def app_config(request):
|
||||||
|
if not oidc.key_enabled():
|
||||||
|
return JsonResponse({'enabled': False})
|
||||||
|
try:
|
||||||
|
disco = oidc.discovery()
|
||||||
|
except oidc.OidcError as exc:
|
||||||
|
log.warning('oidc app config: %s', exc)
|
||||||
|
return JsonResponse({'enabled': False, 'error': 'provider unreachable'}, status=502)
|
||||||
|
return JsonResponse({
|
||||||
|
'enabled': True,
|
||||||
|
'provider': settings.OIDC_PROVIDER_NAME,
|
||||||
|
'issuer': settings.OIDC_ISSUER.rstrip('/'),
|
||||||
|
'authorize': disco['authorization_endpoint'],
|
||||||
|
'token': disco['token_endpoint'],
|
||||||
|
'userinfo': disco.get('userinfo_endpoint', ''),
|
||||||
|
'client_id': settings.OIDC_APP_CLIENT_ID,
|
||||||
|
'scope': 'openid profile diora_key',
|
||||||
|
'nonce': signing.dumps(secrets.token_urlsafe(16), salt=APP_NONCE_SALT),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _app_nonce_ok(value):
|
||||||
|
try:
|
||||||
|
signing.loads(value, salt=APP_NONCE_SALT, max_age=APP_NONCE_AGE)
|
||||||
|
return True
|
||||||
|
except signing.BadSignature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt # a native client without cookies; the signed id_token is the proof
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def app_login(request):
|
||||||
|
if not oidc.key_enabled():
|
||||||
|
return JsonResponse({'error': 'not enabled'}, status=404)
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body or b'{}')
|
||||||
|
id_token = body['id_token']
|
||||||
|
if not isinstance(id_token, str):
|
||||||
|
raise TypeError
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
return JsonResponse({'error': 'id_token required'}, status=400)
|
||||||
|
try:
|
||||||
|
claims = oidc.verify_id_token(id_token, _app_nonce_ok, client_id=settings.OIDC_APP_CLIENT_ID)
|
||||||
|
except oidc.OidcError as exc:
|
||||||
|
log.warning('oidc app login: %s', exc)
|
||||||
|
return JsonResponse({'error': 'Die Anmeldung über Authelia war ungültig.'}, status=401)
|
||||||
|
identity = OidcIdentity.objects.filter(
|
||||||
|
issuer=settings.OIDC_ISSUER.rstrip('/'), sub=claims['sub']).select_related('user').first()
|
||||||
|
if identity is None:
|
||||||
|
return JsonResponse({'error': 'Dieses Authelia-Konto ist mit keinem diora-Konto verknüpft. '
|
||||||
|
'Verknüpfe es einmal im Browser unter Einstellungen.'}, status=403)
|
||||||
|
if not identity.user.is_active:
|
||||||
|
return JsonResponse({'error': 'Dieses diora-Konto ist gesperrt.'}, status=403)
|
||||||
|
OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now())
|
||||||
|
token, _ = ApiToken.objects.get_or_create(user=identity.user)
|
||||||
|
return JsonResponse({'token': token.token, 'user_id': identity.user.id,
|
||||||
|
'username': identity.user.username, 'sub': claims['sub']})
|
||||||
321
accounts/test_oidc.py
Normal file
321
accounts/test_oidc.py
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from . import oidc
|
||||||
|
from .models import ApiToken, KeyShare, OidcIdentity
|
||||||
|
|
||||||
|
ISSUER = 'https://auth.example.org'
|
||||||
|
PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
OTHER_PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url(b):
|
||||||
|
return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _int_b64(i):
|
||||||
|
return _b64url(i.to_bytes((i.bit_length() + 7) // 8, 'big'))
|
||||||
|
|
||||||
|
|
||||||
|
def make_token(claims, key=PRIV, alg='RS256', kid='k1'):
|
||||||
|
header = _b64url(json.dumps({'alg': alg, 'kid': kid, 'typ': 'JWT'}).encode())
|
||||||
|
payload = _b64url(json.dumps(claims).encode())
|
||||||
|
sig = key.sign(f'{header}.{payload}'.encode(), padding.PKCS1v15(), hashes.SHA256())
|
||||||
|
return f'{header}.{payload}.{_b64url(sig)}'
|
||||||
|
|
||||||
|
|
||||||
|
DISCOVERY = {
|
||||||
|
'issuer': ISSUER,
|
||||||
|
'authorization_endpoint': ISSUER + '/api/oidc/authorization',
|
||||||
|
'token_endpoint': ISSUER + '/api/oidc/token',
|
||||||
|
'jwks_uri': ISSUER + '/jwks.json',
|
||||||
|
}
|
||||||
|
pub = PRIV.public_key().public_numbers()
|
||||||
|
JWKS = {'keys': [{'kty': 'RSA', 'kid': 'k1', 'n': _int_b64(pub.n), 'e': _int_b64(pub.e)}]}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
def __init__(self, data, status=200):
|
||||||
|
self._data, self.status_code = data, status
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise oidc.requests.HTTPError(str(self.status_code))
|
||||||
|
|
||||||
|
|
||||||
|
def fake_get(url, timeout=None):
|
||||||
|
return FakeResp(DISCOVERY if url.endswith('openid-configuration') else JWKS)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(OIDC_ISSUER=ISSUER, OIDC_CLIENT_ID='diora', OIDC_CLIENT_SECRET='s3cret',
|
||||||
|
OIDC_REDIRECT_URI='https://diora.test/accounts/oidc/callback/')
|
||||||
|
class OidcFlowTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
oidc._cache.clear()
|
||||||
|
self.user = User.objects.create_user(username='crm', password='pw12345678')
|
||||||
|
self.get = patch('accounts.oidc.requests.get', side_effect=fake_get)
|
||||||
|
self.get.start()
|
||||||
|
self.addCleanup(self.get.stop)
|
||||||
|
|
||||||
|
def _start(self, query=''):
|
||||||
|
resp = self.client.get('/accounts/oidc/start/' + query)
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
q = parse_qs(urlsplit(resp['Location']).query)
|
||||||
|
self.assertEqual(q['code_challenge_method'], ['S256'])
|
||||||
|
self.assertEqual(q['client_id'], ['diora'])
|
||||||
|
return q['state'][0], q['nonce'][0]
|
||||||
|
|
||||||
|
def _callback(self, state, claims, token_status=200):
|
||||||
|
token = make_token(claims)
|
||||||
|
with patch('accounts.oidc.requests.post',
|
||||||
|
return_value=FakeResp({'id_token': token}, token_status)) as post:
|
||||||
|
resp = self.client.get('/accounts/oidc/callback/', {'state': state, 'code': 'c0de'})
|
||||||
|
return resp, post
|
||||||
|
|
||||||
|
def _claims(self, nonce, **extra):
|
||||||
|
c = {'iss': ISSUER, 'aud': ['diora'], 'sub': 'uuid-1', 'exp': time.time() + 300,
|
||||||
|
'nonce': nonce, 'preferred_username': 'marwin', 'email': 'm@example.org'}
|
||||||
|
c.update(extra)
|
||||||
|
return c
|
||||||
|
|
||||||
|
def test_unlinked_identity_is_turned_away(self):
|
||||||
|
state, nonce = self._start()
|
||||||
|
resp, _ = self._callback(state, self._claims(nonce))
|
||||||
|
self.assertEqual(resp['Location'], '/accounts/login/')
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
self.assertFalse(User.objects.exclude(pk=self.user.pk).exists())
|
||||||
|
|
||||||
|
def test_link_then_login(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
state, nonce = self._start('?link=1')
|
||||||
|
resp, post = self._callback(state, self._claims(nonce))
|
||||||
|
self.assertEqual(resp['Location'], '/accounts/settings/')
|
||||||
|
ident = OidcIdentity.objects.get()
|
||||||
|
self.assertEqual((ident.user, ident.sub, ident.username), (self.user, 'uuid-1', 'marwin'))
|
||||||
|
# client_secret_basic + PKCE verifier
|
||||||
|
self.assertEqual(post.call_args.kwargs['auth'], ('diora', 's3cret'))
|
||||||
|
self.assertIn('code_verifier', post.call_args.kwargs['data'])
|
||||||
|
|
||||||
|
self.client.logout()
|
||||||
|
state, nonce = self._start('?next=/books/')
|
||||||
|
resp, _ = self._callback(state, self._claims(nonce))
|
||||||
|
self.assertEqual(resp['Location'], '/books/')
|
||||||
|
self.assertEqual(int(self.client.session['_auth_user_id']), self.user.pk)
|
||||||
|
ident.refresh_from_db()
|
||||||
|
self.assertIsNotNone(ident.last_login_at)
|
||||||
|
|
||||||
|
def test_next_to_other_host_is_dropped(self):
|
||||||
|
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||||
|
state, nonce = self._start('?next=https://evil.example/')
|
||||||
|
resp, _ = self._callback(state, self._claims(nonce))
|
||||||
|
self.assertEqual(resp['Location'], '/')
|
||||||
|
|
||||||
|
def test_identity_of_other_account_is_not_moved(self):
|
||||||
|
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
|
OidcIdentity.objects.create(user=other, issuer=ISSUER, sub='uuid-1')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
state, nonce = self._start('?link=1')
|
||||||
|
self._callback(state, self._claims(nonce))
|
||||||
|
self.assertEqual(OidcIdentity.objects.get().user, other)
|
||||||
|
|
||||||
|
def test_wrong_state_nonce_audience_or_signature_fail(self):
|
||||||
|
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||||
|
cases = [
|
||||||
|
('state', {}),
|
||||||
|
('nonce', {'nonce': 'other'}),
|
||||||
|
('aud', {'aud': 'someone-else'}),
|
||||||
|
('iss', {'iss': 'https://evil.example'}),
|
||||||
|
('exp', {'exp': time.time() - 3600}),
|
||||||
|
]
|
||||||
|
for name, extra in cases:
|
||||||
|
with self.subTest(name):
|
||||||
|
state, nonce = self._start()
|
||||||
|
claims = {**self._claims(nonce), **extra}
|
||||||
|
resp, _ = self._callback('wrong' if name == 'state' else state, claims)
|
||||||
|
self.assertEqual(resp['Location'], '/accounts/login/')
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
state, nonce = self._start()
|
||||||
|
with patch('accounts.oidc.requests.post',
|
||||||
|
return_value=FakeResp({'id_token': make_token(self._claims(nonce), key=OTHER_PRIV)})):
|
||||||
|
self.client.get('/accounts/oidc/callback/', {'state': state, 'code': 'x'})
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_state_is_single_use(self):
|
||||||
|
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||||
|
state, nonce = self._start()
|
||||||
|
self._callback(state, self._claims(nonce))
|
||||||
|
self.client.logout()
|
||||||
|
resp, _ = self._callback(state, self._claims(nonce))
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_unlink(self):
|
||||||
|
ident = OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||||
|
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
|
foreign = OidcIdentity.objects.create(user=other, issuer=ISSUER, sub='uuid-2')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.client.post(f'/accounts/oidc/{foreign.pk}/unlink/')
|
||||||
|
self.client.post(f'/accounts/oidc/{ident.pk}/unlink/')
|
||||||
|
self.assertEqual(list(OidcIdentity.objects.all()), [foreign])
|
||||||
|
|
||||||
|
def test_with_app_client_login_and_link_go_by_the_key_page(self):
|
||||||
|
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||||
|
with override_settings(OIDC_APP_CLIENT_ID='diora-app'):
|
||||||
|
state, nonce = self._start('?next=/books/')
|
||||||
|
resp, _ = self._callback(state, self._claims(nonce))
|
||||||
|
self.assertEqual(resp['Location'], '/accounts/oidc/key/?next=%2Fbooks%2F')
|
||||||
|
page = self.client.get(resp['Location'])
|
||||||
|
self.assertContains(page, 'oidc-key-config')
|
||||||
|
cfg = page.context['config']
|
||||||
|
self.assertEqual(cfg['client_id'], 'diora-app')
|
||||||
|
self.assertEqual(cfg['redirect_uri'], 'https://diora.test/accounts/oidc/key/callback/')
|
||||||
|
self.assertEqual(cfg['subs'], ['uuid-1'])
|
||||||
|
self.assertEqual(cfg['next'], '/books/')
|
||||||
|
self.assertEqual(cfg['token'], DISCOVERY['token_endpoint'])
|
||||||
|
self.assertEqual(self.client.get('/accounts/oidc/key/callback/?code=x&state=y').status_code, 200)
|
||||||
|
|
||||||
|
state, nonce = self._start('?link=1')
|
||||||
|
resp, _ = self._callback(state, self._claims(nonce))
|
||||||
|
self.assertEqual(resp['Location'], '/accounts/oidc/key/?next=/accounts/settings/')
|
||||||
|
|
||||||
|
def test_key_page_without_app_client_goes_straight_on(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
resp = self.client.get('/accounts/oidc/key/?next=/books/')
|
||||||
|
self.assertEqual(resp['Location'], '/books/')
|
||||||
|
|
||||||
|
def test_login_page_shows_button_only_when_configured(self):
|
||||||
|
self.assertContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden')
|
||||||
|
with override_settings(OIDC_CLIENT_SECRET=''):
|
||||||
|
self.assertNotContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(OIDC_ISSUER=ISSUER, OIDC_CLIENT_ID='diora', OIDC_CLIENT_SECRET='s3cret',
|
||||||
|
OIDC_APP_CLIENT_ID='diora-app')
|
||||||
|
class OidcAppLoginTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
oidc._cache.clear()
|
||||||
|
self.user = User.objects.create_user(username='crm', password='pw12345678')
|
||||||
|
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||||
|
p = patch('accounts.oidc.requests.get', side_effect=fake_get)
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
|
||||||
|
def _nonce(self):
|
||||||
|
cfg = self.client.get('/accounts/oidc/app/').json()
|
||||||
|
self.assertEqual((cfg['client_id'], cfg['token']), ('diora-app', DISCOVERY['token_endpoint']))
|
||||||
|
return cfg['nonce']
|
||||||
|
|
||||||
|
def _login(self, **claims):
|
||||||
|
c = {'iss': ISSUER, 'aud': ['diora-app'], 'sub': 'uuid-1', 'exp': time.time() + 300,
|
||||||
|
'nonce': self._nonce()}
|
||||||
|
c.update(claims)
|
||||||
|
return self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token(c)}),
|
||||||
|
content_type='application/json')
|
||||||
|
|
||||||
|
def test_linked_account_gets_its_existing_token(self):
|
||||||
|
existing = ApiToken.objects.create(user=self.user)
|
||||||
|
resp = self._login()
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.json(), {'token': existing.token, 'user_id': self.user.id,
|
||||||
|
'username': 'crm', 'sub': 'uuid-1'})
|
||||||
|
|
||||||
|
def test_token_is_made_when_there_is_none(self):
|
||||||
|
resp = self._login()
|
||||||
|
self.assertEqual(resp.json()['token'], ApiToken.objects.get(user=self.user).token)
|
||||||
|
|
||||||
|
def test_refused(self):
|
||||||
|
for name, claims, code in [
|
||||||
|
('unlinked', {'sub': 'other'}, 403),
|
||||||
|
('web client token', {'aud': 'diora'}, 401),
|
||||||
|
('own nonce', {'nonce': 'made-up'}, 401),
|
||||||
|
('expired', {'exp': time.time() - 3600}, 401),
|
||||||
|
]:
|
||||||
|
with self.subTest(name):
|
||||||
|
self.assertEqual(self._login(**claims).status_code, code)
|
||||||
|
self.assertEqual(self.client.post('/accounts/oidc/app/login/', '{}',
|
||||||
|
content_type='application/json').status_code, 400)
|
||||||
|
|
||||||
|
def test_old_nonce_is_refused(self):
|
||||||
|
nonce = self._nonce()
|
||||||
|
with patch('django.core.signing.time.time', return_value=time.time() + 3600):
|
||||||
|
resp = self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token(
|
||||||
|
{'iss': ISSUER, 'aud': 'diora-app', 'sub': 'uuid-1', 'exp': time.time() + 7200, 'nonce': nonce})}),
|
||||||
|
content_type='application/json')
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
def test_off_without_app_client(self):
|
||||||
|
with override_settings(OIDC_APP_CLIENT_ID=''):
|
||||||
|
self.assertEqual(self.client.get('/accounts/oidc/app/').json(), {'enabled': False})
|
||||||
|
|
||||||
|
|
||||||
|
PUB = base64.b64encode(b'\x04' + b'\x01' * 64).decode()
|
||||||
|
|
||||||
|
|
||||||
|
class KeyShareTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='crm', password='pw12345678')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def _post(self, url, body):
|
||||||
|
return self.client.post(url, json.dumps(body), content_type='application/json')
|
||||||
|
|
||||||
|
def test_round_trip_hands_answer_out_once(self):
|
||||||
|
token = self._post('/accounts/keyshare/', {'pub': PUB, 'device': 'Firefox auf Linux'}).json()['token']
|
||||||
|
self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'pending'})
|
||||||
|
pending = self.client.get('/accounts/keyshare/pending/').json()['requests']
|
||||||
|
self.assertEqual([(r['token'], r['pub'], r['device']) for r in pending], [(token, PUB, 'Firefox auf Linux')])
|
||||||
|
|
||||||
|
answer = {'pub': PUB, 'iv': base64.b64encode(b'i' * 12).decode(),
|
||||||
|
'ct': base64.b64encode(b'c' * 48).decode()}
|
||||||
|
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', answer).status_code, 200)
|
||||||
|
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', answer).status_code, 409)
|
||||||
|
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
|
||||||
|
got = self.client.get(f'/accounts/keyshare/{token}/').json()
|
||||||
|
self.assertEqual(got, {'state': 'answered', **answer})
|
||||||
|
self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'expired'})
|
||||||
|
|
||||||
|
def test_requests_of_devices_that_stopped_asking_are_not_offered(self):
|
||||||
|
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
|
||||||
|
KeyShare.objects.update(polled_at=KeyShare.objects.get().created_at.replace(year=2000))
|
||||||
|
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
|
||||||
|
self.client.get(f'/accounts/keyshare/{token}/') # asks again
|
||||||
|
self.assertEqual(len(self.client.get('/accounts/keyshare/pending/').json()['requests']), 1)
|
||||||
|
|
||||||
|
def test_decline(self):
|
||||||
|
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
|
||||||
|
self._post(f'/accounts/keyshare/{token}/answer/', {'decline': True})
|
||||||
|
self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'declined'})
|
||||||
|
|
||||||
|
def test_bad_input_is_refused(self):
|
||||||
|
self.assertEqual(self._post('/accounts/keyshare/', {'pub': 'nope'}).status_code, 400)
|
||||||
|
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
|
||||||
|
bad = {'pub': PUB, 'iv': base64.b64encode(b'i' * 12).decode(), 'ct': base64.b64encode(b'c' * 10).decode()}
|
||||||
|
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', bad).status_code, 400)
|
||||||
|
|
||||||
|
def test_other_accounts_see_nothing(self):
|
||||||
|
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
|
||||||
|
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
|
self.client.force_login(other)
|
||||||
|
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
|
||||||
|
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', {'decline': True}).status_code, 404)
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(self.client.get('/accounts/keyshare/pending/').status_code, 401)
|
||||||
|
|
||||||
|
def test_open_requests_are_limited_and_run_out(self):
|
||||||
|
tokens = [self._post('/accounts/keyshare/', {'pub': PUB}).json()['token'] for _ in range(7)]
|
||||||
|
self.assertEqual(KeyShare.objects.count(), 5)
|
||||||
|
self.assertEqual(self.client.get(f'/accounts/keyshare/{tokens[0]}/').json(), {'state': 'expired'})
|
||||||
|
self.assertEqual(self.client.get(f'/accounts/keyshare/{tokens[-1]}/').json(), {'state': 'pending'})
|
||||||
|
KeyShare.objects.update(created_at=KeyShare.objects.first().created_at.replace(year=2000))
|
||||||
|
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
|
||||||
|
self.assertEqual(self._post('/accounts/keyshare/', {'pub': PUB}).status_code, 200)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib.auth.views import LogoutView
|
from django.contrib.auth.views import LogoutView
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import keyshare, oidc_views, views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('register/', views.register, name='register'),
|
path('register/', views.register, name='register'),
|
||||||
|
|
@ -21,4 +21,15 @@ urlpatterns = [
|
||||||
path('webdav/<int:pk>/test/', views.webdav_test, name='webdav_test'),
|
path('webdav/<int:pk>/test/', views.webdav_test, name='webdav_test'),
|
||||||
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
|
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
|
||||||
path('vault/<slug:name>/', views.vault, name='vault'),
|
path('vault/<slug:name>/', views.vault, name='vault'),
|
||||||
|
path('oidc/start/', oidc_views.oidc_start, name='oidc_start'),
|
||||||
|
path('oidc/callback/', oidc_views.oidc_callback, name='oidc_callback'),
|
||||||
|
path('oidc/app/', oidc_views.app_config, name='oidc_app_config'),
|
||||||
|
path('oidc/app/login/', oidc_views.app_login, name='oidc_app_login'),
|
||||||
|
path('oidc/key/', oidc_views.oidc_key, name='oidc_key'),
|
||||||
|
path('oidc/key/callback/', oidc_views.oidc_key, name='oidc_key_callback'),
|
||||||
|
path('oidc/<int:pk>/unlink/', oidc_views.oidc_unlink, name='oidc_unlink'),
|
||||||
|
path('keyshare/', keyshare.create, name='keyshare_create'),
|
||||||
|
path('keyshare/pending/', keyshare.pending, name='keyshare_pending'),
|
||||||
|
path('keyshare/<str:token>/', keyshare.status, name='keyshare_status'),
|
||||||
|
path('keyshare/<str:token>/answer/', keyshare.answer, name='keyshare_answer'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from django.views.decorators.http import require_http_methods
|
||||||
from radio import lastfm as lastfm_module
|
from radio import lastfm as lastfm_module
|
||||||
|
|
||||||
from .models import ApiToken, UserVault, WebDAVSource
|
from .models import ApiToken, UserVault, WebDAVSource
|
||||||
|
from .oidc_views import oidc_context
|
||||||
from .webdav import WebDAVError, list_directory
|
from .webdav import WebDAVError, list_directory
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
@ -56,7 +57,7 @@ def login_view(request):
|
||||||
else:
|
else:
|
||||||
form = AuthenticationForm()
|
form = AuthenticationForm()
|
||||||
|
|
||||||
return render(request, 'accounts/login.html', {'form': form})
|
return render(request, 'accounts/login.html', {'form': form, **oidc_context(request)})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -78,6 +79,7 @@ def settings_view(request):
|
||||||
'password_form': PasswordChangeForm(request.user),
|
'password_form': PasswordChangeForm(request.user),
|
||||||
'api_token': getattr(request.user, 'api_token', None),
|
'api_token': getattr(request.user, 'api_token', None),
|
||||||
'webdav_sources': request.user.webdav_sources.all(),
|
'webdav_sources': request.user.webdav_sources.all(),
|
||||||
|
**oidc_context(request),
|
||||||
}
|
}
|
||||||
return render(request, 'accounts/settings.html', context)
|
return render(request, 'accounts/settings.html', context)
|
||||||
|
|
||||||
|
|
@ -217,6 +219,7 @@ def change_password(request):
|
||||||
'password_form_open': True,
|
'password_form_open': True,
|
||||||
'api_token': getattr(request.user, 'api_token', None),
|
'api_token': getattr(request.user, 'api_token', None),
|
||||||
'webdav_sources': request.user.webdav_sources.all(),
|
'webdav_sources': request.user.webdav_sources.all(),
|
||||||
|
**oidc_context(request),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
10
deploy/whisper/Dockerfile
Normal file
10
deploy/whisper/Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# diora's built-in Whisper (docs/transcription.md). Built on the server next to diora-prod:
|
||||||
|
# docker compose build whisper
|
||||||
|
FROM python:3.12-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN pip install --no-cache-dir faster-whisper==1.2.1 fastapi==0.143.1 uvicorn==0.54.0 python-multipart==0.0.32
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server.py .
|
||||||
|
ENV WHISPER_CACHE=/models HF_HOME=/models/hf
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
85
deploy/whisper/server.py
Normal file
85
deploy/whisper/server.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""diora's built-in Whisper: a small OpenAI-compatible transcription server.
|
||||||
|
|
||||||
|
POST /v1/audio/transcriptions (multipart: file, model, language, prompt, response_format)
|
||||||
|
returns {text} or, with response_format=verbose_json, {language, duration, text, segments}.
|
||||||
|
The `model` field is ignored; WHISPER_MODEL decides. The model is loaded on the first request
|
||||||
|
and freed again after WHISPER_IDLE seconds without work, so it holds no memory while unused.
|
||||||
|
One transcription at a time; parallel requests wait (the server has few cores to share).
|
||||||
|
See docs/transcription.md.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Windows with a GPU: where the CUDA libraries (cuBLAS, cuDNN) are, separated by ';'.
|
||||||
|
for _d in filter(None, os.environ.get('WHISPER_DLL_DIRS', '').split(';')):
|
||||||
|
os.add_dll_directory(_d)
|
||||||
|
os.environ['PATH'] = _d + os.pathsep + os.environ['PATH']
|
||||||
|
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile # noqa: E402
|
||||||
|
from faster_whisper import WhisperModel # noqa: E402
|
||||||
|
|
||||||
|
MODEL = os.environ.get('WHISPER_MODEL', 'large-v3-turbo')
|
||||||
|
THREADS = int(os.environ.get('WHISPER_THREADS', '4'))
|
||||||
|
DEVICE = os.environ.get('WHISPER_DEVICE', 'cpu') # cpu or cuda
|
||||||
|
COMPUTE = os.environ.get('WHISPER_COMPUTE', 'int8') # int8_float16 on a GPU
|
||||||
|
IDLE = int(os.environ.get('WHISPER_IDLE', '600'))
|
||||||
|
CACHE = os.environ.get('WHISPER_CACHE', '/models')
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
work = threading.Lock() # one transcription at a time
|
||||||
|
state = {'model': None, 'last': 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def model():
|
||||||
|
if state['model'] is None:
|
||||||
|
state['model'] = WhisperModel(MODEL, device=DEVICE, compute_type=COMPUTE, cpu_threads=THREADS,
|
||||||
|
download_root=CACHE)
|
||||||
|
return state['model']
|
||||||
|
|
||||||
|
|
||||||
|
def unloader():
|
||||||
|
while True:
|
||||||
|
time.sleep(30)
|
||||||
|
if state['model'] is not None and time.time() - state['last'] > IDLE and work.acquire(blocking=False):
|
||||||
|
try:
|
||||||
|
state['model'] = None
|
||||||
|
finally:
|
||||||
|
work.release()
|
||||||
|
|
||||||
|
|
||||||
|
threading.Thread(target=unloader, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def decode(data: bytes) -> np.ndarray:
|
||||||
|
p = subprocess.run(['ffmpeg', '-nostdin', '-loglevel', 'error', '-i', 'pipe:0', '-ac', '1', '-ar', '16000',
|
||||||
|
'-f', 'f32le', 'pipe:1'], input=data, capture_output=True)
|
||||||
|
if p.returncode != 0:
|
||||||
|
raise HTTPException(400, 'not audio: ' + p.stderr.decode('utf-8', 'replace')[-200:])
|
||||||
|
return np.frombuffer(p.stdout, np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/health')
|
||||||
|
def health():
|
||||||
|
return {'ok': True, 'model': MODEL, 'device': DEVICE, 'loaded': state['model'] is not None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/v1/audio/transcriptions')
|
||||||
|
def transcriptions(file: UploadFile = File(...), language: str = Form(''), prompt: str = Form(''),
|
||||||
|
response_format: str = Form('json')):
|
||||||
|
audio = decode(file.file.read())
|
||||||
|
t0 = time.time()
|
||||||
|
with work:
|
||||||
|
segs, info = model().transcribe(audio, language=language or None, initial_prompt=prompt or None,
|
||||||
|
beam_size=5, vad_filter=True)
|
||||||
|
segs = [{'id': i, 'start': round(s.start, 2), 'end': round(s.end, 2), 'text': s.text}
|
||||||
|
for i, s in enumerate(segs)]
|
||||||
|
state['last'] = time.time()
|
||||||
|
dur = len(audio) / 16000
|
||||||
|
print(f'{dur:.0f} s audio in {time.time() - t0:.0f} s', flush=True)
|
||||||
|
text = ''.join(s['text'] for s in segs).strip()
|
||||||
|
if response_format == 'verbose_json':
|
||||||
|
return {'task': 'transcribe', 'language': info.language, 'duration': dur, 'text': text, 'segments': segs}
|
||||||
|
return {'text': text}
|
||||||
|
|
@ -84,6 +84,9 @@ DATABASES = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The built-in Whisper instance (OpenAI-compatible, base such as http://whisper:8000/v1);
|
||||||
|
# empty = none (docs/transcription.md).
|
||||||
|
WHISPER_URL = os.environ.get('DIORA_WHISPER_URL', '').strip().rstrip('/')
|
||||||
PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200'))
|
PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200'))
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
|
@ -138,6 +141,17 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
LOGIN_URL = '/accounts/login/'
|
LOGIN_URL = '/accounts/login/'
|
||||||
LOGIN_REDIRECT_URL = '/'
|
LOGIN_REDIRECT_URL = '/'
|
||||||
|
|
||||||
|
# Sign-in with Authelia (accounts/oidc.py); off while one of the three is empty.
|
||||||
|
OIDC_ISSUER = os.environ.get('DIORA_OIDC_ISSUER', '')
|
||||||
|
OIDC_CLIENT_ID = os.environ.get('DIORA_OIDC_CLIENT_ID', '')
|
||||||
|
OIDC_CLIENT_SECRET = os.environ.get('DIORA_OIDC_CLIENT_SECRET', '')
|
||||||
|
# Fixed callback address; empty = built from the request (needs the real https host).
|
||||||
|
OIDC_REDIRECT_URI = os.environ.get('DIORA_OIDC_REDIRECT_URI', '')
|
||||||
|
OIDC_PROVIDER_NAME = os.environ.get('DIORA_OIDC_PROVIDER_NAME', 'Authelia')
|
||||||
|
# Public client the devices use to fetch their `diora_key` claim from the provider themselves
|
||||||
|
# (docs/accounts.md, "Schlüssel über Authelia"); empty = off.
|
||||||
|
OIDC_APP_CLIENT_ID = os.environ.get('DIORA_OIDC_APP_CLIENT_ID', '')
|
||||||
LOGOUT_REDIRECT_URL = '/'
|
LOGOUT_REDIRECT_URL = '/'
|
||||||
|
|
||||||
# Last.fm
|
# Last.fm
|
||||||
|
|
|
||||||
91
docs/accounts.md
Normal file
91
docs/accounts.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Anmeldung und Bücher-Schlüssel
|
||||||
|
|
||||||
|
## Zwei Wege hinein
|
||||||
|
|
||||||
|
**Benutzername und Passwort** (`accounts/views.py` `login_view`): Das Login-Formular leitet im
|
||||||
|
Browser aus dem Passwort den Bücher-Schlüssel ab (PBKDF2-SHA256, Salt `diora:<username>`,
|
||||||
|
200 000 Runden) und legt ihn bis zur nächsten Seite in `localStorage['diora_pending_enc_key']`
|
||||||
|
ab. Die App-Seite übernimmt ihn als `diora_enc_key_<userId>` (`32-crypto.js`, `99-init.js`).
|
||||||
|
|
||||||
|
**Authelia (OpenID Connect)** (`accounts/oidc.py`, `accounts/oidc_views.py`): als zusätzlicher
|
||||||
|
Weg, die Anmeldung mit Passwort bleibt. Der Ablauf ist Authorization Code mit PKCE (S256) und
|
||||||
|
einem vertraulichen Client (`client_secret_basic`). Das id_token wird hier geprüft (RS256 gegen
|
||||||
|
die JWKS des Anbieters, `iss`, `aud`, `exp`, `nonce`), dafür reicht `cryptography`.
|
||||||
|
|
||||||
|
- Ein Konto wird **einmal verknüpft**: in den Einstellungen „Mit Authelia verknüpfen“, während
|
||||||
|
man normal angemeldet ist (`/accounts/oidc/start/?link=1`). Gespeichert wird `OidcIdentity`
|
||||||
|
(`issuer`, `sub`; Name und Mail nur zur Anzeige). Danach führt „Mit Authelia anmelden“ auf der
|
||||||
|
Login-Seite in dieses Konto.
|
||||||
|
- Eine nicht verknüpfte Authelia-Identität wird abgewiesen. Es entstehen **keine neuen
|
||||||
|
Konten** über Authelia, und es wird auch nicht über Name oder Mail automatisch verknüpft.
|
||||||
|
- Wer hinein darf, regelt Authelia (Policy `mitglieder` = Gruppen `admins` oder `freunde`).
|
||||||
|
- Einstellungen per Umgebung: `DIORA_OIDC_ISSUER`, `DIORA_OIDC_CLIENT_ID`,
|
||||||
|
`DIORA_OIDC_CLIENT_SECRET`, `DIORA_OIDC_REDIRECT_URI` (fest, weil hinter dem Proxy kein
|
||||||
|
https erkannt wird), optional `DIORA_OIDC_PROVIDER_NAME`. Fehlt eins der ersten drei, gibt es
|
||||||
|
keinen Knopf. Auf prod stehen sie in `~/docker-compose/diora-prod/.env` (Strato), das
|
||||||
|
Gegenstück liegt auf Hetzner in `/opt/authelia/oidc-clients.env`.
|
||||||
|
- Redirect-URI im Authelia-Client: `https://diora.creamfresh.xyz/accounts/oidc/callback/`.
|
||||||
|
|
||||||
|
## Der Schlüssel nach einer Authelia-Anmeldung
|
||||||
|
|
||||||
|
### Normalfall: über Authelia, ohne Nachfrage
|
||||||
|
|
||||||
|
Authelia hält je Nutzer ein geheimes Attribut `diora_key` (32 Byte, base64). Es geht nur an den
|
||||||
|
öffentlichen Client `diora-app` (`DIORA_OIDC_APP_CLIENT_ID`, PKCE, Scope `diora_key`) und nur
|
||||||
|
über userinfo, nicht im id_token. Nach jedem Authelia-Login (und nach dem Verknüpfen) führt
|
||||||
|
diora auf `/accounts/oidc/key/` (`templates/accounts/oidc_key.html`). Die Seite meldet sich
|
||||||
|
**im Browser** noch einmal mit `prompt=none` als `diora-app` an, unsichtbar, weil die
|
||||||
|
Authelia-Sitzung noch besteht, und holt per CORS Token und userinfo. Daraus leitet sie mit
|
||||||
|
HKDF-SHA256 (Salt = `sub`, info `diora authelia key v1`) einen AES-GCM-Schlüssel ab:
|
||||||
|
|
||||||
|
- Hat das Gerät den Bücher-Schlüssel (und passt er zu einem Buch), wird er damit verschlüsselt
|
||||||
|
und im Vault `authelia-key` abgelegt, bei jedem Authelia-Login neu. So heilt ein geänderter
|
||||||
|
`diora_key` von selbst.
|
||||||
|
- Hat es keinen, öffnet sie den Vault-Eintrag damit und legt den Schlüssel ab.
|
||||||
|
- Es zählt nur das Authelia-Konto, das mit diesem diora-Konto verknüpft ist (`sub`).
|
||||||
|
|
||||||
|
Der diora-Server sieht `diora_key` nie. **Grenze, ehrlich:** Wer Authelia (Hetzner-Root) oder
|
||||||
|
dein Authelia-Konto (Passwort, Mail-Reset) übernimmt, kommt damit an die Bücher. Wer Strato
|
||||||
|
übernimmt, kann im Browser ohnehin manipuliertes JS ausliefern. Geschützt ist gegen geleakte
|
||||||
|
Daten **einer** Seite (diora-DB/Backup ohne Authelia-Nutzerdatei oder umgekehrt). Geht
|
||||||
|
`diora_key` verloren, sind die Bücher nicht weg: Der Schlüssel kommt weiter aus dem Passwort.
|
||||||
|
Entschieden 2026-11-01 vom Nutzer (Komfort vor stärkerer Trennung).
|
||||||
|
|
||||||
|
### Notweg: Code oder Passwort
|
||||||
|
|
||||||
|
Ohne Passwort gibt es keinen Schlüssel. Deshalb erzeugt `getOrCreateEncKey()` **nie mehr
|
||||||
|
selbst einen neuen Schlüssel**: Früher machte es einen zufälligen, wenn keiner da war, und damit
|
||||||
|
waren die vorhandenen Bücher auf diesem Gerät verloren. Jetzt fragt es `dioraUnlock()`
|
||||||
|
(`static/js/app/33-unlock.js`). Es gibt zwei Wege:
|
||||||
|
|
||||||
|
1. **Von einem entsperrten Gerät übergeben** (`accounts/keyshare.py`, Modell `KeyShare`). Das
|
||||||
|
neue Gerät schickt einen ECDH-P-256-Public-Key und zeigt einen 6-stelligen Code, der aus
|
||||||
|
diesem Key berechnet wird. Entsperrte Geräte fragen alle 15 s
|
||||||
|
`/accounts/keyshare/pending/` ab und zeigen denselben Code mit „Freigeben/Ablehnen“. Die
|
||||||
|
Freigabe verschlüsselt den Bücher-Schlüssel mit AES-GCM unter dem gemeinsamen Geheimnis
|
||||||
|
(HKDF-SHA256, info `diora keyshare v1`). Der Server sieht nur Public Keys und Chiffrat und
|
||||||
|
gibt die Antwort genau einmal heraus. Anträge laufen nach 10 min ab. Angeboten werden nur
|
||||||
|
solche, deren Gerät in den letzten 20 s nachgefragt hat, und je Konto höchstens 5, die
|
||||||
|
ältesten weichen.
|
||||||
|
2. **Das diora-Passwort einmal eingeben**: dieselbe Ableitung wie beim Login.
|
||||||
|
|
||||||
|
In beiden Fällen wird der Schlüssel an einem Buch geprüft, bevor er gespeichert wird. Mit
|
||||||
|
„Später“ bleiben Bücher gesperrt, und die Bibliothek zeigt einen Knopf „Entsperren“. Die
|
||||||
|
Einstellungsseite erzeugt ebenfalls keinen Schlüssel mehr.
|
||||||
|
|
||||||
|
## Apps
|
||||||
|
|
||||||
|
Android und Desktop melden sich bisher nur mit Passwort an (eigene Login-Seiten in
|
||||||
|
`diora-android/web-native/login.html` und `diora-desktop/src/web/login.js`, die einen
|
||||||
|
API-Token holen). Mit Authelia melden sie sich selbst als öffentlicher Client `diora-app` an:
|
||||||
|
|
||||||
|
1. `GET /accounts/oidc/app/` sagt, wo Authelia ist, gibt die Client-ID und eine vom Server
|
||||||
|
signierte nonce (10 min gültig).
|
||||||
|
2. Die App führt den Code-Flow mit PKCE im Systembrowser aus. Redirect:
|
||||||
|
`xyz.creamfresh.diora:/oauth` bzw. `xyz.creamfresh.diora.reader:/oauth` (Android),
|
||||||
|
Loopback `http://127.0.0.1:<port>/oauth` (Desktop, jeder Port). Den Code tauscht sie selbst
|
||||||
|
und liest `diora_key` aus userinfo.
|
||||||
|
3. `POST /accounts/oidc/app/login/ {id_token}` prüft das Token (`aud` = `diora-app`, nonce) und
|
||||||
|
gibt den API-Token des verknüpften Kontos heraus (den vorhandenen, kein neuer).
|
||||||
|
4. Die App holt mit dem Token `/accounts/vault/authelia-key/` und öffnet den Bücher-Schlüssel
|
||||||
|
wie die Webseite (HKDF, Salt `sub`). Ist dort noch nichts, fragt sie nach dem diora-Passwort.
|
||||||
|
|
@ -29,6 +29,8 @@ kept by the server as a blob it can't read:
|
||||||
the key can't be read: the settings ask for it again. Not on diora Reader (e-readers).
|
the key can't be read: the settings ask for it again. Not on diora Reader (e-readers).
|
||||||
- Server-side jobs (in the background, without an app open) were left out on purpose: they
|
- Server-side jobs (in the background, without an app open) were left out on purpose: they
|
||||||
would need the key on the server. The project Radar has its own server key (docs/projects.md).
|
would need the key on the server. The project Radar has its own server key (docs/projects.md).
|
||||||
|
Podcast articles wait on the server as a wish until a device with the key is open
|
||||||
|
(docs/transcription.md).
|
||||||
|
|
||||||
## Building blocks (`static/js/ai.js`, an ES module)
|
## Building blocks (`static/js/ai.js`, an ES module)
|
||||||
|
|
||||||
|
|
@ -41,6 +43,8 @@ kept by the server as a blob it can't read:
|
||||||
(`output_config.format` json_schema), effort `low` by default, streamed, `fallbacks: "default"`
|
(`output_config.format` json_schema), effort `low` by default, streamed, `fallbacks: "default"`
|
||||||
(beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long
|
(beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long
|
||||||
answers become plain German messages.
|
answers become plain German messages.
|
||||||
|
- `ask` also takes `onProgress(words)` (words written so far while streaming; OpenRouter is not streamed and reports once at the end)
|
||||||
|
and `signal` (an `AbortSignal`), both optional; `DioraAI.modelName()` is the model of the chosen provider. Used by the podcast articles.
|
||||||
- `DioraAI.review({title, intro, items, onApply})`: the suggestions with checkboxes — nothing
|
- `DioraAI.review({title, intro, items, onApply})`: the suggestions with checkboxes — nothing
|
||||||
changes until the user ticks and presses "Übernehmen".
|
changes until the user ticks and presses "Übernehmen".
|
||||||
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
|
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
|
||||||
|
|
@ -53,5 +57,6 @@ kept by the server as a blob it can't read:
|
||||||
| Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark |
|
| Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark |
|
||||||
| Podcasts | ✦ in Abos (diora-android `web-native/static/native-podcast.js`, copied to `static/podcasts/`) | per subscription: title, author, folders, level, heard in 12 months, publishing interval, three newest episode titles | folders to add (≤ 2), level Stamm / Strom / Auf Abruf (only with a clear reason) |
|
| Podcasts | ✦ in Abos (diora-android `web-native/static/native-podcast.js`, copied to `static/podcasts/`) | per subscription: title, author, folders, level, heard in 12 months, publishing interval, three newest episode titles | folders to add (≤ 2), level Stamm / Strom / Auf Abruf (only with a clear reason) |
|
||||||
| Nachrichten, Blogs | ✦ in the feeds' bar (`static/js/feeds.js`; the apps' section bar presses it) | per source: title, address, kind, Ressort or folders, recent titles shown | Ressort for news, folders for blogs, news ↔ blog when clearly wrong |
|
| Nachrichten, Blogs | ✦ in the feeds' bar (`static/js/feeds.js`; the apps' section bar presses it) | per source: title, address, kind, Ressort or folders, recent titles shown | Ressort for news, folders for blogs, news ↔ blog when clearly wrong |
|
||||||
|
| Podcast-Artikel | ✦ Artikel in an episode (`static/js/podcast-article.js`, docs/transcription.md) | the episode's transcript (feed or Whisper), title, author, shownotes, chapters | an article from the speakers' standpoint with summary and sections, saved to the episode and to Blogs (no review sheet: it is new text, removable) |
|
||||||
|
|
||||||
Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).
|
Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).
|
||||||
|
|
|
||||||
|
|
@ -182,3 +182,12 @@ Listening without committing: one button plays the Strom like a station.
|
||||||
- Web host (`static/js/podcasts-web.js`, `DioraPodcastHost`): `podcastLaterAdd(json episodes)`,
|
- Web host (`static/js/podcasts-web.js`, `DioraPodcastHost`): `podcastLaterAdd(json episodes)`,
|
||||||
`podcastLaterRemove(json ids)`, `podcastListsSwitch(on)`, `podcastStreamSeen()` (keeps the
|
`podcastLaterRemove(json ids)`, `podcastListsSwitch(on)`, `podcastStreamSeen()` (keeps the
|
||||||
previous time as `state.stream_seen_before` for the marks).
|
previous time as `state.stream_seen_before` for the marks).
|
||||||
|
|
||||||
|
## Transcripts
|
||||||
|
|
||||||
|
An episode's Transkript tab shows one of two kinds. **From the feed**: `<podcast:transcript>`
|
||||||
|
(SRT, VTT, JSON, Podlove), fetched and parsed on first use and cached in `EpisodeTranscript`
|
||||||
|
(`podcasts/transcripts.py`, `GET /podcasts/episodes/<id>/transcript/`). **Made by Whisper**
|
||||||
|
(since 2026-11): only when the user asks, kept on `TranscriptionJob`; the feed's wins when both
|
||||||
|
exist. From a transcript the device can write an article that is pinned to the episode and shows
|
||||||
|
in Blogs. All of that is in docs/transcription.md.
|
||||||
|
|
|
||||||
262
docs/transcription.md
Normal file
262
docs/transcription.md
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
# Smarte Transkription: Transkript und Artikel zu einer Folge
|
||||||
|
|
||||||
|
Decided with the user on 2026-11-09. Plan and order of work:
|
||||||
|
diora-android `plans/smarte-transkription.md`.
|
||||||
|
|
||||||
|
## What it is for
|
||||||
|
|
||||||
|
Sometimes the shownotes don't tell whether an episode still has something to teach. Then the
|
||||||
|
user presses a button and, a few hours later at most, gets a readable article written from the
|
||||||
|
speakers' own standpoint. It is meant to let them judge the episode for themselves, so it keeps
|
||||||
|
arguments, reasons and evidence and does not just summarise them. The article sits on the
|
||||||
|
episode and in Blogs.
|
||||||
|
|
||||||
|
Nothing happens on its own. The server never transcribes ahead, by cron or on subscribing; the
|
||||||
|
only way to start a transcription is the `POST` below, sent when the user presses a button.
|
||||||
|
|
||||||
|
## Who does what
|
||||||
|
|
||||||
|
The **server** transcribes. It already downloads and decodes episodes for skipping silence
|
||||||
|
(`podcasts/silence.py`), and browsers and the WebView can't fetch podcast audio (CORS). It cuts
|
||||||
|
the audio at pauses and sends the pieces in parallel to a Whisper instance with an
|
||||||
|
OpenAI-compatible API (`POST /v1/audio/transcriptions`): the built-in one by default (`DIORA_WHISPER_URL`),
|
||||||
|
or any instance the user enters. YouTube episodes are
|
||||||
|
not transcribed: the server never fetches their sound (docs/youtube.md).
|
||||||
|
|
||||||
|
The **device** writes the article with the user's own key (docs/ai.md). The key never reaches the
|
||||||
|
server. It is one LLM call of a few minutes. If diora is open when the transcript is done, it
|
||||||
|
happens right away; otherwise the next time diora is opened on any of the user's devices. The
|
||||||
|
server only remembers that an article was wanted.
|
||||||
|
|
||||||
|
Transcript and article are stored in plain text on the server, like all podcast and feed data.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
1. In the episode the user presses "Transkript erstellen" (the Transkript tab) or
|
||||||
|
"✦ Artikel erstellen" / "✦ Transkribieren und Artikel erstellen". The second sends
|
||||||
|
`article: true`. With a transcript already there (from the feed or an earlier run) only the
|
||||||
|
article is written.
|
||||||
|
2. The server works through the episode (minutes to hours, no GPU on Strato). The UI shows
|
||||||
|
"Transkribiere 7/38 …" while it is open.
|
||||||
|
3. Done: a notice for `GET /feeds/notifications/`. Android's BackgroundSync and the desktop show
|
||||||
|
"Transkript fertig: <Folge>", with "Artikel wird beim Öffnen geschrieben" when one is wanted.
|
||||||
|
Tapping it opens diora at the episode.
|
||||||
|
4. A device with a key (`static/js/podcast-article.js`) picks up the open wishes, reserves one,
|
||||||
|
writes the article and saves it. It then says "Artikel fertig" (on Android as a system
|
||||||
|
notification through the bridge, `DioraNative.notify(title, text, target)` with target
|
||||||
|
`episode:<id>` or `item:<id>`; elsewhere a toast) and opens it on a tap.
|
||||||
|
5. The article is the first tab of the episode ("Artikel") and an item of the favourite blog
|
||||||
|
"Podcast-Artikel", linking back to the episode.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
**Settings.** `GET /podcasts/transcription/settings/` returns
|
||||||
|
`{engine: "builtin"|"custom", url, model, language, parallel, has_token, builtin_available}`.
|
||||||
|
`POST` takes the same fields and `token` (write-only; `""` removes it). The token is kept
|
||||||
|
encrypted with Fernet like `feeds.SiteLogin.secret`. `language` is `""` for automatic or an ISO
|
||||||
|
code (`de`). `parallel` is 1 to 8, default 3. `builtin_available` is whether `DIORA_WHISPER_URL`
|
||||||
|
is set. `url` is the instance's base, e.g. `https://whisper.example.org/v1`; the server appends
|
||||||
|
`/audio/transcriptions`.
|
||||||
|
|
||||||
|
An empty `model` is sent as `whisper-1`. The built-in URL is read into `settings.WHISPER_URL` from
|
||||||
|
`DIORA_WHISPER_URL`. A custom engine needs a `url`; the token is only sent to custom instances.
|
||||||
|
|
||||||
|
`POST /podcasts/transcription/test/` sends one second of silence to the configured instance and
|
||||||
|
returns `{ok, model, error}`. A custom URL goes through the same SSRF check as chapters
|
||||||
|
(`podcasts/chapters.py` `_open`): no private, loopback or link-local addresses. Only the built-in
|
||||||
|
URL from `DIORA_WHISPER_URL` is exempt.
|
||||||
|
|
||||||
|
**Job.** `POST /podcasts/episodes/<id>/transcribe/` with `{article: true|false}` starts a job, or
|
||||||
|
updates `want_article` on a running one. With a transcript already there it returns
|
||||||
|
`{state: "done"}` straight away (and keeps `want_article`). `GET` on the same address returns
|
||||||
|
`{state, chunks_done, chunks_total, error, want_article, started_at, finished_at}`, with `state`
|
||||||
|
one of `none`, `queued`, `downloading`, `transcribing`, `done`, `error`. `DELETE` cancels a
|
||||||
|
queued or running job (its row is deleted, so `GET` then says `none`; a finished one stays). The server runs one job at a time; the others wait as `queued`. A YouTube
|
||||||
|
episode returns 400 `{error: "youtube"}`, an episode over 800 MB 400 `{error: "too large"}`. The size is asked with a `HEAD`
|
||||||
|
request (`Content-Length`; if that fails the job starts anyway) and checked again while
|
||||||
|
downloading, where it ends the job with the error `too large`. `article: true` is only ever
|
||||||
|
raised, never lowered, by a repeated `POST`; a `POST` after an `error` starts a new job (at the
|
||||||
|
back of the queue). Jobs run in id order, which is why the notice below can use ids as its cursor.
|
||||||
|
A feed transcript already there gives a job that is `done` with no `data`: it only holds the wish
|
||||||
|
for an article and causes no `transcripts` notice.
|
||||||
|
|
||||||
|
**Transcript.** The result is kept on the job (`TranscriptionJob.data`) in the format of
|
||||||
|
`EpisodeTranscript.data`: `{segments: [{start, end, speaker, text}], source: {type: "whisper",
|
||||||
|
model, language}}`. `speaker` is `""`. It is kept apart from `EpisodeTranscript`, which is a cache
|
||||||
|
of the feed's transcripts and is rewritten when the feed changes them.
|
||||||
|
`GET /podcasts/episodes/<id>/transcript/` returns the feed's transcript when the feed has a
|
||||||
|
usable one, else the Whisper one, else 404 as before. `episode_detail` adds
|
||||||
|
`transcript_origin` (`"feed"`, `"whisper"` or `null`), `has_transcript` is true for both, and it
|
||||||
|
adds `article_item_id` (or `null`) and `transcription` (the job's state as above, or `null`).
|
||||||
|
|
||||||
|
**Article.** `POST /podcasts/episodes/<id>/article/` takes `{title, summary, body_html, model,
|
||||||
|
kind}` and returns `{item_id}`. The server cleans `body_html` with `feeds/fetch.py clean_html`.
|
||||||
|
It creates the user's source "Podcast-Artikel" when missing (`kind` blog, `fav` true,
|
||||||
|
`synthetic` true, `url` `diora://podcast-artikel`) and adds a `feeds.Item` there, or replaces the
|
||||||
|
episode's earlier one. Item fields: the episode's link (else its audio URL) as `url`, the feed's
|
||||||
|
author as `author`, the episode's artwork as `image_url`, now as `published` (so it is on top of
|
||||||
|
the blog stream under "heute"), `words` counted. Saving clears the wish. `DELETE` on the same
|
||||||
|
address removes the article and its item. `GET /feeds/items/<id>/` and `_item_data` carry
|
||||||
|
`episode_id` for such items.
|
||||||
|
`body_html` may contain `href="#t=<seconds>"` links; `clean_html` keeps exactly this fragment form
|
||||||
|
(`podcasts/shownotes.py` `TIME_LINK`) and no other `#` links. Validation: `title` 1 to 300
|
||||||
|
characters, `body_html` non-empty after cleaning and at most 1,000,000 characters, `summary` at most
|
||||||
|
2000, `model` at most 100, `kind` at most 20 (400 otherwise). The item has `content_full` true (no
|
||||||
|
page is ever fetched) and, as the reader's furniture filter would strip repeated "▶ ab" blocks,
|
||||||
|
`GET /feeds/items/<id>/` skips that filter for synthetic sources. The endpoint is
|
||||||
|
`podcasts/article.py`.
|
||||||
|
|
||||||
|
Synthetic sources are skipped by the feed refresh (`refresh_newsfeeds`, `refresh_now`) and by the
|
||||||
|
pruning of old blog items (`feeds/refresh.py`). They can be hidden or have their bell turned off
|
||||||
|
like any blog; they can't be refreshed, renamed to another URL or turned into news. `refresh.refresh()` itself drops them, the OPML export leaves them out, and `_source_data` carries `synthetic`.
|
||||||
|
|
||||||
|
**Open wishes.** `GET /podcasts/transcription/pending/` returns
|
||||||
|
`[{episode_id, title, podcast, duration_seconds}]`: the user's episodes whose transcript is done,
|
||||||
|
that want an article, have none and aren't reserved. `POST
|
||||||
|
/podcasts/episodes/<id>/article/claim/` reserves one for 15 minutes for the calling device
|
||||||
|
(200), or answers 409 when another device holds it, so phone and desktop don't both write it.
|
||||||
|
`DELETE` on the claim releases it (the device gave up, e.g. no key or an error the user saw).
|
||||||
|
The device names itself with `device` in the JSON body (or, if that is missing, the
|
||||||
|
`X-Diora-Device` header): a short stable name such as `android`, `desktop` or `browser`, at most 60
|
||||||
|
characters; a claim without one is 400. The same device claiming again renews its 15 minutes. A
|
||||||
|
claim on an episode with no open wish (no job done, no article wanted, or an article already
|
||||||
|
there) is 404. `DELETE` by a device that does not hold the claim is 409.
|
||||||
|
|
||||||
|
**Notice.** `GET /feeds/notifications/` additionally takes `t_after` and returns
|
||||||
|
`transcripts: [{job_id, episode_id, title, podcast, want_article}]` (jobs done after that id) and
|
||||||
|
`t_last`. Without `t_after` (a device's first time) the list is empty and only `t_last` comes,
|
||||||
|
like `after`/`last`. Devices that don't know the fields ignore them.
|
||||||
|
|
||||||
|
## Transcribing (`podcasts/transcribe.py`)
|
||||||
|
|
||||||
|
Download with the same limits as `silence.py`, decode with imageio-ffmpeg to 16 kHz mono. Cut
|
||||||
|
into pieces of about eight minutes; each cut moves to the longest pause within ±30 seconds
|
||||||
|
(`silence.py`'s detection), so no word is cut. Pieces go out as FLAC to the built-in instance
|
||||||
|
and as Opus 32 kbit/s to custom ones, to keep uploads small.
|
||||||
|
|
||||||
|
Each piece is sent with `model`, `language` (when set), `response_format=verbose_json` and a
|
||||||
|
`prompt` of podcast title, episode title and author names, so Whisper spells names right. Pieces
|
||||||
|
run in a thread pool of `parallel` workers; each is retried up to three times with a growing
|
||||||
|
pause; progress is written after each. Segment times are shifted by the piece's start; the
|
||||||
|
segments are joined and smoothed with `transcripts.merge`. A job runs in a daemon thread, like
|
||||||
|
`silence.ensure()`; one per server. A job found `downloading` or `transcribing` after a restart
|
||||||
|
is started again. This happens when the first
|
||||||
|
request (`GET`/`POST` on `/transcribe/` or `/transcription/pending/`) after a start reaches the server.
|
||||||
|
Cancelling deletes the job's row; the worker notices at the latest after five seconds, stops
|
||||||
|
sending pieces and removes its temporary files (they live in a temporary directory that is always
|
||||||
|
removed). Only the worker's main thread writes to the database. The settings used are those of the
|
||||||
|
episode's owner, read when the job starts. A refusal by the instance with a 4xx status other than
|
||||||
|
408 and 429 is not retried and ends the job with `Whisper: HTTP <code>`.
|
||||||
|
|
||||||
|
## The article (`static/js/podcast-article.js`)
|
||||||
|
|
||||||
|
Input: the transcript as plain text, one line per segment group of about 30 seconds,
|
||||||
|
`[mm:ss] Sprecher: text` (or `[mm:ss] text` without speakers), plus the episode's metadata:
|
||||||
|
podcast title, author, episode title, date, duration, the shownotes as plain text and the
|
||||||
|
chapters. Over about 120,000 tokens (more than about nine hours) the device stops with a clear
|
||||||
|
message for now. A five-hour episode is about 70,000 tokens.
|
||||||
|
|
||||||
|
`DioraAI.ask({system, prompt, schema, effort: "medium", maxTokens: 32000, onProgress, signal})`.
|
||||||
|
`onProgress(words)` reports the words written so far while streaming ("Schreibe Artikel … 2.300
|
||||||
|
Wörter"); `signal` aborts. Both are optional and new in ai.js; the existing callers don't change.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["title", "summary", "kind", "speakers", "sections"],
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"summary": {"type": "string"},
|
||||||
|
"kind": {"type": "string", "enum": ["Interview", "Gespräch", "Monolog", "Diskussion"]},
|
||||||
|
"speakers": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"sections": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["heading", "start", "body_markdown"],
|
||||||
|
"properties": {
|
||||||
|
"heading": {"type": "string"},
|
||||||
|
"start": {"type": "integer"},
|
||||||
|
"body_markdown": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTML: the summary as a lead paragraph, then per section an `<h2>`, below it a link
|
||||||
|
"▶ ab 12:34" (`<a href="#t=754">`; `clean_html` keeps fragment links, and feeds.js plays the
|
||||||
|
item's `episode_id` from there when such a link is tapped) and the body. Markdown is limited to paragraphs, emphasis, lists and block
|
||||||
|
quotes, converted on the device; the server cleans the result anyway.
|
||||||
|
|
||||||
|
System prompt (the user's words decide the perspective; keep its substance when changing it):
|
||||||
|
|
||||||
|
```text
|
||||||
|
You turn the transcript of a podcast episode into an article that reads as if the people
|
||||||
|
speaking had written it themselves instead of recording the episode.
|
||||||
|
|
||||||
|
The reader wants to decide whether this episode still has something to teach them. They do not
|
||||||
|
want a summary; they want to understand what is argued here well enough to judge it for
|
||||||
|
themselves. Write for that reader.
|
||||||
|
|
||||||
|
Perspective. Write from the speakers' standpoint and state their positions directly, as their
|
||||||
|
own claims. Never distance yourself from them: no "im Podcast wird die These aufgestellt", "der
|
||||||
|
Sprecher meint", "in dieser Folge geht es um", "die Gäste diskutieren", "laut X". If the episode
|
||||||
|
is one person speaking, write as that person argues, in the third person or impersonal voice,
|
||||||
|
but without reporting verbs. Do not add your own opinion, criticism or fact checks, and do not
|
||||||
|
soften what is said.
|
||||||
|
|
||||||
|
Conversations. Interviews, conversations and discussions are condensed into their core theses;
|
||||||
|
the form of question and answer disappears. Where people hold different positions, keep them
|
||||||
|
apart and name who holds which ("Zuckermann hält dagegen, dass …"), but do not turn it into
|
||||||
|
minutes. Questions from a host matter only where they shape the argument. When the transcript
|
||||||
|
has no speaker names, infer them from the context (introductions, the shownotes, how people
|
||||||
|
address each other); if you can't, do not invent names.
|
||||||
|
|
||||||
|
Substance over summary. Give the arguments with their reasons, examples and evidence, not just
|
||||||
|
the conclusions. Keep figures, dates, quotations that carry an argument (short, in quotation
|
||||||
|
marks, attributed), and every work mentioned (books, articles, studies, films) with author and
|
||||||
|
title. Make clear what is new, distinctive or surprising in the episode and where it restates
|
||||||
|
what is commonly said, by how you weight and order it, without grading it.
|
||||||
|
|
||||||
|
Leave out: advertising and sponsors, appeals for donations, subscriptions or ratings, notes on
|
||||||
|
the podcast itself (schedules, technology, community), greetings, small talk and digressions that
|
||||||
|
lead nowhere.
|
||||||
|
|
||||||
|
Form. Write in the language of the episode. `title`: a title for the article, not the episode
|
||||||
|
title repeated. `summary`: three to six sentences up front that state the central thesis and how
|
||||||
|
the episode gets there. `sections`: as many as the content needs (usually four to ten), each
|
||||||
|
with a heading that says something (not "Einleitung"), `start` = the second in the episode where
|
||||||
|
its content begins (from the [mm:ss] marks), and `body_markdown` in plain paragraphs; lists only
|
||||||
|
where the speakers list things themselves. Follow the order of the episode unless a different
|
||||||
|
order makes the argument clearer. Length: as long as needed for the reader to judge the episode,
|
||||||
|
roughly a tenth of the transcript, at most about 5,000 words.
|
||||||
|
|
||||||
|
The transcript was made by speech recognition: fix obvious mishearings, especially of names,
|
||||||
|
using the metadata, and don't quote garbled passages.
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompt (user turn): the metadata block, then `Transkript:` and the text.
|
||||||
|
|
||||||
|
## The built-in instance (since 2026-11-09)
|
||||||
|
|
||||||
|
Whisper does not fit on the Strato server: with about 2 GB of memory free, large-v3-turbo needed
|
||||||
|
more than 2.2 GB and even `small` broke a 1.2 GB limit, and earlyoom then killed other services.
|
||||||
|
So the built-in instance runs on the user's PC (multi11, GTX 1660 Ti) and diora-prod reaches it over
|
||||||
|
the tailnet: `DIORA_WHISPER_URL=http://100.64.0.3:8766/v1` in `~/docker-compose/diora-prod/.env`.
|
||||||
|
It is `deploy/whisper/server.py` (a small OpenAI-compatible server around faster-whisper,
|
||||||
|
large-v3-turbo, int8_float16 on CUDA; one request at a time; the model is loaded on demand and freed
|
||||||
|
after 10 idle minutes), started at logon from `F:\diora-whisper` (start.cmd via start-hidden.vbs,
|
||||||
|
shortcut in the user's Startup folder; log `F:\diora-whisper\server.log`), listening only on the
|
||||||
|
tailnet address. One hour of speech takes about five minutes.
|
||||||
|
|
||||||
|
When the PC is off, a job does not fail: the server checks the instance before downloading, and a
|
||||||
|
connection error (there or while pieces are out) puts the job back into the queue with
|
||||||
|
`error = "Whisper-Instanz nicht erreichbar, neuer Versuch um HH:MM"`, tried again every ten
|
||||||
|
minutes (`WAIT_RETRY`) for up to two days after the button was pressed (`WAIT_MAX`). Only the
|
||||||
|
built-in instance waits like this; a custom instance's connection errors fail the job as before.
|
||||||
129
e2e/fixtures/make_test_pdf.py
Normal file
129
e2e/fixtures/make_test_pdf.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""A small PDF with the cases the PDF reader has to get right (e2e and by hand).
|
||||||
|
|
||||||
|
python e2e/fixtures/make_test_pdf.py e2e/fixtures/test.pdf
|
||||||
|
|
||||||
|
12 pages: A4 portrait text pages, one A5 page and one A4 landscape page (mixed
|
||||||
|
sizes), page 7 nearly empty (only a page number: the smart crop must not zoom
|
||||||
|
it to infinity), an outline (table of contents) with three entries, internal
|
||||||
|
links from page 1 to pages 5 and 9 and one external link, a coloured picture on
|
||||||
|
page 2 (it must stay as it is in the reader's colours) and page labels like a
|
||||||
|
book's: i, ii for the first two pages, then 1, 2, 3 … Plain PDF 1.4, Helvetica,
|
||||||
|
no dependencies.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
A4 = (595, 842)
|
||||||
|
A5 = (420, 595)
|
||||||
|
A4L = (842, 595)
|
||||||
|
|
||||||
|
WORDS = ('Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. '
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. ').split()
|
||||||
|
|
||||||
|
|
||||||
|
def esc(t):
|
||||||
|
return t.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
|
||||||
|
|
||||||
|
|
||||||
|
def text_lines(n, words_per_line=11):
|
||||||
|
out, i = [], 0
|
||||||
|
for _ in range(n):
|
||||||
|
out.append(' '.join(WORDS[(i + k) % len(WORDS)] for k in range(words_per_line)))
|
||||||
|
i += words_per_line
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def page_content(no, size):
|
||||||
|
w, h = size
|
||||||
|
ops = ['BT', '/F1 22 Tf', f'50 {h - 70} Td', f'(Kapitel {no} - Seite {no}) Tj', 'ET']
|
||||||
|
if no == 7:
|
||||||
|
# Nearly empty: just the number at the bottom.
|
||||||
|
return '\n'.join(['BT', '/F1 10 Tf', f'{w / 2 - 10:.0f} 30 Td', f'({no}) Tj', 'ET'])
|
||||||
|
y = h - 110
|
||||||
|
lines = text_lines(28 if h > 700 else 18, 11 if w > 500 else 8)
|
||||||
|
ops += ['BT', '/F1 11 Tf', f'50 {y} Td', '14 TL']
|
||||||
|
for ln in lines:
|
||||||
|
ops.append(f'({esc(ln)}) Tj T*')
|
||||||
|
ops.append('ET')
|
||||||
|
if no == 2:
|
||||||
|
ops += ['q', f'200 0 0 120 {w - 260} 60 cm', '/Im1 Do', 'Q']
|
||||||
|
if no == 1:
|
||||||
|
ops += ['BT', '/F1 12 Tf', f'50 {y - 30 - 14 * len(lines)} Td',
|
||||||
|
'(Link zu Seite 5) Tj', '0 -20 Td', '(Link zu Seite 9) Tj', '0 -20 Td', '(Externer Link: example.org) Tj', 'ET']
|
||||||
|
ops += ['BT', '/F1 10 Tf', f'{w / 2 - 10:.0f} 30 Td', f'({no}) Tj', 'ET']
|
||||||
|
return '\n'.join(ops)
|
||||||
|
|
||||||
|
|
||||||
|
def build(path):
|
||||||
|
sizes = [A4] * 12
|
||||||
|
sizes[3] = A5 # page 4
|
||||||
|
sizes[8] = A4L # page 9
|
||||||
|
objs = [] # index = object number - 1
|
||||||
|
|
||||||
|
def add(body):
|
||||||
|
objs.append(body)
|
||||||
|
return len(objs)
|
||||||
|
|
||||||
|
catalog = add(None)
|
||||||
|
pages_obj = add(None)
|
||||||
|
font = add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>')
|
||||||
|
# A picture of four colours (2 x 2 pixels, drawn large).
|
||||||
|
pixels = b'FF0000 00A000 0000FF FFD700>'
|
||||||
|
image = add(b'<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB '
|
||||||
|
b'/BitsPerComponent 8 /Filter /ASCIIHexDecode /Length %d >>\nstream\n' % len(pixels)
|
||||||
|
+ pixels + b'\nendstream')
|
||||||
|
page_ids = []
|
||||||
|
for i, size in enumerate(sizes):
|
||||||
|
content = page_content(i + 1, size).encode('cp1252')
|
||||||
|
cid = add(b'<< /Length %d >>\nstream\n' % len(content) + content + b'\nendstream')
|
||||||
|
pid = add(None)
|
||||||
|
page_ids.append((pid, cid, size))
|
||||||
|
# links on page 1 (placed where the text is: bottom part of the text block)
|
||||||
|
p1_h = sizes[0][1]
|
||||||
|
base_y = p1_h - 110 - 30 - 14 * 28
|
||||||
|
|
||||||
|
def link_to(page_no, y):
|
||||||
|
return add(f'<< /Type /Annot /Subtype /Link /Rect [48 {y - 4} 220 {y + 14}] /Border [0 0 0] '
|
||||||
|
f'/Dest [{page_ids[page_no - 1][0]} 0 R /XYZ 0 {sizes[page_no - 1][1]} null] >>')
|
||||||
|
annots = [link_to(5, base_y), link_to(9, base_y - 20),
|
||||||
|
add(f'<< /Type /Annot /Subtype /Link /Rect [48 {base_y - 44} 300 {base_y - 26}] /Border [0 0 0] '
|
||||||
|
f'/A << /S /URI /URI (https://example.org/) >> >>')]
|
||||||
|
for n, (pid, cid, (w, h)) in enumerate(page_ids):
|
||||||
|
extra = f' /Annots [{" ".join(f"{a} 0 R" for a in annots)}]' if n == 0 else ''
|
||||||
|
xobj = f' /XObject << /Im1 {image} 0 R >>' if n == 1 else ''
|
||||||
|
objs[pid - 1] = (f'<< /Type /Page /Parent {pages_obj} 0 R /MediaBox [0 0 {w} {h}] '
|
||||||
|
f'/Resources << /Font << /F1 {font} 0 R >>{xobj} >> /Contents {cid} 0 R{extra} >>')
|
||||||
|
objs[pages_obj - 1] = f'<< /Type /Pages /Kids [{" ".join(f"{p} 0 R" for p, _, _ in page_ids)}] /Count {len(page_ids)} >>'
|
||||||
|
# outline: three entries
|
||||||
|
outline = add(None)
|
||||||
|
entries = [('Anfang', 1), ('Querformat', 9), ('Fast leer', 7)]
|
||||||
|
ids = [add(None) for _ in entries]
|
||||||
|
for k, ((title, page), oid) in enumerate(zip(entries, ids)):
|
||||||
|
nav = ''
|
||||||
|
if k > 0:
|
||||||
|
nav += f' /Prev {ids[k - 1]} 0 R'
|
||||||
|
if k < len(ids) - 1:
|
||||||
|
nav += f' /Next {ids[k + 1]} 0 R'
|
||||||
|
objs[oid - 1] = (f'<< /Title ({esc(title)}) /Parent {outline} 0 R{nav} '
|
||||||
|
f'/Dest [{page_ids[page - 1][0]} 0 R /XYZ 0 {sizes[page - 1][1]} null] >>')
|
||||||
|
objs[outline - 1] = f'<< /Type /Outlines /First {ids[0]} 0 R /Last {ids[-1]} 0 R /Count {len(ids)} >>'
|
||||||
|
objs[catalog - 1] = (f'<< /Type /Catalog /Pages {pages_obj} 0 R /Outlines {outline} 0 R /PageMode /UseOutlines '
|
||||||
|
f'/PageLabels << /Nums [0 << /S /r >> 2 << /S /D >>] >> >>')
|
||||||
|
info = add('<< /Title (Testdokument PDF) /Author (diora Test) >>')
|
||||||
|
|
||||||
|
out = bytearray(b'%PDF-1.4\n%\xe2\xe3\xcf\xd3\n')
|
||||||
|
offsets = []
|
||||||
|
for i, body in enumerate(objs):
|
||||||
|
offsets.append(len(out))
|
||||||
|
data = body if isinstance(body, bytes) else body.encode('cp1252')
|
||||||
|
out += b'%d 0 obj\n' % (i + 1) + data + b'\nendobj\n'
|
||||||
|
xref = len(out)
|
||||||
|
out += b'xref\n0 %d\n0000000000 65535 f \n' % (len(objs) + 1)
|
||||||
|
for off in offsets:
|
||||||
|
out += b'%010d 00000 n \n' % off
|
||||||
|
out += (b'trailer\n<< /Size %d /Root %d 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n'
|
||||||
|
% (len(objs) + 1, catalog, info, xref))
|
||||||
|
open(path, 'wb').write(out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
build(sys.argv[1] if len(sys.argv) > 1 else 'test.pdf')
|
||||||
652
e2e/fixtures/test.pdf
Normal file
652
e2e/fixtures/test.pdf
Normal file
|
|
@ -0,0 +1,652 @@
|
||||||
|
%PDF-1.4
|
||||||
|
%âãÏÓ
|
||||||
|
1 0 obj
|
||||||
|
<< /Type /Catalog /Pages 2 0 R /Outlines 32 0 R /PageMode /UseOutlines /PageLabels << /Nums [0 << /S /r >> 2 << /S /D >>] >> >>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<< /Type /Pages /Kids [6 0 R 8 0 R 10 0 R 12 0 R 14 0 R 16 0 R 18 0 R 20 0 R 22 0 R 24 0 R 26 0 R 28 0 R] /Count 12 >>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 28 >>
|
||||||
|
stream
|
||||||
|
FF0000 00A000 0000FF FFD700>
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
5 0 obj
|
||||||
|
<< /Length 2463 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 772 Td
|
||||||
|
(Kapitel 1 - Seite 1) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 732 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 12 Tf
|
||||||
|
50 310 Td
|
||||||
|
(Link zu Seite 5) Tj
|
||||||
|
0 -20 Td
|
||||||
|
(Link zu Seite 9) Tj
|
||||||
|
0 -20 Td
|
||||||
|
(Externer Link: example.org) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(1) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R /Annots [29 0 R 30 0 R 31 0 R] >>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<< /Length 2379 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 772 Td
|
||||||
|
(Kapitel 2 - Seite 2) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 732 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
ET
|
||||||
|
q
|
||||||
|
200 0 0 120 335 60 cm
|
||||||
|
/Im1 Do
|
||||||
|
Q
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(2) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
8 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 4 0 R >> >> /Contents 7 0 R >>
|
||||||
|
endobj
|
||||||
|
9 0 obj
|
||||||
|
<< /Length 2345 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 772 Td
|
||||||
|
(Kapitel 3 - Seite 3) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 732 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(3) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
10 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 9 0 R >>
|
||||||
|
endobj
|
||||||
|
11 0 obj
|
||||||
|
<< /Length 1196 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 525 Td
|
||||||
|
(Kapitel 4 - Seite 4) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 485 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
200 30 Td
|
||||||
|
(4) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
12 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 11 0 R >>
|
||||||
|
endobj
|
||||||
|
13 0 obj
|
||||||
|
<< /Length 2345 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 772 Td
|
||||||
|
(Kapitel 5 - Seite 5) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 732 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(5) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
14 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 13 0 R >>
|
||||||
|
endobj
|
||||||
|
15 0 obj
|
||||||
|
<< /Length 2345 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 772 Td
|
||||||
|
(Kapitel 6 - Seite 6) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 732 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(6) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
16 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 15 0 R >>
|
||||||
|
endobj
|
||||||
|
17 0 obj
|
||||||
|
<< /Length 32 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(7) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
18 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 17 0 R >>
|
||||||
|
endobj
|
||||||
|
19 0 obj
|
||||||
|
<< /Length 2345 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 772 Td
|
||||||
|
(Kapitel 8 - Seite 8) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 732 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(8) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
20 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 19 0 R >>
|
||||||
|
endobj
|
||||||
|
21 0 obj
|
||||||
|
<< /Length 1547 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 525 Td
|
||||||
|
(Kapitel 9 - Seite 9) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 485 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
411 30 Td
|
||||||
|
(9) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
22 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 21 0 R >>
|
||||||
|
endobj
|
||||||
|
23 0 obj
|
||||||
|
<< /Length 2348 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 772 Td
|
||||||
|
(Kapitel 10 - Seite 10) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 732 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(10) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
24 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 23 0 R >>
|
||||||
|
endobj
|
||||||
|
25 0 obj
|
||||||
|
<< /Length 2348 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 772 Td
|
||||||
|
(Kapitel 11 - Seite 11) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 732 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(11) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
26 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 25 0 R >>
|
||||||
|
endobj
|
||||||
|
27 0 obj
|
||||||
|
<< /Length 2348 >>
|
||||||
|
stream
|
||||||
|
BT
|
||||||
|
/F1 22 Tf
|
||||||
|
50 772 Td
|
||||||
|
(Kapitel 12 - Seite 12) Tj
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 11 Tf
|
||||||
|
50 732 Td
|
||||||
|
14 TL
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||||
|
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||||
|
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||||
|
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||||
|
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||||
|
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||||
|
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||||
|
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||||
|
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||||
|
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||||
|
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||||
|
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||||
|
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||||
|
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||||
|
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||||
|
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||||
|
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||||
|
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||||
|
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||||
|
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||||
|
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||||
|
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||||
|
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||||
|
ET
|
||||||
|
BT
|
||||||
|
/F1 10 Tf
|
||||||
|
288 30 Td
|
||||||
|
(12) Tj
|
||||||
|
ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
28 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 27 0 R >>
|
||||||
|
endobj
|
||||||
|
29 0 obj
|
||||||
|
<< /Type /Annot /Subtype /Link /Rect [48 306 220 324] /Border [0 0 0] /Dest [14 0 R /XYZ 0 842 null] >>
|
||||||
|
endobj
|
||||||
|
30 0 obj
|
||||||
|
<< /Type /Annot /Subtype /Link /Rect [48 286 220 304] /Border [0 0 0] /Dest [22 0 R /XYZ 0 595 null] >>
|
||||||
|
endobj
|
||||||
|
31 0 obj
|
||||||
|
<< /Type /Annot /Subtype /Link /Rect [48 266 300 284] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>
|
||||||
|
endobj
|
||||||
|
32 0 obj
|
||||||
|
<< /Type /Outlines /First 33 0 R /Last 35 0 R /Count 3 >>
|
||||||
|
endobj
|
||||||
|
33 0 obj
|
||||||
|
<< /Title (Anfang) /Parent 32 0 R /Next 34 0 R /Dest [6 0 R /XYZ 0 842 null] >>
|
||||||
|
endobj
|
||||||
|
34 0 obj
|
||||||
|
<< /Title (Querformat) /Parent 32 0 R /Prev 33 0 R /Next 35 0 R /Dest [22 0 R /XYZ 0 595 null] >>
|
||||||
|
endobj
|
||||||
|
35 0 obj
|
||||||
|
<< /Title (Fast leer) /Parent 32 0 R /Prev 34 0 R /Dest [18 0 R /XYZ 0 842 null] >>
|
||||||
|
endobj
|
||||||
|
36 0 obj
|
||||||
|
<< /Title (Testdokument PDF) /Author (diora Test) >>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 37
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000015 00000 n
|
||||||
|
0000000158 00000 n
|
||||||
|
0000000292 00000 n
|
||||||
|
0000000389 00000 n
|
||||||
|
0000000584 00000 n
|
||||||
|
0000003099 00000 n
|
||||||
|
0000003256 00000 n
|
||||||
|
0000005687 00000 n
|
||||||
|
0000005839 00000 n
|
||||||
|
0000008236 00000 n
|
||||||
|
0000008363 00000 n
|
||||||
|
0000009612 00000 n
|
||||||
|
0000009740 00000 n
|
||||||
|
0000012138 00000 n
|
||||||
|
0000012266 00000 n
|
||||||
|
0000014664 00000 n
|
||||||
|
0000014792 00000 n
|
||||||
|
0000014875 00000 n
|
||||||
|
0000015003 00000 n
|
||||||
|
0000017401 00000 n
|
||||||
|
0000017529 00000 n
|
||||||
|
0000019129 00000 n
|
||||||
|
0000019257 00000 n
|
||||||
|
0000021658 00000 n
|
||||||
|
0000021786 00000 n
|
||||||
|
0000024187 00000 n
|
||||||
|
0000024315 00000 n
|
||||||
|
0000026716 00000 n
|
||||||
|
0000026844 00000 n
|
||||||
|
0000026964 00000 n
|
||||||
|
0000027084 00000 n
|
||||||
|
0000027218 00000 n
|
||||||
|
0000027292 00000 n
|
||||||
|
0000027388 00000 n
|
||||||
|
0000027502 00000 n
|
||||||
|
0000027602 00000 n
|
||||||
|
trailer
|
||||||
|
<< /Size 37 /Root 1 0 R /Info 36 0 R >>
|
||||||
|
startxref
|
||||||
|
27671
|
||||||
|
%%EOF
|
||||||
|
|
@ -13,7 +13,7 @@ class Command(BaseCommand):
|
||||||
parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
|
parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
qs = Source.objects.select_related('user').order_by('last_refreshed_at')
|
qs = Source.objects.filter(synthetic=False).select_related('user').order_by('last_refreshed_at')
|
||||||
if options['source_id']:
|
if options['source_id']:
|
||||||
qs = qs.filter(pk=options['source_id'])
|
qs = qs.filter(pk=options['source_id'])
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
18
feeds/migrations/0007_transcription.py
Normal file
18
feeds/migrations/0007_transcription.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 6.1 on 2026-11-09 08:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('feeds', '0006_folder_order'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='synthetic',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -35,6 +35,9 @@ class Source(models.Model):
|
||||||
fav = models.BooleanField(default=False)
|
fav = models.BooleanField(default=False)
|
||||||
hidden = models.BooleanField(default=False)
|
hidden = models.BooleanField(default=False)
|
||||||
notify = models.BooleanField(default=False)
|
notify = models.BooleanField(default=False)
|
||||||
|
# Filled by diora itself (the blog "Podcast-Artikel"), not by a feed: never
|
||||||
|
# fetched, pruned, given another URL or turned into news.
|
||||||
|
synthetic = models.BooleanField(default=False)
|
||||||
|
|
||||||
# Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed).
|
# Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed).
|
||||||
last_refreshed_at = models.DateTimeField(null=True, blank=True)
|
last_refreshed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ def expire(user, now=None):
|
||||||
Item.objects.filter(user=user, gone=True, published__lt=now - timedelta(days=GONE_KEEP_DAYS)).delete()
|
Item.objects.filter(user=user, gone=True, published__lt=now - timedelta(days=GONE_KEEP_DAYS)).delete()
|
||||||
Story.objects.filter(user=user).exclude(items__gone=False).delete()
|
Story.objects.filter(user=user).exclude(items__gone=False).delete()
|
||||||
# Blogs: the oldest read articles beyond BLOG_KEEP per blog.
|
# Blogs: the oldest read articles beyond BLOG_KEEP per blog.
|
||||||
for source in Source.objects.filter(user=user, kind=Source.BLOG):
|
for source in Source.objects.filter(user=user, kind=Source.BLOG, synthetic=False):
|
||||||
ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
|
ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
|
||||||
if ids:
|
if ids:
|
||||||
Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete()
|
Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete()
|
||||||
|
|
@ -225,7 +225,7 @@ def expire(user, now=None):
|
||||||
def refresh(sources, deadline=None):
|
def refresh(sources, deadline=None):
|
||||||
"""Refresh `sources` (downloads in parallel, writes one by one).
|
"""Refresh `sources` (downloads in parallel, writes one by one).
|
||||||
Returns (refreshed, failed, new items)."""
|
Returns (refreshed, failed, new items)."""
|
||||||
sources = list(sources)
|
sources = [s for s in sources if not s.synthetic] # synthetic sources have no feed to fetch
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
keep = {}
|
keep = {}
|
||||||
refreshed = failed = 0
|
refreshed = failed = 0
|
||||||
|
|
|
||||||
|
|
@ -539,3 +539,56 @@ class NewsTextApiTest(TestCase):
|
||||||
self.assertEqual(got['related'], [])
|
self.assertEqual(got['related'], [])
|
||||||
single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json()
|
single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json()
|
||||||
self.assertNotIn('summary', single)
|
self.assertNotIn('summary', single)
|
||||||
|
|
||||||
|
|
||||||
|
class SyntheticSourceTest(TestCase):
|
||||||
|
"""A source diora fills itself ("Podcast-Artikel") is never fetched or pruned."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.u = User.objects.create_user('tester', password='pw12345678')
|
||||||
|
self.client.force_login(self.u)
|
||||||
|
self.synth = Source.objects.create(user=self.u, url='diora://podcast-artikel', title='Podcast-Artikel',
|
||||||
|
kind='blog', fav=True, synthetic=True)
|
||||||
|
self.real = Source.objects.create(user=self.u, url='https://real.example/feed', title='Real', kind='blog')
|
||||||
|
|
||||||
|
def post(self, url, data):
|
||||||
|
return self.client.post(url, json.dumps(data), content_type='application/json')
|
||||||
|
|
||||||
|
def test_refresh_skips_it(self):
|
||||||
|
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([]), 'https://real.example/feed')) as get:
|
||||||
|
refresh.refresh([self.synth, self.real])
|
||||||
|
self.assertEqual([c.args[0] for c in get.call_args_list], ['https://real.example/feed'])
|
||||||
|
|
||||||
|
def test_refresh_now_and_command_skip_it(self):
|
||||||
|
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([]), 'https://real.example/feed')) as get:
|
||||||
|
r = self.post('/feeds/refresh/', {})
|
||||||
|
from django.core.management import call_command
|
||||||
|
call_command('refresh_newsfeeds', stdout=mock.MagicMock())
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertTrue(get.call_args_list)
|
||||||
|
self.assertTrue(all(c.args[0] == 'https://real.example/feed' for c in get.call_args_list))
|
||||||
|
call_command('refresh_newsfeeds', '--source-id', str(self.synth.id), stdout=mock.MagicMock())
|
||||||
|
|
||||||
|
def test_old_items_are_not_pruned(self):
|
||||||
|
old = timezone.now() - timedelta(days=400)
|
||||||
|
for source in (self.synth, self.real):
|
||||||
|
for n in range(refresh.BLOG_KEEP + 5):
|
||||||
|
Item.objects.create(source=source, user=self.u, kind='blog', guid_hash=f'{source.pk}-{n}',
|
||||||
|
title='x', published=old - timedelta(minutes=n), read_at=old)
|
||||||
|
refresh.expire(self.u)
|
||||||
|
self.assertEqual(self.synth.items.count(), refresh.BLOG_KEEP + 5)
|
||||||
|
self.assertEqual(self.real.items.count(), refresh.BLOG_KEEP)
|
||||||
|
|
||||||
|
def test_cannot_become_news_but_can_be_hidden(self):
|
||||||
|
r = self.post(f'/feeds/sources/{self.synth.id}/', {'kind': 'news'})
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
r = self.post(f'/feeds/sources/{self.synth.id}/', {'hidden': True, 'notify': True})
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.synth.refresh_from_db()
|
||||||
|
self.assertEqual((self.synth.kind, self.synth.hidden, self.synth.notify), ('blog', True, True))
|
||||||
|
self.assertTrue(r.json()['source']['synthetic'])
|
||||||
|
|
||||||
|
def test_not_in_opml(self):
|
||||||
|
body = self.client.get('/feeds/export.opml').content.decode()
|
||||||
|
self.assertIn('real.example', body)
|
||||||
|
self.assertNotIn('diora://', body)
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ def _source_data(s, unread=0, new=0, interval=None):
|
||||||
'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url,
|
'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url,
|
||||||
'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext,
|
'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext,
|
||||||
'unread': unread, 'new': new,
|
'unread': unread, 'new': new,
|
||||||
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify,
|
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, 'synthetic': s.synthetic,
|
||||||
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
|
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
|
||||||
'interval_days': interval,
|
'interval_days': interval,
|
||||||
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
|
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
|
||||||
|
|
@ -108,6 +108,11 @@ def _item_data(i, content=False):
|
||||||
'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'progress': i.progress,
|
'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'progress': i.progress,
|
||||||
'starred': i.starred, 'story': i.story_id,
|
'starred': i.starred, 'story': i.story_id,
|
||||||
}
|
}
|
||||||
|
if i.source.synthetic:
|
||||||
|
# The article of a podcast episode (docs/transcription.md); the lists fetch it with the item.
|
||||||
|
art = getattr(i, 'episode_article', None)
|
||||||
|
if art is not None:
|
||||||
|
d['episode_id'] = art.episode_id
|
||||||
if content:
|
if content:
|
||||||
d['content'] = i.content
|
d['content'] = i.content
|
||||||
d['content_full'] = i.content_full
|
d['content_full'] = i.content_full
|
||||||
|
|
@ -356,7 +361,7 @@ def add_sources(request):
|
||||||
defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
|
defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
|
||||||
title=(x.get('title') or url)[:300]))
|
title=(x.get('title') or url)[:300]))
|
||||||
added += created
|
added += created
|
||||||
sources = Source.objects.filter(user=request.user, last_refreshed_at=None)
|
sources = Source.objects.filter(user=request.user, last_refreshed_at=None, synthetic=False)
|
||||||
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
|
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
|
||||||
return JsonResponse({'added': added})
|
return JsonResponse({'added': added})
|
||||||
|
|
||||||
|
|
@ -386,6 +391,8 @@ def edit_source(request, pk):
|
||||||
if body.get('fulltext') in dict(Source.FULLTEXTS):
|
if body.get('fulltext') in dict(Source.FULLTEXTS):
|
||||||
source.fulltext = body['fulltext']
|
source.fulltext = body['fulltext']
|
||||||
new_kind = _kind(body.get('kind'))
|
new_kind = _kind(body.get('kind'))
|
||||||
|
if source.synthetic and new_kind == Source.NEWS:
|
||||||
|
return JsonResponse({'error': 'Diese Quelle bleibt ein Blog'}, status=400)
|
||||||
if new_kind and new_kind != source.kind:
|
if new_kind and new_kind != source.kind:
|
||||||
# A section becomes a folder and back.
|
# A section becomes a folder and back.
|
||||||
if new_kind == Source.BLOG and source.category and not source.folders:
|
if new_kind == Source.BLOG and source.category and not source.folders:
|
||||||
|
|
@ -416,7 +423,7 @@ def export_opml(request):
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
groups = {}
|
groups = {}
|
||||||
for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'):
|
for s in Source.objects.filter(user=request.user, synthetic=False).order_by('kind', 'category', 'title'):
|
||||||
kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs'
|
kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs'
|
||||||
place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0])
|
place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0])
|
||||||
label = kind + (f' – {place}' if place else '')
|
label = kind + (f' – {place}' if place else '')
|
||||||
|
|
@ -440,7 +447,7 @@ def export_opml(request):
|
||||||
def refresh_now(request):
|
def refresh_now(request):
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user),
|
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user, synthetic=False),
|
||||||
deadline=time.monotonic() + REFRESH_WAIT)
|
deadline=time.monotonic() + REFRESH_WAIT)
|
||||||
return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new})
|
return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new})
|
||||||
|
|
||||||
|
|
@ -611,7 +618,7 @@ def story_overview(request, pk):
|
||||||
def blogs(request):
|
def blogs(request):
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source')
|
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source', 'episode_article')
|
||||||
if request.GET.get('source', '').isdigit():
|
if request.GET.get('source', '').isdigit():
|
||||||
qs = qs.filter(source_id=int(request.GET['source']))
|
qs = qs.filter(source_id=int(request.GET['source']))
|
||||||
elif 'folder' in request.GET:
|
elif 'folder' in request.GET:
|
||||||
|
|
@ -640,7 +647,7 @@ def blogs(request):
|
||||||
def starred(request):
|
def starred(request):
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
items = Item.objects.filter(user=request.user, starred=True).select_related('source').order_by('-published')
|
items = Item.objects.filter(user=request.user, starred=True).select_related('source', 'episode_article').order_by('-published')
|
||||||
return JsonResponse({'items': [_item_data(i) for i in items[:200]]})
|
return JsonResponse({'items': [_item_data(i) for i in items[:200]]})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -649,12 +656,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').first()
|
i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source', 'episode_article').first()
|
||||||
if i is None:
|
if i is None:
|
||||||
return JsonResponse({'error': 'not found'}, status=404)
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
if refresh.needs_article(i, i.source):
|
if refresh.needs_article(i, i.source):
|
||||||
refresh.load_article(i)
|
refresh.load_article(i)
|
||||||
d = _item_data(i, content=True)
|
d = _item_data(i, content=True)
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
@ -744,17 +752,32 @@ def star(request, pk):
|
||||||
def notifications(request):
|
def notifications(request):
|
||||||
"""What a device should announce: posts marked when they came in, still
|
"""What a device should announce: posts marked when they came in, still
|
||||||
unread, of blogs that still have the bell on, after the id the device saw
|
unread, of blogs that still have the bell on, after the id the device saw
|
||||||
last. Without `after` (a device's first time) nothing, only where to start."""
|
last. Without `after` (a device's first time) nothing, only where to start.
|
||||||
|
The same for finished transcripts (`t_after`, `transcripts`, `t_last`; jobs the
|
||||||
|
user's episodes have, docs/transcription.md)."""
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
qs = Item.objects.filter(user=request.user, notify=True)
|
qs = Item.objects.filter(user=request.user, notify=True)
|
||||||
last = qs.order_by('-id').values_list('id', flat=True).first() or 0
|
last = qs.order_by('-id').values_list('id', flat=True).first() or 0
|
||||||
|
# Jobs run in id order, one at a time, so a job never finishes below an id already seen.
|
||||||
|
# (A job that is done with no data only reflects a feed transcript: no notice for it.)
|
||||||
|
from podcasts.models import TranscriptionJob
|
||||||
|
jobs = TranscriptionJob.objects.filter(
|
||||||
|
episode__feed__user=request.user, state=TranscriptionJob.DONE, data__isnull=False)
|
||||||
|
t_last = jobs.order_by('-id').values_list('id', flat=True).first() or 0
|
||||||
|
out = {'items': [], 'last': last, 'transcripts': [], 't_last': t_last}
|
||||||
|
t_after = request.GET.get('t_after', '')
|
||||||
|
if t_after.isdigit():
|
||||||
|
out['transcripts'] = [{
|
||||||
|
'job_id': j.id, 'episode_id': j.episode_id, 'title': j.episode.title,
|
||||||
|
'podcast': j.episode.feed.title, 'want_article': j.want_article,
|
||||||
|
} for j in jobs.filter(id__gt=int(t_after)).select_related('episode__feed').order_by('id')[:50]]
|
||||||
after = request.GET.get('after', '')
|
after = request.GET.get('after', '')
|
||||||
if not after.isdigit():
|
if after.isdigit():
|
||||||
return JsonResponse({'items': [], 'last': last})
|
|
||||||
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
|
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
|
||||||
.select_related('source').order_by('-published')[:50])
|
.select_related('source').order_by('-published')[:50])
|
||||||
return JsonResponse({'items': [_item_data(i) for i in items], 'last': last})
|
out['items'] = [_item_data(i) for i in items]
|
||||||
|
return JsonResponse(out)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
|
@ -770,6 +793,8 @@ def save_login(request):
|
||||||
password = body.get('password') or ''
|
password = body.get('password') or ''
|
||||||
if source is None or not username or not password:
|
if source is None or not username or not password:
|
||||||
return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400)
|
return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400)
|
||||||
|
if source.synthetic:
|
||||||
|
return JsonResponse({'error': 'Diese Quelle hat keine Adresse'}, status=400)
|
||||||
try:
|
try:
|
||||||
adapter = logins.check_and_store(request.user, source.url, username, password)
|
adapter = logins.check_and_store(request.user, source.url, username, password)
|
||||||
except logins.LoginFailed as e:
|
except logins.LoginFailed as e:
|
||||||
|
|
|
||||||
119
podcasts/article.py
Normal file
119
podcasts/article.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""The article written from an episode's transcript (docs/transcription.md): its text
|
||||||
|
is a feeds.Item in the user's favourite blog "Podcast-Artikel"."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from feeds import fetch
|
||||||
|
from feeds.models import Item, Source
|
||||||
|
|
||||||
|
from .models import EpisodeArticle, PodcastEpisode, TranscriptionJob
|
||||||
|
|
||||||
|
SOURCE_URL = 'diora://podcast-artikel'
|
||||||
|
SOURCE_TITLE = 'Podcast-Artikel'
|
||||||
|
TITLE_MAX = 300
|
||||||
|
SUMMARY_MAX = 2000
|
||||||
|
BODY_MAX = 1_000_000 # characters of body_html
|
||||||
|
MODEL_MAX = 100
|
||||||
|
KIND_MAX = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _body(request):
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body or b'{}')
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
return body if isinstance(body, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def article_source(user):
|
||||||
|
"""The user's blog "Podcast-Artikel", made on first use."""
|
||||||
|
source, _ = Source.objects.get_or_create(user=user, url=SOURCE_URL, defaults={
|
||||||
|
'title': SOURCE_TITLE, 'kind': Source.BLOG, 'fav': True, 'synthetic': True})
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value, limit):
|
||||||
|
return value.strip() if isinstance(value, str) and len(value.strip()) <= limit else None
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST', 'DELETE'])
|
||||||
|
def episode_article(request, pk):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).select_related('feed').first()
|
||||||
|
if ep is None:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
|
||||||
|
if art is not None:
|
||||||
|
art.item.delete() # the article goes with it (cascade)
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
body = _body(request)
|
||||||
|
if body is None:
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
title = _text(body.get('title'), TITLE_MAX)
|
||||||
|
if not title:
|
||||||
|
return JsonResponse({'error': 'title needed (at most %d characters)' % TITLE_MAX}, status=400)
|
||||||
|
html_in = body.get('body_html')
|
||||||
|
if not isinstance(html_in, str) or not html_in.strip():
|
||||||
|
return JsonResponse({'error': 'body_html needed'}, status=400)
|
||||||
|
if len(html_in) > BODY_MAX:
|
||||||
|
return JsonResponse({'error': 'body_html too large'}, status=400)
|
||||||
|
summary = body.get('summary') or ''
|
||||||
|
if not isinstance(summary, str) or len(summary) > SUMMARY_MAX:
|
||||||
|
return JsonResponse({'error': 'summary too long'}, status=400)
|
||||||
|
model = body.get('model') or ''
|
||||||
|
kind = body.get('kind') or ''
|
||||||
|
if not isinstance(model, str) or len(model) > MODEL_MAX or not isinstance(kind, str) or len(kind) > KIND_MAX:
|
||||||
|
return JsonResponse({'error': 'model or kind too long'}, status=400)
|
||||||
|
|
||||||
|
url = getattr(ep, 'link', '') or ep.audio_url
|
||||||
|
content = fetch.clean_html(html_in, url)
|
||||||
|
if not fetch.plain(content):
|
||||||
|
return JsonResponse({'error': 'body_html empty'}, status=400)
|
||||||
|
fields = {
|
||||||
|
'url': url[:2000],
|
||||||
|
'title': title,
|
||||||
|
'author': (ep.feed.author or '')[:300],
|
||||||
|
'published': timezone.now(),
|
||||||
|
'summary': fetch.plain(summary),
|
||||||
|
'content': content,
|
||||||
|
'content_full': True, # nothing to fetch from a page
|
||||||
|
'words': fetch.word_count(content),
|
||||||
|
'image_url': (ep.artwork_url or ep.feed.artwork_url or '')[:2000],
|
||||||
|
'read_at': None, 'progress': 0, 'progress_at': None,
|
||||||
|
}
|
||||||
|
with transaction.atomic():
|
||||||
|
source = article_source(request.user)
|
||||||
|
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
|
||||||
|
if art is not None:
|
||||||
|
item = art.item
|
||||||
|
for k, v in fields.items():
|
||||||
|
setattr(item, k, v)
|
||||||
|
item.source = source
|
||||||
|
item.save()
|
||||||
|
art.model, art.kind = model, kind
|
||||||
|
art.save(update_fields=['model', 'kind'])
|
||||||
|
else:
|
||||||
|
guid = hashlib.sha256(f'diora-article:{ep.pk}'.encode()).hexdigest()
|
||||||
|
item = Item.objects.create(source=source, user=request.user, kind=Source.BLOG,
|
||||||
|
guid_hash=guid, **fields)
|
||||||
|
EpisodeArticle.objects.create(episode=ep, item=item, model=model, kind=kind)
|
||||||
|
# Saving fulfils the wish and frees the reservation.
|
||||||
|
TranscriptionJob.objects.filter(episode=ep).update(want_article=False, claimed_by='', claimed_until=None)
|
||||||
|
return JsonResponse({'item_id': item.id})
|
||||||
|
|
@ -128,25 +128,28 @@ FetchUnavailable = ChaptersUnavailable
|
||||||
FetchInvalid = ChaptersInvalid
|
FetchInvalid = ChaptersInvalid
|
||||||
|
|
||||||
|
|
||||||
def _open(url, headers):
|
def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs):
|
||||||
"""GET `url` with every redirect hop checked; the streamed response of
|
"""`method` (GET or POST) `url` with every redirect hop checked; the streamed response of
|
||||||
the final hop, which the caller has to close.
|
the final hop, which the caller has to close. `kwargs` (data, files) go to
|
||||||
|
requests; a POST only follows 307/308, which keep the method and body.
|
||||||
|
|
||||||
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
|
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
|
||||||
ones that may go away (network trouble, server errors).
|
ones that may go away (network trouble, server errors).
|
||||||
"""
|
"""
|
||||||
|
redirects = (301, 302, 303, 307, 308) if method == 'GET' else (307, 308)
|
||||||
for _ in range(CHAPTERS_MAX_REDIRECTS + 1):
|
for _ in range(CHAPTERS_MAX_REDIRECTS + 1):
|
||||||
try:
|
try:
|
||||||
assert_safe_url(url)
|
assert_safe_url(url)
|
||||||
except WebDAVInputError as e:
|
except WebDAVInputError as e:
|
||||||
raise ChaptersInvalid(str(e))
|
raise ChaptersInvalid(str(e))
|
||||||
try:
|
try:
|
||||||
r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers)
|
send = requests.get if method == 'GET' else requests.post
|
||||||
|
r = send(url, timeout=timeout, allow_redirects=False, stream=True, headers=headers, **kwargs)
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise ChaptersUnavailable(e.__class__.__name__)
|
raise ChaptersUnavailable(e.__class__.__name__)
|
||||||
try:
|
try:
|
||||||
_assert_peer_is_safe(r)
|
_assert_peer_is_safe(r)
|
||||||
if r.status_code in (301, 302, 303, 307, 308):
|
if r.status_code in redirects:
|
||||||
location = r.headers.get('Location')
|
location = r.headers.get('Location')
|
||||||
if not location:
|
if not location:
|
||||||
raise ChaptersInvalid('redirect without a location')
|
raise ChaptersInvalid('redirect without a location')
|
||||||
|
|
@ -155,7 +158,7 @@ def _open(url, headers):
|
||||||
continue
|
continue
|
||||||
if r.status_code >= 500 or r.status_code == 429:
|
if r.status_code >= 500 or r.status_code == 429:
|
||||||
raise ChaptersUnavailable(f'HTTP {r.status_code}')
|
raise ChaptersUnavailable(f'HTTP {r.status_code}')
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400 or r.status_code in (301, 302, 303):
|
||||||
raise ChaptersInvalid(f'HTTP {r.status_code}')
|
raise ChaptersInvalid(f'HTTP {r.status_code}')
|
||||||
except WebDAVInputError as e:
|
except WebDAVInputError as e:
|
||||||
r.close()
|
r.close()
|
||||||
|
|
|
||||||
18
podcasts/migrations/0027_podcastepisode_link.py
Normal file
18
podcasts/migrations/0027_podcastepisode_link.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 6.1 on 2026-11-09 08:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('podcasts', '0026_podcastepisode_sponsor'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='podcastepisode',
|
||||||
|
name='link',
|
||||||
|
field=models.URLField(blank=True, max_length=1000),
|
||||||
|
),
|
||||||
|
]
|
||||||
59
podcasts/migrations/0028_transcription.py
Normal file
59
podcasts/migrations/0028_transcription.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Generated by Django 6.1 on 2026-11-09 08:20
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('feeds', '0007_transcription'),
|
||||||
|
('podcasts', '0027_podcastepisode_link'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EpisodeArticle',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('model', models.CharField(blank=True, max_length=100)),
|
||||||
|
('kind', models.CharField(blank=True, max_length=20)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='article', to='podcasts.podcastepisode')),
|
||||||
|
('item', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='episode_article', to='feeds.item')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TranscriptionJob',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('state', models.CharField(choices=[('none', 'none'), ('queued', 'queued'), ('downloading', 'downloading'), ('transcribing', 'transcribing'), ('done', 'done'), ('error', 'error')], default='none', max_length=15)),
|
||||||
|
('chunks_done', models.PositiveIntegerField(default=0)),
|
||||||
|
('chunks_total', models.PositiveIntegerField(default=0)),
|
||||||
|
('error', models.CharField(blank=True, max_length=500)),
|
||||||
|
('want_article', models.BooleanField(default=False)),
|
||||||
|
('claimed_by', models.CharField(blank=True, max_length=60)),
|
||||||
|
('claimed_until', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('data', models.JSONField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_job', to='podcasts.podcastepisode')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TranscriptionSettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('engine', models.CharField(choices=[('builtin', 'Eingebaut'), ('custom', 'Eigene Instanz')], default='builtin', max_length=10)),
|
||||||
|
('url', models.CharField(blank=True, max_length=500)),
|
||||||
|
('token_encrypted', models.TextField(blank=True)),
|
||||||
|
('model', models.CharField(blank=True, max_length=100)),
|
||||||
|
('language', models.CharField(blank=True, max_length=10)),
|
||||||
|
('parallel', models.PositiveSmallIntegerField(default=3)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_settings', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -164,6 +164,8 @@ class PodcastEpisode(models.Model):
|
||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
audio_url = models.URLField(max_length=1000)
|
audio_url = models.URLField(max_length=1000)
|
||||||
|
# The episode's page, from the feed item's <link> (for sharing); '' when it has none.
|
||||||
|
link = models.URLField(max_length=1000, blank=True)
|
||||||
duration_seconds = models.IntegerField(default=0)
|
duration_seconds = models.IntegerField(default=0)
|
||||||
pub_date = models.DateTimeField(null=True, blank=True)
|
pub_date = models.DateTimeField(null=True, blank=True)
|
||||||
episode_number = models.IntegerField(null=True, blank=True)
|
episode_number = models.IntegerField(null=True, blank=True)
|
||||||
|
|
@ -217,6 +219,68 @@ class EpisodeTranscript(models.Model):
|
||||||
return f'Transcript of {self.episode}'
|
return f'Transcript of {self.episode}'
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionSettings(models.Model):
|
||||||
|
"""Where a user's episodes are transcribed (docs/transcription.md): the
|
||||||
|
built-in Whisper instance (DIORA_WHISPER_URL) or their own."""
|
||||||
|
BUILTIN = 'builtin'
|
||||||
|
CUSTOM = 'custom'
|
||||||
|
ENGINES = [(BUILTIN, 'Eingebaut'), (CUSTOM, 'Eigene Instanz')]
|
||||||
|
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='transcription_settings')
|
||||||
|
engine = models.CharField(max_length=10, choices=ENGINES, default=BUILTIN)
|
||||||
|
# The instance's base, e.g. https://whisper.example.org/v1 (custom only).
|
||||||
|
url = models.CharField(max_length=500, blank=True)
|
||||||
|
# The API token, Fernet-encrypted like feeds.SiteLogin.secret; never handed out.
|
||||||
|
token_encrypted = models.TextField(blank=True)
|
||||||
|
model = models.CharField(max_length=100, blank=True)
|
||||||
|
# '' = automatic, else an ISO code ("de").
|
||||||
|
language = models.CharField(max_length=10, blank=True)
|
||||||
|
parallel = models.PositiveSmallIntegerField(default=3) # 1 to 8 pieces at once
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Transcription settings of {self.user}'
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionJob(models.Model):
|
||||||
|
"""One episode's transcription run, its Whisper result and the wish for an
|
||||||
|
article. Kept apart from EpisodeTranscript, which caches the feed's
|
||||||
|
transcripts and is rewritten when the feed changes them."""
|
||||||
|
NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR = (
|
||||||
|
'none', 'queued', 'downloading', 'transcribing', 'done', 'error')
|
||||||
|
STATES = [(s, s) for s in (NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR)]
|
||||||
|
|
||||||
|
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='transcription_job')
|
||||||
|
state = models.CharField(max_length=15, choices=STATES, default=NONE)
|
||||||
|
chunks_done = models.PositiveIntegerField(default=0)
|
||||||
|
chunks_total = models.PositiveIntegerField(default=0)
|
||||||
|
error = models.CharField(max_length=500, blank=True)
|
||||||
|
want_article = models.BooleanField(default=False)
|
||||||
|
# The device writing the article (a short name) and until when it holds the wish.
|
||||||
|
claimed_by = models.CharField(max_length=60, blank=True)
|
||||||
|
claimed_until = models.DateTimeField(null=True, blank=True)
|
||||||
|
# {"segments": [{start, end, speaker, text}], "source": {type: "whisper", model, language}}
|
||||||
|
data = models.JSONField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
started_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
finished_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Transcription of {self.episode} ({self.state})'
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeArticle(models.Model):
|
||||||
|
"""The article written from an episode's transcript; its text is the
|
||||||
|
feeds.Item (in the blog "Podcast-Artikel")."""
|
||||||
|
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='article')
|
||||||
|
item = models.OneToOneField('feeds.Item', on_delete=models.CASCADE, related_name='episode_article')
|
||||||
|
model = models.CharField(max_length=100, blank=True)
|
||||||
|
kind = models.CharField(max_length=20, blank=True) # Interview, Gespräch, Monolog, Diskussion
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Article on {self.episode}'
|
||||||
|
|
||||||
|
|
||||||
# An episode counts as played this close to its end (AntennaPod's default),
|
# An episode counts as played this close to its end (AntennaPod's default),
|
||||||
# the same rule as the apps' "als gehört zählen".
|
# the same rule as the apps' "als gehört zählen".
|
||||||
PLAYED_BEFORE_END_SECONDS = 30
|
PLAYED_BEFORE_END_SECONDS = 30
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ ALLOWED_ATTRS = {
|
||||||
URL_ATTRS = {'href', 'src'}
|
URL_ATTRS = {'href', 'src'}
|
||||||
LINK_SCHEMES = {'http', 'https', 'mailto'}
|
LINK_SCHEMES = {'http', 'https', 'mailto'}
|
||||||
IMAGE_SCHEMES = {'http', 'https'}
|
IMAGE_SCHEMES = {'http', 'https'}
|
||||||
|
# A link to a second in the episode ("#t=754"), as in the article of a transcript
|
||||||
|
# (docs/transcription.md): the only fragment link that is kept.
|
||||||
|
TIME_LINK = re.compile(r'^#t=\d{1,6}$')
|
||||||
|
|
||||||
_CONTROL_AND_SPACE = re.compile(r'[\x00-\x20\x7f]+')
|
_CONTROL_AND_SPACE = re.compile(r'[\x00-\x20\x7f]+')
|
||||||
_SCHEME = re.compile(r'^([a-z][a-z0-9+.\-]*):')
|
_SCHEME = re.compile(r'^([a-z][a-z0-9+.\-]*):')
|
||||||
|
|
@ -81,7 +84,9 @@ class _Sanitizer(HTMLParser):
|
||||||
name = name.lower()
|
name = name.lower()
|
||||||
if name not in allowed or value is None:
|
if name not in allowed or value is None:
|
||||||
continue
|
continue
|
||||||
if name in URL_ATTRS:
|
if tag == 'a' and name == 'href' and TIME_LINK.match(value.strip()):
|
||||||
|
value = value.strip()
|
||||||
|
elif name in URL_ATTRS:
|
||||||
value = _safe_url(value, IMAGE_SCHEMES if tag == 'img' else LINK_SCHEMES)
|
value = _safe_url(value, IMAGE_SCHEMES if tag == 'img' else LINK_SCHEMES)
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
146
podcasts/test_article.py
Normal file
146
podcasts/test_article.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from feeds.models import Item, Source
|
||||||
|
|
||||||
|
from .models import EpisodeArticle, PodcastEpisode, PodcastFeed, TranscriptionJob
|
||||||
|
|
||||||
|
BODY = ('<p>Zusammenfassung <script>alert(1)</script></p><h2>Teil eins</h2>'
|
||||||
|
'<p><a href="#t=754" onclick="evil()">▶ ab 12:34</a> und <a href="https://example.org/x">Quelle</a></p>')
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://p.example/rss', title='Salon',
|
||||||
|
author='Anna Autorin', artwork_url='https://p.example/feed.jpg')
|
||||||
|
self.ep = PodcastEpisode.objects.create(feed=self.feed, guid='e1', title='Folge 1',
|
||||||
|
audio_url='https://p.example/e1.mp3', artwork_url='https://p.example/e1.jpg')
|
||||||
|
|
||||||
|
def url(self, ep=None):
|
||||||
|
return f'/podcasts/episodes/{(ep or self.ep).id}/article/'
|
||||||
|
|
||||||
|
def post(self, data=None, ep=None):
|
||||||
|
data = {'title': 'Ein Artikel', 'summary': 'Kurz.', 'body_html': BODY, 'model': 'claude', 'kind': 'Interview'} \
|
||||||
|
if data is None else data
|
||||||
|
return self.client.post(self.url(ep), json.dumps(data), content_type='application/json')
|
||||||
|
|
||||||
|
def test_requires_login_and_ownership(self):
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(self.post().status_code, 401)
|
||||||
|
self.assertEqual(self.client.delete(self.url()).status_code, 401)
|
||||||
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
|
self.client.force_login(bob)
|
||||||
|
self.assertEqual(self.post().status_code, 404)
|
||||||
|
self.assertEqual(self.client.delete(self.url()).status_code, 404)
|
||||||
|
self.assertFalse(Item.objects.exists())
|
||||||
|
|
||||||
|
def test_create(self):
|
||||||
|
r = self.post()
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
item = Item.objects.get(pk=r.json()['item_id'])
|
||||||
|
self.assertEqual(item.user, self.user)
|
||||||
|
self.assertEqual(item.kind, Source.BLOG)
|
||||||
|
self.assertEqual(item.title, 'Ein Artikel')
|
||||||
|
self.assertEqual(item.summary, 'Kurz.')
|
||||||
|
self.assertEqual(item.author, 'Anna Autorin')
|
||||||
|
self.assertEqual(item.image_url, 'https://p.example/e1.jpg')
|
||||||
|
self.assertEqual(item.url, 'https://p.example/e1.mp3')
|
||||||
|
self.assertTrue(item.content_full)
|
||||||
|
self.assertGreater(item.words, 3)
|
||||||
|
self.assertLess((timezone.now() - item.published).total_seconds(), 60)
|
||||||
|
art = EpisodeArticle.objects.get(episode=self.ep)
|
||||||
|
self.assertEqual((art.item, art.model, art.kind), (item, 'claude', 'Interview'))
|
||||||
|
self.assertEqual(item.episode_article, art)
|
||||||
|
|
||||||
|
def test_image_falls_back_to_feed_artwork(self):
|
||||||
|
self.ep.artwork_url = ''
|
||||||
|
self.ep.save()
|
||||||
|
item = Item.objects.get(pk=self.post().json()['item_id'])
|
||||||
|
self.assertEqual(item.image_url, 'https://p.example/feed.jpg')
|
||||||
|
|
||||||
|
def test_sanitizer_keeps_time_links_only(self):
|
||||||
|
item = Item.objects.get(pk=self.post().json()['item_id'])
|
||||||
|
self.assertNotIn('<script', item.content)
|
||||||
|
self.assertNotIn('alert(1)', item.content)
|
||||||
|
self.assertNotIn('onclick', item.content)
|
||||||
|
self.assertIn('href="#t=754"', item.content)
|
||||||
|
self.assertIn('href="https://example.org/x"', item.content)
|
||||||
|
evil = self.post({'title': 'x', 'body_html': '<a href="#t=1" onfocus="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')
|
||||||
|
|
@ -89,7 +89,7 @@ class PlayerSyncTests(TestCase):
|
||||||
self.assertEqual(d['seq'], 2) # the report, and device a coming online
|
self.assertEqual(d['seq'], 2) # the report, and device a coming online
|
||||||
self.assertEqual((d['kind'], d['playing'], d['position_ms'], d['speed']), ('podcast', True, 12345, 1.5))
|
self.assertEqual((d['kind'], d['playing'], d['position_ms'], d['speed']), ('podcast', True, 12345, 1.5))
|
||||||
self.assertEqual((d['device_id'], d['device_name']), ('a', 'A'))
|
self.assertEqual((d['device_id'], d['device_name']), ('a', 'A'))
|
||||||
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||||
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
|
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
|
||||||
'in_queue'})
|
'in_queue'})
|
||||||
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))
|
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))
|
||||||
|
|
|
||||||
45
podcasts/test_share.py
Normal file
45
podcasts/test_share.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import feedparser
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from .models import PodcastEpisode, PodcastFeed
|
||||||
|
from .views import FeedFetch, _apply_feed, _entry_link
|
||||||
|
|
||||||
|
|
||||||
|
def _feed(link):
|
||||||
|
item_link = f'<link>{link}</link>' if link is not None else ''
|
||||||
|
return feedparser.parse(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>99 ZU EINS</title>'
|
||||||
|
'<item><title>Folge 724</title><guid>g724</guid>' + item_link +
|
||||||
|
'<enclosure url="https://cdn.example/724.mp3" type="audio/mpeg" length="1"/></item>'
|
||||||
|
'</channel></rss>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeLinkTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/rss', title='99 ZU EINS')
|
||||||
|
|
||||||
|
def fetch(self, link):
|
||||||
|
_apply_feed(self.feed, FeedFetch(200, _feed(link)))
|
||||||
|
return PodcastEpisode.objects.get(feed=self.feed)
|
||||||
|
|
||||||
|
def test_entry_link_takes_only_web_addresses(self):
|
||||||
|
self.assertEqual(_entry_link({'link': ' https://x.fireside.fm/724 '}), 'https://x.fireside.fm/724')
|
||||||
|
self.assertEqual(_entry_link({'link': 'javascript:alert(1)'}), '')
|
||||||
|
self.assertEqual(_entry_link({}), '')
|
||||||
|
|
||||||
|
def test_a_new_episode_gets_the_items_link(self):
|
||||||
|
self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
|
||||||
|
|
||||||
|
def test_a_refresh_fills_the_link_of_an_episode_that_has_none(self):
|
||||||
|
self.assertEqual(self.fetch(None).link, '')
|
||||||
|
self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
|
||||||
|
|
||||||
|
def test_episode_detail_returns_the_link(self):
|
||||||
|
ep = self.fetch('https://99zueins.fireside.fm/724')
|
||||||
|
r = self.client.get(f'/podcasts/episodes/{ep.id}/')
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.json()['link'], 'https://99zueins.fireside.fm/724')
|
||||||
821
podcasts/test_transcribe.py
Normal file
821
podcasts/test_transcribe.py
Normal file
|
|
@ -0,0 +1,821 @@
|
||||||
|
"""The transcription pipeline (podcasts/transcribe.py) against a fake Whisper instance, the
|
||||||
|
endpoints around it and the notice in /feeds/notifications/."""
|
||||||
|
import email
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from datetime import timedelta
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import OperationalError
|
||||||
|
from django.test import TestCase, TransactionTestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from feeds.models import Item, Source
|
||||||
|
|
||||||
|
from . import transcribe, transcription, youtube
|
||||||
|
from .models import (
|
||||||
|
EpisodeArticle, EpisodeTranscript, PodcastEpisode, PodcastFeed, TranscriptionJob, TranscriptionSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
RATE = 16000
|
||||||
|
TOTAL = 120
|
||||||
|
# Where the long pauses are (start, length): the pieces should be cut in these. The short
|
||||||
|
# ones (0.3 s every 4 s) are only there to tempt.
|
||||||
|
LONG_PAUSES = [(36.5, 1.6), (83.0, 1.4)]
|
||||||
|
SEGMENTS = [{'start': 0.0, 'end': 2.0, 'text': ' Hallo '}, {'start': 2.0, 'end': 5.5, 'text': 'Welt.'}]
|
||||||
|
|
||||||
|
|
||||||
|
def make_audio():
|
||||||
|
"""~2 minutes of tones with pauses, as mp3 bytes (and the wav as a check of the length)."""
|
||||||
|
samples = []
|
||||||
|
t = 0.0
|
||||||
|
pauses = [(s, s + n) for s, n in LONG_PAUSES]
|
||||||
|
for i in range(RATE * TOTAL):
|
||||||
|
t = i / RATE
|
||||||
|
silent = any(a <= t < b for a, b in pauses) or (t % 4.0) > 3.7
|
||||||
|
value = 0 if silent else int(9000 * math.sin(2 * math.pi * (180 + 40 * ((i // 8000) % 5)) * t))
|
||||||
|
samples.append(value)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with wave.open(buf, 'wb') as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(RATE)
|
||||||
|
w.writeframes(struct.pack('<%dh' % len(samples), *samples))
|
||||||
|
import imageio_ffmpeg
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
src, dst = os.path.join(tmp, 'a.wav'), os.path.join(tmp, 'a.mp3')
|
||||||
|
open(src, 'wb').write(buf.getvalue())
|
||||||
|
subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), '-loglevel', 'error', '-i', src, '-b:a', '64k', dst], check=True)
|
||||||
|
return open(dst, 'rb').read()
|
||||||
|
|
||||||
|
|
||||||
|
_AUDIO = []
|
||||||
|
|
||||||
|
|
||||||
|
def audio():
|
||||||
|
if not _AUDIO:
|
||||||
|
_AUDIO.append(make_audio())
|
||||||
|
return _AUDIO[0]
|
||||||
|
|
||||||
|
|
||||||
|
def decoded_seconds(data, suffix):
|
||||||
|
"""How long the uploaded piece is, by decoding it."""
|
||||||
|
import imageio_ffmpeg
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, 'p' + suffix)
|
||||||
|
open(path, 'wb').write(data)
|
||||||
|
out = subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), '-loglevel', 'error', '-i', path, '-f', 's16le',
|
||||||
|
'-ac', '1', '-ar', str(RATE), '-'], stdout=subprocess.PIPE, check=True).stdout
|
||||||
|
return len(out) / 2 / RATE
|
||||||
|
|
||||||
|
|
||||||
|
class Fake:
|
||||||
|
"""A local HTTP server: GET /ep.mp3 is the test audio, POST /v1/audio/transcriptions a Whisper."""
|
||||||
|
|
||||||
|
def __init__(self, fail_first=0, fail_status=500, delay=0, on_call=None):
|
||||||
|
self.fail_first, self.fail_status, self.delay, self.on_call = fail_first, fail_status, delay, on_call
|
||||||
|
self.calls = [] # {'fields', 'filename', 'body', 'auth'}
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
fake = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == '/missing.mp3':
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
body = audio_holder.data or audio()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'audio/mpeg')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
raw = self.rfile.read(int(self.headers['Content-Length']))
|
||||||
|
msg = email.message_from_bytes(
|
||||||
|
b'Content-Type: ' + self.headers['Content-Type'].encode() + b'\r\n\r\n' + raw)
|
||||||
|
fields, filename, body = {}, '', b''
|
||||||
|
for part in msg.get_payload():
|
||||||
|
name = part.get_param('name', header='content-disposition')
|
||||||
|
if part.get_filename():
|
||||||
|
filename, body = part.get_filename(), part.get_payload(decode=True)
|
||||||
|
else:
|
||||||
|
fields[name] = part.get_payload(decode=True).decode()
|
||||||
|
with fake.lock:
|
||||||
|
fake.calls.append({'fields': fields, 'filename': filename, 'body': body,
|
||||||
|
'auth': self.headers.get('Authorization')})
|
||||||
|
n = len(fake.calls)
|
||||||
|
if fake.on_call:
|
||||||
|
fake.on_call(n)
|
||||||
|
if fake.delay:
|
||||||
|
time.sleep(fake.delay)
|
||||||
|
if n <= fake.fail_first or fake.fail_first < 0:
|
||||||
|
self.send_response(fake.fail_status)
|
||||||
|
self.send_header('Content-Length', '0')
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
out = json.dumps({'language': 'german', 'segments': SEGMENTS}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(out)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(out)
|
||||||
|
|
||||||
|
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||||
|
self.server.daemon_threads = True
|
||||||
|
self.port = self.server.server_address[1]
|
||||||
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def whisper(self):
|
||||||
|
return f'http://127.0.0.1:{self.port}/v1'
|
||||||
|
|
||||||
|
def url(self, path='/ep.mp3'):
|
||||||
|
return f'http://127.0.0.1:{self.port}{path}'
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def leftovers():
|
||||||
|
base = tempfile.gettempdir()
|
||||||
|
return {d for d in os.listdir(base) if d.startswith('diora-transcribe-')}
|
||||||
|
|
||||||
|
|
||||||
|
FAST = dict(CHUNK_SECONDS=40, CUT_WINDOW=8, MIN_TAIL=10, RETRY_PAUSES=(0, 0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(cond, timeout=30):
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
if cond():
|
||||||
|
return True
|
||||||
|
time.sleep(0.05)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineBase(TransactionTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
for k, v in FAST.items():
|
||||||
|
patcher = patch.object(transcribe, k, v)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||||
|
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='Der Podcast',
|
||||||
|
author='Anna Autorin')
|
||||||
|
self.fake = Fake()
|
||||||
|
self.addCleanup(self.fake.stop)
|
||||||
|
self.before = leftovers()
|
||||||
|
|
||||||
|
def job(self, audio_path='/ep.mp3', **kw):
|
||||||
|
n = PodcastEpisode.objects.count()
|
||||||
|
ep = PodcastEpisode.objects.create(feed=self.feed, guid=f'g{n}', title=f'Folge {n}',
|
||||||
|
audio_url=self.fake.url(audio_path), duration_seconds=TOTAL)
|
||||||
|
return TranscriptionJob.objects.create(episode=ep, state=TranscriptionJob.QUEUED, **kw)
|
||||||
|
|
||||||
|
def reload(self, job):
|
||||||
|
for _ in range(50): # the in-memory test database may be locked for a moment by the worker
|
||||||
|
try:
|
||||||
|
return TranscriptionJob.objects.get(pk=job.pk)
|
||||||
|
except OperationalError:
|
||||||
|
time.sleep(0.05)
|
||||||
|
return TranscriptionJob.objects.get(pk=job.pk)
|
||||||
|
|
||||||
|
def assertClean(self):
|
||||||
|
self.assertEqual(leftovers(), self.before)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanTests(PipelineBase):
|
||||||
|
def test_cuts_land_in_the_long_pauses(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
src, pcm = os.path.join(tmp, 'a.mp3'), os.path.join(tmp, 'a.pcm')
|
||||||
|
open(src, 'wb').write(audio())
|
||||||
|
transcribe._decode(src, pcm)
|
||||||
|
total = os.path.getsize(pcm) / 2 / RATE
|
||||||
|
pieces = transcribe.plan(total, transcribe.pauses_of(pcm))
|
||||||
|
self.assertAlmostEqual(total, TOTAL, delta=0.5)
|
||||||
|
self.assertEqual(len(pieces), 3)
|
||||||
|
cuts = [p[1] for p in pieces[:-1]]
|
||||||
|
for cut, (start, length) in zip(cuts, LONG_PAUSES):
|
||||||
|
self.assertTrue(start <= cut <= start + length, f'{cut} not in the pause {start}+{length}')
|
||||||
|
self.assertEqual(pieces[0][0], 0)
|
||||||
|
self.assertEqual(pieces[-1][1], total)
|
||||||
|
self.assertEqual([a[1] for a in pieces[:-1]], [b[0] for b in pieces[1:]])
|
||||||
|
|
||||||
|
def test_plan_without_pauses_cuts_at_the_target(self):
|
||||||
|
self.assertEqual(transcribe.plan(100, []), [(0.0, 40), (40, 80), (80, 100)])
|
||||||
|
self.assertEqual(transcribe.plan(30, []), [(0.0, 30)])
|
||||||
|
# a short tail is joined to the last piece
|
||||||
|
self.assertEqual(transcribe.plan(85, []), [(0.0, 40), (40, 85)])
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(WHISPER_URL='')
|
||||||
|
class RunTests(PipelineBase):
|
||||||
|
def configure(self, **kw):
|
||||||
|
conf = TranscriptionSettings(user=self.user, **kw)
|
||||||
|
conf.save()
|
||||||
|
return conf
|
||||||
|
|
||||||
|
def test_builtin_run_end_to_end(self):
|
||||||
|
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||||
|
self.configure(language='de', parallel=2)
|
||||||
|
job = self.job()
|
||||||
|
started = time.time()
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
took = time.time() - started
|
||||||
|
print(f'\n[pipeline run of the {TOTAL} s test audio took {took:.1f} s]')
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual((job.state, job.error), ('done', ''))
|
||||||
|
self.assertEqual((job.chunks_done, job.chunks_total), (3, 3))
|
||||||
|
self.assertIsNotNone(job.finished_at)
|
||||||
|
self.assertEqual(len(self.fake.calls), 3)
|
||||||
|
for c in self.fake.calls:
|
||||||
|
self.assertEqual(c['fields']['model'], 'whisper-1')
|
||||||
|
self.assertEqual(c['fields']['language'], 'de')
|
||||||
|
self.assertEqual(c['fields']['response_format'], 'verbose_json')
|
||||||
|
self.assertEqual(c['fields']['prompt'], 'Der Podcast. Folge 0. Anna Autorin')
|
||||||
|
self.assertTrue(c['filename'].endswith('.flac'))
|
||||||
|
self.assertIsNone(c['auth']) # the token only goes to custom instances
|
||||||
|
# the uploaded pieces are the planned ones: the same total, cut at the pauses
|
||||||
|
lengths = sorted(decoded_seconds(c['body'], '.flac') for c in self.fake.calls)
|
||||||
|
self.assertAlmostEqual(sum(lengths), TOTAL, delta=0.5)
|
||||||
|
data = job.data
|
||||||
|
self.assertEqual(data['source'], {'type': 'whisper', 'model': 'whisper-1', 'language': 'de'})
|
||||||
|
segs = data['segments']
|
||||||
|
starts = [s['start'] for s in segs]
|
||||||
|
self.assertEqual(starts, sorted(starts))
|
||||||
|
self.assertTrue(all(s['speaker'] == '' for s in segs))
|
||||||
|
# every piece answered "Hallo Welt." at 0 to 5.5 s: its start is where the piece starts,
|
||||||
|
# which lies on a cut in a long pause, and the first at zero
|
||||||
|
self.assertEqual(starts[0], 0)
|
||||||
|
cuts = starts[1:]
|
||||||
|
self.assertEqual(len(cuts), 2)
|
||||||
|
for cut, (p, n) in zip(cuts, LONG_PAUSES):
|
||||||
|
self.assertTrue(p <= cut <= p + n, f'{cut} not in {p}+{n}')
|
||||||
|
self.assertEqual(segs[0]['text'], 'Hallo Welt.')
|
||||||
|
self.assertAlmostEqual(segs[0]['end'], 5.5)
|
||||||
|
self.assertClean()
|
||||||
|
|
||||||
|
def test_language_empty_takes_what_whisper_detects_and_custom_gets_opus_and_token(self):
|
||||||
|
conf = self.configure(engine='custom', url=self.fake.whisper, model='large-v3', language='')
|
||||||
|
conf.token_encrypted = transcription.logins.encrypt('tok')
|
||||||
|
conf.save()
|
||||||
|
job = self.job()
|
||||||
|
with patch.object(transcribe.chapters, 'assert_safe_url'), \
|
||||||
|
patch.object(transcribe.chapters, '_assert_peer_is_safe'):
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual((job.state, job.error), ('done', ''))
|
||||||
|
self.assertEqual(job.data['source'], {'type': 'whisper', 'model': 'large-v3', 'language': 'german'})
|
||||||
|
for c in self.fake.calls:
|
||||||
|
self.assertTrue(c['filename'].endswith('.ogg'))
|
||||||
|
self.assertNotIn('language', c['fields'])
|
||||||
|
self.assertEqual(c['auth'], 'Bearer tok')
|
||||||
|
self.assertLess(len(c['body']), decoded_seconds(c['body'], '.ogg') * 6500) # 32 kbit/s: about 4 kB a second, ogg and VBR on top
|
||||||
|
self.assertAlmostEqual(sum(decoded_seconds(c['body'], '.ogg') for c in self.fake.calls), TOTAL, delta=0.5)
|
||||||
|
self.assertClean()
|
||||||
|
|
||||||
|
def test_custom_instance_on_a_private_address_is_refused(self):
|
||||||
|
self.configure(engine='custom', url=self.fake.whisper)
|
||||||
|
job = self.job()
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual(job.state, 'error')
|
||||||
|
self.assertIn('Whisper', job.error)
|
||||||
|
self.assertEqual(self.fake.calls, [])
|
||||||
|
|
||||||
|
def test_unreachable_builtin_waits_and_tries_again(self):
|
||||||
|
import socket
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(('127.0.0.1', 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close() # nothing listens there now
|
||||||
|
with override_settings(WHISPER_URL=f'http://127.0.0.1:{port}/v1'):
|
||||||
|
self.configure()
|
||||||
|
job = self.job()
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual(job.state, 'queued')
|
||||||
|
self.assertIn('nicht erreichbar', job.error)
|
||||||
|
self.assertGreater(transcribe._not_before[job.pk], time.time())
|
||||||
|
self.assertEqual(self.fake.calls, []) # nothing downloaded while the instance is away
|
||||||
|
self.assertEqual(transcribe._next_job(), transcribe.WAIT)
|
||||||
|
transcribe._not_before[job.pk] = 0 # due again: the instance is back
|
||||||
|
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual((job.state, job.error), ('done', ''))
|
||||||
|
transcribe._not_before.clear()
|
||||||
|
|
||||||
|
def test_waiting_gives_up_after_two_days(self):
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
with override_settings(WHISPER_URL='http://127.0.0.1:9/v1'):
|
||||||
|
self.configure()
|
||||||
|
job = self.job()
|
||||||
|
TranscriptionJob.objects.filter(pk=job.pk).update(created_at=timezone.now() - timedelta(days=3))
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual(job.state, 'error')
|
||||||
|
self.assertIn('zwei Tage', job.error)
|
||||||
|
|
||||||
|
@override_settings(WHISPER_URL='')
|
||||||
|
def test_retries_then_succeeds(self):
|
||||||
|
self.fake.fail_first = 2
|
||||||
|
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||||
|
self.configure(parallel=1)
|
||||||
|
job = self.job()
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
self.assertEqual(self.reload(job).state, 'done')
|
||||||
|
self.assertEqual(len(self.fake.calls), 3 + 2)
|
||||||
|
|
||||||
|
def test_gives_up_after_three_retries(self):
|
||||||
|
self.fake.fail_first = -1
|
||||||
|
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||||
|
self.configure(parallel=1)
|
||||||
|
job = self.job()
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual(job.state, 'error')
|
||||||
|
self.assertEqual(job.error, 'Whisper: HTTP 500')
|
||||||
|
self.assertEqual(len(self.fake.calls), 4) # the first try and three more
|
||||||
|
self.assertIsNone(job.data)
|
||||||
|
self.assertClean()
|
||||||
|
|
||||||
|
def test_a_refusal_is_not_retried(self):
|
||||||
|
self.fake.fail_first, self.fake.fail_status = -1, 401
|
||||||
|
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||||
|
self.configure(parallel=1)
|
||||||
|
job = self.job()
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual((job.state, job.error), ('error', 'Whisper: HTTP 401'))
|
||||||
|
self.assertEqual(len(self.fake.calls), 1)
|
||||||
|
|
||||||
|
def test_download_failure(self):
|
||||||
|
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||||
|
job = self.job('/missing.mp3')
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual((job.state, job.error), ('error', 'Download: HTTP 404'))
|
||||||
|
self.assertClean()
|
||||||
|
|
||||||
|
def test_not_audio(self):
|
||||||
|
with override_settings(WHISPER_URL=self.fake.whisper), patch.object(audio_holder, 'data', b'<html>no</html>'):
|
||||||
|
job = self.job()
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
job = self.reload(job)
|
||||||
|
self.assertEqual(job.state, 'error')
|
||||||
|
self.assertTrue(job.error.startswith('ffmpeg:'), job.error)
|
||||||
|
self.assertClean()
|
||||||
|
|
||||||
|
def test_no_builtin_instance(self):
|
||||||
|
job = self.job()
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
self.assertEqual(self.reload(job).error, 'Keine eingebaute Instanz eingerichtet')
|
||||||
|
|
||||||
|
def test_cancel_while_running(self):
|
||||||
|
job = self.job()
|
||||||
|
|
||||||
|
def cancel(n):
|
||||||
|
if n == 1:
|
||||||
|
TranscriptionJob.objects.filter(pk=job.pk).delete()
|
||||||
|
from django.db import connection
|
||||||
|
connection.close()
|
||||||
|
self.fake.on_call, self.fake.delay = cancel, 0.2
|
||||||
|
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||||
|
self.configure(parallel=1)
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
self.assertFalse(TranscriptionJob.objects.filter(pk=job.pk).exists())
|
||||||
|
self.assertLessEqual(len(self.fake.calls), 1) # no further piece was sent
|
||||||
|
self.assertClean()
|
||||||
|
|
||||||
|
def test_cancelled_while_waiting_is_not_started(self):
|
||||||
|
job = self.job()
|
||||||
|
job.delete()
|
||||||
|
transcribe.run(job.pk)
|
||||||
|
self.assertEqual(self.fake.calls, [])
|
||||||
|
|
||||||
|
|
||||||
|
class _Holder:
|
||||||
|
"""Lets a test swap the audio the fake server sends."""
|
||||||
|
data = None
|
||||||
|
|
||||||
|
|
||||||
|
audio_holder = _Holder()
|
||||||
|
|
||||||
|
|
||||||
|
class QueueTests(PipelineBase):
|
||||||
|
"""The runner and its queue. The work itself (_transcribe) is replaced by a stand-in the test
|
||||||
|
holds back, so only the runner thread writes to the database while the test looks at it."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gate = threading.Event()
|
||||||
|
self.started = []
|
||||||
|
patcher = patch.object(transcribe, '_resumed', True)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
work = patch.object(transcribe, '_transcribe', side_effect=self.work)
|
||||||
|
work.start()
|
||||||
|
self.addCleanup(work.stop)
|
||||||
|
self.addCleanup(self.gate.set)
|
||||||
|
self.addCleanup(lambda: wait_for(lambda: transcribe._runner is None))
|
||||||
|
|
||||||
|
def work(self, job_id):
|
||||||
|
self.started.append(job_id)
|
||||||
|
self.gate.wait(30)
|
||||||
|
return {'segments': [{'start': 0, 'end': 1, 'speaker': '', 'text': 'x'}], 'source': {'type': 'whisper'}}
|
||||||
|
|
||||||
|
def test_one_job_at_a_time_oldest_first(self):
|
||||||
|
first, second, third = self.job(), self.job(), self.job()
|
||||||
|
transcribe.kick()
|
||||||
|
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
|
||||||
|
transcribe.kick() # a second runner doesn't start
|
||||||
|
time.sleep(0.3)
|
||||||
|
self.assertEqual(self.started, [first.pk])
|
||||||
|
self.assertEqual([self.reload(j).state for j in (first, second, third)], ['downloading', 'queued', 'queued'])
|
||||||
|
self.gate.set()
|
||||||
|
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||||
|
self.assertEqual(self.started, [first.pk, second.pk, third.pk])
|
||||||
|
jobs = [self.reload(j) for j in (first, second, third)]
|
||||||
|
self.assertEqual([j.state for j in jobs], ['done'] * 3)
|
||||||
|
self.assertEqual(sorted(j.finished_at for j in jobs), [j.finished_at for j in jobs])
|
||||||
|
|
||||||
|
def test_a_job_queued_while_the_runner_works_is_taken_too(self):
|
||||||
|
first = self.job()
|
||||||
|
transcribe.kick()
|
||||||
|
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
|
||||||
|
second = self.job()
|
||||||
|
transcribe.kick()
|
||||||
|
self.gate.set()
|
||||||
|
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||||
|
self.assertEqual(self.started, [first.pk, second.pk])
|
||||||
|
|
||||||
|
def test_a_cancelled_waiting_job_is_skipped(self):
|
||||||
|
first, second, third = self.job(), self.job(), self.job()
|
||||||
|
transcribe.kick()
|
||||||
|
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
|
||||||
|
second.delete()
|
||||||
|
self.gate.set()
|
||||||
|
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||||
|
self.assertEqual(self.started, [first.pk, third.pk])
|
||||||
|
|
||||||
|
def test_a_crashing_job_does_not_stop_the_queue(self):
|
||||||
|
first, second = self.job(), self.job()
|
||||||
|
self.gate.set()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def work(job_id):
|
||||||
|
calls.append(job_id)
|
||||||
|
if job_id == first.pk:
|
||||||
|
raise RuntimeError('boom')
|
||||||
|
return self.work(job_id)
|
||||||
|
with patch.object(transcribe, '_transcribe', side_effect=work):
|
||||||
|
transcribe.kick()
|
||||||
|
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||||
|
self.assertEqual(calls, [first.pk, second.pk])
|
||||||
|
self.assertEqual((self.reload(first).state, self.reload(first).error), ('error', 'RuntimeError: boom'))
|
||||||
|
self.assertEqual(self.reload(second).state, 'done')
|
||||||
|
|
||||||
|
def test_stuck_jobs_start_again_after_a_restart(self):
|
||||||
|
self.gate.set()
|
||||||
|
job = self.job()
|
||||||
|
TranscriptionJob.objects.filter(pk=job.pk).update(state='transcribing', chunks_done=2, chunks_total=3)
|
||||||
|
with patch.object(transcribe, '_resumed', False):
|
||||||
|
transcribe.kick()
|
||||||
|
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||||
|
self.assertEqual(self.started, [job.pk])
|
||||||
|
self.assertEqual(self.reload(job).state, 'done')
|
||||||
|
|
||||||
|
def test_nothing_queued_starts_no_runner(self):
|
||||||
|
transcribe.kick()
|
||||||
|
self.assertIsNone(transcribe._runner)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- endpoints
|
||||||
|
|
||||||
|
class EndpointBase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='Der Podcast')
|
||||||
|
self.ep = self.episode('e1')
|
||||||
|
kick = patch.object(transcribe, 'kick')
|
||||||
|
self.kick = kick.start()
|
||||||
|
self.addCleanup(kick.stop)
|
||||||
|
|
||||||
|
def episode(self, guid, **kw):
|
||||||
|
kw.setdefault('audio_url', f'https://example.org/{guid}.mp3')
|
||||||
|
return PodcastEpisode.objects.create(feed=self.feed, guid=guid, title=f'Folge {guid}', duration_seconds=600, **kw)
|
||||||
|
|
||||||
|
def post(self, url, body=None, **extra):
|
||||||
|
return self.client.post(url, json.dumps(body or {}), content_type='application/json', **extra)
|
||||||
|
|
||||||
|
def done_job(self, ep, **kw):
|
||||||
|
kw.setdefault('data', {'segments': [{'start': 0, 'end': 4, 'speaker': '', 'text': 'Hallo.'}],
|
||||||
|
'source': {'type': 'whisper', 'model': 'whisper-1', 'language': 'de'}})
|
||||||
|
return TranscriptionJob.objects.create(episode=ep, state='done', finished_at=timezone.now(), **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeEndpointTests(EndpointBase):
|
||||||
|
def url(self, ep=None):
|
||||||
|
return f'/podcasts/episodes/{(ep or self.ep).id}/transcribe/'
|
||||||
|
|
||||||
|
def test_requires_login_and_ownership(self):
|
||||||
|
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
|
self.client.force_login(other)
|
||||||
|
self.assertEqual(self.client.get(self.url()).status_code, 404)
|
||||||
|
self.assertEqual(self.post(self.url()).status_code, 404)
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(self.client.get(self.url()).status_code, 401)
|
||||||
|
|
||||||
|
def test_none_before_a_job(self):
|
||||||
|
self.assertEqual(self.client.get(self.url()).json(), {
|
||||||
|
'state': 'none', 'chunks_done': 0, 'chunks_total': 0, 'error': '', 'want_article': False,
|
||||||
|
'started_at': None, 'finished_at': None})
|
||||||
|
|
||||||
|
@patch('podcasts.transcription._too_large', return_value=False)
|
||||||
|
def test_post_queues_a_job_and_starts_the_runner(self, _):
|
||||||
|
r = self.post(self.url())
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual((r.json()['state'], r.json()['want_article']), ('queued', False))
|
||||||
|
self.assertEqual(TranscriptionJob.objects.get(episode=self.ep).state, 'queued')
|
||||||
|
self.kick.assert_called()
|
||||||
|
|
||||||
|
@patch('podcasts.transcription._too_large', return_value=False)
|
||||||
|
def test_post_again_only_raises_the_article_wish(self, _):
|
||||||
|
self.post(self.url())
|
||||||
|
first = TranscriptionJob.objects.get(episode=self.ep).pk
|
||||||
|
r = self.post(self.url(), {'article': True})
|
||||||
|
self.assertTrue(r.json()['want_article'])
|
||||||
|
r = self.post(self.url(), {'article': False}) # never lowers it
|
||||||
|
self.assertTrue(r.json()['want_article'])
|
||||||
|
self.assertEqual(TranscriptionJob.objects.filter(episode=self.ep).count(), 1)
|
||||||
|
self.assertEqual(TranscriptionJob.objects.get(episode=self.ep).pk, first)
|
||||||
|
|
||||||
|
@patch('podcasts.transcription._too_large', return_value=False)
|
||||||
|
def test_post_with_article_true_sets_the_wish(self, _):
|
||||||
|
self.assertTrue(self.post(self.url(), {'article': True}).json()['want_article'])
|
||||||
|
|
||||||
|
def test_youtube_is_refused(self):
|
||||||
|
ep = self.episode('yt', audio_url=youtube.WATCH + 'abc123')
|
||||||
|
r = self.post(self.url(ep))
|
||||||
|
self.assertEqual((r.status_code, r.json()), (400, {'error': 'youtube'}))
|
||||||
|
self.assertFalse(TranscriptionJob.objects.exists())
|
||||||
|
|
||||||
|
@patch('podcasts.transcription._too_large', return_value=True)
|
||||||
|
def test_too_large_is_refused(self, _):
|
||||||
|
r = self.post(self.url())
|
||||||
|
self.assertEqual((r.status_code, r.json()), (400, {'error': 'too large'}))
|
||||||
|
self.assertFalse(TranscriptionJob.objects.exists())
|
||||||
|
|
||||||
|
def test_the_size_check_reads_the_content_length(self):
|
||||||
|
from . import silence
|
||||||
|
resp = type('R', (), {'headers': {'Content-Length': str(silence.MAX_BYTES + 1)}})()
|
||||||
|
with patch('podcasts.transcription.requests.head', return_value=resp):
|
||||||
|
self.assertTrue(transcription._too_large('https://example.org/x.mp3'))
|
||||||
|
resp.headers = {'Content-Length': '1000'}
|
||||||
|
with patch('podcasts.transcription.requests.head', return_value=resp):
|
||||||
|
self.assertFalse(transcription._too_large('https://example.org/x.mp3'))
|
||||||
|
with patch('podcasts.transcription.requests.head', side_effect=transcription.requests.ConnectionError):
|
||||||
|
self.assertFalse(transcription._too_large('https://example.org/x.mp3'))
|
||||||
|
|
||||||
|
def test_a_whisper_transcript_is_done_already(self):
|
||||||
|
self.done_job(self.ep)
|
||||||
|
r = self.post(self.url(), {'article': True})
|
||||||
|
self.assertEqual((r.json()['state'], r.json()['want_article']), ('done', True))
|
||||||
|
self.kick.assert_not_called()
|
||||||
|
self.assertTrue(TranscriptionJob.objects.get(episode=self.ep).want_article)
|
||||||
|
|
||||||
|
def test_a_feed_transcript_is_done_already(self):
|
||||||
|
self.ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
|
||||||
|
self.ep.save()
|
||||||
|
r = self.post(self.url(), {'article': True})
|
||||||
|
self.assertEqual((r.json()['state'], r.json()['want_article']), ('done', True))
|
||||||
|
job = TranscriptionJob.objects.get(episode=self.ep)
|
||||||
|
self.assertIsNone(job.data)
|
||||||
|
self.kick.assert_not_called()
|
||||||
|
|
||||||
|
@patch('podcasts.transcription._too_large', return_value=False)
|
||||||
|
def test_after_an_error_post_starts_again_at_the_back(self, _):
|
||||||
|
old = TranscriptionJob.objects.create(episode=self.ep, state='error', error='Whisper: HTTP 500')
|
||||||
|
r = self.post(self.url(), {'article': True})
|
||||||
|
self.assertEqual((r.json()['state'], r.json()['error']), ('queued', ''))
|
||||||
|
new = TranscriptionJob.objects.get(episode=self.ep)
|
||||||
|
self.assertGreater(new.pk, old.pk)
|
||||||
|
|
||||||
|
@patch('podcasts.transcription._too_large', return_value=False)
|
||||||
|
def test_delete_cancels_queued_and_running_but_not_a_finished_one(self, _):
|
||||||
|
self.post(self.url())
|
||||||
|
self.assertEqual(self.client.delete(self.url()).json()['state'], 'none')
|
||||||
|
self.assertFalse(TranscriptionJob.objects.exists())
|
||||||
|
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', chunks_total=5, chunks_done=2)
|
||||||
|
self.assertEqual(self.client.delete(self.url()).json()['state'], 'none')
|
||||||
|
self.assertFalse(TranscriptionJob.objects.exists())
|
||||||
|
self.done_job(self.ep)
|
||||||
|
self.assertEqual(self.client.delete(self.url()).json()['state'], 'done')
|
||||||
|
self.assertTrue(TranscriptionJob.objects.exists())
|
||||||
|
|
||||||
|
def test_get_shows_progress(self):
|
||||||
|
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', chunks_total=38, chunks_done=7,
|
||||||
|
started_at=timezone.now())
|
||||||
|
d = self.client.get(self.url()).json()
|
||||||
|
self.assertEqual((d['state'], d['chunks_done'], d['chunks_total']), ('transcribing', 7, 38))
|
||||||
|
self.assertIsNotNone(d['started_at'])
|
||||||
|
self.kick.assert_called() # a restart's first request starts the queue again
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptTests(EndpointBase):
|
||||||
|
def transcript(self, ep=None):
|
||||||
|
return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/transcript/')
|
||||||
|
|
||||||
|
def detail(self, ep=None):
|
||||||
|
return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/').json()
|
||||||
|
|
||||||
|
def test_404_as_before_without_any(self):
|
||||||
|
self.assertEqual((self.transcript().status_code, self.transcript().json()), (404, {'error': 'no transcript'}))
|
||||||
|
d = self.detail()
|
||||||
|
self.assertEqual((d['has_transcript'], d['transcript_origin'], d['transcription'], d['article_item_id']),
|
||||||
|
(False, None, None, None))
|
||||||
|
|
||||||
|
def test_falls_back_to_the_whisper_transcript(self):
|
||||||
|
job = self.done_job(self.ep)
|
||||||
|
r = self.transcript()
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.json(), job.data)
|
||||||
|
d = self.detail()
|
||||||
|
self.assertEqual((d['has_transcript'], d['transcript_origin']), (True, 'whisper'))
|
||||||
|
self.assertEqual(d['transcription']['state'], 'done')
|
||||||
|
|
||||||
|
def test_a_running_job_has_no_transcript_yet(self):
|
||||||
|
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', data=None)
|
||||||
|
self.assertEqual(self.transcript().status_code, 404)
|
||||||
|
d = self.detail()
|
||||||
|
self.assertEqual((d['has_transcript'], d['transcript_origin'], d['transcription']['state']),
|
||||||
|
(False, None, 'transcribing'))
|
||||||
|
|
||||||
|
def feed_transcript(self, error=''):
|
||||||
|
ep = self.ep
|
||||||
|
ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
|
||||||
|
ep.save()
|
||||||
|
EpisodeTranscript.objects.create(
|
||||||
|
episode=ep, sources=['https://example.org/t.vtt'], error=error,
|
||||||
|
data=None if error else {'segments': [{'start': 0, 'end': 1, 'speaker': 'A', 'text': 'Aus dem Feed'}],
|
||||||
|
'source': {'url': 'https://example.org/t.vtt'}})
|
||||||
|
|
||||||
|
def test_the_feeds_transcript_wins(self):
|
||||||
|
self.done_job(self.ep)
|
||||||
|
self.feed_transcript()
|
||||||
|
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Aus dem Feed')
|
||||||
|
self.assertEqual(self.detail()['transcript_origin'], 'feed')
|
||||||
|
|
||||||
|
def test_an_unusable_feed_transcript_gives_way(self):
|
||||||
|
self.done_job(self.ep)
|
||||||
|
self.feed_transcript(error='invalid')
|
||||||
|
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Hallo.')
|
||||||
|
self.assertEqual(self.detail()['transcript_origin'], 'whisper')
|
||||||
|
|
||||||
|
def test_a_feed_transcript_that_cannot_be_loaded_now_gives_way_too(self):
|
||||||
|
self.done_job(self.ep)
|
||||||
|
self.ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
|
||||||
|
self.ep.save()
|
||||||
|
from . import chapters
|
||||||
|
with patch('podcasts.views.transcripts_mod.load', side_effect=chapters.FetchUnavailable('x')):
|
||||||
|
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Hallo.')
|
||||||
|
|
||||||
|
def test_article_item_id(self):
|
||||||
|
source = Source.objects.create(user=self.user, url='diora://podcast-artikel', title='Podcast-Artikel',
|
||||||
|
kind='blog')
|
||||||
|
item = Item.objects.create(source=source, user=self.user, kind='blog', guid_hash='x', title='A',
|
||||||
|
published=timezone.now())
|
||||||
|
EpisodeArticle.objects.create(episode=self.ep, item=item)
|
||||||
|
self.assertEqual(self.detail()['article_item_id'], item.id)
|
||||||
|
|
||||||
|
|
||||||
|
class WishTests(EndpointBase):
|
||||||
|
PENDING = '/podcasts/transcription/pending/'
|
||||||
|
|
||||||
|
def claim(self, ep=None, method='post', **kw):
|
||||||
|
return getattr(self.client, method)(
|
||||||
|
f'/podcasts/episodes/{(ep or self.ep).id}/article/claim/', json.dumps(kw.pop('body', {})),
|
||||||
|
content_type='application/json', **kw)
|
||||||
|
|
||||||
|
def test_pending_lists_open_wishes_only(self):
|
||||||
|
wished = self.done_job(self.ep, want_article=True)
|
||||||
|
e2, e3, e4, e5 = (self.episode(g) for g in ('e2', 'e3', 'e4', 'e5'))
|
||||||
|
self.done_job(e2) # no wish
|
||||||
|
TranscriptionJob.objects.create(episode=e3, state='transcribing', want_article=True) # not done
|
||||||
|
self.done_job(e4, want_article=True, claimed_by='tablet', claimed_until=timezone.now() + timedelta(minutes=5))
|
||||||
|
job5 = self.done_job(e5, want_article=True)
|
||||||
|
source = Source.objects.create(user=self.user, url='diora://podcast-artikel', title='P', kind='blog')
|
||||||
|
item = Item.objects.create(source=source, user=self.user, kind='blog', guid_hash='x', title='A',
|
||||||
|
published=timezone.now())
|
||||||
|
EpisodeArticle.objects.create(episode=e5, item=item) # written already
|
||||||
|
r = self.client.get(self.PENDING).json()
|
||||||
|
self.assertEqual(r, [{'episode_id': self.ep.id, 'title': 'Folge e1', 'podcast': 'Der Podcast',
|
||||||
|
'duration_seconds': 600}])
|
||||||
|
self.assertTrue(wished.want_article and job5.want_article)
|
||||||
|
|
||||||
|
def test_pending_is_per_user_and_needs_login(self):
|
||||||
|
self.done_job(self.ep, want_article=True)
|
||||||
|
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
|
self.client.force_login(other)
|
||||||
|
self.assertEqual(self.client.get(self.PENDING).json(), [])
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(self.client.get(self.PENDING).status_code, 401)
|
||||||
|
|
||||||
|
def test_an_expired_claim_is_open_again(self):
|
||||||
|
self.done_job(self.ep, want_article=True, claimed_by='tablet',
|
||||||
|
claimed_until=timezone.now() - timedelta(seconds=1))
|
||||||
|
self.assertEqual(len(self.client.get(self.PENDING).json()), 1)
|
||||||
|
|
||||||
|
def test_claim_blocks_the_other_device_until_it_expires(self):
|
||||||
|
job = self.done_job(self.ep, want_article=True)
|
||||||
|
r = self.claim(body={'device': 'phone'})
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.json()['claimed_by'], 'phone')
|
||||||
|
job.refresh_from_db()
|
||||||
|
left = job.claimed_until - timezone.now()
|
||||||
|
self.assertTrue(timedelta(minutes=14) < left <= timedelta(minutes=15))
|
||||||
|
self.assertEqual(self.client.get(self.PENDING).json(), [])
|
||||||
|
r = self.claim(body={'device': 'desktop'})
|
||||||
|
self.assertEqual(r.status_code, 409)
|
||||||
|
self.assertEqual(r.json()['error'], 'claimed')
|
||||||
|
self.assertEqual(self.claim(body={'device': 'phone'}).status_code, 200) # the holder renews
|
||||||
|
TranscriptionJob.objects.filter(pk=job.pk).update(claimed_until=timezone.now() - timedelta(seconds=1))
|
||||||
|
self.assertEqual(self.claim(body={'device': 'desktop'}).status_code, 200)
|
||||||
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.claimed_by, 'desktop')
|
||||||
|
|
||||||
|
def test_the_device_may_come_in_a_header(self):
|
||||||
|
self.done_job(self.ep, want_article=True)
|
||||||
|
self.assertEqual(self.claim(HTTP_X_DIORA_DEVICE='phone').status_code, 200)
|
||||||
|
self.assertEqual(self.claim(HTTP_X_DIORA_DEVICE='desktop').status_code, 409)
|
||||||
|
|
||||||
|
def test_a_device_name_is_required(self):
|
||||||
|
self.done_job(self.ep, want_article=True)
|
||||||
|
self.assertEqual(self.claim().status_code, 400)
|
||||||
|
|
||||||
|
def test_release(self):
|
||||||
|
self.done_job(self.ep, want_article=True)
|
||||||
|
self.claim(body={'device': 'phone'})
|
||||||
|
self.assertEqual(self.claim(method='delete', body={'device': 'desktop'}).status_code, 409)
|
||||||
|
self.assertEqual(self.claim(method='delete', body={'device': 'phone'}).status_code, 200)
|
||||||
|
self.assertEqual(len(self.client.get(self.PENDING).json()), 1)
|
||||||
|
|
||||||
|
def test_no_wish_no_claim(self):
|
||||||
|
self.done_job(self.ep) # done, but no article wanted
|
||||||
|
self.assertEqual(self.claim(body={'device': 'phone'}).status_code, 404)
|
||||||
|
self.assertEqual(self.claim(self.episode('e9'), body={'device': 'phone'}).status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTests(EndpointBase):
|
||||||
|
def get(self, **q):
|
||||||
|
return self.client.get('/feeds/notifications/', q).json()
|
||||||
|
|
||||||
|
def test_first_time_only_the_cursor(self):
|
||||||
|
j = self.done_job(self.ep, want_article=True)
|
||||||
|
d = self.get()
|
||||||
|
self.assertEqual((d['transcripts'], d['t_last'], d['items']), ([], j.id, []))
|
||||||
|
self.assertEqual(d['last'], 0)
|
||||||
|
|
||||||
|
def test_jobs_done_after_the_cursor(self):
|
||||||
|
e2, e3 = self.episode('e2'), self.episode('e3')
|
||||||
|
a = self.done_job(self.ep)
|
||||||
|
b = self.done_job(e2, want_article=True)
|
||||||
|
TranscriptionJob.objects.create(episode=e3, state='transcribing') # not done: no notice
|
||||||
|
d = self.get(t_after=a.id)
|
||||||
|
self.assertEqual(d['transcripts'], [{'job_id': b.id, 'episode_id': e2.id, 'title': 'Folge e2',
|
||||||
|
'podcast': 'Der Podcast', 'want_article': True}])
|
||||||
|
self.assertEqual(d['t_last'], b.id)
|
||||||
|
self.assertEqual(self.get(t_after=b.id)['transcripts'], [])
|
||||||
|
self.assertEqual([t['job_id'] for t in self.get(t_after=0)['transcripts']], [a.id, b.id])
|
||||||
|
|
||||||
|
def test_only_the_users_own_and_not_feed_only_jobs(self):
|
||||||
|
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
|
feed = PodcastFeed.objects.create(user=other, rss_url='https://example.org/o', title='O')
|
||||||
|
ep = PodcastEpisode.objects.create(feed=feed, guid='o', title='O', audio_url='https://example.org/o.mp3')
|
||||||
|
self.done_job(ep)
|
||||||
|
self.done_job(self.episode('e2'), data=None) # only the wish for an article from a feed transcript
|
||||||
|
d = self.get(t_after=0)
|
||||||
|
self.assertEqual((d['transcripts'], d['t_last']), ([], 0))
|
||||||
|
|
||||||
|
def test_items_still_work_beside_it(self):
|
||||||
|
d = self.get(after=0)
|
||||||
|
self.assertEqual((d['items'], d['last'], d['transcripts'], d['t_last']), ([], 0, [], 0))
|
||||||
153
podcasts/test_transcription.py
Normal file
153
podcasts/test_transcription.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import wave
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from feeds import logins
|
||||||
|
|
||||||
|
from . import chapters, transcription
|
||||||
|
from .models import TranscriptionSettings
|
||||||
|
|
||||||
|
SETTINGS = '/podcasts/transcription/settings/'
|
||||||
|
TEST = '/podcasts/transcription/test/'
|
||||||
|
|
||||||
|
|
||||||
|
def fake_response(status=200):
|
||||||
|
r = MagicMock()
|
||||||
|
r.status_code = status
|
||||||
|
r.headers = {}
|
||||||
|
r.raw = None # nothing to introspect for the peer check
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def post(self, data, url=SETTINGS):
|
||||||
|
return self.client.post(url, json.dumps(data), content_type='application/json')
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(self.client.get(SETTINGS).status_code, 401)
|
||||||
|
self.assertEqual(self.post({}, TEST).status_code, 401)
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
self.assertEqual(self.client.get(SETTINGS).json(), {
|
||||||
|
'engine': 'builtin', 'url': '', 'model': '', 'language': '', 'parallel': 3,
|
||||||
|
'has_token': False, 'builtin_available': False})
|
||||||
|
self.assertFalse(TranscriptionSettings.objects.exists())
|
||||||
|
|
||||||
|
@override_settings(WHISPER_URL='http://whisper:8000/v1')
|
||||||
|
def test_builtin_available_follows_the_environment_setting(self):
|
||||||
|
self.assertTrue(self.client.get(SETTINGS).json()['builtin_available'])
|
||||||
|
|
||||||
|
def test_roundtrip(self):
|
||||||
|
r = self.post({'engine': 'custom', 'url': 'https://whisper.example.org/v1/', 'model': 'large-v3',
|
||||||
|
'language': 'de', 'parallel': 5, 'token': 'sk-secret'})
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
want = {'engine': 'custom', 'url': 'https://whisper.example.org/v1', 'model': 'large-v3',
|
||||||
|
'language': 'de', 'parallel': 5, 'has_token': True, 'builtin_available': False}
|
||||||
|
self.assertEqual(r.json(), want)
|
||||||
|
self.assertEqual(self.client.get(SETTINGS).json(), want)
|
||||||
|
|
||||||
|
def test_token_is_write_only_and_stored_encrypted(self):
|
||||||
|
self.post({'engine': 'custom', 'url': 'https://w.example.org/v1', 'token': 'sk-secret'})
|
||||||
|
conf = TranscriptionSettings.objects.get(user=self.user)
|
||||||
|
self.assertTrue(conf.token_encrypted)
|
||||||
|
self.assertNotIn('sk-secret', conf.token_encrypted)
|
||||||
|
self.assertEqual(transcription.token_of(conf), 'sk-secret')
|
||||||
|
for body in (self.client.get(SETTINGS).content, self.post({'model': 'm'}).content):
|
||||||
|
self.assertNotIn(b'sk-secret', body)
|
||||||
|
self.assertNotIn(conf.token_encrypted.encode(), body)
|
||||||
|
# Not sending a token keeps it; an empty one removes it.
|
||||||
|
conf.refresh_from_db()
|
||||||
|
self.assertTrue(conf.token_encrypted)
|
||||||
|
self.assertFalse(self.post({'token': ''}).json()['has_token'])
|
||||||
|
conf.refresh_from_db()
|
||||||
|
self.assertEqual(conf.token_encrypted, '')
|
||||||
|
|
||||||
|
def test_validation(self):
|
||||||
|
bad = [
|
||||||
|
{'engine': 'cloud'}, {'parallel': 0}, {'parallel': 9}, {'parallel': '3'}, {'parallel': True},
|
||||||
|
{'url': 'ftp://x.org/v1'}, {'url': 'javascript:alert(1)'}, {'url': 'whisper.local/v1'},
|
||||||
|
{'language': 'Deutsch'}, {'model': 5}, {'token': 5},
|
||||||
|
{'engine': 'custom'}, # a custom instance needs a url
|
||||||
|
]
|
||||||
|
for body in bad:
|
||||||
|
self.assertEqual(self.post(body).status_code, 400, body)
|
||||||
|
self.assertEqual(self.client.post(SETTINGS, 'nope', content_type='application/json').status_code, 400)
|
||||||
|
self.assertFalse(TranscriptionSettings.objects.exists())
|
||||||
|
for body in ({'parallel': 1}, {'parallel': 8}, {'language': ''}, {'language': 'pt-BR'}):
|
||||||
|
self.assertEqual(self.post(body).status_code, 200, body)
|
||||||
|
|
||||||
|
def test_settings_are_per_user(self):
|
||||||
|
self.post({'parallel': 7})
|
||||||
|
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
|
self.client.force_login(other)
|
||||||
|
self.assertEqual(self.client.get(SETTINGS).json()['parallel'], 3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndpointTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def configure(self, **kw):
|
||||||
|
TranscriptionSettings.objects.update_or_create(user=self.user, defaults=kw)
|
||||||
|
|
||||||
|
def test_custom_sends_a_second_of_silence(self):
|
||||||
|
self.configure(engine='custom', url='https://whisper.example.org/v1', model='large-v3', language='de',
|
||||||
|
token_encrypted=logins.encrypt('sk-secret'))
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def post(url, **kw):
|
||||||
|
calls.append((url, kw))
|
||||||
|
return fake_response()
|
||||||
|
|
||||||
|
with patch('podcasts.chapters.assert_safe_url'), patch('podcasts.chapters.requests.post', post):
|
||||||
|
r = self.client.post(TEST)
|
||||||
|
self.assertEqual(r.json(), {'ok': True, 'model': 'large-v3', 'error': ''})
|
||||||
|
url, kw = calls[0]
|
||||||
|
self.assertEqual(url, 'https://whisper.example.org/v1/audio/transcriptions')
|
||||||
|
self.assertEqual(kw['headers']['Authorization'], 'Bearer sk-secret')
|
||||||
|
self.assertEqual(kw['data']['model'], 'large-v3')
|
||||||
|
self.assertEqual(kw['data']['language'], 'de')
|
||||||
|
name, wav, mime = kw['files']['file']
|
||||||
|
self.assertEqual(mime, 'audio/wav')
|
||||||
|
with wave.open(io.BytesIO(wav)) as w:
|
||||||
|
self.assertEqual((w.getframerate(), w.getnchannels(), w.getnframes()), (16000, 1, 16000))
|
||||||
|
|
||||||
|
def test_custom_private_address_is_refused(self):
|
||||||
|
self.configure(engine='custom', url='http://127.0.0.1:9000/v1')
|
||||||
|
with patch('podcasts.chapters.requests.post') as post:
|
||||||
|
r = self.client.post(TEST)
|
||||||
|
self.assertFalse(r.json()['ok'])
|
||||||
|
self.assertTrue(r.json()['error'])
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
def test_custom_http_error_is_reported(self):
|
||||||
|
self.configure(engine='custom', url='https://whisper.example.org/v1')
|
||||||
|
with patch('podcasts.chapters.assert_safe_url'), \
|
||||||
|
patch('podcasts.chapters.requests.post', return_value=fake_response(401)):
|
||||||
|
r = self.client.post(TEST)
|
||||||
|
self.assertEqual((r.json()['ok'], r.json()['error']), (False, 'HTTP 401'))
|
||||||
|
|
||||||
|
@override_settings(WHISPER_URL='http://whisper:8000/v1')
|
||||||
|
def test_builtin_is_exempt_from_the_address_check(self):
|
||||||
|
self.configure(engine='builtin')
|
||||||
|
with patch('podcasts.transcription.requests.post', return_value=fake_response()) as post, \
|
||||||
|
patch('podcasts.chapters.assert_safe_url', side_effect=chapters.WebDAVInputError('private')):
|
||||||
|
r = self.client.post(TEST)
|
||||||
|
self.assertTrue(r.json()['ok'])
|
||||||
|
self.assertEqual(post.call_args.args[0], 'http://whisper:8000/v1/audio/transcriptions')
|
||||||
|
self.assertNotIn('Authorization', post.call_args.kwargs['headers'])
|
||||||
|
|
||||||
|
def test_builtin_without_instance(self):
|
||||||
|
r = self.client.post(TEST)
|
||||||
|
self.assertFalse(r.json()['ok'])
|
||||||
|
self.assertTrue(r.json()['error'])
|
||||||
|
|
@ -837,7 +837,7 @@ class EpisodeListTests(TestCase):
|
||||||
a2 = dict(eps[self.a2.id])
|
a2 = dict(eps[self.a2.id])
|
||||||
self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark)
|
self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark)
|
||||||
self.assertEqual(a2, {
|
self.assertEqual(a2, {
|
||||||
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'duration_seconds': 0,
|
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'link': '', 'duration_seconds': 0,
|
||||||
'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '',
|
'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '',
|
||||||
'feed__id': self.fa.id, 'feed__title': 'Alpha', 'feed__artwork_url': 'https://example.org/a.jpg',
|
'feed__id': self.fa.id, 'feed__title': 'Alpha', 'feed__artwork_url': 'https://example.org/a.jpg',
|
||||||
'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True, 'in_later': False,
|
'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True, 'in_later': False,
|
||||||
|
|
|
||||||
431
podcasts/transcribe.py
Normal file
431
podcasts/transcribe.py
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
"""Transcribe an episode with a Whisper instance (docs/transcription.md, "Transcribing").
|
||||||
|
|
||||||
|
The server downloads the episode (as silence.py does, with the same size limit), decodes it
|
||||||
|
once to 16 kHz mono, cuts it into pieces of about eight minutes where there is a pause (so no
|
||||||
|
word is cut), sends the pieces in parallel to the instance's OpenAI-compatible
|
||||||
|
`/audio/transcriptions` and joins the answers into one transcript, which is kept on the job
|
||||||
|
(TranscriptionJob.data).
|
||||||
|
|
||||||
|
A job runs in a daemon thread, one at a time per server; the others wait as `queued` and are
|
||||||
|
taken oldest first (by id). A job found `downloading` or `transcribing` when the process has
|
||||||
|
just started (a restart killed it) is queued again. Cancelling deletes the job's row: the
|
||||||
|
worker notices when it next writes progress and stops.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import close_old_connections
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from . import chapters, silence, transcripts
|
||||||
|
from .models import TranscriptionJob, TranscriptionSettings
|
||||||
|
from .transcription import DEFAULT_MODEL, for_user, token_of
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHUNK_SECONDS = 8 * 60 # about this long a piece
|
||||||
|
CUT_WINDOW = 30 # a cut moves to the longest pause within this many seconds
|
||||||
|
MIN_TAIL = 60 # a last piece shorter than this is joined to the one before
|
||||||
|
RATE = 16000 # Hz, mono, 16-bit
|
||||||
|
WINDOW = silence.WINDOW
|
||||||
|
RETRIES = 3
|
||||||
|
RETRY_PAUSES = (3, 10, 30) # seconds before each retry
|
||||||
|
UPLOAD_TIMEOUT = (10, 3600) # a CPU instance may need many minutes for a piece
|
||||||
|
DECODE_TIMEOUT = 3600
|
||||||
|
OPUS_BITRATE = '32k'
|
||||||
|
MAX_PROMPT = 500 # characters
|
||||||
|
ALIVE_CHECK = 5 # seconds between looks whether the job was cancelled
|
||||||
|
WAIT_RETRY = 600 # seconds between tries while the built-in instance can't be reached
|
||||||
|
WAIT_MAX = 48 * 3600 # after this long since the button was pressed a waiting job gives up
|
||||||
|
WAIT_IDLE = 30 # seconds the runner sleeps while every queued job is waiting
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_runner = None # the thread working through the queue, or None
|
||||||
|
_resumed = False # whether jobs left over from before a restart were queued again
|
||||||
|
_not_before = {} # job id -> time.time() before which a waiting job isn't tried again
|
||||||
|
WAIT = 'wait' # _next_job: jobs are queued, but none is due yet
|
||||||
|
|
||||||
|
|
||||||
|
class Cancelled(Exception):
|
||||||
|
"""The job's row is gone: the user cancelled it."""
|
||||||
|
|
||||||
|
|
||||||
|
class Failed(Exception):
|
||||||
|
"""The job can't succeed; the message is shown to the user."""
|
||||||
|
|
||||||
|
|
||||||
|
class Unreachable(Exception):
|
||||||
|
"""The built-in instance can't be reached (e.g. the machine it runs on is off): the job waits
|
||||||
|
and tries again later instead of failing."""
|
||||||
|
|
||||||
|
|
||||||
|
class Permanent(Exception):
|
||||||
|
"""A request the instance refused for good (a client error): not worth a retry."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Queue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def kick():
|
||||||
|
"""Make sure queued jobs are being worked on. Called by the endpoints, so after a restart
|
||||||
|
the first request brings back what was running."""
|
||||||
|
global _runner, _resumed
|
||||||
|
with _lock:
|
||||||
|
if not _resumed:
|
||||||
|
_resumed = True
|
||||||
|
TranscriptionJob.objects.filter(
|
||||||
|
state__in=[TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING],
|
||||||
|
).update(state=TranscriptionJob.QUEUED, chunks_done=0, chunks_total=0)
|
||||||
|
if _runner is not None:
|
||||||
|
return
|
||||||
|
if not TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED).exists():
|
||||||
|
return
|
||||||
|
_runner = threading.Thread(target=_loop, name='transcribe', daemon=True)
|
||||||
|
_runner.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _next_job():
|
||||||
|
global _runner
|
||||||
|
with _lock:
|
||||||
|
queued = list(TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED)
|
||||||
|
.order_by('id').values_list('id', flat=True))
|
||||||
|
if not queued:
|
||||||
|
_runner = None # decided under the lock, so a job queued right now starts a new runner
|
||||||
|
return None
|
||||||
|
now = time.time()
|
||||||
|
due = next((i for i in queued if _not_before.get(i, 0) <= now), None)
|
||||||
|
return due if due is not None else WAIT
|
||||||
|
|
||||||
|
|
||||||
|
def _loop():
|
||||||
|
global _runner
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
close_old_connections()
|
||||||
|
job_id = _next_job()
|
||||||
|
if job_id is None:
|
||||||
|
return
|
||||||
|
if job_id == WAIT:
|
||||||
|
time.sleep(WAIT_IDLE)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
run(job_id)
|
||||||
|
except Exception: # run() reports its own failures; this keeps the queue going
|
||||||
|
log.exception('transcription job %s crashed', job_id)
|
||||||
|
except BaseException:
|
||||||
|
with _lock:
|
||||||
|
_runner = None
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
close_old_connections()
|
||||||
|
|
||||||
|
|
||||||
|
def run(job_id):
|
||||||
|
"""Work one job through to `done` or `error`, in this thread."""
|
||||||
|
started = TranscriptionJob.objects.filter(pk=job_id, state=TranscriptionJob.QUEUED).update(
|
||||||
|
state=TranscriptionJob.DOWNLOADING, started_at=timezone.now(), finished_at=None,
|
||||||
|
error='', chunks_done=0, chunks_total=0)
|
||||||
|
if not started:
|
||||||
|
return # cancelled while it waited
|
||||||
|
try:
|
||||||
|
data = _transcribe(job_id)
|
||||||
|
_write(job_id, state=TranscriptionJob.DONE, data=data, error='', finished_at=timezone.now())
|
||||||
|
except Cancelled:
|
||||||
|
log.info('transcription job %s cancelled', job_id)
|
||||||
|
except Unreachable as e:
|
||||||
|
_wait(job_id, str(e))
|
||||||
|
except Failed as e:
|
||||||
|
_fail(job_id, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
log.exception('transcription job %s failed', job_id)
|
||||||
|
_fail(job_id, f'{type(e).__name__}: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def _wait(job_id, reason):
|
||||||
|
"""Back into the queue to try again in WAIT_RETRY seconds, or give up when it waited too long."""
|
||||||
|
job = TranscriptionJob.objects.filter(pk=job_id).only('created_at').first()
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
if (timezone.now() - job.created_at).total_seconds() > WAIT_MAX:
|
||||||
|
_not_before.pop(job_id, None)
|
||||||
|
_fail(job_id, 'Whisper-Instanz zwei Tage lang nicht erreichbar')
|
||||||
|
return
|
||||||
|
_not_before[job_id] = time.time() + WAIT_RETRY
|
||||||
|
again = timezone.localtime(timezone.now() + datetime.timedelta(seconds=WAIT_RETRY)).strftime('%H:%M')
|
||||||
|
log.info('transcription job %s waits for the instance (%s)', job_id, reason)
|
||||||
|
TranscriptionJob.objects.filter(pk=job_id).update(
|
||||||
|
state=TranscriptionJob.QUEUED, chunks_done=0, chunks_total=0, started_at=None,
|
||||||
|
error=f'Whisper-Instanz nicht erreichbar, neuer Versuch um {again}')
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(url):
|
||||||
|
"""Unreachable unless something answers at the built-in instance's address."""
|
||||||
|
try:
|
||||||
|
requests.get(url.rsplit('/audio/transcriptions', 1)[0], timeout=(5, 10)).close()
|
||||||
|
except (requests.ConnectionError, requests.Timeout) as e:
|
||||||
|
raise Unreachable(e.__class__.__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(job_id, message):
|
||||||
|
TranscriptionJob.objects.filter(pk=job_id).update(
|
||||||
|
state=TranscriptionJob.ERROR, error=(message or 'failed')[:500], finished_at=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
def _write(job_id, **fields):
|
||||||
|
"""Update the job; Cancelled when its row is gone."""
|
||||||
|
if not TranscriptionJob.objects.filter(pk=job_id).update(**fields):
|
||||||
|
raise Cancelled()
|
||||||
|
|
||||||
|
|
||||||
|
def _alive(job_id):
|
||||||
|
if not TranscriptionJob.objects.filter(pk=job_id).exists():
|
||||||
|
raise Cancelled()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The work
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _target(conf):
|
||||||
|
"""(url, headers, builtin) of the instance the settings point at."""
|
||||||
|
builtin = conf.engine == TranscriptionSettings.BUILTIN
|
||||||
|
base = settings.WHISPER_URL if builtin else conf.url
|
||||||
|
if not base:
|
||||||
|
raise Failed('Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen')
|
||||||
|
headers = {'User-Agent': 'diora transcription'}
|
||||||
|
if not builtin and conf.token_encrypted:
|
||||||
|
headers['Authorization'] = 'Bearer ' + token_of(conf)
|
||||||
|
return base.rstrip('/') + '/audio/transcriptions', headers, builtin
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(episode):
|
||||||
|
parts = [episode.feed.title, episode.title, episode.feed.author]
|
||||||
|
return '. '.join(p.strip() for p in parts if p and p.strip())[:MAX_PROMPT]
|
||||||
|
|
||||||
|
|
||||||
|
def _transcribe(job_id):
|
||||||
|
job = TranscriptionJob.objects.select_related('episode__feed').get(pk=job_id)
|
||||||
|
episode = job.episode
|
||||||
|
conf = for_user(episode.feed.user)
|
||||||
|
url, headers, builtin = _target(conf)
|
||||||
|
if builtin:
|
||||||
|
_probe(url) # before downloading: is the instance there at all?
|
||||||
|
options = {'model': conf.model or DEFAULT_MODEL, 'response_format': 'verbose_json'}
|
||||||
|
if conf.language:
|
||||||
|
options['language'] = conf.language
|
||||||
|
prompt = _prompt(episode)
|
||||||
|
if prompt:
|
||||||
|
options['prompt'] = prompt
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix='diora-transcribe-') as tmp:
|
||||||
|
source = os.path.join(tmp, 'episode.audio')
|
||||||
|
_download(episode.audio_url, source, job_id)
|
||||||
|
pcm = os.path.join(tmp, 'episode.pcm')
|
||||||
|
_decode(source, pcm)
|
||||||
|
os.remove(source)
|
||||||
|
total = os.path.getsize(pcm) // 2 / RATE
|
||||||
|
if total <= 0:
|
||||||
|
raise Failed('Kein Ton in der Datei')
|
||||||
|
pieces = plan(total, pauses_of(pcm))
|
||||||
|
_write(job_id, state=TranscriptionJob.TRANSCRIBING, chunks_total=len(pieces), chunks_done=0)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
stop = threading.Event() # set when the job ends early: no piece starts after that
|
||||||
|
suffix, mime = ('.flac', 'audio/flac') if builtin else ('.ogg', 'audio/ogg')
|
||||||
|
|
||||||
|
def work(index):
|
||||||
|
# Only the HTTP side runs here; the database is touched by this thread alone.
|
||||||
|
if stop.is_set():
|
||||||
|
return index, None
|
||||||
|
start, end = pieces[index]
|
||||||
|
path = _encode(pcm, start, end, tmp, index, opus=not builtin)
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
body = f.read()
|
||||||
|
finally:
|
||||||
|
os.remove(path)
|
||||||
|
return index, _send(url, headers, builtin, f'piece-{index}{suffix}', mime, body, options, stop)
|
||||||
|
|
||||||
|
pool = ThreadPoolExecutor(max_workers=max(1, min(conf.parallel, 8)))
|
||||||
|
try:
|
||||||
|
pending = {pool.submit(work, i) for i in range(len(pieces))}
|
||||||
|
while pending:
|
||||||
|
finished, pending = wait(pending, timeout=ALIVE_CHECK, return_when=FIRST_COMPLETED)
|
||||||
|
_alive(job_id) # also while a long piece is out
|
||||||
|
for f in finished:
|
||||||
|
index, answer = f.result() # the first failure ends the job
|
||||||
|
results[index] = answer
|
||||||
|
if finished:
|
||||||
|
_write(job_id, chunks_done=len(results))
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
pool.shutdown(wait=True, cancel_futures=True)
|
||||||
|
return build(pieces, [results[i] for i in range(len(pieces))], options['model'], conf.language)
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url, path, job_id):
|
||||||
|
size = 0
|
||||||
|
with open(path, 'wb') as out, requests.get(
|
||||||
|
url, stream=True, timeout=(10, 60), headers={'User-Agent': 'diora transcription'}) as r:
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise Failed(f'Download: HTTP {r.status_code}')
|
||||||
|
if int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES:
|
||||||
|
raise Failed('too large')
|
||||||
|
for n, chunk in enumerate(r.iter_content(1 << 20)):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > silence.MAX_BYTES:
|
||||||
|
raise Failed('too large')
|
||||||
|
out.write(chunk)
|
||||||
|
if n % 8 == 0:
|
||||||
|
_alive(job_id)
|
||||||
|
if size == 0:
|
||||||
|
raise Failed('Download: leere Datei')
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ffmpeg(args, input=None, timeout=DECODE_TIMEOUT):
|
||||||
|
import imageio_ffmpeg
|
||||||
|
proc = subprocess.run(
|
||||||
|
[imageio_ffmpeg.get_ffmpeg_exe(), '-nostdin', '-hide_banner', '-loglevel', 'error', *args],
|
||||||
|
input=input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err = proc.stderr.decode('utf-8', 'replace').strip().splitlines()
|
||||||
|
raise Failed('ffmpeg: ' + (err[-1] if err else f'exit {proc.returncode}')[:200])
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(source, pcm):
|
||||||
|
"""The audio at `source` as raw 16 kHz mono 16-bit samples in the file `pcm`."""
|
||||||
|
_run_ffmpeg(['-vn', '-sn', '-dn', '-i', source, '-ac', '1', '-ar', str(RATE), '-f', 's16le', '-y', pcm])
|
||||||
|
|
||||||
|
|
||||||
|
def pauses_of(pcm):
|
||||||
|
"""The pauses of the decoded audio as [(middle, length)] in seconds, found with
|
||||||
|
silence.py's rules (what it cuts out of a pause is centred on the pause)."""
|
||||||
|
size = int(RATE * WINDOW) * 2
|
||||||
|
levels = []
|
||||||
|
with open(pcm, 'rb') as f:
|
||||||
|
while True:
|
||||||
|
data = f.read(size * 100000)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
for i in range(0, len(data) - size + 1, size):
|
||||||
|
levels.append(silence._level_db(data[i:i + size]))
|
||||||
|
return [((a + b) / 2, b - a) for a, b in silence.cuts(levels)]
|
||||||
|
|
||||||
|
|
||||||
|
def plan(total, pauses):
|
||||||
|
"""[(start, end)] in seconds covering 0..total: pieces of about CHUNK_SECONDS, each cut at
|
||||||
|
the longest pause within CUT_WINDOW of where it would fall (right there when there is none)."""
|
||||||
|
cuts = []
|
||||||
|
last = 0.0
|
||||||
|
k = 1
|
||||||
|
while k * CHUNK_SECONDS < total - MIN_TAIL:
|
||||||
|
target = k * CHUNK_SECONDS
|
||||||
|
k += 1
|
||||||
|
near = [p for p in pauses if abs(p[0] - target) <= CUT_WINDOW and p[0] > last + 1]
|
||||||
|
at = max(near, key=lambda p: p[1])[0] if near else target
|
||||||
|
if at <= last or at >= total:
|
||||||
|
continue
|
||||||
|
cuts.append(at)
|
||||||
|
last = at
|
||||||
|
edges = [0.0, *cuts, total]
|
||||||
|
return [(edges[i], edges[i + 1]) for i in range(len(edges) - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(pcm, start, end, tmp, index, opus):
|
||||||
|
"""The piece [start, end) of the decoded audio as FLAC or Opus (ogg), in a file."""
|
||||||
|
with open(pcm, 'rb') as f:
|
||||||
|
f.seek(int(round(start * RATE)) * 2)
|
||||||
|
data = f.read(int(round((end - start) * RATE)) * 2)
|
||||||
|
out = os.path.join(tmp, f'piece-{index}.' + ('ogg' if opus else 'flac'))
|
||||||
|
codec = ['-c:a', 'libopus', '-b:a', OPUS_BITRATE, '-f', 'ogg'] if opus else ['-c:a', 'flac', '-f', 'flac']
|
||||||
|
_run_ffmpeg(['-f', 's16le', '-ar', str(RATE), '-ac', '1', '-i', 'pipe:0', *codec, '-y', out], input=data)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _post(url, headers, builtin, name, mime, body, options):
|
||||||
|
"""One request; the parsed answer. Permanent for a refusal that won't change, RuntimeError
|
||||||
|
for trouble that may pass."""
|
||||||
|
files = {'file': (name, body, mime)}
|
||||||
|
try:
|
||||||
|
if builtin:
|
||||||
|
# The operator's own instance (DIORA_WHISPER_URL) may sit on a private network.
|
||||||
|
try:
|
||||||
|
r = requests.post(url, headers=headers, files=files, data=options, timeout=UPLOAD_TIMEOUT)
|
||||||
|
except requests.ConnectionError as e:
|
||||||
|
raise Unreachable(e.__class__.__name__)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
if r.status_code < 500 and r.status_code not in (408, 429):
|
||||||
|
raise Permanent(f'HTTP {r.status_code}')
|
||||||
|
raise RuntimeError(f'HTTP {r.status_code}')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
r = chapters._open(url, headers, method='POST', timeout=UPLOAD_TIMEOUT, files=files, data=options)
|
||||||
|
except chapters.ChaptersInvalid as e:
|
||||||
|
raise Permanent(str(e))
|
||||||
|
except chapters.ChaptersUnavailable as e:
|
||||||
|
raise RuntimeError(str(e))
|
||||||
|
try:
|
||||||
|
return r.json()
|
||||||
|
finally:
|
||||||
|
r.close()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise RuntimeError(e.__class__.__name__)
|
||||||
|
except ValueError:
|
||||||
|
raise RuntimeError('Antwort ist kein JSON')
|
||||||
|
|
||||||
|
|
||||||
|
def _send(url, headers, builtin, name, mime, body, options, stop):
|
||||||
|
"""A piece to the instance, retried up to RETRIES times with a growing pause."""
|
||||||
|
for attempt in range(RETRIES + 1):
|
||||||
|
if stop.is_set():
|
||||||
|
raise Cancelled()
|
||||||
|
try:
|
||||||
|
return _post(url, headers, builtin, name, mime, body, options)
|
||||||
|
except Permanent as e:
|
||||||
|
raise Failed(f'Whisper: {e}')
|
||||||
|
except Unreachable:
|
||||||
|
if attempt >= RETRIES:
|
||||||
|
raise
|
||||||
|
time.sleep(RETRY_PAUSES[min(attempt, len(RETRY_PAUSES) - 1)])
|
||||||
|
except RuntimeError as e:
|
||||||
|
if attempt >= RETRIES:
|
||||||
|
raise Failed(f'Whisper: {e}')
|
||||||
|
log.info('whisper piece %s failed (%s), retrying', name, e)
|
||||||
|
time.sleep(RETRY_PAUSES[min(attempt, len(RETRY_PAUSES) - 1)])
|
||||||
|
|
||||||
|
|
||||||
|
def build(pieces, answers, model, language):
|
||||||
|
"""{segments, source} from the pieces' places and the instance's verbose_json answers."""
|
||||||
|
fragments = []
|
||||||
|
detected = ''
|
||||||
|
for (start, end), answer in zip(pieces, answers):
|
||||||
|
if not isinstance(answer, dict):
|
||||||
|
continue
|
||||||
|
detected = detected or (answer.get('language') or '')
|
||||||
|
segments = answer.get('segments')
|
||||||
|
if not isinstance(segments, list): # a plain {"text": …} answer: one segment for the piece
|
||||||
|
text = (answer.get('text') or '').strip()
|
||||||
|
segments = [{'start': 0, 'end': end - start, 'text': text}] if text else []
|
||||||
|
for s in segments:
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
text = ' '.join(str(s.get('text') or '').split())
|
||||||
|
try:
|
||||||
|
a, b = float(s.get('start', 0)), float(s.get('end', 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if text:
|
||||||
|
fragments.append((start + a, start + b, '', text))
|
||||||
|
merged = transcripts.merge(fragments)
|
||||||
|
if not merged:
|
||||||
|
raise Failed('Kein Text erkannt')
|
||||||
|
return {'segments': merged, 'source': {'type': 'whisper', 'model': model, 'language': language or detected}}
|
||||||
297
podcasts/transcription.py
Normal file
297
podcasts/transcription.py
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
"""Transcription settings and the test of the configured Whisper instance
|
||||||
|
(docs/transcription.md). The job itself is podcasts/transcribe.py."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import wave
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from feeds import logins
|
||||||
|
|
||||||
|
from . import chapters
|
||||||
|
from .models import EpisodeArticle, PodcastEpisode, TranscriptionJob, TranscriptionSettings
|
||||||
|
|
||||||
|
DEFAULT_MODEL = 'whisper-1'
|
||||||
|
TEST_TIMEOUT = 60 # a cold CPU instance may load its model first
|
||||||
|
LANGUAGE_RE = re.compile(r'^[a-z]{2,3}(-[A-Za-z]{2,4})?$')
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _body(request):
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body or b'{}')
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
return body if isinstance(body, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def for_user(user):
|
||||||
|
"""The user's settings (unsaved defaults when there are none)."""
|
||||||
|
return TranscriptionSettings.objects.filter(user=user).first() or TranscriptionSettings(user=user)
|
||||||
|
|
||||||
|
|
||||||
|
def token_of(conf):
|
||||||
|
"""The plain API token ('' when there is none or it can't be read)."""
|
||||||
|
return (logins.decrypt(conf.token_encrypted) or '') if conf.token_encrypted else ''
|
||||||
|
|
||||||
|
|
||||||
|
def _data(conf):
|
||||||
|
return {
|
||||||
|
'engine': conf.engine, 'url': conf.url, 'model': conf.model, 'language': conf.language,
|
||||||
|
'parallel': conf.parallel, 'has_token': bool(conf.token_encrypted),
|
||||||
|
'builtin_available': bool(settings.WHISPER_URL),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET', 'POST'])
|
||||||
|
def transcription_settings(request):
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
conf = for_user(request.user)
|
||||||
|
if request.method == 'GET':
|
||||||
|
return JsonResponse(_data(conf))
|
||||||
|
body = _body(request)
|
||||||
|
if body is None:
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
if 'engine' in body:
|
||||||
|
if body['engine'] not in dict(TranscriptionSettings.ENGINES):
|
||||||
|
return JsonResponse({'error': 'engine must be builtin or custom'}, status=400)
|
||||||
|
conf.engine = body['engine']
|
||||||
|
if 'url' in body:
|
||||||
|
url = body['url']
|
||||||
|
if not isinstance(url, str):
|
||||||
|
return JsonResponse({'error': 'url must be text'}, status=400)
|
||||||
|
url = url.strip().rstrip('/')
|
||||||
|
if url and (urlsplit(url).scheme not in ('http', 'https') or not urlsplit(url).hostname):
|
||||||
|
return JsonResponse({'error': 'url must start with http:// or https://'}, status=400)
|
||||||
|
if len(url) > 500:
|
||||||
|
return JsonResponse({'error': 'url too long'}, status=400)
|
||||||
|
conf.url = url
|
||||||
|
if 'model' in body:
|
||||||
|
if not isinstance(body['model'], str) or len(body['model'].strip()) > 100:
|
||||||
|
return JsonResponse({'error': 'invalid model'}, status=400)
|
||||||
|
conf.model = body['model'].strip()
|
||||||
|
if 'language' in body:
|
||||||
|
lang = body['language']
|
||||||
|
if not isinstance(lang, str) or (lang.strip() and not LANGUAGE_RE.match(lang.strip())):
|
||||||
|
return JsonResponse({'error': 'language must be empty or an ISO code'}, status=400)
|
||||||
|
conf.language = lang.strip()
|
||||||
|
if 'parallel' in body:
|
||||||
|
n = body['parallel']
|
||||||
|
if isinstance(n, bool) or not isinstance(n, int) or not 1 <= n <= 8:
|
||||||
|
return JsonResponse({'error': 'parallel must be 1 to 8'}, status=400)
|
||||||
|
conf.parallel = n
|
||||||
|
if 'token' in body:
|
||||||
|
if not isinstance(body['token'], str):
|
||||||
|
return JsonResponse({'error': 'token must be text'}, status=400)
|
||||||
|
conf.token_encrypted = logins.encrypt(body['token']) if body['token'] else ''
|
||||||
|
if conf.engine == TranscriptionSettings.CUSTOM and not conf.url:
|
||||||
|
return JsonResponse({'error': 'a custom instance needs a url'}, status=400)
|
||||||
|
conf.save()
|
||||||
|
return JsonResponse(_data(conf))
|
||||||
|
|
||||||
|
|
||||||
|
def silent_wav(seconds=1, rate=16000):
|
||||||
|
"""`seconds` of 16-bit mono silence as a WAV file, made in memory."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with wave.open(buf, 'wb') as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(rate)
|
||||||
|
w.writeframes(b'\0\0' * rate * seconds)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def transcription_test(request):
|
||||||
|
"""One second of silence to the configured instance: {ok, model, error}."""
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
conf = for_user(request.user)
|
||||||
|
builtin = conf.engine == TranscriptionSettings.BUILTIN
|
||||||
|
base = settings.WHISPER_URL if builtin else conf.url
|
||||||
|
model = conf.model or DEFAULT_MODEL
|
||||||
|
if not base:
|
||||||
|
error = 'Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen'
|
||||||
|
return JsonResponse({'ok': False, 'model': model, 'error': error})
|
||||||
|
url = base.rstrip('/') + '/audio/transcriptions'
|
||||||
|
headers = {'User-Agent': 'diora transcription test'}
|
||||||
|
if not builtin and conf.token_encrypted:
|
||||||
|
headers['Authorization'] = 'Bearer ' + token_of(conf)
|
||||||
|
files = {'file': ('silence.wav', silent_wav(), 'audio/wav')}
|
||||||
|
data = {'model': model, 'response_format': 'json'}
|
||||||
|
if conf.language:
|
||||||
|
data['language'] = conf.language
|
||||||
|
try:
|
||||||
|
if builtin:
|
||||||
|
# The operator's own instance (DIORA_WHISPER_URL) may sit on a private network.
|
||||||
|
r = requests.post(url, headers=headers, files=files, data=data, timeout=TEST_TIMEOUT)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise chapters.ChaptersInvalid(f'HTTP {r.status_code}')
|
||||||
|
else:
|
||||||
|
r = chapters._open(url, headers, method='POST', timeout=TEST_TIMEOUT, files=files, data=data)
|
||||||
|
r.close()
|
||||||
|
except (chapters.ChaptersInvalid, chapters.ChaptersUnavailable) as e:
|
||||||
|
return JsonResponse({'ok': False, 'model': model, 'error': str(e)})
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return JsonResponse({'ok': False, 'model': model, 'error': e.__class__.__name__})
|
||||||
|
return JsonResponse({'ok': True, 'model': model, 'error': ''})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Jobs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CLAIM_MINUTES = 15
|
||||||
|
ACTIVE = (TranscriptionJob.QUEUED, TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING)
|
||||||
|
|
||||||
|
|
||||||
|
def job_state(job):
|
||||||
|
"""The job as the API shows it; state `none` when there is no job."""
|
||||||
|
if job is None:
|
||||||
|
return {'state': TranscriptionJob.NONE, 'chunks_done': 0, 'chunks_total': 0, 'error': '',
|
||||||
|
'want_article': False, 'started_at': None, 'finished_at': None}
|
||||||
|
return {
|
||||||
|
'state': job.state, 'chunks_done': job.chunks_done, 'chunks_total': job.chunks_total,
|
||||||
|
'error': job.error, 'want_article': job.want_article,
|
||||||
|
'started_at': job.started_at.isoformat() if job.started_at else None,
|
||||||
|
'finished_at': job.finished_at.isoformat() if job.finished_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def job_of(episode):
|
||||||
|
return TranscriptionJob.objects.filter(episode=episode).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _too_large(url):
|
||||||
|
"""Whether the server says the file at `url` is over the size limit (a failed look doesn't count)."""
|
||||||
|
from . import silence
|
||||||
|
try:
|
||||||
|
r = requests.head(url, timeout=5, allow_redirects=True, headers={'User-Agent': 'diora transcription'})
|
||||||
|
return int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET', 'POST', 'DELETE'])
|
||||||
|
def episode_transcribe(request, pk):
|
||||||
|
"""POST starts the episode's transcription (or just raises `want_article` on one that exists),
|
||||||
|
GET tells how far it is, DELETE cancels a queued or running one."""
|
||||||
|
from . import transcribe, views
|
||||||
|
from .youtube import is_video
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
ep = PodcastEpisode.objects.select_related('feed').filter(pk=pk, feed__user=request.user).first()
|
||||||
|
if ep is None:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
job = job_of(ep)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
transcribe.kick() # after a restart the first request brings the work back
|
||||||
|
return JsonResponse(job_state(job))
|
||||||
|
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
if job is not None and job.state != TranscriptionJob.DONE:
|
||||||
|
job.delete() # a running worker notices its row is gone and stops
|
||||||
|
job = None
|
||||||
|
return JsonResponse(job_state(job))
|
||||||
|
|
||||||
|
body = _body(request)
|
||||||
|
if body is None:
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
article = body.get('article') is True
|
||||||
|
if is_video(ep.audio_url):
|
||||||
|
return JsonResponse({'error': 'youtube'}, status=400)
|
||||||
|
|
||||||
|
has_feed = views._has_transcript(ep)
|
||||||
|
if job is not None and (job.state in ACTIVE or (job.state == TranscriptionJob.DONE and (job.data or has_feed))):
|
||||||
|
if article and not job.want_article:
|
||||||
|
job.want_article = True
|
||||||
|
job.save(update_fields=['want_article'])
|
||||||
|
return JsonResponse(job_state(job))
|
||||||
|
if has_feed: # the feed's own transcript is there: only the article is wanted
|
||||||
|
if job is None:
|
||||||
|
job = TranscriptionJob(episode=ep)
|
||||||
|
job.state, job.want_article, job.finished_at = TranscriptionJob.DONE, article or job.want_article, timezone.now()
|
||||||
|
job.save()
|
||||||
|
return JsonResponse(job_state(job))
|
||||||
|
|
||||||
|
if not ep.audio_url:
|
||||||
|
return JsonResponse({'error': 'no audio'}, status=400)
|
||||||
|
if _too_large(ep.audio_url):
|
||||||
|
return JsonResponse({'error': 'too large'}, status=400)
|
||||||
|
if job is not None:
|
||||||
|
job.delete() # a failed or hollow earlier run: the new one starts at the back of the queue
|
||||||
|
job = TranscriptionJob.objects.create(episode=ep, state=TranscriptionJob.QUEUED, want_article=article)
|
||||||
|
transcribe.kick()
|
||||||
|
return JsonResponse(job_state(job))
|
||||||
|
|
||||||
|
|
||||||
|
def _wishes(user):
|
||||||
|
"""The user's jobs whose transcript is there and whose article is wanted but not written."""
|
||||||
|
return (TranscriptionJob.objects
|
||||||
|
.filter(episode__feed__user=user, state=TranscriptionJob.DONE, want_article=True)
|
||||||
|
.exclude(episode__in=EpisodeArticle.objects.values('episode')))
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def transcription_pending(request):
|
||||||
|
"""The open wishes for an article that no device holds right now."""
|
||||||
|
from . import transcribe
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
transcribe.kick()
|
||||||
|
jobs = (_wishes(request.user).exclude(claimed_until__gt=timezone.now())
|
||||||
|
.select_related('episode__feed').order_by('id'))
|
||||||
|
return JsonResponse([{
|
||||||
|
'episode_id': j.episode_id, 'title': j.episode.title, 'podcast': j.episode.feed.title,
|
||||||
|
'duration_seconds': j.episode.duration_seconds,
|
||||||
|
} for j in jobs], safe=False)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST', 'DELETE'])
|
||||||
|
def article_claim(request, pk):
|
||||||
|
"""Reserve the wish for an article for the calling device for CLAIM_MINUTES (POST), or give
|
||||||
|
it up (DELETE). The device names itself with `device` in the JSON body or the
|
||||||
|
X-Diora-Device header. 409 when another device holds it."""
|
||||||
|
from datetime import timedelta
|
||||||
|
if (err := _auth(request)):
|
||||||
|
return err
|
||||||
|
body = _body(request)
|
||||||
|
if body is None:
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
device = body.get('device') or request.META.get('HTTP_X_DIORA_DEVICE') or ''
|
||||||
|
if not isinstance(device, str) or not device.strip():
|
||||||
|
return JsonResponse({'error': 'device missing'}, status=400)
|
||||||
|
device = device.strip()[:60]
|
||||||
|
job = _wishes(request.user).filter(episode_id=pk).first()
|
||||||
|
if job is None:
|
||||||
|
return JsonResponse({'error': 'no open wish'}, status=404)
|
||||||
|
now = timezone.now()
|
||||||
|
if job.claimed_until and job.claimed_until > now and job.claimed_by != device:
|
||||||
|
return JsonResponse({'error': 'claimed', 'claimed_until': job.claimed_until.isoformat()}, status=409)
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
job.claimed_by, job.claimed_until = '', None
|
||||||
|
else:
|
||||||
|
job.claimed_by, job.claimed_until = device, now + timedelta(minutes=CLAIM_MINUTES)
|
||||||
|
job.save(update_fields=['claimed_by', 'claimed_until'])
|
||||||
|
return JsonResponse({'claimed_by': job.claimed_by,
|
||||||
|
'claimed_until': job.claimed_until.isoformat() if job.claimed_until else None})
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import audiobooks, discover, folders, radio, views
|
from . import article, audiobooks, discover, folders, radio, transcription, views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('search/', views.podcast_search, name='podcast_search'),
|
path('search/', views.podcast_search, name='podcast_search'),
|
||||||
|
|
@ -28,9 +28,15 @@ urlpatterns = [
|
||||||
path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
|
path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
|
||||||
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
|
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
|
||||||
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
|
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
|
||||||
|
path('episodes/<int:pk>/article/', article.episode_article, name='podcast_episode_article'),
|
||||||
path('episodes/<int:pk>/silences/', views.episode_silences, name='podcast_episode_silences'),
|
path('episodes/<int:pk>/silences/', views.episode_silences, name='podcast_episode_silences'),
|
||||||
path('episodes/<int:pk>/sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'),
|
path('episodes/<int:pk>/sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'),
|
||||||
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
|
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
|
||||||
|
path('transcription/settings/', transcription.transcription_settings, name='podcast_transcription_settings'),
|
||||||
|
path('transcription/test/', transcription.transcription_test, name='podcast_transcription_test'),
|
||||||
|
path('transcription/pending/', transcription.transcription_pending, name='podcast_transcription_pending'),
|
||||||
|
path('episodes/<int:pk>/transcribe/', transcription.episode_transcribe, name='podcast_episode_transcribe'),
|
||||||
|
path('episodes/<int:pk>/article/claim/', transcription.article_claim, name='podcast_article_claim'),
|
||||||
path('stats/', views.podcast_stats, name='podcast_stats'),
|
path('stats/', views.podcast_stats, name='podcast_stats'),
|
||||||
path('queue/', views.queue_get, name='podcast_queue_get'),
|
path('queue/', views.queue_get, name='podcast_queue_get'),
|
||||||
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,12 @@ from . import chapters as chapters_mod
|
||||||
from . import silence as silence_mod
|
from . import silence as silence_mod
|
||||||
from . import sponsor as sponsor_mod
|
from . import sponsor as sponsor_mod
|
||||||
from . import feedext
|
from . import feedext
|
||||||
|
from . import transcription as transcription_mod
|
||||||
from . import transcripts as transcripts_mod
|
from . import transcripts as transcripts_mod
|
||||||
from . import youtube as youtube_mod
|
from . import youtube as youtube_mod
|
||||||
from . import folders as folders_mod
|
from . import folders as folders_mod
|
||||||
from .models import (
|
from .models import (
|
||||||
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
|
EpisodeArticle, EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
|
||||||
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
|
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
|
||||||
)
|
)
|
||||||
from .shownotes import extract_timestamps, sanitize_html
|
from .shownotes import extract_timestamps, sanitize_html
|
||||||
|
|
@ -231,7 +232,7 @@ def _later_data(user):
|
||||||
.order_by(F('episode__pub_date').desc(nulls_last=True), '-id')
|
.order_by(F('episode__pub_date').desc(nulls_last=True), '-id')
|
||||||
.values(
|
.values(
|
||||||
'id', 'added_at', 'auto',
|
'id', 'added_at', 'auto',
|
||||||
'episode__id', 'episode__title', 'episode__audio_url',
|
'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
|
||||||
'episode__duration_seconds', 'episode__artwork_url',
|
'episode__duration_seconds', 'episode__artwork_url',
|
||||||
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
||||||
'episode__pub_date',
|
'episode__pub_date',
|
||||||
|
|
@ -383,7 +384,7 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts,
|
||||||
return bool(changed)
|
return bool(changed)
|
||||||
|
|
||||||
|
|
||||||
EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
|
EXTRA_FIELDS = ['link', 'description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
|
||||||
'title', 'pub_date', 'episode_number']
|
'title', 'pub_date', 'episode_number']
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -642,6 +643,12 @@ def _move_to_permanent_url(feed_obj, new_url):
|
||||||
feed_obj.rss_url = new_url
|
feed_obj.rss_url = new_url
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_link(entry):
|
||||||
|
"""The item's <link>, the episode's own page; '' unless it is an http(s) address."""
|
||||||
|
link = (entry.get('link') or '').strip()
|
||||||
|
return link[:1000] if re.match(r'^https?://', link, re.I) else ''
|
||||||
|
|
||||||
|
|
||||||
def _entry_media_url(entry):
|
def _entry_media_url(entry):
|
||||||
"""The episode's file: its audio enclosure, else a video one (the players
|
"""The episode's file: its audio enclosure, else a video one (the players
|
||||||
play only its sound). '' when there is neither."""
|
play only its sound). '' when there is neither."""
|
||||||
|
|
@ -713,6 +720,7 @@ def _apply_feed(feed_obj, fetched, full=False):
|
||||||
audio_url = _entry_media_url(entry)
|
audio_url = _entry_media_url(entry)
|
||||||
if not audio_url:
|
if not audio_url:
|
||||||
continue
|
continue
|
||||||
|
link = _entry_link(entry)
|
||||||
|
|
||||||
guid = entry.get('id') or entry.get('guid') or audio_url
|
guid = entry.get('id') or entry.get('guid') or audio_url
|
||||||
guid = str(guid)[:1000]
|
guid = str(guid)[:1000]
|
||||||
|
|
@ -737,7 +745,7 @@ def _apply_feed(feed_obj, fetched, full=False):
|
||||||
|
|
||||||
known = existing.get(guid)
|
known = existing.get(guid)
|
||||||
if known is not None:
|
if known is not None:
|
||||||
renamed = {'title': title, 'episode_number': ep_number}
|
renamed = {'title': title, 'episode_number': ep_number, 'link': link}
|
||||||
if pub_date is not None:
|
if pub_date is not None:
|
||||||
renamed['pub_date'] = pub_date
|
renamed['pub_date'] = pub_date
|
||||||
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
|
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
|
||||||
|
|
@ -762,6 +770,7 @@ def _apply_feed(feed_obj, fetched, full=False):
|
||||||
'title': title,
|
'title': title,
|
||||||
'description': description,
|
'description': description,
|
||||||
'audio_url': audio_url[:1000],
|
'audio_url': audio_url[:1000],
|
||||||
|
'link': link,
|
||||||
'duration_seconds': duration_seconds,
|
'duration_seconds': duration_seconds,
|
||||||
'pub_date': pub_date,
|
'pub_date': pub_date,
|
||||||
'episode_number': ep_number,
|
'episode_number': ep_number,
|
||||||
|
|
@ -1324,7 +1333,7 @@ def feed_episodes(request, pk):
|
||||||
|
|
||||||
episodes = list(
|
episodes = list(
|
||||||
feed.episodes.values(
|
feed.episodes.values(
|
||||||
'id', 'title', 'description', 'audio_url', 'duration_seconds',
|
'id', 'title', 'description', 'audio_url', 'link', 'duration_seconds',
|
||||||
'pub_date', 'artwork_url', 'episode_number', 'season_number',
|
'pub_date', 'artwork_url', 'episode_number', 'season_number',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -1424,6 +1433,9 @@ def episode_detail(request, pk):
|
||||||
chapters = _load_chapters(ep)
|
chapters = _load_chapters(ep)
|
||||||
|
|
||||||
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
|
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
|
||||||
|
job = transcription_mod.job_of(ep)
|
||||||
|
origin = _transcript_origin(ep, job)
|
||||||
|
article = EpisodeArticle.objects.filter(episode=ep).first()
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'id': ep.id,
|
'id': ep.id,
|
||||||
'title': ep.title,
|
'title': ep.title,
|
||||||
|
|
@ -1431,9 +1443,13 @@ def episode_detail(request, pk):
|
||||||
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
|
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
|
||||||
'chapters': chapters,
|
'chapters': chapters,
|
||||||
'transcripts': ep.transcripts,
|
'transcripts': ep.transcripts,
|
||||||
'has_transcript': _has_transcript(ep),
|
'has_transcript': origin is not None,
|
||||||
|
'transcript_origin': origin,
|
||||||
|
'article_item_id': article.item_id if article else None,
|
||||||
|
'transcription': transcription_mod.job_state(job) if job else None,
|
||||||
'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
|
'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
|
||||||
'audio_url': ep.audio_url,
|
'audio_url': ep.audio_url,
|
||||||
|
'link': ep.link,
|
||||||
'artwork_url': ep.artwork_url,
|
'artwork_url': ep.artwork_url,
|
||||||
'duration_seconds': ep.duration_seconds,
|
'duration_seconds': ep.duration_seconds,
|
||||||
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
|
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
|
||||||
|
|
@ -1461,6 +1477,13 @@ def _has_transcript(ep):
|
||||||
return not (cached and cached[0] == sources and cached[1])
|
return not (cached and cached[0] == sources and cached[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _transcript_origin(ep, job=None):
|
||||||
|
""""feed" when the feed has a usable transcript, else "whisper" when we made one, else None."""
|
||||||
|
if _has_transcript(ep):
|
||||||
|
return 'feed'
|
||||||
|
return 'whisper' if job is not None and job.state == 'done' and job.data else None
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@require_http_methods(['GET'])
|
@require_http_methods(['GET'])
|
||||||
def episode_silences(request, pk):
|
def episode_silences(request, pk):
|
||||||
|
|
@ -1503,20 +1526,28 @@ def episode_sponsor(request, pk):
|
||||||
def episode_transcript(request, pk):
|
def episode_transcript(request, pk):
|
||||||
"""The episode's transcript as {"segments": [{start, end, speaker, text}],
|
"""The episode's transcript as {"segments": [{start, end, speaker, text}],
|
||||||
"source": {url, type, language}}, fetched and parsed on first use, then
|
"source": {url, type, language}}, fetched and parsed on first use, then
|
||||||
kept. 404 {"error": "no transcript" | "unsupported" | "invalid"} when
|
kept. When the feed has none we can use, the one Whisper made (a finished
|
||||||
there's none to show; 502 {"error": "unavailable"} when it couldn't be
|
TranscriptionJob, docs/transcription.md). 404 {"error": "no transcript" |
|
||||||
loaded right now."""
|
"unsupported" | "invalid"} when there's none to show; 502
|
||||||
|
{"error": "unavailable"} when it couldn't be loaded right now."""
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
try:
|
try:
|
||||||
ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
|
ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
|
||||||
except PodcastEpisode.DoesNotExist:
|
except PodcastEpisode.DoesNotExist:
|
||||||
return JsonResponse({'error': 'not found'}, status=404)
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
def fallback(error, status=404):
|
||||||
|
job = transcription_mod.job_of(ep)
|
||||||
|
if job is not None and job.state == 'done' and job.data:
|
||||||
|
return JsonResponse(job.data)
|
||||||
|
return JsonResponse({'error': error}, status=status)
|
||||||
|
|
||||||
if not ep.transcripts:
|
if not ep.transcripts:
|
||||||
return JsonResponse({'error': 'no transcript'}, status=404)
|
return fallback('no transcript')
|
||||||
sources = _transcript_sources(ep)
|
sources = _transcript_sources(ep)
|
||||||
if not sources:
|
if not sources:
|
||||||
return JsonResponse({'error': 'unsupported'}, status=404)
|
return fallback('unsupported')
|
||||||
|
|
||||||
cached = EpisodeTranscript.objects.filter(episode=ep).first()
|
cached = EpisodeTranscript.objects.filter(episode=ep).first()
|
||||||
if cached is not None and cached.sources == sources:
|
if cached is not None and cached.sources == sources:
|
||||||
|
|
@ -1527,7 +1558,7 @@ def episode_transcript(request, pk):
|
||||||
error = ''
|
error = ''
|
||||||
except chapters_mod.FetchUnavailable as e:
|
except chapters_mod.FetchUnavailable as e:
|
||||||
log.info('transcript of episode %s not available: %s', ep.id, e)
|
log.info('transcript of episode %s not available: %s', ep.id, e)
|
||||||
return JsonResponse({'error': 'unavailable'}, status=502)
|
return fallback('unavailable', 502)
|
||||||
except transcripts_mod.Unsupported as e:
|
except transcripts_mod.Unsupported as e:
|
||||||
log.info('transcript of episode %s unsupported: %s', ep.id, e)
|
log.info('transcript of episode %s unsupported: %s', ep.id, e)
|
||||||
data, error, final = None, 'unsupported', True
|
data, error, final = None, 'unsupported', True
|
||||||
|
|
@ -1539,7 +1570,7 @@ def episode_transcript(request, pk):
|
||||||
episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
|
episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
|
||||||
)
|
)
|
||||||
if error:
|
if error:
|
||||||
return JsonResponse({'error': error}, status=404)
|
return fallback(error)
|
||||||
return JsonResponse(data)
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1645,7 +1676,7 @@ EPISODE_IDS_MAX = 1000
|
||||||
EPISODE_LIST_LIMIT = 100
|
EPISODE_LIST_LIMIT = 100
|
||||||
EPISODE_LIST_MAX = 500
|
EPISODE_LIST_MAX = 500
|
||||||
EPISODE_LIST_FIELDS = (
|
EPISODE_LIST_FIELDS = (
|
||||||
'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||||
'feed__id', 'feed__title', 'feed__artwork_url',
|
'feed__id', 'feed__title', 'feed__artwork_url',
|
||||||
'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at',
|
'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at',
|
||||||
)
|
)
|
||||||
|
|
@ -2010,7 +2041,7 @@ def _queue_data(user):
|
||||||
.select_related('episode__feed')
|
.select_related('episode__feed')
|
||||||
.values(
|
.values(
|
||||||
'id', 'position',
|
'id', 'position',
|
||||||
'episode__id', 'episode__title', 'episode__audio_url',
|
'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
|
||||||
'episode__duration_seconds', 'episode__artwork_url',
|
'episode__duration_seconds', 'episode__artwork_url',
|
||||||
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
||||||
'episode__pub_date',
|
'episode__pub_date',
|
||||||
|
|
@ -2506,7 +2537,7 @@ def inbox(request):
|
||||||
|
|
||||||
|
|
||||||
def _inbox_fields(with_description=True):
|
def _inbox_fields(with_description=True):
|
||||||
fields = ['id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
fields = ['id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||||
'feed__id', 'feed__title', 'feed__artwork_url']
|
'feed__id', 'feed__title', 'feed__artwork_url']
|
||||||
if with_description:
|
if with_description:
|
||||||
fields.insert(2, 'description')
|
fields.insert(2, 'description')
|
||||||
|
|
|
||||||
|
|
@ -734,6 +734,19 @@ html[data-theme="light"] .form-errors { background: #fdecee; border-color: #e7a3
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-alt { margin-top: 1rem; }
|
||||||
|
.auth-or {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
margin: 0 0 1rem; font-size: 0.8rem; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.auth-or::before, .auth-or::after { content: ''; flex: 1; border-top: 1px solid var(--border, #333); }
|
||||||
|
.auth-alt .btn-full { display: block; text-align: center; text-decoration: none; }
|
||||||
|
|
||||||
|
/* Unlocking the books on a device without the key (js/app/33-unlock.js) */
|
||||||
|
.unlock-code { font: 600 1.6rem/1.2 ui-monospace, monospace; letter-spacing: 0.15em; text-align: center; margin: 4px 0 14px; }
|
||||||
|
.unlock-or { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 8px; }
|
||||||
|
.unlock-status { font-size: 0.85rem; color: var(--text-muted); min-height: 1.2em; margin: 0 0 10px; }
|
||||||
|
|
||||||
.auth-switch {
|
.auth-switch {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|
@ -1792,14 +1805,25 @@ body.dnd-mode .timer-display {
|
||||||
.pdf-page-inner { position:relative; display:inline-block; line-height:0; }
|
.pdf-page-inner { position:relative; display:inline-block; line-height:0; }
|
||||||
|
|
||||||
/* PDF text layer — sits flush over the canvas inside pdf-page-inner */
|
/* PDF text layer — sits flush over the canvas inside pdf-page-inner */
|
||||||
.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }
|
.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; line-height:1; -webkit-text-size-adjust:none; text-size-adjust:none; z-index:1; }
|
||||||
.pdf-text-layer span { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; }
|
.pdf-text-layer span, .pdf-text-layer br { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; transform-origin:0 0; }
|
||||||
.pdf-text-layer span::selection { background:rgba(0,120,255,0.3); }
|
.pdf-text-layer span::selection, .pdf-text-layer mark::selection { background:rgba(0,120,255,0.3); }
|
||||||
.pdf-text-layer span.reader-search-match { background:rgba(241,196,15,.5); }
|
.pdf-text-layer br::selection { background:transparent; }
|
||||||
.pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); }
|
.pdf-text-layer mark.reader-search-match { color:transparent; background:rgba(241,196,15,.5); padding:0; border-radius:2px; }
|
||||||
|
.pdf-text-layer mark.reader-search-match.active { background:rgba(230,57,70,.6); }
|
||||||
|
/* PDF links — over the text, so a tap on one follows it */
|
||||||
|
.pdf-link-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:2; }
|
||||||
|
.pdf-link { position:absolute; display:block; pointer-events:auto; border-radius:2px; }
|
||||||
|
.pdf-link:hover, .pdf-link:focus-visible { background:rgba(0,120,255,0.15); outline:none; }
|
||||||
|
.pdf-back-btn { position:absolute; left:50%; bottom:calc(var(--bar-h, 0px) + 24px); transform:translateX(-50%); z-index:20; box-shadow:0 2px 8px rgba(0,0,0,.25); }
|
||||||
|
|
||||||
/* PDF invert */
|
/* PDF invert */
|
||||||
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); }
|
/* Dark pages: inverted, and the hues turned back, so photos and coloured figures aren't negatives. */
|
||||||
|
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1) hue-rotate(180deg); }
|
||||||
|
/* Leserfarben (58-pdf.js _pdfColorFilter): the page recoloured, its pictures over it as printed */
|
||||||
|
.pdf-page.pdf-recolored { filter:url(#pdf-reader-colors); }
|
||||||
|
.pdf-image-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }
|
||||||
|
.pdf-image-layer canvas { position:absolute; display:block; }
|
||||||
|
|
||||||
/* ---- Immersive reader mode ---- */
|
/* ---- Immersive reader mode ---- */
|
||||||
.navbar { transition: transform 0.35s ease; }
|
.navbar { transition: transform 0.35s ease; }
|
||||||
|
|
@ -1824,8 +1848,17 @@ html.nui-tts-on body.reader-immersive .now-playing-bar { transform: none; }
|
||||||
html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); }
|
html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); }
|
||||||
|
|
||||||
/* PDF paginated */
|
/* PDF paginated */
|
||||||
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
|
/* Page by page the page fills the whole reading area, without its padding: 66-pdf-paginated.js
|
||||||
|
measures the area and places the page by its text box itself. */
|
||||||
|
.reader-content.pdf-paginated { overflow:hidden !important; display:block; padding:0 !important; }
|
||||||
|
/* The pages are as wide as they're drawn, not the text column's line length (--reader-max-width);
|
||||||
|
zoomed wider than the screen, scrolling reaches their left edge too (not centred past it). */
|
||||||
|
.reader-content > #pdf-viewport { max-width:none; }
|
||||||
|
.reader-content:not(.pdf-paginated) > #pdf-viewport { width:max-content; min-width:100%; }
|
||||||
|
.reader-content.pdf-paginated > #pdf-viewport { width:100%; height:100%; max-width:none; margin:0; }
|
||||||
.pdf-paginated .pdf-page-wrapper { margin:0; }
|
.pdf-paginated .pdf-page-wrapper { margin:0; }
|
||||||
|
.pdf-paginated .pdf-page-wrapper { position:relative; }
|
||||||
|
.pdf-paginated .pdf-page-inner { position:absolute; left:0; top:0; }
|
||||||
|
|
||||||
/* PDF loading overlay */
|
/* PDF loading overlay */
|
||||||
.pdf-loading-overlay {
|
.pdf-loading-overlay {
|
||||||
|
|
@ -1866,6 +1899,57 @@ html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); }
|
||||||
.footnote-popover.reader-theme-sepia { background:#ede0c4; color:#3b2a1a; border-color:#c8b89a; }
|
.footnote-popover.reader-theme-sepia { background:#ede0c4; color:#3b2a1a; border-color:#c8b89a; }
|
||||||
.footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; }
|
.footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; }
|
||||||
.footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; }
|
.footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; }
|
||||||
|
.footnote-popover > :first-child { margin-top:0; }
|
||||||
|
.footnote-popover > :last-child { margin-bottom:0; }
|
||||||
|
|
||||||
|
/* ---- Reading in pages (63-pages.js) ----
|
||||||
|
Only the current chapter is laid out; the others stay in the DOM (block
|
||||||
|
indices, search, notes, anchors). No free scrolling: the pager moves. */
|
||||||
|
#reader-content.nui-window > [data-epub-src]:not(.nui-ch-current) { display:none; }
|
||||||
|
/* Room below the chapter, so its last page can show just the remaining text
|
||||||
|
(then the page's colour) instead of scrolling back over lines already read. */
|
||||||
|
#reader-content.nui-window::after { content:""; display:block; height:100vh; }
|
||||||
|
#reader-content.nui-window { overflow-y:hidden; -ms-overflow-style:none; scrollbar-width:none; }
|
||||||
|
#reader-content.nui-window::-webkit-scrollbar { display:none; }
|
||||||
|
/* Masks: the line cut at the page's bottom and whatever sits in the top gap. */
|
||||||
|
.nui-page-mask { position:fixed; z-index:250; pointer-events:none; display:none; }
|
||||||
|
/* Footnotes at the foot of the page, like a printed book. */
|
||||||
|
.nui-page-notes { position:fixed; z-index:255; overflow:hidden; padding:0 0 4px; box-sizing:border-box;
|
||||||
|
line-height:1.4; font-weight:400; cursor:pointer; }
|
||||||
|
.nui-pn-rule { width:30%; border-top:1px solid currentColor; margin:2px 0 6px; }
|
||||||
|
.nui-pn-item { margin:0 0 4px; padding-left:1.6em; text-indent:-1.6em; }
|
||||||
|
.nui-pn-item p, .nui-pn-item div, .nui-pn-item aside, .nui-pn-item li { display:inline; margin:0; padding:0; text-indent:0; }
|
||||||
|
.nui-pn-item p + p::before { content:" "; }
|
||||||
|
.nui-pn-label { display:inline-block; min-width:1.6em; text-indent:0; font-weight:700; }
|
||||||
|
.nui-pn-item a { color:inherit; text-decoration:none; }
|
||||||
|
.nui-pn-item img { display:none; }
|
||||||
|
/* Cut off: say so, a tap shows the whole note. */
|
||||||
|
.nui-page-notes.nui-pn-cut::after { content:"… mehr"; position:absolute; right:0; bottom:2px; padding-left:8px;
|
||||||
|
background:inherit; font-weight:700; }
|
||||||
|
/* Two pages side by side (a spread, 63-pages.js): the right one is a copy of
|
||||||
|
the chapter, scrolled to the page after the left one. */
|
||||||
|
#reader-spread-page { display:none; }
|
||||||
|
#reader-overlay.reader-spread #reader-content,
|
||||||
|
#reader-overlay.reader-spread #reader-spread-page { flex:1 1 0; min-width:0; }
|
||||||
|
#reader-overlay.reader-spread #reader-spread-page { display:block; overflow:hidden; box-shadow:inset 1px 0 0 var(--border);
|
||||||
|
user-select:none; -webkit-user-select:none; cursor:default; }
|
||||||
|
#reader-spread-page::after { content:""; display:block; height:100vh; }
|
||||||
|
/* An article in the reader (65-article.js): what only books have stays out. */
|
||||||
|
#reader-overlay.reader-article-mode .reader-progress-wrap,
|
||||||
|
#reader-overlay.reader-article-mode #reader-bookmark-btn,
|
||||||
|
#reader-overlay.reader-article-mode #reader-bm-list-btn,
|
||||||
|
#reader-overlay.reader-article-mode #reader-toc-btn,
|
||||||
|
#reader-overlay.reader-article-mode #reader-margin-btn,
|
||||||
|
#reader-overlay.reader-article-mode #reader-reset-pos-btn,
|
||||||
|
#reader-overlay.reader-article-mode .marker-mode-btn,
|
||||||
|
#reader-overlay.reader-article-mode #reader-margin,
|
||||||
|
#reader-overlay.reader-article-mode .nui-toc { display:none !important; }
|
||||||
|
.reader-article-actions { display:inline-flex; gap:10px; align-items:center; }
|
||||||
|
/* The browser's line under the page (the apps have their own footer). */
|
||||||
|
#reader-page-footer { display:flex; justify-content:space-between; gap:12px; flex-shrink:0; padding:4px 16px 6px;
|
||||||
|
font-size:12px; color:var(--muted,#888); font-variant-numeric:tabular-nums; }
|
||||||
|
.reader-theme-sepia #reader-page-footer { background:#f5e6c8; color:#7a6650; }
|
||||||
|
.reader-theme-bright #reader-page-footer { background:#fff; color:#777; }
|
||||||
|
|
||||||
/* Page numbers of the printed edition (epub:type="pagebreak") */
|
/* Page numbers of the printed edition (epub:type="pagebreak") */
|
||||||
.reader-content .epub-pagenum { display:inline-block; font-size:0.62em; line-height:1.3; font-weight:400; font-style:normal; letter-spacing:0; text-transform:none; vertical-align:0.35em; padding:0 0.35em; margin-right:0.4em; border:1px solid currentColor; border-radius:3px; opacity:0.45; user-select:none; }
|
.reader-content .epub-pagenum { display:inline-block; font-size:0.62em; line-height:1.3; font-weight:400; font-style:normal; letter-spacing:0; text-transform:none; vertical-align:0.35em; padding:0 0.35em; margin-right:0.4em; border:1px solid currentColor; border-radius:3px; opacity:0.45; user-select:none; }
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
|
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
|
||||||
// authors, folder names …). Nothing is changed without the user ticking it in review().
|
// authors, folder names …). Nothing is changed without the user ticking it in review().
|
||||||
//
|
//
|
||||||
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema}), review(…)}
|
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema, effort, maxTokens, onProgress, signal}), review(…)}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
|
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
|
||||||
|
|
@ -116,12 +116,17 @@ function parseAnswer(text) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Words in a text so far (for onProgress).
|
||||||
|
const countWords = t => { const m = String(t || '').match(/\S+/g); return m ? m.length : 0; };
|
||||||
|
|
||||||
// One request with a JSON answer of `schema` (structured outputs).
|
// One request with a JSON answer of `schema` (structured outputs).
|
||||||
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
|
// Optional: onProgress(words) while the answer is written (throttled; OpenRouter is not streamed,
|
||||||
|
// it reports once at the end), signal (an AbortSignal) to give up.
|
||||||
|
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000, onProgress, signal}) {
|
||||||
const v = await loadVault();
|
const v = await loadVault();
|
||||||
const key = keyOf(v);
|
const key = keyOf(v);
|
||||||
if (!key) throw fail('Kein API-Key hinterlegt.');
|
if (!key) throw fail('Kein API-Key hinterlegt.');
|
||||||
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens});
|
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal});
|
||||||
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
|
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
|
||||||
const stream = client(key).beta.messages.stream({
|
const stream = client(key).beta.messages.stream({
|
||||||
model: PROVIDERS.anthropic.model,
|
model: PROVIDERS.anthropic.model,
|
||||||
|
|
@ -131,8 +136,18 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000})
|
||||||
system,
|
system,
|
||||||
messages: [{role: 'user', content: prompt}],
|
messages: [{role: 'user', content: prompt}],
|
||||||
output_config: {effort, format: {type: 'json_schema', schema}},
|
output_config: {effort, format: {type: 'json_schema', schema}},
|
||||||
|
}, signal ? {signal} : undefined);
|
||||||
|
if (onProgress) {
|
||||||
|
let last = 0;
|
||||||
|
stream.on('text', (delta, snapshot) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - last < 400) return;
|
||||||
|
last = now;
|
||||||
|
try { onProgress(countWords(snapshot)); } catch (e) { /* the caller's display must not break the call */ }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
const msg = await stream.finalMessage();
|
const msg = await stream.finalMessage();
|
||||||
|
if (onProgress) { try { onProgress(countWords(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''))); } catch (e) { /* see above */ } }
|
||||||
if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.');
|
if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.');
|
||||||
if (msg.stop_reason === 'max_tokens') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
|
if (msg.stop_reason === 'max_tokens') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
|
||||||
return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''));
|
return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''));
|
||||||
|
|
@ -149,8 +164,8 @@ async function openRouterFetch(key, path, init) {
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
|
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}) {
|
||||||
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', body: JSON.stringify({
|
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', signal, body: JSON.stringify({
|
||||||
model: PROVIDERS.openrouter.model,
|
model: PROVIDERS.openrouter.model,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
reasoning: {effort},
|
reasoning: {effort},
|
||||||
|
|
@ -161,7 +176,9 @@ async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
|
||||||
if (!choice) throw fail('Die Antwort ließ sich nicht lesen.');
|
if (!choice) throw fail('Die Antwort ließ sich nicht lesen.');
|
||||||
if (choice.message && choice.message.refusal) throw fail('Claude hat diese Anfrage abgelehnt.');
|
if (choice.message && choice.message.refusal) throw fail('Claude hat diese Anfrage abgelehnt.');
|
||||||
if (choice.finish_reason === 'length') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
|
if (choice.finish_reason === 'length') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
|
||||||
return parseAnswer((choice.message && choice.message.content) || '');
|
const text = (choice.message && choice.message.content) || '';
|
||||||
|
if (onProgress) { try { onProgress(countWords(text)); } catch (e) { /* the caller's display must not break the call */ } }
|
||||||
|
return parseAnswer(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.)
|
// Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.)
|
||||||
|
|
@ -361,6 +378,8 @@ window.__dioraBack = function () {
|
||||||
return back ? back.apply(this, arguments) : false;
|
return back ? back.apply(this, arguments) : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), settings, withKey, ask, review, progress, showError,
|
const modelName = () => PROVIDERS[providerOf(vault)].model;
|
||||||
|
|
||||||
|
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), modelName, settings, withKey, ask, review, progress, showError,
|
||||||
close, toast, explain};
|
close, toast, explain};
|
||||||
window.dispatchEvent(new Event('diora-ai'));
|
window.dispatchEvent(new Event('diora-ai'));
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,12 @@ document.addEventListener('keydown', e => {
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); }
|
if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); }
|
||||||
if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); }
|
if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); }
|
||||||
}
|
}
|
||||||
if (readerSettings.pdfPaginated && currentPdfDoc) {
|
// PDF page by page: arrows, Space, PageUp/PageDown turn (scrolling has its own keys below).
|
||||||
if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); }
|
if (readerSettings.pdfPaginated && currentPdfDoc && !readerSearchOpen
|
||||||
if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); }
|
&& !/^(INPUT|TEXTAREA|SELECT)$/.test((document.activeElement && document.activeElement.tagName) || '')) {
|
||||||
|
const turn = {ArrowRight: 1, ArrowDown: 1, PageDown: 1, ' ': e.shiftKey ? -1 : 1,
|
||||||
|
ArrowLeft: -1, ArrowUp: -1, PageUp: -1}[e.key];
|
||||||
|
if (turn) { e.preventDefault(); pdfGoToPage(pdfCurrentPage + turn); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vim-style scroll — ignore when typing in an input
|
// Vim-style scroll — ignore when typing in an input
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,19 @@ async function getOrCreateEncKey() {
|
||||||
return _encKey;
|
return _encKey;
|
||||||
} catch (e) { /* fall through, generate new */ }
|
} catch (e) { /* fall through, generate new */ }
|
||||||
}
|
}
|
||||||
// No key found — generate one and store it
|
// Just signed in with the password: the login form left the derived key (99-init.js
|
||||||
_encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
// moves it too, but this may run first).
|
||||||
const raw = await crypto.subtle.exportKey('raw', _encKey);
|
const pending = window.USER_ID ? localStorage.getItem('diora_pending_enc_key') : null;
|
||||||
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
|
if (pending) {
|
||||||
|
localStorage.setItem(storageKey, pending);
|
||||||
|
localStorage.removeItem('diora_pending_enc_key');
|
||||||
|
return getOrCreateEncKey();
|
||||||
|
}
|
||||||
|
// No key on this device (signed in through Authelia, or the storage was cleared): never
|
||||||
|
// make a new one — that would lock the existing books away. Ask for the real one
|
||||||
|
// (33-unlock.js); throws DioraLocked when the user puts it off.
|
||||||
|
const raw = await dioraUnlock();
|
||||||
|
_encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
||||||
return _encKey;
|
return _encKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
254
static/js/app/33-unlock.js
Normal file
254
static/js/app/33-unlock.js
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unlocking the books on a device without the key (accounts/keyshare.py)
|
||||||
|
//
|
||||||
|
// A password login derives the book key on the device (login.html). A device signed in
|
||||||
|
// another way (Authelia, accounts/oidc.py) has none: getOrCreateEncKey() then asks here
|
||||||
|
// instead of making a new key. Two ways in:
|
||||||
|
// - an unlocked device of the account hands it over: this device posts an ECDH P-256
|
||||||
|
// public key, the other one shows the same 6-digit code (made from that key), the user
|
||||||
|
// approves there and the book key comes back encrypted for this device only;
|
||||||
|
// - or the diora password, the same derivation as at login.
|
||||||
|
// The server only ever sees public keys and ciphertext.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const KEYSHARE_INFO = new TextEncoder().encode('diora keyshare v1');
|
||||||
|
let _unlockPromise = null;
|
||||||
|
let _unlockDismissed = false;
|
||||||
|
|
||||||
|
function dioraLockedError() {
|
||||||
|
const e = new Error('Bücher sind auf diesem Gerät noch nicht entsperrt');
|
||||||
|
e.name = 'DioraLocked';
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _csrf() {
|
||||||
|
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _ksFetch(url, body) {
|
||||||
|
const opts = body === undefined ? {credentials: 'same-origin'} : {
|
||||||
|
method: 'POST', credentials: 'same-origin',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': _csrf()},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6-digit code from the asking device's public key, shown on both devices.
|
||||||
|
async function keyshareCode(pubB64) {
|
||||||
|
const h = new Uint8Array(await crypto.subtle.digest('SHA-256', base64ToBytes(pubB64)));
|
||||||
|
const n = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0;
|
||||||
|
const s = String(n % 1000000).padStart(6, '0');
|
||||||
|
return s.slice(0, 3) + ' ' + s.slice(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _sharedAesKey(privateKey, otherPubB64) {
|
||||||
|
const otherPub = await crypto.subtle.importKey('raw', base64ToBytes(otherPubB64),
|
||||||
|
{name: 'ECDH', namedCurve: 'P-256'}, false, []);
|
||||||
|
const bits = await crypto.subtle.deriveBits({name: 'ECDH', public: otherPub}, privateKey, 256);
|
||||||
|
const hk = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
|
||||||
|
return crypto.subtle.deriveKey({name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: KEYSHARE_INFO},
|
||||||
|
hk, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _deviceName() {
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
const app = /diora-desktop|Electron/i.test(ua) ? 'diora Desktop'
|
||||||
|
: /; wv\)|diora/i.test(ua) && /Android/i.test(ua) ? 'diora App'
|
||||||
|
: /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge'
|
||||||
|
: /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
|
||||||
|
const os = /Android/i.test(ua) ? 'Android' : /iPhone|iPad/i.test(ua) ? 'iOS'
|
||||||
|
: /Windows/i.test(ua) ? 'Windows' : /Mac OS/i.test(ua) ? 'macOS' : /Linux/i.test(ua) ? 'Linux' : '';
|
||||||
|
return os ? `${app} auf ${os}` : app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this raw key the account's book key? Tried on one book's metadata; without books
|
||||||
|
// there is nothing to compare with, so it is taken.
|
||||||
|
async function _keyFits(raw) {
|
||||||
|
let books = [];
|
||||||
|
try { books = await _ksFetch('/books/'); } catch (e) { return true; }
|
||||||
|
const b = Array.isArray(books) ? books.find(x => x && x.meta_ct && x.meta_iv) : null;
|
||||||
|
if (!b) return true;
|
||||||
|
try {
|
||||||
|
const key = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['decrypt']);
|
||||||
|
await decryptBytes(key, b.meta_iv, b.meta_ct);
|
||||||
|
return true;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _storeRawKey(raw) {
|
||||||
|
localStorage.setItem(`diora_enc_key_${window.USER_ID || 0}`, bytesToBase64(new Uint8Array(raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _el(tag, attrs, text) {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
for (const k in (attrs || {})) el.setAttribute(k, attrs[k]);
|
||||||
|
if (text) el.textContent = text;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _openDialog(build) {
|
||||||
|
const overlay = _el('div', {class: 'modal-overlay'});
|
||||||
|
const dialog = _el('div', {class: 'modal-dialog', role: 'dialog', 'aria-modal': 'true'});
|
||||||
|
document.body.append(overlay, dialog);
|
||||||
|
const close = () => { overlay.remove(); dialog.remove(); };
|
||||||
|
build(dialog, close);
|
||||||
|
return close;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask for the key. Resolves with the raw key (already stored), rejects with DioraLocked.
|
||||||
|
function dioraUnlock(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (_unlockPromise) return _unlockPromise;
|
||||||
|
if (_unlockDismissed && !opts.force) return Promise.reject(dioraLockedError());
|
||||||
|
_unlockPromise = new Promise((resolve, reject) => {
|
||||||
|
let stopped = false;
|
||||||
|
let pair = null, token = null;
|
||||||
|
const finish = (raw) => {
|
||||||
|
stopped = true; close(); _unlockPromise = null; _unlockDismissed = false;
|
||||||
|
_storeRawKey(raw);
|
||||||
|
resolve(raw);
|
||||||
|
document.dispatchEvent(new CustomEvent('diora:unlocked'));
|
||||||
|
};
|
||||||
|
const close = _openDialog((d, closeFn) => {
|
||||||
|
d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Bücher entsperren'));
|
||||||
|
d.append(_el('p', {class: 'modal-message'},
|
||||||
|
'Deine Bücher sind mit deinem diora-Passwort verschlüsselt. Öffne diora auf einem Gerät, '
|
||||||
|
+ 'auf dem sie schon offen sind, und bestätige dort diesen Code:'));
|
||||||
|
const code = _el('div', {class: 'unlock-code'}, '··· ···');
|
||||||
|
const status = _el('p', {class: 'unlock-status'}, 'Frage an deine anderen Geräte …');
|
||||||
|
d.append(code, status);
|
||||||
|
d.append(_el('p', {class: 'unlock-or'}, 'Oder gib hier dein diora-Passwort ein:'));
|
||||||
|
const pw = _el('input', {type: 'password', class: 'modal-input', autocomplete: 'current-password',
|
||||||
|
placeholder: 'diora-Passwort'});
|
||||||
|
d.append(pw);
|
||||||
|
const actions = _el('div', {class: 'modal-actions'});
|
||||||
|
const later = _el('button', {class: 'btn', type: 'button'}, 'Später');
|
||||||
|
const ok = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Entsperren');
|
||||||
|
actions.append(later, ok);
|
||||||
|
d.append(actions);
|
||||||
|
|
||||||
|
const tryPassword = async () => {
|
||||||
|
if (!pw.value) { pw.focus(); return; }
|
||||||
|
ok.disabled = true;
|
||||||
|
status.textContent = 'Schlüssel wird abgeleitet …';
|
||||||
|
try {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const username = document.querySelector('meta[name="username"]')?.content || '';
|
||||||
|
const mat = await crypto.subtle.importKey('raw', enc.encode(pw.value), 'PBKDF2', false, ['deriveKey']);
|
||||||
|
const key = await crypto.subtle.deriveKey(
|
||||||
|
{name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'},
|
||||||
|
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||||||
|
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key));
|
||||||
|
if (!(await _keyFits(raw))) { status.textContent = 'Das Passwort passt nicht zu deinen Büchern.'; ok.disabled = false; return; }
|
||||||
|
finish(raw);
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = 'Fehler: ' + e.message; ok.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ok.addEventListener('click', tryPassword);
|
||||||
|
pw.addEventListener('keydown', (e) => { if (e.key === 'Enter') tryPassword(); });
|
||||||
|
later.addEventListener('click', () => {
|
||||||
|
stopped = true; closeFn(); _unlockPromise = null; _unlockDismissed = true;
|
||||||
|
reject(dioraLockedError());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ask the other devices.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']);
|
||||||
|
const pub = bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)));
|
||||||
|
token = (await _ksFetch('/accounts/keyshare/', {pub, device: _deviceName()})).token;
|
||||||
|
code.textContent = await keyshareCode(pub);
|
||||||
|
status.textContent = 'Warte auf Freigabe …';
|
||||||
|
while (!stopped) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
if (stopped) break;
|
||||||
|
let st;
|
||||||
|
try { st = await _ksFetch(`/accounts/keyshare/${token}/`); } catch (e) { continue; }
|
||||||
|
if (st.state === 'pending') continue;
|
||||||
|
if (st.state === 'declined') { status.textContent = 'Auf dem anderen Gerät abgelehnt.'; break; }
|
||||||
|
if (st.state === 'expired') { status.textContent = 'Die Anfrage ist abgelaufen. Lade die Seite neu, um es noch einmal zu versuchen.'; break; }
|
||||||
|
const aes = await _sharedAesKey(pair.privateKey, st.pub);
|
||||||
|
const raw = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: base64ToBytes(st.iv)}, aes, base64ToBytes(st.ct)));
|
||||||
|
if (raw.length !== 32 || !(await _keyFits(raw))) { status.textContent = 'Der übergebene Schlüssel passt nicht.'; break; }
|
||||||
|
finish(raw);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!stopped) status.textContent = 'Übergabe nicht möglich (' + e.message + '). Das Passwort geht immer.';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return _unlockPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The other side: an unlocked device approves new ones -------------------------------
|
||||||
|
|
||||||
|
const _seenShares = new Set();
|
||||||
|
let _approving = false;
|
||||||
|
|
||||||
|
async function _approveShare(req, rawKey) {
|
||||||
|
const codeText = await keyshareCode(req.pub);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
_openDialog((d, close) => {
|
||||||
|
d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Neues Gerät entsperren?'));
|
||||||
|
d.append(_el('p', {class: 'modal-message'},
|
||||||
|
`„${req.device || 'Ein Gerät'}“ möchte deine Bücher öffnen. Gib nur frei, wenn dort derselbe Code steht:`));
|
||||||
|
d.append(_el('div', {class: 'unlock-code'}, codeText));
|
||||||
|
const status = _el('p', {class: 'unlock-status'});
|
||||||
|
d.append(status);
|
||||||
|
const actions = _el('div', {class: 'modal-actions'});
|
||||||
|
const no = _el('button', {class: 'btn', type: 'button'}, 'Ablehnen');
|
||||||
|
const yes = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Freigeben');
|
||||||
|
actions.append(no, yes);
|
||||||
|
d.append(actions);
|
||||||
|
no.addEventListener('click', async () => {
|
||||||
|
try { await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, {decline: true}); } catch (e) {}
|
||||||
|
close(); resolve(false);
|
||||||
|
});
|
||||||
|
yes.addEventListener('click', async () => {
|
||||||
|
yes.disabled = no.disabled = true;
|
||||||
|
try {
|
||||||
|
const pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']);
|
||||||
|
const aes = await _sharedAesKey(pair.privateKey, req.pub);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aes, rawKey);
|
||||||
|
await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, {
|
||||||
|
pub: bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))),
|
||||||
|
iv: bytesToBase64(iv), ct: bytesToBase64(new Uint8Array(ct)),
|
||||||
|
});
|
||||||
|
close(); resolve(true);
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = 'Freigabe fehlgeschlagen: ' + e.message;
|
||||||
|
yes.disabled = no.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkKeyShareRequests() {
|
||||||
|
if (_approving || !window.USER_ID || document.hidden) return;
|
||||||
|
const stored = localStorage.getItem(`diora_enc_key_${window.USER_ID}`);
|
||||||
|
if (!stored) return;
|
||||||
|
let data;
|
||||||
|
try { data = await _ksFetch('/accounts/keyshare/pending/'); } catch (e) { return; }
|
||||||
|
const req = (data.requests || []).find(r => !_seenShares.has(r.token));
|
||||||
|
if (!req) return;
|
||||||
|
_seenShares.add(req.token);
|
||||||
|
_approving = true;
|
||||||
|
try { await _approveShare(req, base64ToBytes(stored)); } finally { _approving = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
if (!window.USER_ID || !window.crypto || !crypto.subtle) return;
|
||||||
|
setTimeout(checkKeyShareRequests, 2000);
|
||||||
|
setInterval(checkKeyShareRequests, 15000);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) checkKeyShareRequests(); });
|
||||||
|
})();
|
||||||
|
|
@ -106,10 +106,12 @@ async function parseEpub(arrayBuffer) {
|
||||||
// spine text by CHARS_PER_BOOK_PAGE (as the reader estimates it). The library's length sorts.
|
// spine text by CHARS_PER_BOOK_PAGE (as the reader estimates it). The library's length sorts.
|
||||||
async function bookPagesOfFile(buf, type) {
|
async function bookPagesOfFile(buf, type) {
|
||||||
if (type === 'pdf') {
|
if (type === 'pdf') {
|
||||||
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(buf)}).promise;
|
const pdf = await _pdfLoad(new Uint8Array(buf)).promise;
|
||||||
const n = pdf.numPages;
|
try {
|
||||||
pdf.destroy();
|
return pdf.numPages;
|
||||||
return n;
|
} finally {
|
||||||
|
try { pdf.destroy(); } catch (e) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const zip = await JSZip.loadAsync(buf);
|
const zip = await JSZip.loadAsync(buf);
|
||||||
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
||||||
|
|
@ -228,14 +230,41 @@ function _injectImageBlobs(html, chapterDir, imageMap) {
|
||||||
// Calibre/zeno.org conversions) use neither and just wrap a plain link to
|
// Calibre/zeno.org conversions) use neither and just wrap a plain link to
|
||||||
// another chapter file's fragment in a <sup> — in either direction
|
// another chapter file's fragment in a <sup> — in either direction
|
||||||
// (<sup><a>…</a></sup> or <a><sup>…</sup></a>). Recognize all of these
|
// (<sup><a>…</a></sup> or <a><sup>…</sup></a>). Recognize all of these
|
||||||
// rather than only the single hardcoded class name.
|
// rather than only the single hardcoded class name. Also role="doc-noteref",
|
||||||
|
// more note classes, and a bare note number as the link's text ("3", "[12]", "*").
|
||||||
function _looksLikeFootnoteLink(el, href) {
|
function _looksLikeFootnoteLink(el, href) {
|
||||||
if (!href.includes('#')) return false;
|
if (!href.includes('#')) return false;
|
||||||
const cls = (el.getAttribute('class') || '').toLowerCase();
|
const cls = (el.getAttribute('class') || '').toLowerCase();
|
||||||
if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true;
|
if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true;
|
||||||
|
if (/\b(fnref|noteref|footnote-?ref|endnote-?ref)\b/.test(cls)) return true;
|
||||||
const epubType = (el.getAttribute('epub:type') || '').toLowerCase();
|
const epubType = (el.getAttribute('epub:type') || '').toLowerCase();
|
||||||
if (epubType.includes('noteref')) return true;
|
if (epubType.includes('noteref')) return true;
|
||||||
return !!(el.closest('sup') || el.querySelector('sup'));
|
if ((el.getAttribute('role') || '').toLowerCase() === 'doc-noteref') return true;
|
||||||
|
if (el.closest('sup') || el.querySelector('sup')) return true;
|
||||||
|
return /^\s*[\[(]?\s*(\d{1,4}|[ivxlc]{1,6}|[a-z]|[*†‡§¶]+)\s*[\])]?\s*$/i.test(el.textContent || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The number at the start of a note that links back to the text ("3 Endnote…")
|
||||||
|
// is a way back, not a reference to another note.
|
||||||
|
const _NOTE_BOX_SELECTOR = '.footnote, .endnote, aside, [role="doc-footnote"], [role="doc-endnote"], ' +
|
||||||
|
'[epub\\:type~="footnote"], [epub\\:type~="endnote"], [epub\\:type~="rearnote"]';
|
||||||
|
function _isNoteBacklink(el) {
|
||||||
|
const role = (el.getAttribute('role') || '').toLowerCase();
|
||||||
|
const type = (el.getAttribute('epub:type') || '').toLowerCase();
|
||||||
|
if (role === 'doc-backlink' || /backlink/.test(type)) return true;
|
||||||
|
// Inside a note's box. Not the link itself: a reference can carry the class
|
||||||
|
// "footnote" too (<a class="footnote" href="#ftn.fn1">, Suhrkamp/DocBook).
|
||||||
|
if (el.parentElement && el.parentElement.closest(_NOTE_BOX_SELECTOR)) return true;
|
||||||
|
const block = el.closest('p, li, div, dd');
|
||||||
|
if (!block) return false;
|
||||||
|
// A note: the block is a link target itself, or holds an empty anchor that is
|
||||||
|
// (zeno.org / e-artnow: <p><a id="F258"></a><a href="…#N258">1</a> Note…</p>).
|
||||||
|
const anchored = block.id || el.id || Array.prototype.some.call(block.querySelectorAll('[id]'),
|
||||||
|
x => x !== el && !(x.textContent || '').trim());
|
||||||
|
if (!anchored) return false;
|
||||||
|
const text = (block.textContent || '').trim();
|
||||||
|
const firstLink = Array.prototype.find.call(block.querySelectorAll('a'), a => (a.textContent || '').trim());
|
||||||
|
return firstLink === el && text.startsWith((el.textContent || '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML
|
// Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML
|
||||||
|
|
@ -272,15 +301,18 @@ function sanitizeEpubHtml(html) {
|
||||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||||
el.setAttribute('target', '_blank');
|
el.setAttribute('target', '_blank');
|
||||||
el.setAttribute('rel', 'noopener noreferrer');
|
el.setAttribute('rel', 'noopener noreferrer');
|
||||||
} else if (_looksLikeFootnoteLink(el, href)) {
|
} else if (!(href.includes('#') && _isNoteBacklink(el)) && _looksLikeFootnoteLink(el, href)) {
|
||||||
// The whole book is one concatenated DOM, so a fragment id resolves
|
// data-footnote-ref (the id) marks the link as a reference; the href
|
||||||
// via getElementById regardless of which chapter file it originally
|
// as written (data-footnote-href) finds the note in the chapter file it
|
||||||
// pointed into — strip any "../Text/other.html" path prefix and keep
|
// points into (78-footnotes.js) — ids like "fn1" repeat across files.
|
||||||
// just the id after the last '#'.
|
|
||||||
el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1);
|
el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1);
|
||||||
|
el.dataset.footnoteHref = href;
|
||||||
el.removeAttribute('href');
|
el.removeAttribute('href');
|
||||||
el.style.cursor = 'pointer';
|
el.style.cursor = 'pointer';
|
||||||
} else {
|
} else {
|
||||||
|
// A link within the book (a cross-reference, a note's way back): kept
|
||||||
|
// for the apps, which follow it (diora-android native-ui.js).
|
||||||
|
if (href && !/^[a-z]+:/i.test(href)) el.dataset.internalHref = href;
|
||||||
el.removeAttribute('href');
|
el.removeAttribute('href');
|
||||||
el.style.cursor = 'default';
|
el.style.cursor = 'default';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ let _parseCodeSig = null;
|
||||||
|
|
||||||
function _parsePreparationSig() {
|
function _parsePreparationSig() {
|
||||||
if (_parseCodeSig) return _parseCodeSig;
|
if (_parseCodeSig) return _parseCodeSig;
|
||||||
// window.__dioraPrepareSig: what an app layer adds to the preparation (the
|
// window.__dioraPrepareSig: what an app layer adds to the preparation — a
|
||||||
// footnote detection in diora-android's native-ui.js) — a change there too.
|
// change there too.
|
||||||
const src = [parseEpub, sanitizeEpubHtml, _injectImageBlobs, _resolveImageBlob, _parseEpubToc]
|
const src = [parseEpub, sanitizeEpubHtml, _looksLikeFootnoteLink, _isNoteBacklink, _injectImageBlobs, _resolveImageBlob, _parseEpubToc]
|
||||||
.map(f => String(f)).join('\n') + (window.__dioraPrepareSig || '');
|
.map(f => String(f)).join('\n') + (window.__dioraPrepareSig || '');
|
||||||
let h = 5381;
|
let h = 5381;
|
||||||
for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0;
|
for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// Reader settings
|
// Reader settings
|
||||||
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
|
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
|
||||||
fontFamily: 'serif', noBold: false, fontWeight: null,
|
fontFamily: 'serif', noBold: false, fontWeight: null,
|
||||||
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false };
|
pdfZoom: 100, pdfInverted: false, pdfPageColors: false, pdfPaginated: false, pdfSpread: false };
|
||||||
let readerSettingsPanelOpen = false;
|
let readerSettingsPanelOpen = false;
|
||||||
let currentPdfDoc = null;
|
let currentPdfDoc = null;
|
||||||
let currentPdfBuffer = null;
|
let currentPdfBuffer = null;
|
||||||
|
|
@ -45,9 +45,6 @@ function _readerScrollTick() {
|
||||||
_readerScrollLastTs = now;
|
_readerScrollLastTs = now;
|
||||||
$('reader-content')?.scrollBy({top: Math.round(_readerScrollDir * READER_SCROLL_SPEED * dt / 1000)});
|
$('reader-content')?.scrollBy({top: Math.round(_readerScrollDir * READER_SCROLL_SPEED * dt / 1000)});
|
||||||
}
|
}
|
||||||
let _pinchStartDist = 0;
|
|
||||||
let _pinchStartZoom = 100;
|
|
||||||
let _isPinching = false;
|
|
||||||
|
|
||||||
if (typeof pdfjsLib !== 'undefined') {
|
if (typeof pdfjsLib !== 'undefined') {
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,13 @@ async function loadBookList() {
|
||||||
try {
|
try {
|
||||||
key = await getOrCreateEncKey();
|
key = await getOrCreateEncKey();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.name === 'DioraLocked') {
|
||||||
|
listEl.innerHTML = '<p class="muted">Deine Bücher sind auf diesem Gerät noch gesperrt. <button class="btn" type="button" data-unlock-books>Entsperren</button></p>';
|
||||||
|
listEl.querySelector('[data-unlock-books]')?.addEventListener('click', () => {
|
||||||
|
dioraUnlock({force: true}).then(() => loadBookList(), () => {});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
listEl.innerHTML = `<p class="muted">Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.</p>`;
|
listEl.innerHTML = `<p class="muted">Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -126,10 +133,14 @@ function repairBook(bookId) {
|
||||||
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = '';
|
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = '';
|
||||||
try {
|
try {
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
|
const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise;
|
||||||
|
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');
|
||||||
|
|
@ -778,10 +789,14 @@ async function _importEbookBuffer(buf, filename, statusEl, opts) {
|
||||||
|
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
try {
|
try {
|
||||||
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
|
const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise;
|
||||||
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -933,7 +933,7 @@ function _libAddMenu() {
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.textContent = [
|
const rules = [
|
||||||
'html.lib-v2 #tab-books .book-drop-zone, html.lib-v2 #tab-books .book-list-filter,',
|
'html.lib-v2 #tab-books .book-drop-zone, html.lib-v2 #tab-books .book-list-filter,',
|
||||||
'html.lib-v2 #book-upload-area > button.btn, html.lib-v2 #book-upload-area > .nui-add-row { display: none !important; }',
|
'html.lib-v2 #book-upload-area > button.btn, html.lib-v2 #book-upload-area > .nui-add-row { display: none !important; }',
|
||||||
'#book-list .lib-head { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }',
|
'#book-list .lib-head { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }',
|
||||||
|
|
@ -1026,6 +1026,15 @@ function _libAddMenu() {
|
||||||
'.cloud-chosen:empty { display: none; }',
|
'.cloud-chosen:empty { display: none; }',
|
||||||
'.cloud-entry .cloud-check { width: 18px; height: 18px; margin: 0 8px 0 2px; flex: none; }',
|
'.cloud-entry .cloud-check { width: 18px; height: 18px; margin: 0 8px 0 2px; flex: none; }',
|
||||||
'body.eink #book-list .lib-cover { box-shadow: none; border: 1px solid #000; }',
|
'body.eink #book-list .lib-cover { box-shadow: none; border: 1px solid #000; }',
|
||||||
].join('\n');
|
];
|
||||||
|
// The audiobooks' shelf (audiobooks.js) is built of the same parts: the same rules for it,
|
||||||
|
// its covers square like an audiobook's.
|
||||||
|
const shelf = rules.filter(r => r.indexOf('#book-list') >= 0).map(r => r.replace(/#book-list/g, '#audiobook-shelf'));
|
||||||
|
style.textContent = rules.concat(shelf, [
|
||||||
|
'#audiobook-shelf .lib-cover { aspect-ratio: 1; }',
|
||||||
|
'#audiobook-shelf .lib-hero-cover { height: auto; aspect-ratio: 1; width: 120px; align-self: flex-start; }',
|
||||||
|
'@media (min-width: 721px) { #audiobook-shelf .lib-hero-cover { width: 160px; height: auto; } }',
|
||||||
|
'#audiobook-shelf button.lib-play { border: none; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }',
|
||||||
|
]).join('\n');
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,7 @@ const _issueNorm = s => String(s || '').toLowerCase().replace(/ß/g, 'ss').norma
|
||||||
|
|
||||||
async function _issueIndexPdf(bytes) {
|
async function _issueIndexPdf(bytes) {
|
||||||
if (typeof pdfjsLib === 'undefined') return null;
|
if (typeof pdfjsLib === 'undefined') return null;
|
||||||
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise;
|
const pdf = await _pdfLoad(new Uint8Array(bytes)).promise;
|
||||||
try {
|
try {
|
||||||
return await _issuePdfArticles(pdf);
|
return await _issuePdfArticles(pdf);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,569 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The PDF reader: one document, shown either scrolling (every page has its
|
||||||
|
// place, only those near the view are drawn) or page by page, cropped to the
|
||||||
|
// page's text (66-pdf-paginated.js). Whichever, a page is drawn only through
|
||||||
|
// _pdfDraw: one drawing at a time per canvas, sharp at the device's pixel
|
||||||
|
// ratio but never larger than PDF_MAX_PIXELS.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// pdf.js's own viewer caps a canvas at this many pixels: beyond it phones run out of memory.
|
||||||
|
const PDF_MAX_PIXELS = 16777216;
|
||||||
|
|
||||||
|
// One pdf.js worker for every document (the reader, covers, uploads): starting one takes a
|
||||||
|
// moment — on an e-reader a long one.
|
||||||
|
let _pdfWorker = null;
|
||||||
|
|
||||||
|
function _pdfSharedWorker() {
|
||||||
|
if (!_pdfWorker && typeof pdfjsLib !== 'undefined' && pdfjsLib.PDFWorker) {
|
||||||
|
try { _pdfWorker = new pdfjsLib.PDFWorker(); } catch (e) { _pdfWorker = null; }
|
||||||
|
}
|
||||||
|
return _pdfWorker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pdf.js's loading task for [bytes] (a Uint8Array), on the shared worker. */
|
||||||
|
function _pdfLoad(bytes) {
|
||||||
|
const worker = _pdfSharedWorker();
|
||||||
|
return pdfjsLib.getDocument(worker ? {data: bytes, worker} : {data: bytes});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page (from 1) a destination leads to — a named one or an explicit array, whose page is
|
||||||
|
* a reference or, in some files, a plain index. 0 when it leads nowhere.
|
||||||
|
*/
|
||||||
|
async function _pdfDestPage(pdf, dest) {
|
||||||
|
try {
|
||||||
|
if (typeof dest === 'string') dest = await pdf.getDestination(dest);
|
||||||
|
if (!dest || dest[0] == null) return 0;
|
||||||
|
return (typeof dest[0] === 'number' ? dest[0] : await pdf.getPageIndex(dest[0])) + 1;
|
||||||
|
} catch (e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The table of contents: all entries resolved at once (one after the other cost a round trip
|
||||||
|
// to the worker each).
|
||||||
async function _parsePdfOutline(pdf, items, depth) {
|
async function _parsePdfOutline(pdf, items, depth) {
|
||||||
depth = depth || 0;
|
depth = depth || 0;
|
||||||
const result = [];
|
const parts = await Promise.all(items.map(async item => {
|
||||||
for (const item of items) {
|
|
||||||
let href = '';
|
let href = '';
|
||||||
if (item.dest) {
|
if (item.dest) {
|
||||||
try {
|
const n = await _pdfDestPage(pdf, item.dest);
|
||||||
const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest;
|
if (n) href = `#pdf-page-${n}`;
|
||||||
if (dest) {
|
|
||||||
const pageIndex = await pdf.getPageIndex(dest[0]);
|
|
||||||
href = `#pdf-page-${pageIndex + 1}`;
|
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
const kids = item.items && item.items.length ? await _parsePdfOutline(pdf, item.items, depth + 1) : [];
|
||||||
}
|
return [{label: item.title || '(untitled)', href, depth}].concat(kids);
|
||||||
result.push({label: item.title || '(untitled)', href, depth});
|
}));
|
||||||
if (item.items && item.items.length) {
|
return [].concat.apply([], parts);
|
||||||
result.push(...await _parsePdfOutline(pdf, item.items, depth + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The document being loaded (pdf.js loading task), so opening can be cancelled.
|
// The document being loaded (pdf.js loading task), so opening can be cancelled.
|
||||||
let _pdfLoadingTask = null;
|
let _pdfLoadingTask = null;
|
||||||
// The pages' drawing of the last renderPdf: an observer and its queue (stopped on the next one).
|
// The open document's pages and how they are drawn (renderPdf; stopped on the next one).
|
||||||
|
let _pdfView = null;
|
||||||
|
// Backwards name: the reader stops "the lazy drawing" when a book closes.
|
||||||
let _pdfLazy = null;
|
let _pdfLazy = null;
|
||||||
|
|
||||||
function _pdfStopLazy() {
|
// The open document's text, page by page (pdf.js text content): for the text layer, the
|
||||||
if (_pdfLazy) {
|
// search and the crop to the text box. Kept for the document, not for one layout of it.
|
||||||
_pdfLazy.stopped = true;
|
let _pdfTexts = null;
|
||||||
if (_pdfLazy.observer) _pdfLazy.observer.disconnect();
|
|
||||||
_pdfLazy = null;
|
function _pdfPageText(pdf, num) {
|
||||||
|
if (!_pdfTexts || _pdfTexts.pdf !== pdf) _pdfTexts = {pdf, pages: new Map()};
|
||||||
|
let p = _pdfTexts.pages.get(num);
|
||||||
|
if (!p) {
|
||||||
|
p = pdf.getPage(num).then(page => page.getTextContent()).catch(() => ({items: [], styles: {}}));
|
||||||
|
_pdfTexts.pages.set(num, p);
|
||||||
}
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page's text as one string, lower case, for searching — with where each item (each span of
|
||||||
|
* the text layer, in order) starts in it. Lines ending inside it get a space.
|
||||||
|
*/
|
||||||
|
function _pdfPageString(tc) {
|
||||||
|
if (tc._diora) return tc._diora;
|
||||||
|
let str = '';
|
||||||
|
const starts = [];
|
||||||
|
for (const item of tc.items) {
|
||||||
|
starts.push(str.length);
|
||||||
|
str += item.str || '';
|
||||||
|
if (item.hasEOL) str += ' ';
|
||||||
|
}
|
||||||
|
tc._diora = {str: str.toLowerCase(), starts};
|
||||||
|
return tc._diora;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title, table of contents and page labels: asked for once per document, alongside the
|
||||||
|
// pages (opening doesn't wait for them; a layout anew reuses them).
|
||||||
|
let _pdfExtras = null;
|
||||||
|
|
||||||
|
function _pdfDocExtras(pdf) {
|
||||||
|
if (_pdfExtras && _pdfExtras.pdf === pdf) return _pdfExtras;
|
||||||
|
const meta = pdf.getMetadata().then(m => ({
|
||||||
|
title: (m && m.info && m.info.Title || '').trim(),
|
||||||
|
author: (m && m.info && m.info.Author || '').trim(),
|
||||||
|
}), () => ({title: '', author: ''}));
|
||||||
|
const toc = pdf.getOutline().then(o => (o && o.length ? _parsePdfOutline(pdf, o) : []), () => []);
|
||||||
|
const labels = pdf.getPageLabels().then(l => {
|
||||||
|
// Labels that only say 1, 2, 3 … are no labels.
|
||||||
|
if (!l || !l.length || l.every((s, i) => !s || s === String(i + 1))) return null;
|
||||||
|
return l.map((s, i) => s || String(i + 1));
|
||||||
|
}, () => null);
|
||||||
|
_pdfExtras = {pdf, meta, toc, labels};
|
||||||
|
return _pdfExtras;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page labels of the open document (front matter "iv", appendix "A-3" …), null without.
|
||||||
|
let _pdfLabels = null;
|
||||||
|
|
||||||
|
/** Page [n] (from 1) as the book numbers it. */
|
||||||
|
function pdfPageLabel(n) {
|
||||||
|
return (_pdfLabels && _pdfLabels[n - 1]) || String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page meant by [value] typed in: a label of the book first, else a page number. 0: none. */
|
||||||
|
function _pdfPageFromInput(value) {
|
||||||
|
const v = String(value || '').trim();
|
||||||
|
if (!v) return 0;
|
||||||
|
if (_pdfLabels) {
|
||||||
|
let i = _pdfLabels.indexOf(v);
|
||||||
|
if (i < 0) i = _pdfLabels.findIndex(l => l.toLowerCase() === v.toLowerCase());
|
||||||
|
if (i >= 0) return i + 1;
|
||||||
|
}
|
||||||
|
return parseInt(v, 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pages in the reader's colours (Leserfarben): {background, foreground} as #rrggbb, the
|
||||||
|
* reader's (Dunkel, Sepia …). null: as printed (also when the reader is black on white).
|
||||||
|
*/
|
||||||
|
function _pdfPageColors() {
|
||||||
|
if (!readerSettings.pdfPageColors || readerSettings.pdfInverted) return null;
|
||||||
|
const contentEl = $('reader-content');
|
||||||
|
if (!contentEl) return null;
|
||||||
|
let bg = null;
|
||||||
|
for (let el = contentEl; el && !bg; el = el.parentElement) bg = _pdfCssHex(getComputedStyle(el).backgroundColor);
|
||||||
|
const fg = _pdfCssHex(getComputedStyle(contentEl).color);
|
||||||
|
// On white the page is as printed anyway (Hell).
|
||||||
|
if (!bg || !fg || bg === '#ffffff') return null;
|
||||||
|
return {background: bg, foreground: fg};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "rgb(…)" / "rgba(…)" as #rrggbb; null for transparent. */
|
||||||
|
function _pdfCssHex(css) {
|
||||||
|
const m = /rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?/.exec(css || '');
|
||||||
|
if (!m || (m[4] !== undefined && parseFloat(m[4]) === 0)) return null;
|
||||||
|
return '#' + [m[1], m[2], m[3]].map(x => (+x).toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the pages are drawn for Leserfarben (with their pictures laid over them, below):
|
||||||
|
// switching that draws them anew; another theme only changes the filter's colours.
|
||||||
|
function _pdfColorsKey() {
|
||||||
|
return _pdfPageColors() ? 'reader' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leserfarben: the page is drawn as printed and recoloured by an SVG filter on its canvas —
|
||||||
|
* its brightness from the reader's text colour (black) to its background (white). (pdf.js's
|
||||||
|
* own pageColors recolours the pictures too, and lets nothing be left out.) Kept up to date
|
||||||
|
* with the colours; true when it applies.
|
||||||
|
*/
|
||||||
|
function _pdfColorFilter() {
|
||||||
|
const c = _pdfPageColors();
|
||||||
|
if (!c) return false;
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
let svg = document.getElementById('pdf-reader-colors-svg');
|
||||||
|
if (!svg) {
|
||||||
|
svg = document.createElementNS(ns, 'svg');
|
||||||
|
svg.id = 'pdf-reader-colors-svg';
|
||||||
|
svg.setAttribute('width', '0');
|
||||||
|
svg.setAttribute('height', '0');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
svg.style.position = 'absolute';
|
||||||
|
svg.innerHTML = '<filter id="pdf-reader-colors" color-interpolation-filters="sRGB">' +
|
||||||
|
'<feColorMatrix type="matrix" values="0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 ' +
|
||||||
|
'0.2126 0.7152 0.0722 0 0 0 0 0 1 0"/>' +
|
||||||
|
'<feComponentTransfer><feFuncR type="table"/><feFuncG type="table"/><feFuncB type="table"/>' +
|
||||||
|
'</feComponentTransfer></filter>';
|
||||||
|
document.body.appendChild(svg);
|
||||||
|
}
|
||||||
|
const ch = (hex, i) => (parseInt(hex.substr(1 + i * 2, 2), 16) / 255).toFixed(3);
|
||||||
|
['R', 'G', 'B'].forEach((name, i) => {
|
||||||
|
svg.querySelector('feFunc' + name).setAttribute('tableValues', `${ch(c.foreground, i)} ${ch(c.background, i)}`);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where page [page]'s pictures are, in CSS pixels of [viewport]: from its drawing operations
|
||||||
|
* (the unit square of each image under the transform at that point). Image masks are left
|
||||||
|
* out (they're drawn in a colour, like text).
|
||||||
|
*/
|
||||||
|
async function _pdfImageRects(page, viewport) {
|
||||||
|
const OPS = pdfjsLib.OPS;
|
||||||
|
let list;
|
||||||
|
try {
|
||||||
|
list = await page.getOperatorList({annotationMode: pdfjsLib.AnnotationMode.ENABLE_FORMS});
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const mul = (m, n) => [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
|
||||||
|
m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
|
||||||
|
m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];
|
||||||
|
let ctm = viewport.transform.slice();
|
||||||
|
const stack = [];
|
||||||
|
const rects = [];
|
||||||
|
for (let i = 0; i < list.fnArray.length; i++) {
|
||||||
|
const fn = list.fnArray[i], args = list.argsArray[i];
|
||||||
|
if (fn === OPS.save) stack.push(ctm);
|
||||||
|
else if (fn === OPS.restore) ctm = stack.pop() || ctm;
|
||||||
|
else if (fn === OPS.transform) ctm = mul(ctm, args);
|
||||||
|
else if (fn === OPS.paintFormXObjectBegin) {
|
||||||
|
stack.push(ctm);
|
||||||
|
if (Array.isArray(args[0]) && args[0].length === 6) ctm = mul(ctm, args[0]);
|
||||||
|
} else if (fn === OPS.paintFormXObjectEnd) ctm = stack.pop() || ctm;
|
||||||
|
else if (fn === OPS.paintImageXObject || fn === OPS.paintInlineImageXObject) {
|
||||||
|
const xs = [ctm[4], ctm[0] + ctm[4], ctm[2] + ctm[4], ctm[0] + ctm[2] + ctm[4]];
|
||||||
|
const ys = [ctm[5], ctm[1] + ctm[5], ctm[3] + ctm[5], ctm[1] + ctm[3] + ctm[5]];
|
||||||
|
const x = Math.max(0, Math.min.apply(null, xs)), y = Math.max(0, Math.min.apply(null, ys));
|
||||||
|
const w = Math.min(viewport.width, Math.max.apply(null, xs)) - x;
|
||||||
|
const h = Math.min(viewport.height, Math.max.apply(null, ys)) - y;
|
||||||
|
if (w > 2 && h > 2) rects.push({x, y, w, h});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A picture covering this much of the page is the page itself (a scan): recoloured with it.
|
||||||
|
const PDF_SCAN_SHARE = 0.5;
|
||||||
|
|
||||||
|
/** Leserfarben: page [e]'s pictures laid over it as printed (copied from its canvas). */
|
||||||
|
async function _pdfImageLayer(e, page, viewport, ratio, token) {
|
||||||
|
const rects = await _pdfImageRects(page, viewport);
|
||||||
|
if (e.token !== token) return;
|
||||||
|
const area = viewport.width * viewport.height;
|
||||||
|
const shown = rects.filter(r => r.w * r.h < area * PDF_SCAN_SHARE);
|
||||||
|
if (!shown.length) return;
|
||||||
|
const layer = document.createElement('div');
|
||||||
|
layer.className = 'pdf-image-layer';
|
||||||
|
for (const r of shown) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const sx = Math.floor(r.x * ratio), sy = Math.floor(r.y * ratio);
|
||||||
|
const sw = Math.min(e.canvas.width - sx, Math.ceil(r.w * ratio)), sh = Math.min(e.canvas.height - sy, Math.ceil(r.h * ratio));
|
||||||
|
if (sw <= 0 || sh <= 0) continue;
|
||||||
|
c.width = sw;
|
||||||
|
c.height = sh;
|
||||||
|
c.getContext('2d').drawImage(e.canvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||||
|
c.style.left = sx / ratio + 'px';
|
||||||
|
c.style.top = sy / ratio + 'px';
|
||||||
|
c.style.width = sw / ratio + 'px';
|
||||||
|
c.style.height = sh / ratio + 'px';
|
||||||
|
layer.appendChild(c);
|
||||||
|
}
|
||||||
|
e.inner.insertBefore(layer, e.canvas.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal links followed, the pages they were followed from (the back button).
|
||||||
|
let _pdfBackStack = [];
|
||||||
|
|
||||||
|
/** The document is gone (another one, or the reader closed): what belonged to it goes too. */
|
||||||
|
function _pdfForgetDoc() {
|
||||||
|
_pdfTexts = null;
|
||||||
|
_pdfExtras = null;
|
||||||
|
_pdfLabels = null;
|
||||||
|
_pdfBackStack = [];
|
||||||
|
_pdfShowBack();
|
||||||
|
if (typeof _pdfSearchStop === 'function') _pdfSearchStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _pdfFollowDest(dest) {
|
||||||
|
const pdf = currentPdfDoc;
|
||||||
|
if (!pdf) return;
|
||||||
|
const n = await _pdfDestPage(pdf, dest);
|
||||||
|
if (!n || pdf !== currentPdfDoc) return;
|
||||||
|
const contentEl = $('reader-content');
|
||||||
|
const from = readerSettings.pdfPaginated || !contentEl ? pdfCurrentPage : _pdfTopPage(contentEl);
|
||||||
|
if (n === from) return;
|
||||||
|
_pdfBackStack.push(from);
|
||||||
|
pdfGoToPage(n);
|
||||||
|
_pdfShowBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The button back to where the last link was followed from (gone when there's none). */
|
||||||
|
function _pdfShowBack() {
|
||||||
|
let btn = document.getElementById('pdf-back-btn');
|
||||||
|
const from = _pdfBackStack[_pdfBackStack.length - 1];
|
||||||
|
if (!from) {
|
||||||
|
if (btn) btn.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!btn) {
|
||||||
|
const overlay = $('reader-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
btn = document.createElement('button');
|
||||||
|
btn.id = 'pdf-back-btn';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-sm pdf-back-btn';
|
||||||
|
btn.addEventListener('click', ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const page = _pdfBackStack.pop();
|
||||||
|
if (page) pdfGoToPage(page);
|
||||||
|
_pdfShowBack();
|
||||||
|
});
|
||||||
|
overlay.appendChild(btn);
|
||||||
|
}
|
||||||
|
btn.textContent = `← Zurück zu S. ${pdfPageLabel(from)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page's text layer and links taken off (another scale, or the page let go). */
|
||||||
|
function _pdfDropLayers(e) {
|
||||||
|
if (e.textTask) { try { e.textTask.cancel(); } catch (err) {} }
|
||||||
|
e.textTask = null;
|
||||||
|
e.textDivs = null;
|
||||||
|
e.textInfo = null;
|
||||||
|
e.marked = null;
|
||||||
|
e.inner.querySelectorAll('.pdf-text-layer, .pdf-link-layer, .pdf-image-layer').forEach(el => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Over a drawn page: its text (transparent, to select and copy, and where the search marks
|
||||||
|
* go) and its links. After the pixels, so a turn isn't held up by them.
|
||||||
|
*/
|
||||||
|
async function _pdfLayers(e, page, viewport, token) {
|
||||||
|
const pdf = currentPdfDoc;
|
||||||
|
if (!pdf) return;
|
||||||
|
let annots = [];
|
||||||
|
try { annots = await page.getAnnotations({intent: 'display'}); } catch (err) {}
|
||||||
|
if (e.token !== token) return;
|
||||||
|
_pdfLinks(e, viewport, annots);
|
||||||
|
if (typeof pdfjsLib.renderTextLayer !== 'function') return;
|
||||||
|
const text = await _pdfPageText(pdf, e.num);
|
||||||
|
if (e.token !== token || !text.items.length) return;
|
||||||
|
const layer = document.createElement('div');
|
||||||
|
layer.className = 'pdf-text-layer';
|
||||||
|
layer.style.setProperty('--scale-factor', viewport.scale);
|
||||||
|
e.inner.appendChild(layer);
|
||||||
|
const divs = [];
|
||||||
|
const task = pdfjsLib.renderTextLayer({textContentSource: text, container: layer, viewport, textDivs: divs});
|
||||||
|
e.textTask = task;
|
||||||
|
try {
|
||||||
|
await task.promise;
|
||||||
|
} catch (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.token !== token || e.textTask !== task) return;
|
||||||
|
e.textTask = null;
|
||||||
|
e.textDivs = divs;
|
||||||
|
e.textInfo = {items: text.items, string: _pdfPageString(text)};
|
||||||
|
if (typeof _pdfSearchMarkPage === 'function') _pdfSearchMarkPage(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links: ones into the document go there (and back, _pdfShowBack); web addresses open outside.
|
||||||
|
function _pdfLinks(e, viewport, annots) {
|
||||||
|
const links = annots.filter(a => a.subtype === 'Link' && (a.url || a.dest));
|
||||||
|
if (!links.length) return;
|
||||||
|
const layer = document.createElement('div');
|
||||||
|
layer.className = 'pdf-link-layer';
|
||||||
|
for (const a of links) {
|
||||||
|
const r = viewport.convertToViewportRectangle(a.rect);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.className = 'pdf-link';
|
||||||
|
link.style.left = Math.min(r[0], r[2]) + 'px';
|
||||||
|
link.style.top = Math.min(r[1], r[3]) + 'px';
|
||||||
|
link.style.width = Math.abs(r[2] - r[0]) + 'px';
|
||||||
|
link.style.height = Math.abs(r[3] - r[1]) + 'px';
|
||||||
|
if (a.url) {
|
||||||
|
link.href = a.url;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
link.title = a.url;
|
||||||
|
} else {
|
||||||
|
const dest = a.dest;
|
||||||
|
link.href = '#';
|
||||||
|
link.addEventListener('click', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
_pdfFollowDest(dest);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
layer.appendChild(link);
|
||||||
|
}
|
||||||
|
e.inner.appendChild(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pdfStopLazy() {
|
||||||
|
const v = _pdfView;
|
||||||
|
if (!v) return;
|
||||||
|
v.stopped = true;
|
||||||
|
if (v.observer) v.observer.disconnect();
|
||||||
|
if (v.resize) v.resize.disconnect();
|
||||||
|
clearTimeout(v.resizeTimer);
|
||||||
|
v.entries.forEach(e => { e.token++; if (e.task) { try { e.task.cancel(); } catch (err) {} } });
|
||||||
|
_pdfView = _pdfLazy = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw page [page] of entry [e] at [cssScale] (CSS pixels per PDF point). Cancels a drawing
|
||||||
|
* of the same canvas still under way (pdf.js refuses two at once) and waits for it to let go.
|
||||||
|
* Returns false when a newer drawing (or letting go) came first.
|
||||||
|
*/
|
||||||
|
function _pdfDraw(e, page, cssScale) {
|
||||||
|
// The same drawing asked for again while it's under way (the page shown, then laid out
|
||||||
|
// again as the bars go): that one, not a new one cancelling it.
|
||||||
|
if (e.drawing && e.drawing.scale === cssScale) return e.drawing.promise;
|
||||||
|
const drawing = {scale: cssScale, promise: null};
|
||||||
|
drawing.promise = _pdfDrawNow(e, page, cssScale).then(ok => {
|
||||||
|
if (e.drawing === drawing) e.drawing = null;
|
||||||
|
return ok;
|
||||||
|
}, err => {
|
||||||
|
if (e.drawing === drawing) e.drawing = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
e.drawing = drawing;
|
||||||
|
return drawing.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _pdfDrawNow(e, page, cssScale) {
|
||||||
|
const token = ++e.token;
|
||||||
|
_pdfDropLayers(e);
|
||||||
|
const prev = e.task;
|
||||||
|
if (prev) {
|
||||||
|
try { prev.cancel(); } catch (err) {}
|
||||||
|
try { await prev.promise; } catch (err) {}
|
||||||
|
if (e.token !== token) return false;
|
||||||
|
}
|
||||||
|
const viewport = page.getViewport({scale: cssScale});
|
||||||
|
let ratio = window.devicePixelRatio || 1;
|
||||||
|
const area = viewport.width * viewport.height;
|
||||||
|
if (area * ratio * ratio > PDF_MAX_PIXELS) ratio = Math.max(0.5, Math.sqrt(PDF_MAX_PIXELS / area));
|
||||||
|
// Drawn on a canvas of its own and put in place when it's done: the page shown so far stays
|
||||||
|
// until then (after a zoom larger or smaller and blurred for a moment, never blank).
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'pdf-page';
|
||||||
|
canvas.width = Math.round(viewport.width * ratio);
|
||||||
|
canvas.height = Math.round(viewport.height * ratio);
|
||||||
|
canvas.style.width = viewport.width + 'px';
|
||||||
|
canvas.style.height = viewport.height + 'px';
|
||||||
|
const task = page.render({
|
||||||
|
canvasContext: canvas.getContext('2d'),
|
||||||
|
viewport,
|
||||||
|
transform: ratio !== 1 ? [ratio, 0, 0, ratio, 0, 0] : null,
|
||||||
|
});
|
||||||
|
e.task = task;
|
||||||
|
const discard = () => { canvas.width = 0; canvas.height = 0; };
|
||||||
|
try {
|
||||||
|
await task.promise;
|
||||||
|
} catch (err) {
|
||||||
|
if (e.task === task) e.task = null;
|
||||||
|
discard();
|
||||||
|
if (err && err.name !== 'RenderingCancelledException') console.warn('PDF page', e.num, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (e.task === task) e.task = null;
|
||||||
|
if (e.token !== token) { discard(); return false; }
|
||||||
|
const old = e.canvas;
|
||||||
|
if (old.parentNode) old.parentNode.replaceChild(canvas, old);
|
||||||
|
old.width = 0;
|
||||||
|
old.height = 0;
|
||||||
|
e.canvas = canvas;
|
||||||
|
e.drawn = cssScale;
|
||||||
|
e.stale = false;
|
||||||
|
const recolor = _pdfColorFilter();
|
||||||
|
e.canvas.classList.toggle('pdf-recolored', recolor);
|
||||||
|
if (recolor) _pdfImageLayer(e, page, viewport, ratio, token).catch(err => console.warn('PDF page pictures', e.num, err));
|
||||||
|
_pdfLayers(e, page, viewport, token).catch(err => console.warn('PDF page layers', e.num, err));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let a page's pixels go (its place stays) and pdf.js forget what it parsed of it. */
|
||||||
|
function _pdfLetGo(e, pdf) {
|
||||||
|
e.token++;
|
||||||
|
e.drawing = null;
|
||||||
|
if (e.task) { try { e.task.cancel(); } catch (err) {} }
|
||||||
|
_pdfDropLayers(e);
|
||||||
|
e.drawn = 0;
|
||||||
|
e.canvas.width = 0;
|
||||||
|
e.canvas.height = 0;
|
||||||
|
if (pdf) pdf.getPage(e.num).then(p => { if (!e.task) p.cleanup(); }, () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What of which page is at (x, y) on the screen (client pixels): {e, x, y, rx, ry}, rx/ry the
|
||||||
|
* place on the page as a share of its width and height.
|
||||||
|
*/
|
||||||
|
function _pdfPointAt(x, y) {
|
||||||
|
const v = _pdfView;
|
||||||
|
if (!v || !v.entries.length) return null;
|
||||||
|
let lo = 0, hi = v.entries.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (v.entries[mid].wrapper.getBoundingClientRect().bottom > y) hi = mid; else lo = mid + 1;
|
||||||
|
}
|
||||||
|
const e = v.entries[lo];
|
||||||
|
const r = e.canvas.getBoundingClientRect();
|
||||||
|
return {e, x, y, rx: r.width ? (x - r.left) / r.width : 0, ry: r.height ? (y - r.top) / r.height : 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolling, zoomed by [f]: every page's place grows or shrinks at once (the ones drawn are
|
||||||
|
* shown larger or smaller until drawn anew, those near the view first), and [anchor]'s point
|
||||||
|
* (_pdfPointAt) stays where it is on the screen. false when there's no scrolling view.
|
||||||
|
*/
|
||||||
|
function _pdfRezoomScroll(f, anchor) {
|
||||||
|
const v = _pdfView;
|
||||||
|
if (!v || v.stopped || readerSettings.pdfPaginated || !v.work) return false;
|
||||||
|
for (const e of v.entries) {
|
||||||
|
e.canvas.style.width = (parseFloat(e.canvas.style.width) || 0) * f + 'px';
|
||||||
|
e.canvas.style.height = (parseFloat(e.canvas.style.height) || 0) * f + 'px';
|
||||||
|
if (e.task) {
|
||||||
|
e.token++;
|
||||||
|
e.drawing = null;
|
||||||
|
try { e.task.cancel(); } catch (err) {}
|
||||||
|
}
|
||||||
|
if (e.drawn || e.task) {
|
||||||
|
_pdfDropLayers(e);
|
||||||
|
e.stale = true;
|
||||||
|
}
|
||||||
|
if (e.near) v.queue.add(e);
|
||||||
|
}
|
||||||
|
if (anchor) {
|
||||||
|
const contentEl = v.contentEl;
|
||||||
|
const r = anchor.e.canvas.getBoundingClientRect();
|
||||||
|
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
|
||||||
|
contentEl.scrollLeft += r.left + anchor.rx * r.width - anchor.x;
|
||||||
|
contentEl.scrollTop += r.top + anchor.ry * r.height - anchor.y;
|
||||||
|
}
|
||||||
|
v.work();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page shown at the top of the scrolling view (binary search, the pages are in order). */
|
||||||
|
function _pdfTopPage(contentEl) {
|
||||||
|
const v = _pdfView;
|
||||||
|
if (!v || !v.entries.length) return pdfCurrentPage || 1;
|
||||||
|
const top = contentEl.getBoundingClientRect().top + 20;
|
||||||
|
let lo = 0, hi = v.entries.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (v.entries[mid].wrapper.getBoundingClientRect().bottom > top) hi = mid; else lo = mid + 1;
|
||||||
|
}
|
||||||
|
return v.entries[lo].num;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every page gets its place at once (sized like the first page until it is drawn), but only the
|
// Every page gets its place at once (sized like the first page until it is drawn), but only the
|
||||||
// pages near the view are drawn; ones far away are let go again. A book of 1000 scanned pages
|
// pages near the view are drawn; ones far away are let go again. A book of 1000 scanned pages
|
||||||
// opens as fast as one of ten, and doesn't hold 1000 canvases.
|
// opens as fast as one of ten, and doesn't hold 1000 canvases. Page by page (pdfPaginated),
|
||||||
|
// 66-pdf-paginated.js shows and draws the pages itself.
|
||||||
async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
||||||
const myGen = ++_pdfRenderGen;
|
const myGen = ++_pdfRenderGen;
|
||||||
_pdfStopLazy();
|
_pdfStopLazy();
|
||||||
let pdf = currentPdfDoc;
|
let pdf = currentPdfDoc;
|
||||||
if (!pdf) {
|
if (!pdf) {
|
||||||
_pdfLoadingTask = pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))});
|
_pdfForgetDoc();
|
||||||
|
_pdfLoadingTask = _pdfLoad(new Uint8Array(arrayBuffer.slice(0)));
|
||||||
try {
|
try {
|
||||||
pdf = await _pdfLoadingTask.promise;
|
pdf = await _pdfLoadingTask.promise;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -52,20 +572,15 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
||||||
}
|
}
|
||||||
if (_pdfRenderGen !== myGen) return null;
|
if (_pdfRenderGen !== myGen) return null;
|
||||||
currentPdfDoc = pdf;
|
currentPdfDoc = pdf;
|
||||||
|
// [pivotPage] may be given as what of the document it is (the place saved, a fraction).
|
||||||
|
if (typeof pivotPage === 'function') pivotPage = pivotPage(pdf.numPages);
|
||||||
|
|
||||||
let pdfTitle = '', pdfAuthor = '';
|
const extras = _pdfDocExtras(pdf);
|
||||||
try {
|
extras.labels.then(labels => {
|
||||||
const meta = await pdf.getMetadata();
|
if (pdf !== currentPdfDoc) return;
|
||||||
pdfTitle = meta.info?.Title?.trim() || '';
|
_pdfLabels = labels;
|
||||||
pdfAuthor = meta.info?.Author?.trim() || '';
|
if (labels) _pdfSetCurrentPage(pdfCurrentPage);
|
||||||
} catch (e) {}
|
});
|
||||||
|
|
||||||
let toc = [];
|
|
||||||
try {
|
|
||||||
const outline = await pdf.getOutline();
|
|
||||||
if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline);
|
|
||||||
} catch (e) {}
|
|
||||||
if (_pdfRenderGen !== myGen) return null;
|
|
||||||
|
|
||||||
contentEl.innerHTML = '';
|
contentEl.innerHTML = '';
|
||||||
|
|
||||||
|
|
@ -77,7 +592,6 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
||||||
? contentEl.clientWidth - 32
|
? contentEl.clientWidth - 32
|
||||||
: Math.min(contentEl.clientWidth - 32, 900);
|
: Math.min(contentEl.clientWidth - 32, 900);
|
||||||
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
|
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const scaleFor = vp => (scaleOverride != null ? scaleOverride
|
const scaleFor = vp => (scaleOverride != null ? scaleOverride
|
||||||
: Math.max(0.5, pageWidth / vp.width) * (readerSettings.pdfZoom / 100));
|
: Math.max(0.5, pageWidth / vp.width) * (readerSettings.pdfZoom / 100));
|
||||||
|
|
||||||
|
|
@ -102,7 +616,7 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
||||||
canvas.style.height = firstVp.height + 'px';
|
canvas.style.height = firstVp.height + 'px';
|
||||||
inner.appendChild(canvas);
|
inner.appendChild(canvas);
|
||||||
wrapper.appendChild(inner);
|
wrapper.appendChild(inner);
|
||||||
if (!readerSettings.pdfSpread) {
|
if (!readerSettings.pdfSpread || readerSettings.pdfPaginated) {
|
||||||
pdfVp.appendChild(wrapper);
|
pdfVp.appendChild(wrapper);
|
||||||
} else if (pageNum === 1) {
|
} else if (pageNum === 1) {
|
||||||
wrapper.classList.add('pdf-spread-cover');
|
wrapper.classList.add('pdf-spread-cover');
|
||||||
|
|
@ -117,78 +631,93 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
||||||
if (spreadContainer) spreadContainer.appendChild(wrapper);
|
if (spreadContainer) spreadContainer.appendChild(wrapper);
|
||||||
else pdfVp.appendChild(wrapper);
|
else pdfVp.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
entries.push({num: pageNum, wrapper, canvas, drawn: false, near: false, task: null});
|
entries.push({num: pageNum, wrapper, inner, canvas, drawn: 0, near: false, task: null, token: 0});
|
||||||
}
|
}
|
||||||
|
|
||||||
pdfTotalPages = pdf.numPages;
|
pdfTotalPages = pdf.numPages;
|
||||||
|
|
||||||
const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1));
|
const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1));
|
||||||
const pivotEl = document.getElementById(`pdf-page-${pivot}`);
|
|
||||||
if (pivotEl && pivot > 1) pivotEl.scrollIntoView({block: 'start'});
|
|
||||||
|
|
||||||
const lazy = {stopped: false, observer: null, queue: new Set(), working: false};
|
const view = {pdf, contentEl, entries, gen: myGen, stopped: false, observer: null, resize: null,
|
||||||
_pdfLazy = lazy;
|
resizeTimer: 0, queue: new Set(), working: false, scaleFor, showToken: 0,
|
||||||
const gone = () => lazy.stopped || _pdfRenderGen !== myGen;
|
colorsKey: _pdfColorsKey()};
|
||||||
|
_pdfView = _pdfLazy = view;
|
||||||
|
const gone = () => view.stopped || _pdfRenderGen !== myGen;
|
||||||
|
|
||||||
async function draw(e) {
|
async function draw(e) {
|
||||||
const page = await pdf.getPage(e.num);
|
const page = await pdf.getPage(e.num);
|
||||||
if (gone() || !e.near) return;
|
if (gone() || !e.near || readerSettings.pdfPaginated) return;
|
||||||
const viewport = page.getViewport({scale: scaleFor(page.getViewport({scale: 1}))});
|
await _pdfDraw(e, page, scaleFor(page.getViewport({scale: 1})));
|
||||||
e.canvas.style.width = viewport.width + 'px';
|
|
||||||
e.canvas.style.height = viewport.height + 'px';
|
|
||||||
e.canvas.width = Math.round(viewport.width * dpr);
|
|
||||||
e.canvas.height = Math.round(viewport.height * dpr);
|
|
||||||
const ctx = e.canvas.getContext('2d');
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
e.task = page.render({canvasContext: ctx, viewport});
|
|
||||||
try {
|
|
||||||
await e.task.promise;
|
|
||||||
e.drawn = true;
|
|
||||||
} catch (err) { /* cancelled: let go below */ }
|
|
||||||
e.task = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function letGo(e) {
|
|
||||||
if (e.task) { try { e.task.cancel(); } catch (err) {} }
|
|
||||||
if (!e.drawn) return;
|
|
||||||
e.drawn = false;
|
|
||||||
e.canvas.width = 0; // frees the pixels; the CSS size keeps the page's place
|
|
||||||
e.canvas.height = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One page at a time, the nearest to the current one first.
|
// One page at a time, the nearest to the current one first.
|
||||||
async function work() {
|
async function work() {
|
||||||
if (lazy.working) return;
|
if (view.working) return;
|
||||||
lazy.working = true;
|
view.working = true;
|
||||||
try {
|
try {
|
||||||
while (!gone() && lazy.queue.size) {
|
while (!gone() && view.queue.size && !readerSettings.pdfPaginated) {
|
||||||
const cur = typeof _currentScrollPage === 'function' && !readerSettings.pdfPaginated
|
const cur = _pdfTopPage(contentEl);
|
||||||
? _currentScrollPage(contentEl) : (pdfCurrentPage || pivot);
|
const next = Array.from(view.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0];
|
||||||
const next = Array.from(lazy.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0];
|
view.queue.delete(next);
|
||||||
lazy.queue.delete(next);
|
if (next.near && (!next.drawn || next.stale)) await draw(next);
|
||||||
if (next.near && !next.drawn) await draw(next);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
lazy.working = false;
|
view.working = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
view.work = work;
|
||||||
|
|
||||||
|
// Turning the device, a narrower window, the bars coming or going: laid out again at the same page.
|
||||||
|
let lastW = contentEl.clientWidth, lastH = contentEl.clientHeight;
|
||||||
|
if (typeof ResizeObserver === 'function') {
|
||||||
|
view.resize = new ResizeObserver(() => {
|
||||||
|
const w = contentEl.clientWidth, h = contentEl.clientHeight;
|
||||||
|
if (!w || !h || (w === lastW && h === lastH)) return;
|
||||||
|
const widthChanged = Math.abs(w - lastW) > 2;
|
||||||
|
lastW = w;
|
||||||
|
lastH = h;
|
||||||
|
clearTimeout(view.resizeTimer);
|
||||||
|
view.resizeTimer = setTimeout(() => {
|
||||||
|
if (gone()) return;
|
||||||
|
if (readerSettings.pdfPaginated) _pdfShowPage(pdfCurrentPage);
|
||||||
|
else if (widthChanged) reRenderPdf(_pdfTopPage(contentEl));
|
||||||
|
}, readerSettings.pdfPaginated ? 150 : 300);
|
||||||
|
});
|
||||||
|
view.resize.observe(contentEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readerSettings.pdfPaginated) {
|
||||||
|
// Page by page: the pivot page cropped to its text, at once (66-pdf-paginated.js).
|
||||||
|
pdfCurrentPage = pivot;
|
||||||
|
await _pdfShowPage(pivot);
|
||||||
|
if (gone()) return null;
|
||||||
|
return _pdfResult(pdf, extras);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening at the saved page is no "scrolled far" (44-books.js).
|
||||||
|
if (pivot > 1) {
|
||||||
|
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
|
||||||
|
entries[pivot - 1].wrapper.scrollIntoView({block: 'start'});
|
||||||
|
}
|
||||||
|
pdfCurrentPage = pivot;
|
||||||
|
|
||||||
const byEl = new Map(entries.map(e => [e.wrapper, e]));
|
const byEl = new Map(entries.map(e => [e.wrapper, e]));
|
||||||
if (typeof IntersectionObserver === 'function') {
|
if (typeof IntersectionObserver === 'function') {
|
||||||
lazy.observer = new IntersectionObserver(items => {
|
view.observer = new IntersectionObserver(items => {
|
||||||
|
if (readerSettings.pdfPaginated) return;
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const e = byEl.get(it.target);
|
const e = byEl.get(it.target);
|
||||||
if (!e) continue;
|
if (!e) continue;
|
||||||
e.near = it.isIntersecting;
|
e.near = it.isIntersecting;
|
||||||
if (e.near) { if (!e.drawn) lazy.queue.add(e); }
|
if (e.near) { if (!e.drawn) view.queue.add(e); }
|
||||||
else { lazy.queue.delete(e); letGo(e); }
|
else { view.queue.delete(e); if (e.drawn || e.task) _pdfLetGo(e, pdf); }
|
||||||
}
|
}
|
||||||
work();
|
work();
|
||||||
}, {root: contentEl, rootMargin: '150% 0px'});
|
}, {root: contentEl, rootMargin: '150% 0px'});
|
||||||
entries.forEach(e => lazy.observer.observe(e.wrapper));
|
entries.forEach(e => view.observer.observe(e.wrapper));
|
||||||
} else {
|
} else {
|
||||||
// Very old WebViews: every page, from the pivot on (as before).
|
// Very old WebViews: every page, from the pivot on (as before).
|
||||||
entries.forEach(e => { e.near = true; lazy.queue.add(e); });
|
entries.forEach(e => { e.near = true; view.queue.add(e); });
|
||||||
work();
|
work();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,5 +726,12 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
||||||
pe.near = true;
|
pe.near = true;
|
||||||
if (!pe.drawn) await draw(pe);
|
if (!pe.drawn) await draw(pe);
|
||||||
if (gone()) return null;
|
if (gone()) return null;
|
||||||
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
|
return _pdfResult(pdf, extras);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What openBook needs of the document: the title (asked for while the page was drawn) and
|
||||||
|
// the table of contents to come (a long one resolves its pages after the book is shown).
|
||||||
|
async function _pdfResult(pdf, extras) {
|
||||||
|
const meta = await extras.meta;
|
||||||
|
return {title: meta.title, author: meta.author, toc: [], tocLater: extras.toc, numPages: pdf.numPages};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,65 @@ function _immHandleTap(e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A double click on the text (with a mouse): full screen, the bars out of the
|
||||||
|
// way; again (or Esc): back as it was. Fingers keep their double tap; beside
|
||||||
|
// the text a click turns the page (63-pages.js), so only on the text itself.
|
||||||
|
let _readerFsHidBars = false;
|
||||||
|
|
||||||
|
// What openBook hangs on the content and the page number for this book, taken off again in
|
||||||
|
// closeReader — else every book opened adds its own, and a PDF's handlers run under the next
|
||||||
|
// EPUB. (Not addEventListener's signal: the tolino's Chrome 61 ignores it.)
|
||||||
|
let _readerListeners = [];
|
||||||
|
|
||||||
|
function _readerListen(el, type, fn, opts) {
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener(type, fn, opts);
|
||||||
|
_readerListeners.push([el, type, fn, opts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _readerUnlistenAll() {
|
||||||
|
for (const [el, type, fn, opts] of _readerListeners) el.removeEventListener(type, fn, opts);
|
||||||
|
_readerListeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _readerDblClick(e) {
|
||||||
|
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
|
||||||
|
if (!e.target.closest('[data-epub-src], .pdf-page-wrapper')) return;
|
||||||
|
if (e.target.closest('button, a, input, select, textarea, label, [data-footnote-ref], mark.epub-highlight')) return;
|
||||||
|
// The double click also picked a word: that's not a marking.
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel) sel.removeAllRanges();
|
||||||
|
if (typeof dismissHighlightPopover === 'function') dismissHighlightPopover();
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
if (document.exitFullscreen) document.exitFullscreen().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = document.documentElement;
|
||||||
|
if (!el.requestFullscreen) return;
|
||||||
|
el.requestFullscreen().then(() => {
|
||||||
|
if (_immBarsVisible) {
|
||||||
|
_immBarsVisible = false;
|
||||||
|
document.body.classList.add('reader-immersive');
|
||||||
|
_readerFsHidBars = true;
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
if (document.fullscreenElement || !_readerFsHidBars) return;
|
||||||
|
_readerFsHidBars = false;
|
||||||
|
const overlay = $('reader-overlay');
|
||||||
|
if (overlay && overlay.style.display !== 'none' && !_immBarsVisible) {
|
||||||
|
_immBarsVisible = true;
|
||||||
|
document.body.classList.remove('reader-immersive');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const row = $('reader-body-row');
|
||||||
|
if (row) row.addEventListener('dblclick', _readerDblClick);
|
||||||
|
});
|
||||||
|
|
||||||
function enterReaderImmersiveMode() {
|
function enterReaderImmersiveMode() {
|
||||||
_immBarsVisible = true;
|
_immBarsVisible = true;
|
||||||
document.body.classList.remove('reader-immersive');
|
document.body.classList.remove('reader-immersive');
|
||||||
|
|
@ -64,8 +123,10 @@ function cancelBookOpening() {
|
||||||
if (overlay) overlay.style.display = 'none';
|
if (overlay) overlay.style.display = 'none';
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
if (contentEl) contentEl.innerHTML = '';
|
if (contentEl) contentEl.innerHTML = '';
|
||||||
|
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
|
||||||
currentPdfDoc = null;
|
currentPdfDoc = null;
|
||||||
currentPdfBuffer = null;
|
currentPdfBuffer = null;
|
||||||
|
_readerUnlistenAll();
|
||||||
_readPos = null;
|
_readPos = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +147,36 @@ async function openBook(bookId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async /**
|
||||||
|
* Book [bookId]'s entry with its place: the server's (offline: the one kept here), or an own
|
||||||
|
* newer one the server doesn't have yet. null when there's none.
|
||||||
|
*/
|
||||||
|
async function _bookProgress(bookId) {
|
||||||
|
let bookData = null;
|
||||||
|
try {
|
||||||
|
const progressRes = await fetch('/books/', {cache: 'no-store'});
|
||||||
|
_noteServerNowHeader(progressRes);
|
||||||
|
const allBooks = await progressRes.json();
|
||||||
|
bookData = allBooks.find(b => b.id === bookId) || null;
|
||||||
|
} catch {
|
||||||
|
// offline — use cached meta
|
||||||
|
return _bookProgressCached(bookId);
|
||||||
|
}
|
||||||
|
// The newer of the server's place and an own one it doesn't have yet.
|
||||||
|
return bookData ? _withPendingProgress(bookData) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same from what's kept on this device only (no waiting for the network). */
|
||||||
|
async function _bookProgressCached(bookId) {
|
||||||
|
try {
|
||||||
|
const cached = await _loadCachedBookMeta();
|
||||||
|
const bookData = cached.find(b => b.id === bookId) || null;
|
||||||
|
return bookData ? _withPendingProgress(bookData) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function _openBookOnce(bookId) {
|
async function _openBookOnce(bookId) {
|
||||||
// Nothing is saved for this book until its place is restored.
|
// Nothing is saved for this book until its place is restored.
|
||||||
_readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false};
|
_readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false};
|
||||||
|
|
@ -107,6 +198,9 @@ async function _openBookOnce(bookId) {
|
||||||
loadingEl.querySelector('button').addEventListener('click', () => cancelBookOpening());
|
loadingEl.querySelector('button').addEventListener('click', () => cancelBookOpening());
|
||||||
const showLoading = setTimeout(() => { if (!tok.cancelled) overlay.appendChild(loadingEl); }, 400);
|
const showLoading = setTimeout(() => { if (!tok.cancelled) overlay.appendChild(loadingEl); }, 400);
|
||||||
const left = () => tok.cancelled;
|
const left = () => tok.cancelled;
|
||||||
|
// The place this book was read at: asked for at once, alongside the book's file (a PDF
|
||||||
|
// opens at that page instead of drawing page 1 first).
|
||||||
|
const progressP = _bookProgress(bookId).catch(() => null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loadReaderSettings(bookId);
|
loadReaderSettings(bookId);
|
||||||
|
|
@ -151,14 +245,28 @@ async function _openBookOnce(bookId) {
|
||||||
const isPdfBook = cachedMeta.type === 'pdf';
|
const isPdfBook = cachedMeta.type === 'pdf';
|
||||||
|
|
||||||
if (isPdfBook) {
|
if (isPdfBook) {
|
||||||
currentPdfDoc = null; // reset so renderPdf creates fresh doc
|
// A document still open (another book) goes; renderPdf loads this one.
|
||||||
const result = await renderPdf(plain, contentEl);
|
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
|
||||||
|
currentPdfDoc = null;
|
||||||
|
// The colours of the reader first: the pages may be drawn in them (Leserfarben).
|
||||||
|
applyReaderSettings(true);
|
||||||
|
// The page it opens at: the place saved, if it's known by now (else the one kept here).
|
||||||
|
const saved = await Promise.race([progressP, new Promise(r => setTimeout(() => r(undefined), 400))])
|
||||||
|
|| await _bookProgressCached(bookId);
|
||||||
|
const startFraction = saved ? (saved.scroll_fraction || 0) : 0;
|
||||||
|
const result = await renderPdf(plain, contentEl, undefined,
|
||||||
|
n => (startFraction > 0 && n > 1 ? Math.max(1, Math.min(n, Math.round(startFraction * (n - 1)) + 1)) : 1));
|
||||||
if (left() || !result) return;
|
if (left() || !result) return;
|
||||||
title = result.title || title;
|
title = result.title || title;
|
||||||
author = result.author || author;
|
author = result.author || author;
|
||||||
toc = result.toc;
|
toc = result.toc;
|
||||||
numPages = result.numPages;
|
numPages = result.numPages;
|
||||||
currentPdfBuffer = plain;
|
// The table of contents follows when its pages are resolved.
|
||||||
|
result.tocLater.then(entries => {
|
||||||
|
if (currentBookId === bookId && currentPdfDoc && entries.length) currentBookToc = entries;
|
||||||
|
});
|
||||||
|
// pdf.js holds the document: the decrypted bytes needn't stay as well.
|
||||||
|
currentPdfBuffer = null;
|
||||||
} else {
|
} else {
|
||||||
currentPdfBuffer = null;
|
currentPdfBuffer = null;
|
||||||
// Prepared before (same book file, same preparation code): only the images to unpack.
|
// Prepared before (same book file, same preparation code): only the images to unpack.
|
||||||
|
|
@ -219,18 +327,14 @@ async function _openBookOnce(bookId) {
|
||||||
// Apply reader settings (theme, font size, etc.)
|
// Apply reader settings (theme, font size, etc.)
|
||||||
applyReaderSettings(isPdfBook);
|
applyReaderSettings(isPdfBook);
|
||||||
|
|
||||||
// If this book already has notes/highlights, surface them right away
|
// The notes margin stays closed when a book opens (also with notes in it): it's one
|
||||||
// instead of making the reader hide them behind a manual toggle.
|
// tap away (✎), and opens by itself when a note is written.
|
||||||
// Not on phones: there the margin covers the text; it stays one tap away (✎).
|
|
||||||
if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) {
|
|
||||||
toggleAnnotationsMargin();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch: swipe (paginated) + pinch-to-zoom
|
// Touch: swipe (paginated) + pinch-to-zoom
|
||||||
contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true});
|
_readerListen(contentEl, 'touchstart', _pdfTouchStart, {passive: true});
|
||||||
contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false});
|
_readerListen(contentEl, 'touchmove', _pdfTouchMove, {passive: false});
|
||||||
contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true});
|
_readerListen(contentEl, 'touchend', _pdfTouchEnd, {passive: true});
|
||||||
contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true});
|
_readerListen(contentEl, 'touchcancel', _pdfTouchEnd, {passive: true});
|
||||||
|
|
||||||
// Set up progress input
|
// Set up progress input
|
||||||
const progressInput = $('reader-progress-input');
|
const progressInput = $('reader-progress-input');
|
||||||
|
|
@ -247,16 +351,13 @@ async function _openBookOnce(bookId) {
|
||||||
if (progressSuffix) progressSuffix.textContent = '%';
|
if (progressSuffix) progressSuffix.textContent = '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
progressInput.addEventListener('change', function () {
|
_readerListen(progressInput, 'change', function () {
|
||||||
_suppressScrollJumpDetect();
|
_suppressScrollJumpDetect();
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1));
|
// The book's page number ("iv", "12") or the file's.
|
||||||
this.value = page;
|
const page = Math.min(numPages, Math.max(1, _pdfPageFromInput(this.value) || pdfCurrentPage || 1));
|
||||||
const target = contentEl.querySelector(`#pdf-page-${page}`);
|
this.value = pdfPageLabel(page);
|
||||||
if (target) {
|
pdfGoToPage(page);
|
||||||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
|
||||||
contentEl.scrollBy({top: top - 8, behavior: 'smooth'});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
|
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
|
||||||
this.value = _formatBookPercent(pct / 100);
|
this.value = _formatBookPercent(pct / 100);
|
||||||
|
|
@ -264,7 +365,7 @@ async function _openBookOnce(bookId) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
progressInput.addEventListener('click', function () { this.select(); });
|
_readerListen(progressInput, 'click', function () { this.select(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore scroll position — must happen BEFORE auto-save timer is started
|
// Restore scroll position — must happen BEFORE auto-save timer is started
|
||||||
|
|
@ -272,19 +373,7 @@ async function _openBookOnce(bookId) {
|
||||||
let openedProgress = null;
|
let openedProgress = null;
|
||||||
try {
|
try {
|
||||||
let fraction = 0, anchor = '';
|
let fraction = 0, anchor = '';
|
||||||
let bookData = null;
|
const bookData = await progressP;
|
||||||
try {
|
|
||||||
const progressRes = await fetch('/books/', {cache: 'no-store'});
|
|
||||||
_noteServerNowHeader(progressRes);
|
|
||||||
const allBooks = await progressRes.json();
|
|
||||||
bookData = allBooks.find(b => b.id === bookId) || null;
|
|
||||||
} catch {
|
|
||||||
// offline — use cached meta
|
|
||||||
const cached = await _loadCachedBookMeta();
|
|
||||||
bookData = cached.find(b => b.id === bookId) || null;
|
|
||||||
}
|
|
||||||
// The newer of the server's place and an own one it doesn't have yet.
|
|
||||||
if (bookData) bookData = _withPendingProgress(bookData);
|
|
||||||
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
||||||
anchor = bookData ? (bookData.position_anchor || '') : '';
|
anchor = bookData ? (bookData.position_anchor || '') : '';
|
||||||
openedProgress = bookData;
|
openedProgress = bookData;
|
||||||
|
|
@ -298,6 +387,7 @@ async function _openBookOnce(bookId) {
|
||||||
await new Promise(r => requestAnimationFrame(r));
|
await new Promise(r => requestAnimationFrame(r));
|
||||||
if (fraction > 0)
|
if (fraction > 0)
|
||||||
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
||||||
|
_pdfSetCurrentPage(_pdfTopPage(contentEl));
|
||||||
} else {
|
} else {
|
||||||
// EPUB — wait for images so scrollHeight is final
|
// EPUB — wait for images so scrollHeight is final
|
||||||
const imgs = Array.from(contentEl.querySelectorAll('img'));
|
const imgs = Array.from(contentEl.querySelectorAll('img'));
|
||||||
|
|
@ -330,29 +420,24 @@ async function _openBookOnce(bookId) {
|
||||||
// first — that could otherwise be a later, unrelated layout correction.
|
// first — that could otherwise be a later, unrelated layout correction.
|
||||||
_scrollRestTop = contentEl.scrollTop;
|
_scrollRestTop = contentEl.scrollTop;
|
||||||
|
|
||||||
// Update progress input on scroll
|
// PDF scrolling: the page shown at the top is the current one (header, bookmarks, the
|
||||||
contentEl.addEventListener('scroll', () => {
|
// app's footer). EPUB: shown from the position anchor, see the anchor tracking below.
|
||||||
if (!progressInput) return;
|
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
let pageRaf = 0;
|
||||||
const cTop = contentEl.getBoundingClientRect().top;
|
_readerListen(contentEl, 'scroll', () => {
|
||||||
let currentPage = 1;
|
if (pageRaf || readerSettings.pdfPaginated) return;
|
||||||
for (const w of wrappers) {
|
pageRaf = requestAnimationFrame(() => {
|
||||||
if (w.getBoundingClientRect().bottom > cTop + 20) {
|
pageRaf = 0;
|
||||||
currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1;
|
if (currentPdfDoc) _pdfSetCurrentPage(_pdfTopPage(contentEl));
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
progressInput.value = currentPage;
|
|
||||||
}
|
|
||||||
// EPUB: shown from the position anchor, see the anchor tracking below.
|
|
||||||
});
|
});
|
||||||
|
}, {passive: true});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-save progress every 10s and on scroll (debounced 2s)
|
// Auto-save progress every 10s and on scroll (debounced 2s)
|
||||||
// Started AFTER restore so an early visibilitychange can't overwrite with position 0
|
// Started AFTER restore so an early visibilitychange can't overwrite with position 0
|
||||||
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
|
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
|
||||||
let _scrollDebounce = null;
|
let _scrollDebounce = null;
|
||||||
contentEl.addEventListener('scroll', () => {
|
_readerListen(contentEl, 'scroll', () => {
|
||||||
clearTimeout(_scrollDebounce);
|
clearTimeout(_scrollDebounce);
|
||||||
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
|
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
|
||||||
}, {passive: true});
|
}, {passive: true});
|
||||||
|
|
@ -362,7 +447,7 @@ async function _openBookOnce(bookId) {
|
||||||
// scrolling) would otherwise restore to a stale, pre-scroll anchor.
|
// scrolling) would otherwise restore to a stale, pre-scroll anchor.
|
||||||
if (!isPdf) {
|
if (!isPdf) {
|
||||||
let _anchorTrackDebounce = null;
|
let _anchorTrackDebounce = null;
|
||||||
contentEl.addEventListener('scroll', () => {
|
_readerListen(contentEl, 'scroll', () => {
|
||||||
clearTimeout(_anchorTrackDebounce);
|
clearTimeout(_anchorTrackDebounce);
|
||||||
_anchorTrackDebounce = setTimeout(() => {
|
_anchorTrackDebounce = setTimeout(() => {
|
||||||
_currentPositionAnchor = getPositionAnchor(contentEl);
|
_currentPositionAnchor = getPositionAnchor(contentEl);
|
||||||
|
|
@ -391,7 +476,7 @@ async function _openBookOnce(bookId) {
|
||||||
// fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above.
|
// fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above.
|
||||||
_scrollRestTop = null;
|
_scrollRestTop = null;
|
||||||
let _scrollJumpRaf = null;
|
let _scrollJumpRaf = null;
|
||||||
contentEl.addEventListener('scroll', () => {
|
_readerListen(contentEl, 'scroll', () => {
|
||||||
if (_scrollJumpRaf) return;
|
if (_scrollJumpRaf) return;
|
||||||
_scrollJumpRaf = requestAnimationFrame(() => {
|
_scrollJumpRaf = requestAnimationFrame(() => {
|
||||||
_scrollJumpRaf = null;
|
_scrollJumpRaf = null;
|
||||||
|
|
@ -549,6 +634,8 @@ function closeReader() {
|
||||||
// Still opening: just leave it (nothing to save yet).
|
// Still opening: just leave it (nothing to save yet).
|
||||||
if (cancelBookOpening()) return;
|
if (cancelBookOpening()) return;
|
||||||
if (typeof _issueReaderClosed === 'function') _issueReaderClosed();
|
if (typeof _issueReaderClosed === 'function') _issueReaderClosed();
|
||||||
|
// An article (65-article.js): where it was read to, before the reader empties.
|
||||||
|
if (typeof _articleReaderClosed === 'function') _articleReaderClosed();
|
||||||
exitReaderImmersiveMode();
|
exitReaderImmersiveMode();
|
||||||
stopReadAloud();
|
stopReadAloud();
|
||||||
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
|
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
|
||||||
|
|
@ -598,12 +685,12 @@ function closeReader() {
|
||||||
|
|
||||||
// Remove touch handlers and free image blob URLs
|
// Remove touch handlers and free image blob URLs
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
|
_readerUnlistenAll();
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
contentEl.removeEventListener('touchstart', _pdfTouchStart);
|
contentEl.removeEventListener('click', _pdfPaginatedClick);
|
||||||
contentEl.removeEventListener('touchmove', _pdfTouchMove);
|
contentEl.removeEventListener('wheel', _pdfPaginatedWheel);
|
||||||
contentEl.removeEventListener('touchend', _pdfTouchEnd);
|
contentEl.classList.remove('pinch-active', 'pdf-paginated');
|
||||||
contentEl.removeEventListener('touchcancel', _pdfTouchEnd);
|
contentEl.style.overflow = '';
|
||||||
contentEl.classList.remove('pinch-active');
|
|
||||||
contentEl.innerHTML = '';
|
contentEl.innerHTML = '';
|
||||||
}
|
}
|
||||||
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
|
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
|
||||||
|
|
@ -616,6 +703,7 @@ function closeReader() {
|
||||||
if (typeof _pdfStopLazy === 'function') _pdfStopLazy();
|
if (typeof _pdfStopLazy === 'function') _pdfStopLazy();
|
||||||
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
|
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
|
||||||
currentPdfDoc = null;
|
currentPdfDoc = null;
|
||||||
|
if (typeof _pdfForgetDoc === 'function') _pdfForgetDoc();
|
||||||
currentPdfBuffer = null;
|
currentPdfBuffer = null;
|
||||||
currentBookmarks = [];
|
currentBookmarks = [];
|
||||||
bookmarksDirty = false;
|
bookmarksDirty = false;
|
||||||
|
|
@ -667,13 +755,9 @@ function jumpToTocEntry(href) {
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
|
|
||||||
// PDF page jump
|
// PDF page jump (scrolling or page by page)
|
||||||
if (href.startsWith('#pdf-page-')) {
|
if (href.startsWith('#pdf-page-')) {
|
||||||
const target = contentEl.querySelector(href);
|
pdfGoToPage(parseInt(href.slice('#pdf-page-'.length), 10) || 1);
|
||||||
if (target) {
|
|
||||||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
|
||||||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1030
static/js/app/63-pages.js
Normal file
1030
static/js/app/63-pages.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -15,10 +15,14 @@ function _readerTypeDefaults() {
|
||||||
? _READER_TYPE_DEFAULTS_WIDE : _READER_TYPE_DEFAULTS_NARROW;
|
? _READER_TYPE_DEFAULTS_WIDE : _READER_TYPE_DEFAULTS_NARROW;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where the settings shown are kept: the book's id, or 'article' (all articles, 65-article.js).
|
||||||
|
let _readerSettingsKey = null;
|
||||||
|
|
||||||
function loadReaderSettings(bookId) {
|
function loadReaderSettings(bookId) {
|
||||||
|
_readerSettingsKey = bookId;
|
||||||
// Reset to defaults, then apply per-book overrides
|
// Reset to defaults, then apply per-book overrides
|
||||||
Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false,
|
Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false,
|
||||||
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false },
|
pdfZoom: 100, pdfInverted: false, pdfPageColors: false, pdfPaginated: false, pdfSpread: false },
|
||||||
_readerTypeDefaults());
|
_readerTypeDefaults());
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}');
|
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}');
|
||||||
|
|
@ -40,8 +44,9 @@ function loadReaderSettings(bookId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveReaderSettings() {
|
function saveReaderSettings() {
|
||||||
if (currentBookId) {
|
const key = currentBookId || _readerSettingsKey;
|
||||||
localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 3 }));
|
if (key) {
|
||||||
|
localStorage.setItem(`diora_reader_settings_${key}`, JSON.stringify({ ...readerSettings, v: 3 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,6 +94,12 @@ function applyReaderSettings(isPdf) {
|
||||||
// PDF invert
|
// PDF invert
|
||||||
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted');
|
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted');
|
||||||
else overlay.classList.remove('pdf-inverted');
|
else overlay.classList.remove('pdf-inverted');
|
||||||
|
// Leserfarben: another theme changes the filter's colours; switching it on or off draws
|
||||||
|
// the pages anew (with or without their pictures laid over them).
|
||||||
|
if (isPdf && currentPdfDoc) {
|
||||||
|
_pdfColorFilter();
|
||||||
|
if (_pdfView && _pdfView.colorsKey !== _pdfColorsKey()) reRenderPdf();
|
||||||
|
}
|
||||||
|
|
||||||
// Annotations (marker mode + margin panel) are EPUB-only for now — PDFs have no text layer yet
|
// Annotations (marker mode + margin panel) are EPUB-only for now — PDFs have no text layer yet
|
||||||
const marginBtn = $('reader-margin-btn');
|
const marginBtn = $('reader-margin-btn');
|
||||||
|
|
@ -113,6 +124,21 @@ function _readerWeight() {
|
||||||
return Math.round(w / step) * step;
|
return Math.round(w / step) * step;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Scrollen | Seiten" and, in pages, whether the notes stand at the page's foot
|
||||||
|
// and whether two pages lie side by side.
|
||||||
|
// The apps' e-reader mode always reads in pages: no choice there.
|
||||||
|
function _readerModeButtons() {
|
||||||
|
if (!window.DioraPages || DioraPages.isForced()) return '';
|
||||||
|
const pages = DioraPages.isOn();
|
||||||
|
return `
|
||||||
|
<button class="btn btn-sm ${pages ? '' : 'active'}" data-rs-mode="scroll">Scrollen</button>
|
||||||
|
<button class="btn btn-sm ${pages ? 'active' : ''}" data-rs-mode="pages">Seiten</button>
|
||||||
|
<button class="btn btn-sm ${DioraPages.pageNotes() ? 'active' : ''}" id="rs-page-notes" style="${pages ? '' : 'display:none'}"
|
||||||
|
title="Die Fußnoten stehen unten auf der Seite, auf der auf sie verwiesen wird">Fußnoten unten</button>
|
||||||
|
<button class="btn btn-sm ${DioraPages.spread() ? 'active' : ''}" id="rs-page-spread" style="${pages ? '' : 'display:none'}"
|
||||||
|
title="Zwei Seiten nebeneinander wie ein aufgeschlagenes Buch — ab einer Breite von 900 Pixeln">Doppelseite</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSettingsPanel() {
|
function toggleSettingsPanel() {
|
||||||
const overlay = $('reader-overlay');
|
const overlay = $('reader-overlay');
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
|
|
@ -148,12 +174,20 @@ function toggleSettingsPanel() {
|
||||||
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dunkel</button>
|
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dunkel</button>
|
||||||
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
|
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
|
||||||
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Hell</button>
|
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Hell</button>
|
||||||
|
${_readerModeButtons()}
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<label>Zoom <button class="btn btn-sm" id="rs-zoom-minus">−</button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
|
<label>Zoom <button class="btn btn-sm" id="rs-zoom-minus">−</button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
|
||||||
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invertieren</button>
|
<button class="btn btn-sm ${readerSettings.pdfPaginated ? '' : 'active'}" data-rs-pdfmode="scroll">Scrollen</button>
|
||||||
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Doppelseite</button>
|
<button class="btn btn-sm ${readerSettings.pdfPaginated ? 'active' : ''}" data-rs-pdfmode="pages">Seiten</button>
|
||||||
|
<button class="btn btn-sm ${!readerSettings.pdfInverted && !readerSettings.pdfPageColors ? 'active' : ''}" data-rs-pdfcolors="original"
|
||||||
|
title="Die Seiten wie gedruckt">Originalfarben</button>
|
||||||
|
<button class="btn btn-sm ${!readerSettings.pdfInverted && readerSettings.pdfPageColors ? 'active' : ''}" data-rs-pdfcolors="reader"
|
||||||
|
title="Text und Hintergrund in den Farben des Lesers (Dunkel, Sepia), Bilder bleiben, wie sie sind">Leserfarben</button>
|
||||||
|
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" data-rs-pdfcolors="invert"
|
||||||
|
title="Die ganze Seite umgekehrt, auch Bilder (Farben bleiben ungefähr)">Invertieren</button>
|
||||||
|
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread"${readerSettings.pdfPaginated ? ' style="display:none"' : ''}>Doppelseite</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,21 +301,78 @@ function toggleSettingsPanel() {
|
||||||
saveReaderSettings();
|
saveReaderSettings();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scrolling or pages (63-pages.js): per device, for every book.
|
||||||
|
const syncMode = () => {
|
||||||
|
const pages = DioraPages.isOn();
|
||||||
|
panel.querySelectorAll('[data-rs-mode]').forEach(b => b.classList.toggle('active', (b.dataset.rsMode === 'pages') === pages));
|
||||||
|
const notes = panel.querySelector('#rs-page-notes');
|
||||||
|
if (notes) {
|
||||||
|
notes.style.display = pages ? '' : 'none';
|
||||||
|
notes.classList.toggle('active', DioraPages.pageNotes());
|
||||||
|
}
|
||||||
|
const spread = panel.querySelector('#rs-page-spread');
|
||||||
|
if (spread) {
|
||||||
|
spread.style.display = pages ? '' : 'none';
|
||||||
|
spread.classList.toggle('active', DioraPages.spread());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
panel.querySelectorAll('[data-rs-mode]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => { DioraPages.setOn(btn.dataset.rsMode === 'pages'); syncMode(); });
|
||||||
|
});
|
||||||
|
panel.querySelector('#rs-page-notes')?.addEventListener('click', () => { DioraPages.togglePageNotes(); syncMode(); });
|
||||||
|
panel.querySelector('#rs-page-spread')?.addEventListener('click', () => { DioraPages.setSpread(!DioraPages.spread()); syncMode(); });
|
||||||
} else {
|
} else {
|
||||||
const zoomRange = panel.querySelector('#rs-zoom');
|
const zoomRange = panel.querySelector('#rs-zoom');
|
||||||
|
|
||||||
zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10)));
|
// While the slider moves only the number follows; the pages are drawn anew once it rests.
|
||||||
|
let zoomTimer = 0;
|
||||||
|
zoomRange.addEventListener('input', () => {
|
||||||
|
const z = parseInt(zoomRange.value, 10);
|
||||||
|
const val = panel.querySelector('#rs-zoom-val');
|
||||||
|
if (val) val.textContent = z + '%';
|
||||||
|
clearTimeout(zoomTimer);
|
||||||
|
zoomTimer = setTimeout(() => applyPdfZoom(z), 250);
|
||||||
|
});
|
||||||
panel.querySelector('#rs-zoom-minus').addEventListener('click', () =>
|
panel.querySelector('#rs-zoom-minus').addEventListener('click', () =>
|
||||||
applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10)));
|
applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10)));
|
||||||
panel.querySelector('#rs-zoom-plus').addEventListener('click', () =>
|
panel.querySelector('#rs-zoom-plus').addEventListener('click', () =>
|
||||||
applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10)));
|
applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10)));
|
||||||
|
|
||||||
panel.querySelector('#rs-invert').addEventListener('click', function () {
|
// The pages' colours: as printed, in the reader's colours, or inverted.
|
||||||
readerSettings.pdfInverted = !readerSettings.pdfInverted;
|
panel.querySelectorAll('[data-rs-pdfcolors]').forEach(btn => {
|
||||||
this.classList.toggle('active', readerSettings.pdfInverted);
|
btn.addEventListener('click', () => {
|
||||||
|
const which = btn.dataset.rsPdfcolors;
|
||||||
|
readerSettings.pdfInverted = which === 'invert';
|
||||||
|
readerSettings.pdfPageColors = which === 'reader';
|
||||||
|
panel.querySelectorAll('[data-rs-pdfcolors]').forEach(b => b.classList.toggle('active', b === btn));
|
||||||
applyReaderSettings(true);
|
applyReaderSettings(true);
|
||||||
saveReaderSettings();
|
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 () {
|
||||||
readerSettings.pdfSpread = !readerSettings.pdfSpread;
|
readerSettings.pdfSpread = !readerSettings.pdfSpread;
|
||||||
|
|
@ -293,37 +384,42 @@ function toggleSettingsPanel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPdfZoom(newZoom) {
|
/**
|
||||||
|
* The PDF at another zoom (%). Scrolling, [anchor] (_pdfPointAt: where the fingers were) stays
|
||||||
|
* in place, else the middle of the screen; page by page, the page is fitted anew (_pdfFitPage).
|
||||||
|
*/
|
||||||
|
async function applyPdfZoom(newZoom, anchor) {
|
||||||
|
const old = readerSettings.pdfZoom;
|
||||||
readerSettings.pdfZoom = newZoom;
|
readerSettings.pdfZoom = newZoom;
|
||||||
const zoomRange = document.getElementById('rs-zoom');
|
const zoomRange = document.getElementById('rs-zoom');
|
||||||
const zoomVal = document.getElementById('rs-zoom-val');
|
const zoomVal = document.getElementById('rs-zoom-val');
|
||||||
if (zoomRange) zoomRange.value = newZoom;
|
if (zoomRange) zoomRange.value = newZoom;
|
||||||
if (zoomVal) zoomVal.textContent = newZoom + '%';
|
if (zoomVal) zoomVal.textContent = newZoom + '%';
|
||||||
saveReaderSettings();
|
saveReaderSettings();
|
||||||
|
if (!currentPdfDoc) return;
|
||||||
if (readerSettings.pdfPaginated) {
|
if (readerSettings.pdfPaginated) {
|
||||||
pdfSmartZoomPage(pdfCurrentPage);
|
await _pdfShowPage(pdfCurrentPage);
|
||||||
} else {
|
return;
|
||||||
await reRenderPdf();
|
|
||||||
}
|
}
|
||||||
|
if (newZoom === old) return;
|
||||||
|
if (!anchor) {
|
||||||
|
const r = $('reader-content').getBoundingClientRect();
|
||||||
|
anchor = _pdfPointAt(r.left + r.width / 2, r.top + r.height / 2);
|
||||||
|
}
|
||||||
|
if (!_pdfRezoomScroll(newZoom / old, anchor)) await reRenderPdf();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page at the top of the view (scrolling) — 58-pdf.js keeps the pages in order.
|
||||||
function _currentScrollPage(contentEl) {
|
function _currentScrollPage(contentEl) {
|
||||||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
return _pdfTopPage(contentEl);
|
||||||
const viewTop = contentEl.scrollTop;
|
|
||||||
for (const w of wrappers) {
|
|
||||||
if (w.offsetTop + w.offsetHeight > viewTop) {
|
|
||||||
const n = parseInt(w.id.replace('pdf-page-', ''), 10);
|
|
||||||
if (n) return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pdfCurrentPage || 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reRenderPdf() {
|
/** Lay the open PDF out again (zoom, spreads, another width), at [pivot] or the page shown now. */
|
||||||
if (!currentPdfBuffer) return;
|
async function reRenderPdf(pivot) {
|
||||||
|
if (!currentPdfDoc) return;
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl);
|
if (!pivot) pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _pdfTopPage(contentEl);
|
||||||
await renderPdf(currentPdfBuffer, contentEl, undefined, pivot);
|
await renderPdf(null, contentEl, undefined, pivot);
|
||||||
if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
|
if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
static/js/app/65-article.js
Normal file
174
static/js/app/65-article.js
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Articles in the book reader (DioraArticle) — blog posts and news (feeds.js)
|
||||||
|
// read like books: the same overlay, typography and colours (⚙, kept for all
|
||||||
|
// articles together), pages or scrolling (63-pages.js) with the notes at the
|
||||||
|
// page's foot, search, read-aloud, full screen. Bookmarks, markings and the
|
||||||
|
// notes margin stay with books (an article has nowhere to keep them).
|
||||||
|
//
|
||||||
|
// The article is one "chapter" in #reader-content; currentBookId stays null,
|
||||||
|
// so nothing of a book's position or annotations is saved. Where it was read
|
||||||
|
// to goes back to its owner (onProgress), as do clicks on its own buttons.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
(function () {
|
||||||
|
let session = null; // {title, html, startFraction, onProgress, onClick, onClose, actions}
|
||||||
|
let progressTimer = 0;
|
||||||
|
|
||||||
|
const overlay = () => document.getElementById('reader-overlay');
|
||||||
|
const content = () => document.getElementById('reader-content');
|
||||||
|
|
||||||
|
// Same-page links: the notes of a blog post ("¹" → "#fn1") become references
|
||||||
|
// like in books; other jumps inside the post must not change the app's
|
||||||
|
// address (#books, #feeds …): kept as internal links (the apps follow them).
|
||||||
|
function markLinks(root) {
|
||||||
|
root.querySelectorAll('a[href^="#"]').forEach(a => {
|
||||||
|
const href = a.getAttribute('href');
|
||||||
|
a.removeAttribute('href');
|
||||||
|
if (href.length < 2) return;
|
||||||
|
// "#t=754": a second in a podcast's article, never a note.
|
||||||
|
if (!/^#t=\d+$/.test(href) && !_isNoteBacklink(a) && _looksLikeFootnoteLink(a, href)) {
|
||||||
|
a.dataset.footnoteRef = href.slice(1);
|
||||||
|
a.dataset.footnoteHref = href;
|
||||||
|
a.style.cursor = 'pointer';
|
||||||
|
} else {
|
||||||
|
a.dataset.internalHref = href;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Everything else opens outside.
|
||||||
|
root.querySelectorAll('a[href]').forEach(a => {
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far it is read: down to the bottom of what is on screen (0-1).
|
||||||
|
function fraction() {
|
||||||
|
const c = content();
|
||||||
|
const ch = c && c.querySelector(':scope > [data-epub-src]');
|
||||||
|
if (!ch || !ch.offsetHeight) return 0;
|
||||||
|
return Math.max(0, Math.min(1, (c.scrollTop + c.clientHeight - ch.offsetTop) / ch.offsetHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
function report() {
|
||||||
|
if (session && session.onProgress) session.onProgress(fraction());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
clearTimeout(progressTimer);
|
||||||
|
progressTimer = setTimeout(report, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e) {
|
||||||
|
if (session && session.onClick) session.onClick(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The article's own buttons (star, project, original): in the browser's
|
||||||
|
// header; the apps put them into their reader menu (native-ui.js asks actions()).
|
||||||
|
function renderActions() {
|
||||||
|
const bar = document.querySelector('#reader-overlay .reader-header-actions');
|
||||||
|
if (!bar) return;
|
||||||
|
let box = bar.querySelector('.reader-article-actions');
|
||||||
|
if (!box) {
|
||||||
|
box = document.createElement('span');
|
||||||
|
box.className = 'reader-article-actions';
|
||||||
|
bar.insertBefore(box, bar.firstChild);
|
||||||
|
}
|
||||||
|
box.textContent = '';
|
||||||
|
actions().forEach(a => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'btn-icon' + (a.active ? ' active' : '');
|
||||||
|
b.title = a.label;
|
||||||
|
b.textContent = a.icon;
|
||||||
|
b.addEventListener('click', e => { e.stopPropagation(); a.action(); });
|
||||||
|
box.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function actions() {
|
||||||
|
return session && session.actions ? session.actions().filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(o) {
|
||||||
|
const ov = overlay();
|
||||||
|
const c = content();
|
||||||
|
if (!ov || !c) return;
|
||||||
|
if (session) {
|
||||||
|
report();
|
||||||
|
stopReadAloud(); // the next article: the voice was reading the last one
|
||||||
|
}
|
||||||
|
else if (ov.style.display !== 'none') closeReader(); // a book was open
|
||||||
|
session = o;
|
||||||
|
loadReaderSettings('article');
|
||||||
|
_charIndex = null;
|
||||||
|
_readPos = null;
|
||||||
|
currentBookId = null;
|
||||||
|
ov.classList.add('reader-article-mode');
|
||||||
|
$('reader-title').textContent = o.title || '';
|
||||||
|
c.textContent = '';
|
||||||
|
const ch = document.createElement('div');
|
||||||
|
ch.setAttribute('data-epub-src', 'article');
|
||||||
|
ch.id = 'epub-chapter-0';
|
||||||
|
ch.className = 'fd-artview';
|
||||||
|
ch.innerHTML = o.html;
|
||||||
|
markLinks(ch);
|
||||||
|
c.appendChild(ch);
|
||||||
|
ov.style.display = '';
|
||||||
|
applyReaderSettings(false);
|
||||||
|
enterReaderImmersiveMode();
|
||||||
|
renderActions();
|
||||||
|
c.scrollTop = 0;
|
||||||
|
if (window.DioraPages) DioraPages.opened();
|
||||||
|
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
|
||||||
|
const f = o.startFraction || 0;
|
||||||
|
if (f > 0.02 && f < 0.9) {
|
||||||
|
// Back where reading stopped (on any device).
|
||||||
|
await new Promise(r => requestAnimationFrame(r));
|
||||||
|
if (session !== o) return;
|
||||||
|
_suppressScrollJumpDetect();
|
||||||
|
c.scrollTop = Math.max(0, f * ch.offsetHeight + ch.offsetTop - c.clientHeight);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => { if (session === o) report(); }, 1500); // short enough to fit: read at once
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaces what is shown (the overview of a story arrived) without moving.
|
||||||
|
function update(html) {
|
||||||
|
const ch = content() && content().querySelector(':scope > [data-epub-src="article"]');
|
||||||
|
if (!ch || !session) return;
|
||||||
|
session.html = html;
|
||||||
|
ch.innerHTML = html;
|
||||||
|
markLinks(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeReader (62-reader.js) calls this before it empties the reader.
|
||||||
|
window._articleReaderClosed = function () {
|
||||||
|
if (!session) return;
|
||||||
|
clearTimeout(progressTimer);
|
||||||
|
report();
|
||||||
|
const s = session;
|
||||||
|
session = null;
|
||||||
|
overlay().classList.remove('reader-article-mode');
|
||||||
|
const box = document.querySelector('#reader-overlay .reader-article-actions');
|
||||||
|
if (box) box.remove();
|
||||||
|
if (s.onClose) setTimeout(s.onClose, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.DioraArticle = {
|
||||||
|
open,
|
||||||
|
update,
|
||||||
|
isOpen: () => !!session,
|
||||||
|
close: () => { if (session) closeReader(); },
|
||||||
|
actions,
|
||||||
|
refreshActions: renderActions,
|
||||||
|
fraction,
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const c = content();
|
||||||
|
if (!c) return;
|
||||||
|
c.addEventListener('scroll', () => { if (session) onScroll(); }, {passive: true});
|
||||||
|
c.addEventListener('click', e => { if (session) onClick(e); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -1,175 +1,386 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PDF Paginated Mode
|
// PDF page by page (phones and e-readers by default; "Seiten" in the PDF panel):
|
||||||
|
// one page at a time, cropped to its text so the letters are as large as the
|
||||||
|
// screen allows, the next one drawn ahead for the turn. A tap on the left or
|
||||||
|
// right turns (the middle shows the bars), so do a swipe, the arrow keys,
|
||||||
|
// Space, PageUp/PageDown and the mouse wheel. Every page goes through
|
||||||
|
// _pdfDraw (58-pdf.js).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// A page nearly empty (only its number) would be zoomed to fill the screen with one digit:
|
||||||
|
// at most this many times the size the whole page would have.
|
||||||
|
const PDF_CROP_MAX = 2.5;
|
||||||
|
const PDF_CROP_PAD = 12;
|
||||||
|
// Zoom (readerSettings.pdfZoom, %): the PDF panel's slider and pinching.
|
||||||
|
const PDF_ZOOM_MIN = 50;
|
||||||
|
const PDF_ZOOM_MAX = 200;
|
||||||
|
|
||||||
|
let _pdfTurnSaveTimer = 0;
|
||||||
|
let _pdfWheelSum = 0;
|
||||||
|
let _pdfWheelAt = 0;
|
||||||
|
let _touchStartY = 0;
|
||||||
|
// A touch gesture under way: {kind: 'pinch' | 'pan' | 'after'} (_pdfTouchStart).
|
||||||
|
let _pdfGesture = null;
|
||||||
|
let _pdfPanAt = 0; // when a page was last moved by a finger (that touch is no tap)
|
||||||
|
|
||||||
function enterPdfPaginatedMode() {
|
function enterPdfPaginatedMode() {
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
contentEl.classList.add('pdf-paginated');
|
contentEl.classList.add('pdf-paginated');
|
||||||
contentEl.style.overflow = 'hidden';
|
contentEl.style.overflow = 'hidden';
|
||||||
const pdfVp = document.getElementById('pdf-viewport');
|
const pdfVp = document.getElementById('pdf-viewport');
|
||||||
if (pdfVp) pdfVp.style.zoom = 1;
|
if (pdfVp) { pdfVp.style.transform = ''; pdfVp.style.transformOrigin = ''; }
|
||||||
|
// From scrolling: the drawing near the view stops, the pages are this mode's now.
|
||||||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
const v = _pdfView;
|
||||||
wrappers.forEach((w, i) => {
|
if (v && v.observer) { v.observer.disconnect(); v.observer = null; v.queue.clear(); }
|
||||||
w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
pdfSmartZoomPage(pdfCurrentPage);
|
|
||||||
|
|
||||||
// Tap left/right to navigate
|
|
||||||
contentEl.addEventListener('click', _pdfPaginatedClick);
|
contentEl.addEventListener('click', _pdfPaginatedClick);
|
||||||
|
contentEl.addEventListener('wheel', _pdfPaginatedWheel, {passive: false});
|
||||||
|
_pdfShowPage(pdfCurrentPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
function exitPdfPaginatedMode() {
|
/** Back to scrolling, at [page] (the one shown now). */
|
||||||
|
function exitPdfPaginatedMode(page) {
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
contentEl.classList.remove('pdf-paginated');
|
contentEl.classList.remove('pdf-paginated');
|
||||||
contentEl.style.overflow = '';
|
contentEl.style.overflow = '';
|
||||||
contentEl.removeEventListener('click', _pdfPaginatedClick);
|
contentEl.removeEventListener('click', _pdfPaginatedClick);
|
||||||
reRenderPdf();
|
contentEl.removeEventListener('wheel', _pdfPaginatedWheel);
|
||||||
|
reRenderPdf(page || pdfCurrentPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _pdfPaginatedClick(e) {
|
function _pdfPaginatedClick(e) {
|
||||||
const w = e.currentTarget.clientWidth;
|
if (e.target.closest('a, button, input')) return;
|
||||||
if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1);
|
// The page was just moved with a finger: no tap.
|
||||||
else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1);
|
if (Date.now() - _pdfPanAt < 400) return;
|
||||||
|
// Text just selected (the text layer): that tap wasn't a turn.
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && !sel.isCollapsed) return;
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - r.left;
|
||||||
|
const dir = x < r.width * 0.4 ? -1 : x > r.width * 0.6 ? 1 : 0;
|
||||||
|
if (!dir) return; // the middle: the bars come or go (62-reader.js)
|
||||||
|
// A turn is a turn only: not also the bars.
|
||||||
|
e.stopPropagation();
|
||||||
|
pdfGoToPage(pdfCurrentPage + dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _pdfPaginatedWheel(e) {
|
||||||
|
if (e.ctrlKey) return; // the browser's zoom
|
||||||
|
e.preventDefault();
|
||||||
|
// A page taller than the screen (zoomed in) is moved first; at its end the wheel turns.
|
||||||
|
const pe = _pdfView && _pdfView.entries[pdfCurrentPage - 1];
|
||||||
|
if (pe && pe.place && pe.place.minY < pe.place.maxY) {
|
||||||
|
const y = Math.max(pe.place.minY, Math.min(pe.place.maxY, pe.pos.y - e.deltaY));
|
||||||
|
if (Math.abs(y - pe.pos.y) > 0.5) {
|
||||||
|
_pdfPlace(pe, pe.pos.x, y);
|
||||||
|
_pdfKeepFocus(pe);
|
||||||
|
_pdfWheelSum = 0;
|
||||||
|
_pdfWheelAt = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - _pdfWheelAt > 400) _pdfWheelSum = 0;
|
||||||
|
_pdfWheelAt = now;
|
||||||
|
_pdfWheelSum += e.deltaY;
|
||||||
|
if (Math.abs(_pdfWheelSum) < 60) return;
|
||||||
|
const dir = _pdfWheelSum > 0 ? 1 : -1;
|
||||||
|
_pdfWheelSum = 0;
|
||||||
|
_pdfWheelAt = now + 250; // one turn per flick of the wheel
|
||||||
|
pdfGoToPage(pdfCurrentPage + dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch (62-reader.js listens for every book; only a PDF's are handled here):
|
||||||
|
// - two fingers zoom around the point between them: a preview while they move (a CSS
|
||||||
|
// transform, the layout stays as it is), then drawn anew at the new size with that point
|
||||||
|
// where it was (scrolling: _pdfRezoomScroll; page by page: _pdfFitPage and v.focus);
|
||||||
|
// - page by page, a page larger than the screen moves with one finger, and pushed on past
|
||||||
|
// its edge sideways it turns; otherwise a swipe turns.
|
||||||
function _pdfTouchStart(e) {
|
function _pdfTouchStart(e) {
|
||||||
|
const v = _pdfView;
|
||||||
|
if (!currentPdfDoc || !v) return;
|
||||||
|
const contentEl = $('reader-content');
|
||||||
if (e.touches.length === 2) {
|
if (e.touches.length === 2) {
|
||||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
const a = e.touches[0], b = e.touches[1];
|
||||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
const x = (a.clientX + b.clientX) / 2, y = (a.clientY + b.clientY) / 2;
|
||||||
_pinchStartDist = Math.hypot(dx, dy);
|
const r = contentEl.getBoundingClientRect();
|
||||||
_pinchStartZoom = readerSettings.pdfZoom;
|
const g = {kind: 'pinch', dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1,
|
||||||
_isPinching = true;
|
zoom: readerSettings.pdfZoom, f: 1, cx: x - r.left, cy: y - r.top};
|
||||||
const ce = $('reader-content');
|
if (readerSettings.pdfPaginated) {
|
||||||
if (ce) ce.classList.add('pinch-active');
|
g.entry = v.entries[pdfCurrentPage - 1];
|
||||||
|
if (!g.entry || !g.entry.pos) return;
|
||||||
|
g.pos = {x: g.entry.pos.x, y: g.entry.pos.y};
|
||||||
} else {
|
} else {
|
||||||
_touchStartX = e.touches[0].clientX;
|
const vp = document.getElementById('pdf-viewport');
|
||||||
_isPinching = false;
|
if (!vp) return;
|
||||||
|
const vr = vp.getBoundingClientRect();
|
||||||
|
g.vp = vp;
|
||||||
|
g.ox = x - vr.left;
|
||||||
|
g.oy = y - vr.top;
|
||||||
|
g.anchor = _pdfPointAt(x, y);
|
||||||
|
}
|
||||||
|
_pdfGesture = g;
|
||||||
|
contentEl.classList.add('pinch-active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
_touchStartX = t.clientX;
|
||||||
|
_touchStartY = t.clientY;
|
||||||
|
_pdfGesture = null;
|
||||||
|
const pe = readerSettings.pdfPaginated && v.entries[pdfCurrentPage - 1];
|
||||||
|
if (pe && pe.place && (pe.place.minX < pe.place.maxX || pe.place.minY < pe.place.maxY)) {
|
||||||
|
_pdfGesture = {kind: 'pan', entry: pe, x: t.clientX, y: t.clientY, ox: pe.pos.x, oy: pe.pos.y,
|
||||||
|
dx: 0, dy: 0, moved: false};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _pdfTouchMove(e) {
|
function _pdfTouchMove(e) {
|
||||||
if (e.touches.length !== 2 || !_isPinching) return;
|
const g = _pdfGesture;
|
||||||
|
if (!g) return;
|
||||||
|
if (g.kind === 'pinch') {
|
||||||
|
if (e.touches.length !== 2) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
const a = e.touches[0], b = e.touches[1];
|
||||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
||||||
const dist = Math.hypot(dx, dy);
|
const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, g.zoom * dist / g.dist));
|
||||||
if (_pinchStartDist === 0) return;
|
g.f = zoom / g.zoom;
|
||||||
const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist)));
|
if (g.entry) {
|
||||||
const vp = document.getElementById('pdf-viewport');
|
// The page grows around the point between the fingers (its transform's origin is 0 0).
|
||||||
if (vp) vp.style.zoom = liveZoom / 100;
|
const x = g.cx - g.f * (g.cx - g.pos.x), y = g.cy - g.f * (g.cy - g.pos.y);
|
||||||
|
g.entry.inner.style.transform = `translate(${x}px, ${y}px) scale(${g.f})`;
|
||||||
|
} else {
|
||||||
|
g.vp.style.transformOrigin = `${g.ox}px ${g.oy}px`;
|
||||||
|
g.vp.style.transform = `scale(${g.f})`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g.kind === 'pan' && e.touches.length === 1) {
|
||||||
|
const t = e.touches[0];
|
||||||
|
g.dx = t.clientX - g.x;
|
||||||
|
g.dy = t.clientY - g.y;
|
||||||
|
if (!g.moved && Math.hypot(g.dx, g.dy) < 8) return;
|
||||||
|
g.moved = true;
|
||||||
|
e.preventDefault();
|
||||||
|
_pdfPlace(g.entry, g.ox + g.dx, g.oy + g.dy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _pdfTouchEnd(e) {
|
function _pdfTouchEnd(e) {
|
||||||
if (_isPinching) {
|
const g = _pdfGesture;
|
||||||
_isPinching = false;
|
if (g && g.kind === 'pinch') {
|
||||||
const ce = $('reader-content');
|
// The other finger may stay down a moment: it's no swipe.
|
||||||
if (ce) ce.classList.remove('pinch-active');
|
_pdfGesture = {kind: 'after'};
|
||||||
const vp = document.getElementById('pdf-viewport');
|
const contentEl = $('reader-content');
|
||||||
const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom;
|
if (contentEl) contentEl.classList.remove('pinch-active');
|
||||||
const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10));
|
const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, Math.round(g.zoom * g.f / 10) * 10));
|
||||||
applyPdfZoom(snapped);
|
if (g.entry) {
|
||||||
|
const pe = g.entry, s = pe.drawn, p = pe.place;
|
||||||
|
// The page's point between the fingers stays there (_pdfFitPage); the preview stays
|
||||||
|
// until the page is drawn at its new size.
|
||||||
|
if (s && p) {
|
||||||
|
_pdfView.focus = {x: (g.cx - g.pos.x) / s, y: (g.cy - g.pos.y) / s, sx: g.cx / p.W, sy: g.cy / p.H};
|
||||||
|
_pdfView.focusPage = pe.num;
|
||||||
|
}
|
||||||
|
if (zoom === readerSettings.pdfZoom) _pdfPlace(pe, g.pos.x, g.pos.y);
|
||||||
|
} else {
|
||||||
|
g.vp.style.transform = '';
|
||||||
|
g.vp.style.transformOrigin = '';
|
||||||
|
}
|
||||||
|
if (zoom !== readerSettings.pdfZoom) applyPdfZoom(zoom, g.anchor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!readerSettings.pdfPaginated) return;
|
if (g && g.kind === 'after') {
|
||||||
const delta = e.changedTouches[0].clientX - _touchStartX;
|
if (!e.touches || !e.touches.length) _pdfGesture = null;
|
||||||
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
|
return;
|
||||||
else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1);
|
}
|
||||||
|
if (g && g.kind === 'pan') {
|
||||||
|
_pdfGesture = null;
|
||||||
|
if (!g.moved) return; // a tap: the click turns or shows the bars
|
||||||
|
_pdfPanAt = Date.now();
|
||||||
|
_pdfKeepFocus(g.entry);
|
||||||
|
// Already at its edge and pushed on sideways: the next or previous page (a move that only
|
||||||
|
// reaches the edge doesn't turn as well).
|
||||||
|
const p = g.entry.place;
|
||||||
|
const atEdge = g.dx < 0 ? g.ox <= p.minX + 1 : g.ox >= p.maxX - 1;
|
||||||
|
if (atEdge && Math.abs(g.dx) > 50 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5) pdfGoToPage(pdfCurrentPage + (g.dx > 0 ? -1 : 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentPdfDoc || !readerSettings.pdfPaginated || !e.changedTouches.length) return;
|
||||||
|
const dx = e.changedTouches[0].clientX - _touchStartX;
|
||||||
|
const dy = e.changedTouches[0].clientY - _touchStartY;
|
||||||
|
// Sideways, clearly: a slanted scroll is not a turn.
|
||||||
|
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||||
|
// Dragging a selection's handles (the text layer) isn't a swipe.
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && !sel.isCollapsed) return;
|
||||||
|
pdfGoToPage(pdfCurrentPage + (dx > 0 ? -1 : 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The page number the reader shows (header input) follows the page. */
|
||||||
|
function _pdfSetCurrentPage(n) {
|
||||||
|
pdfCurrentPage = n;
|
||||||
|
const input = $('reader-progress-input');
|
||||||
|
if (input && currentPdfDoc && document.activeElement !== input) input.value = pdfPageLabel(n);
|
||||||
|
// With the book's own page numbers: which page of the file it is beside them.
|
||||||
|
const suffix = $('reader-progress-suffix');
|
||||||
|
if (suffix && currentPdfDoc && pdfTotalPages) {
|
||||||
|
suffix.textContent = _pdfLabels ? `(${n} / ${pdfTotalPages})` : `/ ${pdfTotalPages}`;
|
||||||
|
if (input) input.setAttribute('inputmode', _pdfLabels ? 'text' : 'decimal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to page [n], scrolling or page by page: the table of contents, the page
|
||||||
|
* number in the header, bookmarks, keys and the app's "Zu Seite springen".
|
||||||
|
*/
|
||||||
function pdfGoToPage(n) {
|
function pdfGoToPage(n) {
|
||||||
if (!currentPdfDoc) return;
|
if (!currentPdfDoc) return;
|
||||||
n = Math.max(1, Math.min(pdfTotalPages, n));
|
n = Math.max(1, Math.min(pdfTotalPages, n | 0));
|
||||||
if (n === pdfCurrentPage) return;
|
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
|
if (!readerSettings.pdfPaginated) {
|
||||||
const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
|
const target = contentEl.querySelector(`#pdf-page-${n}`);
|
||||||
if (oldWrapper) oldWrapper.style.display = 'none';
|
if (target) {
|
||||||
|
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
|
||||||
pdfCurrentPage = n;
|
contentEl.scrollTop += target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top - 8;
|
||||||
const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
|
}
|
||||||
if (newWrapper) newWrapper.style.display = '';
|
_pdfSetCurrentPage(n);
|
||||||
|
return;
|
||||||
pdfSmartZoomPage(pdfCurrentPage);
|
}
|
||||||
|
if (n === pdfCurrentPage && _pdfView && _pdfView.shown === n) return;
|
||||||
const progressInput = $('reader-progress-input');
|
_pdfShowPage(n);
|
||||||
if (progressInput) progressInput.value = pdfCurrentPage;
|
_pdfTurned();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pdfSmartZoomPage(pageNum) {
|
// A turn is reading: the place is saved soon (not only by the 10 s timer), and e-readers
|
||||||
if (!currentPdfDoc) return;
|
// get their refresh (native-eink.js listens).
|
||||||
const contentEl = $('reader-content');
|
function _pdfTurned() {
|
||||||
if (!contentEl) return;
|
if (typeof _noteReaderInteraction === 'function') _noteReaderInteraction();
|
||||||
const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`);
|
clearTimeout(_pdfTurnSaveTimer);
|
||||||
if (!wrapper) return;
|
_pdfTurnSaveTimer = setTimeout(() => { if (currentPdfDoc) saveReaderProgress(); }, 1500);
|
||||||
const canvas = wrapper.querySelector('canvas');
|
try { window.dispatchEvent(new CustomEvent('diora-pdf-turn', {detail: {page: pdfCurrentPage}})); } catch (e) {}
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const page = await currentPdfDoc.getPage(pageNum);
|
|
||||||
const naturalVp = page.getViewport({scale: 1});
|
|
||||||
const pageW = naturalVp.width;
|
|
||||||
const pageH = naturalVp.height;
|
|
||||||
|
|
||||||
let bbox = _pdfPageTextBoxCache[pageNum];
|
|
||||||
if (!bbox) {
|
|
||||||
bbox = await _computePdfTextBox(page, pageW, pageH);
|
|
||||||
_pdfPageTextBoxCache[pageNum] = bbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerW = contentEl.clientWidth;
|
|
||||||
const containerH = contentEl.clientHeight;
|
|
||||||
const contentW = bbox.x2 - bbox.x1;
|
|
||||||
const contentH = bbox.y2 - bbox.y1;
|
|
||||||
const pad = 12;
|
|
||||||
const scale = Math.min(
|
|
||||||
(containerW - pad * 2) / contentW,
|
|
||||||
(containerH - pad * 2) / contentH
|
|
||||||
) * (readerSettings.pdfZoom / 100);
|
|
||||||
|
|
||||||
// Re-render canvas at new scale if significantly different
|
|
||||||
const currentScale = canvas.width / naturalVp.width;
|
|
||||||
if (Math.abs(scale - currentScale) / currentScale > 0.05) {
|
|
||||||
const vp = page.getViewport({scale});
|
|
||||||
canvas.width = vp.width;
|
|
||||||
canvas.height = vp.height;
|
|
||||||
await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position canvas to center the text bounding box
|
|
||||||
const renderedScale = canvas.width / naturalVp.width;
|
|
||||||
const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2;
|
|
||||||
// PDF y-axis is bottom-up; canvas is top-down
|
|
||||||
const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2;
|
|
||||||
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
|
|
||||||
wrapper.style.overflow = 'hidden';
|
|
||||||
wrapper.style.width = containerW + 'px';
|
|
||||||
wrapper.style.height = containerH + 'px';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _computePdfTextBox(page, pageW, pageH) {
|
/** Show page [n] alone, cropped; draw the next one ahead and let those far away go. */
|
||||||
|
async function _pdfShowPage(n) {
|
||||||
|
const v = _pdfView;
|
||||||
|
if (!v || !v.entries.length) return;
|
||||||
|
n = Math.max(1, Math.min(v.entries.length, n));
|
||||||
|
_pdfSetCurrentPage(n);
|
||||||
|
v.shown = n;
|
||||||
|
const token = ++v.showToken;
|
||||||
|
v.entries.forEach(e => { e.wrapper.style.display = e.num === n ? '' : 'none'; });
|
||||||
|
try {
|
||||||
|
await _pdfFitPage(v, v.entries[n - 1]);
|
||||||
|
if (token !== v.showToken || v.stopped) return;
|
||||||
|
if (n < v.entries.length) await _pdfFitPage(v, v.entries[n], true);
|
||||||
|
if (token !== v.showToken || v.stopped) return;
|
||||||
|
if (n > 1 && !v.entries[n - 2].drawn) await _pdfFitPage(v, v.entries[n - 2], true);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('PDF page', n, err);
|
||||||
|
}
|
||||||
|
if (token !== v.showToken || v.stopped) return;
|
||||||
|
v.entries.forEach(e => { if (Math.abs(e.num - n) > 2 && (e.drawn || e.task)) _pdfLetGo(e, v.pdf); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page [e] sized to the screen by its text (its ink, for scans): drawn when the size differs,
|
||||||
|
* then placed so the text is centred. [ahead]: for the next turn, not shown yet.
|
||||||
|
*/
|
||||||
|
async function _pdfFitPage(v, e, ahead) {
|
||||||
|
const contentEl = v.contentEl;
|
||||||
|
const page = await v.pdf.getPage(e.num);
|
||||||
|
if (v.stopped) return;
|
||||||
|
const natural = page.getViewport({scale: 1});
|
||||||
|
let box = _pdfPageTextBoxCache[e.num];
|
||||||
|
if (!box) {
|
||||||
|
box = await _computePdfTextBox(page, natural);
|
||||||
|
_pdfPageTextBoxCache[e.num] = box;
|
||||||
|
}
|
||||||
|
if (v.stopped) return;
|
||||||
|
const W = contentEl.clientWidth;
|
||||||
|
const H = contentEl.clientHeight;
|
||||||
|
const bw = Math.max(1, box.x2 - box.x1);
|
||||||
|
const bh = Math.max(1, box.y2 - box.y1);
|
||||||
|
const fitBox = Math.min((W - PDF_CROP_PAD * 2) / bw, (H - PDF_CROP_PAD * 2) / bh);
|
||||||
|
const fitPage = Math.min((W - PDF_CROP_PAD * 2) / natural.width, (H - PDF_CROP_PAD * 2) / natural.height);
|
||||||
|
const scale = Math.max(0.1, Math.min(fitBox, fitPage * PDF_CROP_MAX)) * (readerSettings.pdfZoom / 100);
|
||||||
|
if (!e.drawn || Math.abs(scale - e.drawn) / e.drawn > 0.05) {
|
||||||
|
if (!await _pdfDraw(e, page, scale)) return;
|
||||||
|
}
|
||||||
|
// The text box centred where it fits (at the scale it was drawn with); larger than the
|
||||||
|
// screen (zoomed in), it can be moved up to its edges and starts at its top left — or
|
||||||
|
// where it was looked at (v.focus: a pinch, a move, the screen turned).
|
||||||
|
const s = e.drawn;
|
||||||
|
const range = (size, lo, hi) => {
|
||||||
|
if (hi - lo + PDF_CROP_PAD * 2 <= size + 1) {
|
||||||
|
const c = (size - (hi - lo)) / 2 - lo;
|
||||||
|
return [c, c];
|
||||||
|
}
|
||||||
|
return [size - PDF_CROP_PAD - hi, PDF_CROP_PAD - lo];
|
||||||
|
};
|
||||||
|
const [minX, maxX] = range(W, s * box.x1, s * box.x2);
|
||||||
|
const [minY, maxY] = range(H, s * box.y1, s * box.y2);
|
||||||
|
e.place = {minX, maxX, minY, maxY, W, H};
|
||||||
|
const f = v.focusPage === e.num ? v.focus : null;
|
||||||
|
e.inner.style.transformOrigin = '0 0';
|
||||||
|
_pdfPlace(e, f ? W * f.sx - s * f.x : maxX, f ? H * f.sy - s * f.y : maxY);
|
||||||
|
e.wrapper.style.overflow = 'hidden';
|
||||||
|
e.wrapper.style.width = W + 'px';
|
||||||
|
e.wrapper.style.height = H + 'px';
|
||||||
|
if (!ahead && v.shown === e.num) e.wrapper.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page [e] moved to (x, y) (its offset in the reading area), kept within its edges (e.place). */
|
||||||
|
function _pdfPlace(e, x, y) {
|
||||||
|
const p = e.place;
|
||||||
|
if (p) {
|
||||||
|
x = Math.max(p.minX, Math.min(p.maxX, x));
|
||||||
|
y = Math.max(p.minY, Math.min(p.maxY, y));
|
||||||
|
}
|
||||||
|
e.pos = {x, y};
|
||||||
|
e.inner.style.transform = `translate(${x}px, ${y}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page [e] moved: that part of it stays in view when it's laid out again (another zoom, turned). */
|
||||||
|
function _pdfKeepFocus(e) {
|
||||||
|
const v = _pdfView, p = e.place;
|
||||||
|
if (!v || !p || !e.drawn) return;
|
||||||
|
v.focus = {x: (p.W / 2 - e.pos.x) / e.drawn, y: (p.H / 2 - e.pos.y) / e.drawn, sx: 0.5, sy: 0.5};
|
||||||
|
v.focusPage = e.num;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the page's content is, in the page's own top-down coordinates at scale 1 (as
|
||||||
|
* getViewport gives them, so turned pages and odd media boxes come out right): its text,
|
||||||
|
* or — a scan without text — what isn't white on a small drawing of it.
|
||||||
|
*/
|
||||||
|
async function _computePdfTextBox(page, natural) {
|
||||||
|
const pageW = natural.width, pageH = natural.height;
|
||||||
// Tier 1: text-based
|
// Tier 1: text-based
|
||||||
try {
|
try {
|
||||||
const tc = await page.getTextContent();
|
const tc = currentPdfDoc ? await _pdfPageText(currentPdfDoc, page.pageNumber) : await page.getTextContent();
|
||||||
if (tc.items && tc.items.length) {
|
if (tc.items && tc.items.length) {
|
||||||
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
|
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
|
||||||
for (const item of tc.items) {
|
for (const item of tc.items) {
|
||||||
if (!item.transform) continue;
|
if (!item.transform || !item.str || !item.str.trim()) continue;
|
||||||
const tx = item.transform[4], ty = item.transform[5];
|
const tx = item.transform[4], ty = item.transform[5];
|
||||||
const iw = item.width || 0, ih = item.height || 0;
|
const iw = item.width || 0, ih = item.height || Math.abs(item.transform[3]) || 0;
|
||||||
if (tx < x1) x1 = tx;
|
for (const [px, py] of [[tx, ty], [tx + iw, ty + ih]]) {
|
||||||
if (ty < y1) y1 = ty;
|
const [vx, vy] = natural.convertToViewportPoint(px, py);
|
||||||
if (tx + iw > x2) x2 = tx + iw;
|
if (vx < x1) x1 = vx;
|
||||||
if (ty + ih > y2) y2 = ty + ih;
|
if (vx > x2) x2 = vx;
|
||||||
|
if (vy < y1) y1 = vy;
|
||||||
|
if (vy > y2) y2 = vy;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const area = (x2 - x1) * (y2 - y1);
|
const area = (x2 - x1) * (y2 - y1);
|
||||||
if (isFinite(x1) && area > pageW * pageH * 0.25) {
|
if (isFinite(x1) && area > pageW * pageH * 0.25) {
|
||||||
return {x1, y1, x2, y2};
|
return {x1: Math.max(0, x1), y1: Math.max(0, y1), x2: Math.min(pageW, x2), y2: Math.min(pageH, y2)};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
@ -179,17 +390,18 @@ async function _computePdfTextBox(page, pageW, pageH) {
|
||||||
const lowScale = 0.3;
|
const lowScale = 0.3;
|
||||||
const vp = page.getViewport({scale: lowScale});
|
const vp = page.getViewport({scale: lowScale});
|
||||||
const offCanvas = document.createElement('canvas');
|
const offCanvas = document.createElement('canvas');
|
||||||
offCanvas.width = vp.width;
|
offCanvas.width = Math.ceil(vp.width);
|
||||||
offCanvas.height = vp.height;
|
offCanvas.height = Math.ceil(vp.height);
|
||||||
const ctx = offCanvas.getContext('2d');
|
const ctx = offCanvas.getContext('2d');
|
||||||
await page.render({canvasContext: ctx, viewport: vp}).promise;
|
await page.render({canvasContext: ctx, viewport: vp}).promise;
|
||||||
const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height);
|
const {data, width, height} = ctx.getImageData(0, 0, offCanvas.width, offCanvas.height);
|
||||||
|
offCanvas.width = offCanvas.height = 0;
|
||||||
|
|
||||||
let rMin = height, rMax = 0, cMin = width, cMax = 0;
|
let rMin = height, rMax = 0, cMin = width, cMax = 0;
|
||||||
for (let r = 0; r < height; r++) {
|
for (let r = 0; r < height; r++) {
|
||||||
for (let c = 0; c < width; c++) {
|
for (let c = 0; c < width; c++) {
|
||||||
const idx = (r * width + c) * 4;
|
const idx = (r * width + c) * 4;
|
||||||
if (data[idx] + data[idx+1] + data[idx+2] < 720) {
|
if (data[idx] + data[idx + 1] + data[idx + 2] < 720) {
|
||||||
if (r < rMin) rMin = r;
|
if (r < rMin) rMin = r;
|
||||||
if (r > rMax) rMax = r;
|
if (r > rMax) rMax = r;
|
||||||
if (c < cMin) cMin = c;
|
if (c < cMin) cMin = c;
|
||||||
|
|
@ -198,12 +410,7 @@ async function _computePdfTextBox(page, pageW, pageH) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (rMin < rMax && cMin < cMax) {
|
if (rMin < rMax && cMin < cMax) {
|
||||||
return {
|
return {x1: cMin / lowScale, y1: rMin / lowScale, x2: cMax / lowScale, y2: rMax / lowScale};
|
||||||
x1: cMin / lowScale,
|
|
||||||
y1: (height - rMax) / lowScale,
|
|
||||||
x2: cMax / lowScale,
|
|
||||||
y2: (height - rMin) / lowScale,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ function addBookmark() {
|
||||||
let label, anchor, scrollFraction;
|
let label, anchor, scrollFraction;
|
||||||
|
|
||||||
if (currentPdfDoc) {
|
if (currentPdfDoc) {
|
||||||
const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1;
|
const page = pdfCurrentPage || 1;
|
||||||
label = `Seite ${page}`;
|
label = `Seite ${pdfPageLabel(page)}`;
|
||||||
anchor = `pdf-page-${page}`;
|
anchor = `pdf-page-${page}`;
|
||||||
scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1);
|
scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -143,15 +143,7 @@ function jumpToBookmark(id) {
|
||||||
const contentEl = $('reader-content');
|
const contentEl = $('reader-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
if (bm.anchor.startsWith('pdf-page-')) {
|
if (bm.anchor.startsWith('pdf-page-')) {
|
||||||
if (readerSettings.pdfPaginated) {
|
|
||||||
pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1);
|
pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1);
|
||||||
} else {
|
|
||||||
const target = contentEl.querySelector('#' + bm.anchor);
|
|
||||||
if (target) {
|
|
||||||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
|
||||||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
|
const target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
|
||||||
.find(el => el.getAttribute('data-epub-src') === bm.anchor);
|
.find(el => el.getAttribute('data-epub-src') === bm.anchor);
|
||||||
|
|
|
||||||
|
|
@ -94,15 +94,9 @@ async function doReaderSearch(query) {
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// PDF: collect text layer spans
|
// PDF: the whole document, page by page (not only the pages drawn now).
|
||||||
const spans = contentEl.querySelectorAll('.pdf-text-layer > span');
|
_pdfSearchAll(query);
|
||||||
const lq = query.toLowerCase();
|
return;
|
||||||
for (const span of spans) {
|
|
||||||
if (span.textContent.toLowerCase().includes(lq)) {
|
|
||||||
span.classList.add('reader-search-match');
|
|
||||||
searchMatches.push(span);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0';
|
if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0';
|
||||||
|
|
@ -131,10 +125,7 @@ function clearReaderSearchHighlights() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// PDF: remove highlight class from spans
|
_pdfSearchStop();
|
||||||
document.querySelectorAll('.reader-search-match').forEach(el => {
|
|
||||||
el.classList.remove('reader-search-match', 'active');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
searchMatches = [];
|
searchMatches = [];
|
||||||
searchMatchIndex = -1;
|
searchMatchIndex = -1;
|
||||||
|
|
@ -150,6 +141,7 @@ function clearReaderSearch() {
|
||||||
|
|
||||||
function scrollToSearchMatch(idx) {
|
function scrollToSearchMatch(idx) {
|
||||||
if (!searchMatches.length) return;
|
if (!searchMatches.length) return;
|
||||||
|
if (currentPdfDoc) { _pdfShowMatch(idx); return; }
|
||||||
_suppressScrollJumpDetect();
|
_suppressScrollJumpDetect();
|
||||||
searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx));
|
searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx));
|
||||||
searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
|
searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||||
|
|
@ -157,6 +149,134 @@ function scrollToSearchMatch(idx) {
|
||||||
if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`;
|
if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PDF search: through the text of every page (58-pdf.js keeps it), in order; the matches
|
||||||
|
// marked in the text layer of the pages drawn, and of those drawn later.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _pdfSearchState = null; // {query, matches: [{page, start, end}], done}
|
||||||
|
let _pdfSearchGen = 0;
|
||||||
|
|
||||||
|
function _pdfSearchCount() {
|
||||||
|
const countEl = document.getElementById('rs-search-count');
|
||||||
|
const st = _pdfSearchState;
|
||||||
|
if (!countEl || !st) return;
|
||||||
|
const more = st.done ? '' : ' …';
|
||||||
|
countEl.textContent = st.matches.length
|
||||||
|
? `${searchMatchIndex >= 0 ? searchMatchIndex + 1 : '–'} / ${st.matches.length}${more}`
|
||||||
|
: (st.done ? '0' : '…');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _pdfSearchAll(query) {
|
||||||
|
const pdf = currentPdfDoc;
|
||||||
|
const gen = ++_pdfSearchGen;
|
||||||
|
const lq = query.toLowerCase();
|
||||||
|
const st = {query, matches: [], done: false};
|
||||||
|
_pdfSearchState = st;
|
||||||
|
searchMatches = st.matches;
|
||||||
|
searchMatchIndex = -1;
|
||||||
|
_pdfSearchCount();
|
||||||
|
// The first match from the page being read on is shown as soon as it's found.
|
||||||
|
const from = pdfCurrentPage || 1;
|
||||||
|
for (let n = 1; n <= pdf.numPages; n++) {
|
||||||
|
const tc = await _pdfPageText(pdf, n);
|
||||||
|
if (gen !== _pdfSearchGen || pdf !== currentPdfDoc) return;
|
||||||
|
const {str} = _pdfPageString(tc);
|
||||||
|
const before = st.matches.length;
|
||||||
|
let i = 0;
|
||||||
|
while (lq && (i = str.indexOf(lq, i)) !== -1) {
|
||||||
|
st.matches.push({page: n, start: i, end: i + lq.length});
|
||||||
|
i += lq.length;
|
||||||
|
}
|
||||||
|
if (st.matches.length > before) {
|
||||||
|
const e = _pdfView && _pdfView.entries[n - 1];
|
||||||
|
if (e) _pdfSearchMarkPage(e);
|
||||||
|
if (searchMatchIndex < 0 && n >= from) { searchMatchIndex = before; _pdfShowMatch(before); }
|
||||||
|
}
|
||||||
|
if (n % 20 === 0) _pdfSearchCount();
|
||||||
|
}
|
||||||
|
st.done = true;
|
||||||
|
if (searchMatchIndex < 0 && st.matches.length) { searchMatchIndex = 0; _pdfShowMatch(0); }
|
||||||
|
_pdfSearchCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks of search [st] in page [e]'s text layer (none when no search is on). */
|
||||||
|
function _pdfSearchMarkPage(e) {
|
||||||
|
if (!e.textDivs || !e.textInfo) return;
|
||||||
|
if (e.marked) {
|
||||||
|
for (const i of e.marked) if (e.textDivs[i]) e.textDivs[i].textContent = e.textInfo.items[i].str;
|
||||||
|
e.marked = null;
|
||||||
|
}
|
||||||
|
const st = _pdfSearchState;
|
||||||
|
if (!st) return;
|
||||||
|
const {starts} = e.textInfo.string;
|
||||||
|
const items = e.textInfo.items;
|
||||||
|
// Per span, the pieces of it that match (one match can run over several spans).
|
||||||
|
const pieces = new Map();
|
||||||
|
st.matches.forEach((m, idx) => {
|
||||||
|
if (m.page !== e.num) return;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const s = starts[i], len = (items[i].str || '').length;
|
||||||
|
if (s >= m.end) break;
|
||||||
|
if (s + len <= m.start || !len) continue;
|
||||||
|
if (!pieces.has(i)) pieces.set(i, []);
|
||||||
|
pieces.get(i).push([Math.max(m.start, s) - s, Math.min(m.end, s + len) - s, idx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
e.marked = [];
|
||||||
|
pieces.forEach((list, i) => {
|
||||||
|
const div = e.textDivs[i];
|
||||||
|
if (!div) return;
|
||||||
|
const str = items[i].str;
|
||||||
|
div.textContent = '';
|
||||||
|
let at = 0;
|
||||||
|
for (const [a, b, idx] of list) {
|
||||||
|
if (a > at) div.appendChild(document.createTextNode(str.slice(at, a)));
|
||||||
|
const mark = document.createElement('mark');
|
||||||
|
mark.className = 'reader-search-match' + (idx === searchMatchIndex ? ' active' : '');
|
||||||
|
mark.dataset.m = idx;
|
||||||
|
mark.textContent = str.slice(a, b);
|
||||||
|
div.appendChild(mark);
|
||||||
|
at = b;
|
||||||
|
}
|
||||||
|
if (at < str.length) div.appendChild(document.createTextNode(str.slice(at)));
|
||||||
|
e.marked.push(i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Go to match [idx]: its page, and (scrolling) the match itself into view. */
|
||||||
|
async function _pdfShowMatch(idx) {
|
||||||
|
const st = _pdfSearchState;
|
||||||
|
const m = st && st.matches[idx];
|
||||||
|
if (!m) return;
|
||||||
|
const gen = _pdfSearchGen;
|
||||||
|
_pdfSearchCount();
|
||||||
|
document.querySelectorAll('.pdf-text-layer mark.reader-search-match').forEach(mk => {
|
||||||
|
mk.classList.toggle('active', +mk.dataset.m === idx);
|
||||||
|
});
|
||||||
|
const contentEl = $('reader-content');
|
||||||
|
const shown = readerSettings.pdfPaginated ? pdfCurrentPage : (contentEl ? _pdfTopPage(contentEl) : 0);
|
||||||
|
if (m.page !== shown || readerSettings.pdfPaginated) pdfGoToPage(m.page);
|
||||||
|
if (readerSettings.pdfPaginated || !contentEl) return;
|
||||||
|
// Scrolling: the page is drawn (and its text laid over it) once it's near the view.
|
||||||
|
for (let t = 0; t < 50; t++) {
|
||||||
|
if (gen !== _pdfSearchGen || searchMatchIndex !== idx) return;
|
||||||
|
const mk = contentEl.querySelector(`#pdf-page-${m.page} mark[data-m="${idx}"]`);
|
||||||
|
if (mk) {
|
||||||
|
_suppressScrollJumpDetect();
|
||||||
|
mk.scrollIntoView({block: 'center'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pdfSearchStop() {
|
||||||
|
_pdfSearchGen++;
|
||||||
|
_pdfSearchState = null;
|
||||||
|
if (_pdfView) _pdfView.entries.forEach(e => { if (e.marked) _pdfSearchMarkPage(e); });
|
||||||
|
}
|
||||||
|
|
||||||
function readerSearchNext() {
|
function readerSearchNext() {
|
||||||
if (!searchMatches.length) return;
|
if (!searchMatches.length) return;
|
||||||
searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length;
|
searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length;
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,9 @@ function handleReaderSelection(e) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!markerModeActive) return;
|
// Markings belong to books (an article has nowhere to keep them, 65-article.js), and to
|
||||||
|
// EPUBs only: a PDF's text layer is there to select and copy.
|
||||||
|
if (!markerModeActive || !currentBookId || currentPdfDoc) return;
|
||||||
// Don't hijack taps on real interactive content (footnote refs, external
|
// Don't hijack taps on real interactive content (footnote refs, external
|
||||||
// links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless
|
// links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless
|
||||||
// <a id="..."> used only as an internal jump target (sanitizeEpubHtml
|
// <a id="..."> used only as an internal jump target (sanitizeEpubHtml
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,131 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Footnote popover
|
// Footnotes: finding a reference's note, and the popover that shows it
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
// The whole book is one concatenated DOM, and ids like "fn1" repeat per
|
||||||
|
// chapter file: a note is looked up in the chapter its link points into (the
|
||||||
|
// link's original href, kept in data-footnote-href by sanitizeEpubHtml), and
|
||||||
|
// only exactly the note is shown — not the parent around an id that sits on
|
||||||
|
// the note's own <p>/<aside>/<li>, which can be a whole chapter. The same
|
||||||
|
// lookup serves the popover here, the notes at the foot of a page
|
||||||
|
// (63-pages.js) and the apps' note sheet (diora-android native-ui.js).
|
||||||
|
|
||||||
|
const _NOTE_INLINE = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL|CODE)$/;
|
||||||
|
|
||||||
|
function noteHrefOf(link) {
|
||||||
|
return link.dataset.footnoteHref || ('#' + (link.dataset.footnoteRef || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _noteChapterFor(fromEl, href) {
|
||||||
|
const chapter = fromEl.closest('[data-epub-src]');
|
||||||
|
const file = href.split('#')[0];
|
||||||
|
if (!file || !chapter) return chapter;
|
||||||
|
const src = chapter.dataset.epubSrc || '';
|
||||||
|
const dir = src.includes('/') ? src.slice(0, src.lastIndexOf('/') + 1) : '';
|
||||||
|
const target = resolveEpubPath(dir, file);
|
||||||
|
return document.querySelector(`#reader-content > [data-epub-src="${CSS.escape(target)}"]`) || chapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The element `href` (as written in the book, relative to fromEl's chapter) points at.
|
||||||
|
function resolveNoteTarget(fromEl, href) {
|
||||||
|
const id = decodeURIComponent(href.slice(href.indexOf('#') + 1));
|
||||||
|
const scope = _noteChapterFor(fromEl, href);
|
||||||
|
const content = document.getElementById('reader-content');
|
||||||
|
let el = id && scope ? scope.querySelector(`[id="${CSS.escape(id)}"]`) : null;
|
||||||
|
if (!el && id && content) el = content.querySelector(`[id="${CSS.escape(id)}"]`);
|
||||||
|
if (!el && !id) el = scope; // link to a whole chapter file
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converters (zeno.org / e-artnow, some Calibre output) park an empty
|
||||||
|
// <a id="F537"></a> right *before* the note's paragraph instead of inside it —
|
||||||
|
// or at the start of it (then the paragraph is the note, not what follows).
|
||||||
|
function _noteAfterEmptyAnchor(target) {
|
||||||
|
if ((target.textContent || '').trim() || target.querySelector('img')) return null;
|
||||||
|
const own = target.closest('p, li, dd, dt, td, blockquote');
|
||||||
|
const ownText = own && !own.hasAttribute('data-epub-src') ? (own.textContent || '').trim() : '';
|
||||||
|
if (ownText && !/^[\[(]?\d{1,4}[\])]?\.?$/.test(ownText)) return own;
|
||||||
|
if (ownText) {
|
||||||
|
// Only the number: the note (a poem, a long quote) runs on in the next
|
||||||
|
// paragraphs, up to the next note's anchor.
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.appendChild(own.cloneNode(true));
|
||||||
|
let len = 0;
|
||||||
|
for (let next = own.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
|
||||||
|
if (next.matches('h1, h2, h3, h4, h5, h6') || next.querySelector('[id]')) break;
|
||||||
|
if (!(next.textContent || '').trim()) continue;
|
||||||
|
box.appendChild(next.cloneNode(true));
|
||||||
|
len += next.textContent.length;
|
||||||
|
}
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
for (let el = target; el && !el.hasAttribute('data-epub-src'); el = el.parentElement) {
|
||||||
|
let next = el.nextElementSibling;
|
||||||
|
while (next && !(next.textContent || '').trim()) next = next.nextElementSibling;
|
||||||
|
if (next) return next.matches('h1, h2, h3, h4, h5, h6') ? null : next;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _noteBox(target) {
|
||||||
|
const following = _noteAfterEmptyAnchor(target);
|
||||||
|
if (following) return following;
|
||||||
|
let box = target.closest(_NOTE_BOX_SELECTOR); // 40-epub.js
|
||||||
|
if (box && (box.hasAttribute('data-epub-src') || box.id === 'reader-content')) box = null;
|
||||||
|
if (!box) box = _NOTE_INLINE.test(target.tagName) ? (target.closest('p, li, dd, div, td, blockquote') || target) : target;
|
||||||
|
// Never a whole chapter or section: fall back to the paragraph around the target.
|
||||||
|
if (box.hasAttribute('data-epub-src') || (box.textContent || '').length > 6000) {
|
||||||
|
box = target.closest('p, li, dd') || target;
|
||||||
|
}
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A copy of the note at `target`, ready to show outside the book.
|
||||||
|
function noteContentFor(target, label) {
|
||||||
|
const clone = _noteBox(target).cloneNode(true);
|
||||||
|
clone.querySelectorAll('[role="doc-backlink"], [epub\\:type~="backlink"]').forEach(e => e.remove());
|
||||||
|
// A page of the printed edition that starts inside the note.
|
||||||
|
clone.querySelectorAll('.epub-pagenum, [role="doc-pagebreak"], [epub\\:type~="pagebreak"]').forEach(e => e.remove());
|
||||||
|
// Links back to the text ("↩", or the note's own number at its start).
|
||||||
|
clone.querySelectorAll('a').forEach(a => {
|
||||||
|
const t = (a.textContent || '').trim();
|
||||||
|
if (/^[↩↑⤴^]+$/.test(t)) a.remove();
|
||||||
|
});
|
||||||
|
// The note's own number at its very start ("3 Endnote…", "[4] …"): whatever
|
||||||
|
// shows the note labels it already.
|
||||||
|
const first = Array.prototype.find.call(clone.querySelectorAll('a, sup, span, b, strong'),
|
||||||
|
e => (e.textContent || '').trim());
|
||||||
|
const firstText = first ? (first.textContent || '').trim() : '';
|
||||||
|
const bare = t => t.replace(/[\[\]().\s]/g, '');
|
||||||
|
if (first && firstText && (clone.textContent || '').trim().startsWith(firstText) &&
|
||||||
|
(bare(firstText) === bare(label || '') || /^[\[(]?(\d{1,4}|[*†‡§]+)[\])]?\.?$/.test(firstText))) {
|
||||||
|
first.remove();
|
||||||
|
// …and the space that followed it.
|
||||||
|
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null, false);
|
||||||
|
const t = walker.nextNode();
|
||||||
|
if (t) t.nodeValue = t.nodeValue.replace(/^\s+/, '');
|
||||||
|
}
|
||||||
|
// No duplicate ids in the page (getElementById must keep finding the book's).
|
||||||
|
clone.removeAttribute('id');
|
||||||
|
clone.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
|
||||||
|
clone.querySelectorAll('mark.tts-current, mark.reader-search-match').forEach(m => m.replaceWith(...m.childNodes));
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The note of a reference link, or null.
|
||||||
|
function noteForLink(link) {
|
||||||
|
const target = resolveNoteTarget(link, noteHrefOf(link));
|
||||||
|
return target ? noteContentFor(target, (link.textContent || '').trim()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
let currentFootnotePopover = null;
|
let currentFootnotePopover = null;
|
||||||
let _footnotePopoverTimer = null;
|
let _footnotePopoverTimer = null;
|
||||||
|
|
||||||
function showFootnotePopover(link) {
|
function showFootnotePopover(link) {
|
||||||
dismissFootnotePopover();
|
dismissFootnotePopover();
|
||||||
const targetEl = document.getElementById(link.dataset.footnoteRef);
|
const note = noteForLink(link);
|
||||||
if (!targetEl) return;
|
if (!note) return;
|
||||||
// The note's paragraph, also when the id sits on an inline element in it
|
|
||||||
// (<p><span><a id="F400" …>143</a></span> Note…</p>).
|
|
||||||
const inline = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL)$/.test(targetEl.tagName);
|
|
||||||
const container = targetEl.closest('.footnote') ||
|
|
||||||
(inline && targetEl.closest('p, li, dd, blockquote')) || targetEl.parentElement;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const clone = container.cloneNode(true);
|
|
||||||
clone.querySelector('sup')?.remove();
|
|
||||||
clone.querySelectorAll('.epub-pagenum').forEach(e => e.remove());
|
|
||||||
// The note's own number at its start, a link back to the text ("1 Note…").
|
|
||||||
const first = Array.from(clone.querySelectorAll('a')).find(a => a.textContent.trim());
|
|
||||||
const num = first ? first.textContent.trim() : '';
|
|
||||||
if (/^[\[(]?\d{1,4}[\])]?\.?$/.test(num) && clone.textContent.trim().startsWith(num)) first.remove();
|
|
||||||
// Only the number: the note (a poem, a long quote) runs on in the next
|
|
||||||
// paragraphs, up to the next note's anchor.
|
|
||||||
if (!clone.textContent.trim()) {
|
|
||||||
let len = 0;
|
|
||||||
for (let next = container.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
|
|
||||||
if (/^H[1-6]$/.test(next.tagName) || next.querySelector('[id]')) break;
|
|
||||||
if (!next.textContent.trim()) continue;
|
|
||||||
clone.appendChild(next.cloneNode(true));
|
|
||||||
len += next.textContent.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const popover = document.createElement('div');
|
const popover = document.createElement('div');
|
||||||
popover.className = 'footnote-popover';
|
popover.className = 'footnote-popover';
|
||||||
|
|
@ -43,7 +133,7 @@ function showFootnotePopover(link) {
|
||||||
['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => {
|
['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => {
|
||||||
if (_overlay?.classList.contains(cls)) popover.classList.add(cls);
|
if (_overlay?.classList.contains(cls)) popover.classList.add(cls);
|
||||||
});
|
});
|
||||||
popover.innerHTML = clone.innerHTML;
|
popover.appendChild(note);
|
||||||
|
|
||||||
const closeBtn = document.createElement('button');
|
const closeBtn = document.createElement('button');
|
||||||
closeBtn.className = 'footnote-popover-close';
|
closeBtn.className = 'footnote-popover-close';
|
||||||
|
|
@ -66,7 +156,7 @@ function showFootnotePopover(link) {
|
||||||
|
|
||||||
const pr = popover.getBoundingClientRect();
|
const pr = popover.getBoundingClientRect();
|
||||||
if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px';
|
if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px';
|
||||||
if (pr.bottom > window.innerHeight - 8) popover.style.top = (rect.top - pr.height - 8) + 'px';
|
if (pr.bottom > window.innerHeight - 8) popover.style.top = Math.max(8, rect.top - pr.height - 8) + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissFootnotePopover() {
|
function dismissFootnotePopover() {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@
|
||||||
folder: null, // the folder chip chosen (null: all, '': without a folder)
|
folder: null, // the folder chip chosen (null: all, '': without a folder)
|
||||||
folderOrder: [], // the podcasts' folder order (/podcasts/folders/)
|
folderOrder: [], // the podcasts' folder order (/podcasts/folders/)
|
||||||
series: null, // the series shown (its key), inside the folder
|
series: null, // the series shown (its key), inside the folder
|
||||||
|
q: '', // the search in the shelf's head
|
||||||
|
searching: false, // the search field open (narrow screens show a ⌕ first)
|
||||||
|
sort: cached('sort') || 'recent',
|
||||||
|
status: 'all', // the state chip: all, started, unheard, heard
|
||||||
};
|
};
|
||||||
|
|
||||||
const shelf = document.createElement('div');
|
const shelf = document.createElement('div');
|
||||||
|
|
@ -175,45 +179,6 @@
|
||||||
return st.detail;
|
return st.detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the shelf in "Bücher" ------------------------------------------------------------
|
|
||||||
|
|
||||||
function renderShelf() {
|
|
||||||
const h = host();
|
|
||||||
const books = st.books || [];
|
|
||||||
const state = h ? podcastState() : null;
|
|
||||||
const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : [];
|
|
||||||
if (!h) { shelf.innerHTML = ''; return; }
|
|
||||||
if (!books.length && !pods.length) {
|
|
||||||
shelf.innerHTML = '<p class="ab-empty">Hörbücher und Hörspiele sind hier Podcasts, die wie ein Buch gehört ' +
|
|
||||||
'werden: Kapitel in Reihenfolge, Lesezeichen. Erst einen Podcast abonnieren (Podcasts → Suche), dann hier markieren.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let html = '';
|
|
||||||
const go = books.filter(b => b.next && b.last_played_at)[0];
|
|
||||||
if (go) {
|
|
||||||
html += '<div class="ab-continue" data-ab-open="' + go.feed_id + '">' +
|
|
||||||
cover(go.artwork_url, go.title, 'ab-cover ab-cover-s') +
|
|
||||||
'<div class="ab-card-text"><div class="ab-label">Weiterhören</div>' +
|
|
||||||
'<div class="ab-title">' + esc(go.title) + '</div>' +
|
|
||||||
'<div class="ab-meta">' + esc(progressText(go)) + '</div>' +
|
|
||||||
'<div class="ab-bar"><span style="width:' + pct(go) + '%"></span></div></div>' +
|
|
||||||
'<button type="button" class="ab-play" data-ab-go="' + go.feed_id + '" aria-label="Weiterhören">▶</button></div>';
|
|
||||||
}
|
|
||||||
html += '<div class="ab-head"><h3 class="ab-h">Hörbücher</h3>' +
|
|
||||||
(pods.length ? '<button type="button" class="ab-link" data-ab="pick">+ Hörbuch</button>' : '') + '</div>';
|
|
||||||
if (books.length) {
|
|
||||||
html += gridHtml(books);
|
|
||||||
} else {
|
|
||||||
html += '<p class="ab-empty">Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' +
|
|
||||||
'wie ein Buch hören: in Kapitelreihenfolge, mit Lesezeichen. „+ Hörbuch“ wählt einen aus.</p>';
|
|
||||||
}
|
|
||||||
if (st.picking) html += pickHtml(pods);
|
|
||||||
shelf.innerHTML = html;
|
|
||||||
drawChips(books);
|
|
||||||
const input = shelf.querySelector('.ab-pick-q');
|
|
||||||
if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- organising: folders (set per book) and series (found in the titles) ----------------
|
// ---- organising: folders (set per book) and series (found in the titles) ----------------
|
||||||
|
|
||||||
// "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12":
|
// "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12":
|
||||||
|
|
@ -270,28 +235,131 @@
|
||||||
x.sort.localeCompare(y.sort, 'de'));
|
x.sort.localeCompare(y.sort, 'de'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the shelf, built like the books' library (53-library.js: its lib-* parts, and its
|
||||||
|
// styles, which hold for #audiobook-shelf too): a head with search, sort and +, chips for
|
||||||
|
// the state and the folders, "Weiterhören" on top, the books as cards. ---------------------
|
||||||
|
|
||||||
|
const SORTS = [['recent', 'Zuletzt'], ['title', 'Titel'], ['author', 'Autor'],
|
||||||
|
['long', 'Längste zuerst'], ['short', 'Kürzeste zuerst']];
|
||||||
|
const heard = b => !b.next;
|
||||||
|
const started = b => !!b.next && (b.heard_seconds > 0 || b.done > 0 || !!b.last_played_at);
|
||||||
|
const unheard = b => !!b.next && !started(b);
|
||||||
|
const STATES = {started, unheard, heard};
|
||||||
|
|
||||||
|
function renderShelf() {
|
||||||
|
const h = host();
|
||||||
|
const books = st.books || [];
|
||||||
|
const state = h ? podcastState() : null;
|
||||||
|
const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : [];
|
||||||
|
if (!h) { shelf.innerHTML = ''; return; }
|
||||||
|
const narrow = window.matchMedia && matchMedia('(max-width: 720px)').matches;
|
||||||
|
let html = '<div class="lib-head"><h2 class="lib-h">Hörbücher</h2>' +
|
||||||
|
(!narrow || st.searching || st.q
|
||||||
|
? '<input class="lib-q" type="search" placeholder="Titel, Autor …" value="' + esc(st.q) + '">'
|
||||||
|
: '<button type="button" class="lib-icon" data-ab="search" aria-label="Suchen">⌕</button>') +
|
||||||
|
'<select class="lib-sort" aria-label="Sortieren">' + SORTS.map(s =>
|
||||||
|
'<option value="' + s[0] + '"' + (st.sort === s[0] ? ' selected' : '') + '>' + s[1] + '</option>').join('') + '</select>' +
|
||||||
|
(pods.length ? '<button type="button" class="lib-icon lib-add" data-ab="pick" aria-label="Hörbuch hinzufügen" ' +
|
||||||
|
'title="Einen abonnierten Podcast als Hörbuch führen">+</button>' : '') +
|
||||||
|
'</div><div class="lib-chips"></div>';
|
||||||
|
if (st.picking) html += pickHtml(pods);
|
||||||
|
if (!books.length) {
|
||||||
|
html += '<p class="lib-empty">' + (pods.length
|
||||||
|
? 'Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier wie ein Buch hören: ' +
|
||||||
|
'in Kapitelreihenfolge, mit Lesezeichen. Mit + einen auswählen.'
|
||||||
|
: 'Hörbücher und Hörspiele sind hier Podcasts, die wie ein Buch gehört werden: Kapitel in Reihenfolge, ' +
|
||||||
|
'Lesezeichen. Erst einen Podcast abonnieren (Podcasts → Suche), dann hier mit + markieren.') + '</p>';
|
||||||
|
} else {
|
||||||
|
html += listHtml(books);
|
||||||
|
}
|
||||||
|
const q = shelf.querySelector('.lib-q');
|
||||||
|
const focused = q && document.activeElement === q;
|
||||||
|
shelf.innerHTML = html;
|
||||||
|
drawChips(books);
|
||||||
|
if (focused || (st.searching && !st.q)) {
|
||||||
|
const n = shelf.querySelector('.lib-q');
|
||||||
|
if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); }
|
||||||
|
}
|
||||||
|
const input = shelf.querySelector('.ab-pick-q');
|
||||||
|
if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function shownBooks(books) {
|
||||||
|
const q = (st.q || '').trim().toLowerCase();
|
||||||
|
return books.filter(b =>
|
||||||
|
(st.folder == null ? true : st.folder === '' ? !foldersOf(b).length : foldersOf(b).indexOf(st.folder) >= 0) &&
|
||||||
|
(!STATES[st.status] || STATES[st.status](b)) &&
|
||||||
|
(!q || (b.title + ' ' + (b.author || '')).toLowerCase().indexOf(q) >= 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list's order (entries() comes "Zuletzt": the last heard first).
|
||||||
|
function sorted(list) {
|
||||||
|
const books = e => (e.kind === 'series' ? e.books : [e.b]);
|
||||||
|
const length = e => books(e).reduce((n, b) => n + (b.total_seconds || 0), 0);
|
||||||
|
const author = e => books(e)[0].author || '';
|
||||||
|
const by = {
|
||||||
|
title: (x, y) => x.sort.localeCompare(y.sort, 'de'),
|
||||||
|
author: (x, y) => author(x).localeCompare(author(y), 'de') || x.sort.localeCompare(y.sort, 'de'),
|
||||||
|
long: (x, y) => length(y) - length(x),
|
||||||
|
short: (x, y) => length(x) - length(y),
|
||||||
|
}[st.sort];
|
||||||
|
return by ? list.slice().sort(by) : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listHtml(books) {
|
||||||
|
const here = shownBooks(books);
|
||||||
|
// Series as stacks, unless searching or filtering by state (as with the books).
|
||||||
|
const stack = !st.q && !STATES[st.status];
|
||||||
|
const list = sorted(stack ? entries(here) : here.map(b => ({kind: 'book', b, sort: b.title})));
|
||||||
|
if (st.series != null) {
|
||||||
|
const g = list.filter(e => e.kind === 'series' && e.key === st.series)[0];
|
||||||
|
if (g) {
|
||||||
|
return '<div class="lib-place"><button type="button" class="lib-btn" data-ab="up">‹ Hörbücher</button>' +
|
||||||
|
'<strong class="lib-place-name">' + esc(g.name) + '</strong></div>' +
|
||||||
|
'<div class="lib-grid">' + g.books.map(b => bookCard(b, b._volume)).join('') + '</div>';
|
||||||
|
}
|
||||||
|
st.series = null;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
const go = !st.q && st.folder == null && !STATES[st.status] && books.filter(b => b.next && b.last_played_at)[0];
|
||||||
|
if (go) html += continueHtml(go);
|
||||||
|
if (!list.length) return html + '<p class="lib-empty">Nichts gefunden.</p>';
|
||||||
|
return html + '<div class="lib-grid">' + list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Weiterhören": the book last listened to, large — like "Weiterlesen".
|
||||||
|
function continueHtml(b) {
|
||||||
|
return '<section class="lib-recent" aria-label="Weiterhören"><div class="lib-continue" data-ab-open="' + b.feed_id + '">' +
|
||||||
|
cover(b.artwork_url, b.title, 'lib-hero-cover', b.author) +
|
||||||
|
'<span class="lib-text"><span class="lib-kicker">Weiterhören</span>' +
|
||||||
|
'<span class="lib-title lib-hero-title">' + esc(b.title) + '</span>' +
|
||||||
|
(b.author ? '<span class="lib-meta">' + esc(b.author) + '</span>' : '') +
|
||||||
|
'<span class="lib-hero-progress"><span class="lib-bar"><span style="width:' + pct(b) + '%"></span></span>' +
|
||||||
|
'<span class="lib-meta">' + esc(progressText(b)) + '</span></span>' +
|
||||||
|
'<button type="button" class="lib-play" data-ab-go="' + b.feed_id + '">▶ Weiterhören</button></span></div></section>';
|
||||||
|
}
|
||||||
|
|
||||||
function bookCard(b, label) {
|
function bookCard(b, label) {
|
||||||
return '<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
|
return '<div class="lib-card" data-ab-open="' + b.feed_id + '">' +
|
||||||
cover(b.artwork_url, b.title, 'ab-cover') +
|
'<span class="lib-coverbox">' + cover(b.artwork_url, b.title, 'lib-cover', b.author) +
|
||||||
'<span class="ab-card-text"><span class="ab-title">' + esc(label || b.title) + '</span>' +
|
(heard(b) ? '<span class="lib-badge">✓</span>' : '') + '</span>' +
|
||||||
(b.author && !label ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
|
'<span class="lib-bar"><span style="width:' + pct(b) + '%"></span></span>' +
|
||||||
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
|
'<span class="lib-title">' + esc(label || b.title) + '</span>' +
|
||||||
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>';
|
'<span class="lib-meta">' + esc(label ? progressText(b) : (b.author || progressText(b))) + '</span></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function seriesCard(g) {
|
function seriesCard(g) {
|
||||||
const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0);
|
const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0);
|
||||||
const heard = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
|
const heardSecs = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
|
||||||
const going = g.books.filter(b => b.next && b.last_played_at)[0];
|
const going = g.books.filter(b => b.next && b.last_played_at)[0];
|
||||||
const done = g.books.filter(b => !b.next).length;
|
const done = g.books.filter(heard).length;
|
||||||
const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume
|
const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume
|
||||||
: done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : '');
|
: done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : '');
|
||||||
return '<button type="button" class="ab-card ab-stack" data-ab-series="' + esc(g.key) + '">' +
|
return '<div class="lib-card lib-stack" data-ab-series="' + esc(g.key) + '">' +
|
||||||
'<span class="ab-stack-covers">' + cover(g.books[0].artwork_url, g.name, 'ab-cover') + '</span>' +
|
'<span class="lib-coverbox lib-stackbox">' + cover(g.books[0].artwork_url, g.name, 'lib-cover', g.books[0].author) + '</span>' +
|
||||||
'<span class="ab-card-text"><span class="ab-title">' + esc(g.name) + '</span>' +
|
'<span class="lib-bar"><span style="width:' + (total ? Math.round(100 * heardSecs / total) : 0) + '%"></span></span>' +
|
||||||
(g.books[0].author ? '<span class="ab-meta">' + esc(g.books[0].author) + '</span>' : '') +
|
'<span class="lib-title">' + esc(g.name) + '</span>' +
|
||||||
'<span class="ab-meta">' + esc(meta) + '</span>' +
|
'<span class="lib-meta">' + esc(meta) + '</span></div>';
|
||||||
'<span class="ab-bar"><span style="width:' + (total ? Math.round(100 * heard / total) : 0) + '%"></span></span></span></button>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const foldersOf = b => (b.folders && b.folders.length ? b.folders : (b.folder ? [b.folder] : []));
|
const foldersOf = b => (b.folders && b.folders.length ? b.folders : (b.folder ? [b.folder] : []));
|
||||||
|
|
@ -311,32 +379,24 @@
|
||||||
return {folders: names.map(n => ({name: n, count: counts.get(n)})), none: names.length ? none : 0};
|
return {folders: names.map(n => ({name: n, count: counts.get(n)})), none: names.length ? none : 0};
|
||||||
}
|
}
|
||||||
|
|
||||||
function gridHtml(books) {
|
|
||||||
const here = books.filter(b => st.folder == null ? true
|
|
||||||
: st.folder === '' ? !foldersOf(b).length : foldersOf(b).indexOf(st.folder) >= 0);
|
|
||||||
const list = entries(here);
|
|
||||||
if (st.series != null) {
|
|
||||||
const g = list.filter(e => e.kind === 'series' && e.key === st.series)[0];
|
|
||||||
if (g) {
|
|
||||||
return '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ Hörbücher</button>' +
|
|
||||||
'<strong>' + esc(g.name) + '</strong></div>' +
|
|
||||||
'<div class="ab-grid">' + g.books.map(b => bookCard(b, b._volume)).join('') + '</div>';
|
|
||||||
}
|
|
||||||
st.series = null;
|
|
||||||
}
|
|
||||||
return '<div class="ab-chips"></div><div class="ab-grid">' +
|
|
||||||
list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawChips(books) {
|
function drawChips(books) {
|
||||||
const el = shelf.querySelector('.ab-chips');
|
const el = shelf.querySelector('.lib-chips');
|
||||||
if (!el || !window.DioraFolders) return;
|
if (!el || !window.DioraFolders) return;
|
||||||
|
if (!books.length) { el.innerHTML = ''; return; }
|
||||||
const c = folderChips(books);
|
const c = folderChips(books);
|
||||||
if (!c.folders.length) { el.innerHTML = ''; return; }
|
|
||||||
window.DioraFolders.chips(el, {
|
window.DioraFolders.chips(el, {
|
||||||
|
lead: [
|
||||||
|
{id: 'started', label: 'Angefangen', count: books.filter(started).length, on: st.status === 'started'},
|
||||||
|
{id: 'unheard', label: 'Ungehört', count: books.filter(unheard).length, on: st.status === 'unheard'},
|
||||||
|
{id: 'heard', label: 'Gehört', count: books.filter(heard).length, on: st.status === 'heard'},
|
||||||
|
],
|
||||||
folders: c.folders, active: st.folder, none: c.none,
|
folders: c.folders, active: st.folder, none: c.none,
|
||||||
onPick(name) { st.folder = name; renderShelf(); },
|
onPick(p) {
|
||||||
onManage: manageFolders,
|
if (p && p.lead) st.status = st.status === p.lead ? 'all' : p.lead;
|
||||||
|
else st.folder = p;
|
||||||
|
renderShelf();
|
||||||
|
},
|
||||||
|
onManage: c.folders.length ? manageFolders : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -442,7 +502,7 @@
|
||||||
const d = st.detail && st.detail.book.feed_id === st.open ? st.detail : null;
|
const d = st.detail && st.detail.book.feed_id === st.open ? st.detail : null;
|
||||||
const fromList = (st.books || []).filter(b => b.feed_id === st.open)[0];
|
const fromList = (st.books || []).filter(b => b.feed_id === st.open)[0];
|
||||||
const b = d ? d.book : fromList;
|
const b = d ? d.book : fromList;
|
||||||
if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; }
|
if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Hörbücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; }
|
||||||
const now = nowInBook();
|
const now = nowInBook();
|
||||||
const marks = d ? d.bookmarks : [];
|
const marks = d ? d.bookmarks : [];
|
||||||
const dlMap = d ? downloads() : null;
|
const dlMap = d ? downloads() : null;
|
||||||
|
|
@ -452,7 +512,7 @@
|
||||||
return ch ? 'Kapitel ' + (ch.index + 1) : '';
|
return ch ? 'Kapitel ' + (ch.index + 1) : '';
|
||||||
};
|
};
|
||||||
view.innerHTML =
|
view.innerHTML =
|
||||||
'<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button>' +
|
'<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Hörbücher</button>' +
|
||||||
'<button type="button" class="ab-icon" data-ab="menu" aria-label="Optionen">⋮</button></div>' +
|
'<button type="button" class="ab-icon" data-ab="menu" aria-label="Optionen">⋮</button></div>' +
|
||||||
'<div class="ab-menu" hidden>' +
|
'<div class="ab-menu" hidden>' +
|
||||||
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
|
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
|
||||||
|
|
@ -763,8 +823,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
shelf.addEventListener('click', e => {
|
shelf.addEventListener('click', e => {
|
||||||
const t = e.target.closest('button, .ab-continue');
|
const t = e.target.closest('button, .lib-continue, .lib-card');
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
|
if (t.dataset.ab === 'search') { st.searching = true; renderShelf(); return; }
|
||||||
if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); return; }
|
if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); return; }
|
||||||
if (t.dataset.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; }
|
if (t.dataset.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; }
|
||||||
if (t.dataset.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; }
|
if (t.dataset.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; }
|
||||||
|
|
@ -792,7 +853,18 @@
|
||||||
if (t.dataset.ab === 'pick') { st.picking = !st.picking; st.pickFocus = st.picking; renderShelf(); return; }
|
if (t.dataset.ab === 'pick') { st.picking = !st.picking; st.pickFocus = st.picking; renderShelf(); return; }
|
||||||
if (t.dataset.ab === 'pick-close') { st.picking = false; renderShelf(); }
|
if (t.dataset.ab === 'pick-close') { st.picking = false; renderShelf(); }
|
||||||
});
|
});
|
||||||
|
shelf.addEventListener('change', e => {
|
||||||
|
if (!e.target.classList.contains('lib-sort')) return;
|
||||||
|
st.sort = e.target.value;
|
||||||
|
cached('sort', st.sort);
|
||||||
|
renderShelf();
|
||||||
|
});
|
||||||
shelf.addEventListener('input', e => {
|
shelf.addEventListener('input', e => {
|
||||||
|
if (e.target.classList.contains('lib-q')) {
|
||||||
|
st.q = e.target.value;
|
||||||
|
renderShelf();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!e.target.classList.contains('ab-pick-q')) return;
|
if (!e.target.classList.contains('ab-pick-q')) return;
|
||||||
st.pickQuery = e.target.value;
|
st.pickQuery = e.target.value;
|
||||||
const box = shelf.querySelector('.ab-pick-list');
|
const box = shelf.querySelector('.ab-pick-list');
|
||||||
|
|
|
||||||
|
|
@ -122,16 +122,20 @@
|
||||||
|
|
||||||
async function pdfCover(bytes) {
|
async function pdfCover(bytes) {
|
||||||
if (typeof pdfjsLib === 'undefined') return null;
|
if (typeof pdfjsLib === 'undefined') return null;
|
||||||
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise;
|
const pdf = await (typeof _pdfLoad === 'function' ? _pdfLoad(new Uint8Array(bytes))
|
||||||
|
: pdfjsLib.getDocument({data: new Uint8Array(bytes)})).promise;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
try {
|
||||||
const page = await pdf.getPage(1);
|
const page = await pdf.getPage(1);
|
||||||
const v = page.getViewport({scale: 1});
|
const v = page.getViewport({scale: 1});
|
||||||
const scale = Math.min(MAX_W / v.width, MAX_H / v.height);
|
const scale = Math.min(MAX_W / v.width, MAX_H / v.height);
|
||||||
const viewport = page.getViewport({scale});
|
const viewport = page.getViewport({scale});
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = Math.round(viewport.width);
|
canvas.width = Math.round(viewport.width);
|
||||||
canvas.height = Math.round(viewport.height);
|
canvas.height = Math.round(viewport.height);
|
||||||
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
|
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
|
||||||
pdf.destroy();
|
} 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -603,59 +603,35 @@
|
||||||
|
|
||||||
// ---- article view
|
// ---- article view
|
||||||
|
|
||||||
let reader = null; // the overlay element
|
// ---- reading: an article opens in the book reader (65-article.js, DioraArticle) —
|
||||||
|
// the same typography, colours, pages, search, read-aloud and full screen as books.
|
||||||
|
|
||||||
let readerList = []; // what "next" walks through (blog items)
|
let readerList = []; // what "next" walks through (blog items)
|
||||||
let readerStory = null;
|
let readerStory = null;
|
||||||
let readerItem = null;
|
let readerItem = null;
|
||||||
|
let reading = false; // an article of ours is in the reader
|
||||||
function fontSize() {
|
|
||||||
let v = 18;
|
|
||||||
try { v = parseInt(localStorage.getItem('diora_feeds_font') || '18', 10) || 18; } catch (e) {}
|
|
||||||
return Math.max(14, Math.min(26, v));
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureReader() {
|
|
||||||
if (reader) return reader;
|
|
||||||
reader = document.createElement('div');
|
|
||||||
reader.id = 'fd-article';
|
|
||||||
reader.hidden = true;
|
|
||||||
reader.addEventListener('click', onReaderClick);
|
|
||||||
reader.addEventListener('scroll', () => {
|
|
||||||
clearTimeout(progressTimer);
|
|
||||||
progressTimer = setTimeout(saveProgress, 1200);
|
|
||||||
}, {passive: true});
|
|
||||||
document.body.appendChild(reader);
|
|
||||||
return reader;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readerOpen() {
|
function readerOpen() {
|
||||||
return !!(reader && !reader.hidden);
|
return reading && !!(window.DioraArticle && DioraArticle.isOpen());
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeReader() {
|
function closeReader() {
|
||||||
if (!reader) return;
|
if (window.DioraArticle) DioraArticle.close();
|
||||||
saveProgress();
|
}
|
||||||
reader.hidden = true;
|
|
||||||
reader.innerHTML = '';
|
function articleClosed() {
|
||||||
document.documentElement.classList.remove('fd-reading');
|
reading = false;
|
||||||
readerItem = readerStory = null;
|
readerItem = readerStory = null;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// How far the open article is read (0-1); saved now and then and when leaving it.
|
// How far the open article is read (0-1, from the reader); saved now and then and when leaving it.
|
||||||
let progressSent = 0;
|
let progressSent = 0;
|
||||||
let progressTimer = null;
|
|
||||||
|
|
||||||
function readProgress() {
|
function saveProgress(f) {
|
||||||
if (!reader || reader.hidden) return 0;
|
|
||||||
const room = reader.scrollHeight - reader.clientHeight;
|
|
||||||
return room <= 40 ? 1 : Math.min(1, reader.scrollTop / room);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveProgress() {
|
|
||||||
const i = readerItem;
|
const i = readerItem;
|
||||||
if (!i) return;
|
if (!i) return;
|
||||||
const p = Math.round(readProgress() * 100) / 100;
|
const p = Math.round(f * 100) / 100;
|
||||||
if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return;
|
if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return;
|
||||||
progressSent = p;
|
progressSent = p;
|
||||||
i.progress = p;
|
i.progress = p;
|
||||||
|
|
@ -667,51 +643,68 @@
|
||||||
api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {});
|
api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openItem(id, story) {
|
// Opens a link outside, like the article's own links (the app hands them to the browser).
|
||||||
ensureReader();
|
function openOutside(url) {
|
||||||
if (readerItem) saveProgress();
|
const a = document.createElement('a');
|
||||||
readerStory = story || null;
|
a.href = url;
|
||||||
reader.hidden = false;
|
a.target = '_blank';
|
||||||
document.documentElement.classList.add('fd-reading');
|
a.rel = 'noopener';
|
||||||
reader.innerHTML = readerShell('<p class="fd-note">Lädt …</p>');
|
document.body.appendChild(a);
|
||||||
reader.scrollTop = 0;
|
a.click();
|
||||||
try {
|
a.remove();
|
||||||
const data = await api('/feeds/items/' + id + '/');
|
|
||||||
readerItem = data.item;
|
|
||||||
reader.innerHTML = readerShell(articleHtml(readerItem));
|
|
||||||
reader.scrollTop = 0;
|
|
||||||
if (readerStory) loadOverview(readerStory);
|
|
||||||
progressSent = readerItem.progress || 0;
|
|
||||||
if (readerItem.kind === 'news') {
|
|
||||||
// News is read once opened; a blog post once read to 90 % (the server keeps where).
|
|
||||||
if (!readerItem.read) {
|
|
||||||
readerItem.read = true;
|
|
||||||
markRead([id], true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (progressSent > 0.02 && progressSent < 0.9) {
|
|
||||||
// Back where reading stopped (on any device).
|
|
||||||
requestAnimationFrame(() => { reader.scrollTop = progressSent * (reader.scrollHeight - reader.clientHeight); });
|
|
||||||
} else {
|
|
||||||
setTimeout(saveProgress, 1500); // short enough to fit: read at once
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
reader.innerHTML = readerShell('<p class="fd-error">' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '</p>');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readerShell(inner) {
|
// The article's own buttons, in the reader's header (the apps: in its menu).
|
||||||
const i = readerItem;
|
function articleActions(i) {
|
||||||
return '<div class="fd-art-bar">' +
|
return [
|
||||||
'<button type="button" class="fd-icon" data-r="close" aria-label="Zurück" title="Zurück">‹</button>' +
|
{icon: i.starred ? '★' : '☆', label: i.starred ? 'Gemerkt' : 'Merken', active: !!i.starred, action: () => toggleStar(i)},
|
||||||
'<span class="fd-art-src">' + esc(i ? i.source_title : '') + '</span>' +
|
|
||||||
'<button type="button" class="fd-icon" data-r="font" aria-label="Schriftgröße" title="Schriftgröße">Aa</button>' +
|
|
||||||
(i ? '<button type="button" class="fd-icon' + (i.starred ? ' on' : '') + '" data-r="star" aria-label="Merken" title="Merken">' + (i.starred ? '★' : '☆') + '</button>' : '') +
|
|
||||||
// Projekte (projects.js): this article as a source of a project.
|
// Projekte (projects.js): this article as a source of a project.
|
||||||
(i && window.DioraProjects ? '<button type="button" class="fd-icon" data-r="project" aria-label="Mit Projekt verknüpfen" title="Mit Projekt verknüpfen">◇</button>' : '') +
|
window.DioraProjects && {icon: '◇', label: 'Mit Projekt verknüpfen', action: () => window.DioraProjects.link('article', i.id, {
|
||||||
(i && i.url ? '<a class="fd-icon" href="' + esc(i.url) + '" target="_blank" rel="noopener" aria-label="Im Browser öffnen" title="Im Browser öffnen">↗</a>' : '') +
|
title: i.title, source: i.source_title, author: i.author, date: i.published, url: i.url, news: i.kind === 'news'})},
|
||||||
'</div><div class="fd-art" style="font-size:' + fontSize() + 'px">' + inner + '</div>';
|
i.url && {icon: '↗', label: 'Im Browser öffnen', action: () => openOutside(i.url)},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStar(i) {
|
||||||
|
i.starred = !i.starred;
|
||||||
|
api('/feeds/items/' + i.id + '/star/', {starred: i.starred}).catch(() => {});
|
||||||
|
st.items.forEach(x => { if (x.id === i.id) x.starred = i.starred; });
|
||||||
|
if (window.DioraArticle) DioraArticle.refreshActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openItem(id, story) {
|
||||||
|
if (!window.DioraArticle) return;
|
||||||
|
readerStory = story || null;
|
||||||
|
let item = null;
|
||||||
|
let error = '';
|
||||||
|
try {
|
||||||
|
item = (await api('/feeds/items/' + id + '/')).item;
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message;
|
||||||
|
}
|
||||||
|
readerItem = item;
|
||||||
|
reading = true;
|
||||||
|
if (!item) {
|
||||||
|
DioraArticle.open({title: 'Artikel', html: '<p class="fd-error">' + esc(error) + '</p>',
|
||||||
|
onClick: onReaderClick, onClose: articleClosed});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
progressSent = item.progress || 0;
|
||||||
|
await DioraArticle.open({
|
||||||
|
title: item.source_title || item.title,
|
||||||
|
html: articleHtml(item),
|
||||||
|
// News is read once opened; a blog post once read to 90 % (the server keeps where).
|
||||||
|
startFraction: item.kind === 'news' ? 0 : progressSent,
|
||||||
|
onProgress: f => { if (readerItem === item) saveProgress(f); },
|
||||||
|
onClick: onReaderClick,
|
||||||
|
onClose: articleClosed,
|
||||||
|
actions: () => articleActions(item),
|
||||||
|
});
|
||||||
|
if (readerStory) loadOverview(readerStory);
|
||||||
|
if (item.kind === 'news' && !item.read) {
|
||||||
|
item.read = true;
|
||||||
|
markRead([id], true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function articleHtml(i) {
|
function articleHtml(i) {
|
||||||
|
|
@ -727,6 +720,10 @@
|
||||||
if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>';
|
if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>';
|
||||||
const content = i.content || '';
|
const content = i.content || '';
|
||||||
if (i.image_url && content.indexOf(i.image_url) < 0) h += '<img class="fd-art-img" alt="" src="' + esc(i.image_url) + '">';
|
if (i.image_url && content.indexOf(i.image_url) < 0) h += '<img class="fd-art-img" alt="" src="' + esc(i.image_url) + '">';
|
||||||
|
if (i.episode_id) {
|
||||||
|
// The article of a podcast episode (docs/transcription.md): to the episode itself.
|
||||||
|
h += '<p class="fd-art-listen"><button type="button" class="btn" data-r="episode" data-id="' + i.episode_id + '">▶ Folge anhören</button></p>';
|
||||||
|
}
|
||||||
h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>';
|
h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>';
|
||||||
if (i.paywalled) {
|
if (i.paywalled) {
|
||||||
h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
|
h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
|
||||||
|
|
@ -787,7 +784,7 @@
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return; // offline and never fetched: the article alone
|
return; // offline and never fetched: the article alone
|
||||||
}
|
}
|
||||||
const box = reader && !reader.hidden && readerStory === story && reader.querySelector('.fd-ov');
|
const box = readerOpen() && readerStory === story && document.querySelector('#reader-content .fd-ov');
|
||||||
if (box) box.innerHTML = overviewHtml(story);
|
if (box) box.innerHTML = overviewHtml(story);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -807,37 +804,76 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onReaderClick(e) {
|
// ---- the episode of a podcast article (docs/transcription.md)
|
||||||
const b = e.target.closest('[data-r]');
|
|
||||||
if (!b) {
|
function podcastHost() {
|
||||||
// Links in the article open outside (the app hands them to the browser).
|
return window.DioraPodcastHost || window.DioraNative || null;
|
||||||
const a = e.target.closest('.fd-art-body a[href]');
|
}
|
||||||
if (a) { a.target = '_blank'; a.rel = 'noopener'; }
|
|
||||||
|
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;
|
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) {
|
||||||
|
// A time mark in a podcast article ("▶ ab 12:34", href="#t=754"; the reader keeps it as data-internal-href).
|
||||||
|
const t = e.target.closest('a[data-internal-href^="#t="], a[href^="#t="]');
|
||||||
|
if (t && readerItem && readerItem.episode_id) {
|
||||||
|
const sec = parseInt((t.dataset.internalHref || t.getAttribute('href')).slice(3), 10);
|
||||||
|
if (sec >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
playEpisode(readerItem.episode_id, sec);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = e.target.closest('[data-r]');
|
||||||
|
if (!b) return;
|
||||||
const r = b.dataset.r;
|
const r = b.dataset.r;
|
||||||
if (r === 'close') closeReader();
|
if (r === 'episode') playEpisode(parseInt(b.dataset.id, 10), null);
|
||||||
else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
|
else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
|
||||||
else if (r === 'story') {
|
else if (r === 'story') {
|
||||||
const other = readerStory && readerStory.overview && (readerStory.overview.related || []).find(x => x.id === parseInt(b.dataset.id, 10));
|
const other = readerStory && readerStory.overview && (readerStory.overview.related || []).find(x => x.id === parseInt(b.dataset.id, 10));
|
||||||
if (other) openItem((other.lead || other.items[0]).id, other);
|
if (other) openItem((other.lead || other.items[0]).id, other);
|
||||||
}
|
}
|
||||||
else if (r === 'font') {
|
|
||||||
let v = fontSize() + 2;
|
|
||||||
if (v > 26) v = 14;
|
|
||||||
try { localStorage.setItem('diora_feeds_font', String(v)); } catch (err) {}
|
|
||||||
const art = reader.querySelector('.fd-art');
|
|
||||||
if (art) art.style.fontSize = v + 'px';
|
|
||||||
} else if (r === 'project' && readerItem && window.DioraProjects) {
|
|
||||||
window.DioraProjects.link('article', readerItem.id, {title: readerItem.title, source: readerItem.source_title,
|
|
||||||
author: readerItem.author, date: readerItem.published, url: readerItem.url, news: readerItem.kind === 'news'});
|
|
||||||
} else if (r === 'star' && readerItem) {
|
|
||||||
readerItem.starred = !readerItem.starred;
|
|
||||||
api('/feeds/items/' + readerItem.id + '/star/', {starred: readerItem.starred}).catch(() => {});
|
|
||||||
b.textContent = readerItem.starred ? '★' : '☆';
|
|
||||||
b.classList.toggle('on', readerItem.starred);
|
|
||||||
st.items.forEach(i => { if (i.id === readerItem.id) i.starred = readerItem.starred; });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openStory(id) {
|
async function openStory(id) {
|
||||||
|
|
@ -1354,9 +1390,6 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Escape' && readerOpen()) closeReader();
|
|
||||||
});
|
|
||||||
|
|
||||||
// diora Reader has no tab bar of its own: a small one. With the navigation (nav.js) it
|
// diora Reader has no tab bar of its own: a small one. With the navigation (nav.js) it
|
||||||
// holds the sections of Lesen itself — a row of them to swipe is no good on e-ink.
|
// holds the sections of Lesen itself — a row of them to swipe is no good on e-ink.
|
||||||
|
|
@ -1436,9 +1469,9 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
|
||||||
'.fd-sources { display: flex; gap: 6px; margin: 4px -18px 10px; padding: 0 18px; align-items: center; overflow-x: auto; white-space: nowrap; scrollbar-width: none; }',
|
'.fd-sources { display: flex; gap: 6px; margin: 4px -18px 10px; padding: 0 18px; align-items: center; overflow-x: auto; white-space: nowrap; scrollbar-width: none; }',
|
||||||
'.fd-sources::-webkit-scrollbar { display: none; }',
|
'.fd-sources::-webkit-scrollbar { display: none; }',
|
||||||
'.fd-sources .fd-chip { flex: none; }',
|
'.fd-sources .fd-chip { flex: none; }',
|
||||||
'#fd-article .fd-art-more { font-family: var(--font); margin: 30px 0 4px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
|
'.fd-artview .fd-art-more { font-family: var(--font); margin: 30px 0 4px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
|
||||||
'#fd-article .fd-other { display: flex; flex-direction: column; gap: 3px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 0.85em; text-align: left; cursor: pointer; }',
|
'.fd-artview .fd-other { display: flex; flex-direction: column; gap: 3px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 0.85em; text-align: left; cursor: pointer; }',
|
||||||
'#fd-article .fd-other-title { font-weight: 600; line-height: 1.35; }',
|
'.fd-artview .fd-other-title { font-weight: 600; line-height: 1.35; }',
|
||||||
'.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
|
'.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
|
||||||
'.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }',
|
'.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }',
|
||||||
'.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }',
|
'.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }',
|
||||||
|
|
@ -1491,45 +1524,39 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
|
||||||
'#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }',
|
'#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }',
|
||||||
'#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }',
|
'#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }',
|
||||||
'#tab-feeds .fd-file { display: inline-block; }',
|
'#tab-feeds .fd-file { display: inline-block; }',
|
||||||
// The article: a page over everything, like the book reader.
|
// The article, in the book reader (65-article.js).
|
||||||
'#fd-article { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; background: var(--bg, #000); color: var(--fg, #fff); overflow-y: auto; -webkit-overflow-scrolling: touch; }',
|
'.fd-artview .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
|
||||||
'#fd-article[hidden] { display: none; }',
|
'.fd-artview .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
|
||||||
'#fd-article .fd-art-bar { position: sticky; top: 0; display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: var(--bg, #000); border-bottom: 1px solid var(--border, #2a2a2a); padding-top: calc(4px + env(safe-area-inset-top, 0px)); }',
|
'.fd-artview .fd-art-img, .fd-artview .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }',
|
||||||
'#fd-article .fd-art-src { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--nui-muted, #9a9a9a); }',
|
'.fd-artview .fd-art-listen { margin: 0 0 12px; }',
|
||||||
'#fd-article .fd-art { max-width: 68ch; margin: 0 auto; padding: 12px 18px 48px; font-family: "Source Serif 4", Georgia, serif; font-weight: 380; line-height: 1.65; }',
|
'.fd-artview .fd-art-body a { color: var(--accent, #e63946); }',
|
||||||
'#fd-article .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
|
'.fd-artview .fd-art-body figure { margin: 12px 0; }',
|
||||||
'#fd-article .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
|
'.fd-artview .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
|
||||||
'#fd-article .fd-art-img, #fd-article .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }',
|
'.fd-artview .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
|
||||||
'#fd-article .fd-art-body a { color: var(--accent, #e63946); }',
|
'.fd-artview .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
|
||||||
'#fd-article .fd-art-body figure { margin: 12px 0; }',
|
'.fd-artview .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
|
||||||
'#fd-article .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
|
'.fd-artview .fd-art-body td, .fd-artview .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
|
||||||
'#fd-article .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
|
'.fd-artview .fd-sources { font-family: var(--font); }',
|
||||||
'#fd-article .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
|
|
||||||
'#fd-article .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
|
|
||||||
'#fd-article .fd-art-body td, #fd-article .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
|
|
||||||
'#fd-article .fd-sources { font-family: var(--font); }',
|
|
||||||
'#tab-feeds .fd-topics .fd-note { margin-right: 2px; }',
|
'#tab-feeds .fd-topics .fd-note { margin-right: 2px; }',
|
||||||
'#tab-feeds .fd-topics small { opacity: 0.6; }',
|
'#tab-feeds .fd-topics small { opacity: 0.6; }',
|
||||||
'#fd-article .fd-ov { font-size: 0.9em; }',
|
'.fd-artview .fd-ov { font-size: 0.9em; }',
|
||||||
'#fd-article .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
|
'.fd-artview .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
|
||||||
'#fd-article .fd-ov-box summary { font-family: var(--font); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); cursor: pointer; }',
|
'.fd-artview .fd-ov-box summary { font-family: var(--font); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); cursor: pointer; }',
|
||||||
'#fd-article .fd-ov p { margin: 8px 0; line-height: 1.45; }',
|
'.fd-artview .fd-ov p { margin: 8px 0; line-height: 1.45; }',
|
||||||
'#fd-article .fd-ov h4 { font-family: var(--font); font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); margin: 14px 0 4px; }',
|
'.fd-artview .fd-ov h4 { font-family: var(--font); font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); margin: 14px 0 4px; }',
|
||||||
'#fd-article .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
|
'.fd-artview .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
|
||||||
'#fd-article .fd-ov-words { font-family: var(--font); font-size: 13px; }',
|
'.fd-artview .fd-ov-words { font-family: var(--font); font-size: 13px; }',
|
||||||
'#fd-article .fd-ov-rel { margin: 0 0 18px; }',
|
'.fd-artview .fd-ov-rel { margin: 0 0 18px; }',
|
||||||
'#fd-article .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }',
|
'.fd-artview .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }',
|
||||||
'html.fd-reading #nui-dock { display: none; }',
|
|
||||||
'@media (hover: hover) { #tab-feeds .fd-card:hover, #tab-feeds .fd-row:hover, #tab-feeds .fd-post:hover, #tab-feeds .fd-src:hover { background: var(--hover, #111); } }',
|
'@media (hover: hover) { #tab-feeds .fd-card:hover, #tab-feeds .fd-row:hover, #tab-feeds .fd-post:hover, #tab-feeds .fd-src:hover { background: var(--hover, #111); } }',
|
||||||
// Bright pages and e-ink: black on white, no grey boxes, no thumbnails to flicker.
|
// Bright pages and e-ink: black on white, no grey boxes, no thumbnails to flicker.
|
||||||
'body.bright-bg #tab-feeds .fd-brief, body.eink #tab-feeds .fd-brief { background: #fff; }',
|
'body.bright-bg #tab-feeds .fd-brief, body.eink #tab-feeds .fd-brief { background: #fff; }',
|
||||||
'body.eink #tab-feeds .fd-thumb { display: none; }',
|
'body.eink #tab-feeds .fd-thumb { display: none; }',
|
||||||
'body.eink #tab-feeds .fd-seg button.on, body.eink #tab-feeds .fd-badge { background: #000; color: #fff; }',
|
'body.eink #tab-feeds .fd-seg button.on, body.eink #tab-feeds .fd-badge { background: #000; color: #fff; }',
|
||||||
'body.eink .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }',
|
'body.eink .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }',
|
||||||
'body.eink #fd-article, body.eink #fd-article .fd-art-bar { background: #fff; color: #000; }',
|
'body.eink .fd-artview .fd-ov-box { border-color: #000; }',
|
||||||
'body.eink #fd-article .fd-ov-box { border-color: #000; }',
|
'body.eink .fd-artview .fd-ov-src, body.eink .fd-artview .fd-ov h4, body.eink .fd-artview .fd-ov-box summary { color: #333; }',
|
||||||
'body.eink #fd-article .fd-ov-src, body.eink #fd-article .fd-ov h4, body.eink #fd-article .fd-ov-box summary { color: #333; }',
|
'body.eink .fd-artview .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }',
|
||||||
'body.eink #fd-article .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }',
|
|
||||||
].join('\n');
|
].join('\n');
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -22,22 +22,45 @@
|
||||||
// lead: chips before the folders (the library's Angefangen/Ungelesen …): [{id, label, count, on}],
|
// lead: chips before the folders (the library's Angefangen/Ungelesen …): [{id, label, count, on}],
|
||||||
// picked with onPick({lead: id}). active: null = all, '' = without a folder, else a name.
|
// picked with onPick({lead: id}). active: null = all, '' = without a folder, else a name.
|
||||||
|
|
||||||
|
// The folders fold away (a "Ordner" chip), per area (the list's element id), remembered on
|
||||||
|
// this device; folded, only the folder chosen stays in sight. Folded until opened.
|
||||||
|
function areaKey(el) {
|
||||||
|
const owner = el.id ? el : el.parentElement && el.parentElement.closest('[id]');
|
||||||
|
return 'diora_folders_folded_' + (owner ? owner.id : 'x');
|
||||||
|
}
|
||||||
|
|
||||||
|
function folded(el) {
|
||||||
|
try { return localStorage.getItem(areaKey(el)) !== '0'; } catch (e) { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
function chips(el, o) {
|
function chips(el, o) {
|
||||||
const lead = o.lead || [];
|
const lead = o.lead || [];
|
||||||
const leadOn = lead.some(c => c.on);
|
const leadOn = lead.some(c => c.on);
|
||||||
|
const folders = o.folders || [];
|
||||||
|
const fold = folders.length > 0 && folded(el);
|
||||||
let h = '<div class="dfo-row" role="toolbar">';
|
let h = '<div class="dfo-row" role="toolbar">';
|
||||||
if (!lead.length) h += chip('dfo-all', 'Alle', null, o.active == null && !leadOn, 'data-dfo-all="1"');
|
if (!lead.length) h += chip('dfo-all', 'Alle', null, o.active == null && !leadOn, 'data-dfo-all="1"');
|
||||||
lead.forEach(c => { h += chip('', c.label, c.count, !!c.on, 'data-dfo-lead="' + esc(c.id) + '"'); });
|
lead.forEach(c => { h += chip('', c.label, c.count, !!c.on, 'data-dfo-lead="' + esc(c.id) + '"'); });
|
||||||
if (lead.length && (o.folders || []).length) h += '<span class="dfo-sep" aria-hidden="true"></span>';
|
if (lead.length && folders.length) h += '<span class="dfo-sep" aria-hidden="true"></span>';
|
||||||
(o.folders || []).forEach(f => {
|
if (folders.length) {
|
||||||
|
h += '<button type="button" class="dfo-chip dfo-fold" data-dfo-fold="1" aria-expanded="' + !fold + '" title="' +
|
||||||
|
(fold ? 'Ordner zeigen' : 'Ordner einklappen') + '">Ordner ' + (fold ? '▸ <span class="dfo-count">' + folders.length + '</span>' : '▾') + '</button>';
|
||||||
|
}
|
||||||
|
folders.forEach(f => {
|
||||||
|
if (fold && o.active !== f.name) return;
|
||||||
h += chip('', f.name, f.count, o.active === f.name, 'data-dfo-folder="' + esc(f.name) + '"');
|
h += chip('', f.name, f.count, o.active === f.name, 'data-dfo-folder="' + esc(f.name) + '"');
|
||||||
});
|
});
|
||||||
if (o.none) h += chip('dfo-none', 'Ohne Ordner', o.none, o.active === '', 'data-dfo-folder=""');
|
if (o.none && (!fold || o.active === '')) h += chip('dfo-none', 'Ohne Ordner', o.none, o.active === '', 'data-dfo-folder=""');
|
||||||
if (o.onManage) h += '<button type="button" class="dfo-chip dfo-manage" data-dfo-manage="1" title="Ordner verwalten" aria-label="Ordner verwalten">⋯</button>';
|
if (o.onManage && !fold) h += '<button type="button" class="dfo-chip dfo-manage" data-dfo-manage="1" title="Ordner verwalten" aria-label="Ordner verwalten">⋯</button>';
|
||||||
el.innerHTML = h + '</div>';
|
el.innerHTML = h + '</div>';
|
||||||
el.onclick = e => {
|
el.onclick = e => {
|
||||||
const b = e.target.closest('button');
|
const b = e.target.closest('button');
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
|
if (b.dataset.dfoFold) {
|
||||||
|
try { localStorage.setItem(areaKey(el), fold ? '0' : '1'); } catch (err) {}
|
||||||
|
chips(el, o);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (b.dataset.dfoManage) { o.onManage(); return; }
|
if (b.dataset.dfoManage) { o.onManage(); return; }
|
||||||
if (b.dataset.dfoAll) { o.onPick(null); return; }
|
if (b.dataset.dfoAll) { o.onPick(null); return; }
|
||||||
if (b.dataset.dfoLead != null) { o.onPick({lead: b.dataset.dfoLead}); return; }
|
if (b.dataset.dfoLead != null) { o.onPick({lead: b.dataset.dfoLead}); return; }
|
||||||
|
|
@ -188,6 +211,7 @@
|
||||||
'.dfo-count { opacity: .7; font-size: 12px; }',
|
'.dfo-count { opacity: .7; font-size: 12px; }',
|
||||||
'.dfo-none { font-style: italic; }',
|
'.dfo-none { font-style: italic; }',
|
||||||
'.dfo-manage { min-width: 34px; padding: 0 8px; }',
|
'.dfo-manage { min-width: 34px; padding: 0 8px; }',
|
||||||
|
'.dfo-fold { border-style: dashed; }',
|
||||||
'.dfo-sep { flex: none; width: 1px; height: 20px; background: var(--border-strong, #444); margin: 0 2px; }',
|
'.dfo-sep { flex: none; width: 1px; height: 20px; background: var(--border-strong, #444); margin: 0 2px; }',
|
||||||
// Above the book view (900), below the page's dialogs (990): a name is asked for on top.
|
// Above the book view (900), below the page's dialogs (990): a name is asked for on top.
|
||||||
'.dfo-scrim { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 950; background: var(--scrim, rgba(0,0,0,.5)); display: flex; align-items: flex-end; justify-content: center; }',
|
'.dfo-scrim { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 950; background: var(--scrim, rgba(0,0,0,.5)); display: flex; align-items: flex-end; justify-content: center; }',
|
||||||
|
|
|
||||||
380
static/js/podcast-article.js
Normal file
380
static/js/podcast-article.js
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Podcast articles on the device (docs/transcription.md): the transcript of an episode goes to
|
||||||
|
// Claude with the user's own key (DioraAI, docs/ai.md) and comes back as an article that is saved
|
||||||
|
// to the episode and to the blog "Podcast-Artikel". Also the picker-up of open wishes: the server
|
||||||
|
// only remembers that an article was wanted; a device with a key writes it.
|
||||||
|
//
|
||||||
|
// An ES module like ai.js. window.DioraPodcastArticle = {write, pickUp, watch, ...}; the helpers
|
||||||
|
// are exported too (tools/tests/podcast-article.test.mjs runs them under Node).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const MAX_TOKENS_IN = 120000; // about nine hours of speech; more stops with a message
|
||||||
|
export const GROUP_SECONDS = 30;
|
||||||
|
const SHOWNOTES_MAX = 6000; // characters of the shownotes handed to Claude
|
||||||
|
const POLL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
const ai = () => globalThis.DioraAI;
|
||||||
|
|
||||||
|
export const SYSTEM = `You turn the transcript of a podcast episode into an article that reads as if the people
|
||||||
|
speaking had written it themselves instead of recording the episode.
|
||||||
|
|
||||||
|
The reader wants to decide whether this episode still has something to teach them. They do not
|
||||||
|
want a summary; they want to understand what is argued here well enough to judge it for
|
||||||
|
themselves. Write for that reader.
|
||||||
|
|
||||||
|
Perspective. Write from the speakers' standpoint and state their positions directly, as their
|
||||||
|
own claims. Never distance yourself from them: no "im Podcast wird die These aufgestellt", "der
|
||||||
|
Sprecher meint", "in dieser Folge geht es um", "die Gäste diskutieren", "laut X". If the episode
|
||||||
|
is one person speaking, write as that person argues, in the third person or impersonal voice,
|
||||||
|
but without reporting verbs. Do not add your own opinion, criticism or fact checks, and do not
|
||||||
|
soften what is said.
|
||||||
|
|
||||||
|
Conversations. Interviews, conversations and discussions are condensed into their core theses;
|
||||||
|
the form of question and answer disappears. Where people hold different positions, keep them
|
||||||
|
apart and name who holds which ("Zuckermann hält dagegen, dass …"), but do not turn it into
|
||||||
|
minutes. Questions from a host matter only where they shape the argument. When the transcript
|
||||||
|
has no speaker names, infer them from the context (introductions, the shownotes, how people
|
||||||
|
address each other); if you can't, do not invent names.
|
||||||
|
|
||||||
|
Substance over summary. Give the arguments with their reasons, examples and evidence, not just
|
||||||
|
the conclusions. Keep figures, dates, quotations that carry an argument (short, in quotation
|
||||||
|
marks, attributed), and every work mentioned (books, articles, studies, films) with author and
|
||||||
|
title. Make clear what is new, distinctive or surprising in the episode and where it restates
|
||||||
|
what is commonly said, by how you weight and order it, without grading it.
|
||||||
|
|
||||||
|
Leave out: advertising and sponsors, appeals for donations, subscriptions or ratings, notes on
|
||||||
|
the podcast itself (schedules, technology, community), greetings, small talk and digressions that
|
||||||
|
lead nowhere.
|
||||||
|
|
||||||
|
Form. Write in the language of the episode. \`title\`: a title for the article, not the episode
|
||||||
|
title repeated. \`summary\`: three to six sentences up front that state the central thesis and how
|
||||||
|
the episode gets there. \`sections\`: as many as the content needs (usually four to ten), each
|
||||||
|
with a heading that says something (not "Einleitung"), \`start\` = the second in the episode where
|
||||||
|
its content begins (from the [mm:ss] marks), and \`body_markdown\` in plain paragraphs; lists only
|
||||||
|
where the speakers list things themselves. Follow the order of the episode unless a different
|
||||||
|
order makes the argument clearer. Length: as long as needed for the reader to judge the episode,
|
||||||
|
roughly a tenth of the transcript, at most about 5,000 words.
|
||||||
|
|
||||||
|
The transcript was made by speech recognition: fix obvious mishearings, especially of names,
|
||||||
|
using the metadata, and don't quote garbled passages.`;
|
||||||
|
|
||||||
|
export const SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"summary",
|
||||||
|
"kind",
|
||||||
|
"speakers",
|
||||||
|
"sections"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"Interview",
|
||||||
|
"Gespräch",
|
||||||
|
"Monolog",
|
||||||
|
"Diskussion"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"speakers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"heading",
|
||||||
|
"start",
|
||||||
|
"body_markdown"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"heading": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"body_markdown": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- the input text -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const pad = n => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// mm:ss (minutes keep counting past 59: "125:03"), the form of the [mm:ss] marks Claude reads.
|
||||||
|
export function mmss(sec) {
|
||||||
|
const s = Math.max(0, Math.floor(Number(sec) || 0));
|
||||||
|
return pad(Math.floor(s / 60)) + ':' + pad(s % 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// h:mm:ss from an hour on, else mm:ss: the label of the "▶ ab …" links.
|
||||||
|
export function clock(sec) {
|
||||||
|
const s = Math.max(0, Math.floor(Number(sec) || 0));
|
||||||
|
return s >= 3600 ? Math.floor(s / 3600) + ':' + pad(Math.floor(s / 60) % 60) + ':' + pad(s % 60) : mmss(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One line per group of about GROUP_SECONDS: "[mm:ss] Sprecher: text", or "[mm:ss] text" without
|
||||||
|
// speakers. A new speaker always starts a new line.
|
||||||
|
export function transcriptToText(segments) {
|
||||||
|
const lines = [];
|
||||||
|
let cur = null;
|
||||||
|
for (const seg of segments || []) {
|
||||||
|
const text = String((seg && seg.text) || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!text) continue;
|
||||||
|
const start = Number(seg.start) || 0;
|
||||||
|
const speaker = String(seg.speaker || '').trim();
|
||||||
|
if (!cur || speaker !== cur.speaker || start - cur.start >= GROUP_SECONDS) {
|
||||||
|
cur = {start, speaker, parts: []};
|
||||||
|
lines.push(cur);
|
||||||
|
}
|
||||||
|
cur.parts.push(text);
|
||||||
|
}
|
||||||
|
return lines.map(l => '[' + mmss(l.start) + '] ' + (l.speaker ? l.speaker + ': ' : '') + l.parts.join(' ')).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const estimateTokens = text => Math.ceil(String(text || '').length / 4);
|
||||||
|
|
||||||
|
// HTML (the shownotes) as plain text.
|
||||||
|
export function htmlToText(html) {
|
||||||
|
return String(html || '')
|
||||||
|
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
|
||||||
|
.replace(/<\s*br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/(p|div|li|h[1-6]|blockquote|tr)\s*>/gi, '\n')
|
||||||
|
.replace(/<li[^>]*>/gi, '- ')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
.replace(/'|'/g, "'").replace(/&/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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v155';
|
const CACHE = 'diora-v171';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app/01-core.js',
|
'/static/js/app/01-core.js',
|
||||||
|
|
@ -16,6 +16,7 @@ const SHELL = [
|
||||||
'/static/js/app/29-m3u.js',
|
'/static/js/app/29-m3u.js',
|
||||||
'/static/js/app/30-contrast.js',
|
'/static/js/app/30-contrast.js',
|
||||||
'/static/js/app/32-crypto.js',
|
'/static/js/app/32-crypto.js',
|
||||||
|
'/static/js/app/33-unlock.js',
|
||||||
'/static/js/app/34-wallpaper.js',
|
'/static/js/app/34-wallpaper.js',
|
||||||
'/static/js/app/40-epub.js',
|
'/static/js/app/40-epub.js',
|
||||||
'/static/js/app/44-books.js',
|
'/static/js/app/44-books.js',
|
||||||
|
|
@ -31,6 +32,8 @@ const SHELL = [
|
||||||
'/static/js/app/58-pdf.js',
|
'/static/js/app/58-pdf.js',
|
||||||
'/static/js/app/60-tts.js',
|
'/static/js/app/60-tts.js',
|
||||||
'/static/js/app/62-reader.js',
|
'/static/js/app/62-reader.js',
|
||||||
|
'/static/js/app/63-pages.js',
|
||||||
|
'/static/js/app/65-article.js',
|
||||||
'/static/js/app/64-reader-settings.js',
|
'/static/js/app/64-reader-settings.js',
|
||||||
'/static/js/app/66-pdf-paginated.js',
|
'/static/js/app/66-pdf-paginated.js',
|
||||||
'/static/js/app/68-bookmarks.js',
|
'/static/js/app/68-bookmarks.js',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copied from diora-android e287f48 2026-10-25: web-native/static/native-podcast.js
|
// Copied from diora-android ba1e933 2026-11-09: web-native/static/native-podcast.js
|
||||||
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
||||||
// and copy again. The browser host is static/js/podcasts-web.js.
|
// and copy again. The browser host is static/js/podcasts-web.js.
|
||||||
|
|
||||||
|
|
@ -273,6 +273,47 @@
|
||||||
t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
|
t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- sharing an episode: its page from the feed (`link`), else the file / the video's watch URL ----
|
||||||
|
|
||||||
|
function shareUrl(e) {
|
||||||
|
const r = e.raw || {};
|
||||||
|
const d = e.id > 0 ? storedDetails(e.id) : null;
|
||||||
|
return r.link || r.episode__link || (d && d.link) || r.audio_url || r.episode__audio_url || (d && d.audio_url) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
return navigator.clipboard.writeText(text).then(() => true, () => copyTextOld(text));
|
||||||
|
}
|
||||||
|
return Promise.resolve(copyTextOld(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTextOld(text) {
|
||||||
|
const t = el('textarea');
|
||||||
|
t.value = text;
|
||||||
|
t.setAttribute('readonly', '');
|
||||||
|
t.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||||
|
document.body.appendChild(t);
|
||||||
|
t.select();
|
||||||
|
let ok = false;
|
||||||
|
try { ok = document.execCommand('copy'); } catch (err) {}
|
||||||
|
t.remove();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android: the system's share sheet. Elsewhere (browser, desktop app): title and link onto the clipboard.
|
||||||
|
async function shareEpisode(e) {
|
||||||
|
let url = shareUrl(e);
|
||||||
|
if (!url && e.id > 0 && navigator.onLine) { // the phone's own list data may not know the link yet
|
||||||
|
const d = await loadDetails(e.id);
|
||||||
|
url = (d && (d.link || d.audio_url)) || '';
|
||||||
|
}
|
||||||
|
if (!url) { toast('Diese Folge hat keine Adresse zum Teilen'); return; }
|
||||||
|
const headline = e.feed ? `${e.title} – ${e.feed}` : e.title;
|
||||||
|
if (N && N.shareText) { N.shareText(headline, url); return; }
|
||||||
|
toast(await copyText(headline + '\n' + url) ?'Link kopiert' : 'Kopieren ging nicht');
|
||||||
|
}
|
||||||
|
|
||||||
// GET from our server; a status other than 2xx throws (as a network error does).
|
// GET from our server; a status other than 2xx throws (as a network error does).
|
||||||
async function getJson(url) {
|
async function getJson(url) {
|
||||||
const r = await fetch(url);
|
const r = await fetch(url);
|
||||||
|
|
@ -581,6 +622,7 @@
|
||||||
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
|
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
|
||||||
!playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
!playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||||
favoriteItem(e),
|
favoriteItem(e),
|
||||||
|
shareItem(e),
|
||||||
!playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
|
!playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
|
||||||
{icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')},
|
{icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')},
|
||||||
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
|
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
|
||||||
|
|
@ -595,6 +637,10 @@
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shareItem(e) {
|
||||||
|
return {icon: '⇪', label: 'Teilen', hint: N && N.shareText ? null : 'Link in die Zwischenablage', action: () => shareEpisode(e)};
|
||||||
|
}
|
||||||
|
|
||||||
function episodeMenu(e, opts) {
|
function episodeMenu(e, opts) {
|
||||||
if (v2()) { listsMenu(e, opts); return; }
|
if (v2()) { listsMenu(e, opts); return; }
|
||||||
const queued = inQueue(e.id);
|
const queued = inQueue(e.id);
|
||||||
|
|
@ -602,6 +648,7 @@
|
||||||
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
|
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
|
||||||
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||||
favoriteItem(e),
|
favoriteItem(e),
|
||||||
|
shareItem(e),
|
||||||
!queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')},
|
!queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')},
|
||||||
!queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')},
|
!queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')},
|
||||||
!queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
|
!queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
|
||||||
|
|
@ -625,6 +672,7 @@
|
||||||
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)},
|
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)},
|
||||||
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||||
favoriteItem(e),
|
favoriteItem(e),
|
||||||
|
shareItem(e),
|
||||||
N.podcastLaterAdd && {icon: kept ? '✕' : '+', svg: kept ? 'listx' : null, label: kept ? 'Aus der Merkliste nehmen' : 'Auf die Merkliste',
|
N.podcastLaterAdd && {icon: kept ? '✕' : '+', svg: kept ? 'listx' : null, label: kept ? 'Aus der Merkliste nehmen' : 'Auf die Merkliste',
|
||||||
hint: kept ? null : 'für später; wird geladen', action: () => toggleLater(e)},
|
hint: kept ? null : 'für später; wird geladen', action: () => toggleLater(e)},
|
||||||
!queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')},
|
!queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')},
|
||||||
|
|
@ -691,6 +739,7 @@
|
||||||
...dlSettingsItems(),
|
...dlSettingsItems(),
|
||||||
]),
|
]),
|
||||||
backgroundSyncItem(),
|
backgroundSyncItem(),
|
||||||
|
{icon: '✎', label: 'Transkription', hint: 'Whisper-Instanz für Transkripte und Artikel, Modell, Sprache', action: transcriptionSettings},
|
||||||
{icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings},
|
{icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings},
|
||||||
startPageItem(),
|
startPageItem(),
|
||||||
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
|
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
|
||||||
|
|
@ -2833,6 +2882,15 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A transcript/article notice was tapped (TranscriptNotices.kt): the episode by id.
|
||||||
|
window.__dioraOpenEpisodeById = async function (id) {
|
||||||
|
const d = await loadDetails(id);
|
||||||
|
if (!d) return false;
|
||||||
|
return window.__dioraPodcastOpenEpisode(Object.assign({}, d, {
|
||||||
|
feed__id: d.feed && d.feed.id, feed__title: d.feed && d.feed.title, feed__artwork_url: d.feed && d.feed.artwork_url,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
window.__dioraPodcastOpenFeed = function (feedId) {
|
window.__dioraPodcastOpenFeed = function (feedId) {
|
||||||
const f = feeds().find(x => x.id === Number(feedId));
|
const f = feeds().find(x => x.id === Number(feedId));
|
||||||
if (!f) return false;
|
if (!f) return false;
|
||||||
|
|
@ -4328,6 +4386,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.'));
|
box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const tr = trOf(id);
|
||||||
|
trFill(tr, d, !!fresh);
|
||||||
if (live && sheet) {
|
if (live && sheet) {
|
||||||
const fav = sheet.querySelector('.nui-pod-fav');
|
const fav = sheet.querySelector('.nui-pod-fav');
|
||||||
if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); }
|
if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); }
|
||||||
|
|
@ -4338,10 +4398,23 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
if (chapBox) chapterBox(chapBox, chapters, id);
|
if (chapBox) chapterBox(chapBox, chapters, id);
|
||||||
const tabs = el('div', 'nui-pod-subtabs');
|
const tabs = el('div', 'nui-pod-subtabs');
|
||||||
const body = el('div', 'nui-pod-notesbody');
|
const body = el('div', 'nui-pod-notesbody');
|
||||||
|
// Jump to a second of the episode (taps on the article's "▶ ab 12:34" links).
|
||||||
|
const seek = s => {
|
||||||
|
if (live) N.podcastSeek(s * 1000);
|
||||||
|
else if (jump) jump(s);
|
||||||
|
else toast('Zum Anhören die Folge öffnen');
|
||||||
|
};
|
||||||
|
let current = '';
|
||||||
|
let sig = '';
|
||||||
|
const sigOf = () => tabList().map(x => x[0]).join() + '|' +
|
||||||
|
(current === 'transcript' ? (tr.has ? 'has' : trSig(id)) : current === 'article' ? trSig(id) + ':' + tr.article : '');
|
||||||
const show = which => {
|
const show = which => {
|
||||||
|
current = which;
|
||||||
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which));
|
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which));
|
||||||
body.textContent = '';
|
body.textContent = '';
|
||||||
if (which === 'chapters') {
|
if (which === 'article') {
|
||||||
|
articlePanel(body, id, seek);
|
||||||
|
} else if (which === 'chapters') {
|
||||||
const ol = el('ol', 'nui-pod-chapters');
|
const ol = el('ol', 'nui-pod-chapters');
|
||||||
chapters.forEach((c, i) => {
|
chapters.forEach((c, i) => {
|
||||||
const li = el('li', 'nui-pod-chapter');
|
const li = el('li', 'nui-pod-chapter');
|
||||||
|
|
@ -4372,6 +4445,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
}
|
}
|
||||||
body.append(notes);
|
body.append(notes);
|
||||||
}
|
}
|
||||||
|
sig = sigOf();
|
||||||
};
|
};
|
||||||
const tab = (which, label) => {
|
const tab = (which, label) => {
|
||||||
const b = el('button', 'nui-pod-subtab', label);
|
const b = el('button', 'nui-pod-subtab', label);
|
||||||
|
|
@ -4380,11 +4454,33 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
b.addEventListener('click', () => show(which));
|
b.addEventListener('click', () => show(which));
|
||||||
tabs.append(b);
|
tabs.append(b);
|
||||||
};
|
};
|
||||||
if (chapters.length && !chapBox) tab('chapters', `Kapitel (${chapters.length})`);
|
// The Artikel tab comes first (when there is one); Transkript for every episode with sound
|
||||||
tab('notes', 'Shownotes');
|
// (not YouTube: the server never fetches its sound), with a button to make one when it is missing.
|
||||||
if (d.has_transcript) tab('transcript', 'Transkript');
|
const canTranscribe = !!d.audio_url && !isYouTube(d.audio_url);
|
||||||
|
function tabList() {
|
||||||
|
const list = [];
|
||||||
|
if (tr.article) list.push(['article', 'Artikel']);
|
||||||
|
if (chapters.length && !chapBox) list.push(['chapters', `Kapitel (${chapters.length})`]);
|
||||||
|
list.push(['notes', 'Shownotes']);
|
||||||
|
if (tr.has || canTranscribe) list.push(['transcript', 'Transkript']);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
const draw = want => {
|
||||||
|
const list = tabList();
|
||||||
|
tabs.textContent = '';
|
||||||
|
list.forEach(([w, l]) => tab(w, l));
|
||||||
|
show(list.some(x => x[0] === want) ? want : list[0][0]);
|
||||||
|
};
|
||||||
|
// Called when the state of the episode's transcript / article changes.
|
||||||
|
box.trUpdate = select => {
|
||||||
|
if (select) draw(select);
|
||||||
|
else if (sigOf() !== sig) draw(current);
|
||||||
|
};
|
||||||
|
trBoxes.add(box);
|
||||||
box.append(tabs, body);
|
box.append(tabs, body);
|
||||||
show(chapters.length && !chapBox ? 'chapters' : 'notes');
|
draw('');
|
||||||
|
if (epScreen && epScreen.e.id === id) episodeScreenParts();
|
||||||
|
trSchedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chapters in the full player: which one plays, previous / next (like AntennaPod's
|
// The chapters in the full player: which one plays, previous / next (like AntennaPod's
|
||||||
|
|
@ -4455,6 +4551,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while
|
let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while
|
||||||
|
|
||||||
async function showTranscript(body, id, live) {
|
async function showTranscript(body, id, live) {
|
||||||
|
// None yet: the button to make one (or its progress).
|
||||||
|
if (!trOf(id).has) { transcriptPanel(body, id); return; }
|
||||||
body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …'));
|
body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …'));
|
||||||
let t = transcripts.get(id);
|
let t = transcripts.get(id);
|
||||||
if (!t) {
|
if (!t) {
|
||||||
|
|
@ -4468,6 +4566,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body.textContent = '';
|
body.textContent = '';
|
||||||
|
if (trOf(id).origin === 'whisper') body.append(el('div', 'nui-pod-meta nui-pod-tr-origin', 'automatisch transkribiert'));
|
||||||
const list = el('div', 'nui-pod-transcript');
|
const list = el('div', 'nui-pod-transcript');
|
||||||
for (const seg of t.segments || []) {
|
for (const seg of t.segments || []) {
|
||||||
const p = el('p', 'nui-pod-seg');
|
const p = el('p', 'nui-pod-seg');
|
||||||
|
|
@ -4495,6 +4594,493 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- smart transcription (diora-web docs/transcription.md) ------------------------------
|
||||||
|
// The server transcribes (a job per episode, polled every 5 s while it is on screen); the article
|
||||||
|
// is written on this device with the user's own key (DioraPodcastArticle). One state per episode
|
||||||
|
// feeds the Transkript tab, the Artikel tab and the "✦ Artikel" button.
|
||||||
|
|
||||||
|
const trStates = new Map(); // episode id -> {job, writing, has, origin, article}
|
||||||
|
const trBoxes = new Set(); // the details boxes on screen (showDetails)
|
||||||
|
const trArticles = new Map(); // feed item id -> its item (the article's text)
|
||||||
|
let trTimer = null;
|
||||||
|
|
||||||
|
async function trFetch(method, url, body) {
|
||||||
|
const headers = {'Content-Type': 'application/json'};
|
||||||
|
if (typeof getCsrfToken === 'function') headers['X-CSRFToken'] = getCsrfToken();
|
||||||
|
const r = await fetch(url, {method, headers, cache: 'no-store', body: body === undefined ? undefined : JSON.stringify(body)});
|
||||||
|
let data = null;
|
||||||
|
try { data = await r.json(); } catch (e) {}
|
||||||
|
return {ok: r.ok, status: r.status, data};
|
||||||
|
}
|
||||||
|
|
||||||
|
function trFill(t, d, fresh) {
|
||||||
|
if (fresh || !t.has) t.has = !!d.has_transcript;
|
||||||
|
if (fresh || !t.origin) t.origin = d.transcript_origin || null;
|
||||||
|
if (fresh || t.article == null) t.article = d.article_item_id || null;
|
||||||
|
if ((fresh || !t.job) && 'transcription' in d) t.job = d.transcription || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trOf(id) {
|
||||||
|
let t = trStates.get(id);
|
||||||
|
if (!t) {
|
||||||
|
t = {job: null, writing: null, has: false, origin: null, article: null, polling: false};
|
||||||
|
trStates.set(id, t);
|
||||||
|
const d = storedDetails(id);
|
||||||
|
if (d) trFill(t, d, true);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trActive = j => !!j && (j.state === 'queued' || j.state === 'downloading' || j.state === 'transcribing');
|
||||||
|
|
||||||
|
// What is going on with the episode right now (a transcription on the server, or the article
|
||||||
|
// being written here), as the line the buttons show; null when nothing.
|
||||||
|
function trStatus(id) {
|
||||||
|
const t = trOf(id);
|
||||||
|
if (t.writing) return {text: t.writing.text, kind: 'writing'};
|
||||||
|
const j = t.job;
|
||||||
|
if (!trActive(j)) return null;
|
||||||
|
const text = j.state === 'queued' ? (j.error ? 'Wartet: ' + j.error : 'Wartet …')
|
||||||
|
: j.state === 'downloading' ? 'Lade Folge …'
|
||||||
|
: j.chunks_total ? `Transkribiere ${j.chunks_done || 0}/${j.chunks_total} …` : 'Transkribiere …';
|
||||||
|
return {text, kind: 'job'};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes only when what a view shows changes.
|
||||||
|
function trSig(id) {
|
||||||
|
const t = trOf(id);
|
||||||
|
const st = trStatus(id);
|
||||||
|
return (st ? st.text : '') + '|' + (t.job && t.job.state === 'error' ? t.job.error || 'x' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trVisible = id => (epScreen && epScreen.e.id === id && !!notesSheet && notesSheet.isConnected) ||
|
||||||
|
[...trBoxes].some(b => b.isConnected && b.dataset.episode === String(id));
|
||||||
|
|
||||||
|
function trChanged(id, select) {
|
||||||
|
for (const b of [...trBoxes]) {
|
||||||
|
if (!b.isConnected) { trBoxes.delete(b); continue; }
|
||||||
|
if (b.dataset.episode === String(id) && b.trUpdate) b.trUpdate(select);
|
||||||
|
}
|
||||||
|
if (epScreen && epScreen.e.id === id) episodeScreenParts();
|
||||||
|
trSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll the jobs on screen every five seconds; nothing runs when none is shown.
|
||||||
|
function trSchedule() {
|
||||||
|
const ids = [...trStates].filter(([id, t]) => trActive(t.job) && trVisible(id)).map(([id]) => id);
|
||||||
|
if (ids.length && !trTimer) {
|
||||||
|
trTimer = setInterval(trTick, 5000);
|
||||||
|
trTick();
|
||||||
|
} else if (!ids.length && trTimer) {
|
||||||
|
clearInterval(trTimer);
|
||||||
|
trTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trTick() {
|
||||||
|
if (document.hidden) return;
|
||||||
|
for (const [id, t] of [...trStates]) {
|
||||||
|
if (!trActive(t.job) || !trVisible(id) || t.polling) continue;
|
||||||
|
t.polling = true;
|
||||||
|
try {
|
||||||
|
const r = await trFetch('GET', `/podcasts/episodes/${id}/transcribe/`);
|
||||||
|
if (r.ok && r.data) await trApply(id, r.data);
|
||||||
|
} catch (e) {} finally { t.polling = false; }
|
||||||
|
}
|
||||||
|
trSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trApply(id, job) {
|
||||||
|
const t = trOf(id);
|
||||||
|
if (!job || job.state === 'none') { t.job = null; trChanged(id); return; }
|
||||||
|
t.job = job;
|
||||||
|
if (job.state !== 'done') { trChanged(id); return; }
|
||||||
|
const want = !!job.want_article;
|
||||||
|
t.job = null;
|
||||||
|
t.has = true;
|
||||||
|
transcripts.delete(id);
|
||||||
|
await trFresh(id);
|
||||||
|
trChanged(id);
|
||||||
|
// diora is open: the article is written right away (when this device has a key; else it waits).
|
||||||
|
if (want && !t.article && !t.writing) writeArticle(id, {claim: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trFresh(id) {
|
||||||
|
const d = await loadDetails(id);
|
||||||
|
if (d) trFill(trOf(id), d, true);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTranscription(id, article) {
|
||||||
|
const t = trOf(id);
|
||||||
|
if (!navigator.onLine) { toast('Das braucht eine Verbindung'); return; }
|
||||||
|
let r;
|
||||||
|
try { r = await trFetch('POST', `/podcasts/episodes/${id}/transcribe/`, {article: !!article}); } catch (e) { r = {ok: false, status: 0}; }
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = r.data && r.data.error;
|
||||||
|
const msg = err === 'youtube' ? 'YouTube-Folgen lassen sich nicht transkribieren.'
|
||||||
|
: err === 'too large' ? 'Die Folge ist zu groß zum Transkribieren (über 800 MB).'
|
||||||
|
: r.status ? `Das Transkribieren ließ sich nicht starten (${r.status}).` : 'Keine Verbindung.';
|
||||||
|
t.job = {state: 'error', error: msg, want_article: !!article};
|
||||||
|
trChanged(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const job = r.data || {};
|
||||||
|
if (job.state === 'done') {
|
||||||
|
t.job = null;
|
||||||
|
t.has = true;
|
||||||
|
await trFresh(id);
|
||||||
|
trChanged(id);
|
||||||
|
if (article) writeArticle(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
t.job = Object.assign({state: 'queued', want_article: !!article}, job);
|
||||||
|
if (article && window.DioraPodcastArticle) DioraPodcastArticle.watch(true);
|
||||||
|
trChanged(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelTranscription(id) {
|
||||||
|
const t = trOf(id);
|
||||||
|
try { await trFetch('DELETE', `/podcasts/episodes/${id}/transcribe/`); } catch (e) {}
|
||||||
|
t.job = null;
|
||||||
|
trChanged(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key first: without one the settings open (write() would wait for ever behind the sheet).
|
||||||
|
async function trKeyReady() {
|
||||||
|
const AI = window.DioraAI;
|
||||||
|
if (!AI || !AI.available()) { toast('Die KI-Funktionen gibt es hier nicht'); return false; }
|
||||||
|
let has = false;
|
||||||
|
try { has = await AI.hasKey(); } catch (e) {}
|
||||||
|
if (!has) {
|
||||||
|
toast('Für den Artikel braucht diora deinen KI-Schlüssel');
|
||||||
|
AI.settings();
|
||||||
|
}
|
||||||
|
return has;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function articleAction(id) {
|
||||||
|
const st = trStatus(id);
|
||||||
|
if (st) { toast(st.text); return; }
|
||||||
|
if (!(await trKeyReady())) return;
|
||||||
|
if (trOf(id).has) writeArticle(id);
|
||||||
|
else startTranscription(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the article here and save it. `claim`: a wish of the server (reserve it first, so that the
|
||||||
|
// phone and the desktop don't both write it) and no questions when there is no key.
|
||||||
|
async function writeArticle(id, opts) {
|
||||||
|
const claim = !!(opts && opts.claim);
|
||||||
|
const t = trOf(id);
|
||||||
|
if (t.writing) return;
|
||||||
|
const AI = window.DioraAI;
|
||||||
|
const PA = window.DioraPodcastArticle;
|
||||||
|
if (!AI || !AI.available() || !PA) { if (!claim) toast('Artikel schreiben geht hier nicht'); return; }
|
||||||
|
if (claim) {
|
||||||
|
let has = false;
|
||||||
|
try { has = await AI.hasKey(); } catch (e) {}
|
||||||
|
if (!has) return;
|
||||||
|
}
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
t.writing = {text: 'Lade Transkript …', ctrl};
|
||||||
|
trChanged(id);
|
||||||
|
let claimed = false;
|
||||||
|
try {
|
||||||
|
if (claim) {
|
||||||
|
const c = await PA.claim(id);
|
||||||
|
if (!c.ok) { t.writing = null; trChanged(id); return; } // another device has it
|
||||||
|
claimed = true;
|
||||||
|
}
|
||||||
|
let last = 0;
|
||||||
|
const saved = await PA.write(id, {
|
||||||
|
signal: ctrl.signal,
|
||||||
|
onState: s => {
|
||||||
|
if (!t.writing) return;
|
||||||
|
t.writing.text = s.text;
|
||||||
|
const now = Date.now();
|
||||||
|
if (s.phase !== 'writing' || now - last > 1000) { last = now; trChanged(id); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (t.article) trArticles.delete(t.article);
|
||||||
|
t.article = saved.item_id;
|
||||||
|
t.writing = null;
|
||||||
|
await trFresh(id);
|
||||||
|
if (claim && PA.notifyDone) PA.notifyDone(saved.title, id); else toast('Artikel fertig');
|
||||||
|
trChanged(id, 'article');
|
||||||
|
} catch (e) {
|
||||||
|
t.writing = null;
|
||||||
|
if (claimed) { try { await PA.release(id); } catch (e2) {} }
|
||||||
|
trChanged(id);
|
||||||
|
if (ctrl.signal.aborted) toast('Abgebrochen');
|
||||||
|
else if (AI.showError) AI.showError('Artikel schreiben', e);
|
||||||
|
else toast((e && e.message) || 'Der Artikel ließ sich nicht schreiben');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeArticle(id) {
|
||||||
|
const t = trOf(id);
|
||||||
|
const ok = typeof customConfirm === 'function'
|
||||||
|
? await customConfirm('Den Artikel zu dieser Folge entfernen? Er verschwindet auch aus „Podcast-Artikel“ in den Blogs.', {danger: true}) : true;
|
||||||
|
if (!ok) return;
|
||||||
|
let r;
|
||||||
|
try { r = await trFetch('DELETE', `/podcasts/episodes/${id}/article/`); } catch (e) { r = {ok: false}; }
|
||||||
|
if (!r.ok) { toast('Entfernen ging nicht'); return; }
|
||||||
|
if (t.article) trArticles.delete(t.article);
|
||||||
|
t.article = null;
|
||||||
|
await trFresh(id);
|
||||||
|
trChanged(id);
|
||||||
|
toast('Artikel entfernt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Transkript tab of an episode without a transcript: start, progress, error.
|
||||||
|
function transcriptPanel(body, id) {
|
||||||
|
const t = trOf(id);
|
||||||
|
body.textContent = '';
|
||||||
|
const st = trStatus(id);
|
||||||
|
const wrap = el('div', 'nui-pod-tr');
|
||||||
|
if (st) {
|
||||||
|
wrap.append(el('div', 'nui-pod-tr-status', st.text));
|
||||||
|
if (st.kind === 'job') {
|
||||||
|
wrap.append(el('div', 'nui-pod-meta', t.job.want_article
|
||||||
|
? 'Das dauert je nach Länge Minuten bis Stunden. Danach schreibt diora den Artikel, wenn diora offen ist.'
|
||||||
|
: 'Das dauert je nach Länge Minuten bis Stunden. Du bekommst eine Benachrichtigung, wenn es fertig ist.'));
|
||||||
|
wrap.append(trButton('Abbrechen', () => cancelTranscription(id)));
|
||||||
|
} else {
|
||||||
|
wrap.append(trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
|
||||||
|
}
|
||||||
|
} else if (t.job && t.job.state === 'error') {
|
||||||
|
wrap.append(el('div', 'nui-pod-tr-error', t.job.error || 'Das Transkribieren ist fehlgeschlagen.'));
|
||||||
|
wrap.append(trButton('Erneut versuchen', () => startTranscription(id, !!t.job.want_article), true));
|
||||||
|
} else {
|
||||||
|
wrap.append(el('div', 'nui-pod-meta', 'Diese Folge hat kein Transkript. Der Server kann es erstellen; das dauert je nach Länge ' +
|
||||||
|
'Minuten bis Stunden, am Ende kommt eine Benachrichtigung.'));
|
||||||
|
wrap.append(trButton('Transkript erstellen', () => startTranscription(id, false), true));
|
||||||
|
}
|
||||||
|
body.append(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trButton(label, run, primary) {
|
||||||
|
const b = el('button', 'btn nui-pod-trbtn' + (primary ? ' btn-primary' : ''), label);
|
||||||
|
b.type = 'button';
|
||||||
|
b.addEventListener('click', run);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Artikel tab: the article from Blogs (a feed item), its three actions, taps on "▶ ab 12:34" seek.
|
||||||
|
async function articlePanel(body, id, seek) {
|
||||||
|
const t = trOf(id);
|
||||||
|
const itemId = t.article;
|
||||||
|
body.textContent = '';
|
||||||
|
const bar = el('div', 'nui-pod-trbar');
|
||||||
|
const st = trStatus(id);
|
||||||
|
if (st && st.kind === 'writing') {
|
||||||
|
bar.append(el('span', 'nui-pod-tr-status', st.text), trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
|
||||||
|
} else {
|
||||||
|
if (window.__dioraFeedsOpenItem) {
|
||||||
|
bar.append(trButton('In Blogs öffnen', () => {
|
||||||
|
closeNotes();
|
||||||
|
closePlayer();
|
||||||
|
window.__dioraFeedsOpenItem(itemId);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (window.DioraAI && DioraAI.available() && !st) bar.append(trButton('Neu erstellen', () => rewriteArticle(id)));
|
||||||
|
bar.append(trButton('Entfernen', () => removeArticle(id)));
|
||||||
|
}
|
||||||
|
const text = el('div', 'nui-pod-notes nui-pod-article');
|
||||||
|
body.append(bar, text);
|
||||||
|
let item = trArticles.get(itemId);
|
||||||
|
if (!item) {
|
||||||
|
text.append(el('div', 'nui-pod-meta', 'Artikel wird geladen …'));
|
||||||
|
try {
|
||||||
|
item = (await getJson(`/feeds/items/${itemId}/`)).item;
|
||||||
|
if (item) trArticles.set(itemId, item);
|
||||||
|
} catch (e) {}
|
||||||
|
if (!text.isConnected || t.article !== itemId) return;
|
||||||
|
text.textContent = '';
|
||||||
|
}
|
||||||
|
if (!item) {
|
||||||
|
text.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Der Artikel ließ sich nicht laden.' : 'Der Artikel braucht eine Verbindung.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The server cleaned the HTML (feeds/fetch.py clean_html), as for every feed item.
|
||||||
|
if (item.content) text.innerHTML = item.content;
|
||||||
|
else text.append(el('p', null, item.summary || ''));
|
||||||
|
text.querySelectorAll('a[href]:not([href^="#t="])').forEach(a => a.setAttribute('target', '_blank'));
|
||||||
|
text.addEventListener('click', ev => {
|
||||||
|
const a = ev.target.closest('a[href^="#t="]');
|
||||||
|
if (!a) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const s = parseInt(a.getAttribute('href').slice(3), 10);
|
||||||
|
if (s >= 0) seek(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Neu erstellen" replaces the article: ask first.
|
||||||
|
async function rewriteArticle(id) {
|
||||||
|
const ok = typeof customConfirm === 'function'
|
||||||
|
? await customConfirm('Den Artikel neu schreiben? Der jetzige wird ersetzt.') : true;
|
||||||
|
if (ok) articleAction(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "✦ Artikel" button of the episode screen, or null where it isn't offered.
|
||||||
|
function trArticleButton(e) {
|
||||||
|
const id = e.id;
|
||||||
|
if (!(id > 0)) return null;
|
||||||
|
const t = trOf(id);
|
||||||
|
const d = storedDetails(id) || {};
|
||||||
|
const url = (e.raw && (e.raw.audio_url || e.raw.episode__audio_url)) || d.audio_url || '';
|
||||||
|
if (e.yt || !url || isYouTube(url)) return null;
|
||||||
|
const st = trStatus(id);
|
||||||
|
if (st) return {label: st.text, run: () => toast(st.text), cls: 'on'};
|
||||||
|
if (t.article) return {label: 'Artikel', run: () => { if (epScreen && epScreen.box && epScreen.box.trUpdate) epScreen.box.trUpdate('article'); }, cls: 'on'};
|
||||||
|
if (!(window.DioraAI && DioraAI.available())) return null;
|
||||||
|
return {label: t.has ? 'Artikel erstellen' : 'Transkribieren und Artikel erstellen', run: () => articleAction(id)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- settings of the transcription instance (GET/POST /podcasts/transcription/settings/) ----
|
||||||
|
|
||||||
|
const TR_LANGUAGES = [['', 'Automatisch'], ['de', 'Deutsch'], ['en', 'Englisch'], ['fr', 'Französisch'], ['es', 'Spanisch'],
|
||||||
|
['it', 'Italienisch'], ['nl', 'Niederländisch'], ['pt', 'Portugiesisch'], ['pl', 'Polnisch'], ['ru', 'Russisch'],
|
||||||
|
['tr', 'Türkisch'], ['uk', 'Ukrainisch'], ['ja', 'Japanisch'], ['zh', 'Chinesisch']];
|
||||||
|
let trSheet = null;
|
||||||
|
|
||||||
|
function closeTrSettings() {
|
||||||
|
if (!trSheet) return false;
|
||||||
|
trSheet.remove();
|
||||||
|
trSheet = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcriptionSettings() {
|
||||||
|
closeTrSettings();
|
||||||
|
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-trset', 'Schließen', closeTrSettings);
|
||||||
|
trSheet = made;
|
||||||
|
const body = el('div', 'nui-pod-trform');
|
||||||
|
panel.append(top, el('div', 'nui-pod-player-title', 'Transkription'), body);
|
||||||
|
document.body.append(made);
|
||||||
|
body.append(el('div', 'nui-pod-meta', 'Einstellungen werden geladen …'));
|
||||||
|
let s;
|
||||||
|
try {
|
||||||
|
const r = await trFetch('GET', '/podcasts/transcription/settings/');
|
||||||
|
if (!r.ok) throw new Error(r.status);
|
||||||
|
s = r.data;
|
||||||
|
} catch (e) {
|
||||||
|
body.textContent = '';
|
||||||
|
body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Die Einstellungen ließen sich nicht laden.' : 'Die Einstellungen brauchen eine Verbindung.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trSheet !== made) return;
|
||||||
|
drawTranscriptionSettings(body, s, made);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTranscriptionSettings(body, s, made) {
|
||||||
|
body.textContent = '';
|
||||||
|
const field = (label, input) => {
|
||||||
|
const l = el('label', 'nui-pod-trfield');
|
||||||
|
l.append(el('span', null, label), input);
|
||||||
|
return l;
|
||||||
|
};
|
||||||
|
const text = (type, value, placeholder) => {
|
||||||
|
const i = el('input', 'search-input');
|
||||||
|
i.type = type;
|
||||||
|
i.value = value == null ? '' : value;
|
||||||
|
if (placeholder) i.placeholder = placeholder;
|
||||||
|
i.setAttribute('autocomplete', 'off');
|
||||||
|
i.setAttribute('autocapitalize', 'off');
|
||||||
|
i.spellcheck = false;
|
||||||
|
return i;
|
||||||
|
};
|
||||||
|
body.append(el('div', 'nui-pod-meta', 'Der Server schneidet die Folge in Stücke und schickt sie an eine Whisper-Instanz ' +
|
||||||
|
'(OpenAI-kompatibel). Das passiert nur, wenn du es in einer Folge auslöst.'));
|
||||||
|
let engine = s.engine === 'builtin' && s.builtin_available ? 'builtin' : 'custom';
|
||||||
|
const engines = el('div', 'nui-pod-subtabs');
|
||||||
|
const options = [s.builtin_available && ['builtin', 'Eingebaute Instanz'], ['custom', 'Eigene Instanz']].filter(Boolean);
|
||||||
|
const url = text('url', s.url, 'https://whisper.example.org/v1');
|
||||||
|
const token = text('password', '', s.has_token ? 'gespeichert (leer lassen = behalten)' : 'optional');
|
||||||
|
token.setAttribute('autocomplete', 'new-password');
|
||||||
|
const urlField = field('Adresse', url);
|
||||||
|
const tokenField = field('Token', token);
|
||||||
|
const sync = () => {
|
||||||
|
engines.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.engine === engine));
|
||||||
|
urlField.hidden = tokenField.hidden = engine !== 'custom';
|
||||||
|
};
|
||||||
|
for (const [value, label] of options) {
|
||||||
|
const b = el('button', 'nui-pod-subtab', label);
|
||||||
|
b.type = 'button';
|
||||||
|
b.dataset.engine = value;
|
||||||
|
b.addEventListener('click', () => { engine = value; sync(); });
|
||||||
|
engines.append(b);
|
||||||
|
}
|
||||||
|
body.append(engines, urlField, tokenField);
|
||||||
|
const model = text('text', s.model, 'z. B. large-v3-turbo');
|
||||||
|
const lang = el('select', 'search-input');
|
||||||
|
const langs = TR_LANGUAGES.some(l => l[0] === (s.language || '')) ? TR_LANGUAGES : TR_LANGUAGES.concat([[s.language, s.language]]);
|
||||||
|
for (const [v, l] of langs) {
|
||||||
|
const o = el('option', null, l);
|
||||||
|
o.value = v;
|
||||||
|
lang.append(o);
|
||||||
|
}
|
||||||
|
lang.value = s.language || '';
|
||||||
|
const par = text('number', s.parallel || 3);
|
||||||
|
par.min = '1';
|
||||||
|
par.max = '8';
|
||||||
|
par.inputMode = 'numeric';
|
||||||
|
body.append(field('Modell', model), field('Sprache', lang), field('Parallel (1 bis 8 Stücke gleichzeitig)', par));
|
||||||
|
const status = el('div', 'nui-pod-tr-status');
|
||||||
|
status.setAttribute('aria-live', 'polite');
|
||||||
|
const say = (msg, bad) => { status.textContent = msg; status.classList.toggle('nui-pod-tr-error', !!bad); };
|
||||||
|
|
||||||
|
// Save; the token is sent only when typed (empty keeps it) or removed on purpose.
|
||||||
|
const save = async removeToken => {
|
||||||
|
if (engine === 'custom' && !url.value.trim()) { say('Bitte eine Adresse eintragen.', true); return null; }
|
||||||
|
const post = {
|
||||||
|
engine, url: url.value.trim(), model: model.value.trim(), language: lang.value,
|
||||||
|
parallel: Math.max(1, Math.min(8, parseInt(par.value, 10) || 3)),
|
||||||
|
};
|
||||||
|
if (removeToken) post.token = '';
|
||||||
|
else if (token.value) post.token = token.value;
|
||||||
|
const r = await trFetch('POST', '/podcasts/transcription/settings/', post);
|
||||||
|
if (!r.ok) { say((r.data && r.data.error) || `Speichern ging nicht (${r.status}).`, true); return null; }
|
||||||
|
let next = r.data && 'engine' in r.data ? r.data : null;
|
||||||
|
if (!next) {
|
||||||
|
const g = await trFetch('GET', '/podcasts/transcription/settings/');
|
||||||
|
next = g.ok ? g.data : Object.assign({}, s, post, {has_token: !!(s.has_token && !removeToken) || !!post.token});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
const run = fn => async () => {
|
||||||
|
try { await fn(); } catch (e) { say(navigator.onLine ? 'Das ging nicht.' : 'Keine Verbindung.', true); }
|
||||||
|
};
|
||||||
|
const buttons = el('div', 'nui-pod-trbar');
|
||||||
|
buttons.append(
|
||||||
|
trButton('Speichern', run(async () => {
|
||||||
|
const next = await save(false);
|
||||||
|
if (next && trSheet === made) { toast('Gespeichert'); drawTranscriptionSettings(body, next, made); }
|
||||||
|
}), true),
|
||||||
|
trButton('Verbindung testen', run(async () => {
|
||||||
|
say('Speichere und teste …');
|
||||||
|
const next = await save(false);
|
||||||
|
if (!next) return;
|
||||||
|
const r = await trFetch('POST', '/podcasts/transcription/test/', {});
|
||||||
|
if (trSheet !== made) return;
|
||||||
|
drawTranscriptionSettings(body, next, made);
|
||||||
|
const out = body.querySelector('.nui-pod-tr-status');
|
||||||
|
const ok = r.ok && r.data && r.data.ok;
|
||||||
|
out.textContent = ok ? `Verbindung steht${r.data.model ? ' (Modell ' + r.data.model + ')' : ''}.`
|
||||||
|
: 'Verbindung fehlgeschlagen: ' + ((r.data && r.data.error) || `Fehler ${r.status}`);
|
||||||
|
out.classList.toggle('nui-pod-tr-error', !ok);
|
||||||
|
})));
|
||||||
|
if (s.has_token) {
|
||||||
|
buttons.append(trButton('Token entfernen', run(async () => {
|
||||||
|
const next = await save(true);
|
||||||
|
if (next && trSheet === made) { toast('Token entfernt'); drawTranscriptionSettings(body, next, made); }
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
body.append(buttons, status);
|
||||||
|
if (s.has_token) body.append(el('div', 'nui-pod-meta', 'Token: gespeichert'));
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the episode screen: a tap on a row opens it ----------------------------------------
|
// ---- the episode screen: a tap on a row opens it ----------------------------------------
|
||||||
// Cover, title, podcast (→ its page), date, length, where it stands; one big button to play;
|
// Cover, title, podcast (→ its page), date, length, where it stands; one big button to play;
|
||||||
// the actions with the swipes' names and icons; chapters (a tap plays from there) and notes.
|
// the actions with the swipes' names and icons; chapters (a tap plays from there) and notes.
|
||||||
|
|
@ -4552,7 +5138,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
document.body.append(notesSheet);
|
document.body.append(notesSheet);
|
||||||
epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')};
|
epScreen = {e, ctx, actions, playBtn, box, facts: info.querySelector('.nui-pod-epfacts')};
|
||||||
episodeScreenParts();
|
episodeScreenParts();
|
||||||
showDetails(box, e.id, false, seconds => playAt(e, seconds));
|
showDetails(box, e.id, false, seconds => playAt(e, seconds));
|
||||||
}
|
}
|
||||||
|
|
@ -4613,6 +5199,9 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
if (N.podcastDiscard) {
|
if (N.podcastDiscard) {
|
||||||
act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger');
|
act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger');
|
||||||
}
|
}
|
||||||
|
act(null, '⇪', 'Teilen', () => shareEpisode(e));
|
||||||
|
const art = trArticleButton(e);
|
||||||
|
if (art) act(null, '✦', art.label, art.run, art.cls);
|
||||||
const fav = e.raw.favorite;
|
const fav = e.raw.favorite;
|
||||||
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
|
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
|
||||||
fav ? 'on' : '');
|
fav ? 'on' : '');
|
||||||
|
|
@ -5828,6 +6417,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
window.__dioraBack = function () {
|
window.__dioraBack = function () {
|
||||||
if (closeStats()) return true;
|
if (closeStats()) return true;
|
||||||
if (closeDownloads()) return true;
|
if (closeDownloads()) return true;
|
||||||
|
if (closeTrSettings()) return true;
|
||||||
if (closeNotes()) return true;
|
if (closeNotes()) return true;
|
||||||
if (closePlayer()) return true;
|
if (closePlayer()) return true;
|
||||||
const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';
|
const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* Copied from diora-android 7f37d4e 2026-10-25: the podcast parts of web-native/static/native.css
|
/* Copied from diora-android 6a0a362 2026-11-09: the podcast parts of web-native/static/native.css
|
||||||
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
||||||
The web's own additions are in podcasts-web.css. */
|
The web's own additions are in podcasts-web.css. */
|
||||||
|
|
||||||
|
|
@ -500,7 +500,9 @@ body.eink .nui-pod-chip.on strong,
|
||||||
body.eink .nui-pod-sleep.on { color: #000 !important; }
|
body.eink .nui-pod-sleep.on { color: #000 !important; }
|
||||||
|
|
||||||
/* podcast chapters and shownotes */
|
/* podcast chapters and shownotes */
|
||||||
.nui-pod-notesbox { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; min-height: 40px; }
|
/* flex: none – in the sheet's scrolling column the box would otherwise shrink to its 40 px and
|
||||||
|
the notes spill over what comes after (the similar episodes). */
|
||||||
|
.nui-pod-notesbox { flex: none; margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; min-height: 40px; }
|
||||||
.nui-pod-subtabs { display: flex; gap: 4px; margin-bottom: 6px; }
|
.nui-pod-subtabs { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||||
.nui-pod-subtab {
|
.nui-pod-subtab {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
|
|
@ -548,6 +550,21 @@ body.eink .nui-pod-sleep.on { color: #000 !important; }
|
||||||
.nui-pod-epact-icon { font-size: 20px; line-height: 22px; }
|
.nui-pod-epact-icon { font-size: 20px; line-height: 22px; }
|
||||||
.nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); }
|
.nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); }
|
||||||
.nui-pod-epact-danger { color: var(--red, #e5484d); }
|
.nui-pod-epact-danger { color: var(--red, #e5484d); }
|
||||||
|
/* Transcript and article of an episode (the Transkript / Artikel tabs, the transcription settings). */
|
||||||
|
.nui-pod-tr { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 6px 2px; }
|
||||||
|
.nui-pod-tr-status { font-size: 14.5px; font-weight: 600; }
|
||||||
|
.nui-pod-tr-error { font-size: 14px; color: var(--red, #e5484d); }
|
||||||
|
.nui-pod-tr-origin { margin-bottom: 6px; font-style: italic; }
|
||||||
|
.nui-pod-trbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; }
|
||||||
|
.nui-pod-trbtn { min-height: 40px; }
|
||||||
|
.nui-pod-article h2 { font-size: 17px; margin: 1.2em 0 0.2em; }
|
||||||
|
.nui-pod-article p { margin: 0.5em 0; }
|
||||||
|
.nui-pod-article blockquote { margin: 0.6em 0; padding-left: 12px; border-left: 3px solid var(--border); }
|
||||||
|
.nui-pod-trform { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.nui-pod-trfield { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--nui-muted); }
|
||||||
|
.nui-pod-trfield input, .nui-pod-trfield select { width: 100%; box-sizing: border-box; font-size: 15px; color: inherit; }
|
||||||
|
.nui-pod-trfield[hidden] { display: none; }
|
||||||
|
body.eink .nui-pod-tr-error { color: #000; }
|
||||||
body.eink .nui-pod-epact { border-color: #000; color: #000; }
|
body.eink .nui-pod-epact { border-color: #000; color: #000; }
|
||||||
body.eink .nui-pod-epact-danger { color: #000; }
|
body.eink .nui-pod-epact-danger { color: #000; }
|
||||||
body.eink .nui-pod-chapter.current,
|
body.eink .nui-pod-chapter.current,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if oidc_enabled %}
|
||||||
|
<div class="auth-alt">
|
||||||
|
<div class="auth-or"><span>oder</span></div>
|
||||||
|
<a class="btn btn-full" href="{% url 'oidc_start' %}{% if request.GET.next %}?next={{ request.GET.next|urlencode }}{% endif %}">Mit {{ oidc_provider }} anmelden</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<p class="auth-switch">
|
<p class="auth-switch">
|
||||||
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
|
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
120
templates/accounts/oidc_key.html
Normal file
120
templates/accounts/oidc_key.html
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Anmelden — diora</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||||
|
font: 15px/1.5 system-ui, sans-serif; background: #111; color: #ccc; }
|
||||||
|
@media (prefers-color-scheme: light) { body { background: #fafafa; color: #444; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p id="status">Bücher werden entsperrt …</p>
|
||||||
|
{{ config|json_script:"oidc-key-config" }}
|
||||||
|
<script>
|
||||||
|
// The book key with Authelia's help (accounts/oidc_views.py oidc_key, docs/accounts.md).
|
||||||
|
// Every way out ends on `next`; whatever fails here, the app asks for the key itself
|
||||||
|
// (static/js/app/33-unlock.js).
|
||||||
|
(async function () {
|
||||||
|
const C = JSON.parse(document.getElementById('oidc-key-config').textContent);
|
||||||
|
const SS = 'diora_oidc_key_flow';
|
||||||
|
const KEY_NAME = 'diora_enc_key_' + C.user_id;
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||||
|
const unb64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
|
||||||
|
const b64url = (buf) => b64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
const hexToBytes = (h) => Uint8Array.from(h.match(/../g) || [], x => parseInt(x, 16));
|
||||||
|
const csrf = () => (document.cookie.split('; ').find(r => r.startsWith('csrftoken=')) || '').split('=')[1] || '';
|
||||||
|
const done = (to) => location.replace(to || C.next || '/');
|
||||||
|
|
||||||
|
async function wrapKey(secret, sub) {
|
||||||
|
const hk = await crypto.subtle.importKey('raw', unb64(secret), 'HKDF', false, ['deriveKey']);
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode(sub), info: new TextEncoder().encode('diora authelia key v1')},
|
||||||
|
hk, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this the key of the books? Tried on one book's metadata (none: nothing to check).
|
||||||
|
async function fits(raw) {
|
||||||
|
try {
|
||||||
|
const books = await (await fetch('/books/', {credentials: 'same-origin'})).json();
|
||||||
|
const b = Array.isArray(books) ? books.find(x => x && x.meta_ct && x.meta_iv) : null;
|
||||||
|
if (!b) return true;
|
||||||
|
const k = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['decrypt']);
|
||||||
|
await crypto.subtle.decrypt({name: 'AES-GCM', iv: hexToBytes(b.meta_iv)}, k, unb64(b.meta_ct));
|
||||||
|
return true;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const isCallback = location.pathname.endsWith('/callback/');
|
||||||
|
|
||||||
|
if (!isCallback) {
|
||||||
|
// Also a device that has its key goes once round: it leaves the key (again) wrapped with
|
||||||
|
// the current claim, so a changed claim at Authelia heals with the next login.
|
||||||
|
const flow = {
|
||||||
|
state: b64url(crypto.getRandomValues(new Uint8Array(24))),
|
||||||
|
verifier: b64url(crypto.getRandomValues(new Uint8Array(48))),
|
||||||
|
next: C.next,
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(SS, JSON.stringify(flow));
|
||||||
|
const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(flow.verifier)));
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
response_type: 'code', client_id: C.client_id, redirect_uri: C.redirect_uri,
|
||||||
|
scope: 'openid profile diora_key', state: flow.state, prompt: 'none',
|
||||||
|
code_challenge: challenge, code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
location.replace(C.authorize + '?' + q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let flow = null;
|
||||||
|
try { flow = JSON.parse(sessionStorage.getItem(SS) || 'null'); } catch (e) {}
|
||||||
|
sessionStorage.removeItem(SS);
|
||||||
|
const next = flow && flow.next;
|
||||||
|
try {
|
||||||
|
if (!flow || params.get('state') !== flow.state || !params.get('code')) return done(next);
|
||||||
|
const tok = await (await fetch(C.token, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code', code: params.get('code'), redirect_uri: C.redirect_uri,
|
||||||
|
client_id: C.client_id, code_verifier: flow.verifier,
|
||||||
|
}),
|
||||||
|
})).json();
|
||||||
|
if (!tok.access_token) return done(next);
|
||||||
|
const info = await (await fetch(C.userinfo, {headers: {Authorization: 'Bearer ' + tok.access_token}})).json();
|
||||||
|
// Only the Authelia account linked to this diora account counts.
|
||||||
|
if (!info.sub || !C.subs.includes(info.sub) || !info.diora_key) return done(next);
|
||||||
|
const wrap = await wrapKey(info.diora_key, info.sub);
|
||||||
|
|
||||||
|
let have = null;
|
||||||
|
try { have = localStorage.getItem(KEY_NAME); } catch (e) {}
|
||||||
|
if (have) {
|
||||||
|
// This device has the key: leave it with the server, wrapped — only the right one (an
|
||||||
|
// old browser may hold a stray key of its own).
|
||||||
|
if (!(await fits(unb64(have)))) return done(next);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrap, unb64(have));
|
||||||
|
await fetch('/accounts/vault/authelia-key/', {
|
||||||
|
method: 'POST', credentials: 'same-origin',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
|
||||||
|
body: JSON.stringify({ct: b64(ct), iv: b64(iv)}),
|
||||||
|
});
|
||||||
|
return done(next);
|
||||||
|
}
|
||||||
|
const v = await (await fetch('/accounts/vault/authelia-key/', {credentials: 'same-origin'})).json();
|
||||||
|
if (!v.ct) return done(next);
|
||||||
|
const raw = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: unb64(v.iv)}, wrap, unb64(v.ct)));
|
||||||
|
if (raw.length === 32 && await fits(raw)) localStorage.setItem(KEY_NAME, b64(raw));
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = 'Entsperren über Authelia ging nicht – du kannst es gleich mit Code oder Passwort tun.';
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
}
|
||||||
|
done(next);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -94,6 +94,25 @@
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
{% if oidc_enabled or oidc_identities %}
|
||||||
|
<div class="oidc-links" style="margin-top:1rem;">
|
||||||
|
<h3 style="font-size:1rem; margin:0 0 0.4rem;">Anmelden über {{ oidc_provider }}</h3>
|
||||||
|
{% for ident in oidc_identities %}
|
||||||
|
<form method="post" action="{% url 'oidc_unlink' ident.pk %}" class="inline-form" style="display:flex; gap:0.6rem; align-items:center; margin-bottom:0.4rem;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<span>Verknüpft mit <strong>{{ ident.username|default:ident.sub }}</strong>{% if ident.email %} ({{ ident.email }}){% endif %}</span>
|
||||||
|
<button type="submit" class="btn">Lösen</button>
|
||||||
|
</form>
|
||||||
|
{% empty %}
|
||||||
|
<p class="muted" style="margin:0 0 0.4rem;">Verknüpfe dein Konto, dann kannst du dich auch über {{ oidc_provider }} anmelden. Die Anmeldung mit Passwort bleibt.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% if oidc_enabled and not oidc_identities %}
|
||||||
|
<a class="btn" href="{% url 'oidc_start' %}?link=1">Mit {{ oidc_provider }} verknüpfen</a>
|
||||||
|
{% endif %}
|
||||||
|
<p class="muted" style="font-size:0.85rem; margin-top:0.4rem;">Deine Bücher bleiben mit deinem diora-Passwort verschlüsselt. Ein Gerät, das du über {{ oidc_provider }} anmeldest, bekommt den Schlüssel von einem schon entsperrten Gerät, oder du gibst dort einmal dein Passwort ein.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
|
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn">Abmelden</button>
|
<button type="submit" class="btn">Abmelden</button>
|
||||||
|
|
@ -268,10 +287,8 @@ async function _getOrCreateEncKey() {
|
||||||
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
// Never a new key here: it would lock the existing books away (static/js/app/33-unlock.js).
|
||||||
const raw = await crypto.subtle.exportKey('raw', key);
|
throw new Error('Bücher sind auf diesem Gerät noch nicht entsperrt – öffne zuerst die Bibliothek');
|
||||||
localStorage.setItem(storageKey, _bytesToBase64(raw));
|
|
||||||
return key;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
|
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,7 @@
|
||||||
<script src="/static/js/app/29-m3u.js"></script>
|
<script src="/static/js/app/29-m3u.js"></script>
|
||||||
<script src="/static/js/app/30-contrast.js"></script>
|
<script src="/static/js/app/30-contrast.js"></script>
|
||||||
<script src="/static/js/app/32-crypto.js"></script>
|
<script src="/static/js/app/32-crypto.js"></script>
|
||||||
|
<script src="/static/js/app/33-unlock.js"></script>
|
||||||
<script src="/static/js/app/34-wallpaper.js"></script>
|
<script src="/static/js/app/34-wallpaper.js"></script>
|
||||||
<script src="/static/js/app/40-epub.js"></script>
|
<script src="/static/js/app/40-epub.js"></script>
|
||||||
<script src="/static/js/app/44-books.js"></script>
|
<script src="/static/js/app/44-books.js"></script>
|
||||||
|
|
@ -386,7 +387,9 @@
|
||||||
<script src="/static/js/app/58-pdf.js"></script>
|
<script src="/static/js/app/58-pdf.js"></script>
|
||||||
<script src="/static/js/app/60-tts.js"></script>
|
<script src="/static/js/app/60-tts.js"></script>
|
||||||
<script src="/static/js/app/62-reader.js"></script>
|
<script src="/static/js/app/62-reader.js"></script>
|
||||||
|
<script src="/static/js/app/63-pages.js"></script>
|
||||||
<script src="/static/js/app/64-reader-settings.js"></script>
|
<script src="/static/js/app/64-reader-settings.js"></script>
|
||||||
|
<script src="/static/js/app/65-article.js"></script>
|
||||||
<script src="/static/js/app/66-pdf-paginated.js"></script>
|
<script src="/static/js/app/66-pdf-paginated.js"></script>
|
||||||
<script src="/static/js/app/68-bookmarks.js"></script>
|
<script src="/static/js/app/68-bookmarks.js"></script>
|
||||||
<script src="/static/js/app/70-search.js"></script>
|
<script src="/static/js/app/70-search.js"></script>
|
||||||
|
|
@ -397,7 +400,8 @@
|
||||||
<script src="/static/js/app/80-radio-sidebar.js"></script>
|
<script src="/static/js/app/80-radio-sidebar.js"></script>
|
||||||
<script src="/static/js/app/99-init.js"></script>
|
<script src="/static/js/app/99-init.js"></script>
|
||||||
{% if user.is_authenticated %}{# The KI features (docs/ai.md): the user's own key, calls from the device. #}
|
{% if user.is_authenticated %}{# The KI features (docs/ai.md): the user's own key, calls from the device. #}
|
||||||
<script type="module" src="/static/js/ai.js"></script>{% endif %}
|
<script type="module" src="/static/js/ai.js"></script>
|
||||||
|
<script type="module" src="/static/js/podcast-article.js"></script>{% endif %}
|
||||||
<script src="/static/js/podcasts-web.js"></script>
|
<script src="/static/js/podcasts-web.js"></script>
|
||||||
<script src="/static/js/home.js"></script>
|
<script src="/static/js/home.js"></script>
|
||||||
<script src="/static/js/feeds.js"></script>
|
<script src="/static/js/feeds.js"></script>
|
||||||
|
|
|
||||||
172
tools/tests/podcast-article.test.mjs
Normal file
172
tools/tests/podcast-article.test.mjs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
// Tests of static/js/podcast-article.js under Node: node --test tools/tests/
|
||||||
|
// No network, no LLM: fetch and DioraAI are stubbed.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as pa from '../../static/js/podcast-article.js';
|
||||||
|
|
||||||
|
test('transcriptToText groups 30 seconds and splits on speaker change', () => {
|
||||||
|
const segs = [
|
||||||
|
{start: 0, end: 5, speaker: '', text: 'Hallo zusammen.'},
|
||||||
|
{start: 20, end: 29, speaker: '', text: 'Heute geht es um X.'},
|
||||||
|
{start: 31, end: 40, speaker: '', text: 'Zweite Gruppe.'},
|
||||||
|
{start: 3725, end: 3730, speaker: '', text: 'Spät.'},
|
||||||
|
{start: 3726, end: 3730, speaker: '', text: ' '},
|
||||||
|
];
|
||||||
|
assert.equal(pa.transcriptToText(segs),
|
||||||
|
'[00:00] Hallo zusammen. Heute geht es um X.\n[00:31] Zweite Gruppe.\n[62:05] Spät.');
|
||||||
|
const sp = [
|
||||||
|
{start: 0, speaker: 'Anna', text: 'Eins.'},
|
||||||
|
{start: 4, speaker: 'Anna', text: 'Zwei.'},
|
||||||
|
{start: 8, speaker: 'Bernd', text: 'Drei.'},
|
||||||
|
];
|
||||||
|
assert.equal(pa.transcriptToText(sp), '[00:00] Anna: Eins. Zwei.\n[00:08] Bernd: Drei.');
|
||||||
|
assert.equal(pa.transcriptToText([]), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clock and mmss', () => {
|
||||||
|
assert.equal(pa.mmss(754), '12:34');
|
||||||
|
assert.equal(pa.clock(754), '12:34');
|
||||||
|
assert.equal(pa.clock(3725), '1:02:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('token estimate and the limit message', () => {
|
||||||
|
assert.equal(pa.estimateTokens('a'.repeat(400)), 100);
|
||||||
|
const big = {segments: Array.from({length: 20000}, (_, i) => ({start: i * 31, speaker: '', text: 'x'.repeat(30)}))};
|
||||||
|
assert.throws(() => pa.buildPrompt({title: 'T'}, big), e => e.diora && /zu lang/.test(e.message));
|
||||||
|
const ok = pa.buildPrompt({title: 'T', feed: {title: 'P'}, description: '<p>Notes & 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('<script>') && evil.includes('<strong><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 & gut.</p>\n<h2>Erste <b>Idee</b></h2>\n<p><a href="#t=754">▶ ab 12:34</a></p>\n<p>Text.</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- stubs ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function stubWorld({pending, claims = {}, hasKey = true, askImpl, saveStatus = 200}) {
|
||||||
|
const calls = [];
|
||||||
|
const notices = [];
|
||||||
|
globalThis.DioraNative = undefined;
|
||||||
|
globalThis.DioraAI = {
|
||||||
|
available: () => true,
|
||||||
|
hasKey: async () => hasKey,
|
||||||
|
withKey: fn => fn(),
|
||||||
|
modelName: () => 'test-model',
|
||||||
|
toast: t => notices.push(t),
|
||||||
|
ask: askImpl || (async opts => {
|
||||||
|
opts.onProgress && opts.onProgress(1200);
|
||||||
|
return {title: 'Titel', summary: 'Sum.', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
globalThis.fetch = async (url, init = {}) => {
|
||||||
|
const method = init.method || 'GET';
|
||||||
|
calls.push(method + ' ' + url + (init.body ? ' ' + init.body : ''));
|
||||||
|
const json = (status, data) => ({status, ok: status < 400, json: async () => data});
|
||||||
|
if (url === '/podcasts/transcription/pending/') return json(200, pending);
|
||||||
|
let m = /^\/podcasts\/episodes\/(\d+)\/article\/claim\/$/.exec(url);
|
||||||
|
if (m) return method === 'POST' ? json(claims[m[1]] || 200, {}) : json(200, {});
|
||||||
|
m = /^\/podcasts\/episodes\/(\d+)\/transcript\/$/.exec(url);
|
||||||
|
if (m) return m[1] === '3' ? json(404, {error: 'no transcript'}) : json(200, {segments: [{start: 0, speaker: '', text: 'Hallo'}]});
|
||||||
|
m = /^\/podcasts\/episodes\/(\d+)\/article\/$/.exec(url);
|
||||||
|
if (m) return json(saveStatus, {item_id: 900 + Number(m[1])});
|
||||||
|
m = /^\/podcasts\/episodes\/(\d+)\/$/.exec(url);
|
||||||
|
if (m) return json(200, {id: Number(m[1]), title: 'Folge ' + m[1], description: '', chapters: [], feed: {title: 'P'}});
|
||||||
|
return json(404, {});
|
||||||
|
};
|
||||||
|
return {calls, notices};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('write: loads, asks with the contract options, saves the HTML', async () => {
|
||||||
|
const w = stubWorld({pending: []});
|
||||||
|
let opts;
|
||||||
|
globalThis.DioraAI.ask = async o => { opts = o; o.onProgress(5); return {title: 'T', summary: 'S', kind: 'Monolog', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]}; };
|
||||||
|
const states = [];
|
||||||
|
const r = await pa.write(7, {onState: s => states.push(s.phase)});
|
||||||
|
assert.deepEqual(r, {item_id: 907, title: 'T'});
|
||||||
|
assert.equal(opts.effort, 'medium');
|
||||||
|
assert.equal(opts.maxTokens, 32000);
|
||||||
|
assert.equal(opts.system, pa.SYSTEM);
|
||||||
|
assert.match(opts.prompt, /Transkript:\n\[00:00\] Hallo$/);
|
||||||
|
assert.deepEqual(states, ['loading', 'writing', 'saving']);
|
||||||
|
const post = w.calls.find(c => c.startsWith('POST /podcasts/episodes/7/article/ '));
|
||||||
|
const body = JSON.parse(post.slice(post.indexOf('{')));
|
||||||
|
assert.equal(body.model, 'test-model');
|
||||||
|
assert.equal(body.kind, 'Monolog');
|
||||||
|
assert.match(body.body_html, /<a href="#t=61">▶ ab 01:01<\/a>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write: no transcript gives a German message', async () => {
|
||||||
|
stubWorld({pending: []});
|
||||||
|
await assert.rejects(pa.write(3), /noch kein Transkript/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickUp: claim, 409 skipped, failure releases the claim, notifier per success', async () => {
|
||||||
|
pa._reset();
|
||||||
|
const w = stubWorld({
|
||||||
|
pending: [{episode_id: 1, title: 'Eins'}, {episode_id: 2, title: 'Zwei'}, {episode_id: 4, title: 'Vier'}],
|
||||||
|
claims: {2: 409},
|
||||||
|
askImpl: async o => {
|
||||||
|
if (o.prompt.includes('Folge 4')) throw new Error('kaputt');
|
||||||
|
return {title: 'A', summary: 'S', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 0, body_markdown: 'B'}]};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await pa.pickUp();
|
||||||
|
assert.deepEqual(r, {done: 1, failed: 1, skipped: 1});
|
||||||
|
assert.ok(w.calls.some(c => /^POST \/podcasts\/episodes\/2\/article\/claim\/ \{"device":"d-/.test(c)));
|
||||||
|
assert.ok(!w.calls.some(c => c.startsWith('GET /podcasts/episodes/2/'))); // 409: not written
|
||||||
|
assert.ok(w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/4/article/claim/'))); // failed: released
|
||||||
|
assert.ok(!w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/1/article/claim/')));
|
||||||
|
assert.deepEqual(w.notices, ['Artikel fertig: A']);
|
||||||
|
// The failed one is not tried again in this visit.
|
||||||
|
const w2 = stubWorld({pending: [{episode_id: 4, title: 'Vier'}]});
|
||||||
|
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 1});
|
||||||
|
assert.ok(!w2.calls.some(c => c.includes('claim')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickUp: android bridge notifies instead of the toast', async () => {
|
||||||
|
pa._reset();
|
||||||
|
const w = stubWorld({pending: [{episode_id: 1, title: 'Eins'}]});
|
||||||
|
const got = [];
|
||||||
|
globalThis.DioraNative = {notify: (...a) => got.push(a)};
|
||||||
|
await pa.pickUp();
|
||||||
|
assert.deepEqual(got, [['Artikel fertig', 'Titel', 'episode:1']]);
|
||||||
|
assert.deepEqual(w.notices, []);
|
||||||
|
globalThis.DioraNative = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickUp: no key does nothing, not even a request; no second run at the same time', async () => {
|
||||||
|
pa._reset();
|
||||||
|
const w = stubWorld({pending: [{episode_id: 1, title: 'x'}], hasKey: false});
|
||||||
|
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 0});
|
||||||
|
assert.deepEqual(w.calls, []);
|
||||||
|
|
||||||
|
const w2 = stubWorld({pending: [{episode_id: 1, title: 'x'}]});
|
||||||
|
const a = pa.pickUp();
|
||||||
|
const b = await pa.pickUp();
|
||||||
|
assert.deepEqual(b, {busy: true});
|
||||||
|
assert.equal((await a).done, 1);
|
||||||
|
assert.equal(w2.calls.filter(c => c.startsWith('POST') && c.includes('/claim/ {"device":')).length, 1);
|
||||||
|
});
|
||||||
126
tui/README.md
Normal file
126
tui/README.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# diora-tui
|
||||||
|
|
||||||
|
EPUB-Reader im Terminal — die erste Stufe einer TUI-Version von diora.
|
||||||
|
|
||||||
|
Zeigt ein Buch als **eine durchgehende Ansicht** über alle Kapitel hinweg (wie diora's
|
||||||
|
Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe
|
||||||
|
`"blockIndex:innerFraction"`-Positionsangabe geführt wie der Web-Reader (`books/models.py`,
|
||||||
|
`EBookProgress`) — `blockIndex` zählt dabei exakt wie `static/js/app.js`s
|
||||||
|
`EPUB_BLOCK_SELECTOR` (`p, h1-h6, li, blockquote, dt, dd, figcaption` + textbasierte
|
||||||
|
`div`s ohne Element-Kinder), fortlaufend über das ganze Buch. Dadurch ist die Position
|
||||||
|
zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in
|
||||||
|
beide Richtungen synchronisieren (siehe unten).
|
||||||
|
|
||||||
|
Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben
|
||||||
|
("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via
|
||||||
|
`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene
|
||||||
|
Position zurücksetzt.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tui
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
diora-tui --library ~/Books # Standard: ~/Books
|
||||||
|
```
|
||||||
|
|
||||||
|
Tastenkürzel:
|
||||||
|
|
||||||
|
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen
|
||||||
|
- `n` — nächstes Kapitel, `p` — vorheriges Kapitel
|
||||||
|
- `f` — Fußnote in der Nähe der aktuellen Position anzeigen (Peek-Overlay, `Escape`/`f`/`q`
|
||||||
|
zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s
|
||||||
|
`_looksLikeFootnoteLink` (Link in/um `<sup>`, Klassenname mit note/footnote/fn, oder
|
||||||
|
`epub:type="noteref"`)
|
||||||
|
- `Enter` — markiertes Buch aus der Bibliothek öffnen
|
||||||
|
- `r` (in der Bibliothek) — gelesene Bücher ein-/ausblenden (siehe unten)
|
||||||
|
- `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek)
|
||||||
|
|
||||||
|
Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
|
||||||
|
volle Terminalbreite bei breiten Fenstern).
|
||||||
|
|
||||||
|
Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit,
|
||||||
|
sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht.
|
||||||
|
|
||||||
|
Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand
|
||||||
|
des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher
|
||||||
|
standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json`
|
||||||
|
gespiegelt) — `r` zeigt sie wieder an, für diese Sitzung.
|
||||||
|
|
||||||
|
## Bücher + Fortschritt vom Server holen (`diora-tui sync`)
|
||||||
|
|
||||||
|
Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe
|
||||||
|
unten), synct `diora-tui` **automatisch** — einmal leise im Hintergrund beim Start (neue
|
||||||
|
Bücher + Fortschritt werden nachgeladen, die Bibliotheksliste aktualisiert sich von
|
||||||
|
selbst) und einmal beim Beenden über `q` (kurzer Moment Verzögerung, bevor die App
|
||||||
|
tatsächlich schließt). Der explizite Befehl ist für's Ersteinrichten und für
|
||||||
|
Nicht-interaktive Nutzung (Cron o.ä.):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage
|
||||||
|
diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern
|
||||||
|
```
|
||||||
|
|
||||||
|
Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books/<id>/data/` herunter,
|
||||||
|
entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als
|
||||||
|
normale `.epub`-Dateien in `--library` ab (Dateiname `<id> - <Titel>.epub`) — von da an
|
||||||
|
funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden
|
||||||
|
beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot
|
||||||
|
wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch).
|
||||||
|
|
||||||
|
Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am `<id> - `-Dateinamens-
|
||||||
|
Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server
|
||||||
|
(`POST /books/<id>/progress/`, `force: false` — überschreibt also nie eine weiter
|
||||||
|
gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein
|
||||||
|
lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff.
|
||||||
|
|
||||||
|
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter
|
||||||
|
`~/.config/diora-tui/config.json`, `chmod 600`):
|
||||||
|
|
||||||
|
- **Server-URL** — z.B. `https://diora.creamfresh.xyz`.
|
||||||
|
- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token".
|
||||||
|
- **Verschlüsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig
|
||||||
|
verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege:
|
||||||
|
1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's
|
||||||
|
"Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000
|
||||||
|
Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`).
|
||||||
|
Funktioniert nur, wenn der Account diesen Weg im Browser mindestens einmal benutzt
|
||||||
|
hat — sonst wurde der Key ursprünglich zufällig im Browser erzeugt, und diese
|
||||||
|
Ableitung trifft ihn nicht. `sync` meldet einen falschen Key als
|
||||||
|
Entschlüsselungsfehler (harmlos, kein Datenverlust), nie als falsches Ergebnis.
|
||||||
|
2. **Base64-Key direkt einfügen** — für den Fall, dass Weg 1 nicht passt. Der
|
||||||
|
Export-Button dafür ist im Browser-UI aktuell nicht verdrahtet (`exportEncKey()` in
|
||||||
|
`app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, in
|
||||||
|
der Browser-Devtools-Konsole auf der diora-Seite (nicht `/accounts/settings/` — die
|
||||||
|
lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins
|
||||||
|
Clipboard, von dort ins `sync`-Prompt einfügen. Schlägt das mit einem
|
||||||
|
`NotAllowedError`/`InvalidAccessError` fehl, stattdessen direkt aus `localStorage`
|
||||||
|
lesen: `localStorage.getItem('diora_enc_key_' + window.USER_ID)`.
|
||||||
|
|
||||||
|
Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der
|
||||||
|
Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei.
|
||||||
|
|
||||||
|
## Performance bei großen Büchern
|
||||||
|
|
||||||
|
Sehr große Bücher (mehrstellige Tausend Absätze — z.B. Sammelbände) können beim
|
||||||
|
*ersten* Öffnen mehrere Sekunden bis niedrige zweistellige Sekunden brauchen (Parsing +
|
||||||
|
Zeilenumbruch-Berechnung für die durchgehende Ansicht). Ein zweites Öffnen desselben
|
||||||
|
Buchs bei gleicher Terminalbreite ist dank Cache (`~/.cache/diora-tui/layout_cache/`)
|
||||||
|
deutlich schneller. Das Rendering selbst skaliert nicht mit der Buchgröße — nur die
|
||||||
|
tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch
|
||||||
|
auf einmal.
|
||||||
|
|
||||||
|
## Grenzen der aktuellen Version
|
||||||
|
|
||||||
|
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen,
|
||||||
|
wird gemeldet), da der Reader sie ohnehin nicht darstellen kann.
|
||||||
|
- Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt.
|
||||||
|
- `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung
|
||||||
|
(zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung
|
||||||
|
zählt primär `blockIndex`, der exakt mit dem Web-Reader übereinstimmt; `innerFraction`
|
||||||
|
ist nur ein Tiebreaker innerhalb desselben Blocks.
|
||||||
1
tui/diora_tui/__init__.py
Normal file
1
tui/diora_tui/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients."""
|
||||||
4
tui/diora_tui/__main__.py
Normal file
4
tui/diora_tui/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from diora_tui.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
48
tui/diora_tui/api.py
Normal file
48
tui/diora_tui/api.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for
|
||||||
|
local clients"). Status: that API is provisional — expect endpoint/field
|
||||||
|
changes as the server-side implementation settles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_sync_snapshot(server_url: str, token: str) -> dict:
|
||||||
|
resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_book_data(server_url: str, token: str, book_id: int) -> dict:
|
||||||
|
resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=120)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def post_progress(
|
||||||
|
server_url: str, token: str, book_id: int, *, scroll_fraction: float, position_anchor: str, force: bool = False
|
||||||
|
) -> None:
|
||||||
|
body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force}
|
||||||
|
resp = requests.post(
|
||||||
|
f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30
|
||||||
|
)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
|
||||||
|
resp.raise_for_status()
|
||||||
231
tui/diora_tui/app.py
Normal file
231
tui/diora_tui/app.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress
|
||||||
|
against a diora server (see project README)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import getpass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from textual import work
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Footer, Header, Label, ListItem, ListView
|
||||||
|
|
||||||
|
from . import config as config_mod
|
||||||
|
from . import crypto, epub, library_meta, progress, remote
|
||||||
|
from .reader_screen import ReaderScreen
|
||||||
|
from .statusbar import StatusBar
|
||||||
|
|
||||||
|
DEFAULT_LIBRARY = Path.home() / "Books"
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryScreen(Screen):
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("enter", "open_selected", "Öffnen"),
|
||||||
|
Binding("r", "toggle_read_filter", "Gelesene ein-/ausblenden"),
|
||||||
|
Binding("q", "quit", "Beenden"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, library_dir: Path) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.library_dir = library_dir
|
||||||
|
self.paths: list[Path] = []
|
||||||
|
self.visible_paths: list[Path] = []
|
||||||
|
self.show_read = False
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield ListView(id="library-list")
|
||||||
|
yield StatusBar()
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.sub_title = str(self.library_dir)
|
||||||
|
self._refresh_list()
|
||||||
|
cfg = config_mod.load()
|
||||||
|
if cfg is not None:
|
||||||
|
self._auto_sync(cfg)
|
||||||
|
|
||||||
|
@work(thread=True)
|
||||||
|
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
|
||||||
|
try:
|
||||||
|
result = remote.sync_library(self.library_dir, cfg)
|
||||||
|
except remote.SyncError:
|
||||||
|
return # best-effort — e.g. offline; the local library still works
|
||||||
|
self.app.call_from_thread(self._on_auto_sync_done, result)
|
||||||
|
|
||||||
|
def _on_auto_sync_done(self, result: remote.SyncResult) -> None:
|
||||||
|
if result.downloaded or result.progress_pulled:
|
||||||
|
self._refresh_list()
|
||||||
|
if result.downloaded:
|
||||||
|
self.notify(f"{len(result.downloaded)} neue(s) Buch/Bücher synchronisiert.", timeout=3)
|
||||||
|
|
||||||
|
def _refresh_list(self) -> None:
|
||||||
|
self.paths = epub.scan_library(self.library_dir)
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for path in self.paths:
|
||||||
|
book_id = epub._book_id(path)
|
||||||
|
read = library_meta.is_read(book_id)
|
||||||
|
if read and not self.show_read:
|
||||||
|
continue
|
||||||
|
saved = progress.load(book_id)
|
||||||
|
last_opened = saved.updated_at if saved else 0.0
|
||||||
|
entries.append((last_opened, path, read))
|
||||||
|
entries.sort(key=lambda e: e[0], reverse=True)
|
||||||
|
self.visible_paths = [path for _, path, _ in entries]
|
||||||
|
|
||||||
|
list_view = self.query_one("#library-list", ListView)
|
||||||
|
list_view.clear()
|
||||||
|
if not self.paths:
|
||||||
|
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
|
||||||
|
return
|
||||||
|
if not entries:
|
||||||
|
list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen")))
|
||||||
|
return
|
||||||
|
for _, path, read in entries:
|
||||||
|
label = f"✓ {path.stem}" if read else path.stem
|
||||||
|
list_view.append(ListItem(Label(label)))
|
||||||
|
list_view.index = 0
|
||||||
|
list_view.focus()
|
||||||
|
|
||||||
|
def action_toggle_read_filter(self) -> None:
|
||||||
|
self.show_read = not self.show_read
|
||||||
|
self._refresh_list()
|
||||||
|
state = "eingeblendet" if self.show_read else "ausgeblendet"
|
||||||
|
self.notify(f"Gelesene Bücher {state}.", timeout=2)
|
||||||
|
|
||||||
|
def action_open_selected(self) -> None:
|
||||||
|
list_view = self.query_one("#library-list", ListView)
|
||||||
|
if not self.visible_paths or list_view.index is None:
|
||||||
|
return
|
||||||
|
self.app.push_screen(ReaderScreen(self.visible_paths[list_view.index]))
|
||||||
|
|
||||||
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||||
|
self.action_open_selected()
|
||||||
|
|
||||||
|
|
||||||
|
class DioraTuiApp(App):
|
||||||
|
CSS_PATH = "app.tcss"
|
||||||
|
TITLE = "diora-tui"
|
||||||
|
|
||||||
|
def __init__(self, library_dir: Path) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.library_dir = library_dir
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.push_screen(LibraryScreen(self.library_dir))
|
||||||
|
|
||||||
|
async def action_quit(self) -> None:
|
||||||
|
cfg = config_mod.load()
|
||||||
|
if cfg is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(remote.sync_library, self.library_dir, cfg)
|
||||||
|
except remote.SyncError:
|
||||||
|
pass # best-effort — don't block quitting on a sync failure
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(label: str, *, secret: bool = False) -> str:
|
||||||
|
value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip()
|
||||||
|
if not value:
|
||||||
|
raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_yes_no(question: str) -> bool:
|
||||||
|
return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja")
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_enc_key() -> str:
|
||||||
|
print()
|
||||||
|
print("Verschlüsselungs-Key — zwei Wege:")
|
||||||
|
print(" [1] Aus Benutzername + Passwort ableiten (wie diora's 'Unlock'-Formular im Browser)")
|
||||||
|
print(" [2] Base64-Key direkt einfügen (z.B. per Browser-Konsole exportiert)")
|
||||||
|
choice = input("Wahl [1/2, Standard 1]: ").strip() or "1"
|
||||||
|
if choice == "2":
|
||||||
|
return _prompt("Verschlüsselungs-Key (Base64)", secret=True)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Hinweis: das liefert nur dann den richtigen Key, wenn dieser Account je über "
|
||||||
|
"diora's Passwort-Ableitung ('Unlock with password' im Browser) entsperrt wurde. "
|
||||||
|
"War der Key dort nur automatisch zufällig erzeugt, kommt hier ein anderer "
|
||||||
|
"(falscher) Key raus — 'sync' meldet das dann als Entschlüsselungsfehler, ohne "
|
||||||
|
"etwas kaputtzumachen; in dem Fall stattdessen [2] mit dem exportierten Key nutzen."
|
||||||
|
)
|
||||||
|
username = _prompt("diora-Benutzername")
|
||||||
|
password = _prompt("diora-Passwort", secret=True)
|
||||||
|
return crypto.derive_key_b64(username, password)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig:
|
||||||
|
cfg = None if server_override else config_mod.load()
|
||||||
|
if cfg is not None:
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.")
|
||||||
|
server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/")
|
||||||
|
token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True)
|
||||||
|
enc_key = _prompt_enc_key()
|
||||||
|
cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=enc_key)
|
||||||
|
if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"):
|
||||||
|
config_mod.save(cfg)
|
||||||
|
print("Gespeichert.")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None:
|
||||||
|
cfg = _resolve_remote_config(server_override, save=save)
|
||||||
|
print(f"Verbinde zu {cfg.server_url} …")
|
||||||
|
try:
|
||||||
|
result = remote.sync_library(library_dir, cfg)
|
||||||
|
except remote.SyncError as e:
|
||||||
|
raise SystemExit(f"Fehler: {e}")
|
||||||
|
|
||||||
|
print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.")
|
||||||
|
for name in result.downloaded:
|
||||||
|
print(f" + {name}")
|
||||||
|
if result.unchanged:
|
||||||
|
print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.")
|
||||||
|
if result.skipped_pdf:
|
||||||
|
titles = ", ".join(result.skipped_pdf)
|
||||||
|
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}")
|
||||||
|
if result.failed:
|
||||||
|
titles = ", ".join(result.failed)
|
||||||
|
print(f"{len(result.failed)} Buch/Bücher konnten nicht entschlüsselt werden (falscher Key?): {titles}")
|
||||||
|
if result.progress_pulled:
|
||||||
|
print(f"Lesefortschritt für {result.progress_pulled} Buch/Bücher vom Server übernommen.")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--library",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_LIBRARY,
|
||||||
|
help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})",
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln")
|
||||||
|
sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf")
|
||||||
|
sync_parser.add_argument(
|
||||||
|
"--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
library_dir = args.library.expanduser()
|
||||||
|
|
||||||
|
if args.command == "sync":
|
||||||
|
run_sync(library_dir, args.server, save=args.save)
|
||||||
|
return
|
||||||
|
|
||||||
|
DioraTuiApp(library_dir).run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
49
tui/diora_tui/app.tcss
Normal file
49
tui/diora_tui/app.tcss
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#library-list {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusBar {
|
||||||
|
dock: bottom;
|
||||||
|
height: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
background: $panel;
|
||||||
|
color: $text-muted;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reader-scroll {
|
||||||
|
height: 1fr;
|
||||||
|
padding: 1 2;
|
||||||
|
align-horizontal: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reader-loading {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
FootnoteScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footnote-body {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 100;
|
||||||
|
height: auto;
|
||||||
|
max-height: 80%;
|
||||||
|
background: $panel;
|
||||||
|
border: round $primary;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote-note {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footnote-hint {
|
||||||
|
dock: bottom;
|
||||||
|
width: 80%;
|
||||||
|
max-width: 100;
|
||||||
|
background: $panel;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 2;
|
||||||
|
}
|
||||||
214
tui/diora_tui/blocks.py
Normal file
214
tui/diora_tui/blocks.py
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
"""Whole-book flat block extraction, matching static/js/app.js's EPUB_BLOCK_SELECTOR
|
||||||
|
and getPositionAnchor()/restoreFromAnchor() exactly in *structure* (blockIndex is
|
||||||
|
purely DOM order, no rendering needed) so position anchors are comparable across
|
||||||
|
the web reader and this TUI. See CLAUDE.md's `tui/` section for the full picture.
|
||||||
|
|
||||||
|
innerFraction on the web is defined via getBoundingClientRect() pixel geometry,
|
||||||
|
which has no TUI equivalent — a terminal has no font metrics/reflow the way a
|
||||||
|
browser does. We approximate it with row-based geometry from Rich's own text
|
||||||
|
wrapping (see reader.py), which is close enough because the server's "furthest
|
||||||
|
wins" comparison (_progress_is_further) only falls back to comparing fractions
|
||||||
|
when two anchors share the exact same block index — block index is the primary,
|
||||||
|
exactly-reproducible signal.
|
||||||
|
|
||||||
|
Footnote references are detected with the same heuristic as app.js's
|
||||||
|
_looksLikeFootnoteLink: wrapped in/wrapping a <sup>, a class name containing
|
||||||
|
note/footnote/fn, or an epub:type="noteref" attribute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import warnings
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ebooklib
|
||||||
|
from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning
|
||||||
|
from ebooklib import epub
|
||||||
|
|
||||||
|
# EPUB content documents are XHTML; treating them as HTML (matching app.js's
|
||||||
|
# `new DOMParser().parseFromString(html, 'text/html')`, static/js/app.js:2638)
|
||||||
|
# is intentional, not a mistake — silence bs4's XML-vs-HTML nudge for it.
|
||||||
|
# lxml's HTML parser is ~3x faster than bs4's built-in html.parser, which
|
||||||
|
# matters here: some real-world EPUBs run to tens of thousands of blocks.
|
||||||
|
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||||
|
_PARSER = "lxml"
|
||||||
|
|
||||||
|
# Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li,
|
||||||
|
# blockquote, dt, dd, figcaption, div:not(:has(*))'
|
||||||
|
_BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt", "dd", "figcaption"}
|
||||||
|
# Matches app.js's sanitizeEpubHtml() strip list (script/style are also stripped
|
||||||
|
# via regex before DOMParser even runs there; decomposing here is equivalent).
|
||||||
|
_STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"]
|
||||||
|
# Matches app.js's _looksLikeFootnoteLink's class-name check.
|
||||||
|
_FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b")
|
||||||
|
|
||||||
|
# Anchor format the server accepts (books/views.py:save_progress); anything else
|
||||||
|
# is silently discarded back to ''.
|
||||||
|
_ANCHOR_RE = re.compile(r"\d{1,7}:\d(\.\d{1,6})?")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FootnoteRef:
|
||||||
|
marker: str # visible text of the reference link, e.g. "1"
|
||||||
|
offset: int # character offset into the block's text where the marker sits
|
||||||
|
target_id: str # fragment id to resolve against block ids
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Block:
|
||||||
|
text: str
|
||||||
|
tag: str
|
||||||
|
chapter_index: int
|
||||||
|
id: str | None = None
|
||||||
|
footnotes: list[FootnoteRef] = field(default_factory=list)
|
||||||
|
# Every id found anywhere in this block's subtree, not just on the block
|
||||||
|
# element itself — footnote *targets* are frequently an <a id="..."> or
|
||||||
|
# similar nested a level or two inside the actual containing paragraph
|
||||||
|
# (see showFootnotePopover's `.closest('.footnote') || .parentElement`
|
||||||
|
# walk-up in app.js), so a target id resolves to "the block containing
|
||||||
|
# it" rather than requiring the id to sit on the block tag itself.
|
||||||
|
ids: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlatBook:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
author: str
|
||||||
|
path: Path
|
||||||
|
blocks: list[Block]
|
||||||
|
chapter_titles: list[str]
|
||||||
|
chapter_start_block: list[int] # blocks[chapter_start_block[i]] is chapter i's first block
|
||||||
|
footnote_targets: dict[str, int] # fragment id -> block index of its content
|
||||||
|
|
||||||
|
|
||||||
|
def _is_leaf_div(tag: Tag) -> bool:
|
||||||
|
return tag.name == "div" and tag.find(True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_footnote_link(el: Tag, href: str) -> bool:
|
||||||
|
if "#" not in href:
|
||||||
|
return False
|
||||||
|
cls = " ".join(el.get("class") or []).lower()
|
||||||
|
if _FOOTNOTE_CLASS_RE.search(cls):
|
||||||
|
return True
|
||||||
|
epub_type = (el.get("epub:type") or "").lower()
|
||||||
|
if "noteref" in epub_type:
|
||||||
|
return True
|
||||||
|
return el.find_parent("sup") is not None or el.find("sup") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
|
||||||
|
for child in el.children:
|
||||||
|
if isinstance(child, NavigableString):
|
||||||
|
out.append(str(child))
|
||||||
|
elif isinstance(child, Tag):
|
||||||
|
if child.name == "a":
|
||||||
|
href = child.get("href") or ""
|
||||||
|
if _looks_like_footnote_link(child, href):
|
||||||
|
marker = child.get_text(" ", strip=True)
|
||||||
|
offset = len("".join(out))
|
||||||
|
target_id = href.split("#", 1)[1] if "#" in href else ""
|
||||||
|
footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id))
|
||||||
|
out.append(marker)
|
||||||
|
continue
|
||||||
|
_walk_text(child, footnotes, out)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_blocks(html: bytes, chapter_index: int) -> list[Block]:
|
||||||
|
soup = BeautifulSoup(html, _PARSER)
|
||||||
|
for name in _STRIP_TAGS:
|
||||||
|
for el in soup.find_all(name):
|
||||||
|
el.decompose()
|
||||||
|
|
||||||
|
blocks: list[Block] = []
|
||||||
|
for el in soup.find_all(True):
|
||||||
|
if el.name in _BLOCK_TAGS or _is_leaf_div(el):
|
||||||
|
footnotes: list[FootnoteRef] = []
|
||||||
|
parts: list[str] = []
|
||||||
|
_walk_text(el, footnotes, parts)
|
||||||
|
text = re.sub(r"\s+", " ", "".join(parts)).strip()
|
||||||
|
ids = [tag_id for tag_id in (el.get("id"), *(d.get("id") for d in el.find_all(True))) if tag_id]
|
||||||
|
blocks.append(
|
||||||
|
Block(
|
||||||
|
text=text,
|
||||||
|
tag=el.name,
|
||||||
|
chapter_index=chapter_index,
|
||||||
|
id=el.get("id"),
|
||||||
|
footnotes=footnotes,
|
||||||
|
ids=ids,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def load_flat_book(path: Path) -> FlatBook:
|
||||||
|
from .epub import _book_id # reuse the same path-hash id scheme
|
||||||
|
|
||||||
|
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
|
||||||
|
|
||||||
|
title_meta = raw.get_metadata("DC", "title")
|
||||||
|
title = title_meta[0][0] if title_meta else path.stem
|
||||||
|
author_meta = raw.get_metadata("DC", "creator")
|
||||||
|
author = author_meta[0][0] if author_meta else "Unbekannt"
|
||||||
|
|
||||||
|
blocks: list[Block] = []
|
||||||
|
chapter_titles: list[str] = []
|
||||||
|
chapter_start_block: list[int] = []
|
||||||
|
|
||||||
|
# app.js's parseEpub() includes every spine itemref unconditionally — no
|
||||||
|
# `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote
|
||||||
|
# targets are commonly parked in a linear="no" document. Skipping it here
|
||||||
|
# would both break footnote-target resolution and shift blockIndex
|
||||||
|
# numbering out of sync with the web reader for every block after it.
|
||||||
|
for idref, _linear in raw.spine:
|
||||||
|
item = raw.get_item_with_id(idref)
|
||||||
|
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
|
||||||
|
continue
|
||||||
|
chapter_index = len(chapter_titles)
|
||||||
|
chapter_blocks = _extract_blocks(item.get_content(), chapter_index)
|
||||||
|
chapter_start_block.append(len(blocks))
|
||||||
|
first_text = next((b.text for b in chapter_blocks if b.text), None)
|
||||||
|
chapter_titles.append((first_text or item.get_name())[:60])
|
||||||
|
blocks.extend(chapter_blocks)
|
||||||
|
|
||||||
|
footnote_targets: dict[str, int] = {}
|
||||||
|
for idx, b in enumerate(blocks):
|
||||||
|
for tag_id in b.ids:
|
||||||
|
footnote_targets.setdefault(tag_id, idx)
|
||||||
|
|
||||||
|
return FlatBook(
|
||||||
|
id=_book_id(path),
|
||||||
|
title=title,
|
||||||
|
author=author,
|
||||||
|
path=path,
|
||||||
|
blocks=blocks,
|
||||||
|
chapter_titles=chapter_titles,
|
||||||
|
chapter_start_block=chapter_start_block,
|
||||||
|
footnote_targets=footnote_targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_anchor(block_index: int, inner_fraction: float) -> str:
|
||||||
|
inner_fraction = max(0.0, min(1.0, inner_fraction))
|
||||||
|
return f"{block_index}:{inner_fraction:.6f}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_anchor(anchor: str) -> tuple[int, float] | None:
|
||||||
|
if not anchor or not _ANCHOR_RE.fullmatch(anchor):
|
||||||
|
return None
|
||||||
|
block_str, _, frac_str = anchor.partition(":")
|
||||||
|
return int(block_str), float(frac_str)
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_is_further(new_anchor: str, old_anchor: str) -> bool:
|
||||||
|
"""Mirrors _progress_is_further / _cmpProgress (books/views.py, app.js)."""
|
||||||
|
new_parts = parse_anchor(new_anchor)
|
||||||
|
old_parts = parse_anchor(old_anchor)
|
||||||
|
if new_parts is None or old_parts is None:
|
||||||
|
return bool(new_anchor) and not old_anchor
|
||||||
|
nb, ni = new_parts
|
||||||
|
ob, oi = old_parts
|
||||||
|
return ni >= oi if nb == ob else nb >= ob
|
||||||
42
tui/diora_tui/book_view.py
Normal file
42
tui/diora_tui/book_view.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Line-API scroll view for the continuous reader — renders only the rows
|
||||||
|
actually visible on screen (via Widget.render_line), reading from the flat
|
||||||
|
Strip list diora_tui.layout.build_layout() precomputed once. This is what
|
||||||
|
keeps very large books responsive: nothing here scales with book size at
|
||||||
|
paint time, only with viewport height.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.geometry import Size
|
||||||
|
from textual.scroll_view import ScrollView
|
||||||
|
from textual.strip import Strip
|
||||||
|
|
||||||
|
from .layout import BookLayout
|
||||||
|
|
||||||
|
|
||||||
|
class ContinuousBookView(ScrollView):
|
||||||
|
# We always wrap text to fit exactly, so a horizontal scrollbar should
|
||||||
|
# never be needed — and "scroll" (not "auto") for the vertical one keeps
|
||||||
|
# its gutter reserved from the very first (still-empty) layout pass, so
|
||||||
|
# the width we wrap text at later never has to guess whether a scrollbar
|
||||||
|
# will appear and steal columns out from under already-wrapped lines.
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
ContinuousBookView {
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, book_layout: BookLayout) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.book_layout = book_layout
|
||||||
|
self.virtual_size = Size(book_layout.width, book_layout.total_rows)
|
||||||
|
|
||||||
|
def render_line(self, y: int) -> Strip:
|
||||||
|
_scroll_x, scroll_y = self.scroll_offset
|
||||||
|
row = scroll_y + y
|
||||||
|
strips = self.book_layout.row_strips
|
||||||
|
width = self.scrollable_content_region.width
|
||||||
|
if row < 0 or row >= len(strips):
|
||||||
|
return Strip.blank(width, self.rich_style)
|
||||||
|
return strips[row].crop_extend(0, width, self.rich_style)
|
||||||
52
tui/diora_tui/cache.py
Normal file
52
tui/diora_tui/cache.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""On-disk cache for the (FlatBook, BookLayout) pair a book open computes —
|
||||||
|
extraction + row-layout for very large books (tens of thousands of blocks)
|
||||||
|
can take several seconds each; reopening the same book at the same terminal
|
||||||
|
width should be near-instant instead of paying that cost again every time.
|
||||||
|
|
||||||
|
Not a correctness-critical cache: any miss (new book, different width, edited
|
||||||
|
file) just falls back to recomputing from scratch, so a stale/corrupt cache
|
||||||
|
entry is handled by overwriting it, never by crashing the reader.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platformdirs import user_cache_dir
|
||||||
|
|
||||||
|
from .blocks import FlatBook
|
||||||
|
from .layout import BookLayout
|
||||||
|
|
||||||
|
_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache"
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(path: Path, width: int) -> str:
|
||||||
|
stat = path.stat()
|
||||||
|
raw = f"{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}"
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path, width: int) -> tuple[FlatBook, BookLayout] | None:
|
||||||
|
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
|
||||||
|
if not cache_file.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with cache_file.open("rb") as f:
|
||||||
|
book, book_layout = pickle.load(f)
|
||||||
|
if not isinstance(book, FlatBook) or not isinstance(book_layout, BookLayout):
|
||||||
|
return None
|
||||||
|
return book, book_layout
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save(path: Path, width: int, book: FlatBook, book_layout: BookLayout) -> None:
|
||||||
|
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
|
||||||
|
try:
|
||||||
|
with cache_file.open("wb") as f:
|
||||||
|
pickle.dump((book, book_layout), f, protocol=pickle.HIGHEST_PROTOCOL)
|
||||||
|
except Exception:
|
||||||
|
pass # best-effort — a failed cache write shouldn't break reading
|
||||||
42
tui/diora_tui/config.py
Normal file
42
tui/diora_tui/config.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Local storage for diora server credentials used by `diora-tui sync`.
|
||||||
|
|
||||||
|
Mirrors the trust model of the web client's key storage (static/js/app.js,
|
||||||
|
getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept
|
||||||
|
in plaintext on disk, scoped to this machine/user (0600), with no additional
|
||||||
|
at-rest encryption — same exposure as the browser's localStorage already has.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import stat
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
|
_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora"))
|
||||||
|
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RemoteConfig:
|
||||||
|
server_url: str
|
||||||
|
api_token: str
|
||||||
|
enc_key_b64: str
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> RemoteConfig | None:
|
||||||
|
if not _CONFIG_FILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(_CONFIG_FILE.read_text())
|
||||||
|
return RemoteConfig(**data)
|
||||||
|
except (json.JSONDecodeError, OSError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save(cfg: RemoteConfig) -> None:
|
||||||
|
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2))
|
||||||
|
_CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||||
51
tui/diora_tui/crypto.py
Normal file
51
tui/diora_tui/crypto.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""AES-256-GCM helpers matching static/js/app.js's encryptBytes/decryptBytes
|
||||||
|
(Web Crypto AES-GCM, 12-byte IV hex-encoded, ciphertext base64, key handled
|
||||||
|
as raw bytes) — diora's books are end-to-end encrypted client-side, so the
|
||||||
|
server (and this module) only ever sees ciphertext plus the key the user
|
||||||
|
supplies out-of-band.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
_PBKDF2_ITERATIONS = 200_000
|
||||||
|
_KEY_LENGTH_BYTES = 32
|
||||||
|
|
||||||
|
|
||||||
|
class DecryptError(Exception):
|
||||||
|
"""Ciphertext could not be decrypted with the given key (wrong key, or corrupt data)."""
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(key_b64)
|
||||||
|
iv = bytes.fromhex(iv_hex)
|
||||||
|
ct = base64.b64decode(ciphertext_b64)
|
||||||
|
return AESGCM(key).decrypt(iv, ct, None)
|
||||||
|
except (InvalidTag, ValueError) as e:
|
||||||
|
raise DecryptError(str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
def derive_key_b64(username: str, password: str) -> str:
|
||||||
|
"""Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does:
|
||||||
|
PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only
|
||||||
|
yields the key that actually decrypts a user's books if that account's
|
||||||
|
key was ever set up via diora's "unlock with password" flow — a browser
|
||||||
|
that only ever auto-generated a random key (the default) has a key this
|
||||||
|
can't reproduce.
|
||||||
|
"""
|
||||||
|
salt = f"diora:{username}".encode("utf-8")
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=_KEY_LENGTH_BYTES,
|
||||||
|
salt=salt,
|
||||||
|
iterations=_PBKDF2_ITERATIONS,
|
||||||
|
)
|
||||||
|
raw = kdf.derive(password.encode("utf-8"))
|
||||||
|
return base64.b64encode(raw).decode()
|
||||||
19
tui/diora_tui/epub.py
Normal file
19
tui/diora_tui/epub.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Library scanning and the stable per-book id shared across progress.py,
|
||||||
|
blocks.py, and remote.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _book_id(path: Path) -> str:
|
||||||
|
# Identifies a book by its resolved path for now. Once the sync API exists,
|
||||||
|
# this should switch to whatever stable id diora assigns server-side.
|
||||||
|
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_library(library_dir: Path) -> list[Path]:
|
||||||
|
if not library_dir.exists():
|
||||||
|
return []
|
||||||
|
return sorted(library_dir.rglob("*.epub"))
|
||||||
90
tui/diora_tui/layout.py
Normal file
90
tui/diora_tui/layout.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Row-based layout for the continuous reader view — the TUI's terminal-grid
|
||||||
|
analogue of the browser's pixel-based getBoundingClientRect() geometry (see
|
||||||
|
blocks.py's module docstring for why blockIndex is exact but innerFraction is
|
||||||
|
only an approximation here).
|
||||||
|
|
||||||
|
Each block is wrapped independently at a known width, once, into a flat list
|
||||||
|
of pre-rendered Strip objects (one per terminal row) that book_view.py's
|
||||||
|
Line-API widget indexes directly in render_line() — this is what makes very
|
||||||
|
large books (tens of thousands of blocks) open in seconds rather than
|
||||||
|
minutes: Textual only ever renders the rows actually on screen, instead of
|
||||||
|
laying out the whole book up front the way a single giant Static would.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.strip import Strip
|
||||||
|
|
||||||
|
_SEPARATOR_ROWS = 1 # one blank row between blocks, matching the old "\n\n".join style
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BookLayout:
|
||||||
|
starts: list[int] # row where block i begins
|
||||||
|
heights: list[int] # rendered row count for block i
|
||||||
|
row_strips: list[Strip] # one Strip per absolute row, len == total_rows
|
||||||
|
total_rows: int
|
||||||
|
width: int
|
||||||
|
|
||||||
|
|
||||||
|
def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout:
|
||||||
|
width = max(1, width)
|
||||||
|
starts: list[int] = []
|
||||||
|
heights: list[int] = []
|
||||||
|
row_strips: list[Strip] = []
|
||||||
|
blank = Strip.blank(width)
|
||||||
|
row = 0
|
||||||
|
for text in block_texts:
|
||||||
|
starts.append(row)
|
||||||
|
wrapped_lines = list(Text(text).wrap(console, width)) if text else []
|
||||||
|
if not wrapped_lines:
|
||||||
|
wrapped_lines = [Text("")]
|
||||||
|
heights.append(len(wrapped_lines))
|
||||||
|
for line in wrapped_lines:
|
||||||
|
strip = Strip(line.render(console), None).adjust_cell_length(width)
|
||||||
|
row_strips.append(strip)
|
||||||
|
row_strips.append(blank)
|
||||||
|
row += len(wrapped_lines) + _SEPARATOR_ROWS
|
||||||
|
|
||||||
|
return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width)
|
||||||
|
|
||||||
|
|
||||||
|
def get_position_anchor(layout: BookLayout, scroll_y: int) -> tuple[int, float]:
|
||||||
|
"""Mirrors app.js's getPositionAnchor(): the last block whose top row is at
|
||||||
|
or above scroll_y, or the first block if none has scrolled that far yet."""
|
||||||
|
n = len(layout.heights)
|
||||||
|
if n == 0:
|
||||||
|
return 0, 0.0
|
||||||
|
|
||||||
|
best_index = 0
|
||||||
|
found = False
|
||||||
|
for i in range(n):
|
||||||
|
if layout.heights[i] < 1:
|
||||||
|
continue
|
||||||
|
if layout.starts[i] > scroll_y:
|
||||||
|
break
|
||||||
|
best_index = i
|
||||||
|
found = True
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
best_index = next((i for i in range(n) if layout.heights[i] >= 1), 0)
|
||||||
|
|
||||||
|
top = layout.starts[best_index]
|
||||||
|
height = max(1, layout.heights[best_index])
|
||||||
|
inner_fraction = max(0.0, min(1.0, (scroll_y - top) / height))
|
||||||
|
return best_index, inner_fraction
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_y_for_anchor(layout: BookLayout, block_index: int, inner_fraction: float) -> int:
|
||||||
|
"""Mirrors app.js's restoreFromAnchor()."""
|
||||||
|
n = len(layout.heights)
|
||||||
|
if n == 0:
|
||||||
|
return 0
|
||||||
|
idx = max(0, min(block_index, n - 1))
|
||||||
|
top = layout.starts[idx]
|
||||||
|
height = layout.heights[idx]
|
||||||
|
return top + round(inner_fraction * height)
|
||||||
43
tui/diora_tui/library_meta.py
Normal file
43
tui/diora_tui/library_meta.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Local per-book metadata pulled from the server — currently just "read"
|
||||||
|
status (EBook.is_read from the /api/sync/ snapshot). Kept separate from
|
||||||
|
progress.py (reading position): different concern, different cadence — this
|
||||||
|
is only ever set by `diora-tui sync`, never by the reader itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platformdirs import user_data_dir
|
||||||
|
|
||||||
|
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
|
||||||
|
_META_FILE = _DATA_DIR / "library.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_all() -> dict[str, dict]:
|
||||||
|
if not _META_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(_META_FILE.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_all(data: dict[str, dict]) -> None:
|
||||||
|
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_META_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def is_read(book_id: str) -> bool:
|
||||||
|
entry = _load_all().get(book_id)
|
||||||
|
return bool(entry and entry.get("is_read"))
|
||||||
|
|
||||||
|
|
||||||
|
def set_many(read_status: dict[str, bool]) -> None:
|
||||||
|
if not read_status:
|
||||||
|
return
|
||||||
|
data = _load_all()
|
||||||
|
for book_id, read in read_status.items():
|
||||||
|
data.setdefault(book_id, {})["is_read"] = read
|
||||||
|
_save_all(data)
|
||||||
68
tui/diora_tui/progress.py
Normal file
68
tui/diora_tui/progress.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Local reading-progress storage.
|
||||||
|
|
||||||
|
Positions are stored as the same "blockIndex:innerFraction" anchor string the
|
||||||
|
server uses (books/models.py, EBookProgress.save_progress) — see
|
||||||
|
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js
|
||||||
|
exactly, and anchor_is_further() for the identical "furthest wins" comparison
|
||||||
|
used server-side (_progress_is_further in books/views.py). A saved position
|
||||||
|
only ever advances (unless forced), so an older/offline run can't regress a
|
||||||
|
further-along read position — matching that same rule locally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platformdirs import user_data_dir
|
||||||
|
|
||||||
|
from .blocks import anchor_is_further
|
||||||
|
|
||||||
|
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
|
||||||
|
_PROGRESS_FILE = _DATA_DIR / "progress.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Position:
|
||||||
|
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000"
|
||||||
|
updated_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_all() -> dict[str, dict]:
|
||||||
|
if not _PROGRESS_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(_PROGRESS_FILE.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_all(data: dict[str, dict]) -> None:
|
||||||
|
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_PROGRESS_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def load(book_id: str) -> Position | None:
|
||||||
|
entry = _load_all().get(book_id)
|
||||||
|
if not isinstance(entry, dict) or not entry.get("anchor"):
|
||||||
|
return None
|
||||||
|
return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0))
|
||||||
|
|
||||||
|
|
||||||
|
def save(book_id: str, position: Position, *, force: bool = False) -> bool:
|
||||||
|
"""Returns True if the position was actually written (i.e. it was further
|
||||||
|
along, or forced) — callers that push to the server only need to do so
|
||||||
|
when this returns True."""
|
||||||
|
data = _load_all()
|
||||||
|
existing = data.get(book_id)
|
||||||
|
if existing is not None and not force:
|
||||||
|
old_anchor = existing.get("anchor", "")
|
||||||
|
if not anchor_is_further(position.anchor, old_anchor):
|
||||||
|
return False
|
||||||
|
if not position.updated_at:
|
||||||
|
position.updated_at = time.time()
|
||||||
|
data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at}
|
||||||
|
_save_all(data)
|
||||||
|
return True
|
||||||
286
tui/diora_tui/reader_screen.py
Normal file
286
tui/diora_tui/reader_screen.py
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
"""Continuous whole-book reader screen — mirrors the web reader's single
|
||||||
|
scrollable view (see blocks.py's module docstring) instead of the old
|
||||||
|
per-chapter pagination, so reading position is expressed in the same
|
||||||
|
"blockIndex:innerFraction" anchor space as the server and the web client.
|
||||||
|
|
||||||
|
Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the
|
||||||
|
whole book into one Static: for large books (tens of thousands of blocks) a
|
||||||
|
single giant renderable made Textual's layout/paint pass take upwards of a
|
||||||
|
minute, whereas the Line API only ever renders the rows on screen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bisect
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from textual import work
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Container, VerticalScroll
|
||||||
|
from textual.geometry import Size
|
||||||
|
from textual.screen import ModalScreen, Screen
|
||||||
|
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
|
||||||
|
|
||||||
|
from . import api, blocks, cache, config as config_mod, layout, progress
|
||||||
|
from .book_view import ContinuousBookView
|
||||||
|
from .statusbar import StatusBar
|
||||||
|
|
||||||
|
MAX_LINE_WIDTH = 120
|
||||||
|
AUTOSAVE_INTERVAL = 5.0
|
||||||
|
# ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
|
||||||
|
# so this stays constant — measuring the container's width and subtracting
|
||||||
|
# this fixed amount avoids a measure-after-constrain race against book_view's
|
||||||
|
# own (possibly already-constrained-from-a-previous-layout) width.
|
||||||
|
SCROLLBAR_GUTTER = 2
|
||||||
|
|
||||||
|
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
|
||||||
|
|
||||||
|
# Downloaded-via-sync files are named "<server-id> - <title>.epub" (see
|
||||||
|
# remote.py) — reused here to find the server book id for progress push.
|
||||||
|
_SERVER_ID_RE = re.compile(r"^(\d{4,7}) - ")
|
||||||
|
|
||||||
|
|
||||||
|
def _server_book_id(path: Path) -> int | None:
|
||||||
|
m = _SERVER_ID_RE.match(path.name)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
class FootnoteScreen(ModalScreen[None]):
|
||||||
|
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
|
||||||
|
|
||||||
|
def __init__(self, notes: list[str]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._notes = notes
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
with VerticalScroll(id="footnote-body"):
|
||||||
|
for note in self._notes:
|
||||||
|
yield Static(note, classes="footnote-note")
|
||||||
|
yield Label("Escape/f zum Schließen", id="footnote-hint")
|
||||||
|
|
||||||
|
def action_dismiss_self(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
|
||||||
|
class ReaderScreen(Screen):
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("j,down", "scroll_down_line", "Runter", show=False),
|
||||||
|
Binding("k,up", "scroll_up_line", "Hoch", show=False),
|
||||||
|
Binding("n", "next_chapter", "Nächstes Kapitel"),
|
||||||
|
Binding("p", "prev_chapter", "Vorheriges Kapitel"),
|
||||||
|
Binding("f", "peek_footnote", "Fußnote"),
|
||||||
|
Binding("q,escape", "back", "Zurück zur Bibliothek"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.path = path
|
||||||
|
self.book: blocks.FlatBook | None = None
|
||||||
|
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
|
||||||
|
self._server_id = _server_book_id(path)
|
||||||
|
self._remote_cfg = config_mod.load() if self._server_id is not None else None
|
||||||
|
self._last_pushed_anchor = ""
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
yield Header()
|
||||||
|
yield LoadingIndicator(id="reader-loading")
|
||||||
|
with Container(id="reader-scroll") as scroll_container:
|
||||||
|
self._scroll_container = scroll_container
|
||||||
|
# Stays visible (empty) from the start rather than toggling display
|
||||||
|
# on once loaded — a widget that's just been switched from hidden
|
||||||
|
# to visible hasn't been through a layout pass yet, so its `.size`
|
||||||
|
# is still (0, 0) and an immediate scroll_to() right after has
|
||||||
|
# nothing to clamp against and silently resets to 0.
|
||||||
|
self._book_view = ContinuousBookView(_EMPTY_LAYOUT)
|
||||||
|
yield self._book_view
|
||||||
|
yield StatusBar()
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
# Deferred rather than read synchronously here: right after mount the
|
||||||
|
# container hasn't been through a layout pass yet, so its size isn't
|
||||||
|
# reliable — same lesson as the scroll-restore race below.
|
||||||
|
self.call_after_refresh(self._start_loading)
|
||||||
|
|
||||||
|
def _start_loading(self) -> None:
|
||||||
|
if self._scroll_container.size.width == 0:
|
||||||
|
# Not sized yet after all — keep deferring instead of guessing a
|
||||||
|
# fixed number of refresh cycles (mirrors _restore_position below).
|
||||||
|
self.call_after_refresh(self._start_loading)
|
||||||
|
return
|
||||||
|
self._load_book(self._content_width())
|
||||||
|
|
||||||
|
@work(thread=True)
|
||||||
|
def _load_book(self, width: int) -> None:
|
||||||
|
cached = cache.load(self.path, width)
|
||||||
|
if cached is not None:
|
||||||
|
book, book_layout = cached
|
||||||
|
else:
|
||||||
|
book = blocks.load_flat_book(self.path)
|
||||||
|
console = Console(width=width)
|
||||||
|
book_layout = layout.build_layout([b.text for b in book.blocks], width, console)
|
||||||
|
cache.save(self.path, width, book, book_layout)
|
||||||
|
self.app.call_from_thread(self._on_book_loaded, book, book_layout, width)
|
||||||
|
|
||||||
|
def _on_book_loaded(self, book: blocks.FlatBook, book_layout: layout.BookLayout, width: int) -> None:
|
||||||
|
self.book = book
|
||||||
|
self.sub_title = book.title
|
||||||
|
self._apply_layout(book_layout, width)
|
||||||
|
self.query_one("#reader-loading", LoadingIndicator).display = False
|
||||||
|
self.call_after_refresh(self._restore_position)
|
||||||
|
self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
|
||||||
|
|
||||||
|
def _content_width(self) -> int:
|
||||||
|
# Measuring the *container* rather than book_view's own size avoids a
|
||||||
|
# measure-after-constrain race: once a layout has been applied,
|
||||||
|
# book_view's width is pinned to a previous value via styles.width
|
||||||
|
# (below), so re-measuring book_view itself on a later resize would
|
||||||
|
# just read that stale pinned width back instead of the new
|
||||||
|
# available space. The container's width is unaffected by that.
|
||||||
|
available = self._scroll_container.size.width - SCROLLBAR_GUTTER
|
||||||
|
return max(20, min(MAX_LINE_WIDTH, available))
|
||||||
|
|
||||||
|
def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
|
||||||
|
self.book_layout = book_layout
|
||||||
|
# Pin book_view's outer width to content-width-plus-scrollbar so its
|
||||||
|
# *inner* content region (what render_line actually draws into) ends
|
||||||
|
# up exactly `width` — matching what block texts were wrapped at.
|
||||||
|
self._book_view.styles.width = width + SCROLLBAR_GUTTER
|
||||||
|
self._book_view.book_layout = book_layout
|
||||||
|
self._book_view.virtual_size = Size(width, book_layout.total_rows)
|
||||||
|
self._book_view.refresh()
|
||||||
|
|
||||||
|
def _build_layout(self) -> None:
|
||||||
|
assert self.book is not None
|
||||||
|
width = self._content_width()
|
||||||
|
console = Console(width=width)
|
||||||
|
block_texts = [b.text for b in self.book.blocks]
|
||||||
|
new_layout = layout.build_layout(block_texts, width, console)
|
||||||
|
cache.save(self.path, width, self.book, new_layout)
|
||||||
|
self._apply_layout(new_layout, width)
|
||||||
|
|
||||||
|
def on_resize(self) -> None:
|
||||||
|
if self.book is None:
|
||||||
|
return
|
||||||
|
old_y = self._book_view.scroll_y
|
||||||
|
anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y)
|
||||||
|
self._build_layout()
|
||||||
|
new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac)
|
||||||
|
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
|
||||||
|
|
||||||
|
def _restore_position(self, attempt: int = 0) -> None:
|
||||||
|
if self.book is None:
|
||||||
|
return
|
||||||
|
saved = progress.load(self.book.id)
|
||||||
|
if saved is None:
|
||||||
|
return
|
||||||
|
parsed = blocks.parse_anchor(saved.anchor)
|
||||||
|
if parsed is None:
|
||||||
|
return
|
||||||
|
block_index, inner_fraction = parsed
|
||||||
|
y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction)
|
||||||
|
if y <= 0:
|
||||||
|
return
|
||||||
|
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||||
|
# A widget that's only just become part of the layout doesn't always
|
||||||
|
# honor an immediate scroll on the first attempt (its own size/scroll
|
||||||
|
# bounds can still be mid-update) — verify it actually landed and
|
||||||
|
# retry a bounded number of times rather than guessing a fixed delay.
|
||||||
|
if attempt < 20 and abs(self._book_view.scroll_y - y) > 1:
|
||||||
|
self.call_after_refresh(lambda: self._restore_position(attempt + 1))
|
||||||
|
|
||||||
|
def _current_anchor_str(self) -> str:
|
||||||
|
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||||
|
return blocks.format_anchor(block_index, inner_fraction)
|
||||||
|
|
||||||
|
def _autosave(self) -> None:
|
||||||
|
self._save_progress()
|
||||||
|
|
||||||
|
def _save_progress(self) -> None:
|
||||||
|
if self.book is None or not self.book_layout.heights:
|
||||||
|
return
|
||||||
|
anchor = self._current_anchor_str()
|
||||||
|
advanced = progress.save(self.book.id, progress.Position(anchor=anchor))
|
||||||
|
if advanced and self._remote_cfg is not None and self._server_id is not None:
|
||||||
|
self._push_remote_progress(anchor)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="progress-push")
|
||||||
|
def _push_remote_progress(self, anchor: str) -> None:
|
||||||
|
if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
api.post_progress(
|
||||||
|
self._remote_cfg.server_url,
|
||||||
|
self._remote_cfg.api_token,
|
||||||
|
self._server_id,
|
||||||
|
scroll_fraction=0.0,
|
||||||
|
position_anchor=anchor,
|
||||||
|
force=False,
|
||||||
|
)
|
||||||
|
self._last_pushed_anchor = anchor
|
||||||
|
except Exception:
|
||||||
|
pass # best-effort — local progress is already saved regardless
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
self._save_progress()
|
||||||
|
|
||||||
|
def action_scroll_down_line(self) -> None:
|
||||||
|
self._book_view.scroll_relative(y=1, animate=False)
|
||||||
|
|
||||||
|
def action_scroll_up_line(self) -> None:
|
||||||
|
self._book_view.scroll_relative(y=-1, animate=False)
|
||||||
|
|
||||||
|
def _current_chapter_index(self) -> int:
|
||||||
|
if self.book is None:
|
||||||
|
return 0
|
||||||
|
block_index, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||||
|
return bisect.bisect_right(self.book.chapter_start_block, block_index) - 1
|
||||||
|
|
||||||
|
def _jump_to_block(self, block_index: int) -> None:
|
||||||
|
y = layout.scroll_y_for_anchor(self.book_layout, block_index, 0.0)
|
||||||
|
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||||
|
|
||||||
|
def action_next_chapter(self) -> None:
|
||||||
|
if self.book is None:
|
||||||
|
return
|
||||||
|
chapter = self._current_chapter_index()
|
||||||
|
if chapter + 1 < len(self.book.chapter_start_block):
|
||||||
|
self._jump_to_block(self.book.chapter_start_block[chapter + 1])
|
||||||
|
self._save_progress()
|
||||||
|
|
||||||
|
def action_prev_chapter(self) -> None:
|
||||||
|
if self.book is None:
|
||||||
|
return
|
||||||
|
chapter = self._current_chapter_index()
|
||||||
|
if chapter > 0:
|
||||||
|
self._jump_to_block(self.book.chapter_start_block[chapter - 1])
|
||||||
|
self._save_progress()
|
||||||
|
|
||||||
|
def action_peek_footnote(self) -> None:
|
||||||
|
if self.book is None or not self.book_layout.heights:
|
||||||
|
return
|
||||||
|
current_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||||
|
|
||||||
|
forward = [(i, b) for i, b in enumerate(self.book.blocks) if i >= current_block and b.footnotes]
|
||||||
|
backward = [(i, b) for i, b in enumerate(self.book.blocks) if i < current_block and b.footnotes]
|
||||||
|
candidate = forward[0] if forward else (backward[-1] if backward else None)
|
||||||
|
if candidate is None:
|
||||||
|
self.notify("Keine Fußnote in diesem Buch gefunden.", timeout=3)
|
||||||
|
return
|
||||||
|
|
||||||
|
_, block = candidate
|
||||||
|
notes: list[str] = []
|
||||||
|
for ref in block.footnotes:
|
||||||
|
target_idx = self.book.footnote_targets.get(ref.target_id)
|
||||||
|
if target_idx is None:
|
||||||
|
continue
|
||||||
|
notes.append(self.book.blocks[target_idx].text)
|
||||||
|
if not notes:
|
||||||
|
self.notify("Fußnote konnte nicht aufgelöst werden.", timeout=3)
|
||||||
|
return
|
||||||
|
self.app.push_screen(FootnoteScreen(notes))
|
||||||
|
|
||||||
|
def action_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
110
tui/diora_tui/remote.py
Normal file
110
tui/diora_tui/remote.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Fetch + decrypt books from a diora server into the local TUI library, and
|
||||||
|
pull reading progress for them into the local progress store.
|
||||||
|
|
||||||
|
Progress is pull-only here (server -> local); the reader pushes local ->
|
||||||
|
server itself while a book is open (see reader_screen.py). Both directions
|
||||||
|
use the same "blockIndex:innerFraction" anchor format as the web reader
|
||||||
|
(diora_tui/blocks.py) and the same furthest-wins merge rule
|
||||||
|
(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can
|
||||||
|
only ever advance local progress, never regress it, unless forced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from . import api, crypto, library_meta
|
||||||
|
from .config import RemoteConfig
|
||||||
|
from .epub import _book_id
|
||||||
|
from .progress import Position, save as save_progress
|
||||||
|
|
||||||
|
|
||||||
|
class SyncError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncResult:
|
||||||
|
downloaded: list[str] = field(default_factory=list)
|
||||||
|
unchanged: int = 0
|
||||||
|
skipped_pdf: list[str] = field(default_factory=list)
|
||||||
|
failed: list[str] = field(default_factory=list)
|
||||||
|
progress_pulled: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_filename(name: str) -> str:
|
||||||
|
name = re.sub(r"[^\w\s.-]", "_", name).strip()
|
||||||
|
return (name or "buch")[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_meta(raw: bytes) -> dict:
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
|
||||||
|
try:
|
||||||
|
snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token)
|
||||||
|
except api.ApiError as e:
|
||||||
|
raise SyncError(str(e)) from e
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
|
||||||
|
|
||||||
|
library_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
result = SyncResult()
|
||||||
|
local_path_by_server_id: dict[int, Path] = {}
|
||||||
|
|
||||||
|
for book in snapshot.get("books", []):
|
||||||
|
book_id = book["id"]
|
||||||
|
try:
|
||||||
|
meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"]))
|
||||||
|
except crypto.DecryptError:
|
||||||
|
result.failed.append(f"#{book_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if meta.get("type") == "pdf":
|
||||||
|
result.skipped_pdf.append(meta.get("title") or f"#{book_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None)
|
||||||
|
if existing is not None:
|
||||||
|
result.unchanged += 1
|
||||||
|
local_path_by_server_id[book_id] = existing
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id)
|
||||||
|
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
|
||||||
|
except (api.ApiError, crypto.DecryptError, requests.RequestException):
|
||||||
|
result.failed.append(meta.get("title") or f"#{book_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = meta.get("title") or f"book-{book_id}"
|
||||||
|
dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub"
|
||||||
|
dest.write_bytes(raw)
|
||||||
|
result.downloaded.append(dest.name)
|
||||||
|
local_path_by_server_id[book_id] = dest
|
||||||
|
|
||||||
|
for entry in snapshot.get("book_progress", []):
|
||||||
|
anchor = entry.get("position_anchor") or ""
|
||||||
|
if not anchor:
|
||||||
|
continue # PDF-only scroll_fraction progress — this TUI is EPUB-only
|
||||||
|
dest = local_path_by_server_id.get(entry.get("book_id"))
|
||||||
|
if dest is None:
|
||||||
|
continue
|
||||||
|
local_id = _book_id(dest)
|
||||||
|
if save_progress(local_id, Position(anchor=anchor)):
|
||||||
|
result.progress_pulled += 1
|
||||||
|
|
||||||
|
read_status = {
|
||||||
|
_book_id(path): bool(book.get("is_read"))
|
||||||
|
for book in snapshot.get("books", [])
|
||||||
|
if (path := local_path_by_server_id.get(book["id"])) is not None
|
||||||
|
}
|
||||||
|
library_meta.set_many(read_status)
|
||||||
|
|
||||||
|
return result
|
||||||
44
tui/diora_tui/statusbar.py
Normal file
44
tui/diora_tui/statusbar.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Bottom status bar: battery level + current time, refreshed every second.
|
||||||
|
Battery is read via psutil (cross-platform); on a desktop machine with no
|
||||||
|
battery, psutil.sensors_battery() returns None and the bar just shows time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
except ImportError:
|
||||||
|
psutil = None # battery display degrades gracefully to time-only
|
||||||
|
|
||||||
|
|
||||||
|
class StatusBar(Static):
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._refresh()
|
||||||
|
self.set_interval(1.0, self._refresh)
|
||||||
|
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
self.update(self._render_text())
|
||||||
|
|
||||||
|
def _render_text(self) -> str:
|
||||||
|
parts = []
|
||||||
|
battery = self._battery_text()
|
||||||
|
if battery:
|
||||||
|
parts.append(battery)
|
||||||
|
parts.append(datetime.now().strftime("%H:%M:%S"))
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
def _battery_text(self) -> str | None:
|
||||||
|
if psutil is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
battery = psutil.sensors_battery()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if battery is None:
|
||||||
|
return None
|
||||||
|
state = "lädt" if battery.power_plugged else "Akku"
|
||||||
|
return f"{state} {round(battery.percent)}%"
|
||||||
25
tui/pyproject.toml
Normal file
25
tui/pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[project]
|
||||||
|
name = "diora-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Terminal-EPUB-Reader für diora mit Server-Sync für Bücher und Lesefortschritt."
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"textual>=0.60",
|
||||||
|
"ebooklib>=0.18",
|
||||||
|
"beautifulsoup4>=4.12",
|
||||||
|
"lxml>=5.0",
|
||||||
|
"platformdirs>=4.0",
|
||||||
|
"requests>=2.31",
|
||||||
|
"cryptography>=42.0",
|
||||||
|
"psutil>=5.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
diora-tui = "diora_tui.app:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["diora_tui"]
|
||||||
Loading…
Add table
Reference in a new issue